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