Files
cmautobuy/admin/syb/ocr.go
T

130 lines
4.6 KiB
Go
Raw Normal View History

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