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:
@@ -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:
|
||||
// 不一致说明串号了,同样按未登录处理)。
|
||||
|
||||
Reference in New Issue
Block a user