feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经 进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。 按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面 手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。 shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有 这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空, 把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在 DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。 增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而 last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。 宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at, 否则下次跳过这段区间,漏的单永远补不回来。 日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间: 抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是 "2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时, 订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天, 当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation—— 那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。 金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder 在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。 迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec), v1-v4 逐字未动,CheckSchema 覆盖新表新列。 会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do() 一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。 测试全部用 httptest 假服务端,不打真实站点。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// OnlineThreshold 是判定客户端"在线"的时间窗:
|
||||
@@ -78,3 +81,102 @@ func isTempBuild(exe string) bool {
|
||||
// go run 的产物路径里通常带 go-build 字样
|
||||
return strings.Contains(filepath.ToSlash(exe), "/go-build")
|
||||
}
|
||||
|
||||
// ---------- 顺运宝配置(config.yaml) ----------
|
||||
|
||||
// SybConfig 是 config.yaml 里 `syb:` 一节,见 admin/config.example.yaml
|
||||
// 和 docs/admin/08-顺运宝接口.md §8。
|
||||
type SybConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PageSize int `yaml:"page_size"`
|
||||
MaxMatches int `yaml:"max_matches"`
|
||||
SyncFrom string `yaml:"sync_from"`
|
||||
}
|
||||
|
||||
// String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法
|
||||
// 不小心把明文密码带进日志——日志可能被贴进工单排查问题。
|
||||
//
|
||||
// `[必须]` 这是 admin/AGENTS.md「日志、页面、导出里不得出现 token、密码、Cookie」
|
||||
// 的从源头防护,不依赖每个调用方都记得手动打码。
|
||||
func (c SybConfig) String() string {
|
||||
pw := "(空)"
|
||||
if c.Password != "" {
|
||||
pw = "****"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s}",
|
||||
c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom)
|
||||
}
|
||||
|
||||
// Config 是 config.yaml 的顶层结构。目前只有顺运宝一节,
|
||||
// 后续如果要给别的模块加配置,在这里加新的字段即可。
|
||||
type Config struct {
|
||||
Syb SybConfig `yaml:"syb"`
|
||||
}
|
||||
|
||||
// configFileName 是 config.yaml 相对 exe(或 go run 时相对工作目录)的文件名。
|
||||
// 和 admin/config.example.yaml 同一目录,方便操作员按提示复制。
|
||||
const configFileName = "config.yaml"
|
||||
|
||||
// ConfigPath 返回 config.yaml 应该在的路径(不保证文件存在)。
|
||||
//
|
||||
// 路径规则和 DataDir 一致(exe 旁边;go run 时是当前工作目录),
|
||||
// 因为 config.example.yaml 的说明就是"复制到 admin/ 目录",
|
||||
// 和 data/ 同级。
|
||||
func ConfigPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if isTempBuild(exe) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = wd
|
||||
}
|
||||
return filepath.Join(dir, configFileName), nil
|
||||
}
|
||||
|
||||
// Load 读取并解析 config.yaml。
|
||||
//
|
||||
// `[必须]` 文件不存在时给出「复制 config.example.yaml」的明确提示,
|
||||
// 不是一句冷冰冰的「读取失败」——初级程序员第一次跑起来大概率会踩到这个。
|
||||
func Load() (*Config, error) {
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法确定 config.yaml 应该在的位置: %w", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, missingConfigError(path)
|
||||
}
|
||||
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return parseConfig(raw, path)
|
||||
}
|
||||
|
||||
// missingConfigError 是缺文件时的提示,抽成函数是为了配置测试
|
||||
// (config_test.go)能直接复用同一句提示,不用真的绕开 os.Executable。
|
||||
func missingConfigError(path string) error {
|
||||
return fmt.Errorf(
|
||||
"没有找到配置文件 %s。\n"+
|
||||
"请复制 config.example.yaml 为 config.yaml,并填入顺运宝账号密码:\n"+
|
||||
" Windows: copy admin\\config.example.yaml admin\\config.yaml\n"+
|
||||
" Linux: cp admin/config.example.yaml admin/config.yaml",
|
||||
path)
|
||||
}
|
||||
|
||||
// parseConfig 解析 YAML 内容,抽成函数同样是为了让测试不依赖 os.Executable。
|
||||
func parseConfig(raw []byte, path string) (*Config, error) {
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadFrom 是测试专用的小工具:把一段 YAML 文本写到临时目录里的
|
||||
// config.yaml,绕开 ConfigPath()(它依赖 os.Executable,测试环境里
|
||||
// 不可控),直接测 Load 里"读文件 + 解析"这段逻辑。
|
||||
func loadFrom(t *testing.T, yamlText string) (*Config, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, configFileName)
|
||||
if yamlText != "" {
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
|
||||
t.Fatalf("写测试配置文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, missingConfigError(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return parseConfig(raw, path)
|
||||
}
|
||||
|
||||
func TestLoad_缺文件时提示复制模板(t *testing.T) {
|
||||
// 注意:不要在错误信息断言里用测试名本身会出现的中文片段做 Contains 判断——
|
||||
// t.TempDir() 生成的目录名会把测试函数名拼进路径,路径又被拼进错误信息,
|
||||
// 断言字符串一旦和测试名撞了就会产生误报,这里刻意避开这个坑。
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, configFileName)
|
||||
err := func() error {
|
||||
_, e := os.ReadFile(missingPath)
|
||||
if os.IsNotExist(e) {
|
||||
return missingConfigError(missingPath)
|
||||
}
|
||||
return e
|
||||
}()
|
||||
if err == nil {
|
||||
t.Fatal("缺文件时应该返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "config.example.yaml") {
|
||||
t.Errorf("错误信息应该提示复制 config.example.yaml,实际: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "复制") {
|
||||
t.Errorf("错误信息应该给出「复制模板文件」这个具体操作,不是一句笼统的报错,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_纯数字密码加引号能读出字符串(t *testing.T) {
|
||||
cfg, err := loadFrom(t, `
|
||||
syb:
|
||||
base_url: https://www.shunyunbaoerp.com
|
||||
username: tester
|
||||
password: "0012345"
|
||||
page_size: 20
|
||||
max_matches: 500
|
||||
sync_from: "2026-07-01"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if cfg.Syb.Password != "0012345" {
|
||||
t.Errorf("密码应该原样保留字符串(含前导 0),实际 %q", cfg.Syb.Password)
|
||||
}
|
||||
if cfg.Syb.Username != "tester" {
|
||||
t.Errorf("username 解析错误: %q", cfg.Syb.Username)
|
||||
}
|
||||
if cfg.Syb.PageSize != 20 || cfg.Syb.MaxMatches != 500 {
|
||||
t.Errorf("数字字段解析错误: page_size=%d max_matches=%d", cfg.Syb.PageSize, cfg.Syb.MaxMatches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_纯数字密码不加引号仍能读出字符串(t *testing.T) {
|
||||
// go-yaml 对 "password: 0012345"(不加引号)这种写法的行为要在这里锁定:
|
||||
// 如果被解析成整数再转字符串,前导 0 会丢,密码就错了。
|
||||
// 这里不强制要求这种写法本身合法,只要求:如果它没有报错,
|
||||
// 结果不能悄悄丢字符。
|
||||
cfg, err := loadFrom(t, `
|
||||
syb:
|
||||
base_url: https://www.shunyunbaoerp.com
|
||||
username: tester
|
||||
password: 12345
|
||||
`)
|
||||
if err != nil {
|
||||
// 反序列化到 string 字段直接报错也是可以接受的行为
|
||||
// (文档 08 §8 描述的正是这种失败模式),不是本测试要断言的重点。
|
||||
t.Skipf("不加引号的纯数字密码解析失败(符合文档描述的已知坑): %v", err)
|
||||
}
|
||||
if cfg.Syb.Password != "12345" {
|
||||
t.Errorf("密码字符串不应该丢字符,实际 %q", cfg.Syb.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybConfig_String不泄露密码(t *testing.T) {
|
||||
cfg := SybConfig{
|
||||
BaseURL: "https://www.shunyunbaoerp.com",
|
||||
Username: "tester",
|
||||
Password: "super-secret-password",
|
||||
}
|
||||
s := cfg.String()
|
||||
if strings.Contains(s, "super-secret-password") {
|
||||
t.Fatalf("SybConfig.String() 不能包含明文密码,实际: %s", s)
|
||||
}
|
||||
if strings.Contains(s, "tester") == false {
|
||||
t.Errorf("String() 应该保留用户名等非敏感信息方便排查: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_配置文件损坏时报明确错误(t *testing.T) {
|
||||
_, err := loadFrom(t, "syb: [this is not a valid mapping")
|
||||
if err == nil {
|
||||
t.Fatal("格式错误的 YAML 应该返回错误")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user