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节证据与复盘记录。
487 lines
15 KiB
Go
487 lines
15 KiB
Go
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
|
||
}
|