Files
cmautobuy/admin/handler/web/others.go
T
chengmaandClaude Opus 5 2e686b17a4 feat: 顺运宝登录接入验证码自动识别 (#47)
#46 的登录只有手工输验证码一条路,而会话 24 小时就过期——每天第一次
同步都得有人在场,将来也做不了定时同步。

docs/admin/08 §8 当时写死"不引入 OCR 服务",理由是"多一个必须先启动的
东西"。那条判断基于示例脚本里的 http://127.0.0.1:8000/ocr(本机服务)。
用户提供了托管地址后前提不成立,本工单推翻它——文档里改写并保留原文,
让后来人知道这个决定变过、为什么变。

OCR 优先、手工兜底:识别成功直接登录,失败或服务不可达降级到 #46 已有的
手工弹窗,并在弹窗里说明是"已尝试 N 次"还是"服务不可用"。手工路径不删,
外部服务挂了不该让整个同步功能不可用。

识别失败也是 code:200。实测拿无文字图片探测 https://ocr.ilapage.cn/ocr
返回 {"code":200,"message":"Success","data":""}——不是错误码。所以
Recognize 只负责"这次 HTTP 调用有没有问题",空 data 照常返回 (", nil),
业务校验交给调用方;空 data 和长度不对收敛到同一个 len(code) != 4,
一条规则覆盖两种情况。

不合格的验证码不拿去登录:白费一次尝试,且频繁错误登录可能触发风控。
审查时变异测试发现这条没有测试守着——原测试只断言"重新取图了"和
"最终登录成功",禁用长度校验后依然成立。已补 loginRecorder 记录每次
提交到 /am/auth/login 的 code,断言登录只被调用一次且提交的是合格的那个。

每次重试重新取图(同一张图再识别结果一样,且可能已被上次失败的登录作废);
OCR 用独立 HTTP 客户端不带顺运宝 Cookie;验证码图片只在内存里传,不落盘。

OCR 不可达立即降级、不占用重试次数——对着连不上的地址重试 5 次,
操作员要等 50 秒才看到手工输入框,结果注定一样。

测试全部用 httptest,不打真实的 ocr.ilapage.cn 和 shunyunbaoerp.com。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 12:35:02 +08:00

509 lines
18 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) {
h.renderSybListWithLoginReason(c, keyword, pageRaw, msg, c.Query("login_reason"))
}
// renderSybListWithLoginReason 同 renderSybList,额外带一条"登录弹窗里要
// 显示的原因"——工单 #47:自动识别验证码失败或降级时,操作员需要知道
// 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。
func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason 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,
"NeedLoginReason": loginReason,
"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/#47:
//
// 会话有效 ─────────────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ OCR 已配置 ──→ 自动识别验证码登录成功 ──→ 直接开始同步,无人值守
// │ └→ 识别失败/OCR 不可达 ──┐
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
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) {
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
return
}
// `[必须]` 会话过期后先尝试 OCR 自动登录(工单 #47),
// 识别不出来或 OCR 不可达再降级弹手工输入框——不是直接报错。
reason, ok := h.attemptSybAutoLogin(c, client, *cfg)
if !ok {
h.sybRedirectLogin(c, reason)
return
}
if !h.startSybSync(client, *cfg) {
h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果")
return
}
h.sybRedirect(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果")
return
}
if !h.startSybSync(client, *cfg) {
h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击")
return
}
h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果")
}
// attemptSybAutoLogin 尝试用配置的 OCR 服务自动识别验证码并登录。
//
// `[必须]` cfg.Syb.OcrURL 留空 = 禁用,直接返回 ok=false、reason=""——
// 这种情况下弹出的手工登录框不应该带任何"失败原因"文案,因为压根没
// 尝试过自动识别,见工单 #47 验收标准「ocr_url 留空 → 直接走手工,不报错」。
//
// 返回 ok=true 时表示登录已成功、会话已缓存,调用方可以直接发起同步;
// ok=false 时 reason 是给操作员看的降级原因,调用方应该带着它弹手工输入框。
func (h *Handler) attemptSybAutoLogin(c *gin.Context, client *syb.Client, cfg config.Config) (reason string, ok bool) {
if strings.TrimSpace(cfg.Syb.OcrURL) == "" {
return "", false
}
ocrClient, err := syb.NewOcrClient(cfg.Syb.OcrURL, syb.DefaultOcrTimeout)
if err != nil {
// `[必须]` OCR 客户端本身构造失败(配置有误)也是"服务不可用"的
// 一种,同样降级到手工,不报错,见工单 #47。
return "验证码识别服务配置有误(" + err.Error() + "),请手工输入", false
}
result, degradeReason := client.LoginWithOCR(
c.Request.Context(), ocrClient, cfg.Syb.Username, cfg.Syb.Password, cfg.Syb.OcrMaxAttempts)
if degradeReason != "" {
return degradeReason, false
}
// `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步,
// 和 SybLoginAndSync 手工登录路径的处理方式一致。
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)
}
return "", true
}
// 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)
}
// sybRedirectLogin 同 sybRedirect,但把 reason 放进 login_reason 参数,
// 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要
// 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
params := url.Values{}
if q := c.PostForm("order_no"); q != "" {
params.Set("order_no", q)
}
if reason != "" {
params.Set("login_reason", reason)
}
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")
}