909 lines
29 KiB
Go
909 lines
29 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 RockBindingInfo struct {
|
||
Bound bool `json:"bound"`
|
||
Role string `json:"role"`
|
||
RoleID string `json:"roleId"`
|
||
Region string `json:"region"`
|
||
BindTime string `json:"bindTime"`
|
||
}
|
||
|
||
type RockRefreshResponse struct {
|
||
Token RockTokenData `json:"token"`
|
||
Binding RockBindingInfo `json:"binding"`
|
||
DeliveryList []RockDeliveryItem `json:"deliveryList"`
|
||
Message string `json:"message,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func (r *RockRefreshResponse) IsBound() bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
if r.Token.IsBound() {
|
||
return true
|
||
}
|
||
return r.Binding.Bound || strings.TrimSpace(r.Binding.RoleID) != ""
|
||
}
|
||
|
||
func (r *RockRefreshResponse) GetRoleName() string {
|
||
if r == nil {
|
||
return ""
|
||
}
|
||
if name := r.Token.GetRoleName(); name != "" {
|
||
return name
|
||
}
|
||
return strings.TrimSpace(r.Binding.Role)
|
||
}
|
||
|
||
func (r *RockRefreshResponse) GetRoleID() string {
|
||
if r == nil {
|
||
return ""
|
||
}
|
||
if id := r.Token.GetRoleID(); id != "" {
|
||
return id
|
||
}
|
||
return strings.TrimSpace(r.Binding.RoleID)
|
||
}
|
||
|
||
func (r *RockRefreshResponse) GetChannel() string {
|
||
if r == nil {
|
||
return ""
|
||
}
|
||
if channel := r.Token.GetChannel(); channel != "" {
|
||
return channel
|
||
}
|
||
return strings.TrimSpace(r.Binding.Region)
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
// RockPackage 是上游打包记录(/api/user/packages)的列表项。
|
||
type RockPackage struct {
|
||
ID int64 `json:"id"`
|
||
UserID int64 `json:"user_id"`
|
||
TokenCount int `json:"token_count"`
|
||
ShareCode string `json:"share_code"`
|
||
Status string `json:"status"`
|
||
CreatedAt string `json:"created_at"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
// RockPackageToken 是打包详情(/api/package/{share_code})里的 Token 原始结构。
|
||
// 字段命名与 0666.pro 返回保持一致,便于把整条 JSON 原样存入 raw_data。
|
||
type RockPackageToken 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"`
|
||
ClaimGroup string `json:"claim_group"`
|
||
ClaimRound int `json:"claim_round"`
|
||
ClaimNo int `json:"claim_no"`
|
||
ExpiresAt string `json:"expires_at"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type RockPackageDetailResponse struct {
|
||
Package RockPackage `json:"package"`
|
||
Owner RockPackageOwner `json:"owner"`
|
||
Tokens []RockPackageToken `json:"tokens"`
|
||
Config map[string]any `json:"config"`
|
||
Settings map[string]any `json:"settings"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type RockPackageOwner struct {
|
||
Username string `json:"username"`
|
||
Phone string `json:"phone"`
|
||
}
|
||
|
||
// RockPackageCreateResponse 是 POST /api/user/packages 的返回结构。
|
||
// 上游示例:{"package":{"id":1512,"token_count":2,"share_code":"1448fffa85bb3d4f913c",...}}
|
||
type RockPackageCreateResponse struct {
|
||
Package *RockPackage `json:"package"`
|
||
Packages []RockPackage `json:"packages"`
|
||
ShareCode string `json:"share_code"`
|
||
URL string `json:"url"`
|
||
Message string `json:"message,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ResolvePackage 兼容 package / packages / share_code / url 四种返回形态,返回包 ID 与 share_code。
|
||
func (r *RockPackageCreateResponse) ResolvePackage() (int64, string) {
|
||
if r == nil {
|
||
return 0, ""
|
||
}
|
||
if r.Package != nil {
|
||
if code := strings.TrimSpace(r.Package.ShareCode); code != "" {
|
||
return r.Package.ID, code
|
||
}
|
||
}
|
||
for _, p := range r.Packages {
|
||
if code := strings.TrimSpace(p.ShareCode); code != "" {
|
||
return p.ID, code
|
||
}
|
||
}
|
||
if code := strings.TrimSpace(r.ShareCode); code != "" {
|
||
return 0, code
|
||
}
|
||
if rawURL := strings.TrimSpace(r.URL); rawURL != "" {
|
||
if idx := strings.LastIndex(rawURL, "/"); idx >= 0 && idx < len(rawURL)-1 {
|
||
return 0, strings.TrimSpace(rawURL[idx+1:])
|
||
}
|
||
}
|
||
return 0, ""
|
||
}
|
||
|
||
// RockPackageListResponse 是 GET /api/user/packages 的返回结构。
|
||
type RockPackageListResponse struct {
|
||
Packages []RockPackage `json:"packages"`
|
||
Pagination struct {
|
||
Page int `json:"page"`
|
||
PageSize int `json:"pageSize"`
|
||
Total int `json:"total"`
|
||
TotalPages int `json:"totalPages"`
|
||
Offset int `json:"offset"`
|
||
} `json:"pagination"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ListPackages 分页查询上游打包记录(需 Bearer Token)。
|
||
func (c *RockClient) ListPackages(ctx context.Context, bearerToken string, page, pageSize int) (*RockPackageListResponse, error) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
reqURL := fmt.Sprintf("%s/api/user/packages?page=%d&pageSize=%d", c.BaseURL(), page, pageSize)
|
||
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, 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 packages 列表失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err)
|
||
}
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("上游 packages 列表接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
var res RockPackageListResponse
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return nil, fmt.Errorf("解析 packages 列表响应失败: %w", err)
|
||
}
|
||
if res.Error != "" {
|
||
return nil, fmt.Errorf("上游 packages 列表业务错误: %s", res.Error)
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
// CreatePackage 把一批已提取 Token 打包成公开分享包(需 Bearer Token)。
|
||
// 注意:上游未返回 share_code 时不会在这里报错,由调用方决定兜底策略。
|
||
func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids []string, note string) (*RockPackageCreateResponse, error) {
|
||
startedAt := time.Now()
|
||
if len(ids) == 0 {
|
||
return nil, fmt.Errorf("打包 ids 不能为空")
|
||
}
|
||
reqURL := fmt.Sprintf("%s/api/user/packages", c.BaseURL())
|
||
payload := map[string]interface{}{
|
||
"ids": ids,
|
||
"note": note,
|
||
}
|
||
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 packages 创建接口失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 packages 创建响应失败: %w", err)
|
||
}
|
||
traceRockRequest(ctx, "package", http.MethodPost, reqURL, jsonBytes, resp.StatusCode, body, time.Since(startedAt), err)
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("上游 packages 创建接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
var res RockPackageCreateResponse
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return nil, fmt.Errorf("解析 packages 创建响应失败: %w", err)
|
||
}
|
||
if res.Error != "" {
|
||
return nil, fmt.Errorf("上游 packages 创建业务错误: %s", res.Error)
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
// GetPackageDetail 按 share_code 查询打包详情(公开接口,无需 Bearer)。
|
||
func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*RockPackageDetailResponse, error) {
|
||
startedAt := time.Now()
|
||
shareCode = strings.TrimSpace(shareCode)
|
||
if shareCode == "" {
|
||
return nil, fmt.Errorf("share_code 不能为空")
|
||
}
|
||
reqURL := fmt.Sprintf("%s/api/package/%s", c.BaseURL(), url.PathEscape(shareCode))
|
||
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 package 详情失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 package 详情响应失败: %w", err)
|
||
}
|
||
traceRockRequest(ctx, "detail", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("上游 package 详情接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
var res RockPackageDetailResponse
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return nil, fmt.Errorf("解析 package 详情失败: %w", err)
|
||
}
|
||
if res.Error != "" {
|
||
return nil, fmt.Errorf("上游 package 详情业务错误: %s", res.Error)
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
// GetMe 获取商户个人信息及积分
|
||
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
|
||
startedAt := time.Now()
|
||
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)
|
||
}
|
||
traceRockRequest(ctx, "me", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), 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
|
||
}
|
||
|
||
// RockDashboardResponse 是 GET /api/user/dashboard 的返回结构。
|
||
type RockDashboardResponse struct {
|
||
Available struct {
|
||
Black int `json:"black"`
|
||
Blue int `json:"blue"`
|
||
} `json:"available"`
|
||
EffectiveTokenCosts struct {
|
||
BlackTokenCost FlexibleFloat64 `json:"blackTokenCost"`
|
||
BlueTokenCost FlexibleFloat64 `json:"blueTokenCost"`
|
||
} `json:"effectiveTokenCosts"`
|
||
Config struct {
|
||
BlackTokenCost FlexibleFloat64 `json:"blackTokenCost"`
|
||
BlueTokenCost FlexibleFloat64 `json:"blueTokenCost"`
|
||
} `json:"config"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ResolveBlackTokenCost 优先取 effectiveTokenCosts,其次 config。
|
||
func (r *RockDashboardResponse) ResolveBlackTokenCost() float64 {
|
||
if r == nil {
|
||
return 0
|
||
}
|
||
if cost := float64(r.EffectiveTokenCosts.BlackTokenCost); cost > 0 {
|
||
return cost
|
||
}
|
||
return float64(r.Config.BlackTokenCost)
|
||
}
|
||
|
||
func (r *RockDashboardResponse) ResolveBlueTokenCost() float64 {
|
||
if r == nil {
|
||
return 0
|
||
}
|
||
if cost := float64(r.EffectiveTokenCosts.BlueTokenCost); cost > 0 {
|
||
return cost
|
||
}
|
||
return float64(r.Config.BlueTokenCost)
|
||
}
|
||
|
||
// GetDashboard 获取上游可用库存与实时采购单价(需 Bearer Token)。
|
||
func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*RockDashboardResponse, error) {
|
||
startedAt := time.Now()
|
||
reqURL := fmt.Sprintf("%s/api/user/dashboard", 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, 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 dashboard 失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 dashboard 响应失败: %w", err)
|
||
}
|
||
traceRockRequest(ctx, "dashboard", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("上游 dashboard 接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
var res RockDashboardResponse
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return nil, fmt.Errorf("解析 dashboard 响应失败: %w", err)
|
||
}
|
||
if res.Error != "" {
|
||
return nil, fmt.Errorf("上游 dashboard 业务错误: %s", res.Error)
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
// ClaimTokens 批量提取采购 Token
|
||
func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color string, count int) ([]RockClaimedToken, error) {
|
||
startedAt := time.Now()
|
||
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)
|
||
}
|
||
traceRockRequest(ctx, "claim", http.MethodPost, reqURL, jsonBytes, resp.StatusCode, body, time.Since(startedAt), 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
|
||
}
|
||
|
||
// RockUpstreamStatusError 保留上游 HTTP 状态码,调用方可据此决定是否降级到备用接口。
|
||
type RockUpstreamStatusError struct {
|
||
StatusCode int
|
||
Body string
|
||
}
|
||
|
||
func (e *RockUpstreamStatusError) Error() string {
|
||
return fmt.Sprintf("上游返回错误码 %d: %s", e.StatusCode, e.Body)
|
||
}
|
||
|
||
// RefreshRedeemBinding 通过兑换码刷新绑定状态(0666.pro 公开端接口,无需 Bearer)。
|
||
func (c *RockClient) RefreshRedeemBinding(ctx context.Context, redeemCode string) (*RockRefreshResponse, error) {
|
||
code := strings.TrimSpace(redeemCode)
|
||
if code == "" {
|
||
return nil, fmt.Errorf("兑换码为空,无法刷新绑定状态")
|
||
}
|
||
reqURL := fmt.Sprintf("%s/api/redeem/%s/refresh", c.BaseURL(), url.PathEscape(code))
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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 redeem refresh 失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 redeem refresh 响应失败: %w", err)
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, &RockUpstreamStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||
}
|
||
|
||
var res RockRefreshResponse
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
return nil, fmt.Errorf("解析 redeem 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
|
||
}
|