顺运宝模块此前是骨架,「同步」点了提示"待接入"。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>
222 lines
7.2 KiB
Go
222 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|