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
+71
View File
@@ -351,6 +351,77 @@ func jwtExpiry(token string) (time.Time, bool) {
return time.Unix(claims.Exp, 0), true
}
// ---------- 登录:验证码自动识别(工单 #47) ----------
// DefaultOcrMaxAttempts 是自动识别失败后的重试次数上限,maxAttempts<=0
// 时退化成这个值——示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5。
const DefaultOcrMaxAttempts = 5
// LoginWithOCR 尝试用 OCR 服务自动识别验证码并登录,最多重试 maxAttempts 次。
//
// `[必须]` 见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节和工单 #47:
// - 每次重试都要重新取一张验证码图——同一张图再识别一次结果不会变,
// 纯属浪费,而且验证码可能已经被上一次失败的登录作废;
// - 识别结果必须**非空且恰好 4 位字母数字**才拿去登录,过滤掉
// OCR 可能带回的空格和标点;不满足就换图重试,不拿去试登录——
// 白费一次登录尝试,而且频繁错误登录可能触发对方风控;
// - OCR 请求本身失败(网络/超时/格式错误/业务失败)判定为"服务不可用",
// 立即降级,不占用重试次数——服务本身连不上,重试没有意义;
// - 达到 maxAttempts 仍没登录成功,降级并说明已尝试的次数。
//
// `[必须]` 本函数**不返回 error**:外部 OCR 服务失败、识别失败都是
// 预期路径,不是异常——调用方应该把非空的返回原因展示给操作员并弹
// 手工输入框,而不是当成系统错误处理。返回 (result, "") 表示登录成功;
// 返回 (nil, reason) 表示需要降级到手工,reason 是给操作员看的说明。
func (c *Client) LoginWithOCR(ctx context.Context, ocr *OcrClient, username, password string, maxAttempts int) (*LoginResult, string) {
if ocr == nil {
return nil, "验证码识别服务未配置,请手工输入"
}
if maxAttempts <= 0 {
maxAttempts = DefaultOcrMaxAttempts
}
for attempt := 1; attempt <= maxAttempts; attempt++ {
captcha, err := c.FetchCaptcha(ctx)
if err != nil {
return nil, fmt.Sprintf("获取验证码图片失败(%s),请手工输入", err.Error())
}
code, err := ocr.Recognize(ctx, captcha.Image)
if err != nil {
return nil, fmt.Sprintf("验证码识别服务暂时不可用(%s),请手工输入", err.Error())
}
code = filterAlnum(code)
if len(code) != 4 {
// 识别结果为空或长度不对,说明这张图没识别对,换图重试,
// 不拿去登录(08 §OCR:不是 4 位就不要拿去登录)。
continue
}
result, err := c.Login(ctx, username, password, code)
if err == nil {
return result, ""
}
// 登录失败(验证码错/密码错,08 §9 未区分),换一张图重试。
}
return nil, fmt.Sprintf("自动识别验证码失败(已尝试 %d 次),请手工输入", maxAttempts)
}
// filterAlnum 只保留字母和数字,滤掉 OCR 可能带回的空格和标点
// (照抄 raw_data/shunyunbaoerp_single.py 的 ocr_captcha() 的做法)。
func filterAlnum(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
}
}
return b.String()
}
// CheckSession 用 GET /am/user/get?id=<userID> 校验当前 Cookie 代表的
// 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5:
// 不一致说明串号了,同样按未登录处理)。