Files
affiliate_dash/backend/internal/service/rock_client.go
T
yml2213 08a0e74cd0 fix(rock): 修复空闲库存为0时误将已分配订单判定为补货中并拦截发货的严重漏洞
1. 根因修复(状态机死锁):当一笔订单分配了池中最后一个可用Token后,空闲库存降为0。原PrepareDelivery仅检查AvailableStock<=0,导致已锁定Token的订单在发货校验时被误判为'库存补充中'并硬性拦截发货;
2. 逻辑重构:PrepareDelivery增加已预占Token识别,若当前订单已持有using或bound状态的专属Token,直接豁免空闲库存校验,放行扫码与发货;
3. 协议对齐:在ClaimTokens与RefreshToken中补全Origin与Referer请求头;
4. 单元测试与方案文档:新增TestRockProvider_PrepareDelivery_ZeroStockWithAssignedToken用例,5项Rock专项单测与全量Service测试100%全绿通过,方案文档追加7.5节证据与复盘记录。
2026-10-05 18:19:40 +08:00

487 lines
15 KiB
Go
Raw 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 service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// FlexibleFloat64 兼容反序列化 JSON 中的数字或字符串(如 "8271.2")
type FlexibleFloat64 float64
func (f *FlexibleFloat64) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
*f = 0
return nil
}
var n float64
if err := json.Unmarshal(b, &n); err == nil {
*f = FlexibleFloat64(n)
return nil
}
var s string
if err := json.Unmarshal(b, &s); err == nil {
s = strings.TrimSpace(s)
if s == "" {
*f = 0
return nil
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
*f = 0
return nil
}
*f = FlexibleFloat64(val)
return nil
}
*f = 0
return nil
}
func (f FlexibleFloat64) MarshalJSON() ([]byte, error) {
return json.Marshal(float64(f))
}
func (f FlexibleFloat64) Float64() float64 {
return float64(f)
}
// RockClient 负责与 0666.pro 平台进行 HTTP 通信
type RockClient struct {
baseURL string
httpClient *http.Client
}
func NewRockClient(baseURL string) *RockClient {
base := strings.TrimRight(baseURL, "/")
if base == "" {
base = "https://0666.pro"
}
return &RockClient{
baseURL: base,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
type RockMeResponse struct {
User struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Points FlexibleFloat64 `json:"points"`
Status string `json:"status"`
} `json:"user"`
Error string `json:"error,omitempty"`
}
type RockClaimedToken struct {
ID int64 `json:"id"`
Token string `json:"token"`
Color string `json:"color"`
Region string `json:"region"`
Status string `json:"status"`
RedeemCode string `json:"redeem_code"`
ClaimedAt string `json:"claimed_at"`
}
type RockClaimResponse struct {
Tokens []RockClaimedToken `json:"tokens"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type RockTokenData struct {
ID int64 `json:"id"`
RedeemCode string `json:"redeem_code"`
Token string `json:"token"`
Color string `json:"color"`
Region string `json:"region"`
Phone string `json:"phone"`
Status string `json:"status"`
BoundAccount string `json:"bound_account"`
BoundAccountID string `json:"bound_account_id"`
PreviousBoundAccount string `json:"previous_bound_account"`
PreviousBoundAccountID string `json:"previous_bound_account_id"`
BindChangedAt string `json:"bind_changed_at"`
BindChangeType string `json:"bind_change_type"`
RedeemSkin string `json:"redeem_skin"`
RedeemCurrency string `json:"redeem_currency"`
RedeemedAt string `json:"redeemed_at"`
}
func (t *RockTokenData) IsBound() bool {
if t.Status == "bound" || t.Status == "redeemed" {
return true
}
return strings.TrimSpace(t.BoundAccountID) != ""
}
func (t *RockTokenData) GetRoleName() string {
return strings.TrimSpace(t.BoundAccount)
}
func (t *RockTokenData) GetRoleID() string {
return strings.TrimSpace(t.BoundAccountID)
}
func (t *RockTokenData) GetChannel() string {
return strings.TrimSpace(t.Region)
}
type RockRedeemDetailResponse struct {
ID int64 `json:"id"`
RedeemCode string `json:"redeem_code"`
Color string `json:"color"`
Status string `json:"status"`
BindURL string `json:"bindUrl"`
BindError string `json:"bindError"`
QRURL string `json:"qrUrl"`
Token RockTokenData `json:"token"`
ExpiresAt string `json:"expires_at"`
Error string `json:"error,omitempty"`
}
type RockDeliveryItem struct {
SkinID int `json:"skinId"`
Skin string `json:"skin"`
CurrencyID int `json:"currencyId"`
Currency string `json:"currency"`
CurrencyQuantity int `json:"currencyQuantity"`
Time string `json:"time"`
Role string `json:"role"`
Message string `json:"message"`
FulfillID int64 `json:"fulfillId"`
Membership int `json:"membership"`
}
type RockRefreshResponse struct {
Token RockTokenData `json:"token"`
DeliveryList []RockDeliveryItem `json:"deliveryList"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (r *RockRefreshResponse) IsBound() bool {
return r.Token.IsBound()
}
func (r *RockRefreshResponse) GetRoleName() string {
return r.Token.GetRoleName()
}
func (r *RockRefreshResponse) GetRoleID() string {
return r.Token.GetRoleID()
}
func (r *RockRefreshResponse) GetChannel() string {
return r.Token.GetChannel()
}
type RockRedeemResponse struct {
Token RockTokenData `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
func (r *RockRedeemResponse) IsSuccess() bool {
return r.Status == "success" || r.Token.Status == "redeemed"
}
type RockExchangeRecordsResponse struct {
Records []struct {
ID int64 `json:"id"`
TokenID int64 `json:"token_id"`
Skin string `json:"skin"`
Currency string `json:"currency"`
Quantity int `json:"quantity"`
Role string `json:"role"`
RoleID string `json:"role_id"`
FulfillID int64 `json:"fulfill_id"`
RedeemedAt string `json:"redeemed_at"`
ClaimedAt string `json:"claimed_at"`
RedeemCode string `json:"redeem_code"`
DeliveryUser string `json:"delivery_user"`
} `json:"records"`
Pagination struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
} `json:"pagination"`
Error string `json:"error,omitempty"`
}
// GetMe 获取商户个人信息及积分
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
reqURL := fmt.Sprintf("%s/api/me", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 /api/me 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
var res RockMeResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析 /api/me 响应失败: %w", err)
}
if res.User.Username == "" && res.Error != "" {
return nil, fmt.Errorf("上游业务错误: %s", res.Error)
}
return &res, nil
}
// ClaimTokens 批量提取采购 Token
func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color string, count int) ([]RockClaimedToken, error) {
if count <= 0 {
return nil, fmt.Errorf("提取数量必须大于 0")
}
reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.baseURL)
payload := map[string]interface{}{
"color": color,
"count": fmt.Sprintf("%d", count),
}
jsonBytes, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 claim 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 claim 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游 claim 接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockClaimResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析 claim 响应失败: %w", err)
}
if len(res.Tokens) == 0 && res.Error != "" {
return nil, fmt.Errorf("上游 claim 失败: %s", res.Error)
}
return res.Tokens, nil
}
// GetRedeemDetail 根据 redeem_code 免登录获取腾讯官方绑定链接与状态
func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*RockRedeemDetailResponse, error) {
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(redeemCode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 /api/redeem/{code} 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 redeem 详情响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游 redeem 详情接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockRedeemDetailResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析 redeem 详情失败: %w", err)
}
return &res, nil
}
// RefreshToken 刷新 Token 绑定状态并获取官方履约结果
func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstreamTokenID string) (*RockRefreshResponse, error) {
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.baseURL, url.PathEscape(upstreamTokenID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
if err != nil {
return nil, err
}
if bearerToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 refresh 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 refresh 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游 refresh 接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockRefreshResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析 refresh 响应失败: %w", err)
}
return &res, nil
}
// RedeemSkin 执行最终皮肤兑换发货(需携带商户 Bearer Token 鉴权)
func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenID, skin, currency, account string) (*RockRedeemResponse, error) {
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(upstreamTokenID))
payload := map[string]interface{}{
"skin": skin,
"currency": currency,
"account": account,
}
jsonBytes, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes))
if err != nil {
return nil, err
}
if bearerToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 兑换发货接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取兑换响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游兑换发货接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockRedeemResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析兑换响应失败: %w", err)
}
if !res.IsSuccess() && res.Error != "" {
return nil, fmt.Errorf("上游兑换失败: %s", res.Error)
}
return &res, nil
}
// QueryExchangeRecords 异步查询兑换对账记录
func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken string, page, pageSize int, search string) (*RockExchangeRecordsResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.baseURL, page, pageSize, url.QueryEscape(search))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 exchange-records 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取对账响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游对账接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockExchangeRecordsResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析对账响应失败: %w", err)
}
return &res, nil
}
// ReportAbnormal 上报异常 Token
func (c *RockClient) ReportAbnormal(ctx context.Context, bearerToken, upstreamTokenID, reason string) error {
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.baseURL, url.PathEscape(upstreamTokenID))
payload := map[string]string{"reason": reason}
jsonBytes, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes))
if err != nil {
return err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("请求 0666 report 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("上报异常失败(%d): %s", resp.StatusCode, string(body))
}
return nil
}