#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>
130 lines
4.6 KiB
Go
130 lines
4.6 KiB
Go
// 验证码自动识别(工单 #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
|
||
}
|