payx: auto re-login on upstream token expiry

merchantPost now retries once after clearing the cached token and
re-authenticating when the upstream signals an invalid session:
HTML login page, HTTP 401, or business msgs containing login-expiry
keywords. Previously a stale token left stock queries returning
empty until manual intervention.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-11 22:54:12 +08:00
co-authored by Devin
parent 2ab39ecfab
commit d805f28024
+39 -3
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
@@ -24,6 +25,9 @@ type apiError struct{ msg string }
func (e *apiError) Error() string { return e.msg } func (e *apiError) Error() string { return e.msg }
// errNeedRelogin 上游登录态失效的信号(HTML 登录页 / HTTP 401),merchantPost 捕获后自动重登重试。
var errNeedRelogin = errors.New("upstream re-login required")
// envelope 上游统一响应信封 {code,msg,data};code==1 为成功。 // envelope 上游统一响应信封 {code,msg,data};code==1 为成功。
type envelope struct { type envelope struct {
Code int64 `json:"code"` Code int64 `json:"code"`
@@ -134,13 +138,38 @@ func (c *Client) ensureSession() (string, error) {
return c.login() return c.login()
} }
// merchantPost 商户接口调用:带 Merchant-Token;失败原样透出,不自动重登。 // merchantPost 商户接口调用:带 Merchant-Token;令牌失效(登录页/401)时自动重登并重试一次。
func (c *Client) merchantPost(ctx context.Context, path string, body, out any) error { func (c *Client) merchantPost(ctx context.Context, path string, body, out any) error {
tok, err := c.ensureSession() tok, err := c.ensureSession()
if err != nil { if err != nil {
return err return err
} }
return c.postOnce(ctx, path, body, tok, out) err = c.postOnce(ctx, path, body, tok, out)
if !errors.Is(err, errNeedRelogin) {
return err
}
// 旧令牌失效:清缓存强制重登,再原样重试一次;仍失败则透出
c.mu.Lock()
c.token = ""
c.mu.Unlock()
tok, err = c.login()
if err != nil {
return fmt.Errorf("上游令牌失效且重登失败: %w", err)
}
if err = c.postOnce(ctx, path, body, tok, out); errors.Is(err, errNeedRelogin) {
return &apiError{"上游令牌失效,重登后仍被拒绝"}
}
return err
}
// reloginMsg 上游业务错误文案是否属于登录态失效。
func reloginMsg(msg string) bool {
for _, kw := range []string{"未登录", "请先登录", "登录已过期", "登录已失效", "重新登录", "登录状态"} {
if strings.Contains(msg, kw) {
return true
}
}
return false
} }
// post 通用调用:merchantToken 为空=买家接口;data 解码到 out。 // post 通用调用:merchantToken 为空=买家接口;data 解码到 out。
@@ -156,13 +185,17 @@ func (c *Client) postOnce(ctx context.Context, path string, body any, merchantTo
trimmed := bytes.TrimSpace(raw) trimmed := bytes.TrimSpace(raw)
// 令牌失效/被风控时上游会回 HTML 登录页,统一归为鉴权类错误触发重登 // 令牌失效/被风控时上游会回 HTML 登录页,统一归为鉴权类错误触发重登
if len(trimmed) > 0 && trimmed[0] == '<' { if len(trimmed) > 0 && trimmed[0] == '<' {
return &apiError{"上游返回登录页(token 已失效)"} return fmt.Errorf("上游返回登录页(token 已失效): %w", errNeedRelogin)
} }
var env envelope var env envelope
if err := json.Unmarshal(trimmed, &env); err != nil { if err := json.Unmarshal(trimmed, &env); err != nil {
return fmt.Errorf("上游响应解析失败: %w", err) return fmt.Errorf("上游响应解析失败: %w", err)
} }
if env.Code != 1 { if env.Code != 1 {
// 业务错误里带登录态文案(非 HTML/401 形态)同样触发重登
if reloginMsg(env.Msg) {
return fmt.Errorf("%s: %w", env.Msg, errNeedRelogin)
}
return &apiError{env.Msg} return &apiError{env.Msg}
} }
if out != nil && len(env.Data) > 0 { if out != nil && len(env.Data) > 0 {
@@ -193,6 +226,9 @@ func (c *Client) postRawOnce(ctx context.Context, path string, body any, merchan
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("上游返回 HTTP 401: %w", errNeedRelogin)
}
// 5xx/HTML 网关故障单独报错;200 + HTML = 令牌失效被踢回登录页 // 5xx/HTML 网关故障单独报错;200 + HTML = 令牌失效被踢回登录页
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
return nil, &apiError{fmt.Sprintf("上游网关故障(HTTP %d)", resp.StatusCode)} return nil, &apiError{fmt.Sprintf("上游网关故障(HTTP %d)", resp.StatusCode)}