顺运宝模块此前是骨架,「同步」点了提示"待接入"。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>
256 lines
9.3 KiB
Go
256 lines
9.3 KiB
Go
// 顺运宝会话缓存、同步进度和货运单明细的读写。
|
||
//
|
||
// 改动前必读 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
|
||
}
|