fix(t236): retry rejected ERP captchas
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
+8
-2
@@ -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=<user.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=<n> max_attempts=7 result=<固定分类>`,不包含凭证或 ERP 原始响应。
|
||||
|
||||
会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。
|
||||
|
||||
|
||||
@@ -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 分钟所在自然日回看。
|
||||
|
||||
@@ -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` 是协议错误。
|
||||
真实线上请求不属于自动化测试。
|
||||
|
||||
## 身份和规范字段
|
||||
|
||||
|
||||
+10
-7
@@ -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 构建均通过。
|
||||
|
||||
Reference in New Issue
Block a user