Files
QiuSWandClaude Opus 5 d257752441 feat: 货憨憨登录与认证复用 (#7)
由 Codex (gpt-5.6-sol) 实施,Claude 审核。

internal/huohanhan/auth.go
- 六步登录:取 CID/CST 作 Basic 认证 → getCltConf 取 clientId →
  下载验证码 → OCR 识别 → 提交登录 → app-version/info 在线验证
- OCR 响应递归查找 4~8 位验证码,优先 text/result/data/content/captcha/code
- 错误分支:invalid_verify_code 换图重试(上限可配,默认 3);
  invalid_credentials 与 disabled_credentials 直接报中文错误不重试
- 认证复用改用 SQLite kv 表 + sync.Mutex,不照搬 Python 版的 Redis
- GetValidAuth 按「内存 → SQLite 在线验证 → 重新登录」顺序取认证

internal/huohanhan/client.go
- 统一请求方法,自动带 Authorization 与 cookies
- 认证失败时清状态、重登、最多重试一次;超时、500、普通业务错误不重试

app.go
- 新增 TestHuohanhanLogin,供设置页「测试连接」调用

测试全部使用 httptest 假服务,不需要真实账号即可运行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-02 16:45:02 +08:00

169 lines
5.6 KiB
Go

package huohanhan
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"cmsp/internal/logx"
)
func Test业务请求401后自动重登并只重试一次(t *testing.T) {
backend := newFakeLoginBackend(t)
logger := logx.New(100)
manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3)
if _, err := manager.ForceLogin(context.Background()); err != nil {
t.Fatalf("准备初始认证失败:%v", err)
}
businessCalls := 0
backend.businessHandler = func(w http.ResponseWriter, r *http.Request) {
businessCalls++
if r.URL.Path != "/api/product/list" {
t.Errorf("业务路径期望 /api/product/list,实际 %s", r.URL.Path)
}
assertBusinessAuth(t, r)
if businessCalls == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"ok": true})
}
client, err := NewClient(manager.cfg, manager, logger, backend.server.Client())
if err != nil {
t.Fatalf("创建客户端失败:%v", err)
}
response, err := client.Request(context.Background(), http.MethodPost, "product/list", []byte("page=1"), "application/x-www-form-urlencoded")
if err != nil {
t.Fatalf("401 后重登重试应成功,实际错误:%v", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("重试响应内容不正确:%s", body)
}
if businessCalls != 2 {
t.Fatalf("业务请求应执行 2 次,实际 %d 次", businessCalls)
}
if backend.loginCount != 2 {
t.Fatalf("初始登录加失效重登应共 2 次,实际 %d 次", backend.loginCount)
}
}
func Test业务请求连续401不会无限重试(t *testing.T) {
backend := newFakeLoginBackend(t)
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
if _, err := manager.ForceLogin(context.Background()); err != nil {
t.Fatalf("准备初始认证失败:%v", err)
}
businessCalls := 0
backend.businessHandler = func(w http.ResponseWriter, r *http.Request) {
businessCalls++
w.WriteHeader(http.StatusUnauthorized)
}
client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client())
if err != nil {
t.Fatalf("创建客户端失败:%v", err)
}
_, err = client.Request(context.Background(), http.MethodGet, "always-unauthorized", nil, "")
if err == nil || !strings.Contains(err.Error(), "重新登录后认证仍然失效") {
t.Fatalf("第二次 401 应停止并返回可读错误,实际 %v", err)
}
if businessCalls != 2 {
t.Fatalf("同一请求最多执行 2 次,实际 %d 次", businessCalls)
}
if backend.loginCount != 2 {
t.Fatalf("只应额外重登 1 次,实际总登录 %d 次", backend.loginCount)
}
}
func Test认证失败业务码触发一次重登(t *testing.T) {
backend := newFakeLoginBackend(t)
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
if _, err := manager.ForceLogin(context.Background()); err != nil {
t.Fatalf("准备初始认证失败:%v", err)
}
businessCalls := 0
backend.businessHandler = func(w http.ResponseWriter, r *http.Request) {
businessCalls++
if businessCalls == 1 {
writeJSON(w, map[string]any{"code": "invalid_token_expired"})
return
}
writeJSON(w, map[string]any{"data": "ok"})
}
client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client())
if err != nil {
t.Fatalf("创建客户端失败:%v", err)
}
response, err := client.Request(context.Background(), http.MethodGet, "auth-code", nil, "")
if err != nil {
t.Fatalf("认证失败业务码后应重试成功,实际错误:%v", err)
}
response.Body.Close()
if businessCalls != 2 || backend.loginCount != 2 {
t.Fatalf("应请求 2 次且总登录 2 次,实际请求 %d 次、登录 %d 次", businessCalls, backend.loginCount)
}
}
func TestHTTP500和普通业务错误不重试(t *testing.T) {
cases := []struct {
name string
statusCode int
body any
wantError bool
}{
{"HTTP 500", http.StatusInternalServerError, map[string]any{"message": "fake failure"}, true},
{"普通业务错误", http.StatusOK, map[string]any{"code": "product_not_found"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
backend := newFakeLoginBackend(t)
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
if _, err := manager.ForceLogin(context.Background()); err != nil {
t.Fatalf("准备初始认证失败:%v", err)
}
calls := 0
backend.businessHandler = func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(tc.statusCode)
_ = json.NewEncoder(w).Encode(tc.body)
}
client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client())
if err != nil {
t.Fatalf("创建客户端失败:%v", err)
}
response, requestErr := client.Request(context.Background(), http.MethodGet, "ordinary-error", nil, "")
if tc.wantError && requestErr == nil {
t.Fatalf("期望返回错误,实际成功")
}
if !tc.wantError && requestErr != nil {
t.Fatalf("普通业务响应应交给调用方处理,实际错误:%v", requestErr)
}
if response != nil {
response.Body.Close()
}
if calls != 1 {
t.Fatalf("非认证错误不能重试,实际请求 %d 次", calls)
}
})
}
}
func assertBusinessAuth(t *testing.T, r *http.Request) {
t.Helper()
if got := r.Header.Get("Authorization"); got != "Bearer "+testToken {
t.Errorf("Authorization 不正确,实际 %q", got)
}
cookie, err := r.Cookie("auth-session")
if err != nil || cookie.Value != testCookie {
t.Errorf("业务请求应携带登录 cookie,实际 cookie=%v err=%v", cookie, err)
}
}