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:
+90
-16
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user