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:
chengma
2026-08-09 11:49:13 +08:00
co-authored by Claude Opus 5
parent 7fb137f685
commit 5e426cacf6
22 changed files with 3659 additions and 49 deletions
+38 -3
View File
@@ -264,7 +264,7 @@ var migrations = [][]string{
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
// 和代码对不上的库静默启动。
const schemaVersion = 4
const schemaVersion = 5
// migrationV4 给 PDD 商品增加店铺名。
//
@@ -275,6 +275,31 @@ var migrationV4 = []string{
`ALTER TABLE pdd_products ADD COLUMN shop_name TEXT;`,
}
// migrationV5 是工单 #46(顺运宝货运单同步)需要的三样东西:
// 会话缓存表、同步进度表、给 syb_orders 补一列规格原文。
//
// `[必须]` 三条都是新增(新表或 ADD COLUMN),不改动任何已发布的列/表,
// 见 admin/AGENTS.md「迁移只追加」。
var migrationV5 = []string{
// 会话缓存。只存 Cookie 就够——08 §3.1 已确认 JWT 从不参与后续请求认证,
// 缓存 token 没有意义,缓存 Cookie 才能免登录。
`CREATE TABLE syb_session (
username TEXT PRIMARY KEY,
cookies TEXT NOT NULL, -- JSON 数组
expires_at TEXT NOT NULL, -- min(JWT exp, 24h)
updated_at TEXT NOT NULL
);`,
// 上次同步到哪。只允许一行(CHECK id = 1),increment 同步靠它算日期范围。
`CREATE TABLE syb_sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_synced_at TEXT,
updated_at TEXT NOT NULL
);`,
// 规格原文。匹配要用它,放列里才能查、才能在界面显示,
// 对应 shopee_skus.spec_raw,两边同一个概念。
`ALTER TABLE syb_orders ADD COLUMN product_spec TEXT;`,
}
// Migrate 把数据库升到最新版本。
// 已经是最新的就什么都不做,可以重复调用。
func Migrate(db *sql.DB) error {
@@ -332,6 +357,13 @@ func Migrate(db *sql.DB) error {
}
}
// v5 同理,是普通的建表 + 追加列迁移,排在 v4 后面执行。
if reached < 5 {
if err := runSQLMigration(db, 5, migrationV5); err != nil {
return err
}
}
return nil
}
@@ -790,13 +822,16 @@ var requiredTables = []string{
"shopee_products", "shopee_skus", "pdd_products",
"syb_orders", "sku_mappings", "tasks", "clients",
"idempotency_keys", "task_claims",
"syb_session", "syb_sync_state",
}
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
// shop_name 是 v4 新增列;缺少它时查询 PDD 页面会直接失败,因此启动时
// 就应给出明确错误,而不是等操作员点到页面才暴露。
// shop_name 是 v4 新增列;product_spec 是 v5 新增列——两者缺失时
// 查询对应页面会直接失败,因此启动时就应给出明确错误,
// 而不是等操作员点到页面才暴露。
var requiredColumns = map[string][]string{
"pdd_products": {"shop_name"},
"syb_orders": {"product_spec"},
}
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
+148
View File
@@ -543,6 +543,149 @@ func TestMigrate_v2新结构库只更新版本号不打误导性日志(t *testin
}
}
// ── v5:顺运宝会话表、同步状态表、product_spec 列 ──────
// TestMigrate_不同起点最终schema一致(上面已有)用的是全量 dumpSchema 对比,
// v5 的新表/新列不需要改那个测试就已经被覆盖——这里再加行为断言,
// 专门锁住 v5 引入的三样东西,即使将来有人改坏了三起点收敛测试本身,
// 这几个测试仍然能单独发现问题。
func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T) {
for _, c := range []struct {
name string
db *sql.DB
}{
{"全新库", newFreshDB(t)},
{"v2 老结构库", newV2DB(t)},
{"v2 新结构库", newV2NewStructureDB(t)},
} {
if c.name != "全新库" {
if err := Migrate(c.db); err != nil {
t.Fatalf("%s 迁移失败: %v", c.name, err)
}
}
var version int
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
}
if version != schemaVersion {
t.Fatalf("%s user_version = %d,期望 %d", c.name, version, schemaVersion)
}
tables := existingTableSet(t, c.db)
for _, table := range []string{"syb_session", "syb_sync_state"} {
if !tables[table] {
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
}
}
cols, err := tableColumnSet(c.db, "syb_orders")
if err != nil {
t.Fatalf("%s 读取 syb_orders 列失败: %v", c.name, err)
}
if !cols["product_spec"] {
t.Errorf("%s:syb_orders 迁移后应该有 product_spec 列", c.name)
}
if err := CheckSchema(c.db); err != nil {
t.Errorf("%s:迁移后应该通过自检: %v", c.name, err)
}
}
}
// existingTableSet 返回库里当前存在的表名集合。
func existingTableSet(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
if err != nil {
t.Fatalf("读取表清单失败: %v", err)
}
defer rows.Close()
set := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("读取表清单失败: %v", err)
}
set[name] = true
}
return set
}
// TestMigrate_v5会话表主键只允许一个用户名一行 验证 syb_session 的主键约束:
// 同一个用户名重复写入应该走 upsert(由 repository 层保证),底层主键必须
// 拒绝真正的重复插入,防止同一账号在表里出现两行、读的时候不知道信哪行。
func TestMigrate_v5会话表主键约束(t *testing.T) {
db := newFreshDB(t)
const now = "2026-08-09T00:00:00Z"
insert := func() error {
_, err := db.Exec(`
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
VALUES (?, ?, ?, ?)`, "tester", `[]`, now, now)
return err
}
if err := insert(); err != nil {
t.Fatalf("插入第一条会话失败: %v", err)
}
if err := insert(); err == nil {
t.Fatal("同一 username 重复插入应该被主键约束拒绝")
}
}
// TestMigrate_v5同步状态表只允许一行 验证 CHECK (id = 1) 确实生效——
// 这张表按设计只应该有一行(全局的"上次同步到哪"),如果这条 CHECK
// 被误删或写错,程序里到处用 id=1 查询的代码会开始读到错误的行。
func TestMigrate_v5同步状态表只允许id为1(t *testing.T) {
db := newFreshDB(t)
const now = "2026-08-09T00:00:00Z"
if _, err := db.Exec(`
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
VALUES (1, NULL, ?)`, now); err != nil {
t.Fatalf("插入 id=1 失败: %v", err)
}
_, err := db.Exec(`
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
VALUES (2, NULL, ?)`, now)
if err == nil {
t.Fatal("id != 1 应该被 CHECK 约束拒绝")
}
}
func TestCheckSchema_缺少syb_session表时拒绝(t *testing.T) {
db := newFreshDB(t)
if _, err := db.Exec(`DROP TABLE syb_session`); err != nil {
t.Fatalf("删表失败: %v", err)
}
err := CheckSchema(db)
if err == nil || !strings.Contains(err.Error(), "syb_session") {
t.Fatalf("缺 syb_session 表时应该拒绝启动并指出表名,实际 %v", err)
}
}
func TestCheckSchema_缺少syb_sync_state表时拒绝(t *testing.T) {
db := newFreshDB(t)
if _, err := db.Exec(`DROP TABLE syb_sync_state`); err != nil {
t.Fatalf("删表失败: %v", err)
}
err := CheckSchema(db)
if err == nil || !strings.Contains(err.Error(), "syb_sync_state") {
t.Fatalf("缺 syb_sync_state 表时应该拒绝启动并指出表名,实际 %v", err)
}
}
func TestCheckSchema_缺少product_spec列时拒绝(t *testing.T) {
db := newFreshDB(t)
// modernc.org/sqlite 支持 DROP COLUMN(SQLite ≥ 3.35),
// 用它来模拟"迁移没有完整落地、只有旧列"的库。
if _, err := db.Exec(`ALTER TABLE syb_orders DROP COLUMN product_spec`); err != nil {
t.Fatalf("模拟缺列失败: %v", err)
}
err := CheckSchema(db)
if err == nil || !strings.Contains(err.Error(), "product_spec") {
t.Fatalf("缺 product_spec 列时应该拒绝启动并指出列名,实际 %v", err)
}
}
// ── 最终 schema 的硬约束:不依赖路径比对 ──────────────
//
// TestMigrate_不同起点最终schema一致 证明的是"全新库"和"v2 老库"两条路径
@@ -884,6 +1027,11 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
if err := migrateV3(db); err != nil {
t.Fatalf("准备 v3 数据库失败: %v", err)
}
// 故意跳过 v4(不加 shop_name),但把 v5 补上——否则 CheckSchema 会先
// 因为缺 v5 的表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
if err := runSQLMigration(db, 5, migrationV5); err != nil {
t.Fatalf("准备 v5 数据库失败: %v", err)
}
err := CheckSchema(db)
if err == nil || !strings.Contains(err.Error(), "shop_name") {
+255
View File
@@ -0,0 +1,255 @@
// 顺运宝会话缓存、同步进度和货运单明细的读写。
//
// 改动前必读 admin/AGENTS.md:只有本文件(和 db.go)能写 SQL,
// service/syb.go 和 handler/web/others.go 都不许拼 SQL。
package repository
import (
"database/sql"
"errors"
"fmt"
"cmautobuy/admin/model"
)
// ---------- 会话缓存 ----------
// SaveSybSession 写入或更新顺运宝登录会话缓存(按用户名 upsert)。
//
// `[必须]` 缓存写失败不能让已经登录的顺运宝会话失效——调用方拿到错误后
// 只应该记日志,不应该把内存里刚登录成功的会话也扔掉,
// 见 docs/admin/08-顺运宝接口.md §8。这条约束在 service 层落实,
// 这里只负责"写失败就如实返回错误"。
func SaveSybSession(q Execer, username, cookiesJSON, expiresAt string) error {
if username == "" {
return fmt.Errorf("username 不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
cookies = excluded.cookies,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
username, cookiesJSON, expiresAt, now)
if err != nil {
return fmt.Errorf("保存顺运宝会话缓存失败: %w", err)
}
return nil
}
// SybSessionCache 是缓存里的一条顺运宝会话。
type SybSessionCache struct {
Username string
Cookies string // JSON 数组,原样保留,交给 syb.Client 解析
ExpiresAt string
}
// GetSybSession 按用户名查缓存的会话,查不到返回 (nil, nil)。
//
// `[必须]` 这里只负责取数据,**不判断是否过期**——过期时间的比较、
// 是否需要重新登录,是 service 层的业务判断(要用到"现在几点"这个
// 会变化的量,放这里测试起来还要控制时间,不如交给上层)。
func GetSybSession(q Execer, username string) (*SybSessionCache, error) {
var c SybSessionCache
err := q.QueryRow(`
SELECT username, cookies, expires_at FROM syb_session WHERE username = ?`, username,
).Scan(&c.Username, &c.Cookies, &c.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询顺运宝会话缓存失败: %w", err)
}
return &c, nil
}
// DeleteSybSession 清除某个用户名的会话缓存(会话确认失效后调用)。
func DeleteSybSession(q Execer, username string) error {
if _, err := q.Exec(`DELETE FROM syb_session WHERE username = ?`, username); err != nil {
return fmt.Errorf("清除顺运宝会话缓存失败: %w", err)
}
return nil
}
// ---------- 同步进度 ----------
// GetSybLastSyncedAt 查上次同步完成的时间(精确到秒的 ISO 字符串)。
// 从没同步过时返回 ("", false, nil)。
func GetSybLastSyncedAt(q Execer) (lastSyncedAt string, found bool, err error) {
var s sql.NullString
err = q.QueryRow(`SELECT last_synced_at FROM syb_sync_state WHERE id = 1`).Scan(&s)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("查询顺运宝同步进度失败: %w", err)
}
if !s.Valid || s.String == "" {
return "", false, nil
}
return s.String, true, nil
}
// SetSybLastSyncedAt 更新"上次同步到哪"。
//
// `[必须]` 只应该在一次同步**全部成功**之后调用——调用方(service 层)
// 负责这个时机;这里只负责写,不判断"是否该写"。中途失败不调用这个函数,
// 见工单 #46「中途失败不更新 last_synced_at」。
func SetSybLastSyncedAt(q Execer, at string) error {
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
last_synced_at = excluded.last_synced_at,
updated_at = excluded.updated_at`,
at, now)
if err != nil {
return fmt.Errorf("更新顺运宝同步进度失败: %w", err)
}
return nil
}
// ---------- 货运单明细 ----------
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
//
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
// docs/admin/08-顺运宝接口.md §6.2。
//
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
// detail.productId,来自顺运宝,不是人工填的。
//
// 返回 created 表示这一行是不是本次新插入的(供上层统计"新增/更新")。
func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
if o.SybID == "" {
return false, fmt.Errorf("syb_id 不能为空")
}
var exists int
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
switch {
case errors.Is(err, sql.ErrNoRows):
created = true
case err != nil:
return false, fmt.Errorf("查询顺运宝货运单明细 %s 失败: %w", o.SybID, err)
}
now := model.NowISO()
_, err = q.Exec(`
INSERT INTO syb_orders
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(syb_id) DO UPDATE SET
order_no = excluded.order_no,
title = excluded.title,
product_spec = excluded.product_spec,
shopee_goods_id = excluded.shopee_goods_id,
quantity = excluded.quantity,
price_twd_cent = excluded.price_twd_cent,
image_url = excluded.image_url,
syb_data = excluded.syb_data,
updated_at = excluded.updated_at`,
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
// 这一列不受本语句影响,见上面的函数注释。
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
o.SybData, now, now)
if err != nil {
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
}
return created, nil
}
// nullableText 把空字符串转成 SQL NULL,非空字符串原样写入。
// syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL
// 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。
func nullableText(s string) any {
if s == "" {
return nil
}
return s
}
// SybOrderFilter 是货运单列表页支持的筛选条件。
type SybOrderFilter struct {
Keyword string // 匹配订单号或商品标题
}
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
kw := filter.Keyword
if kw == "" {
return "", nil
}
like := "%" + escapeLike(kw) + "%"
return ` WHERE (order_no LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\')`, []any{like, like}
}
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `
SELECT syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
quantity, price_twd_cent, image_url, syb_data, created_at, updated_at
FROM syb_orders` + where + `
ORDER BY updated_at DESC, syb_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询顺运宝货运单列表失败: %w", err)
}
defer rows.Close()
var list []model.SybOrder
for rows.Next() {
var o model.SybOrder
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var priceCent sql.NullInt64
if err := rows.Scan(
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
}
o.Title = title.String
o.ProductSpec = productSpec.String
o.ShopeeGoodsID = shopeeGoodsID.String
o.ShopeeSKUID = shopeeSKUID.String
o.PriceTwdCent = priceCent.Int64
o.ImageURL = imageURL.String
list = append(list, o)
}
return list, rows.Err()
}
// CountSybOrders 统计当前筛选条件下的货运单明细总数。
//
// `[必须]` 用和 ListSybOrders **完全相同**的筛选条件——分页和底部统计
// 靠它,写成两份筛选条件迟早有一天会不一致(工单 #43 的教训)。
func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `SELECT COUNT(*) FROM syb_orders` + where
var n int
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
}
return n, nil
}
// CountSybOrdersTotal 统计全部货运单明细数量(不带筛选),
// 供列表页判断"是否已经同步过任何数据"。
func CountSybOrdersTotal(q Execer) (int, error) {
var n int
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_orders`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err)
}
return n, nil
}
+221
View File
@@ -0,0 +1,221 @@
package repository
import (
"database/sql"
"testing"
"cmautobuy/admin/model"
)
func newSybTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(t.TempDir())
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := Migrate(db); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在(t *testing.T) {
// 这是工单 #46 最要紧的一条:shopee_sku_id 是人工规格匹配的结果,
// 顺运宝同步绝不能把它覆盖成空值——覆盖了不会报错,等到建采购任务
// 才会发现匹配成果被洗掉,那时已经找不回来了。
db := newSybTestDB(t)
first := model.SybOrder{
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "初始标题",
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
Quantity: 1, PriceTwdCent: 23900, ImageURL: "https://x/img.jpg",
SybData: `{"stock":{}}`,
}
created, err := UpsertSybOrder(db, first)
if err != nil {
t.Fatalf("首次写入失败: %v", err)
}
if !created {
t.Fatal("首次写入应该是新建")
}
// 模拟操作员在界面上完成了规格匹配,手工把 shopee_sku_id 填上。
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
"TEST-SKU-123", "SYB-1"); err != nil {
t.Fatalf("模拟人工匹配失败: %v", err)
}
// 再次同步:标题、数量、价格都变了(模拟顺运宝那边数据更新),
// 但 upsert 调用方不会传 shopee_sku_id 的新值(顺运宝根本没有这个字段)。
second := model.SybOrder{
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "更新后的标题",
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
Quantity: 3, PriceTwdCent: 25900, ImageURL: "https://x/img2.jpg",
SybData: `{"stock":{"updated":true}}`,
// 注意:这里刻意不设置 ShopeeSKUID(零值,即空字符串),
// 模拟"同步流程从来不知道匹配结果,只管顺运宝返回的字段"。
}
created2, err := UpsertSybOrder(db, second)
if err != nil {
t.Fatalf("二次写入失败: %v", err)
}
if created2 {
t.Fatal("二次写入应该是更新,不是新建")
}
var skuID, title string
var qty int
if err := db.QueryRow(`SELECT shopee_sku_id, title, quantity FROM syb_orders WHERE syb_id = ?`,
"SYB-1").Scan(&skuID, &title, &qty); err != nil {
t.Fatalf("查询失败: %v", err)
}
if skuID != "TEST-SKU-123" {
t.Fatalf("shopee_sku_id 应该还是人工匹配的 TEST-SKU-123,实际 %q"+
"——同步把匹配成果覆盖掉了,这是本工单最不能接受的问题", skuID)
}
// 其余允许覆盖的字段应该已经更新,证明这不是"upsert 整个没生效"的假通过。
if title != "更新后的标题" || qty != 3 {
t.Errorf("title/quantity 应该被同步更新,实际 title=%q quantity=%d", title, qty)
}
}
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
db := newSybTestDB(t)
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "11111111111", Quantity: 1,
SybData: "{}",
}); err != nil {
t.Fatalf("首次写入失败: %v", err)
}
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "22222222222", Quantity: 1,
SybData: "{}",
}); err != nil {
t.Fatalf("二次写入失败: %v", err)
}
var goodsID string
if err := db.QueryRow(`SELECT shopee_goods_id FROM syb_orders WHERE syb_id = ?`,
"SYB-2").Scan(&goodsID); err != nil {
t.Fatalf("查询失败: %v", err)
}
if goodsID != "22222222222" {
t.Errorf("shopee_goods_id 应该被同步更新为顺运宝返回的新值,实际 %q", goodsID)
}
}
func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
db := newSybTestDB(t)
for i := 0; i < 3; i++ {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-3", OrderNo: "ORDER-3", Quantity: 1, SybData: "{}",
}); err != nil {
t.Fatalf("第 %d 次写入失败: %v", i+1, err)
}
}
n, err := CountSybOrdersTotal(db)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if n != 1 {
t.Fatalf("重复同步同一个 syb_id 应该只有 1 行,实际 %d 行", n)
}
}
func TestSybSession_保存读取和清除(t *testing.T) {
db := newSybTestDB(t)
if got, err := GetSybSession(db, "tester"); err != nil || got != nil {
t.Fatalf("没有会话时应该返回 (nil, nil),实际 got=%v err=%v", got, err)
}
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"abc"}]`, "2026-08-10T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
got, err := GetSybSession(db, "tester")
if err != nil {
t.Fatalf("读取会话失败: %v", err)
}
if got == nil || got.ExpiresAt != "2026-08-10T00:00:00Z" {
t.Fatalf("读取到的会话不对: %+v", got)
}
// upsert:同一用户名再保存一次应该覆盖,不是新增一行。
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"xyz"}]`, "2026-08-11T00:00:00Z"); err != nil {
t.Fatalf("二次保存会话失败: %v", err)
}
got2, err := GetSybSession(db, "tester")
if err != nil || got2 == nil || got2.ExpiresAt != "2026-08-11T00:00:00Z" {
t.Fatalf("二次保存后应该读到新值: got=%+v err=%v", got2, err)
}
if err := DeleteSybSession(db, "tester"); err != nil {
t.Fatalf("删除会话失败: %v", err)
}
if got3, err := GetSybSession(db, "tester"); err != nil || got3 != nil {
t.Fatalf("删除后应该查不到,实际 got=%v err=%v", got3, err)
}
}
func TestSybSyncState_首次为空之后可更新(t *testing.T) {
db := newSybTestDB(t)
_, found, err := GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if found {
t.Fatal("从没同步过时 found 应该是 false")
}
if err := SetSybLastSyncedAt(db, "2026-08-09T14:30:00Z"); err != nil {
t.Fatalf("更新失败: %v", err)
}
at, found, err := GetSybLastSyncedAt(db)
if err != nil || !found || at != "2026-08-09T14:30:00Z" {
t.Fatalf("读取错误: at=%q found=%v err=%v", at, found, err)
}
// 再更新一次,确认是 upsert 而不是报主键冲突。
if err := SetSybLastSyncedAt(db, "2026-08-10T09:00:00Z"); err != nil {
t.Fatalf("二次更新失败: %v", err)
}
at2, _, err := GetSybLastSyncedAt(db)
if err != nil || at2 != "2026-08-10T09:00:00Z" {
t.Fatalf("二次更新后应该读到新值: at2=%q err=%v", at2, err)
}
}
func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
db := newSybTestDB(t)
mustUpsert := func(sybID, orderNo, title string) {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}",
}); err != nil {
t.Fatalf("写入 %s 失败: %v", sybID, err)
}
}
mustUpsert("SYB-A", "260728AAA", "纯棉上衣")
mustUpsert("SYB-B", "260728BBB", "牛仔裤")
rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows) != 1 || rows[0].SybID != "SYB-A" {
t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows)
}
rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" {
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2)
}
}