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
+129
View File
@@ -0,0 +1,129 @@
// 验证码自动识别(工单 #47)。
//
// `[必须]` 这是独立于顺运宝会话的一次 HTTP 调用,见
// docs/admin/08-顺运宝接口.md「验证码自动识别」一节:
// - 用**独立的** http.Client,不带顺运宝的 Cookie Jar,
// 避免把 ERP 会话泄漏给另一个服务;
// - 独立超时(默认 10 秒),不和顺运宝的超时共用;
// - 验证码图片全程在内存里传字节,不落盘。
package syb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
)
// DefaultOcrTimeout 是 OCR 请求的默认超时。
//
// `[建议]` 08 文档:实测耗时约 1.4 秒,10 秒留足够余量,
// 又不至于在服务真的挂了的时候把登录流程拖太久。
const DefaultOcrTimeout = 10 * time.Second
// OcrClient 调用托管的验证码识别服务(形如 https://ocr.ilapage.cn/ocr)。
//
// `[必须]` 它和 Client(顺运宝会话客户端)**没有任何共享状态**——
// 没有共用 Cookie Jar,是两个完全独立的 http.Client,理由见包注释。
type OcrClient struct {
url string
http *http.Client
}
// NewOcrClient 创建一个 OCR 客户端。
//
// `[必须]` url 留空是配置错误(调用方应该在 url 为空时直接跳过 OCR、
// 走手工输入,不应该走到这里来),所以这里返回 error 而不是静默禁用。
func NewOcrClient(url string, timeout time.Duration) (*OcrClient, error) {
url = strings.TrimSpace(url)
if url == "" {
return nil, fmt.Errorf("OCR 地址不能为空")
}
if timeout <= 0 {
timeout = DefaultOcrTimeout
}
return &OcrClient{
url: url,
// `[必须]` 独立的 http.Client,不传 Jar——不带顺运宝 Cookie。
http: &http.Client{Timeout: timeout},
}, nil
}
// ocrResponse 是 OCR 服务的响应形状(工单 #47 实测):
//
// {"code":200,"message":"Success","data":"kycv"} 识别成功
// {"code":200,"message":"Success","data":""} 识别失败(不是错误码!)
type ocrResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data string `json:"data"`
}
// Recognize 把一张验证码图片发给 OCR 服务,返回识别出的文字。
//
// `[必须]` 返回 (data, nil) 表示"成功拿到了一个可信的响应"——data 本身
// 仍然可能是空字符串,那是"没识别出来",**不是错误**(实测:无文字图片
// 探测得到 code:200 + data:"")。调用方要另外校验 data 非空、长度是否
// 符合预期,本函数不做这层业务校验,只负责"这次 HTTP 调用有没有问题"。
//
// 返回非 nil error 表示 OCR 服务本身有问题(连不上、超时、返回非法
// JSON、code != 200),调用方应该把这类情况当成"服务不可用"处理,
// 立即降级到手工,而不是当作"这一张图识别失败"继续重试——重试对
// "服务本身连不上"这种情况没有意义。
func (o *OcrClient) Recognize(ctx context.Context, image []byte) (string, error) {
if o == nil {
return "", fmt.Errorf("OCR 客户端未初始化")
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", "captcha.jpg")
if err != nil {
return "", fmt.Errorf("构造 OCR 请求失败: %w", err)
}
if _, err := part.Write(image); err != nil {
return "", fmt.Errorf("构造 OCR 请求失败: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("构造 OCR 请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.url, &buf)
if err != nil {
return "", fmt.Errorf("构造 OCR 请求失败: %w", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := o.http.Do(req)
if err != nil {
return "", fmt.Errorf("请求 OCR 服务失败(网络问题): %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 OCR 服务响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("OCR 服务返回意外状态码 %d", resp.StatusCode)
}
var payload ocrResponse
if err := json.Unmarshal(raw, &payload); err != nil {
return "", fmt.Errorf("OCR 服务响应不是合法 JSON: %w", err)
}
// `[必须]` 只看 code == 200 才算这次调用成功,msg 只用来拼错误信息。
if payload.Code != 200 {
return "", fmt.Errorf("OCR 服务返回业务失败: code=%d message=%s", payload.Code, orDefault(payload.Message, "(无)"))
}
// data 可能是空字符串——那是"没识别出来",不是这里的错误,
// 交给调用方(LoginWithOCR)按长度校验。
return payload.Data, nil
}