From b27e128c5bcb473a94a38e0b0f3f85ae93163e91 Mon Sep 17 00:00:00 2001
From: zhaoxing <2558691827@qq.com>
Date: Thu, 29 Oct 2026 18:15:40 +0800
Subject: [PATCH] =?UTF-8?q?feat:2=E3=80=81=E5=88=B7=E6=96=B0=E7=BB=91?=
=?UTF-8?q?=E5=AE=9A=E4=BF=A1=E6=81=AF,=E6=93=8D=E4=BD=9C=E5=8A=A0?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=88=B7=E6=96=B0=E7=BB=91=E5=AE=9A=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E7=9A=84=E6=8C=89=E9=92=AE=EF=BC=8C3=E3=80=81?=
=?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=94=99=E8=AF=AF=E6=9C=89=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=EF=BC=8C=E6=89=8B=E5=8A=A8=E4=B8=8A=E6=8A=A5=E5=BC=82=E5=B8=B8?=
=?UTF-8?q?=EF=BC=8C=E6=8B=BF=E5=88=B0=E5=8C=85ID=E5=81=9A=E9=93=BE?=
=?UTF-8?q?=E6=8E=A5=E8=B7=B3=E8=BD=AC=EF=BC=88=E5=AE=9A=E4=BD=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../migrations/017_rock_using_warning.sql | 27 ++
.../018_rock_package_share_code.sql | 7 +
backend/internal/handler/rock.go | 36 ++
backend/internal/model/rock.go | 4 +
backend/internal/router/router.go | 2 +
backend/internal/service/provider_rock.go | 1 +
backend/internal/service/rock_assign.go | 2 +
.../internal/service/rock_binding_refresh.go | 229 +++++++++++
.../service/rock_binding_refresh_test.go | 377 ++++++++++++++++++
backend/internal/service/rock_client.go | 87 +++-
backend/internal/service/rock_package.go | 22 +
backend/internal/service/rock_package_link.go | 49 +++
.../service/rock_package_link_test.go | 107 +++++
.../service/rock_purchase_package_test.go | 9 +
backend/internal/service/rock_recover.go | 4 +
backend/internal/service/rock_redeem.go | 1 +
backend/internal/service/rock_service.go | 34 ++
backend/internal/service/rock_stock.go | 27 +-
.../internal/service/rock_using_at_test.go | 158 ++++++++
docs/项目梳理-业务流程与数据模型.md | 4 +-
frontend/src/api/index.ts | 30 ++
frontend/src/pages/PlatformRock.tsx | 7 +
.../pages/platformRock/RockStrategyCard.tsx | 18 +
.../src/pages/platformRock/RockTokenTable.tsx | 57 +++
.../src/pages/platformRock/tokenColumns.tsx | 186 ++++++---
25 files changed, 1417 insertions(+), 68 deletions(-)
create mode 100644 backend/internal/database/migrations/017_rock_using_warning.sql
create mode 100644 backend/internal/database/migrations/018_rock_package_share_code.sql
create mode 100644 backend/internal/service/rock_binding_refresh.go
create mode 100644 backend/internal/service/rock_binding_refresh_test.go
create mode 100644 backend/internal/service/rock_package_link.go
create mode 100644 backend/internal/service/rock_package_link_test.go
create mode 100644 backend/internal/service/rock_using_at_test.go
diff --git a/backend/internal/database/migrations/017_rock_using_warning.sql b/backend/internal/database/migrations/017_rock_using_warning.sql
new file mode 100644
index 0000000..467b503
--- /dev/null
+++ b/backend/internal/database/migrations/017_rock_using_warning.sql
@@ -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;
diff --git a/backend/internal/database/migrations/018_rock_package_share_code.sql b/backend/internal/database/migrations/018_rock_package_share_code.sql
new file mode 100644
index 0000000..810528d
--- /dev/null
+++ b/backend/internal/database/migrations/018_rock_package_share_code.sql
@@ -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 '';
diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go
index 7de9102..e2b9c10 100644
--- a/backend/internal/handler/rock.go
+++ b/backend/internal/handler/rock.go
@@ -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
diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go
index 56855a3..f53299a 100644
--- a/backend/internal/model/rock.go
+++ b/backend/internal/model/rock.go
@@ -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"`
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 4a2a439..ba4dabc 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -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)
}
diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go
index 62ec289..043cb86 100644
--- a/backend/internal/service/provider_rock.go
+++ b/backend/internal/service/provider_rock.go
@@ -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
diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go
index 5f4fa5a..1c3d344 100644
--- a/backend/internal/service/rock_assign.go
+++ b/backend/internal/service/rock_assign.go
@@ -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 {
diff --git a/backend/internal/service/rock_binding_refresh.go b/backend/internal/service/rock_binding_refresh.go
new file mode 100644
index 0000000..b6235df
--- /dev/null
+++ b/backend/internal/service/rock_binding_refresh.go
@@ -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
+}
diff --git a/backend/internal/service/rock_binding_refresh_test.go b/backend/internal/service/rock_binding_refresh_test.go
new file mode 100644
index 0000000..0de9058
--- /dev/null
+++ b/backend/internal/service/rock_binding_refresh_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/service/rock_client.go b/backend/internal/service/rock_client.go
index 6ab0e1a..aaa306b 100644
--- a/backend/internal/service/rock_client.go
+++ b/backend/internal/service/rock_client.go
@@ -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))
diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go
index 8bc8843..f7a6363 100644
--- a/backend/internal/service/rock_package.go
+++ b/backend/internal/service/rock_package.go
@@ -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)
}
diff --git a/backend/internal/service/rock_package_link.go b/backend/internal/service/rock_package_link.go
new file mode 100644
index 0000000..4902d82
--- /dev/null
+++ b/backend/internal/service/rock_package_link.go
@@ -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
+}
diff --git a/backend/internal/service/rock_package_link_test.go b/backend/internal/service/rock_package_link_test.go
new file mode 100644
index 0000000..620c4e1
--- /dev/null
+++ b/backend/internal/service/rock_package_link_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/service/rock_purchase_package_test.go b/backend/internal/service/rock_purchase_package_test.go
index 10313bb..c963262 100644
--- a/backend/internal/service/rock_purchase_package_test.go
+++ b/backend/internal/service/rock_purchase_package_test.go
@@ -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, "详情拉取失败") {
diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go
index 949ab0a..abc9329 100644
--- a/backend/internal/service/rock_recover.go
+++ b/backend/internal/service/rock_recover.go
@@ -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)
}
diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go
index 3bf15c2..8572d3f 100644
--- a/backend/internal/service/rock_redeem.go
+++ b/backend/internal/service/rock_redeem.go
@@ -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
diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go
index e9c484b..2ebe2ac 100644
--- a/backend/internal/service/rock_service.go
+++ b/backend/internal/service/rock_service.go
@@ -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 {
diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go
index dd6db45..e234e5e 100644
--- a/backend/internal/service/rock_stock.go
+++ b/backend/internal/service/rock_stock.go
@@ -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)
}
diff --git a/backend/internal/service/rock_using_at_test.go b/backend/internal/service/rock_using_at_test.go
new file mode 100644
index 0000000..1582fdf
--- /dev/null
+++ b/backend/internal/service/rock_using_at_test.go
@@ -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)
+ }
+}
diff --git a/docs/项目梳理-业务流程与数据模型.md b/docs/项目梳理-业务流程与数据模型.md
index f85d228..222c7c1 100644
--- a/docs/项目梳理-业务流程与数据模型.md
+++ b/docs/项目梳理-业务流程与数据模型.md
@@ -628,8 +628,8 @@ stateDiagram-v2
**R30:迁移是显式 SQL + checksum + advisory lock,只前进不回滚。**
-- 16 个迁移按文件名排序执行;`schema_migrations(version, name, checksum, applied_at)`;已执行迁移 checksum 不一致直接启动失败;整个迁移在事务内,并用 `pg_advisory_xact_lock` 防并发(`database/migrate.go:31-116`)。
-- 已执行迁移不可修改;`015_rock_packages_final.sql` 是 Rock 打包/采购字段的合并最终态(原 015/016/017);`016_rock_black_token_cost.sql` 给 `rock_configs` 增加 `black_token_cost`,后续新增变更必须从 `017_*.sql` 开始(`docs/项目分析.md:228-229`)。
+- 18 个迁移按文件名排序执行;`schema_migrations(version, name, checksum, applied_at)`;已执行迁移 checksum 不一致直接启动失败;整个迁移在事务内,并用 `pg_advisory_xact_lock` 防并发(`database/migrate.go:31-116`)。
+- 已执行迁移不可修改;`015_rock_packages_final.sql` 是 Rock 打包/采购字段的合并最终态(原 015/016/017);`016_rock_black_token_cost.sql` 给 `rock_configs` 增加 `black_token_cost`;`017_rock_using_warning.sql` 给 `rock_tokens` 增加 `using_at`、给 `rock_configs` 增加使用中超时预警天数;`018_rock_package_share_code.sql` 给 `rock_tokens` 增加 `package_share_code`,后续新增变更必须从 `019_*.sql` 开始(`docs/项目分析.md:228-229`)。
**R31:核心表没有数据库外键。**
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 054cd93..9c4c0de 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -575,6 +575,8 @@ export interface RockConfig {
black_token_cost: number;
min_points_warning: number;
token_warning_stock: number;
+ using_warn_days: number;
+ using_danger_days: number;
auto_purchase_enabled: boolean;
auto_purchase_count: number;
purchase_cooldown_minutes: number;
@@ -607,10 +609,29 @@ export interface RockDashboard {
cost_updated: boolean;
}
+export interface TokenBindingRefreshResult {
+ status: string;
+ bound: boolean;
+ role_name?: string;
+ role_id?: string;
+ channel?: string;
+ changed: boolean;
+ warning: boolean;
+ message: string;
+ redeemed_at?: string;
+}
+
+export interface TokenPackageLink {
+ package_id: number;
+ share_code: string;
+ url: string;
+}
+
export interface RockTokenItem {
id: number;
upstream_token_id: string;
package_id?: number;
+ package_share_code?: string;
token?: string;
redeem_code: string;
color: string;
@@ -632,6 +653,7 @@ export interface RockTokenItem {
remark?: string;
raw_data?: string;
claimed_at?: string;
+ using_at?: string;
bound_at?: string;
redeemed_at?: string;
created_at: string;
@@ -684,6 +706,14 @@ export const rockApi = {
request
.post(`/platform/rock/tokens/${id}/release`)
.then((r) => r.data.data),
+ refreshTokenBinding: (id: number) =>
+ request
+ .post(`/platform/rock/tokens/${id}/refresh-binding`)
+ .then((r) => r.data.data as TokenBindingRefreshResult),
+ getTokenPackageLink: (id: number) =>
+ request
+ .get(`/platform/rock/tokens/${id}/package-link`)
+ .then((r) => r.data.data as TokenPackageLink),
updateRemark: (id: number, data: { remark: string; status?: string }) =>
request
.post(`/platform/rock/tokens/${id}/remark`, data)
diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx
index 94ba6af..7657cca 100644
--- a/frontend/src/pages/PlatformRock.tsx
+++ b/frontend/src/pages/PlatformRock.tsx
@@ -73,6 +73,8 @@ export default function PlatformRock() {
bearer_token: cfg.bearer_token,
min_points_warning: cfg.min_points_warning,
token_warning_stock: cfg.token_warning_stock,
+ using_warn_days: cfg.using_warn_days ?? 3,
+ using_danger_days: cfg.using_danger_days ?? 5,
auto_purchase_count: cfg.auto_purchase_count,
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
})
@@ -262,6 +264,9 @@ export default function PlatformRock() {
const hasMismatch = (stats?.mismatch_stock ?? 0) > 0
const upstreamPoints = stats?.upstream_points ?? 0
const tokenCost = config?.black_token_cost && config.black_token_cost > 0 ? config.black_token_cost : 20
+ const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
+ const usingDangerDays =
+ config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
const estimatedCost = (purchaseCount || 0) * tokenCost
const isPointsInsufficient = estimatedCost > upstreamPoints
@@ -348,6 +353,8 @@ export default function PlatformRock() {