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 下通过;全量测试通过。
515 lines
15 KiB
Go
515 lines
15 KiB
Go
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
|
||
}
|