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:
+206
-14
@@ -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 保存规格匹配结果。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user