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配置与连通测试、库存监控、一键补货及错单解冻重置。
395 lines
12 KiB
Go
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
|
|
}
|