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
|
|||
|
|
}
|