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:
@@ -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 时应该给出降级原因")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user