Files
cmautobuy/admin/handler/web/others.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

435 lines
14 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.
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 渲染货运单列表页。
// 「匹配状态」是算出来的(shopee_sku_id 是否非空),不是存的字段,
// 本工单(#46)不做规格匹配,这里只如实显示"有没有"。
func (h *Handler) SybList(c *gin.Context) {
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
}
// 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": 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()),
}))
}
// 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 点「同步」按钮的入口。
//
// 流程见工单 #46:
//
// 会话有效 ────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ 跳回列表页,页面上弹登录框(不是报错)
func (h *Handler) SybSync(c *gin.Context) {
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 保存规格匹配结果。
//
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
// 不是这一张订单的临时数据。下次遇到同一个蝦皮 SKU 自动带出,
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
func (h *Handler) SybMatch(c *gin.Context) {
// TODO(骨架): upsert sku_mappings
fail(c, http.StatusNotImplemented, "规格匹配尚未实现。")
}
// SybCreateTask 由勾选的货运单创建采购任务。
//
// 六条校验缺一不可,见 docs/admin/01-requirements.md §5:
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行)
// 6. 已选择分配的客户端
//
// 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么。
func (h *Handler) SybCreateTask(c *gin.Context) {
// TODO(骨架): 调 service.CreatePurchaseTasks(sybIDs, clientID)
fail(c, http.StatusNotImplemented, "创建采购任务尚未实现。")
}
// SybDelete 批量删除货运单。
func (h *Handler) SybDelete(c *gin.Context) {
// TODO(骨架)
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
}
// ---------- 3. 采集采购 ----------
//
// `tasks` 是一张表,用 task_type 区分采集和采购,这个模块把两种任务放在
// 同一个列表里显示,靠「目标」一列概括各自的业务信息,见 #19。
// 拼接和翻译逻辑全在 service/task.go,这里只做取参数 → 调 service → 渲染。
// TaskList 渲染「采集采购」列表页。
func (h *Handler) TaskList(c *gin.Context) {
filter := repository.TaskFilter{
Type: service.ParseTaskType(c.Query("type")),
Status: service.ParseTaskStatus(c.Query("status")),
Keyword: c.Query("q"),
}
result, err := service.ListTasksView(h.db, filter)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
// 底部状态条平时显示统计,刚做完删除操作时先显示操作结果,
// 跳转带过来的 msg 参数,见 h.taskRedirect。
statusLine := result.StatusLine()
if msg := c.Query("msg"); msg != "" {
statusLine = msg + " · " + statusLine
}
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
"Rows": result.Rows,
"Keyword": filter.Keyword,
"Status": statusLine,
"TypeFilter": string(filter.Type),
"StatusFilter": string(filter.Status),
"TypeOptions": service.TaskTypeOptions(),
"StatusOptions": service.TaskStatusOptions(),
"IsFiltered": result.IsFiltered,
}))
}
// TaskDetail 渲染双击行弹出的只读详情弹窗内容(不是整页)。
//
// `[必须]` 只读。改派 / 重试 / 取消是后续工单的范围,这里不提供入口。
// 弹窗机制复用 #18 已有的那套(static/js/app.js 的 setupRowDetail),
// 不新造一套。
func (h *Handler) TaskDetail(c *gin.Context) {
taskID := c.Query("id")
if taskID == "" {
fail(c, http.StatusBadRequest, "任务编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetTaskDetail(h.db, taskID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取任务详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个任务不存在,请刷新页面。")
return
}
c.HTML(http.StatusOK, "task/detail_modal", gin.H{"D": detail})
}
// TaskDelete 批量删除任务。
//
// `tasks` 表没有软删除列——加一列是数据库结构变更,不在本工单范围内,
// 见 repository.DeleteTasks 的注释。二次确认在前端做(data-confirm-delete)。
func (h *Handler) TaskDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.taskRedirect(c, "没有勾选任何任务,没有删除")
return
}
n, err := service.DeleteTasks(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"删除失败:"+err.Error()+"。没有删除任何记录。")
return
}
h.taskRedirect(c, fmt.Sprintf("已删除 %d 条", n))
}
// taskRedirect 处理完写操作后跳回列表页,带上当前筛选条件,
// 免得操作员每做一次删除就要重新筛一遍。用 303 而不是直接渲染,
// 是为了让浏览器地址栏变成 GET /tasks——按 F5 不会重复提交刚才的删除。
func (h *Handler) taskRedirect(c *gin.Context, msg string) {
params := url.Values{}
if t := c.PostForm("type"); t != "" {
params.Set("type", t)
}
if s := c.PostForm("status"); s != "" {
params.Set("status", s)
}
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if msg != "" {
params.Set("msg", msg)
}
target := "/tasks"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
// ---------- 4. 客户端列表 ----------
// ClientList 渲染客户端列表页。
//
// 「状态」是**算出来的**:last_seen_at 在 N 分钟内为在线,否则离线。
// 数据库里没有 status 字段,存成字段会和真实情况不同步。
//
// 客户端执行长任务期间不调 claim,可能显示为离线,属正常现象
// (没有心跳是有意的,见 docs/admin/04-client-api.md §3)。
func (h *Handler) ClientList(c *gin.Context) {
keyword := c.Query("name")
views, err := service.ListClientViews(h.db, keyword, h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
return
}
online := 0
for _, v := range views {
if v.Status == "在线" {
online++
}
}
status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d",
len(views), online, len(views)-online)
if len(views) == 0 {
status = "还没有客户端。客户端第一次调用领取接口时会自动登记。"
}
c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{
"Keyword": keyword,
"Rows": views,
"Status": status,
}))
}
// ClientDelete 批量删除客户端。
//
// 二次确认在前端做(见 static/js/app.js),这里直接删。
// 删掉之后客户端下次调 claim 会重新登记,属于正常行为。
func (h *Handler) ClientDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
fail(c, http.StatusBadRequest, "没有选中任何客户端,请勾选后再删除。")
return
}
n, err := service.DeleteClients(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"删除失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
return
}
log.Printf("clients_deleted count=%d", n)
c.Redirect(http.StatusSeeOther, "/clients")
}