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

341 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package huohanhan
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"cmsp/internal/config"
"cmsp/internal/logx"
"cmsp/internal/store"
)
const (
testAccount = "13500000000"
testPassword = "test-password"
testToken = "test-token"
testCookie = "test-cookie"
)
type fakeLoginBackend struct {
t *testing.T
mu sync.Mutex
loginCodes []string
loginCount int
captchaCount int
validateCount int
captchaKeys []string
issuedToken string
businessHandler http.HandlerFunc
server *httptest.Server
}
func newFakeLoginBackend(t *testing.T, loginCodes ...string) *fakeLoginBackend {
t.Helper()
backend := &fakeLoginBackend{t: t, loginCodes: loginCodes, issuedToken: testToken}
backend.server = httptest.NewServer(http.HandlerFunc(backend.serveHTTP))
t.Cleanup(backend.server.Close)
return backend
}
func (b *fakeLoginBackend) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login":
http.SetCookie(w, &http.Cookie{Name: "login-session", Value: testCookie, Path: "/"})
_, _ = io.WriteString(w, `window.config={"CID":"test-cid", 'CST': 'test-cst'}`)
case "/api/butler/client/getCltConf":
if err := r.ParseForm(); err != nil {
b.t.Errorf("解析网站配置表单失败:%v", err)
}
serverURL, _ := url.Parse(b.server.URL)
if got := r.Form.Get("domain"); got != serverURL.Hostname() {
b.t.Errorf("domain 期望 %q,实际 %q", serverURL.Hostname(), got)
}
writeJSON(w, map[string]any{"id": 12345})
case "/api/butler/vrify/kaptcha":
b.mu.Lock()
b.captchaCount++
b.captchaKeys = append(b.captchaKeys, r.URL.Query().Get("kaptchaKey"))
b.mu.Unlock()
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("fake-image"))
case "/api/login":
b.handleLogin(w, r)
case "/api/butler/app-version/info":
b.mu.Lock()
b.validateCount++
b.mu.Unlock()
if err := r.ParseForm(); err != nil {
b.t.Errorf("解析认证校验表单失败:%v", err)
}
if r.Form.Get("appCode") != "HHH" {
b.t.Errorf("认证校验 appCode 期望 HHH,实际 %q", r.Form.Get("appCode"))
}
if r.Header.Get("Authorization") != "Bearer "+b.issuedToken {
w.WriteHeader(http.StatusUnauthorized)
return
}
if cookie, err := r.Cookie("auth-session"); err != nil || cookie.Value != testCookie {
b.t.Errorf("认证校验应携带登录 cookie,实际 cookie=%v err=%v", cookie, err)
}
writeJSON(w, map[string]any{"appCode": "HHH"})
default:
if b.businessHandler != nil {
b.businessHandler(w, r)
return
}
http.NotFound(w, r)
}
}
func (b *fakeLoginBackend) handleLogin(w http.ResponseWriter, r *http.Request) {
user, password, ok := r.BasicAuth()
if !ok || user != "test-cid" || password != "test-cst" {
b.t.Errorf("登录请求 Basic 认证不正确")
}
if err := r.ParseForm(); err != nil {
b.t.Errorf("解析登录表单失败:%v", err)
}
if r.Form.Get("username") != testAccount || r.Form.Get("password") != testPassword {
b.t.Errorf("登录表单账号或密码不正确")
}
if r.Form.Get("clientId") != "12345" || r.Form.Get("kaptchaCode") != "A1b2" {
b.t.Errorf("登录表单 clientId 或验证码不正确:%v", r.Form)
}
if r.Form.Get("kaptchaKey") == "" {
b.t.Errorf("登录表单缺少 kaptchaKey")
}
b.mu.Lock()
index := b.loginCount
b.loginCount++
code := ""
if index < len(b.loginCodes) {
code = b.loginCodes[index]
}
b.mu.Unlock()
if code != "" {
writeJSON(w, map[string]any{"code": code, "message": "fake login error"})
return
}
http.SetCookie(w, &http.Cookie{Name: "auth-session", Value: testCookie, Path: "/"})
writeJSON(w, map[string]any{
"access_token": b.issuedToken,
"token_type": "Bearer",
"expires_in": 3600,
"login_time": time.Now().UnixMilli(),
})
}
func newFakeOCRServer(t *testing.T, calls *int) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
(*calls)++
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("OCR multipart 解析失败:%v", err)
http.Error(w, "bad multipart", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
t.Errorf("OCR 请求缺少 file 字段:%v", err)
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
if header.Filename != "captcha.jpg" {
t.Errorf("OCR 文件名期望 captcha.jpg,实际 %q", header.Filename)
}
if got := header.Header.Get("Content-Type"); got != "image/jpeg" {
t.Errorf("OCR 文件类型期望 image/jpeg,实际 %q", got)
}
image, _ := io.ReadAll(file)
if string(image) != "fake-image" {
t.Errorf("OCR 图片内容不正确,实际 %q", image)
}
writeJSON(w, map[string]any{"data": map[string]any{"text": " A1 b2 "}})
}))
t.Cleanup(server.Close)
return server
}
func newTestAuthManager(t *testing.T, backend *fakeLoginBackend, database *store.Store, logger *logx.Logger, attempts int) *AuthManager {
t.Helper()
ocrCalls := 0
ocr := newFakeOCRServer(t, &ocrCalls)
cfg := config.HuohanhanConfig{
BaseURL: backend.server.URL,
Account: testAccount,
Password: testPassword,
OCRURL: ocr.URL,
}
return NewAuthManager(cfg, database, logger, AuthOptions{
HTTPClient: backend.server.Client(),
MaxCaptchaAttempts: attempts,
})
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
database, err := store.Open(":memory:")
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func Test登录成功并保存认证状态(t *testing.T) {
backend := newFakeLoginBackend(t)
database := newTestStore(t)
logger := logx.New(100)
manager := newTestAuthManager(t, backend, database, logger, 3)
state, err := manager.ForceLogin(context.Background())
if err != nil {
t.Fatalf("登录应当成功,实际错误:%v", err)
}
if state.AccessToken != testToken {
t.Fatalf("token 期望 %q,实际 %q", testToken, state.AccessToken)
}
raw, found, err := database.GetKV(authStateKey)
if err != nil || !found || raw == "" {
t.Fatalf("认证状态应写入 SQLite,found=%v err=%v", found, err)
}
if backend.loginCount != 1 || backend.validateCount != 1 {
t.Fatalf("登录和在线校验都应各执行 1 次,实际登录 %d 次、校验 %d 次", backend.loginCount, backend.validateCount)
}
}
func Test验证码错误后更换图片重试成功(t *testing.T) {
backend := newFakeLoginBackend(t, "invalid_verify_code")
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
if _, err := manager.ForceLogin(context.Background()); err != nil {
t.Fatalf("第二张验证码应登录成功,实际错误:%v", err)
}
if backend.loginCount != 2 || backend.captchaCount != 2 {
t.Fatalf("应下载并提交 2 张验证码,实际下载 %d 次、提交 %d 次", backend.captchaCount, backend.loginCount)
}
if len(backend.captchaKeys) != 2 || backend.captchaKeys[0] == backend.captchaKeys[1] {
t.Fatalf("重试必须更换 kaptchaKey,实际 %v", backend.captchaKeys)
}
}
func Test账号错误和禁用都不重试(t *testing.T) {
cases := []struct {
name string
code string
want string
}{
{"账号密码错误", "invalid_credentials", "账号或密码错误"},
{"账号已禁用", "disabled_credentials", "账号已被禁用"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
backend := newFakeLoginBackend(t, tc.code)
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
_, err := manager.ForceLogin(context.Background())
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("期望错误包含 %q,实际 %v", tc.want, err)
}
if backend.loginCount != 1 || backend.captchaCount != 1 {
t.Fatalf("不可重试的错误应只请求 1 次,实际登录 %d 次、验证码 %d 次", backend.loginCount, backend.captchaCount)
}
})
}
}
func Test验证码错误达到上限后停止(t *testing.T) {
backend := newFakeLoginBackend(t, "invalid_verify_code", "invalid_verify_code", "invalid_verify_code")
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 2)
_, err := manager.ForceLogin(context.Background())
if err == nil || !strings.Contains(err.Error(), "连续多次验证码识别失败") {
t.Fatalf("达到上限应返回可读错误,实际 %v", err)
}
if backend.loginCount != 2 {
t.Fatalf("上限为 2 时应只提交 2 次,实际 %d 次", backend.loginCount)
}
}
func Test其它登录错误包含Code和Message且不重试(t *testing.T) {
backend := newFakeLoginBackend(t, "server_rejected")
manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3)
_, err := manager.ForceLogin(context.Background())
if err == nil || !strings.Contains(err.Error(), "server_rejected") || !strings.Contains(err.Error(), "fake login error") {
t.Fatalf("其它错误应包含 code 和 message,实际 %v", err)
}
if backend.loginCount != 1 {
t.Fatalf("其它登录错误不应重试,实际登录 %d 次", backend.loginCount)
}
}
func TestSQLite中的Token重启后复用且内存不重复校验(t *testing.T) {
backend := newFakeLoginBackend(t)
database := newTestStore(t)
logger := logx.New(100)
first := newTestAuthManager(t, backend, database, logger, 3)
if _, err := first.ForceLogin(context.Background()); err != nil {
t.Fatalf("首次登录失败:%v", err)
}
second := newTestAuthManager(t, backend, database, logger, 3)
if _, err := second.GetValidAuth(context.Background()); err != nil {
t.Fatalf("重启后读取认证状态失败:%v", err)
}
validatedAfterLoad := backend.validateCount
if _, err := second.GetValidAuth(context.Background()); err != nil {
t.Fatalf("内存复用认证状态失败:%v", err)
}
if backend.loginCount != 1 {
t.Fatalf("第二个管理器不应重新登录,实际登录 %d 次", backend.loginCount)
}
if backend.validateCount != validatedAfterLoad {
t.Fatalf("未过期内存状态不应再次在线校验,校验次数从 %d 变成 %d", validatedAfterLoad, backend.validateCount)
}
}
func Test日志不出现密码Token和Cookie(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)
}
logs := logger.Text()
for _, secret := range []string{testPassword, testToken, testCookie} {
if strings.Contains(logs, secret) {
t.Fatalf("日志中不应出现敏感测试值 %q,实际日志:%s", secret, logs)
}
}
}
func TestOCR递归优先读取常见字段(t *testing.T) {
payload := map[string]any{
"unrelated": "ZZZZ",
"result": map[string]any{"content": " A1 b2 "},
}
if got := findCaptchaText(payload); got != "A1b2" {
t.Fatalf("应优先从 result/content 读取 A1b2,实际 %q", got)
}
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
panic(fmt.Sprintf("写入假服务响应失败:%v", err))
}
}