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() { + + + + + + + + + + diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index ae0cbd5..ed49443 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -1,4 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' +import { useState } from 'react' import { Table, message } from 'antd' import type { RockTokenItem } from '../../api' import { rockApi } from '../../api' @@ -13,6 +14,8 @@ export interface RockTokenTableProps { page: number pageSize: number totalTokens: number + usingWarnDays: number + usingDangerDays: number copyingVoucherId: number | null setCopyingVoucherId: Dispatch> setPreviewVoucherUrl: Dispatch> @@ -30,6 +33,8 @@ export function RockTokenTable({ page, pageSize, totalTokens, + usingWarnDays, + usingDangerDays, copyingVoucherId, setCopyingVoucherId, setPreviewVoucherUrl, @@ -40,6 +45,52 @@ export function RockTokenTable({ onReloadData, onReloadTokens, }: RockTokenTableProps) { + const [refreshingTokenId, setRefreshingTokenId] = useState(null) + const [openingUpstreamId, setOpeningUpstreamId] = useState(null) + + const handleOpenUpstream = async (id: number) => { + // 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。 + const win = window.open('about:blank', '_blank') + setOpeningUpstreamId(id) + try { + const res = await rockApi.getTokenPackageLink(id) + if (!win) { + message.warning('浏览器拦截了新窗口,请允许弹窗后重试') + return + } + win.opener = null + win.location.href = res.url + } catch (e) { + win?.close() + message.error('获取上游包链接失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setOpeningUpstreamId(null) + } + } + + const handleRefreshBinding = async (id: number) => { + setRefreshingTokenId(id) + try { + const res = await rockApi.refreshTokenBinding(id) + if (res.status === 'mismatch' || res.warning) { + message.warning(res.message || '上游绑定状态需要人工核对') + } else if (res.status === 'redeemed') { + message.success(res.message || '上游已发货,本地状态已同步为已交付') + } else if (res.bound) { + const role = res.role_name || res.role_id || '-' + message.success(res.message || `刷新成功:上游已绑定「${role}」`) + } else { + message.info(res.message || '上游仍未绑定,本地状态保持不变') + } + onReloadData() + onReloadTokens() + } catch (e) { + message.error('刷新绑定状态失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setRefreshingTokenId(null) + } + } + // 人工重置解冻 Mismatch 或 Abnormal Token const handleResetToken = async (id: number) => { try { @@ -152,6 +203,12 @@ export function RockTokenTable({ const columns = buildTokenColumns({ copyingVoucherId, + usingWarnDays, + usingDangerDays, + refreshingTokenId, + onRefreshBinding: handleRefreshBinding, + openingUpstreamId, + onOpenUpstream: handleOpenUpstream, onCopyVoucher: handleCopyVoucher, onPreviewVoucher: handlePreviewVoucher, onEditUid, diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index c80cc9b..246b9b0 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -1,5 +1,5 @@ import { Button, Popconfirm, Space, Tag, Tooltip, Typography } from 'antd' -import { CopyOutlined, EyeOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons' +import { CopyOutlined, ExportOutlined, EyeOutlined, StopOutlined, SyncOutlined, UndoOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' import type { RockTokenItem } from '../../api' import { formatDateTime } from '../../utils/time' @@ -8,8 +8,25 @@ import type { UidEditState } from './types' const { Text } = Typography +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +// 满 72 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。 +function usingElapsedDays(usingAt?: string): number | null { + if (!usingAt) return null + const startedAt = new Date(usingAt).getTime() + if (Number.isNaN(startedAt)) return null + const diff = Date.now() - startedAt + return diff > 0 ? diff / MS_PER_DAY : 0 +} + export interface TokenColumnHandlers { copyingVoucherId: number | null + usingWarnDays: number + usingDangerDays: number + refreshingTokenId: number | null + onRefreshBinding: (id: number) => void + openingUpstreamId: number | null + onOpenUpstream: (id: number) => void onCopyVoucher: (record: RockTokenItem) => void onPreviewVoucher: (record: RockTokenItem) => void onEditUid: (state: UidEditState) => void @@ -22,6 +39,12 @@ export interface TokenColumnHandlers { export function buildTokenColumns({ copyingVoucherId, + usingWarnDays, + usingDangerDays, + refreshingTokenId, + onRefreshBinding, + openingUpstreamId, + onOpenUpstream, onCopyVoucher, onPreviewVoucher, onEditUid, @@ -99,8 +122,8 @@ export function buildTokenColumns({ { title: '库存状态', dataIndex: 'status', - width: 140, - render: (status) => { + width: 170, + render: (status, record) => { switch (status) { case 'available': return ( @@ -108,12 +131,31 @@ export function buildTokenColumns({ ● 可用库存 ) - case 'using': + case 'using': { + const days = usingElapsedDays(record.using_at) + if (days !== null && days >= usingDangerDays) { + return ( + + ⚠️ 使用中超时 {days.toFixed(1)} 天 + + ) + } + if (days !== null && days >= usingWarnDays) { + return ( + + ● 扫码使用中 · {days.toFixed(1)} 天 + + ) + } return ( ● 扫码使用中 ) + } case 'bound': return ( @@ -154,39 +196,57 @@ export function buildTokenColumns({ }, { title: '流转时间', - width: 175, - render: (_, record) => ( - -
- 入库: - {formatDateTime(record.created_at)} -
- {record.mismatch_at && ( -
- ⚠️ 绑错: - {formatDateTime(record.mismatch_at)} + width: 210, + render: (_, record) => { + const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null + const usingColor = + usingDays === null + ? '#64748b' + : usingDays >= usingDangerDays + ? '#dc2626' + : usingDays >= usingWarnDays + ? '#d97706' + : '#0891b2' + return ( + +
+ 入库: + {formatDateTime(record.created_at)}
- )} - {record.redeemed_at && ( -
- ✅ 发货: - {formatDateTime(record.redeemed_at)} -
- )} - {!record.redeemed_at && record.bound_at && ( -
- 🔗 绑定: - {formatDateTime(record.bound_at)} -
- )} - {!record.bound_at && !record.mismatch_at && record.claimed_at && ( -
- 📦 提货: - {formatDateTime(record.claimed_at)} -
- )} -
- ), + {record.status === 'using' && record.using_at && ( +
+ ⏳ 使用中: + {formatDateTime(record.using_at)} + {usingDays !== null ? `(${usingDays.toFixed(1)} 天)` : ''} +
+ )} + {record.mismatch_at && ( +
+ ⚠️ 绑错: + {formatDateTime(record.mismatch_at)} +
+ )} + {record.redeemed_at && ( +
+ ✅ 发货: + {formatDateTime(record.redeemed_at)} +
+ )} + {!record.redeemed_at && record.bound_at && ( +
+ 🔗 绑定: + {formatDateTime(record.bound_at)} +
+ )} + {!record.bound_at && !record.mismatch_at && record.claimed_at && ( +
+ 📦 提货: + {formatDateTime(record.claimed_at)} +
+ )} + + ) + }, }, { title: '关联业务工单', @@ -381,12 +441,31 @@ export function buildTokenColumns({ { title: '操作', key: 'action', - width: 180, + width: 200, fixed: 'right', render: (_, record) => { + const refreshButton = ( + + ) if (record.status === 'mismatch') { return ( + {refreshButton} + - + + {refreshButton} + {canRelease && ( + onReleaseToken(record.id)} + > + + + )} + ) } + if (record.status === 'bound') { + return refreshButton + } return - }, },