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 }