From cf83fb50e4838c9eb2d35a65f460994dc1d5fa22 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 11:54:06 +0800 Subject: [PATCH] fix(t236): retry rejected ERP captchas --- .../internal/platform/shunyunbao/session.go | 60 ++++++- .../platform/shunyunbao/session_test.go | 156 ++++++++++++++++++ docs/api.md | 10 +- docs/current-state.md | 4 +- docs/integrations/shunyunbao-contract.md | 13 +- docs/tasks/T-236.md | 17 +- 6 files changed, 237 insertions(+), 23 deletions(-) diff --git a/backend-api/internal/platform/shunyunbao/session.go b/backend-api/internal/platform/shunyunbao/session.go index 5a6a460..bf30c83 100644 --- a/backend-api/internal/platform/shunyunbao/session.go +++ b/backend-api/internal/platform/shunyunbao/session.go @@ -23,6 +23,9 @@ import ( const ( defaultSessionTimeout = 30 * time.Second defaultCaptchaTTL = 5 * time.Minute + authenticationBudget = 20 * time.Second + maxCaptchaRetries = 6 + maxCaptchaAttempts = maxCaptchaRetries + 1 maxCaptchaBytes = 2 << 20 maxERPResponseBytes = 4 << 20 maxDiagnosticBytes = 4 << 10 @@ -30,6 +33,7 @@ const ( var ( ErrCaptchaTicketInvalid = errors.New("ERP captcha ticket is invalid") + ErrCaptchaRejected = errors.New("ERP captcha was rejected") ErrLoginRejected = errors.New("ERP login was rejected") ) @@ -143,7 +147,9 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) { func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error { manager.authMu.Lock() defer manager.authMu.Unlock() - if _, err := manager.Validate(ctx); err == nil { + authCtx, cancel := context.WithTimeout(ctx, authenticationBudget) + defer cancel() + if _, err := manager.Validate(authCtx); err == nil { return nil } else if !errors.Is(err, domain.ErrFreightSourceSessionNeeded) { return err @@ -151,6 +157,30 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error { if manager.recognizer == nil { return domain.ErrFreightSourceOCRInvalid } + for attempt := 1; attempt <= maxCaptchaAttempts; attempt++ { + err := manager.authenticateOnce(authCtx) + switch { + case err == nil: + manager.logERPLoginAttempt(attempt, "success") + return nil + case errors.Is(err, ErrCaptchaRejected): + manager.logERPLoginAttempt(attempt, "captcha_rejected") + if attempt < maxCaptchaAttempts { + continue + } + return domain.ErrFreightSourceLoginRejected + case errors.Is(err, ErrLoginRejected): + manager.logERPLoginAttempt(attempt, "login_rejected") + return domain.ErrFreightSourceLoginRejected + default: + manager.logERPLoginAttempt(attempt, "failed") + return err + } + } + return domain.ErrFreightSourceLoginRejected +} + +func (manager *SessionManager) authenticateOnce(ctx context.Context) error { status, err := manager.FetchCaptcha(ctx) if err != nil { return err @@ -171,9 +201,6 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error { } manager.logOCRResult(code) _, err = manager.Login(ctx, status.CaptchaTicket, code) - if errors.Is(err, ErrLoginRejected) { - return domain.ErrFreightSourceLoginRejected - } return err } @@ -393,9 +420,10 @@ func (manager *SessionManager) requestJSONLocked( } manager.logERPResponse(request, response, contentBytes, false) var envelope struct { - Status *bool `json:"status"` - Code json.RawMessage `json:"code"` - Data json.RawMessage `json:"data"` + Status *bool `json:"status"` + Code json.RawMessage `json:"code"` + Data json.RawMessage `json:"data"` + Message *string `json:"msg"` } decoder := json.NewDecoder(bytes.NewReader(contentBytes)) if err := decoder.Decode(&envelope); err != nil || envelope.Status == nil || @@ -408,6 +436,9 @@ func (manager *SessionManager) requestJSONLocked( } if !*envelope.Status { if loginRequest { + if envelope.Message != nil && captchaRejectedMessage(*envelope.Message) { + return nil, ErrCaptchaRejected + } return nil, ErrLoginRejected } if unauthenticatedCode(envelope.Code) { @@ -428,6 +459,10 @@ func (manager *SessionManager) requestJSONLocked( return data, nil } +func captchaRejectedMessage(message string) bool { + return strings.TrimSpace(message) == "图片验证码不正确" +} + func unauthenticatedCode(raw json.RawMessage) bool { var value any decoder := json.NewDecoder(bytes.NewReader(raw)) @@ -538,6 +573,17 @@ func (manager *SessionManager) logOCRResultFailed() { manager.diagnosticLog("erp_ocr_result class=failed") } +func (manager *SessionManager) logERPLoginAttempt(attempt int, result string) { + if !manager.diagnosticsOn { + return + } + manager.diagnosticLog( + "erp_login_attempt attempt=" + strconv.Itoa(attempt) + + " max_attempts=" + strconv.Itoa(maxCaptchaAttempts) + + " result=" + result, + ) +} + func (manager *SessionManager) logERPResponsePreview( request *http.Request, response *http.Response, diff --git a/backend-api/internal/platform/shunyunbao/session_test.go b/backend-api/internal/platform/shunyunbao/session_test.go index e9f45dd..fa755f7 100644 --- a/backend-api/internal/platform/shunyunbao/session_test.go +++ b/backend-api/internal/platform/shunyunbao/session_test.go @@ -259,6 +259,162 @@ func TestSessionManagerEnsureAuthenticatedUsesRecognizerOnce(t *testing.T) { } } +func TestSessionManagerEnsureAuthenticatedRetriesCaptchaRejectionSixTimes(t *testing.T) { + var captchaCalls, loginCalls, userCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CaptchaPath: + captchaCalls++ + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("captcha-image")) + case LoginPath: + loginCalls++ + w.Header().Set("Content-Type", "application/json") + if loginCalls <= maxCaptchaRetries { + _, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"图片验证码不正确"}`)) + return + } + _, _ = w.Write([]byte(`{"status":true,"data":{"user":{"id":12,"username":"test-user"}}}`)) + case UserPath: + userCalls++ + _, _ = w.Write([]byte(`{"status":true,"data":{"id":12,"username":"test-user"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + var events []string + recognizer := &fixedRecognizer{code: "1234"} + manager, err := NewSessionManager(SessionConfig{ + BaseURL: server.URL, + Username: "test-user", + Password: "test-password", + Timeout: time.Second, + AllowInsecureHTTP: true, + CaptchaRecognizer: recognizer, + DiagnosticLogger: func(event string) { + events = append(events, event) + }, + }) + if err != nil { + t.Fatalf("NewSessionManager() error = %v", err) + } + if err := manager.EnsureAuthenticated(context.Background()); err != nil { + t.Fatalf("EnsureAuthenticated() error = %v", err) + } + if captchaCalls != maxCaptchaAttempts || recognizer.calls != maxCaptchaAttempts || + loginCalls != maxCaptchaAttempts || userCalls != 1 { + t.Fatalf( + "captcha/OCR/login/user calls = %d/%d/%d/%d", + captchaCalls, + recognizer.calls, + loginCalls, + userCalls, + ) + } + actual := strings.Join(events, "\n") + for _, expected := range []string{ + "erp_login_attempt attempt=1 max_attempts=7 result=captcha_rejected", + "erp_login_attempt attempt=6 max_attempts=7 result=captcha_rejected", + "erp_login_attempt attempt=7 max_attempts=7 result=success", + } { + if !strings.Contains(actual, expected) { + t.Fatalf("diagnostic log missing %q: %s", expected, actual) + } + } + for _, secret := range []string{"test-user", "test-password"} { + if strings.Contains(actual, secret) { + t.Fatalf("diagnostic log leaked %q: %s", secret, actual) + } + } +} + +func TestSessionManagerEnsureAuthenticatedStopsAfterCaptchaRetries(t *testing.T) { + var captchaCalls, loginCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CaptchaPath: + captchaCalls++ + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("captcha-image")) + case LoginPath: + loginCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"图片验证码不正确"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + recognizer := &fixedRecognizer{code: "1234"} + manager := testSessionManager(t, server.URL, "test-user", "test-password") + manager.recognizer = recognizer + err := manager.EnsureAuthenticated(context.Background()) + if !errors.Is(err, domain.ErrFreightSourceLoginRejected) { + t.Fatalf("EnsureAuthenticated() error = %v", err) + } + if captchaCalls != maxCaptchaAttempts || recognizer.calls != maxCaptchaAttempts || + loginCalls != maxCaptchaAttempts { + t.Fatalf( + "captcha/OCR/login calls = %d/%d/%d", + captchaCalls, + recognizer.calls, + loginCalls, + ) + } +} + +func TestSessionManagerEnsureAuthenticatedDoesNotRetryOtherLoginRejections(t *testing.T) { + var captchaCalls, loginCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CaptchaPath: + captchaCalls++ + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("captcha-image")) + case LoginPath: + loginCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"账号或密码错误"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + recognizer := &fixedRecognizer{code: "1234"} + manager := testSessionManager(t, server.URL, "test-user", "test-password") + manager.recognizer = recognizer + err := manager.EnsureAuthenticated(context.Background()) + if !errors.Is(err, domain.ErrFreightSourceLoginRejected) { + t.Fatalf("EnsureAuthenticated() error = %v", err) + } + if captchaCalls != 1 || recognizer.calls != 1 || loginCalls != 1 { + t.Fatalf( + "captcha/OCR/login calls = %d/%d/%d", + captchaCalls, + recognizer.calls, + loginCalls, + ) + } +} + +func TestCaptchaRejectedMessageIsExact(t *testing.T) { + for _, testCase := range []struct { + message string + want bool + }{ + {message: "图片验证码不正确", want: true}, + {message: " 图片验证码不正确 ", want: true}, + {message: "验证码不正确", want: false}, + {message: "账号或密码错误", want: false}, + {message: "", want: false}, + } { + if actual := captchaRejectedMessage(testCase.message); actual != testCase.want { + t.Fatalf("captchaRejectedMessage(%q) = %t, want %t", testCase.message, actual, testCase.want) + } + } +} + func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) { manager := testSessionManager(t, "https://erp.example.test", "test-user", "test-password") err := manager.EnsureAuthenticated(context.Background()) diff --git a/docs/api.md b/docs/api.md index dc93c44..da57606 100644 --- a/docs/api.md +++ b/docs/api.md @@ -198,7 +198,7 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始 对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。 -### Go ERP 会话(T-230) +### Go ERP 会话(T-230、T-236) `/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步 前,单一 API 进程在未认证时以同一 Cookie jar 获取验证码、调用受控 OCR、登录并校验用户。 @@ -207,6 +207,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 `GET /am/user/get?id=`;返回身份必须一致。只有 HTTP 401/403 或 ERP code `-2` 被视为 会话失效,其他 `status=false` 返回 `ERP_RESPONSE_INVALID`。 +ERP 登录明确返回 `status=false`、`msg=图片验证码不正确` 时,首次失败后最多额外重试 6 次, +单次认证最多登录 7 次;每次都重新获取验证码图片并重新调用 OCR。认证总预算为 20 秒,调用方 +deadline 更短时以调用方为准。账号密码错误、其他登录拒绝、OCR 异常、网络错误和协议错误均不 +重试;次数耗尽返回 `ERP_LOGIN_REJECTED`。 + ERP 配置来源: - `CMROUBAO_SHUNYUNBAO_URL`:默认 `https://www.shunyunbaoerp.com`;只接受无路径、 @@ -230,7 +235,8 @@ ERP 配置来源: `false` 并重启 API。Windows 可使用根目录 `start-backend.bat --erp-debug` 仅为本次 API 进程覆盖开启;也可与 `--migrate` 组合。该模式额外在 ERP 登录前输出单行 `erp_ocr_result value="..." length=...`,值只限本次有效 OCR 结果;OCR 失败或无效时只输出 - `class=failed` 或 `class=invalid`,不输出原文。 + `class=failed` 或 `class=invalid`,不输出原文。每次登录另输出 + `erp_login_attempt attempt= max_attempts=7 result=<固定分类>`,不包含凭证或 ERP 原始响应。 会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。 diff --git a/docs/current-state.md b/docs/current-state.md index 0ff3d1b..513b8d0 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-236 已规划顺运宝验证码错误有限重试,待实现 +- 阶段:T-236 已完成顺运宝验证码错误有限重试 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -25,7 +25,7 @@ 文字并建立内存会话,人工 `/erp` 模块已删除。T-227 已将其作为 `FreightSource`, 查询先校验会话、再执行有界分页/详情 批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key; - T-236 计划在 ERP 明确拒绝验证码时额外重试 6 次,每次重新取图和 OCR,单次认证最多 + T-236 在 ERP 明确拒绝验证码时额外重试 6 次,每次重新取图和 OCR,单次认证最多 7 次登录并受 20 秒总预算限制;不对账号密码、OCR、网络或协议错误重试。未访问真实 ERP。 - ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗 最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。 diff --git a/docs/integrations/shunyunbao-contract.md b/docs/integrations/shunyunbao-contract.md index 1d36441..3d1449f 100644 --- a/docs/integrations/shunyunbao-contract.md +++ b/docs/integrations/shunyunbao-contract.md @@ -39,11 +39,14 @@ T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture - 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值, 测试断言它们不会出现在输出或错误里。 -Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。T-230 在未认证时仅可将 -验证码图片一次提交给受控本机/HTTPS OCR endpoint,结果只用于当前登录请求;不得写入 Redis、 -SQLite、普通日志或浏览器,也不得轮询或重试。T-234 仅允许显式 diagnostics 进程短时输出有效 -OCR 文本;T-235 仅在受锁内存中保存已核验 user id/username,不保存登录 token。HTTP 401/403 -或 ERP code `-2` 才代表会话失效;其他 `status=false` 是协议错误。真实线上请求不属于自动化测试。 +Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。T-230 在未认证时只可将 +验证码图片提交给受控本机/HTTPS OCR endpoint,结果只用于当前登录请求;不得写入 Redis、 +SQLite、普通日志或浏览器。T-236 仅在 ERP 登录精确返回“图片验证码不正确”时,在首次失败后 +额外重试最多 6 次;每次重新获取验证码和调用 OCR,最多 7 次登录且认证总预算为 20 秒。其他 +登录拒绝、OCR、网络或协议错误不重试。T-234 仅允许显式 diagnostics 进程短时输出有效 OCR +文本和固定登录尝试分类;T-235 仅在受锁内存中保存已核验 user id/username,不保存登录 token。 +HTTP 401/403 或 ERP code `-2` 才代表会话失效;其他非登录接口 `status=false` 是协议错误。 +真实线上请求不属于自动化测试。 ## 身份和规范字段 diff --git a/docs/tasks/T-236.md b/docs/tasks/T-236.md index b4a9a71..65526df 100644 --- a/docs/tasks/T-236.md +++ b/docs/tasks/T-236.md @@ -4,7 +4,7 @@ title: 顺运宝验证码错误有限重试 phase: 2 deps: - T-235 -status: PLANNED +status: DONE created: 2026-07-29 context_ref: 81f75cc work_branch: null @@ -37,12 +37,12 @@ write_paths: ## 验收要点 -- [ ] 前 6 次验证码错误、第 7 次成功时认证成功,验证码获取、OCR 和登录均各执行 7 次。 -- [ ] 连续 7 次验证码错误后返回稳定的 `ERP_LOGIN_REJECTED`,不执行第 8 次。 -- [ ] 非验证码登录拒绝、OCR/网络/协议错误不触发重试。 -- [ ] 重试期间每次使用新验证码,且整个认证不突破 20 秒或调用方 deadline。 -- [ ] diagnostics 可定位尝试次数,但不泄露验证码、凭证、Cookie、token 或响应正文。 -- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 +- [x] 前 6 次验证码错误、第 7 次成功时认证成功,验证码获取、OCR 和登录均各执行 7 次。 +- [x] 连续 7 次验证码错误后返回稳定的 `ERP_LOGIN_REJECTED`,不执行第 8 次。 +- [x] 非验证码登录拒绝、OCR/网络/协议错误不触发重试。 +- [x] 重试期间每次使用新验证码,且整个认证不突破 20 秒或调用方 deadline。 +- [x] diagnostics 可定位尝试次数,但不泄露验证码、凭证、Cookie、token 或响应正文。 +- [x] 标准 Go 测试、race、vet 和三个入口构建通过。 ## 边界 @@ -54,3 +54,6 @@ write_paths: - 2026-07-29:创建任务。确认“重试 6 次”定义为首次失败后额外重试 6 次,最多 7 次登录; 仅对 ERP 明确返回的验证码错误执行全新验证码/OCR 尝试,并设置 20 秒总预算。 +- 2026-07-29:实现精确验证码拒绝分类和受锁有限认证循环。脱敏 fixture 覆盖第 7 次成功、 + 连续 7 次耗尽、其他登录拒绝不重试和诊断尝试序号;标准 Go 测试、race、vet 及 + api/migrate/authctl 构建均通过。