Files
cmautobuy/admin/repository/syb.go
T
chengmaandClaude Opus 5 5e426cacf6 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>
2026-08-09 11:49:13 +08:00

256 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 顺运宝会话缓存、同步进度和货运单明细的读写。
//
// 改动前必读 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
}