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
+206 -14
View File
@@ -1,43 +1,235 @@
package web
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
"cmautobuy/admin/syb"
)
// ---------- 2. 顺运宝数据 ----------
// SybList 渲染货运单列表页。
// 「匹配状态」是算出来的(sku_mappings 里有没有记录),不是存的字段。
// 「匹配状态」是算出来的(shopee_sku_id 是否非空),不是存的字段,
// 本工单(#46)不做规格匹配,这里只如实显示"有没有"。
func (h *Handler) SybList(c *gin.Context) {
keyword := c.Query("order_no")
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
}
// TODO(骨架): 查货运单,并左联 sku_mappings 得出匹配状态
var rows []gin.H
// renderSybList 是 SybList、SybSync、SybLoginAndSync 跳转回来共用的渲染逻辑。
func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) {
pageNum := service.ParsePage(pageRaw)
result, err := service.ListSybOrdersView(h.db, keyword, pageNum)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
status := msg
if status == "" {
status = sybStatusLine(result)
}
if service.GetSybSyncStatus().Running {
status = "同步进行中,请稍后刷新页面查看结果 · " + status
}
values := url.Values{}
if keyword != "" {
values.Set("order_no", keyword)
}
// `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。
// 这里只取 username(只读展示)和 base_url 是否配置正确。
username := ""
configProblem := ""
needLogin := false
cfg, cfgErr := config.Load()
switch {
case cfgErr != nil:
configProblem = cfgErr.Error()
default:
username = cfg.Syb.Username
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
configProblem = "顺运宝 base_url 配置有误: " + err.Error()
} else {
sessErr := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now())
needLogin = errors.Is(sessErr, service.ErrSybLoginRequired)
}
}
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
"Keyword": keyword,
"Rows": rows,
"Status": "尚未实现:同步或手工录入后这里显示货运单",
"Keyword": keyword,
"Rows": result.Rows,
"Status": status,
"HasAny": result.HasAny,
"IsFiltered": result.IsFiltered,
"NeedLogin": needLogin,
"Username": username,
"ConfigProblem": configProblem,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
// SybSync 从顺运宝同步待处理货运单。
// sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
func sybStatusLine(result *service.SybListResult) string {
prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total)
if !result.HasAny {
return "还没有货运单明细。点上方「同步」从顺运宝拉取。"
}
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
}
// SybSync 点「同步」按钮的入口。
//
// MVP 阶段只做占位,见 docs/admin/01-requirements.md §11 待确认 #1。
// 流程见工单 #46:
//
// 会话有效 ────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ 跳回列表页,页面上弹登录框(不是报错)
func (h *Handler) SybSync(c *gin.Context) {
// TODO(等待确认): 顺运宝的同步方式(接口?导出文件?)还没定。
// 定了之后在这里实现,注意完整响应要原样存进 syb_data 字段。
fail(c, http.StatusNotImplemented,
"同步功能待接入。顺运宝的对接方式尚未确定,"+
"当前可以先手工录入货运单用于联调。")
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
fail(c, http.StatusBadRequest, "顺运宝 base_url 配置有误: "+err.Error())
return
}
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
if errors.Is(err, service.ErrSybLoginRequired) {
// `[必须]` 会话过期后点同步 → 弹登录框,不是报错。
h.sybRedirect(c, "")
return
}
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
return
}
if !h.startSybSync(client, *cfg) {
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
return
}
h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果")
}
// SybCaptcha 返回一张新的顺运宝验证码图片。
//
// `[必须]` 验证码和随后提交的登录必须用同一个 Cookie Jar(08 §3.2),
// 所以这里新建的 syb.Client 要缓存起来(service.NewPendingSybLogin),
// 供 SybLoginAndSync 复用,不能各请求各建一个客户端。
func (h *Handler) SybCaptcha(c *gin.Context) {
cfg, err := config.Load()
if err != nil {
c.String(http.StatusBadRequest, "%s", err.Error())
return
}
client, err := service.NewPendingSybLogin(cfg.Syb.BaseURL)
if err != nil {
c.String(http.StatusBadRequest, "%s", err.Error())
return
}
captcha, err := client.FetchCaptcha(c.Request.Context())
if err != nil {
c.String(http.StatusBadGateway, "获取验证码失败:%s", err.Error())
return
}
c.Data(http.StatusOK, captcha.ContentType, captcha.Image)
}
// SybLoginAndSync 提交验证码登录,成功后立即发起同步。
//
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
// 界面上只有验证码是操作员手输的,见工单 #46。
func (h *Handler) SybLoginAndSync(c *gin.Context) {
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
code := strings.TrimSpace(c.PostForm("code"))
if code == "" {
h.sybRedirect(c, "验证码不能为空,请重新输入")
return
}
client := service.PendingSybLoginClient()
if client == nil {
h.sybRedirect(c, "验证码已过期,请重新获取后再试")
return
}
defer service.ClearPendingSybLogin()
result, err := client.Login(c.Request.Context(), cfg.Syb.Username, cfg.Syb.Password, code)
if err != nil {
// `[必须]` err 来自 syb.Client,错误信息本身不含密码,可以直接展示。
h.sybRedirect(c, "登录失败:"+err.Error())
return
}
// `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步。
if err := service.SaveSybLoginSession(h.db, client, result.User.Username, result.ExpiresAt); err != nil {
log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err)
}
if !h.startSybSync(client, *cfg) {
h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果")
return
}
h.sybRedirect(c, "登录成功,同步已开始,请稍后刷新页面查看结果")
}
// startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。
//
// `[必须]` 同步是长任务,不能阻塞 HTTP 请求线程直到结束,也不引入后台
// 协程池——一次只允许一个同步在跑,靠 service.TryStartSybSync 这个互斥
// 标志挡住重复点击,见工单 #46。
//
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool {
if !service.TryStartSybSync() {
return false
}
db := h.db
go func() {
report := service.RunSybSync(context.Background(), db, client, cfg.Syb, time.Now())
service.FinishSybSync(report)
}()
return true
}
// sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
params := url.Values{}
if q := c.PostForm("order_no"); q != "" {
params.Set("order_no", q)
}
if msg != "" {
params.Set("msg", msg)
}
target := "/syb"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
// SybMatch 保存规格匹配结果。
+2
View File
@@ -60,7 +60,9 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
// 3. 顺运宝数据
pages.GET("/syb", h.SybList)
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete)