Files
affiliate_dash/backend/internal/service/rock_client.go
T
yml2213 ef7f67dd0c feat(fulfillment): 接入Rock发货平台(0666.pro)代理履约、通用库存池与UID强一致风控
1. 数据库新增 012_rock_fulfillment 迁移,创建 rock_configs 配置表与 rock_tokens 通用库存表,补齐黑色ROCK长袖T恤与蓝色运动服上衣商品目录;
2. 封装 RockClient 与 RockService,实现 0666.pro 平台 Bearer Token 极简连通校验、批量采购50个入库、水位监控巡检与自动补货;
3. 新增 RockProvider 实现标准履约路由,引入 Anti-Ghost 旧绑定基线比对与 UID 强一致门禁,扫错号硬阻断并记录为 mismatch 状态;
4. 针对黑色ROCK与蓝色运动服实现动态发货(绑定后发货前动态选择 skin),并提取腾讯官方发货流水号 fulfillId 对账;
5. 新增全局自动发货总开关(Kill Switch),支持一键软熔断降级转人工接单处理;
6. 前端新增 PlatformRock 管理大屏,提供总开关切换、Token配置与连通测试、库存监控、一键补货及错单解冻重置。
2026-10-05 14:56:11 +08:00

395 lines
12 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// 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 float64 `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
}