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:
@@ -0,0 +1,620 @@
|
||||
// 顺运宝货运单同步的编排逻辑:算日期范围、翻页拉列表和明细、
|
||||
// 字段映射、落库统计、汇总报告。
|
||||
//
|
||||
// 改动前必读 admin/AGENTS.md:本层不认识 *gin.Context,也不拼 SQL——
|
||||
// 那些分别在 handler/web/others.go 和 repository/syb.go。
|
||||
//
|
||||
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
|
||||
// 1. shopee_sku_id 绝不能被同步写入/覆盖——这条已经在
|
||||
// repository.UpsertSybOrder 的 SQL 层面保证,本文件不需要、
|
||||
// 也不允许再传一份"新的" shopee_sku_id 进去。
|
||||
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
|
||||
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// sybLocation 是顺运宝服务端的时区。
|
||||
//
|
||||
// `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测
|
||||
// raw_data/shunyunbaoerp_stock_query.har:抓包于 2026-07-28T03:31:45Z
|
||||
// (= 11:31 UTC+8),同一响应里 created 是 "2026-07-28 10:37:59"。
|
||||
// 若 created 是 UTC,换算成 UTC+8 就是 18:37,比抓包时刻晚 7 小时,
|
||||
// 订单创建于未来,不成立;作为 UTC+8 讲得通(比抓包早 54 分钟)。
|
||||
//
|
||||
// 用 UTC 算日期会在本地(UTC+8)00:00–08:00 这段时间把"今天"算成昨天,
|
||||
// 当天早晨创建的单这一轮拉不到(下一轮的 from 仍是上次同步日,
|
||||
// 范围会覆盖回来,不会永久丢,但操作员当场会以为同步坏了)。
|
||||
// 见 docs/admin/08-顺运宝接口.md §5.3。
|
||||
//
|
||||
// `[必须]` 用 time.FixedZone 写死,不用 time.LoadLocation("Asia/Shanghai")——
|
||||
// 那要读系统 tzdata,Windows 默认没有,打包成 exe 后会失败。
|
||||
var sybLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// dateOf 把一个时刻转成顺运宝服务端时区(UTC+8)下的 YYYY-MM-DD。
|
||||
//
|
||||
// `[必须]` last_synced_at 存的仍是 UTC ISO(和全库其它时间戳一致,
|
||||
// model.NowISO 的约定不动),只在这里换算成 UTC+8 取日期。
|
||||
func dateOf(t time.Time) string {
|
||||
return t.In(sybLocation).Format(dateLayout)
|
||||
}
|
||||
|
||||
// syncDateRange 算出这次同步该拉哪个日期范围。
|
||||
//
|
||||
// `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created
|
||||
// 筛选粒度是日期,last_synced_at 精确到秒,从第二天拉会漏掉当天晚些
|
||||
// 时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏,
|
||||
// 见工单 #46。
|
||||
//
|
||||
// `[必须]` 首次同步(lastSyncedAt 为空)用 syncFrom;结束日期用 now
|
||||
// 对应的日期,不用未来日期。
|
||||
func syncDateRange(lastSyncedAt string, syncFrom string, now time.Time) (from, to string, err error) {
|
||||
to = dateOf(now)
|
||||
if lastSyncedAt == "" {
|
||||
from = strings.TrimSpace(syncFrom)
|
||||
if from == "" {
|
||||
return "", "", fmt.Errorf("从未同步过,且 config.yaml 里没有配置 syb.sync_from,无法确定起始日期")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
t, ok := model.ParseISO(lastSyncedAt)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("上次同步时间 %q 解析失败", lastSyncedAt)
|
||||
}
|
||||
return dateOf(t), to, nil
|
||||
}
|
||||
|
||||
// ---------- 同步报告 ----------
|
||||
|
||||
// SkipNote 是一条跳过或失败的说明。
|
||||
//
|
||||
// `[必须]` 有跳过或失败时要把它们列出来,不能只给个数字,见工单 #46
|
||||
// 「报告要说清楚」。
|
||||
type SkipNote struct {
|
||||
SybID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SyncReport 是一次同步的结果,供状态条显示。
|
||||
type SyncReport struct {
|
||||
From, To string
|
||||
StockCount int // 拉到的货运单数
|
||||
DetailCount int // 落库的商品明细行数(不含跳过的)
|
||||
Created int
|
||||
Updated int
|
||||
SkippedZero int // quantity <= 0 被跳过的条数
|
||||
Notes []SkipNote
|
||||
Err error
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
// Summary 组装状态条文案,格式见工单 #46「报告要说清楚」:
|
||||
//
|
||||
// 同步完成:日期范围 2026-08-09 ~ 2026-08-09,货运单 12 张,商品明细 27 条
|
||||
// (新增 20,更新 7,跳过 0)
|
||||
func (r SyncReport) Summary() string {
|
||||
if r.Err != nil {
|
||||
return "同步失败:" + r.Err.Error()
|
||||
}
|
||||
msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)",
|
||||
r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero)
|
||||
if len(r.Notes) > 0 {
|
||||
var reasons []string
|
||||
for _, n := range r.Notes {
|
||||
reasons = append(reasons, fmt.Sprintf("%s:%s", n.SybID, n.Reason))
|
||||
}
|
||||
msg += ";跳过/失败详情:" + strings.Join(reasons, ";")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ---------- 同步互斥:一次只允许一个同步在跑 ----------
|
||||
//
|
||||
// `[必须]` 同步是长任务,不能阻塞 HTTP 请求线程直到结束;也不需要为此
|
||||
// 引入后台协程池——本项目是单机内部工具,用一个互斥标志挡住重复点击
|
||||
// 即可,见工单 #46。
|
||||
|
||||
var (
|
||||
syncStateMu sync.Mutex
|
||||
syncRunning bool
|
||||
lastReport *SyncReport
|
||||
)
|
||||
|
||||
// TryStartSybSync 尝试把"同步中"标志置上;已经在跑时返回 false。
|
||||
func TryStartSybSync() bool {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
if syncRunning {
|
||||
return false
|
||||
}
|
||||
syncRunning = true
|
||||
return true
|
||||
}
|
||||
|
||||
// FinishSybSync 同步结束(不管成功失败)后调用,记录最后一份报告并
|
||||
// 放开互斥标志。
|
||||
func FinishSybSync(r SyncReport) {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
syncRunning = false
|
||||
rc := r
|
||||
lastReport = &rc
|
||||
}
|
||||
|
||||
// SybSyncStatus 是页面要显示的当前同步状态。
|
||||
type SybSyncStatus struct {
|
||||
Running bool
|
||||
Report *SyncReport // 最近一次已经完成的同步报告,从没同步过是 nil
|
||||
}
|
||||
|
||||
// GetSybSyncStatus 供页面渲染状态条用。
|
||||
func GetSybSyncStatus() SybSyncStatus {
|
||||
syncStateMu.Lock()
|
||||
defer syncStateMu.Unlock()
|
||||
return SybSyncStatus{Running: syncRunning, Report: lastReport}
|
||||
}
|
||||
|
||||
// ---------- 同步主流程 ----------
|
||||
|
||||
// receiverFields 是顺运宝原始数据里属于个人信息的字段,落库前要剔掉。
|
||||
//
|
||||
// `[建议]` 见工单 #46 和 docs/admin/08-顺运宝接口.md §5:收件人姓名/
|
||||
// 电话/地址做采购决策用不到,不入库。
|
||||
var receiverFields = []string{"receiver", "receiverTel", "receiverAddr"}
|
||||
|
||||
func stripReceiverFields(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
skip := false
|
||||
for _, f := range receiverFields {
|
||||
if k == f {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roundYuanToCent 把台币元换算成分,**先四舍五入再转整数**。
|
||||
//
|
||||
// `[必须]` 08 §5.1:直接截断浮点数会算错钱(239.0*100 在浮点下可能是
|
||||
// 23899.999...),必须先 math.Round 再转 int64。
|
||||
func roundYuanToCent(yuan float64) int64 {
|
||||
return int64(math.Round(yuan * 100))
|
||||
}
|
||||
|
||||
// RunSybSync 执行一次完整的顺运宝货运单同步。
|
||||
//
|
||||
// `[必须]` 调用方负责:
|
||||
// 1. 只在 TryStartSybSync() 返回 true 时调用一次;
|
||||
// 2. 调用结束后(不管成功失败)调用 FinishSybSync(report);
|
||||
// 3. client 已经恢复了有效的登录会话(Cookie)。
|
||||
//
|
||||
// 本函数本身不检查会话是否有效——会话有效性判断属于"点同步"这一步的
|
||||
// 前置检查(service.EnsureSybLoginNeeded),不属于同步本身;同步过程中
|
||||
// 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid,
|
||||
// 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。
|
||||
func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport {
|
||||
report := SyncReport{StartedAt: now}
|
||||
|
||||
pageSize := cfg.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
maxMatches := cfg.MaxMatches
|
||||
if maxMatches <= 0 {
|
||||
maxMatches = 500
|
||||
}
|
||||
|
||||
lastSyncedAt, _, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("读取上次同步进度失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now)
|
||||
if err != nil {
|
||||
report.Err = err
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
report.From, report.To = from, to
|
||||
|
||||
total, err := client.ListTotal(ctx, from, to, pageSize)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("查询货运单总数失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if total > maxMatches {
|
||||
report.Err = fmt.Errorf(
|
||||
"日期范围 %s ~ %s 内有 %d 张货运单,超过单次同步上限 %d,"+
|
||||
"请缩小日期范围或联系维护者调大 max_matches", from, to, total, maxMatches)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if total == 0 {
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("更新同步进度失败: %w", err)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// ① 翻页拉全部货运单行。
|
||||
stockByID := map[int64]syb.StockRow{}
|
||||
var orderedIDs []int64
|
||||
for start := 0; start < total; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
rows, err := client.ListPage(ctx, from, to, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("拉取货运单列表第 %d 页失败(已获取 %d/%d 张,本次同步整体作废,"+
|
||||
"下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w",
|
||||
pageIndex, len(orderedIDs), total, err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, dup := stockByID[row.ID]; dup {
|
||||
continue
|
||||
}
|
||||
stockByID[row.ID] = row
|
||||
orderedIDs = append(orderedIDs, row.ID)
|
||||
}
|
||||
}
|
||||
report.StockCount = len(orderedIDs)
|
||||
|
||||
// ② 按 100 个一批取明细,③ 逐张货运单写库。
|
||||
//
|
||||
// `[必须]` 不放在一个大事务里——几千条明细的事务会长时间持锁;
|
||||
// 按货运单为单位提交,失败了已成功的部分保留(下次重拉会 upsert
|
||||
// 覆盖,幂等),见工单 #46。
|
||||
//
|
||||
// `[必须]` 中途失败(拉明细失败,或某张货运单写库失败)**立即停止**、
|
||||
// 不更新 last_synced_at——已经成功写入的部分不回滚(它们本身是幂等
|
||||
// 的),但"这次同步整体算成功"这件事不能发生,否则漏掉的单永远补不回来。
|
||||
const detailBatch = 100
|
||||
for i := 0; i < len(orderedIDs); i += detailBatch {
|
||||
end := i + detailBatch
|
||||
if end > len(orderedIDs) {
|
||||
end = len(orderedIDs)
|
||||
}
|
||||
batch := orderedIDs[i:end]
|
||||
|
||||
details, err := client.DetailListByStock(ctx, batch)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("拉取货运单明细失败(本次同步整体作废,"+
|
||||
"已写入的数据保留,下次重拉会 upsert 覆盖): %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
for _, d := range details {
|
||||
stockRow := stockByID[d.ID]
|
||||
if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil {
|
||||
report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+
|
||||
"已写入的数据保留): %w", d.Code, d.ID, err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 全部成功,才更新 last_synced_at。
|
||||
if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil {
|
||||
report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+
|
||||
"下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err)
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
|
||||
// writeStockDetail 把一张货运单的全部商品明细写进 syb_orders,
|
||||
// 一张货运单一个事务(工单 #46「按货运单为单位提交」)。
|
||||
func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
|
||||
if len(detail.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||||
|
||||
stockRaw := stripReceiverFields(mergeRaw(stockRow.Raw, detail.Raw))
|
||||
|
||||
for _, item := range detail.Details {
|
||||
sybID := strconv.FormatInt(item.ID, 10)
|
||||
|
||||
if item.ProductQty <= 0 {
|
||||
report.SkippedZero++
|
||||
report.Notes = append(report.Notes, SkipNote{
|
||||
SybID: sybID, Reason: fmt.Sprintf("数量为 %d,跳过(表结构要求 quantity > 0)", item.ProductQty),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
sybData, err := buildSybDataJSON(stockRaw, item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("组装 syb_data 失败: %w", err)
|
||||
}
|
||||
|
||||
order := model.SybOrder{
|
||||
SybID: sybID,
|
||||
OrderNo: detail.Code,
|
||||
Title: item.ProductTitle,
|
||||
ProductSpec: item.ProductSpec,
|
||||
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
|
||||
// `[必须]` 不设置 ShopeeSKUID——顺运宝没有这个值,
|
||||
// repository.UpsertSybOrder 也不会用它覆盖已有的匹配结果。
|
||||
Quantity: item.ProductQty,
|
||||
PriceTwdCent: roundYuanToCent(item.ProductPrice),
|
||||
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
|
||||
SybData: sybData,
|
||||
}
|
||||
|
||||
created, err := repository.UpsertSybOrder(tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
report.Created++
|
||||
} else {
|
||||
report.Updated++
|
||||
}
|
||||
report.DetailCount++
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// mergeRaw 合并"货运单列表"和"货运单明细"两次响应里同一张货运单的
|
||||
// 外层字段(不含 details),后者字段优先覆盖前者——明细响应更贴近
|
||||
// "拉这批数据当下"的状态,见工单 #46 字段映射表 syb_data 的说明。
|
||||
func mergeRaw(list, detail map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(list)+len(detail))
|
||||
for k, v := range list {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range detail {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildSybDataJSON 组装落库的 syb_data:{"stock": ..., "detail": ...}。
|
||||
// 嵌套两个 key 而不是拍平合并,是因为 stock 和 detail 两边都有名叫
|
||||
// "id" 的字段,指的是完全不同的东西(货运单 id vs 明细行 id),
|
||||
// 拍平会互相覆盖、审计时看不出原始结构。
|
||||
func buildSybDataJSON(stockRaw, detailRaw map[string]any) (string, error) {
|
||||
payload := map[string]any{"stock": stockRaw, "detail": detailRaw}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// imageURLFromThumb 拼图片地址:{base_url}/api/p/file?id={productThumb},
|
||||
// 见工单 #46 字段映射表。productThumb 是数字 ID,不是 URL;为 0 时
|
||||
// 说明没有缩略图,返回空字符串(不拼一个指向 id=0 的坏链接)。
|
||||
func imageURLFromThumb(baseURL string, productThumb int64) string {
|
||||
if productThumb == 0 || baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + "/api/p/file?id=" + strconv.FormatInt(productThumb, 10)
|
||||
}
|
||||
|
||||
// ---------- 待登录客户端:验证码和登录共用同一个 Cookie Jar ----------
|
||||
//
|
||||
// `[必须]` 08 §3.2:验证码和登录必须用同一个 syb.Client(同一个 Cookie
|
||||
// Jar),换客户端拿到的验证码就对不上。浏览器"取验证码图片"和"提交
|
||||
// 登录表单"是两次独立的 HTTP 请求,Admin 侧要在这两次请求之间把同一个
|
||||
// 客户端存住——本项目单机单操作员使用,用一个包级变量即可,
|
||||
// 不需要按会话/用户区分。
|
||||
|
||||
var (
|
||||
pendingLoginMu sync.Mutex
|
||||
pendingLoginClient *syb.Client
|
||||
)
|
||||
|
||||
// NewPendingSybLogin 为一次新的"取验证码 → 登录"流程创建客户端,
|
||||
// 并存成"待登录"客户端,丢弃上一个(操作员点"换一张"验证码时,
|
||||
// 上一张验证码本来就废了,不需要保留旧客户端)。
|
||||
func NewPendingSybLogin(baseURL string) (*syb.Client, error) {
|
||||
c, err := syb.New(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pendingLoginMu.Lock()
|
||||
pendingLoginClient = c
|
||||
pendingLoginMu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PendingSybLoginClient 取出当前"待登录"客户端,没有则返回 nil——
|
||||
// 调用方应该提示操作员先获取验证码。
|
||||
func PendingSybLoginClient() *syb.Client {
|
||||
pendingLoginMu.Lock()
|
||||
defer pendingLoginMu.Unlock()
|
||||
return pendingLoginClient
|
||||
}
|
||||
|
||||
// ClearPendingSybLogin 清掉"待登录"客户端(登录成功或放弃时调用)。
|
||||
func ClearPendingSybLogin() {
|
||||
pendingLoginMu.Lock()
|
||||
pendingLoginClient = nil
|
||||
pendingLoginMu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- 会话有效性 ----------
|
||||
|
||||
// ErrSybLoginRequired 表示当前没有可用的顺运宝登录会话,
|
||||
// 页面应该弹登录框,而不是报错。
|
||||
var ErrSybLoginRequired = errors.New("顺运宝会话不存在或已过期,请重新登录")
|
||||
|
||||
// EnsureSybSession 检查本地缓存的顺运宝会话是否足够新鲜,够就把 Cookie
|
||||
// 恢复进传入的 client;不够就返回 ErrSybLoginRequired(`errors.Is` 判断),
|
||||
// 提示调用方走登录流程。
|
||||
//
|
||||
// `[决定]` 这里只做**本地**过期时间判断,不额外发一次
|
||||
// GET /am/user/get 去问服务端"你还活着吗"——08 §3.5 描述的"网络故障不能
|
||||
// 判定未登录"这条规则,在同步真正发起后、遇到任何一次 syb.ErrSessionInvalid
|
||||
// 时同样会触发(syb.Client.do() 对所有 /am/** 接口都做了同一套分类),
|
||||
// 不需要在这里再打一次专门的探测请求——省掉一次没有必要的网络往返,
|
||||
// 也避免"探测请求本身超时"这种情况被误判成"未登录"。
|
||||
func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.Time) error {
|
||||
cached, err := repository.GetSybSession(db, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取顺运宝会话缓存失败: %w", err)
|
||||
}
|
||||
if cached == nil {
|
||||
return ErrSybLoginRequired
|
||||
}
|
||||
expiresAt, ok := model.ParseISO(cached.ExpiresAt)
|
||||
if !ok || !now.Before(expiresAt) {
|
||||
return ErrSybLoginRequired
|
||||
}
|
||||
if err := client.ImportCookiesJSON(cached.Cookies); err != nil {
|
||||
return fmt.Errorf("恢复顺运宝会话失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 列表页 ----------
|
||||
|
||||
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
|
||||
// 模板里不做判断和格式化,和其余四个模块的做法一致。
|
||||
type SybOrderView struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
MatchText string // "已匹配" / "待匹配"
|
||||
Matched bool
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// SybListResult 是列表页要的全部数据。
|
||||
type SybListResult struct {
|
||||
Rows []SybOrderView
|
||||
Total int
|
||||
HasAny bool
|
||||
IsFiltered bool
|
||||
Page int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
|
||||
//
|
||||
// `[必须]` 匹配状态是**算出来的**(shopee_sku_id 是否非空),
|
||||
// 不是存的字段,见工单 #46——本工单不做规格匹配,这里只负责如实
|
||||
// 显示"有没有"。
|
||||
func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, error) {
|
||||
filter := repository.SybOrderFilter{Keyword: keyword}
|
||||
total, err := repository.CountSybOrders(db, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := TotalPages(total)
|
||||
page = ClampPage(page, totalPages)
|
||||
offset := (page - 1) * PageSize
|
||||
|
||||
rows, err := repository.ListSybOrders(db, filter, PageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasAny, err := repository.CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SybListResult{
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(keyword) != "",
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for _, o := range rows {
|
||||
v := SybOrderView{
|
||||
SybID: o.SybID,
|
||||
OrderNo: o.OrderNo,
|
||||
Title: o.Title,
|
||||
ProductSpec: o.ProductSpec,
|
||||
ShopeeGoodsID: o.ShopeeGoodsID,
|
||||
ShopeeSKUID: o.ShopeeSKUID,
|
||||
Quantity: o.Quantity,
|
||||
ImageURL: o.ImageURL,
|
||||
UpdatedAt: formatLocalTime(o.UpdatedAt),
|
||||
}
|
||||
if o.PriceTwdCent > 0 {
|
||||
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
|
||||
} else {
|
||||
v.PriceText = placeholder
|
||||
}
|
||||
if o.Title == "" {
|
||||
v.Title = placeholder
|
||||
}
|
||||
if o.ProductSpec == "" {
|
||||
v.ProductSpec = placeholder
|
||||
}
|
||||
if o.ShopeeSKUID != "" {
|
||||
v.Matched = true
|
||||
v.MatchText = "已匹配"
|
||||
} else {
|
||||
v.MatchText = "待匹配"
|
||||
}
|
||||
result.Rows = append(result.Rows, v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSybLoginSession 登录成功后把会话缓存进数据库。
|
||||
//
|
||||
// `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样
|
||||
// 返回,由调用方决定"写失败了但登录已经成功,要不要继续走后面的同步",
|
||||
// 不在这里吞掉错误也不在这里替调用方做决定。
|
||||
func SaveSybLoginSession(db *sql.DB, client *syb.Client, username string, expiresAt time.Time) error {
|
||||
cookiesJSON, err := client.ExportCookiesJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出顺运宝会话 Cookie 失败: %w", err)
|
||||
}
|
||||
return repository.SaveSybSession(db, username, cookiesJSON, expiresAt.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
// ── 增量边界:本工单最容易写错的地方 ─────────────────────
|
||||
|
||||
func TestSyncDateRange_首次同步用配置的SyncFrom(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 15, 0, 0, 0, time.UTC)
|
||||
from, to, err := syncDateRange("", "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if from != "2026-07-01" {
|
||||
t.Errorf("首次同步应该用 sync_from,实际 from=%q", from)
|
||||
}
|
||||
if to != "2026-08-09" {
|
||||
t.Errorf("结束日期应该是今天,实际 to=%q", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_首次同步且未配置SyncFrom时报错(t *testing.T) {
|
||||
_, _, err := syncDateRange("", "", time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("从未同步过又没配置 sync_from 时应该报错,而不是拿一个空日期硬拉")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_增量从上次同步日期当天重拉不是第二天(t *testing.T) {
|
||||
// `[必须]` 这是工单 #46 明确点名"最容易写错的地方":
|
||||
// 上次同步 2026-08-09 14:30,下次必须还从 2026-08-09 开始拉,
|
||||
// 不能从 2026-08-10 开始——否则会漏掉 8-09 14:30 之后创建的单,
|
||||
// 而且不会报错,没人会发现。
|
||||
lastSyncedAt := "2026-08-09T14:30:00Z"
|
||||
now := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
from, to, err := syncDateRange(lastSyncedAt, "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if from != "2026-08-09" {
|
||||
t.Fatalf("增量同步应该从上次同步的当天(2026-08-09)重新拉,实际 from=%q"+
|
||||
"——如果这里算成了 2026-08-10,就是漏单且不报错的那个坑", from)
|
||||
}
|
||||
if to != "2026-08-10" {
|
||||
t.Errorf("结束日期应该是 now 对应的日期,实际 to=%q", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_上次同步时间格式错误时报错(t *testing.T) {
|
||||
_, _, err := syncDateRange("不是一个合法的时间", "2026-07-01", time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("last_synced_at 解析失败时应该报错,不能悄悄退化成一个随便的日期")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UTC(t *testing.T) {
|
||||
// `[必须]` 顺运宝 created 是 UTC+8(08 §5.2 有 HAR 实测证据)。
|
||||
// now = 2026-08-09T23:00:00Z,也就是本地(UTC+8)2026-08-10 07:00——
|
||||
// 如果日期运算错误地用了 UTC,会把"今天"算成 2026-08-09,
|
||||
// 当天早晨(UTC+8)创建的单这一轮就拉不到,操作员会以为同步坏了。
|
||||
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC)
|
||||
|
||||
_, to, err := syncDateRange("", "2026-07-01", now)
|
||||
if err != nil {
|
||||
t.Fatalf("计算日期范围失败: %v", err)
|
||||
}
|
||||
if to != "2026-08-10" {
|
||||
t.Fatalf("结束日期应该按顺运宝服务端时区(UTC+8)算成 2026-08-10,实际 to=%q"+
|
||||
"——如果这里算成了 2026-08-09,就是用错了 UTC 而不是 UTC+8", to)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 金额换算 ──────────────────────────────────────────
|
||||
|
||||
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
|
||||
cases := []struct {
|
||||
yuan float64
|
||||
want int64
|
||||
}{
|
||||
{239.0, 23900},
|
||||
{612.5, 61250},
|
||||
{5.05, 505}, // 08 §5.1 明确点名的样本
|
||||
{0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := roundYuanToCent(c.yuan)
|
||||
if got != c.want {
|
||||
t.Errorf("roundYuanToCent(%v) = %d,期望 %d", c.yuan, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 端到端:httptest 假服务端 ─────────────────────────────
|
||||
//
|
||||
// `[必须]` 绝不能打真实的 shunyunbaoerp.com,见工单 #46。
|
||||
|
||||
// fakeDetail 是假服务端里一条货运单明细行的最小描述。
|
||||
type fakeDetail struct {
|
||||
ID int64
|
||||
ProductID int64
|
||||
ProductTitle string
|
||||
ProductSpec string
|
||||
ProductQty int
|
||||
ProductPrice float64
|
||||
ProductThumb int64
|
||||
}
|
||||
|
||||
// fakeStock 是假服务端里一张货运单。
|
||||
type fakeStock struct {
|
||||
ID int64
|
||||
Code string
|
||||
ShopName string
|
||||
Receiver string // 用来验证个人信息确实没有落库
|
||||
Details []fakeDetail
|
||||
}
|
||||
|
||||
// fakeSybServer 起一个 httptest 假服务端,模拟 listTotal / list /
|
||||
// detail/listByStock 三个接口,数据来自内存里的 stocks 切片。
|
||||
//
|
||||
// failListPageIndex:如果 > 0,/am/stock/list 请求到这一页时返回失败,
|
||||
// 用来测"中途失败不更新 last_synced_at"。
|
||||
func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *httptest.Server {
|
||||
t.Helper()
|
||||
byID := map[int64]fakeStock{}
|
||||
for _, s := range stocks {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/am/stock/listTotal":
|
||||
writeEnvelope(t, w, true, "ok", len(stocks), nil)
|
||||
|
||||
case "/am/stock/list":
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
pageIndex := int(body["pageIndex"].(float64))
|
||||
length := int(body["length"].(float64))
|
||||
start := int(body["start"].(float64))
|
||||
|
||||
if failListPageIndex > 0 && pageIndex == failListPageIndex {
|
||||
writeEnvelope(t, w, false, "模拟的服务端故障", nil, "500")
|
||||
return
|
||||
}
|
||||
|
||||
end := start + length
|
||||
if end > len(stocks) {
|
||||
end = len(stocks)
|
||||
}
|
||||
var list []map[string]any
|
||||
if start < len(stocks) {
|
||||
for _, s := range stocks[start:end] {
|
||||
list = append(list, map[string]any{
|
||||
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
|
||||
"receiver": s.Receiver, "orderStatus": "待出货",
|
||||
})
|
||||
}
|
||||
}
|
||||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
|
||||
|
||||
case "/am/stock/detail/listByStock":
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
var list []map[string]any
|
||||
for _, id := range body.IDs {
|
||||
s, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var details []map[string]any
|
||||
for _, d := range s.Details {
|
||||
details = append(details, map[string]any{
|
||||
"id": d.ID, "productId": d.ProductID, "productTitle": d.ProductTitle,
|
||||
"productSpec": d.ProductSpec, "productQty": d.ProductQty,
|
||||
"productPrice": d.ProductPrice, "productThumb": d.ProductThumb,
|
||||
})
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
|
||||
"receiver": s.Receiver, "details": details,
|
||||
})
|
||||
}
|
||||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
|
||||
|
||||
default:
|
||||
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string, data any, code any) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]any{"status": status, "msg": msg, "data": data, "code": code})
|
||||
if err != nil {
|
||||
t.Fatalf("构造响应失败: %v", err)
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func newSyncTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := repository.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
|
||||
// `[必须]` 端到端版本:整条 RunSybSync 流程跑完,人工匹配的
|
||||
// shopee_sku_id 必须还在——这是工单 #46 唯一"错了要几周后才发现"的点。
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{
|
||||
ID: 75104587, Code: "260728TB95MJTQ", ShopName: "测试店铺", Receiver: "张三",
|
||||
Details: []fakeDetail{
|
||||
{ID: 145306175, ProductID: 50209124255, ProductTitle: "蕾絲花邊拼接背心女",
|
||||
ProductSpec: "白色,L【建議50-60公斤】", ProductQty: 1, ProductPrice: 239.0, ProductThumb: 190639637},
|
||||
},
|
||||
},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, err := syb.New(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("创建客户端失败: %v", err)
|
||||
}
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report1 := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report1.Err != nil {
|
||||
t.Fatalf("首次同步失败: %v", report1.Err)
|
||||
}
|
||||
if report1.Created != 1 {
|
||||
t.Fatalf("首次同步应该新增 1 条,实际 Created=%d", report1.Created)
|
||||
}
|
||||
|
||||
// 操作员在界面上完成规格匹配,手工写入 shopee_sku_id。
|
||||
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
|
||||
"MANUAL-MATCHED-SKU", "145306175"); err != nil {
|
||||
t.Fatalf("模拟人工匹配失败: %v", err)
|
||||
}
|
||||
|
||||
// 再同步一次(模拟顺运宝那边这张单信息有更新)。
|
||||
report2 := RunSybSync(context.Background(), db, client, cfg, now.Add(time.Hour))
|
||||
if report2.Err != nil {
|
||||
t.Fatalf("二次同步失败: %v", report2.Err)
|
||||
}
|
||||
if report2.Updated != 1 {
|
||||
t.Fatalf("二次同步应该是更新,实际 Updated=%d Created=%d", report2.Updated, report2.Created)
|
||||
}
|
||||
|
||||
var skuID string
|
||||
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&skuID); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if skuID != "MANUAL-MATCHED-SKU" {
|
||||
t.Fatalf("同步后 shopee_sku_id 应该还是 MANUAL-MATCHED-SKU,实际 %q"+
|
||||
"——人工匹配成果被顺运宝同步洗掉了", skuID)
|
||||
}
|
||||
|
||||
// 收件人信息不应该出现在 syb_data 里。
|
||||
var sybData string
|
||||
if err := db.QueryRow(`SELECT syb_data FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&sybData); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if strings.Contains(sybData, "张三") {
|
||||
t.Errorf("syb_data 不应该包含收件人姓名,实际: %s", sybData)
|
||||
}
|
||||
|
||||
// 价格换算:239.0 元 -> 23900 分。
|
||||
var priceCent int64
|
||||
if err := db.QueryRow(`SELECT price_twd_cent FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&priceCent); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if priceCent != 23900 {
|
||||
t.Errorf("price_twd_cent 应该是 23900,实际 %d", priceCent)
|
||||
}
|
||||
|
||||
// image_url 拼接。
|
||||
var imageURL string
|
||||
if err := db.QueryRow(`SELECT image_url FROM syb_orders WHERE syb_id = ?`,
|
||||
"145306175").Scan(&imageURL); err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
wantImage := srv.URL + "/api/p/file?id=190639637"
|
||||
if imageURL != wantImage {
|
||||
t.Errorf("image_url 应该是 %q,实际 %q", wantImage, imageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_中途失败不更新last_synced_at(t *testing.T) {
|
||||
// 两页数据,pageSize=1,第二页请求失败——模拟"拉到一半服务端出错"。
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
|
||||
{ID: 2, Code: "B", Details: []fakeDetail{{ID: 2, ProductID: 222, ProductQty: 1, ProductPrice: 1}}},
|
||||
}, 2) // 第 2 页失败
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 1, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err == nil {
|
||||
t.Fatal("第二页失败时同步应该报错")
|
||||
}
|
||||
|
||||
_, found, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("查询同步进度失败: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("中途失败不应该更新 last_synced_at——更新了的话下次同步会跳过这段区间," +
|
||||
"漏掉的单永远补不回来")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
|
||||
at, found, err := repository.GetSybLastSyncedAt(db)
|
||||
if err != nil {
|
||||
t.Fatalf("查询同步进度失败: %v", err)
|
||||
}
|
||||
if !found || at == "" {
|
||||
t.Fatal("全部成功后应该更新 last_synced_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10},
|
||||
{ID: 2, ProductID: 222, ProductQty: 0, ProductPrice: 10}, // 应该被跳过
|
||||
{ID: 3, ProductID: 333, ProductQty: -1, ProductPrice: 10}, // 应该被跳过
|
||||
}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
if report.SkippedZero != 2 {
|
||||
t.Fatalf("应该跳过 2 条 quantity<=0 的明细,实际 SkippedZero=%d", report.SkippedZero)
|
||||
}
|
||||
if len(report.Notes) != 2 {
|
||||
t.Fatalf("跳过的明细应该在报告里列出来,实际 Notes=%v", report.Notes)
|
||||
}
|
||||
if report.Created != 1 {
|
||||
t.Fatalf("只有 1 条应该真正写库,实际 Created=%d", report.Created)
|
||||
}
|
||||
|
||||
n, err := repository.CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("库里应该只有 1 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_超过最大条数报错不硬拉(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A"}, {ID: 2, Code: "B"}, {ID: 3, Code: "C"},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 2, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err == nil {
|
||||
t.Fatal("总数 3 超过 max_matches=2 时应该报错")
|
||||
}
|
||||
if !strings.Contains(report.Err.Error(), "缩小") {
|
||||
t.Errorf("错误信息应该提示缩小日期范围,实际: %v", report.Err)
|
||||
}
|
||||
|
||||
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
|
||||
t.Error("超限报错不应该更新 last_synced_at")
|
||||
}
|
||||
n, _ := repository.CountSybOrdersTotal(db)
|
||||
if n != 0 {
|
||||
t.Errorf("超限报错不应该写入任何数据,实际写了 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSybSync_一张货运单多个商品各落一行(t *testing.T) {
|
||||
srv := fakeSybServer(t, []fakeStock{
|
||||
{ID: 1, Code: "A", Details: []fakeDetail{
|
||||
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10, ProductSpec: "白色,L"},
|
||||
{ID: 2, ProductID: 222, ProductQty: 2, ProductPrice: 20, ProductSpec: "黑色,M"},
|
||||
}},
|
||||
}, 0)
|
||||
defer srv.Close()
|
||||
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New(srv.URL)
|
||||
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
report := RunSybSync(context.Background(), db, client, cfg, now)
|
||||
if report.Err != nil {
|
||||
t.Fatalf("同步失败: %v", report.Err)
|
||||
}
|
||||
if report.Created != 2 {
|
||||
t.Fatalf("一张货运单两个商品应该各落一行,实际 Created=%d", report.Created)
|
||||
}
|
||||
|
||||
n, _ := repository.CountSybOrdersTotal(db)
|
||||
if n != 2 {
|
||||
t.Fatalf("库里应该有 2 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 同步互斥标志 ──────────────────────────────────────
|
||||
|
||||
func TestSybSyncMutex_同一时间只允许一个同步(t *testing.T) {
|
||||
t.Cleanup(func() { FinishSybSync(SyncReport{}) })
|
||||
|
||||
if !TryStartSybSync() {
|
||||
t.Fatal("第一次应该能拿到互斥标志")
|
||||
}
|
||||
if TryStartSybSync() {
|
||||
t.Fatal("同步进行中时,第二次不应该能拿到互斥标志")
|
||||
}
|
||||
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
|
||||
if !TryStartSybSync() {
|
||||
t.Fatal("上一次同步结束后应该能重新拿到互斥标志")
|
||||
}
|
||||
|
||||
status := GetSybSyncStatus()
|
||||
if !status.Running {
|
||||
t.Error("刚拿到互斥标志后 Running 应该是 true")
|
||||
}
|
||||
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
|
||||
status = GetSybSyncStatus()
|
||||
if status.Running {
|
||||
t.Error("FinishSybSync 之后 Running 应该是 false")
|
||||
}
|
||||
if status.Report == nil || status.Report.From != "2026-08-09" {
|
||||
t.Errorf("应该能读到最近一次的报告,实际: %+v", status.Report)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话有效性判断 ────────────────────────────────────
|
||||
|
||||
func TestEnsureSybSession_没有缓存时要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
err := EnsureSybSession(db, client, "tester", time.Now())
|
||||
if err != ErrSybLoginRequired {
|
||||
t.Fatalf("没有缓存的会话时应该返回 ErrSybLoginRequired,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSybSession_已过期时要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if err := repository.SaveSybSession(db, "tester", `[]`, "2026-08-01T00:00:00Z"); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
err := EnsureSybSession(db, client, "tester", now)
|
||||
if err != ErrSybLoginRequired {
|
||||
t.Fatalf("过期会话应该返回 ErrSybLoginRequired,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSybSession_未过期时恢复Cookie不要求登录(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
cookiesJSON := `[{"name":"erp_session","value":"abc"}]`
|
||||
if err := repository.SaveSybSession(db, "tester", cookiesJSON, "2026-08-10T00:00:00Z"); err != nil {
|
||||
t.Fatalf("保存会话失败: %v", err)
|
||||
}
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := EnsureSybSession(db, client, "tester", now); err != nil {
|
||||
t.Fatalf("未过期的会话不应该要求重新登录: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSybLoginSession_写库和读回(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
client, _ := syb.New("https://example.invalid")
|
||||
|
||||
expiresAt := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
|
||||
if err := SaveSybLoginSession(db, client, "tester", expiresAt); err != nil {
|
||||
t.Fatalf("保存登录会话失败: %v", err)
|
||||
}
|
||||
|
||||
cached, err := repository.GetSybSession(db, "tester")
|
||||
if err != nil || cached == nil {
|
||||
t.Fatalf("应该能读到刚保存的会话: cached=%v err=%v", cached, err)
|
||||
}
|
||||
got, ok := model.ParseISO(cached.ExpiresAt)
|
||||
if !ok || !got.Equal(expiresAt) {
|
||||
t.Errorf("expires_at 应该是 %v,实际 %v(parsed=%v)", expiresAt, cached.ExpiresAt, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user