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>
This commit is contained in:
chengma
2026-08-09 12:35:02 +08:00
co-authored by Claude Opus 5
parent 5e426cacf6
commit 2e686b17a4
10 changed files with 890 additions and 23 deletions
+90 -16
View File
@@ -29,6 +29,13 @@ func (h *Handler) SybList(c *gin.Context) {
// 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 {
@@ -71,15 +78,16 @@ func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) {
}
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()),
"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()),
}))
}
@@ -95,10 +103,12 @@ func sybStatusLine(result *service.SybListResult) string {
// SybSync 点「同步」按钮的入口。
//
// 流程见工单 #46:
// 流程见工单 #46/#47:
//
// 会话有效 ────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ 跳回列表页,页面上弹登录框(不是报错)
// 会话有效 ─────────────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ OCR 已配置 ──→ 自动识别验证码登录成功 ──→ 直接开始同步,无人值守
// │ └→ 识别失败/OCR 不可达 ──┐
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
func (h *Handler) SybSync(c *gin.Context) {
cfg, err := config.Load()
if err != nil {
@@ -112,12 +122,23 @@ func (h *Handler) SybSync(c *gin.Context) {
}
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
if errors.Is(err, service.ErrSybLoginRequired) {
// `[必须]` 会话过期后点同步 → 弹登录框,不是报错。
h.sybRedirect(c, "")
if !errors.Is(err, service.ErrSybLoginRequired) {
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
return
}
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
// `[必须]` 会话过期后先尝试 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
}
@@ -128,6 +149,40 @@ func (h *Handler) SybSync(c *gin.Context) {
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),
@@ -232,6 +287,25 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) {
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),