Files
affiliate_dash/backend/internal/service/rock_client.go
T
yml2213 80e2bbf875 fix(rock): 上游地址改为以数据库配置为准,并消除 baseURL 数据竞争
GetConfig 从不把 rock_configs.base_url 回写到客户端,main.go 又硬编码了
https://0666.pro,导致后台改上游地址「保存那一刻生效、进程重启后失效」,
数据库里的值被静默忽略且无任何提示。

- 新增 model.RockDefaultBaseURL 统一默认值来源
- 新增 RockService.ApplyConfigToClient,启动时把 DB 地址同步到客户端
- UpdateConfig 改用 client.SetBaseURL 立即生效,不再直接写字段
- RockClient.baseURL 原为裸字段,被 HTTP 请求线程与库存巡检 goroutine 并发
  读写(数据竞争),改为 sync.RWMutex 保护并新增 BaseURL/SetBaseURL
- ClaimTokens/RefreshToken/RedeemSkin 的 Origin/Referer 跟随配置地址
- 空值回落默认地址,末尾斜杠自动裁剪

为保持该提交可编译,同一次改动把 rock_service.go(1177 行)按域拆分为
8 个文件(最大 221 行):rock_service / rock_bindlink / rock_stock /
rock_assign / rock_bindcheck / rock_redeem / rock_recover / rock_snapshot。
35 个声明块全部有归属,无遗漏无重复;迁移文件 012 受 checksum 保护故未改动。

验证:TestRockServiceBaseURLFromConfig 覆盖「改地址→立即生效→重启→仍生效」;
并发用例在 -race 下通过;全量测试通过。
2026-10-06 19:42:58 +08:00

515 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"
"sync"
"time"
"affiliate_dash/internal/model"
)
// 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 负责与 Rock 上游平台进行 HTTP 通信。
//
// baseURL 在运行期可被后台配置改动(见 RockService.UpdateConfig),而读取方
// (HTTP 请求、库存巡检 goroutine)是并发的,因此必须加锁访问。
type RockClient struct {
mu sync.RWMutex
baseURL string
httpClient *http.Client
}
func NewRockClient(baseURL string) *RockClient {
c := &RockClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
c.SetBaseURL(baseURL)
return c
}
// BaseURL 返回当前生效的上游地址。
func (c *RockClient) BaseURL() string {
if c == nil {
return ""
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL
}
// SetBaseURL 更新上游地址;传入空值或只有空白时回落到内置默认地址。
func (c *RockClient) SetBaseURL(baseURL string) {
if c == nil {
return
}
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = model.RockDefaultBaseURL
}
c.mu.Lock()
c.baseURL = base
c.mu.Unlock()
}
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", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
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", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
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", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
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
}