由 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
133 lines
3.9 KiB
Go
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]
|
|
}
|