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

656 lines
19 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 提供货憨憨 ERP 的登录和统一 HTTP 客户端。
//
// 设计约定(改代码前请先读):
//
// - 账号和密码只从 config 传入,token 与 cookies 只保存在 SQLite kv 表;
// 不要增加 JSON 状态文件,也不要把任何凭据写进日志。
// - 登录验证码可以更换后重试;账号密码错误、账号禁用和其它登录错误
// 都不能重试,避免无意义请求触发服务端风控。
// - AuthManager 只用进程内 mutex 串行登录。本项目是单机 GUI,不能把
// Python 参考实现里的 Redis 和分布式锁搬进来。
package huohanhan
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/textproto"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"cmsp/internal/config"
"cmsp/internal/logx"
"cmsp/internal/store"
)
const (
authStateKey = "huohanhan.auth"
defaultRequestTimeout = 30 * time.Second
defaultCaptchaAttempts = 3
defaultAuthExpirySkew = 5 * time.Minute
maximumResponseBodyBytes = 8 << 20
)
var (
cidPattern = regexp.MustCompile(`["']?CID["']?\s*:\s*["']([^"']+)["']`)
cstPattern = regexp.MustCompile(`["']?CST["']?\s*:\s*["']([^"']+)["']`)
captchaPattern = regexp.MustCompile(`^[A-Za-z0-9]{4,8}$`)
)
// AuthOptions 是登录流程中需要调整的运行参数。
//
// 零值会使用安全默认值。HTTPClient 和 Now 主要供离线测试注入;
// MaxCaptchaAttempts 让上层配置接入后无需修改登录逻辑。
type AuthOptions struct {
HTTPClient *http.Client
RequestTimeout time.Duration
MaxCaptchaAttempts int
ExpirySkew time.Duration
Now func() time.Time
}
// AuthState 是一次登录后需要复用的完整认证状态。
//
// 该结构会序列化到 SQLite,字段可能含敏感内容,禁止整体写入日志。
type AuthState struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
LoginTime int64 `json:"login_time"`
Cookies map[string]string `json:"cookies"`
}
// AuthorizationValue 返回业务请求使用的 Authorization 值。
func (s AuthState) AuthorizationValue() string {
tokenType := strings.TrimSpace(s.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
return tokenType + " " + s.AccessToken
}
// Expired 判断 token 是否已经过期或进入安全提前量。
func (s AuthState) Expired(now time.Time, skew time.Duration) bool {
if s.ExpiresIn <= 0 || s.LoginTime <= 0 {
return true
}
expiresAt := time.UnixMilli(s.LoginTime).Add(time.Duration(s.ExpiresIn) * time.Second)
return !now.Add(skew).Before(expiresAt)
}
// AuthManager 串行管理内存与 SQLite 中的认证状态。
type AuthManager struct {
cfg config.HuohanhanConfig
db *store.Store
log *logx.Logger
httpClient *http.Client
attempts int
expirySkew time.Duration
now func() time.Time
mu sync.Mutex
current *AuthState
}
// NewAuthManager 创建认证管理器。opts 的零值会补成默认配置。
func NewAuthManager(cfg config.HuohanhanConfig, db *store.Store, logger *logx.Logger, opts AuthOptions) *AuthManager {
timeout := opts.RequestTimeout
if timeout <= 0 {
timeout = defaultRequestTimeout
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: timeout}
}
attempts := opts.MaxCaptchaAttempts
if attempts <= 0 {
attempts = defaultCaptchaAttempts
}
skew := opts.ExpirySkew
if skew <= 0 {
skew = defaultAuthExpirySkew
}
now := opts.Now
if now == nil {
now = time.Now
}
if logger == nil {
logger = logx.New(1000)
}
return &AuthManager{
cfg: cfg,
db: db,
log: logger,
httpClient: client,
attempts: attempts,
expirySkew: skew,
now: now,
}
}
// GetValidAuth 按“内存、SQLite、重新登录”的顺序取得有效认证。
//
// SQLite 中的状态在进程首次使用时必须在线验证,不能只相信本地时间。
func (m *AuthManager) GetValidAuth(ctx context.Context) (AuthState, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil && !m.current.Expired(m.now(), m.expirySkew) {
return cloneAuthState(*m.current), nil
}
stored, found, err := m.load()
if err != nil {
return AuthState{}, err
}
if found {
valid, validateErr := m.validate(ctx, stored)
if validateErr != nil {
return AuthState{}, validateErr
}
if valid {
m.current = &stored
m.log.Info("已复用本机保存的货憨憨认证状态")
return cloneAuthState(stored), nil
}
if err := m.clearLocked(); err != nil {
return AuthState{}, err
}
}
return m.loginAndSaveLocked(ctx)
}
// ForceLogin 忽略旧状态并重新登录,供测试连接和认证失败恢复使用。
func (m *AuthManager) ForceLogin(ctx context.Context) (AuthState, error) {
m.mu.Lock()
defer m.mu.Unlock()
// 显式测试连接或服务端已拒绝认证时,不能让失败的重新登录继续留下旧 token。
if err := m.clearLocked(); err != nil {
return AuthState{}, err
}
return m.loginAndSaveLocked(ctx)
}
// Invalidate 清除内存与 SQLite 中已被服务端拒绝的认证状态。
func (m *AuthManager) Invalidate() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.clearLocked()
}
func (m *AuthManager) loginAndSaveLocked(ctx context.Context) (AuthState, error) {
state, err := m.login(ctx)
if err != nil {
return AuthState{}, err
}
if err := m.save(state); err != nil {
return AuthState{}, err
}
m.current = &state
m.log.Success("货憨憨登录成功")
return cloneAuthState(state), nil
}
func (m *AuthManager) login(ctx context.Context) (AuthState, error) {
if m.db == nil {
return AuthState{}, fmt.Errorf("本地数据库未就绪")
}
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/"))
if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" {
return AuthState{}, fmt.Errorf("货憨憨网址不正确")
}
if strings.TrimSpace(m.cfg.Account) == "" || m.cfg.Password == "" {
return AuthState{}, fmt.Errorf("请先填写货憨憨账号和密码")
}
if strings.TrimSpace(m.cfg.OCRURL) == "" {
return AuthState{}, fmt.Errorf("OCR 识别服务地址不能为空")
}
jar, err := cookiejar.New(nil)
if err != nil {
return AuthState{}, fmt.Errorf("创建登录会话失败:%w", err)
}
session := *m.httpClient
session.Jar = jar
cid, cst, clientID, err := m.loadLoginParameters(ctx, &session, baseURL)
if err != nil {
return AuthState{}, err
}
for attempt := 1; attempt <= m.attempts; attempt++ {
m.log.Info("正在识别货憨憨登录验证码,第 %d/%d 次", attempt, m.attempts)
captchaKey, image, err := m.downloadCaptcha(ctx, &session, baseURL)
if err != nil {
return AuthState{}, err
}
captchaCode, err := m.recognizeCaptcha(ctx, image)
if err != nil {
return AuthState{}, err
}
state, code, message, err := m.submitLogin(
ctx, &session, baseURL, cid, cst, clientID, captchaCode, captchaKey,
)
if err != nil {
return AuthState{}, err
}
if state.AccessToken != "" {
valid, validateErr := m.validateWithClient(ctx, &session, baseURL, state)
if validateErr != nil {
return AuthState{}, validateErr
}
if !valid {
return AuthState{}, fmt.Errorf("登录成功,但服务端未通过认证校验")
}
return state, nil
}
switch code {
case "invalid_verify_code":
m.log.Warn("验证码不正确,准备更换验证码")
continue
case "invalid_credentials":
return AuthState{}, fmt.Errorf("账号或密码错误")
case "disabled_credentials":
return AuthState{}, fmt.Errorf("账号已被禁用")
default:
if strings.TrimSpace(code) == "" {
code = "未知错误"
}
if strings.TrimSpace(message) == "" {
message = "登录失败"
}
return AuthState{}, fmt.Errorf("登录失败:%s,%s", code, message)
}
}
return AuthState{}, fmt.Errorf("连续多次验证码识别失败,请稍后重试")
}
func (m *AuthManager) loadLoginParameters(ctx context.Context, client *http.Client, baseURL *url.URL) (string, string, string, error) {
page, err := m.do(ctx, client, http.MethodGet, baseURL.String()+"/login", nil, "", "", "")
if err != nil {
return "", "", "", fmt.Errorf("读取货憨憨登录页失败:%w", err)
}
cid := findRuntimeValue(page, cidPattern)
if cid == "" {
return "", "", "", fmt.Errorf("登录页中没有找到 CID,网页可能已经改版")
}
cst := findRuntimeValue(page, cstPattern)
if cst == "" {
return "", "", "", fmt.Errorf("登录页中没有找到 CST,网页可能已经改版")
}
form := url.Values{"domain": {baseURL.Hostname()}}
body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "butler/client/getCltConf"),
[]byte(form.Encode()), "application/x-www-form-urlencoded", "", "")
if err != nil {
return "", "", "", fmt.Errorf("读取货憨憨网站配置失败:%w", err)
}
var payload struct {
ID json.RawMessage `json:"id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", "", fmt.Errorf("货憨憨网站配置返回的不是有效 JSON")
}
clientID := rawString(payload.ID)
if clientID == "" {
return "", "", "", fmt.Errorf("货憨憨网站配置中没有 clientId")
}
return cid, cst, clientID, nil
}
func (m *AuthManager) downloadCaptcha(ctx context.Context, client *http.Client, baseURL *url.URL) (string, []byte, error) {
key, err := newUUID()
if err != nil {
return "", nil, fmt.Errorf("生成验证码标识失败:%w", err)
}
endpoint := apiURL(baseURL, "butler/vrify/kaptcha") + "?" + url.Values{"kaptchaKey": {key}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", nil, fmt.Errorf("创建验证码请求失败:%w", err)
}
applyCommonHeaders(req)
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("下载验证码失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", nil, fmt.Errorf("下载验证码失败:HTTP %d", resp.StatusCode)
}
if !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "image") {
return "", nil, fmt.Errorf("验证码接口没有返回图片")
}
image, err := readBody(resp.Body)
if err != nil {
return "", nil, fmt.Errorf("读取验证码图片失败:%w", err)
}
return key, image, nil
}
func (m *AuthManager) recognizeCaptcha(ctx context.Context, image []byte) (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="captcha.jpg"`)
header.Set("Content-Type", "image/jpeg")
part, err := writer.CreatePart(header)
if err != nil {
return "", fmt.Errorf("准备验证码图片失败:%w", err)
}
if _, err := part.Write(image); err != nil {
return "", fmt.Errorf("准备验证码图片失败:%w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("准备验证码图片失败:%w", err)
}
payload, err := m.do(ctx, m.httpClient, http.MethodPost, m.cfg.OCRURL,
body.Bytes(), writer.FormDataContentType(), "", "")
if err != nil {
return "", fmt.Errorf("OCR 识别失败:%w", err)
}
var decoded any
if json.Unmarshal(payload, &decoded) != nil {
decoded = string(payload)
}
code := findCaptchaText(decoded)
if code == "" {
return "", fmt.Errorf("OCR 返回成功,但没有找到 4~8 位验证码")
}
return code, nil
}
func (m *AuthManager) submitLogin(ctx context.Context, client *http.Client, baseURL *url.URL, cid, cst, clientID, captchaCode, captchaKey string) (AuthState, string, string, error) {
form := url.Values{
"username": {m.cfg.Account},
"password": {m.cfg.Password},
"clientId": {clientID},
"kaptchaCode": {captchaCode},
"kaptchaKey": {captchaKey},
}
body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "login"),
[]byte(form.Encode()), "application/x-www-form-urlencoded", cid, cst)
if err != nil {
return AuthState{}, "", "", fmt.Errorf("提交货憨憨登录失败:%w", err)
}
var payload map[string]any
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return AuthState{}, "", "", fmt.Errorf("货憨憨登录接口返回的不是有效 JSON")
}
token := stringValue(payload["access_token"])
if token == "" {
return AuthState{}, stringValue(payload["code"]), stringValue(payload["message"]), nil
}
state := AuthState{
AccessToken: token,
TokenType: stringValue(payload["token_type"]),
ExpiresIn: int64Value(payload["expires_in"]),
LoginTime: int64Value(payload["login_time"]),
Cookies: make(map[string]string),
}
if state.TokenType == "" {
state.TokenType = "Bearer"
}
if state.LoginTime <= 0 {
state.LoginTime = m.now().UnixMilli()
}
for _, cookie := range client.Jar.Cookies(baseURL) {
state.Cookies[cookie.Name] = cookie.Value
}
return state, "", "", nil
}
func (m *AuthManager) validate(ctx context.Context, state AuthState) (bool, error) {
baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/"))
if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" {
return false, fmt.Errorf("货憨憨网址不正确")
}
return m.validateWithClient(ctx, m.httpClient, baseURL, state)
}
func (m *AuthManager) validateWithClient(ctx context.Context, client *http.Client, baseURL *url.URL, state AuthState) (bool, error) {
form := url.Values{"appCode": {"HHH"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(baseURL, "butler/app-version/info"), strings.NewReader(form.Encode()))
if err != nil {
return false, fmt.Errorf("创建认证校验请求失败:%w", err)
}
applyCommonHeaders(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", state.AuthorizationValue())
for name, value := range state.Cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("校验货憨憨认证失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return false, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, fmt.Errorf("校验货憨憨认证失败:HTTP %d", resp.StatusCode)
}
body, err := readBody(resp.Body)
if err != nil {
return false, fmt.Errorf("读取认证校验结果失败:%w", err)
}
var payload struct {
AppCode string `json:"appCode"`
}
if json.Unmarshal(body, &payload) != nil {
return false, nil
}
return payload.AppCode == "HHH", nil
}
func (m *AuthManager) do(ctx context.Context, client *http.Client, method, endpoint string, body []byte, contentType, basicUser, basicPassword string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
applyCommonHeaders(req)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if basicUser != "" || basicPassword != "" {
req.SetBasicAuth(basicUser, basicPassword)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return readBody(resp.Body)
}
func (m *AuthManager) save(state AuthState) error {
raw, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("编码货憨憨认证状态失败:%w", err)
}
if err := m.db.SetKV(authStateKey, string(raw), m.now().Format(time.RFC3339)); err != nil {
return fmt.Errorf("保存货憨憨认证状态失败:%w", err)
}
return nil
}
func (m *AuthManager) load() (AuthState, bool, error) {
if m.db == nil {
return AuthState{}, false, fmt.Errorf("本地数据库未就绪")
}
raw, found, err := m.db.GetKV(authStateKey)
if err != nil {
return AuthState{}, false, fmt.Errorf("读取货憨憨认证状态失败:%w", err)
}
if !found || strings.TrimSpace(raw) == "" {
return AuthState{}, false, nil
}
var state AuthState
if err := json.Unmarshal([]byte(raw), &state); err != nil || state.AccessToken == "" {
m.log.Warn("本机保存的货憨憨认证状态无法读取,将重新登录")
return AuthState{}, false, nil
}
if state.Cookies == nil {
state.Cookies = make(map[string]string)
}
return state, true, nil
}
func (m *AuthManager) clearLocked() error {
m.current = nil
if m.db == nil {
return fmt.Errorf("本地数据库未就绪")
}
if err := m.db.SetKV(authStateKey, "", m.now().Format(time.RFC3339)); err != nil {
return fmt.Errorf("清除货憨憨认证状态失败:%w", err)
}
return nil
}
func cloneAuthState(state AuthState) AuthState {
cloned := state
cloned.Cookies = make(map[string]string, len(state.Cookies))
for name, value := range state.Cookies {
cloned.Cookies[name] = value
}
return cloned
}
func findRuntimeValue(page []byte, pattern *regexp.Regexp) string {
match := pattern.FindSubmatch(page)
if len(match) != 2 {
return ""
}
return string(match[1])
}
func findCaptchaText(value any) string {
switch typed := value.(type) {
case string:
cleaned := strings.Join(strings.Fields(typed), "")
if captchaPattern.MatchString(cleaned) {
return cleaned
}
case map[string]any:
for _, key := range []string{"text", "result", "data", "content", "captcha", "code"} {
if child, ok := typed[key]; ok {
if result := findCaptchaText(child); result != "" {
return result
}
}
}
for key, child := range typed {
if isPreferredCaptchaKey(key) {
continue
}
if result := findCaptchaText(child); result != "" {
return result
}
}
case []any:
for _, child := range typed {
if result := findCaptchaText(child); result != "" {
return result
}
}
}
return ""
}
func isPreferredCaptchaKey(key string) bool {
for _, preferred := range []string{"text", "result", "data", "content", "captcha", "code"} {
if key == preferred {
return true
}
}
return false
}
func stringValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
default:
return ""
}
}
func int64Value(value any) int64 {
switch typed := value.(type) {
case json.Number:
result, _ := typed.Int64()
return result
case float64:
return int64(typed)
case string:
result, _ := strconv.ParseInt(typed, 10, 64)
return result
default:
return 0
}
}
func rawString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var text string
if json.Unmarshal(raw, &text) == nil {
return text
}
var number json.Number
if json.Unmarshal(raw, &number) == nil {
return number.String()
}
return ""
}
func apiURL(baseURL *url.URL, path string) string {
return strings.TrimRight(baseURL.String(), "/") + "/api/" + strings.TrimLeft(path, "/")
}
func applyCommonHeaders(req *http.Request) {
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152")
}
func readBody(reader io.Reader) ([]byte, error) {
return io.ReadAll(io.LimitReader(reader, maximumResponseBodyBytes))
}
func newUUID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
raw[6] = (raw[6] & 0x0f) | 0x40
raw[8] = (raw[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(raw[:])
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
}