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

133 lines
3.9 KiB
Go

package huohanhan
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"cmsp/internal/config"
"cmsp/internal/logx"
)
var authFailureCodes = map[string]bool{
"authentication_required": true,
"invalid_token": true,
"invalid_token_expired": true,
}
// Client 是货憨憨业务接口的统一 HTTP 客户端。
//
// Request 会自动添加 Authorization 和登录 cookies。只有服务端明确表示
// 认证失效时才重新登录并重放一次;超时、HTTP 500 和普通业务错误不会重试。
// 请求体使用 []byte,是为了让认证失败后的唯一一次重放不依赖可回卷的 Reader。
type Client struct {
baseURL *url.URL
auth *AuthManager
httpClient *http.Client
log *logx.Logger
}
// NewClient 创建业务请求客户端。
func NewClient(cfg config.HuohanhanConfig, auth *AuthManager, logger *logx.Logger, httpClient *http.Client) (*Client, error) {
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" {
return nil, fmt.Errorf("货憨憨网址不正确")
}
if auth == nil {
return nil, fmt.Errorf("货憨憨认证管理器不能为空")
}
if httpClient == nil {
httpClient = auth.httpClient
}
if logger == nil {
logger = logx.New(1000)
}
return &Client{baseURL: baseURL, auth: auth, httpClient: httpClient, log: logger}, nil
}
// Request 请求一个相对于 /api/ 的货憨憨接口。
//
// 返回的 response 由调用方关闭。非 2xx 状态会返回中文错误;认证失败
// 的 response 在内部关闭后重试,不会泄漏给调用方。
func (c *Client) Request(ctx context.Context, method, path string, body []byte, contentType string) (*http.Response, error) {
for attempt := 0; attempt < 2; attempt++ {
state, err := c.auth.GetValidAuth(ctx)
if err != nil {
return nil, err
}
resp, payload, err := c.do(ctx, method, path, body, contentType, state)
if err != nil {
return nil, err
}
if isAuthFailure(resp.StatusCode, payload) {
resp.Body.Close()
if attempt == 1 {
return nil, fmt.Errorf("重新登录后认证仍然失效")
}
c.log.Warn("货憨憨认证已失效,正在重新登录后重试一次")
if err := c.auth.Invalidate(); err != nil {
return nil, err
}
if _, err := c.auth.ForceLogin(ctx); err != nil {
return nil, err
}
continue
}
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(payload))
resp.ContentLength = int64(len(payload))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return nil, fmt.Errorf("货憨憨接口请求失败:HTTP %d", resp.StatusCode)
}
return resp, nil
}
return nil, fmt.Errorf("货憨憨接口请求失败")
}
func (c *Client) do(ctx context.Context, method, path string, body []byte, contentType string, state AuthState) (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, method, apiURL(c.baseURL, path), bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("创建货憨憨接口请求失败:%w", err)
}
applyCommonHeaders(req)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Authorization", state.AuthorizationValue())
for name, value := range state.Cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("请求货憨憨接口失败:%w", err)
}
payload, err := readBody(resp.Body)
if err != nil {
resp.Body.Close()
return nil, nil, fmt.Errorf("读取货憨憨接口响应失败:%w", err)
}
return resp, payload, nil
}
func isAuthFailure(statusCode int, body []byte) bool {
if statusCode == http.StatusUnauthorized {
return true
}
var payload struct {
Code string `json:"code"`
}
if json.Unmarshal(body, &payload) != nil {
return false
}
return authFailureCodes[payload.Code]
}