Files
cmautobuy/admin/syb/ocr.go
T
chengmaandClaude Opus 5 2e686b17a4 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>
2026-08-09 12:35:02 +08:00

130 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 验证码自动识别(工单 #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
}