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>
This commit is contained in:
chengma
2026-08-09 12:35:02 +08:00
co-authored by Claude Opus 5
parent 5e426cacf6
commit 2e686b17a4
10 changed files with 890 additions and 23 deletions
+223
View File
@@ -0,0 +1,223 @@
package syb
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// `[必须]` 本文件全部用 httptest 起假 OCR 服务端,绝不能打真实的
// ocr.ilapage.cn——见工单 #47 和 admin/AGENTS.md。
func ocrBody(code int, message, data string) []byte {
b, _ := json.Marshal(map[string]any{"code": code, "message": message, "data": data})
return b
}
// ── 三种约定的假响应(工单 #47「怎么验证」逐条要求) ──────────────
func TestOcrClient_Recognize_识别成功(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(ocrBody(200, "Success", "kycv"))
}))
defer srv.Close()
ocr, err := NewOcrClient(srv.URL, time.Second)
if err != nil {
t.Fatalf("创建 OCR 客户端失败: %v", err)
}
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err != nil {
t.Fatalf("识别应该成功,实际报错: %v", err)
}
if data != "kycv" {
t.Errorf("识别结果应该是 kycv,实际 %q", data)
}
}
func TestOcrClient_Recognize_code200但data为空不是错误(t *testing.T) {
// `[必须]` 实测:无文字图片探测得到 code:200 + data:"",这不是
// HTTP/JSON 错误,Recognize 应该返回 (空字符串, nil),由调用方
// (LoginWithOCR)的长度校验去判定"这次没识别出来"。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(ocrBody(200, "Success", ""))
}))
defer srv.Close()
ocr, err := NewOcrClient(srv.URL, time.Second)
if err != nil {
t.Fatalf("创建 OCR 客户端失败: %v", err)
}
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err != nil {
t.Fatalf("code:200 + data:\"\" 不应该被当成错误,实际报错: %v", err)
}
if data != "" {
t.Errorf("期望空字符串,实际 %q", data)
}
}
func TestOcrClient_Recognize_长度不对时原样返回交给调用方校验(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(ocrBody(200, "Success", "ab"))
}))
defer srv.Close()
ocr, err := NewOcrClient(srv.URL, time.Second)
if err != nil {
t.Fatalf("创建 OCR 客户端失败: %v", err)
}
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err != nil {
t.Fatalf("HTTP 调用本身没问题,不应该报错,实际: %v", err)
}
if data != "ab" {
t.Errorf("Recognize 不做长度校验,应该原样返回,实际 %q", data)
}
}
// ── 请求内容:字段名 file,multipart 上传,不落盘 ──────────────
func TestOcrClient_Recognize_用multipart字段名file上传(t *testing.T) {
var gotFieldName string
var gotBytes []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("解析 multipart 表单失败: %v", err)
}
if r.MultipartForm != nil && len(r.MultipartForm.File) > 0 {
for name, files := range r.MultipartForm.File {
gotFieldName = name
if len(files) > 0 {
f, _ := files[0].Open()
gotBytes = make([]byte, files[0].Size)
f.Read(gotBytes)
f.Close()
}
}
}
w.Write(ocrBody(200, "Success", "abcd"))
}))
defer srv.Close()
ocr, _ := NewOcrClient(srv.URL, time.Second)
image := []byte("这是验证码图片的字节内容")
if _, err := ocr.Recognize(context.Background(), image); err != nil {
t.Fatalf("识别失败: %v", err)
}
if gotFieldName != "file" {
t.Errorf("multipart 字段名应该是 file,实际 %q", gotFieldName)
}
if string(gotBytes) != string(image) {
t.Errorf("上传的图片字节应该原样传到服务端,实际 %q", string(gotBytes))
}
}
// ── 降级路径:不可达 / 非 JSON / 非 200 ──────────────────────
func TestOcrClient_Recognize_服务不可达返回error(t *testing.T) {
// 关掉的服务器:地址存在但连不上。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close()
ocr, err := NewOcrClient(srv.URL, 500*time.Millisecond)
if err != nil {
t.Fatalf("创建 OCR 客户端失败: %v", err)
}
_, err = ocr.Recognize(context.Background(), []byte("fake-image"))
if err == nil {
t.Fatal("服务连不上应该返回 error")
}
}
func TestOcrClient_Recognize_非JSON响应返回error不panic(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html>不是 JSON</html>"))
}))
defer srv.Close()
ocr, _ := NewOcrClient(srv.URL, time.Second)
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err == nil {
t.Fatal("非 JSON 响应应该返回 error")
}
}
func TestOcrClient_Recognize_HTTP非200返回error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
}))
defer srv.Close()
ocr, _ := NewOcrClient(srv.URL, time.Second)
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err == nil {
t.Fatal("HTTP 500 应该返回 error")
}
}
func TestOcrClient_Recognize_业务code非200返回error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(ocrBody(500, "内部错误", ""))
}))
defer srv.Close()
ocr, _ := NewOcrClient(srv.URL, time.Second)
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
if err == nil {
t.Fatal("code != 200 应该返回 error")
}
}
func TestNewOcrClient_地址留空报错(t *testing.T) {
if _, err := NewOcrClient("", time.Second); err == nil {
t.Fatal("空地址应该报错——调用方应该在这之前就判断 ocr_url 是否留空并跳过 OCR")
}
if _, err := NewOcrClient(" ", time.Second); err == nil {
t.Fatal("空白地址同样应该报错")
}
}
// ── OCR 请求不带顺运宝 Cookie:独立 http.Client ──────────────
func TestOcrClient_不携带顺运宝的Cookie(t *testing.T) {
var gotCookie string
sybSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "should-not-leak", Path: "/"})
w.Header().Set("Content-Type", "image/jpeg")
w.Write([]byte("fake-jpeg"))
}))
defer sybSrv.Close()
ocrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie("erp_session"); err == nil {
gotCookie = ck.Value
}
w.Write(ocrBody(200, "Success", "abcd"))
}))
defer ocrSrv.Close()
sybClient, err := New(sybSrv.URL)
if err != nil {
t.Fatalf("创建顺运宝客户端失败: %v", err)
}
captcha, err := sybClient.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("获取验证码失败: %v", err)
}
ocrClient, err := NewOcrClient(ocrSrv.URL, time.Second)
if err != nil {
t.Fatalf("创建 OCR 客户端失败: %v", err)
}
if _, err := ocrClient.Recognize(context.Background(), captcha.Image); err != nil {
t.Fatalf("识别失败: %v", err)
}
if gotCookie != "" {
t.Fatalf("OCR 请求不应该带上顺运宝的 Cookie,实际带了 %q", gotCookie)
}
}