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

103 lines
3.4 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 是给浏览器用的页面处理器。
//
// 和 handler/api 分开是有意的:页面出错要渲染错误页、要 CSRF 防护,
// 接口出错要返回 JSON、要认证。混在一起迟早写错。
//
// 改动本文件前必读 admin/AGENTS.md。两条最容易踩:
// - 本层**不写业务逻辑、不拼 SQL**,只做取参数 → 调 service → 渲染;
// - 删除、导入这类破坏性操作用 POST,**不得用 GET**,
// 浏览器和插件会预取 GET 链接。
package web
import (
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Handler 持有各页面共用的依赖。
type Handler struct {
db *sql.DB
// onlineThreshold 是判定客户端"在线"的时间窗。
// 取「轮询周期 + 最长任务时长」,宽松一点,
// 免得客户端执行长任务期间被误判成离线。
onlineThreshold time.Duration
}
// Register 把五个模块的页面路由挂上去。
//
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
h := &Handler{db: db, onlineThreshold: onlineThreshold}
// CSRF 只挂在页面路由上。
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
pages := r.Group("/", CSRFMiddleware())
// 打开根路径直接进第一个模块
pages.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/shopee")
})
// 1. 蝦皮数据
pages.GET("/shopee", h.ShopeeList)
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
pages.POST("/shopee/import", h.ShopeeImport)
pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/delete", h.ShopeeDelete)
pages.POST("/shopee/collect", h.ShopeeCollect)
// 2. PDD 商品
pages.GET("/pdd", h.PddList)
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
pages.POST("/pdd/create", h.PddCreate)
pages.POST("/pdd/save", h.PddSave)
pages.POST("/pdd/collect", h.PddCollect)
pages.POST("/pdd/delete", h.PddDelete)
// 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)
// 4. 采集采购
pages.GET("/tasks", h.TaskList)
pages.GET("/tasks/detail", h.TaskDetail) // 双击行时前端来取弹窗内容
pages.POST("/tasks/delete", h.TaskDelete)
// 5. 客户端列表
pages.GET("/clients", h.ClientList)
pages.POST("/clients/delete", h.ClientDelete)
}
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
func page(c *gin.Context, active, title string, extra gin.H) gin.H {
data := gin.H{
"Active": active,
"Title": title,
"CSRFToken": csrfToken(c),
}
for k, v := range extra {
data[k] = v
}
return data
}
// fail 渲染一个错误页。
//
// 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。
// Go 的错误堆栈只写日志,不要贴到页面上,见 docs/admin/06-quality-security.md §5。
func fail(c *gin.Context, status int, message string) {
c.HTML(status, "partials/error", gin.H{
"Title": "出错了",
"Message": message,
})
}