feat:2、刷新绑定信息,操作加一个刷新绑定状态的按钮,3、绑定错误有提示,手动上报异常,拿到包ID做链接跳转(定位)

This commit is contained in:
2026-10-29 18:15:40 +08:00
parent 8494fabc82
commit b27e128c5b
25 changed files with 1417 additions and 68 deletions
@@ -0,0 +1,27 @@
-- ============================================================================
-- 017_rock_using_warning.sql
-- Rock Token「扫码使用中」超时预警:记录变为 using 的时间 + 可配置阈值
-- ============================================================================
-- 1. 使用中起点:进入 using 时写入,释放/重置回 available 时清空。
ALTER TABLE rock_tokens
ADD COLUMN IF NOT EXISTS using_at TIMESTAMPTZ;
-- 只对 using 建部分索引,列表按状态过滤时可直接用上。
CREATE INDEX IF NOT EXISTS idx_rock_tokens_using_at
ON rock_tokens (using_at)
WHERE status = 'using';
-- 2. 历史数据无法拿到精确的“进入 using”时刻:
-- 0666.pro 只暴露 Token 创建时间,且与后续打包导入的口径保持一致,
-- 这里统一用 claimed_at(缺失时退回 created_at)近似回填。
UPDATE rock_tokens
SET using_at = COALESCE(claimed_at, created_at)
WHERE status = 'using' AND using_at IS NULL;
-- 3. 橙色/红色预警阈值(天),默认 3 / 5。
ALTER TABLE rock_configs
ADD COLUMN IF NOT EXISTS using_warn_days INT NOT NULL DEFAULT 3;
ALTER TABLE rock_configs
ADD COLUMN IF NOT EXISTS using_danger_days INT NOT NULL DEFAULT 5;
@@ -0,0 +1,7 @@
-- ============================================================================
-- 018_rock_package_share_code.sql
-- 保存 Token 所属上游打包记录的分享码,用于跳转 0666.pro 包页面定位
-- ============================================================================
ALTER TABLE rock_tokens
ADD COLUMN IF NOT EXISTS package_share_code VARCHAR(64) NOT NULL DEFAULT '';
+36
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"strconv"
"strings"
@@ -37,6 +38,11 @@ func (h *RockHandler) UpdateConfig(c *gin.Context) {
}
cfg, err := h.rockSvc.UpdateConfig(c.Request.Context(), req)
if err != nil {
var validationErr *service.RockConfigValidationError
if errors.As(err, &validationErr) {
response.BadRequest(c, validationErr.Error())
return
}
response.ServerError(c, err.Error())
return
}
@@ -180,6 +186,36 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
}
// RefreshBinding 人工刷新指定 Token 的上游绑定状态
func (h *RockHandler) RefreshBinding(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
result, err := h.rockSvc.RefreshTokenBinding(c.Request.Context(), uint(idUint))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, result)
}
// TokenPackageLink 返回 Token 所属上游包页面的定位链接
func (h *RockHandler) TokenPackageLink(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
result, err := h.rockSvc.TokenPackageLink(c.Request.Context(), uint(idUint))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, result)
}
type updateRemarkReq struct {
Remark string `json:"remark"`
Status string `json:"status"` // 可选:比如 abnormal
+4
View File
@@ -43,6 +43,8 @@ type RockConfig struct {
MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"`
TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_days"` // 使用中超过 N 天红色预警
AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
@@ -69,6 +71,7 @@ type RockToken struct {
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
@@ -87,6 +90,7 @@ type RockToken struct {
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
ClaimedAt *time.Time `json:"claimed_at"`
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
BoundAt *time.Time `json:"bound_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
CreatedAt time.Time `json:"created_at"`
+2
View File
@@ -293,6 +293,8 @@ func Setup(h *Handlers) *gin.Engine {
platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID)
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
platform.POST("/platform/rock/tokens/:id/remark", h.Rock.UpdateRemark)
platform.POST("/platform/rock/tokens/:id/abnormal", h.Rock.MarkAbnormal)
}
@@ -355,6 +355,7 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"using_at": nil,
}).Error
}
return nil
+2
View File
@@ -117,9 +117,11 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
return err
}
now := time.Now()
token.Status = model.RockStatusUsing
token.AssignedOrderNo = orderNo
token.ExpectedUID = strings.TrimSpace(expectedUID)
token.UsingAt = &now
return tx.Save(&token).Error
})
if err != nil {
@@ -0,0 +1,229 @@
package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"affiliate_dash/internal/model"
)
// TokenBindingRefreshResult 是人工“刷新绑定状态”的返回结果。
type TokenBindingRefreshResult struct {
Status string `json:"status"`
Bound bool `json:"bound"`
RoleName string `json:"role_name"`
RoleID string `json:"role_id"`
Channel string `json:"channel"`
Changed bool `json:"changed"`
Warning bool `json:"warning"`
Message string `json:"message"`
RedeemedAt string `json:"redeemed_at,omitempty"`
}
func canRefreshRockBinding(status string) bool {
switch status {
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch:
return true
default:
return false
}
}
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
//
// 规则:
// - 上游已发货 → 本地同步 redeemed;
// - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch;
// - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变;
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, err
}
if !canRefreshRockBinding(token.Status) {
return nil, errors.New("仅扫码使用中 / 已授权绑定 / 绑定错误的 Token 可以刷新绑定状态")
}
if strings.TrimSpace(token.RedeemCode) == "" {
return nil, errors.New("该 Token 缺少兑换码,无法刷新绑定状态")
}
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
ref, err := s.client.RefreshRedeemBinding(ctx, token.RedeemCode)
if err != nil {
// 公开端接口不可用时,降级到管理端 refresh(需要 upstream_token_id + Bearer)。
var statusErr *RockUpstreamStatusError
if errors.As(err, &statusErr) &&
(statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) &&
strings.TrimSpace(token.UpstreamTokenID) != "" {
ref, err = s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
}
if err != nil {
return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err)
}
}
if ref == nil {
return nil, errors.New("上游未返回刷新结果")
}
// 上游请求期间本地状态可能已被买家轮询改写,落库前重新读取。
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, err
}
if !canRefreshRockBinding(token.Status) {
return nil, fmt.Errorf("Token 状态已变为 %s,无法再刷新绑定状态", token.Status)
}
now := time.Now()
originalStatus := token.Status
originalRoleName := token.ActualRoleName
originalRoleID := token.ActualRoleID
originalChannel := token.ActualChannel
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
expectedUID := strings.TrimSpace(token.ExpectedUID)
updates := map[string]interface{}{
"updated_at": now,
}
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
if ref.IsBound() {
if actualRoleName != "" {
updates["actual_role_name"] = actualRoleName
}
if actualRoleID != "" {
updates["actual_role_id"] = actualRoleID
}
if actualChannel != "" {
updates["actual_channel"] = actualChannel
}
}
result := &TokenBindingRefreshResult{
Status: token.Status,
Bound: ref.IsBound(),
}
if ref.IsBound() {
result.RoleName = actualRoleName
result.RoleID = actualRoleID
result.Channel = actualChannel
}
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt)
switch {
case upstreamStatus == model.RockStatusRedeemed || redeemedAtRaw != "":
redeemedAt, ok := parseRockTime(redeemedAtRaw)
if !ok {
redeemedAt = now
}
updates["status"] = model.RockStatusRedeemed
updates["redeemed_at"] = redeemedAt
if len(ref.DeliveryList) > 0 {
if fulfillID := ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID; fulfillID > 0 {
updates["upstream_fulfill_id"] = fulfillID
}
}
result.Status = model.RockStatusRedeemed
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
result.Message = "上游已发货,本地状态已同步为已交付"
case ref.IsBound() && actualRoleID == "":
result.Warning = true
result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对"
case ref.IsBound() && expectedUID == "":
// 没有预期 UID 无法核验,只同步角色信息。
result.Warning = true
result.Message = fmt.Sprintf(
"上游已绑定「%s」(UID %s),但本地缺少预期 UID,已同步角色信息,请人工核对",
displayRockRole(actualRoleName, actualRoleID), actualRoleID,
)
case ref.IsBound() && token.Status == model.RockStatusMismatch && actualRoleID == expectedUID:
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
if token.BoundAt == nil {
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
result.Message = "上游绑定 UID 与预期一致,已解除绑定错误"
case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref):
result.Warning = true
result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变"
case ref.IsBound() && actualRoleID != expectedUID:
reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)
updates["status"] = model.RockStatusMismatch
updates["mismatch_reason"] = reason
if token.MismatchAt == nil {
updates["mismatch_at"] = now
}
result.Status = model.RockStatusMismatch
result.Warning = true
result.Message = fmt.Sprintf(
"上游绑定「%s」(UID %s) 与预期 UID(%s) 不一致,已标记绑定错误",
displayRockRole(actualRoleName, actualRoleID), actualRoleID, expectedUID,
)
case ref.IsBound():
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
if token.BoundAt == nil {
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
result.Message = "上游已绑定,UID 核验一致"
default:
result.Message = "上游仍未绑定,本地状态保持不变"
}
result.Changed = result.Status != originalStatus ||
(ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) ||
(ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) ||
(ref.IsBound() && actualChannel != "" && actualChannel != originalChannel)
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
return nil, err
}
_ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.refresh_binding", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"status": result.Status,
"role_id": result.RoleID,
"message": result.Message,
})
return result, nil
}
func displayRockRole(name, roleID string) string {
if strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return strings.TrimSpace(roleID)
}
func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time {
if ref != nil {
if t, ok := parseRockTime(ref.Token.BindChangedAt); ok {
return t
}
}
return fallback
}
@@ -0,0 +1,377 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
)
func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/api/redeem/CODE-BOUND/refresh" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"region": "安卓-微信",
},
"binding": map[string]interface{}{
"bound": true,
"role": "痛写心扉",
"roleId": "3132710334",
"region": "安卓-微信",
},
"message": "已刷新绑定信息",
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "9001",
Token: "jwt-9001",
RedeemCode: "CODE-BOUND",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-1",
ExpectedUID: "3132710334",
ClaimedAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusBound || !res.Bound {
t.Fatalf("期望 bound,实际 status=%s bound=%v", res.Status, res.Bound)
}
if res.RoleID != "3132710334" || res.RoleName != "痛写心扉" {
t.Fatalf("角色信息未同步: %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" || saved.ActualRoleName != "痛写心扉" {
t.Fatalf("数据库未更新: %+v", saved)
}
if saved.BoundAt == nil {
t.Fatalf("bound_at 未写入")
}
}
func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"region": "安卓-微信",
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "9002",
RedeemCode: "CODE-MISMATCH",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-2",
ExpectedUID: "1111111111",
ClaimedAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusMismatch || !strings.Contains(res.Message, "不一致") {
t.Fatalf("期望 mismatch 提示,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusMismatch || saved.MismatchAt == nil || !strings.Contains(saved.MismatchReason, "3132710334") {
t.Fatalf("数据库 mismatch 字段不符: %+v", saved)
}
}
func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"binding": map[string]interface{}{
"bound": true,
"role": "痛写心扉",
"roleId": "3132710334",
"region": "安卓-微信",
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9003",
RedeemCode: "CODE-NOUID",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusUsing {
t.Fatalf("缺少预期 UID 时状态应保持 using,实际 %s", res.Status)
}
if !strings.Contains(res.Message, "人工核对") {
t.Fatalf("应提示人工核对,实际 %q", res.Message)
}
if !res.Warning {
t.Fatalf("缺少预期 UID 时应返回 warning=true")
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "3132710334" {
t.Fatalf("只应同步角色信息,实际 %+v", saved)
}
}
func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
"redeemed_at": "2026-10-29 12:00:00",
},
"deliveryList": []map[string]interface{}{
{"fulfillId": 4519960, "time": "2026-10-29T12:00:00.1", "role": "安卓-微信-痛写心扉"},
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9004",
RedeemCode: "CODE-REDEEMED",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-4",
ExpectedUID: "3132710334",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusRedeemed {
t.Fatalf("期望 redeemed,实际 %s", res.Status)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusRedeemed || saved.RedeemedAt == nil || saved.UpstreamFulfillID != 4519960 {
t.Fatalf("已发货字段未同步: %+v", saved)
}
}
func TestRefreshTokenBinding_UpstreamErrorKeepsState(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"refresh down"}`))
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9005",
RedeemCode: "CODE-ERROR",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err == nil {
t.Fatalf("上游 500 时应返回错误")
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "" {
t.Fatalf("失败时不应改动本地状态: %+v", saved)
}
}
func TestRefreshTokenBinding_FallsBackToAdminRefresh(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/redeem/CODE-FALLBACK/refresh":
w.WriteHeader(http.StatusNotFound)
case "/api/user/tokens/9006/refresh":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "痛写心扉",
"bound_account_id": "3132710334",
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "9006",
RedeemCode: "CODE-FALLBACK",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "FO-REFRESH-6",
ExpectedUID: "3132710334",
ClaimedAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("降级刷新失败: %v", err)
}
if res.Status != model.RockStatusBound {
t.Fatalf("降级后期望 bound,实际 %s", res.Status)
}
}
func TestRefreshTokenBinding_NotBoundKeepsRoleFields(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "using",
"region": "Q/V可用",
},
"binding": map[string]interface{}{"bound": false},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "9008",
RedeemCode: "CODE-UNBOUND",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
ActualRoleName: "旧角色",
ActualRoleID: "123456",
ActualChannel: "安卓-微信",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.RefreshTokenBinding(context.Background(), token.ID)
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
if res.Status != model.RockStatusUsing || res.Bound {
t.Fatalf("上游未绑定时应保持 using 且 bound=false,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.ActualRoleName != "旧角色" || saved.ActualRoleID != "123456" || saved.ActualChannel != "安卓-微信" {
t.Fatalf("上游未绑定时不应覆盖本地角色字段: %+v", saved)
}
}
func TestRefreshTokenBinding_RejectsAvailableToken(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
token := model.RockToken{
UpstreamTokenID: "9007",
RedeemCode: "CODE-AVAILABLE",
Color: model.RockColorBlack,
Status: model.RockStatusAvailable,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RefreshTokenBinding(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "仅扫码使用中") {
t.Fatalf("available 状态应被拒绝,实际 err=%v", err)
}
}
+83 -4
View File
@@ -191,27 +191,60 @@ type RockDeliveryItem struct {
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 {
return r.Token.IsBound()
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 {
return r.Token.GetRoleName()
if r == nil {
return ""
}
if name := r.Token.GetRoleName(); name != "" {
return name
}
return strings.TrimSpace(r.Binding.Role)
}
func (r *RockRefreshResponse) GetRoleID() string {
return r.Token.GetRoleID()
if r == nil {
return ""
}
if id := r.Token.GetRoleID(); id != "" {
return id
}
return strings.TrimSpace(r.Binding.RoleID)
}
func (r *RockRefreshResponse) GetChannel() string {
return r.Token.GetChannel()
if r == nil {
return ""
}
if channel := r.Token.GetChannel(); channel != "" {
return channel
}
return strings.TrimSpace(r.Binding.Region)
}
type RockRedeemResponse struct {
@@ -649,6 +682,52 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr
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))
+22
View File
@@ -20,6 +20,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
}
packageID := detail.Package.ID
shareCode := strings.TrimSpace(detail.Package.ShareCode)
inserted, updated, skipped := 0, 0, 0
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, pt := range detail.Tokens {
@@ -44,6 +45,15 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
if _, ok := forceAvailableIDs[upstreamID]; ok {
insertStatus = model.RockStatusAvailable
}
// 上游不提供“进入 using”的时间,用 Token 创建时间近似(与 017 回填口径一致)。
var usingAt *time.Time
if insertStatus == model.RockStatusUsing {
usingAt = claimedAt
if usingAt == nil {
now := time.Now()
usingAt = &now
}
}
rec := model.RockToken{
UpstreamTokenID: upstreamID,
Token: strings.TrimSpace(pt.Token),
@@ -52,6 +62,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
Region: defaultValue(strings.TrimSpace(pt.Region), "Q/V可用"),
Status: insertStatus,
PackageID: &packageID,
PackageShareCode: shareCode,
ActualRoleName: strings.TrimSpace(pt.BoundAccount),
ActualRoleID: strings.TrimSpace(pt.BoundAccountID),
ActualChannel: strings.TrimSpace(pt.Region),
@@ -61,6 +72,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
RawData: string(raw),
Source: "package",
ClaimedAt: claimedAt,
UsingAt: usingAt,
BoundAt: boundAt,
RedeemedAt: redeemedAt,
}
@@ -78,6 +90,9 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
"package_id": packageID,
"raw_data": string(raw),
}
if existing.PackageShareCode == "" && shareCode != "" {
updates["package_share_code"] = shareCode
}
// 已存在的本地 Token 只补空字段,避免覆盖手动采购/正在履约的本地状态。
if existing.Token == "" {
updates["token"] = strings.TrimSpace(pt.Token)
@@ -102,6 +117,13 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
existing.Status != model.RockStatusAvailable
if canRefreshUpstream {
updates["status"] = status
if status == model.RockStatusUsing && existing.UsingAt == nil {
if claimedAt != nil {
updates["using_at"] = claimedAt
} else {
updates["using_at"] = time.Now()
}
}
if strings.TrimSpace(pt.BoundAccount) != "" {
updates["actual_role_name"] = strings.TrimSpace(pt.BoundAccount)
}
@@ -0,0 +1,49 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"affiliate_dash/internal/model"
)
// TokenPackageLinkResult 是 Token 所属上游打包页面的定位链接。
type TokenPackageLinkResult struct {
PackageID int64 `json:"package_id"`
ShareCode string `json:"share_code"`
URL string `json:"url"`
}
// TokenPackageLink 返回 Token 所属上游包页面的跳转链接。
// 只对本地已保存分享码的新包生效;历史包不向上游反查。
func (s *RockService) TokenPackageLink(ctx context.Context, tokenID uint) (*TokenPackageLinkResult, error) {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, err
}
if token.PackageID == nil || *token.PackageID <= 0 {
return nil, errors.New("该 Token 没有关联上游打包记录,无法跳转上游")
}
shareCode := strings.TrimSpace(token.PackageShareCode)
if shareCode == "" {
return nil, errors.New("该 Token 缺少上游包分享码(历史包不支持定位),无法跳转上游")
}
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if base == "" {
base = model.RockDefaultBaseURL
}
return &TokenPackageLinkResult{
PackageID: *token.PackageID,
ShareCode: shareCode,
URL: fmt.Sprintf("%s/#/package/%s", base, url.PathEscape(shareCode)),
}, nil
}
@@ -0,0 +1,107 @@
package service
import (
"context"
"encoding/json"
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestTokenPackageLink_BuildsUpstreamURL(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
packageID := int64(1507)
token := model.RockToken{
UpstreamTokenID: "9101",
RedeemCode: "CODE-LINK",
Color: model.RockColorBlack,
Status: model.RockStatusMismatch,
PackageID: &packageID,
PackageShareCode: "share-abc",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.TokenPackageLink(context.Background(), token.ID)
if err != nil {
t.Fatalf("生成包链接失败: %v", err)
}
if res.PackageID != 1507 || res.ShareCode != "share-abc" {
t.Fatalf("包信息不符: %+v", res)
}
if res.URL != "https://0666.pro/#/package/share-abc" {
t.Fatalf("包链接不符: %s", res.URL)
}
}
func TestTokenPackageLink_RejectsWithoutPackage(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
token := model.RockToken{
UpstreamTokenID: "9102",
RedeemCode: "CODE-NOPKG",
Color: model.RockColorBlack,
Status: model.RockStatusMismatch,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "没有关联上游打包记录") {
t.Fatalf("无包 Token 应被拒绝,实际 err=%v", err)
}
}
func TestTokenPackageLink_RejectsMissingShareCode(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
packageID := int64(1508)
token := model.RockToken{
UpstreamTokenID: "9103",
RedeemCode: "CODE-NOSHARE",
Color: model.RockColorBlack,
Status: model.RockStatusMismatch,
PackageID: &packageID,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.TokenPackageLink(context.Background(), token.ID); err == nil || !strings.Contains(err.Error(), "缺少上游包分享码") {
t.Fatalf("缺少分享码应被拒绝,实际 err=%v", err)
}
}
func TestImportPackageDetail_StoresPackageShareCode(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
var detail RockPackageDetailResponse
raw := `{
"package": {"id": 9009, "share_code": "share-import"},
"tokens": [
{"id": 39001, "token": "t1", "redeem_code": "c1", "color": "black", "status": "using", "created_at": "2026-10-27 23:31:59"}
]
}`
if err := json.Unmarshal([]byte(raw), &detail); err != nil {
t.Fatalf("解析包详情失败: %v", err)
}
if _, _, _, err := svc.importPackageDetail(context.Background(), &detail, nil); err != nil {
t.Fatalf("导入包详情失败: %v", err)
}
var row model.RockToken
if err := db.Where("upstream_token_id = ?", "39001").First(&row).Error; err != nil {
t.Fatalf("查询导入 Token 失败: %v", err)
}
if row.PackageShareCode != "share-import" {
t.Fatalf("package_share_code 未写入,实际 %q", row.PackageShareCode)
}
}
@@ -115,6 +115,9 @@ func TestPurchaseTokens_CreatesPackageAndImportsAvailable(t *testing.T) {
if row.PackageID == nil || *row.PackageID != 9001 {
t.Fatalf("package_id 未写入: %+v", row.PackageID)
}
if row.PackageShareCode != "share123" {
t.Fatalf("package_share_code 未写入,实际 %q", row.PackageShareCode)
}
if row.Token == "" || row.RedeemCode == "" {
t.Fatalf("提取码/兑换码未写入: %+v", row)
}
@@ -169,6 +172,9 @@ func TestPurchaseTokens_PackageFailureFallsBackToPurchase(t *testing.T) {
if row.Status != model.RockStatusAvailable || row.Source != "purchase" || row.PackageID != nil {
t.Fatalf("兜底入库字段不符: status=%s source=%s package_id=%v", row.Status, row.Source, row.PackageID)
}
if row.PackageShareCode != "" {
t.Fatalf("打包创建失败时不应有分享码,实际 %q", row.PackageShareCode)
}
cfg, _ := svc.GetConfig(context.Background())
if !strings.Contains(cfg.LastError, "创建上游打包记录失败") {
@@ -223,6 +229,9 @@ func TestPurchaseTokens_PackageDetailFailureFallsBackWithPackageID(t *testing.T)
if row.PackageID == nil || *row.PackageID != 9002 {
t.Fatalf("兜底入库应保留 package_id=9002,实际 %v", row.PackageID)
}
if row.PackageShareCode != "detailfail" {
t.Fatalf("兜底入库应保留 package_share_code=detailfail,实际 %q", row.PackageShareCode)
}
cfg, _ := svc.GetConfig(context.Background())
if !strings.Contains(cfg.LastError, "详情拉取失败") {
+4
View File
@@ -122,6 +122,7 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"using_at": nil,
"updated_at": time.Now(),
}).Error
}
@@ -144,6 +145,9 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
updates["status"] = trimmedStatus
token.Status = trimmedStatus
if trimmedStatus == model.RockStatusAvailable {
updates["using_at"] = nil
}
if trimmedStatus == model.RockStatusAbnormal {
s.refreshCache.Delete(token.UpstreamTokenID)
}
+1
View File
@@ -133,6 +133,7 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"updated_at": time.Now(),
}).Error; err != nil {
return err
+34
View File
@@ -55,12 +55,23 @@ type RockDashboardResult struct {
CostUpdated bool `json:"cost_updated"`
}
// RockConfigValidationError 表示 Rock 配置参数不合法,handler 会转成 400。
type RockConfigValidationError struct {
Message string
}
func (e *RockConfigValidationError) Error() string {
return e.Message
}
type UpdateRockConfigReq struct {
AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
BaseURL *string `json:"base_url"`
BearerToken *string `json:"bearer_token"`
MinPointsWarning *float64 `json:"min_points_warning"`
TokenWarningStock *int `json:"token_warning_stock"`
UsingWarnDays *int `json:"using_warn_days"`
UsingDangerDays *int `json:"using_danger_days"`
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
AutoPurchaseCount *int `json:"auto_purchase_count"`
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
@@ -79,6 +90,8 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error)
MinPointsWarning: 1050.00,
BlackTokenCost: 20.00,
TokenWarningStock: 10,
UsingWarnDays: 3,
UsingDangerDays: 5,
AutoPurchaseEnabled: false,
AutoPurchaseCount: 50,
PurchaseCooldownMinutes: 10,
@@ -142,6 +155,27 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes
}
// 使用中超时预警阈值:橙色必须小于红色,否则前端无法区分两级。
warnDays := cfg.UsingWarnDays
if req.UsingWarnDays != nil {
warnDays = *req.UsingWarnDays
updates["using_warn_days"] = warnDays
}
dangerDays := cfg.UsingDangerDays
if req.UsingDangerDays != nil {
dangerDays = *req.UsingDangerDays
updates["using_danger_days"] = dangerDays
}
if warnDays < 1 || warnDays > 365 {
return nil, &RockConfigValidationError{Message: "使用中橙色预警天数必须在 1-365 之间"}
}
if dangerDays < 1 || dangerDays > 365 {
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须在 1-365 之间"}
}
if dangerDays <= warnDays {
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
+14 -13
View File
@@ -113,30 +113,30 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
pkgErr = errors.New("上游打包接口未返回内容")
}
if pkgErr != nil {
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "purchase", now,
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "", "purchase", now,
"创建上游打包记录失败: "+pkgErr.Error())
}
// 2. 拉取包详情(带重试)
detail, detailErr := s.getPackageDetailWithRetry(ctx, shareCode)
if detailErr != nil {
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
fmt.Sprintf("包 %s 详情拉取失败: %v", shareCode, detailErr))
}
// 3. 导入包内 Token:本批刚 claim 的 Token 强制 available,其余沿用上游状态
inserted, updated, skipped, importErr := s.importPackageDetail(ctx, detail, claimedSet)
if importErr != nil {
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
fmt.Sprintf("包 %s 导入失败: %v", shareCode, importErr))
}
if inserted+updated == 0 {
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, "package", now,
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
fmt.Sprintf("包 %s 未返回有效 Token", shareCode))
}
// 4. 极端情况下详情缺 Token:用 claim 数据补齐,保证每个已扣积分的 Token 都在本地
ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, "package", now)
ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, shareCode, "package", now)
if ensureErr != nil {
s.recordPurchaseFailure(ctx, cfg.ID, "补录缺失 Token 失败: "+ensureErr.Error())
}
@@ -146,7 +146,7 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
if findErr != nil {
log.Printf("[RockService] 回查采购 Token 失败: %v", findErr)
}
dbTokens = s.buildClaimedTokens(claimed, packageID, "package", now)
dbTokens = s.buildClaimedTokens(claimed, packageID, shareCode, "package", now)
}
if ensureErr == nil {
@@ -158,8 +158,8 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
}
// fallbackClaimedTokens 在打包/详情/导入失败时,用 claim 返回的数据直接入库,避免积分白花。
func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, claimed []RockClaimedToken, packageID int64, source string, at time.Time, reason string) ([]model.RockToken, error) {
tokens, err := s.insertClaimedTokens(ctx, claimed, packageID, source, at)
func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time, reason string) ([]model.RockToken, error) {
tokens, err := s.insertClaimedTokens(ctx, claimed, packageID, shareCode, source, at)
if err != nil {
s.recordPurchaseFailure(ctx, cfgID, reason+";兜底入库失败: "+err.Error())
return nil, fmt.Errorf("%s;兜底入库失败: %w", reason, err)
@@ -169,8 +169,8 @@ func (s *RockService) fallbackClaimedTokens(ctx context.Context, cfgID uint, cla
}
// insertClaimedTokens 把 claim 结果直接写入库存表。
func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) ([]model.RockToken, error) {
tokens := s.buildClaimedTokens(claimed, packageID, source, at)
func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) ([]model.RockToken, error) {
tokens := s.buildClaimedTokens(claimed, packageID, shareCode, source, at)
if len(tokens) == 0 {
return nil, errors.New("没有可入库的 Token")
}
@@ -183,8 +183,8 @@ func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockCla
}
// ensureClaimedTokens 补录本地缺失的 claim Token(详情返回不完整时使用)。
func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, source string, at time.Time) error {
tokens := s.buildClaimedTokens(claimed, packageID, source, at)
func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) error {
tokens := s.buildClaimedTokens(claimed, packageID, shareCode, source, at)
if len(tokens) == 0 {
return nil
}
@@ -217,7 +217,7 @@ func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockCla
})
}
func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID int64, source string, at time.Time) []model.RockToken {
func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID int64, shareCode, source string, at time.Time) []model.RockToken {
out := make([]model.RockToken, 0, len(claimed))
for _, t := range claimed {
if t.ID <= 0 {
@@ -241,6 +241,7 @@ func (s *RockService) buildClaimedTokens(claimed []RockClaimedToken, packageID i
if packageID > 0 {
pid := packageID
rec.PackageID = &pid
rec.PackageShareCode = strings.TrimSpace(shareCode)
}
out = append(out, rec)
}
@@ -0,0 +1,158 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
func TestAssignTokenForOrder_SetsUsingAtAndReleaseClears(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/api/redeem/"):
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": 151153,
"redeem_code": "CODE-USING-1",
"bind_url": "https://gp.qq.com/cp/mock_bind",
})
case strings.HasSuffix(r.URL.Path, "/refresh"):
_ = json.NewEncoder(w).Encode(map[string]interface{}{})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
token := model.RockToken{
UpstreamTokenID: "88001",
Token: "jwt-using",
RedeemCode: "CODE-USING-1",
Color: model.RockColorBlack,
Status: model.RockStatusAvailable,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建可用 Token 失败: %v", err)
}
assigned, _, err := svc.AssignTokenForOrder(context.Background(), "ORDER-USING-1", "12345678")
if err != nil {
t.Fatalf("分配 Token 失败: %v", err)
}
if assigned.Status != model.RockStatusUsing {
t.Fatalf("分配后状态应为 using,实际 %s", assigned.Status)
}
if assigned.UsingAt == nil {
t.Fatalf("分配后 using_at 必须写入")
}
var stored model.RockToken
if err := db.First(&stored, assigned.ID).Error; err != nil {
t.Fatalf("重新读取 Token 失败: %v", err)
}
if stored.UsingAt == nil {
t.Fatalf("数据库中 using_at 未写入")
}
if err := svc.ReleaseUnboundUsingToken(context.Background(), assigned.ID); err != nil {
t.Fatalf("释放未绑定 Token 失败: %v", err)
}
var released model.RockToken
if err := db.First(&released, assigned.ID).Error; err != nil {
t.Fatalf("读取释放后的 Token 失败: %v", err)
}
if released.Status != model.RockStatusAvailable {
t.Fatalf("释放后状态应为 available,实际 %s", released.Status)
}
if released.UsingAt != nil {
t.Fatalf("释放回库存后 using_at 必须清空,实际 %v", released.UsingAt)
}
}
func TestImportPackageDetail_SetsUsingAtFromClaimedAt(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
var detail RockPackageDetailResponse
raw := `{
"package": {"id": 9001, "share_code": "share-using"},
"tokens": [
{"id": 30001, "token": "t1", "redeem_code": "c1", "color": "black", "status": "using", "created_at": "2026-10-27 23:31:59"}
]
}`
if err := json.Unmarshal([]byte(raw), &detail); err != nil {
t.Fatalf("解析包详情失败: %v", err)
}
inserted, _, _, err := svc.importPackageDetail(context.Background(), &detail, nil)
if err != nil {
t.Fatalf("导入包详情失败: %v", err)
}
if inserted != 1 {
t.Fatalf("期望新增 1 条,实际 %d", inserted)
}
var row model.RockToken
if err := db.Where("upstream_token_id = ?", "30001").First(&row).Error; err != nil {
t.Fatalf("查询导入 Token 失败: %v", err)
}
if row.Status != model.RockStatusUsing {
t.Fatalf("期望状态 using,实际 %s", row.Status)
}
if row.UsingAt == nil {
t.Fatalf("上游状态为 using 时必须写入 using_at")
}
expected := time.Date(2026, 10, 27, 23, 31, 59, 0, timeutil.Location())
if !row.UsingAt.Equal(expected) {
t.Fatalf("using_at 应取上游 claimed_at,实际 %v", row.UsingAt)
}
}
func TestUpdateConfig_UsingWarningDaysValidation(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
cfg, err := svc.GetConfig(ctx)
if err != nil {
t.Fatalf("获取配置失败: %v", err)
}
if cfg.UsingWarnDays != 3 || cfg.UsingDangerDays != 5 {
t.Fatalf("默认阈值应为 3/5,实际 %d/%d", cfg.UsingWarnDays, cfg.UsingDangerDays)
}
warnSix := 6
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnSix}); err == nil {
t.Fatalf("橙色预警 6 天大于默认红色 5 天时必须报错")
}
warnFour, dangerFour := 4, 4
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnFour, UsingDangerDays: &dangerFour}); err == nil {
t.Fatalf("红色预警不大于橙色预警时必须报错")
}
warnZero := 0
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnZero}); err == nil {
t.Fatalf("预警天数小于 1 时必须报错")
}
warnTwo, dangerFourAgain := 2, 4
updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{UsingWarnDays: &warnTwo, UsingDangerDays: &dangerFourAgain})
if err != nil {
t.Fatalf("合法阈值保存失败: %v", err)
}
if updated.UsingWarnDays != 2 || updated.UsingDangerDays != 4 {
t.Fatalf("阈值未保存,实际 %d/%d", updated.UsingWarnDays, updated.UsingDangerDays)
}
}