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
+251
View File
@@ -6,9 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -421,3 +424,251 @@ func TestClient_业务失败但不是登录问题时返回普通错误(t *testin
t.Errorf("错误信息应该带上服务端的 msg,实际: %v", err)
}
}
// ── LoginWithOCR:验证码自动识别登录(工单 #47) ──────────────
//
// `[必须]` 全部用 httptest 起假的顺运宝服务端和假的 OCR 服务端,
// 绝不能打真实的 shunyunbaoerp.com 或 ocr.ilapage.cn。
// loginRecorder 记录每一次提交给 /am/auth/login 的验证码文本。
//
// `[必须]` 光断言"重试了几次"证明不了"不合格的验证码没被拿去登录"——
// 变异测试(把长度校验改成 if false)在只看重试次数的断言下依然能
// 全绿通过,因为"拿不合格的码登录失败→触发重试"和"校验不通过→
// 直接换图重试"从外部看调用次数是一样的。必须实际记下提交了什么码,
// 才能证明"ab"、""这类不合格的码**从来没有被提交过**。
type loginRecorder struct {
mu sync.Mutex
codes []string
}
func (r *loginRecorder) record(code string) {
r.mu.Lock()
defer r.mu.Unlock()
r.codes = append(r.codes, code)
}
func (r *loginRecorder) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.codes))
copy(out, r.codes)
return out
}
// fakeSybServer 起一个假顺运宝服务端:验证码接口每次返回一张"新图"
// (用递增的字节内容区分,方便断言"每次重试都取了新图"),登录接口
// 按 loginCheck 决定成功还是失败,并把每次提交的验证码记进
// loginRecorder,供测试断言"不合格的码有没有被拿去登录"。
func fakeSybServer(t *testing.T, loginCheck func(code string) bool) (*httptest.Server, *int32, *loginRecorder) {
t.Helper()
var captchaCalls int32
rec := &loginRecorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/p/code1":
n := atomic.AddInt32(&captchaCalls, 1)
w.Header().Set("Content-Type", "image/jpeg")
w.Write([]byte(fmt.Sprintf("fake-jpeg-%d", n)))
case "/am/auth/login":
var body struct {
Code string `json:"code"`
}
raw, _ := io.ReadAll(r.Body)
json.Unmarshal(raw, &body)
rec.record(body.Code)
if loginCheck(body.Code) {
w.Write(envelopeBody(t, true, "登录成功", map[string]any{
"user": map[string]any{"id": 1001, "username": "tester"},
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
}, nil))
} else {
w.Write(envelopeBody(t, false, "验证码错误", nil, "1"))
}
}
}))
return srv, &captchaCalls, rec
}
// fakeOcrServer 起一个假 OCR 服务端:每次调用按顺序返回 responses 里的
// 下一个 data;responses 用完后重复最后一个。
func fakeOcrServer(t *testing.T, responses ...string) (*httptest.Server, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := int(atomic.AddInt32(&calls, 1)) - 1
if n >= len(responses) {
n = len(responses) - 1
}
w.Write(ocrBody(200, "Success", responses[n]))
}))
return srv, &calls
}
func TestClient_LoginWithOCR_识别成功一次就登录(t *testing.T) {
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return code == "kycv" })
defer sybSrv.Close()
ocrSrv, ocrCalls := fakeOcrServer(t, "kycv")
defer ocrSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
if reason != "" {
t.Fatalf("应该自动登录成功,不应该降级,实际 reason=%q", reason)
}
if result == nil || result.User.Username != "tester" {
t.Fatalf("登录结果不对: %+v", result)
}
if atomic.LoadInt32(captchaCalls) != 1 || atomic.LoadInt32(ocrCalls) != 1 {
t.Errorf("一次成功不应该重试,captcha=%d ocr=%d", *captchaCalls, *ocrCalls)
}
}
func TestClient_LoginWithOCR_data为空判定失败并重新取图重试(t *testing.T) {
// `[必须]` loginCheck 故意让空字符串也能"成功"——如果长度校验被
// 意外禁用/删掉,空字符串会被直接拿去登录并成功,captchaCalls 和
// reason 这两个断言就会跟着变绿,测试就抓不到这个回归。真正能
// 抓住回归的是下面对 rec.snapshot() 的断言:不管登录接口对空码
// 判不判定成功,只要空码被"提交"过一次,就说明校验没生效。
sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true })
defer sybSrv.Close()
// 第一次 data 为空(识别失败,不是错误),第二次识别出 4 位有效码。
ocrSrv, ocrCalls := fakeOcrServer(t, "", "wxyz")
defer ocrSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
if reason != "" {
t.Fatalf("第二次应该识别成功登录,不应该降级,实际 reason=%q", reason)
}
if result == nil {
t.Fatal("登录结果不应该为空")
}
if atomic.LoadInt32(captchaCalls) != 2 {
t.Errorf("data 为空应该重新取验证码图再试一次,captcha 调用次数应该是 2,实际 %d", *captchaCalls)
}
if atomic.LoadInt32(ocrCalls) != 2 {
t.Errorf("应该调用 OCR 两次,实际 %d", *ocrCalls)
}
codes := rec.snapshot()
if len(codes) != 1 {
t.Fatalf("空 data 不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes)
}
if codes[0] != "wxyz" {
t.Errorf("唯一一次登录提交的应该是识别成功的 wxyz,实际 %q(说明空字符串被拿去登录了)", codes[0])
}
}
func TestClient_LoginWithOCR_长度不对判定失败并重试(t *testing.T) {
// `[必须]` loginCheck 故意让任何码都能"成功",理由同上一个测试:
// 真正能抓住"长度校验被删掉"这个回归的是 rec.snapshot() 断言,
// 不是重试次数或最终结果。
sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true })
defer sybSrv.Close()
// 第一次只识别出 2 位,不是合法的 4 位验证码,应该换图重试。
ocrSrv, _ := fakeOcrServer(t, "ab", "wxyz")
defer ocrSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
if reason != "" {
t.Fatalf("第二次应该识别成功登录,实际 reason=%q", reason)
}
if result == nil {
t.Fatal("登录结果不应该为空")
}
if atomic.LoadInt32(captchaCalls) != 2 {
t.Errorf("长度不对应该重新取图重试,captcha 调用次数应该是 2,实际 %d", *captchaCalls)
}
codes := rec.snapshot()
if len(codes) != 1 {
t.Fatalf("长度不对(\"ab\")不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes)
}
if codes[0] != "wxyz" {
t.Errorf("唯一一次登录提交的应该是 4 位的 wxyz,实际 %q(说明 \"ab\" 被拿去登录了)", codes[0])
}
}
func TestClient_LoginWithOCR_过滤空格和标点后再判长度(t *testing.T) {
sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return code == "ab12" })
defer sybSrv.Close()
// OCR 带回空格和标点,过滤后应该恰好是 4 位 "ab12"。
ocrSrv, _ := fakeOcrServer(t, " a-b1 2.")
defer ocrSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
if reason != "" {
t.Fatalf("过滤空格标点后应该识别成 4 位并登录成功,实际 reason=%q", reason)
}
if result == nil {
t.Fatal("登录结果不应该为空")
}
}
func TestClient_LoginWithOCR_达到重试上限后降级说明次数(t *testing.T) {
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return false })
defer sybSrv.Close()
// 每次都识别出 4 位,但登录一直失败(模拟验证码一直识别错)。
ocrSrv, _ := fakeOcrServer(t, "aaaa")
defer ocrSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 3)
if result != nil {
t.Fatal("登录应该一直失败,不应该有结果")
}
if !strings.Contains(reason, "已尝试 3 次") {
t.Errorf("降级说明应该带上已尝试次数,实际: %q", reason)
}
if atomic.LoadInt32(captchaCalls) != 3 {
t.Errorf("应该恰好重试 3 次(=maxAttempts),实际 captcha 调用 %d 次", *captchaCalls)
}
}
func TestClient_LoginWithOCR_OCR不可达立即降级不占满重试次数(t *testing.T) {
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return true })
defer sybSrv.Close()
// 服务地址存在但已关闭:连不上。
deadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadSrv.Close()
c, _ := New(sybSrv.URL)
ocr, _ := NewOcrClient(deadSrv.URL, 500*time.Millisecond)
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
if result != nil {
t.Fatal("OCR 不可达不应该登录成功")
}
if !strings.Contains(reason, "不可用") {
t.Errorf("降级说明应该提示服务不可用,实际: %q", reason)
}
if atomic.LoadInt32(captchaCalls) != 1 {
t.Errorf("OCR 不可达应该立即降级,不应该重试 maxAttempts 次,实际取了 %d 次验证码图", *captchaCalls)
}
}
func TestClient_LoginWithOCR_ocr未配置直接降级(t *testing.T) {
sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return true })
defer sybSrv.Close()
c, _ := New(sybSrv.URL)
result, reason := c.LoginWithOCR(context.Background(), nil, "tester", "pw", 5)
if result != nil {
t.Fatal("ocr 为 nil 时不应该登录成功")
}
if reason == "" {
t.Fatal("ocr 为 nil 时应该给出降级原因")
}
}