feat:Rock刷新去UID拦截,新增绑定错误自动刷新与使用/绑定超时预警
This commit is contained in:
@@ -175,6 +175,8 @@ func main() {
|
|||||||
log.Printf("[Rock] Token 检查失败: %v", err)
|
log.Printf("[Rock] Token 检查失败: %v", err)
|
||||||
}
|
}
|
||||||
rockSvc.CheckStockAndAutoPurchase(context.Background())
|
rockSvc.CheckStockAndAutoPurchase(context.Background())
|
||||||
|
// 绑定错误自动刷新:内部会检查 auto_refresh_mismatch_enabled 开关,关闭时直接跳过。
|
||||||
|
go rockSvc.RefreshMismatchTokens(context.Background())
|
||||||
}
|
}
|
||||||
run()
|
run()
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 023: Rock 绑定过期预警阈值 + 绑定错误自动刷新开关
|
||||||
|
ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS bound_warn_days integer NOT NULL DEFAULT 2;
|
||||||
|
ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS auto_refresh_mismatch_enabled boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN rock_configs.bound_warn_days IS '已绑定超过 N 天视为绑定过期预警';
|
||||||
|
COMMENT ON COLUMN rock_configs.auto_refresh_mismatch_enabled IS '绑定错误自动刷新开关(默认关闭)';
|
||||||
@@ -416,6 +416,29 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshMismatchTokens 一键刷新全部绑定错误的 Token(同步执行,单次最多 20 条)。
|
||||||
|
func (h *RockHandler) RefreshMismatchTokens(c *gin.Context) {
|
||||||
|
result, err := h.rockSvc.RefreshAllMismatchTokens(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := fmt.Sprintf("已扫描 %d 条绑定错误 Token,修复 %d 条", result.Scanned, result.Fixed)
|
||||||
|
if result.Failed > 0 {
|
||||||
|
message += fmt.Sprintf(",失败 %d 条", result.Failed)
|
||||||
|
}
|
||||||
|
if result.Remaining > 0 {
|
||||||
|
message += fmt.Sprintf(",仍异常 %d 条", result.Remaining)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"scanned": result.Scanned,
|
||||||
|
"fixed": result.Fixed,
|
||||||
|
"failed": result.Failed,
|
||||||
|
"remaining": result.Remaining,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshBinding 人工刷新指定 Token 的上游绑定状态
|
// RefreshBinding 人工刷新指定 Token 的上游绑定状态
|
||||||
func (h *RockHandler) RefreshBinding(c *gin.Context) {
|
func (h *RockHandler) RefreshBinding(c *gin.Context) {
|
||||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -36,33 +36,35 @@ const (
|
|||||||
|
|
||||||
// RockConfig 平台配置项
|
// RockConfig 平台配置项
|
||||||
type RockConfig struct {
|
type RockConfig struct {
|
||||||
ID uint `gorm:"primaryKey;default:1" json:"id"`
|
ID uint `gorm:"primaryKey;default:1" json:"id"`
|
||||||
AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
|
AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
|
||||||
BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
|
BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
|
||||||
BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
|
BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
|
||||||
LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"`
|
LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"`
|
||||||
LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"`
|
LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"`
|
||||||
LoginPasswordSet bool `gorm:"-" json:"login_password_set"`
|
LoginPasswordSet bool `gorm:"-" json:"login_password_set"`
|
||||||
LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"`
|
LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"`
|
||||||
LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"`
|
LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"`
|
||||||
Username string `gorm:"size:128;not null;default:''" json:"username"`
|
Username string `gorm:"size:128;not null;default:''" json:"username"`
|
||||||
Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
|
Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
|
||||||
MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
|
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"`
|
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"`
|
TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
|
||||||
UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
|
UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
|
||||||
UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_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"`
|
BoundWarnDays int `gorm:"not null;default:2" json:"bound_warn_days"` // 已绑定超过 N 天预警
|
||||||
AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
|
AutoRefreshMismatchEnabled bool `gorm:"not null;default:false" json:"auto_refresh_mismatch_enabled"` // 绑定错误自动刷新
|
||||||
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
|
||||||
LastPurchasedAt *time.Time `json:"last_purchased_at"`
|
AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
|
||||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却
|
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
||||||
LastAutoAttemptAt *time.Time `json:"last_auto_attempt_at,omitempty"` // 最近一次自动补货尝试时间,自动冷却只认这个
|
LastPurchasedAt *time.Time `json:"last_purchased_at"`
|
||||||
LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却
|
||||||
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
LastAutoAttemptAt *time.Time `json:"last_auto_attempt_at,omitempty"` // 最近一次自动补货尝试时间,自动冷却只认这个
|
||||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (RockConfig) TableName() string {
|
func (RockConfig) TableName() string {
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
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/release", h.Rock.ReleaseToken)
|
||||||
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
||||||
|
platform.POST("/platform/rock/token-batches/refresh-mismatch", h.Rock.RefreshMismatchTokens)
|
||||||
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
||||||
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
|
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
|
||||||
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
|
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ func canRefreshRockBinding(status string) bool {
|
|||||||
|
|
||||||
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
|
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
|
||||||
//
|
//
|
||||||
// 规则:
|
// 规则(只同步上游实时状态,不再用本地预期 UID 拦截):
|
||||||
// - 上游已发货 → 本地同步 redeemed;
|
// - 上游已发货 → 本地同步 redeemed;
|
||||||
// - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch;
|
// - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记;
|
||||||
// - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变;
|
|
||||||
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
|
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
|
||||||
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
|
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
|
||||||
var token model.RockToken
|
var token model.RockToken
|
||||||
@@ -91,13 +90,13 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
|||||||
actualRoleName := ref.GetRoleName()
|
actualRoleName := ref.GetRoleName()
|
||||||
actualRoleID := ref.GetRoleID()
|
actualRoleID := ref.GetRoleID()
|
||||||
actualChannel := ref.GetChannel()
|
actualChannel := ref.GetChannel()
|
||||||
expectedUID := strings.TrimSpace(token.ExpectedUID)
|
upstreamBound := rockUpstreamBound(ref)
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
|
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
|
||||||
if ref.IsBound() {
|
if upstreamBound {
|
||||||
if actualRoleName != "" {
|
if actualRoleName != "" {
|
||||||
updates["actual_role_name"] = actualRoleName
|
updates["actual_role_name"] = actualRoleName
|
||||||
}
|
}
|
||||||
@@ -111,9 +110,9 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
|||||||
|
|
||||||
result := &TokenBindingRefreshResult{
|
result := &TokenBindingRefreshResult{
|
||||||
Status: token.Status,
|
Status: token.Status,
|
||||||
Bound: ref.IsBound(),
|
Bound: upstreamBound,
|
||||||
}
|
}
|
||||||
if ref.IsBound() {
|
if upstreamBound {
|
||||||
result.RoleName = actualRoleName
|
result.RoleName = actualRoleName
|
||||||
result.RoleID = actualRoleID
|
result.RoleID = actualRoleID
|
||||||
result.Channel = actualChannel
|
result.Channel = actualChannel
|
||||||
@@ -139,19 +138,9 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
|||||||
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
|
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
|
||||||
result.Message = "上游已发货,本地状态已同步为已交付"
|
result.Message = "上游已发货,本地状态已同步为已交付"
|
||||||
|
|
||||||
case ref.IsBound() && actualRoleID == "":
|
case upstreamBound:
|
||||||
result.Warning = true
|
// 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
|
||||||
result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对"
|
// 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
|
||||||
|
|
||||||
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["status"] = model.RockStatusBound
|
||||||
updates["mismatch_at"] = nil
|
updates["mismatch_at"] = nil
|
||||||
updates["mismatch_reason"] = ""
|
updates["mismatch_reason"] = ""
|
||||||
@@ -159,44 +148,24 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
|
|||||||
updates["bound_at"] = resolveRockBoundAt(ref, now)
|
updates["bound_at"] = resolveRockBoundAt(ref, now)
|
||||||
}
|
}
|
||||||
result.Status = model.RockStatusBound
|
result.Status = model.RockStatusBound
|
||||||
result.Message = "上游绑定 UID 与预期一致,已解除绑定错误"
|
if actualRoleID == "" {
|
||||||
|
result.Warning = true
|
||||||
case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref):
|
result.Message = "上游已绑定,但未返回角色 UID,已同步为已绑定,请人工核对"
|
||||||
result.Warning = true
|
} else {
|
||||||
result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变"
|
result.Message = fmt.Sprintf(
|
||||||
|
"上游已绑定「%s」(UID %s),已同步为已绑定",
|
||||||
case ref.IsBound() && actualRoleID != expectedUID:
|
displayRockRole(actualRoleName, actualRoleID), actualRoleID,
|
||||||
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:
|
default:
|
||||||
result.Message = "上游仍未绑定,本地状态保持不变"
|
result.Message = "上游仍未绑定,本地状态保持不变"
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Changed = result.Status != originalStatus ||
|
result.Changed = result.Status != originalStatus ||
|
||||||
(ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) ||
|
(upstreamBound && actualRoleName != "" && actualRoleName != originalRoleName) ||
|
||||||
(ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) ||
|
(upstreamBound && actualRoleID != "" && actualRoleID != originalRoleID) ||
|
||||||
(ref.IsBound() && actualChannel != "" && actualChannel != originalChannel)
|
(upstreamBound && actualChannel != "" && actualChannel != originalChannel)
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
|
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
|
// 刷新链路只同步上游实时状态:即使本地预期 UID 与扫码 UID 不一致,也应直接标记为已绑定。
|
||||||
|
func TestRefreshTokenBinding_IgnoresUIDMismatchAndMarksBound(t *testing.T) {
|
||||||
db := newRockTestDB(t)
|
db := newRockTestDB(t)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -115,20 +116,27 @@ func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||||
}
|
}
|
||||||
if res.Status != model.RockStatusMismatch || !strings.Contains(res.Message, "不一致") {
|
if res.Status != model.RockStatusBound || !res.Bound {
|
||||||
t.Fatalf("期望 mismatch 提示,实际 %+v", res)
|
t.Fatalf("UID 不一致时也应同步为 bound,实际 %+v", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
var saved model.RockToken
|
var saved model.RockToken
|
||||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||||
t.Fatalf("读取 Token 失败: %v", err)
|
t.Fatalf("读取 Token 失败: %v", err)
|
||||||
}
|
}
|
||||||
if saved.Status != model.RockStatusMismatch || saved.MismatchAt == nil || !strings.Contains(saved.MismatchReason, "3132710334") {
|
if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" {
|
||||||
t.Fatalf("数据库 mismatch 字段不符: %+v", saved)
|
t.Fatalf("应同步为 bound 并写入实际 UID,实际 %+v", saved)
|
||||||
|
}
|
||||||
|
if saved.MismatchAt != nil || saved.MismatchReason != "" {
|
||||||
|
t.Fatalf("同步为 bound 后应清空绑定错误标记,实际 %+v", saved)
|
||||||
|
}
|
||||||
|
if saved.BoundAt == nil {
|
||||||
|
t.Fatalf("bound_at 未写入")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
|
// 本地没有预期 UID 时同样直接按上游状态落库,不再保留 using。
|
||||||
|
func TestRefreshTokenBinding_EmptyExpectedUIDMarksBound(t *testing.T) {
|
||||||
db := newRockTestDB(t)
|
db := newRockTestDB(t)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -160,22 +168,19 @@ func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("刷新绑定状态失败: %v", err)
|
t.Fatalf("刷新绑定状态失败: %v", err)
|
||||||
}
|
}
|
||||||
if res.Status != model.RockStatusUsing {
|
if res.Status != model.RockStatusBound {
|
||||||
t.Fatalf("缺少预期 UID 时状态应保持 using,实际 %s", res.Status)
|
t.Fatalf("缺少预期 UID 时也应同步为 bound,实际 %s", res.Status)
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.Message, "人工核对") {
|
if !res.Bound {
|
||||||
t.Fatalf("应提示人工核对,实际 %q", res.Message)
|
t.Fatalf("上游已绑定时 bound 应为 true,实际 %+v", res)
|
||||||
}
|
|
||||||
if !res.Warning {
|
|
||||||
t.Fatalf("缺少预期 UID 时应返回 warning=true")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var saved model.RockToken
|
var saved model.RockToken
|
||||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||||
t.Fatalf("读取 Token 失败: %v", err)
|
t.Fatalf("读取 Token 失败: %v", err)
|
||||||
}
|
}
|
||||||
if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "3132710334" {
|
if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" || saved.BoundAt == nil {
|
||||||
t.Fatalf("只应同步角色信息,实际 %+v", saved)
|
t.Fatalf("应同步为 bound 并写入角色信息,实际 %+v", saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 每次巡检/一键刷新最多处理 20 条,避免手动请求超时。
|
||||||
|
rockMismatchScanLimit = 20
|
||||||
|
// 同一 Token 的处理冷却:10 分钟内不重复调用上游。
|
||||||
|
rockMismatchScanCooldown = 10 * time.Minute
|
||||||
|
// 逐条刷新间隔,避免触发上游限流。
|
||||||
|
rockMismatchScanGap = 1500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// errRockMismatchScanBusy 表示已有巡检/一键刷新在跑,本次直接跳过。
|
||||||
|
var errRockMismatchScanBusy = errors.New("绑定错误刷新任务正在执行")
|
||||||
|
|
||||||
|
var rockMismatchScanRunning atomic.Bool
|
||||||
|
|
||||||
|
// RockMismatchRefreshResult 是绑定错误刷新(巡检/一键)的汇总结果。
|
||||||
|
type RockMismatchRefreshResult struct {
|
||||||
|
Scanned int `json:"scanned"` // 本次扫描条数
|
||||||
|
Fixed int `json:"fixed"` // 已同步为正常状态的条数
|
||||||
|
Failed int `json:"failed"` // 调用上游失败的条数
|
||||||
|
Remaining int `json:"remaining"` // 仍停留在绑定错误的条数
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshMismatchTokens 定时巡检入口:受「自动刷新」开关控制,关闭时直接跳过。
|
||||||
|
func (s *RockService) RefreshMismatchTokens(ctx context.Context) {
|
||||||
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Rock] 绑定错误巡检读取配置失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cfg.AutoRefreshMismatchEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.refreshMismatchTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errRockMismatchScanBusy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Rock] 绑定错误巡检失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Scanned == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Rock] 绑定错误巡检完成: 扫描=%d 修复=%d 仍异常=%d 失败=%d",
|
||||||
|
result.Scanned, result.Fixed, result.Remaining, result.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAllMismatchTokens 手动「一键刷新绑定错误」:忽略自动开关,同步执行并返回结果。
|
||||||
|
func (s *RockService) RefreshAllMismatchTokens(ctx context.Context) (*RockMismatchRefreshResult, error) {
|
||||||
|
return s.refreshMismatchTokens(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshMismatchTokens 扫描绑定错误 Token,逐条刷新上游状态。
|
||||||
|
//
|
||||||
|
// 选取条件:status=mismatch 且 updated_at 早于 10 分钟前,按最久未处理优先,单次最多 20 条。
|
||||||
|
// 刷新失败或上游仍未绑定时,RefreshTokenBinding 会写 updated_at,从而进入下一轮冷却。
|
||||||
|
func (s *RockService) refreshMismatchTokens(ctx context.Context) (*RockMismatchRefreshResult, error) {
|
||||||
|
if !rockMismatchScanRunning.CompareAndSwap(false, true) {
|
||||||
|
return nil, errRockMismatchScanBusy
|
||||||
|
}
|
||||||
|
defer rockMismatchScanRunning.Store(false)
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-rockMismatchScanCooldown)
|
||||||
|
var tokens []model.RockToken
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("status = ?", model.RockStatusMismatch).
|
||||||
|
Where("updated_at <= ?", cutoff).
|
||||||
|
Order("updated_at ASC").
|
||||||
|
Limit(rockMismatchScanLimit).
|
||||||
|
Find(&tokens).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RockMismatchRefreshResult{Scanned: len(tokens)}
|
||||||
|
for i, token := range tokens {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return result, ctx.Err()
|
||||||
|
case <-time.After(rockMismatchScanGap):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.RefreshTokenBinding(ctx, token.ID)
|
||||||
|
if err != nil {
|
||||||
|
result.Failed++
|
||||||
|
// 失败也推进 updated_at,保证同一 Token 至少 10 分钟后才会再次尝试。
|
||||||
|
_ = s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||||
|
Where("id = ?", token.ID).
|
||||||
|
Update("updated_at", time.Now()).Error
|
||||||
|
log.Printf("[Rock] 刷新绑定错误 Token %d 失败: %v", token.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.Status == model.RockStatusMismatch {
|
||||||
|
result.Remaining++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Fixed++
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateConfig_BoundWarnDaysAndAutoRefresh(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.BoundWarnDays != 2 {
|
||||||
|
t.Fatalf("绑定过期预警默认值应为 2,实际 %d", cfg.BoundWarnDays)
|
||||||
|
}
|
||||||
|
if cfg.AutoRefreshMismatchEnabled {
|
||||||
|
t.Fatalf("自动刷新开关默认必须关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := 0
|
||||||
|
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BoundWarnDays: &zero}); err == nil {
|
||||||
|
t.Fatalf("绑定过期预警天数小于 1 时必须报错")
|
||||||
|
}
|
||||||
|
tooMany := 366
|
||||||
|
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BoundWarnDays: &tooMany}); err == nil {
|
||||||
|
t.Fatalf("绑定过期预警天数大于 365 时必须报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
seven := 7
|
||||||
|
enabled := true
|
||||||
|
updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{
|
||||||
|
BoundWarnDays: &seven,
|
||||||
|
AutoRefreshMismatchEnabled: &enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("保存绑定过期预警失败: %v", err)
|
||||||
|
}
|
||||||
|
if updated.BoundWarnDays != 7 || !updated.AutoRefreshMismatchEnabled {
|
||||||
|
t.Fatalf("绑定过期预警/自动刷新未保存: %+v", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTokens_OverdueFilters(t *testing.T) {
|
||||||
|
db := newRockTestDB(t)
|
||||||
|
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
old := now.AddDate(0, 0, -10)
|
||||||
|
fresh := now.Add(-time.Hour)
|
||||||
|
|
||||||
|
usingOld := model.RockToken{UpstreamTokenID: "overdue-using", RedeemCode: "C1", Status: model.RockStatusUsing, UsingAt: &old}
|
||||||
|
usingFresh := model.RockToken{UpstreamTokenID: "fresh-using", RedeemCode: "C2", Status: model.RockStatusUsing, UsingAt: &fresh}
|
||||||
|
boundOld := model.RockToken{UpstreamTokenID: "overdue-bound", RedeemCode: "C3", Status: model.RockStatusBound, BoundAt: &old}
|
||||||
|
boundFresh := model.RockToken{UpstreamTokenID: "fresh-bound", RedeemCode: "C4", Status: model.RockStatusBound, BoundAt: &fresh}
|
||||||
|
redeemedOld := model.RockToken{UpstreamTokenID: "old-redeemed", RedeemCode: "C5", Status: model.RockStatusRedeemed, BoundAt: &old}
|
||||||
|
for _, token := range []*model.RockToken{&usingOld, &usingFresh, &boundOld, &boundFresh, &redeemedOld} {
|
||||||
|
if err := db.Create(token).Error; err != nil {
|
||||||
|
t.Fatalf("创建 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
status string
|
||||||
|
wantID uint
|
||||||
|
}{
|
||||||
|
{rockTokenStatusUsingOverdue, usingOld.ID},
|
||||||
|
{rockTokenStatusBoundOverdue, boundOld.ID},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
list, total, err := svc.ListTokens(ctx, 1, 20, tc.status, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询 %s 失败: %v", tc.status, err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(list) != 1 || list[0].ID != tc.wantID {
|
||||||
|
t.Fatalf("%s 应只返回 ID=%d,实际 total=%d list=%+v", tc.status, tc.wantID, total, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := svc.GetStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("获取统计失败: %v", err)
|
||||||
|
}
|
||||||
|
if stats.UsingOverdueStock != 1 || stats.UsingDangerStock != 1 || stats.BoundOverdueStock != 1 {
|
||||||
|
t.Fatalf("过期预警统计不符: using_overdue=%d using_danger=%d bound_overdue=%d",
|
||||||
|
stats.UsingOverdueStock, stats.UsingDangerStock, stats.BoundOverdueStock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshMismatchTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
|
||||||
|
db := newRockTestDB(t)
|
||||||
|
var calls int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewRockService(db, NewRockClient(server.URL))
|
||||||
|
setRockTestBearer(t, svc, "bearer-test")
|
||||||
|
|
||||||
|
token := model.RockToken{
|
||||||
|
UpstreamTokenID: "scan-off",
|
||||||
|
RedeemCode: "CODE-SCAN-OFF",
|
||||||
|
Status: model.RockStatusMismatch,
|
||||||
|
}
|
||||||
|
if err := db.Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("创建 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
|
||||||
|
UpdateColumn("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||||
|
t.Fatalf("调整 updated_at 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.RefreshMismatchTokens(context.Background())
|
||||||
|
|
||||||
|
if atomic.LoadInt32(&calls) != 0 {
|
||||||
|
t.Fatalf("自动刷新关闭时不应调用上游")
|
||||||
|
}
|
||||||
|
var saved model.RockToken
|
||||||
|
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != model.RockStatusMismatch {
|
||||||
|
t.Fatalf("自动刷新关闭时应保持 mismatch,实际 %s", saved.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAllMismatchTokens_FixesMismatch(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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewRockService(db, NewRockClient(server.URL))
|
||||||
|
setRockTestBearer(t, svc, "bearer-test")
|
||||||
|
|
||||||
|
token := model.RockToken{
|
||||||
|
UpstreamTokenID: "scan-on",
|
||||||
|
RedeemCode: "CODE-SCAN-ON",
|
||||||
|
Status: model.RockStatusMismatch,
|
||||||
|
ExpectedUID: "1111111111",
|
||||||
|
}
|
||||||
|
if err := db.Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("创建 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
|
||||||
|
UpdateColumn("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
|
||||||
|
t.Fatalf("调整 updated_at 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.RefreshAllMismatchTokens(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("一键刷新失败: %v", err)
|
||||||
|
}
|
||||||
|
if result.Scanned != 1 || result.Fixed != 1 || result.Remaining != 0 || result.Failed != 0 {
|
||||||
|
t.Fatalf("一键刷新结果不符: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.MismatchAt != nil {
|
||||||
|
t.Fatalf("应修复为 bound 并清空 mismatch 标记,实际 %+v", saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,17 +38,23 @@ func NewRockService(db *gorm.DB, client *RockClient, codecs ...*SecretCodec) *Ro
|
|||||||
|
|
||||||
// RockStatsResponse 看板与库存统计返回
|
// RockStatsResponse 看板与库存统计返回
|
||||||
type RockStatsResponse struct {
|
type RockStatsResponse struct {
|
||||||
AvailableStock int64 `json:"available_stock"`
|
AvailableStock int64 `json:"available_stock"`
|
||||||
UsingStock int64 `json:"using_stock"`
|
UsingStock int64 `json:"using_stock"`
|
||||||
BoundStock int64 `json:"bound_stock"`
|
BoundStock int64 `json:"bound_stock"`
|
||||||
RedeemedStock int64 `json:"redeemed_stock"`
|
RedeemedStock int64 `json:"redeemed_stock"`
|
||||||
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
|
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
|
||||||
AbnormalStock int64 `json:"abnormal_stock"`
|
AbnormalStock int64 `json:"abnormal_stock"`
|
||||||
DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
|
DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
|
||||||
|
// 过期预警计数:仅统计对应主状态(使用中 / 已绑定),用于筛选栏徽标。
|
||||||
|
UsingOverdueStock int64 `json:"using_overdue_stock"`
|
||||||
|
UsingDangerStock int64 `json:"using_danger_stock"`
|
||||||
|
BoundOverdueStock int64 `json:"bound_overdue_stock"`
|
||||||
UpstreamPoints float64 `json:"upstream_points"`
|
UpstreamPoints float64 `json:"upstream_points"`
|
||||||
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
||||||
TokenWarningStock int `json:"token_warning_stock"`
|
TokenWarningStock int `json:"token_warning_stock"`
|
||||||
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
||||||
|
// 自动刷新绑定错误开关状态,供筛选栏 Switch 回显
|
||||||
|
AutoRefreshMismatchEnabled bool `json:"auto_refresh_mismatch_enabled"`
|
||||||
// 自动补货冷却状态:供页面提示“下次可补货时间”
|
// 自动补货冷却状态:供页面提示“下次可补货时间”
|
||||||
AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"`
|
AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"`
|
||||||
NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"`
|
NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"`
|
||||||
@@ -76,18 +82,20 @@ func (e *RockConfigValidationError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateRockConfigReq struct {
|
type UpdateRockConfigReq struct {
|
||||||
AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
|
AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
|
||||||
BaseURL *string `json:"base_url"`
|
BaseURL *string `json:"base_url"`
|
||||||
BearerToken *string `json:"bearer_token"`
|
BearerToken *string `json:"bearer_token"`
|
||||||
MinPointsWarning *float64 `json:"min_points_warning"`
|
MinPointsWarning *float64 `json:"min_points_warning"`
|
||||||
TokenWarningStock *int `json:"token_warning_stock"`
|
TokenWarningStock *int `json:"token_warning_stock"`
|
||||||
UsingWarnDays *int `json:"using_warn_days"`
|
UsingWarnDays *int `json:"using_warn_days"`
|
||||||
UsingDangerDays *int `json:"using_danger_days"`
|
UsingDangerDays *int `json:"using_danger_days"`
|
||||||
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
BoundWarnDays *int `json:"bound_warn_days"`
|
||||||
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
AutoRefreshMismatchEnabled *bool `json:"auto_refresh_mismatch_enabled"`
|
||||||
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
||||||
LoginUsername *string `json:"login_username"`
|
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
||||||
LoginPassword *string `json:"login_password"`
|
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
||||||
|
LoginUsername *string `json:"login_username"`
|
||||||
|
LoginPassword *string `json:"login_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig 获取当前配置,若无则初始化
|
// GetConfig 获取当前配置,若无则初始化
|
||||||
@@ -105,6 +113,7 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error)
|
|||||||
TokenWarningStock: 10,
|
TokenWarningStock: 10,
|
||||||
UsingWarnDays: 3,
|
UsingWarnDays: 3,
|
||||||
UsingDangerDays: 5,
|
UsingDangerDays: 5,
|
||||||
|
BoundWarnDays: 2,
|
||||||
AutoPurchaseEnabled: false,
|
AutoPurchaseEnabled: false,
|
||||||
AutoPurchaseCount: 50,
|
AutoPurchaseCount: 50,
|
||||||
PurchaseCooldownMinutes: 10,
|
PurchaseCooldownMinutes: 10,
|
||||||
@@ -209,6 +218,19 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
|
|||||||
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
|
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定过期预警:已绑定超过 N 天视为绑定超时,独立于使用中两级预警。
|
||||||
|
boundWarnDays := cfg.BoundWarnDays
|
||||||
|
if req.BoundWarnDays != nil {
|
||||||
|
boundWarnDays = *req.BoundWarnDays
|
||||||
|
updates["bound_warn_days"] = boundWarnDays
|
||||||
|
}
|
||||||
|
if boundWarnDays < 1 || boundWarnDays > 365 {
|
||||||
|
return nil, &RockConfigValidationError{Message: "绑定过期预警天数必须在 1-365 之间"}
|
||||||
|
}
|
||||||
|
if req.AutoRefreshMismatchEnabled != nil {
|
||||||
|
updates["auto_refresh_mismatch_enabled"] = *req.AutoRefreshMismatchEnabled
|
||||||
|
}
|
||||||
|
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
updates["updated_at"] = time.Now()
|
updates["updated_at"] = time.Now()
|
||||||
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
|
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
|
||||||
@@ -354,6 +376,26 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stats.DeletedStock = deletedCount
|
stats.DeletedStock = deletedCount
|
||||||
|
stats.AutoRefreshMismatchEnabled = cfg.AutoRefreshMismatchEnabled
|
||||||
|
|
||||||
|
// 过期预警计数:仅统计 using / bound 主状态,避免把其它状态混进筛选徽标。
|
||||||
|
now := time.Now()
|
||||||
|
warnDays, dangerDays, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||||
|
Where("status = ? AND using_at IS NOT NULL AND using_at <= ?", model.RockStatusUsing, rockOverdueCutoff(now, warnDays)).
|
||||||
|
Count(&stats.UsingOverdueStock).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||||
|
Where("status = ? AND using_at IS NOT NULL AND using_at <= ?", model.RockStatusUsing, rockOverdueCutoff(now, dangerDays)).
|
||||||
|
Count(&stats.UsingDangerStock).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||||
|
Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?", model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays)).
|
||||||
|
Count(&stats.BoundOverdueStock).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.AutoPurchaseEnabled {
|
if cfg.AutoPurchaseEnabled {
|
||||||
if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 {
|
if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 {
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rockUpstreamBound 判断上游「当前」是否已绑定。
|
||||||
|
//
|
||||||
|
// 只看本次实时返回的绑定信号(status / bind_changed_at / binding),
|
||||||
|
// 不用 bound_account_id 判断:上游可能保留上一次绑定的账号残留,
|
||||||
|
// 用它会导致刷新按钮把「未扫码」误判成已绑定,也无法把真实绑定同步回本地。
|
||||||
|
func rockUpstreamBound(ref *RockRefreshResponse) bool {
|
||||||
|
if ref == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(ref.Token.Status)) {
|
||||||
|
case model.RockStatusBound, model.RockStatusRedeemed:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ref.Token.BindChangedAt) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ref.Binding.Bound || strings.TrimSpace(ref.Binding.RoleID) != ""
|
||||||
|
}
|
||||||
|
|
||||||
func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
|
func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
|
||||||
if ref == nil || len(ref.DeliveryList) == 0 {
|
if ref == nil || len(ref.DeliveryList) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -518,7 +518,12 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
|
|||||||
pageSize = 20
|
pageSize = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
|
db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search, time.Now(), warnDays, boundWarnDays)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
@@ -527,7 +532,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
|
|||||||
|
|
||||||
var tokens []model.RockToken
|
var tokens []model.RockToken
|
||||||
// 按本地 ID 倒序:ID 自增,等价于按入库时间倒序;分页稳定,且与列表首列 ID 的降序一致
|
// 按本地 ID 倒序:ID 自增,等价于按入库时间倒序;分页稳定,且与列表首列 ID 的降序一致
|
||||||
err := db.Order("id DESC").
|
err = db.Order("id DESC").
|
||||||
Offset((page - 1) * pageSize).
|
Offset((page - 1) * pageSize).
|
||||||
Limit(pageSize).
|
Limit(pageSize).
|
||||||
Find(&tokens).Error
|
Find(&tokens).Error
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
rockTokenBatchDeleteMax = 500
|
rockTokenBatchDeleteMax = 500
|
||||||
rockTokenDeleteAllMax = 5000
|
rockTokenDeleteAllMax = 5000
|
||||||
|
|
||||||
|
// 虚拟筛选状态:不落库,只在查询时换算成「主状态 + 时间阈值」。
|
||||||
|
rockTokenStatusUsingOverdue = "using_overdue"
|
||||||
|
rockTokenStatusBoundOverdue = "bound_overdue"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rockTokenDangerousStatuses = map[string]bool{
|
var rockTokenDangerousStatuses = map[string]bool{
|
||||||
@@ -80,17 +85,56 @@ func (e *RockTokenDeleteConflictError) Error() string {
|
|||||||
func isKnownRockTokenStatus(status string) bool {
|
func isKnownRockTokenStatus(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
|
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
|
||||||
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal:
|
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal,
|
||||||
|
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rockOverdueThresholds 返回生效的预警阈值(含默认值兜底),统计与筛选共用。
|
||||||
|
func rockOverdueThresholds(cfg *model.RockConfig) (warnDays, dangerDays, boundWarnDays int) {
|
||||||
|
warnDays, dangerDays, boundWarnDays = 3, 5, 2
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.UsingWarnDays > 0 {
|
||||||
|
warnDays = cfg.UsingWarnDays
|
||||||
|
}
|
||||||
|
if cfg.UsingDangerDays > warnDays {
|
||||||
|
dangerDays = cfg.UsingDangerDays
|
||||||
|
} else {
|
||||||
|
dangerDays = warnDays + 2
|
||||||
|
}
|
||||||
|
if cfg.BoundWarnDays > 0 {
|
||||||
|
boundWarnDays = cfg.BoundWarnDays
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// rockOverdueCutoff 返回「超过 days 天」的时间分界点。
|
||||||
|
func rockOverdueCutoff(now time.Time, days int) time.Time {
|
||||||
|
if days <= 0 {
|
||||||
|
days = 1
|
||||||
|
}
|
||||||
|
return now.AddDate(0, 0, -days)
|
||||||
|
}
|
||||||
|
|
||||||
// applyRockTokenListFilters 与 Token 列表使用同一套筛选条件,避免“看到多少、删多少”不一致。
|
// applyRockTokenListFilters 与 Token 列表使用同一套筛选条件,避免“看到多少、删多少”不一致。
|
||||||
func applyRockTokenListFilters(db *gorm.DB, status, search string) *gorm.DB {
|
// 列表、删除预览、全部删除、回收站共用;now 由调用方传入以便同一请求内阈值一致。
|
||||||
if value := strings.TrimSpace(status); value != "" {
|
func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time, warnDays, boundWarnDays int) *gorm.DB {
|
||||||
db = db.Where("status = ?", value)
|
switch strings.TrimSpace(status) {
|
||||||
|
case rockTokenStatusUsingOverdue:
|
||||||
|
db = db.Where("status = ? AND using_at IS NOT NULL AND using_at <= ?",
|
||||||
|
model.RockStatusUsing, rockOverdueCutoff(now, warnDays))
|
||||||
|
case rockTokenStatusBoundOverdue:
|
||||||
|
db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?",
|
||||||
|
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays))
|
||||||
|
default:
|
||||||
|
if value := strings.TrimSpace(status); value != "" {
|
||||||
|
db = db.Where("status = ?", value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(search); value != "" {
|
if value := strings.TrimSpace(search); value != "" {
|
||||||
term := "%" + value + "%"
|
term := "%" + value + "%"
|
||||||
@@ -157,7 +201,12 @@ func (s *RockService) PreviewRockTokenDelete(ctx context.Context, status, search
|
|||||||
return nil, errors.New("无效的 Token 状态筛选")
|
return nil, errors.New("无效的 Token 状态筛选")
|
||||||
}
|
}
|
||||||
var rows []model.RockToken
|
var rows []model.RockToken
|
||||||
query := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
|
query := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search, time.Now(), warnDays, boundWarnDays)
|
||||||
if err := query.Order("id DESC").Find(&rows).Error; err != nil {
|
if err := query.Order("id DESC").Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -250,9 +299,15 @@ func (s *RockService) DeleteAllRockTokens(ctx context.Context, status, search st
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := &RockTokenDeleteResult{Forced: force}
|
result := &RockTokenDeleteResult{Forced: force}
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
|
filterNow := time.Now()
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var rows []model.RockToken
|
var rows []model.RockToken
|
||||||
query := applyRockTokenListFilters(tx.Model(&model.RockToken{}), status, search)
|
query := applyRockTokenListFilters(tx.Model(&model.RockToken{}), status, search, filterNow, warnDays, boundWarnDays)
|
||||||
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
|
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,14 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
|
|||||||
return nil, 0, errors.New("无效的 Token 状态筛选")
|
return nil, 0, errors.New("无效的 Token 状态筛选")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
db := applyRockTokenListFilters(
|
db := applyRockTokenListFilters(
|
||||||
s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).Where("deleted_at IS NOT NULL"),
|
s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).Where("deleted_at IS NOT NULL"),
|
||||||
status, search,
|
status, search, time.Now(), warnDays, boundWarnDays,
|
||||||
)
|
)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@@ -577,6 +577,8 @@ export interface RockConfig {
|
|||||||
token_warning_stock: number;
|
token_warning_stock: number;
|
||||||
using_warn_days: number;
|
using_warn_days: number;
|
||||||
using_danger_days: number;
|
using_danger_days: number;
|
||||||
|
bound_warn_days: number;
|
||||||
|
auto_refresh_mismatch_enabled: boolean;
|
||||||
auto_purchase_enabled: boolean;
|
auto_purchase_enabled: boolean;
|
||||||
auto_purchase_count: number;
|
auto_purchase_count: number;
|
||||||
purchase_cooldown_minutes: number;
|
purchase_cooldown_minutes: number;
|
||||||
@@ -596,6 +598,10 @@ export interface RockStats {
|
|||||||
mismatch_stock: number;
|
mismatch_stock: number;
|
||||||
abnormal_stock: number;
|
abnormal_stock: number;
|
||||||
deleted_stock?: number;
|
deleted_stock?: number;
|
||||||
|
using_overdue_stock?: number;
|
||||||
|
using_danger_stock?: number;
|
||||||
|
bound_overdue_stock?: number;
|
||||||
|
auto_refresh_mismatch_enabled?: boolean;
|
||||||
upstream_points: number;
|
upstream_points: number;
|
||||||
auto_delivery_enabled: boolean;
|
auto_delivery_enabled: boolean;
|
||||||
token_warning_stock: number;
|
token_warning_stock: number;
|
||||||
@@ -779,6 +785,14 @@ export interface RockTokenRestoreResult {
|
|||||||
skipped_items: RockTokenRestoreSkipItem[];
|
skipped_items: RockTokenRestoreSkipItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RockMismatchRefreshResult {
|
||||||
|
scanned: number;
|
||||||
|
fixed: number;
|
||||||
|
failed: number;
|
||||||
|
remaining: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const rockApi = {
|
export const rockApi = {
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||||
@@ -853,6 +867,10 @@ export const rockApi = {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
refreshMismatchTokens: () =>
|
||||||
|
request
|
||||||
|
.post("/platform/rock/token-batches/refresh-mismatch")
|
||||||
|
.then((r) => r.data.data as RockMismatchRefreshResult),
|
||||||
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
||||||
request
|
request
|
||||||
.get("/platform/rock/token-batches/preview", { params })
|
.get("/platform/rock/token-batches/preview", { params })
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export default function PlatformRock() {
|
|||||||
const [savingConfig, setSavingConfig] = useState(false)
|
const [savingConfig, setSavingConfig] = useState(false)
|
||||||
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
|
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
|
||||||
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
||||||
|
const [autoRefreshMismatchEnabled, setAutoRefreshMismatchEnabled] = useState(false)
|
||||||
|
const [togglingAutoRefresh, setTogglingAutoRefresh] = useState(false)
|
||||||
|
const [refreshingMismatch, setRefreshingMismatch] = useState(false)
|
||||||
const [purchasing, setPurchasing] = useState(false)
|
const [purchasing, setPurchasing] = useState(false)
|
||||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||||
@@ -79,6 +82,7 @@ export default function PlatformRock() {
|
|||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
setStats(st)
|
setStats(st)
|
||||||
setAutoPurchaseEnabled(cfg.auto_purchase_enabled)
|
setAutoPurchaseEnabled(cfg.auto_purchase_enabled)
|
||||||
|
setAutoRefreshMismatchEnabled(cfg.auto_refresh_mismatch_enabled ?? false)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
auto_delivery_enabled: cfg.auto_delivery_enabled,
|
auto_delivery_enabled: cfg.auto_delivery_enabled,
|
||||||
base_url: cfg.base_url,
|
base_url: cfg.base_url,
|
||||||
@@ -88,6 +92,7 @@ export default function PlatformRock() {
|
|||||||
token_warning_stock: cfg.token_warning_stock,
|
token_warning_stock: cfg.token_warning_stock,
|
||||||
using_warn_days: cfg.using_warn_days ?? 3,
|
using_warn_days: cfg.using_warn_days ?? 3,
|
||||||
using_danger_days: cfg.using_danger_days ?? 5,
|
using_danger_days: cfg.using_danger_days ?? 5,
|
||||||
|
bound_warn_days: cfg.bound_warn_days ?? 2,
|
||||||
auto_purchase_count: cfg.auto_purchase_count,
|
auto_purchase_count: cfg.auto_purchase_count,
|
||||||
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
|
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
|
||||||
})
|
})
|
||||||
@@ -172,6 +177,47 @@ export default function PlatformRock() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一键刷新全部绑定错误的 Token:同步执行,单次最多 20 条
|
||||||
|
const handleRefreshMismatch = async () => {
|
||||||
|
setRefreshingMismatch(true)
|
||||||
|
try {
|
||||||
|
const res = await rockApi.refreshMismatchTokens()
|
||||||
|
if (res.scanned === 0) {
|
||||||
|
message.info('暂无可刷新的绑定错误 Token(10 分钟内已刷新过的会自动跳过)')
|
||||||
|
} else if (res.failed > 0 || res.remaining > 0) {
|
||||||
|
message.warning(res.message)
|
||||||
|
} else {
|
||||||
|
message.success(res.message)
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
loadTokens()
|
||||||
|
} catch (e) {
|
||||||
|
message.error('刷新绑定错误失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setRefreshingMismatch(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动刷新绑定错误开关:切换即保存,后端每 5 分钟巡检一次
|
||||||
|
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
|
||||||
|
const previous = autoRefreshMismatchEnabled
|
||||||
|
setAutoRefreshMismatchEnabled(checked)
|
||||||
|
setTogglingAutoRefresh(true)
|
||||||
|
try {
|
||||||
|
const updated = await rockApi.updateConfig({ auto_refresh_mismatch_enabled: checked })
|
||||||
|
setConfig(updated)
|
||||||
|
setAutoRefreshMismatchEnabled(updated.auto_refresh_mismatch_enabled)
|
||||||
|
message.success(
|
||||||
|
checked ? '自动刷新已开启:每 5 分钟最多刷新 20 条绑定错误 Token' : '自动刷新已关闭',
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
setAutoRefreshMismatchEnabled(previous)
|
||||||
|
message.error('更新自动刷新开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setTogglingAutoRefresh(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 测试连通性
|
// 测试连通性
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
const bearerToken = form.getFieldValue('bearer_token')
|
const bearerToken = form.getFieldValue('bearer_token')
|
||||||
@@ -336,6 +382,7 @@ export default function PlatformRock() {
|
|||||||
const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
|
const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
|
||||||
const usingDangerDays =
|
const usingDangerDays =
|
||||||
config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
|
config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
|
||||||
|
const boundWarnDays = config?.bound_warn_days && config.bound_warn_days > 0 ? config.bound_warn_days : 2
|
||||||
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
|
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
|
||||||
const estimatedCost = (purchaseCount || 0) * tokenCost
|
const estimatedCost = (purchaseCount || 0) * tokenCost
|
||||||
const isPointsInsufficient = estimatedCost > upstreamPoints
|
const isPointsInsufficient = estimatedCost > upstreamPoints
|
||||||
@@ -428,9 +475,14 @@ export default function PlatformRock() {
|
|||||||
filterStatus={filterStatus}
|
filterStatus={filterStatus}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
loadingTokens={loadingTokens}
|
loadingTokens={loadingTokens}
|
||||||
|
autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
|
||||||
|
refreshingMismatch={refreshingMismatch}
|
||||||
|
togglingAutoRefresh={togglingAutoRefresh}
|
||||||
onSearchKeywordChange={setSearchKeyword}
|
onSearchKeywordChange={setSearchKeyword}
|
||||||
onFilterStatusChange={handleFilterStatusChange}
|
onFilterStatusChange={handleFilterStatusChange}
|
||||||
onRefresh={loadTokens}
|
onRefresh={loadTokens}
|
||||||
|
onRefreshMismatch={handleRefreshMismatch}
|
||||||
|
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 主库存与错单审计表格 */}
|
{/* 主库存与错单审计表格 */}
|
||||||
@@ -439,6 +491,7 @@ export default function PlatformRock() {
|
|||||||
loadingTokens={loadingTokens}
|
loadingTokens={loadingTokens}
|
||||||
usingWarnDays={usingWarnDays}
|
usingWarnDays={usingWarnDays}
|
||||||
usingDangerDays={usingDangerDays}
|
usingDangerDays={usingDangerDays}
|
||||||
|
boundWarnDays={boundWarnDays}
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
|
|||||||
@@ -365,6 +365,15 @@ export function RockStrategyCard({
|
|||||||
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="bound_warn_days"
|
||||||
|
label="绑定过期预警"
|
||||||
|
tooltip="已绑定 Token 超过该天数后标记为绑定超时,并计入「绑定超时」筛选"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={365} style={{ width: '100%' }} addonAfter="天" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Button, Card, Col, Input, Radio, Row, Space, Typography } from 'antd'
|
import { Badge, Button, Card, Input, Radio, Space, Switch, Tooltip, Typography } from 'antd'
|
||||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import type { RockStats } from '../../api'
|
import type { RockStats } from '../../api'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@@ -10,9 +10,14 @@ export interface RockTokenFilterBarProps {
|
|||||||
filterStatus: string
|
filterStatus: string
|
||||||
totalTokens: number
|
totalTokens: number
|
||||||
loadingTokens: boolean
|
loadingTokens: boolean
|
||||||
|
autoRefreshMismatchEnabled: boolean
|
||||||
|
refreshingMismatch: boolean
|
||||||
|
togglingAutoRefresh: boolean
|
||||||
onSearchKeywordChange: (value: string) => void
|
onSearchKeywordChange: (value: string) => void
|
||||||
onFilterStatusChange: (value: string) => void
|
onFilterStatusChange: (value: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
onRefreshMismatch: () => void
|
||||||
|
onToggleAutoRefreshMismatch: (checked: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RockTokenFilterBar({
|
export function RockTokenFilterBar({
|
||||||
@@ -21,71 +26,109 @@ export function RockTokenFilterBar({
|
|||||||
filterStatus,
|
filterStatus,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
loadingTokens,
|
loadingTokens,
|
||||||
|
autoRefreshMismatchEnabled,
|
||||||
|
refreshingMismatch,
|
||||||
|
togglingAutoRefresh,
|
||||||
onSearchKeywordChange,
|
onSearchKeywordChange,
|
||||||
onFilterStatusChange,
|
onFilterStatusChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onRefreshMismatch,
|
||||||
|
onToggleAutoRefreshMismatch,
|
||||||
}: RockTokenFilterBarProps) {
|
}: RockTokenFilterBarProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
styles={{ body: { padding: '16px 20px' } }}
|
styles={{ body: { padding: '16px 20px' } }}
|
||||||
style={{ marginBottom: 16, borderRadius: 10, border: '1px solid #e2e8f0' }}
|
style={{ marginBottom: 16, borderRadius: 10, border: '1px solid #e2e8f0' }}
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 12]} align="middle" justify="space-between">
|
{/* 桌面端保持单行:筛选按钮组可横向滚动,移动端自动换行堆叠 */}
|
||||||
<Col xs={24} md={16}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<Space size="middle" wrap>
|
<Input
|
||||||
<Input
|
placeholder="搜索 包ID / 0666 编号 / 提取码 / 兑换码 / 订单号 / UID"
|
||||||
placeholder="搜索 包ID / 0666 编号 / 提取码 / 兑换码 / 订单号 / UID"
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
value={searchKeyword}
|
||||||
value={searchKeyword}
|
onChange={(e) => onSearchKeywordChange(e.target.value)}
|
||||||
onChange={(e) => onSearchKeywordChange(e.target.value)}
|
onPressEnter={onRefresh}
|
||||||
onPressEnter={onRefresh}
|
style={{ width: 320, maxWidth: '100%', flex: '0 0 auto' }}
|
||||||
style={{ width: 400 }}
|
allowClear
|
||||||
allowClear
|
/>
|
||||||
/>
|
<div style={{ flex: '1 1 auto', minWidth: 0, overflowX: 'auto' }}>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onFilterStatusChange(e.target.value)
|
onFilterStatusChange(e.target.value)
|
||||||
}}
|
}}
|
||||||
buttonStyle="solid"
|
buttonStyle="solid"
|
||||||
>
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
<Radio.Button value="all">全部</Radio.Button>
|
>
|
||||||
<Radio.Button value="available">可用</Radio.Button>
|
<Radio.Button value="all">全部</Radio.Button>
|
||||||
<Radio.Button value="using">使用中</Radio.Button>
|
<Radio.Button value="available">可用</Radio.Button>
|
||||||
<Radio.Button value="bound">已绑定</Radio.Button>
|
<Radio.Button value="using">使用中</Radio.Button>
|
||||||
<Radio.Button value="redeemed">已发货</Radio.Button>
|
<Radio.Button value="bound">已绑定</Radio.Button>
|
||||||
<Radio.Button value="mismatch">
|
<Radio.Button value="redeemed">已发货</Radio.Button>
|
||||||
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
|
<Radio.Button value="mismatch">
|
||||||
<span>绑定错误</span>
|
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
|
||||||
</Badge>
|
<span>绑定错误</span>
|
||||||
</Radio.Button>
|
</Badge>
|
||||||
<Radio.Button value="abnormal">
|
</Radio.Button>
|
||||||
<Badge count={stats?.abnormal_stock ?? 0} offset={[6, -2]} size="small" style={{ backgroundColor: '#64748b' }}>
|
<Radio.Button value="abnormal">
|
||||||
<span>已上报失效</span>
|
<Badge
|
||||||
</Badge>
|
count={stats?.abnormal_stock ?? 0}
|
||||||
</Radio.Button>
|
offset={[6, -2]}
|
||||||
</Radio.Group>
|
size="small"
|
||||||
</Space>
|
style={{ backgroundColor: '#64748b' }}
|
||||||
</Col>
|
>
|
||||||
<Col
|
<span>已上报失效</span>
|
||||||
xs={24}
|
</Badge>
|
||||||
md={8}
|
</Radio.Button>
|
||||||
style={{
|
<Radio.Button value="using_overdue">
|
||||||
display: 'flex',
|
<Badge
|
||||||
justifyContent: 'flex-end',
|
count={stats?.using_overdue_stock ?? 0}
|
||||||
alignItems: 'center',
|
offset={[6, -2]}
|
||||||
gap: 12,
|
size="small"
|
||||||
flexWrap: 'wrap',
|
style={{ backgroundColor: '#f59e0b' }}
|
||||||
}}
|
>
|
||||||
>
|
<span>使用超时</span>
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
</Badge>
|
||||||
共检索到 <strong style={{ color: '#0f172a' }}>{totalTokens}</strong> 条 Token 记录
|
</Radio.Button>
|
||||||
|
<Radio.Button value="bound_overdue">
|
||||||
|
<Badge
|
||||||
|
count={stats?.bound_overdue_stock ?? 0}
|
||||||
|
offset={[6, -2]}
|
||||||
|
size="small"
|
||||||
|
style={{ backgroundColor: '#ef4444' }}
|
||||||
|
>
|
||||||
|
<span>绑定超时</span>
|
||||||
|
</Badge>
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
<Space size={10} style={{ flex: '0 0 auto', flexWrap: 'wrap' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>
|
||||||
|
共 <strong style={{ color: '#0f172a' }}>{totalTokens}</strong> 条
|
||||||
</Text>
|
</Text>
|
||||||
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
|
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
<Tooltip title="立即刷新全部绑定错误 Token(单次最多 20 条,约 30 秒)">
|
||||||
</Row>
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={onRefreshMismatch}
|
||||||
|
loading={refreshingMismatch}
|
||||||
|
>
|
||||||
|
一键刷新全部绑定错误
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Space size={6} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={autoRefreshMismatchEnabled}
|
||||||
|
loading={togglingAutoRefresh}
|
||||||
|
onChange={onToggleAutoRefreshMismatch}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 13 }}>启用自动刷新</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface RockTokenTableProps {
|
|||||||
totalTokens: number
|
totalTokens: number
|
||||||
usingWarnDays: number
|
usingWarnDays: number
|
||||||
usingDangerDays: number
|
usingDangerDays: number
|
||||||
|
boundWarnDays: number
|
||||||
selectedTokenIds: number[]
|
selectedTokenIds: number[]
|
||||||
onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void
|
onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void
|
||||||
onBatchDelete: () => void
|
onBatchDelete: () => void
|
||||||
@@ -45,6 +46,7 @@ export function RockTokenTable({
|
|||||||
totalTokens,
|
totalTokens,
|
||||||
usingWarnDays,
|
usingWarnDays,
|
||||||
usingDangerDays,
|
usingDangerDays,
|
||||||
|
boundWarnDays,
|
||||||
selectedTokenIds,
|
selectedTokenIds,
|
||||||
onSelectedTokenIdsChange,
|
onSelectedTokenIdsChange,
|
||||||
onBatchDelete,
|
onBatchDelete,
|
||||||
@@ -222,6 +224,7 @@ export function RockTokenTable({
|
|||||||
copyingVoucherId,
|
copyingVoucherId,
|
||||||
usingWarnDays,
|
usingWarnDays,
|
||||||
usingDangerDays,
|
usingDangerDays,
|
||||||
|
boundWarnDays,
|
||||||
refreshingTokenId,
|
refreshingTokenId,
|
||||||
onRefreshBinding: handleRefreshBinding,
|
onRefreshBinding: handleRefreshBinding,
|
||||||
openingUpstreamId,
|
openingUpstreamId,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
redeemed: '已发货',
|
redeemed: '已发货',
|
||||||
mismatch: '绑定错误',
|
mismatch: '绑定错误',
|
||||||
abnormal: '已上报失效',
|
abnormal: '已上报失效',
|
||||||
|
using_overdue: '使用超时',
|
||||||
|
bound_overdue: '绑定超时',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
redeemed: '已发货',
|
redeemed: '已发货',
|
||||||
mismatch: '绑定错误',
|
mismatch: '绑定错误',
|
||||||
abnormal: '已上报失效',
|
abnormal: '已上报失效',
|
||||||
|
using_overdue: '使用超时',
|
||||||
|
bound_overdue: '绑定超时',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
@@ -44,6 +46,8 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: 'redeemed', label: '已发货' },
|
{ value: 'redeemed', label: '已发货' },
|
||||||
{ value: 'mismatch', label: '绑定错误' },
|
{ value: 'mismatch', label: '绑定错误' },
|
||||||
{ value: 'abnormal', label: '已上报失效' },
|
{ value: 'abnormal', label: '已上报失效' },
|
||||||
|
{ value: 'using_overdue', label: '使用超时' },
|
||||||
|
{ value: 'bound_overdue', label: '绑定超时' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||||
|
|||||||
@@ -18,15 +18,20 @@ const { Text } = Typography
|
|||||||
|
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
// 满 72 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。
|
// 满 24 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。
|
||||||
function usingElapsedDays(usingAt?: string): number | null {
|
function elapsedDaysSince(value?: string): number | null {
|
||||||
if (!usingAt) return null
|
if (!value) return null
|
||||||
const startedAt = new Date(usingAt).getTime()
|
const startedAt = new Date(value).getTime()
|
||||||
if (Number.isNaN(startedAt)) return null
|
if (Number.isNaN(startedAt)) return null
|
||||||
const diff = Date.now() - startedAt
|
const diff = Date.now() - startedAt
|
||||||
return diff > 0 ? diff / MS_PER_DAY : 0
|
return diff > 0 ? diff / MS_PER_DAY : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用中超时起点:using_at。
|
||||||
|
function usingElapsedDays(usingAt?: string): number | null {
|
||||||
|
return elapsedDaysSince(usingAt)
|
||||||
|
}
|
||||||
|
|
||||||
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
|
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
|
||||||
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
|
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
|
||||||
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
|
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
|
||||||
@@ -73,6 +78,7 @@ export interface TokenColumnHandlers {
|
|||||||
copyingVoucherId: number | null
|
copyingVoucherId: number | null
|
||||||
usingWarnDays: number
|
usingWarnDays: number
|
||||||
usingDangerDays: number
|
usingDangerDays: number
|
||||||
|
boundWarnDays: number
|
||||||
refreshingTokenId: number | null
|
refreshingTokenId: number | null
|
||||||
onRefreshBinding: (id: number) => void
|
onRefreshBinding: (id: number) => void
|
||||||
openingUpstreamId: number | null
|
openingUpstreamId: number | null
|
||||||
@@ -91,6 +97,7 @@ export function buildTokenColumns({
|
|||||||
copyingVoucherId,
|
copyingVoucherId,
|
||||||
usingWarnDays,
|
usingWarnDays,
|
||||||
usingDangerDays,
|
usingDangerDays,
|
||||||
|
boundWarnDays,
|
||||||
refreshingTokenId,
|
refreshingTokenId,
|
||||||
onRefreshBinding,
|
onRefreshBinding,
|
||||||
openingUpstreamId,
|
openingUpstreamId,
|
||||||
@@ -166,12 +173,24 @@ export function buildTokenColumns({
|
|||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'bound':
|
case 'bound': {
|
||||||
|
const boundDays = elapsedDaysSince(record.bound_at)
|
||||||
|
if (boundDays !== null && boundDays >= boundWarnDays) {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color="volcano"
|
||||||
|
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
|
||||||
|
>
|
||||||
|
⏱ 绑定超时 {boundDays.toFixed(1)} 天
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||||
● 已授权绑定
|
● 已授权绑定
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
case 'redeemed':
|
case 'redeemed':
|
||||||
return (
|
return (
|
||||||
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||||
@@ -209,6 +228,7 @@ export function buildTokenColumns({
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null
|
const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null
|
||||||
|
const boundDays = record.status === 'bound' ? elapsedDaysSince(record.bound_at) : null
|
||||||
const usingColor =
|
const usingColor =
|
||||||
usingDays === null
|
usingDays === null
|
||||||
? '#64748b'
|
? '#64748b'
|
||||||
@@ -243,9 +263,10 @@ export function buildTokenColumns({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!record.redeemed_at && record.bound_at && (
|
{!record.redeemed_at && record.bound_at && (
|
||||||
<div style={{ fontSize: 11, color: '#0284c7' }}>
|
<div style={{ fontSize: 11, color: boundDays !== null && boundDays >= boundWarnDays ? '#ea580c' : '#0284c7' }}>
|
||||||
<span>🔗 绑定: </span>
|
<span>🔗 绑定: </span>
|
||||||
{formatDateTime(record.bound_at)}
|
{formatDateTime(record.bound_at)}
|
||||||
|
{boundDays !== null ? `(${boundDays.toFixed(1)} 天)` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
{!record.bound_at && !record.mismatch_at && record.claimed_at && (
|
||||||
|
|||||||
Reference in New Issue
Block a user