Files
affiliate_dash/backend/internal/service/rock_client.go
T
yml2213 7e5cbaf65e fix(rock): 兼容 0666.pro 接口 points 字段字符串与数值自适应解析
1. 新增 FlexibleFloat64 类型,解决上游 /api/me 响应中 points 为字符串(如 '8271.2')导致反序列化失败的问题;
2. 服务层与数据库快照存储统一调用 Float64() 保持类型严谨;
3. 单元测试补充真实上游字符串响应场景并全绿通过。
2026-10-05 15:42:00 +08:00

436 lines
13 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 RockRedeemDetailResponse struct {
ID int64 `json:"id"`
RedeemCode string `json:"redeem_code"`
Color string `json:"color"`
Status string `json:"status"`
BindURL string `json:"bindUrl"`
QRURL string `json:"qrUrl"`
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 {
Binding struct {
Bound bool `json:"bound"`
RoleName string `json:"roleName"`
RoleID string `json:"roleId"`
Channel string `json:"channel"`
} `json:"binding"`
Token struct {
ID int64 `json:"id"`
Status string `json:"status"`
BoundAccount string `json:"bound_account"`
BoundAccountID string `json:"bound_account_id"`
PreviousBoundAccount string `json:"previous_bound_account"`
BindChangedAt string `json:"bind_changed_at"`
BindChangeType string `json:"bind_change_type"`
} `json:"token"`
DeliveryList []RockDeliveryItem `json:"deliveryList"`
Error string `json:"error,omitempty"`
}
type RockRedeemResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Delivery struct {
Skin string `json:"skin"`
Currency string `json:"currency"`
Quantity int `json:"quantity"`
FulfillID int64 `json:"fulfillId"`
} `json:"delivery"`
Error string `json:"error,omitempty"`
}
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")
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")
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 执行最终皮肤兑换发货
func (c *RockClient) RedeemSkin(ctx context.Context, 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
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
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.Success && 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
}