refactor: 移除 Rock 发货处理中状态,新增异常派生筛选标记
- 新增迁移 028:历史 redeeming 回退为 bound,重建 pending 部分索引
- pending 语义改为 bound + redeem_requested_at 标记,不再写状态与状态日志
- 新增 anomaly 虚拟筛选项:基于状态日志派生异常标记,不改状态、不落库
- 状态列追加「⚠️ 异常」标签,统计新增 anomaly_stock
- 清理前后端 redeeming 相关分支与文案
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- 028: 移除 redeeming 状态,pending 改为纯标记字段;恢复 redeeming 历史数据
|
||||
-- 背景:上游异步兑奖不再占用独立状态,Token 保持 bound,
|
||||
-- 用 redeem_requested_at / redeemed_at 判定「发货确认中」。
|
||||
|
||||
-- 1) 回退历史 redeeming 数据:只回状态,不写状态日志,避免伪造流转。
|
||||
UPDATE rock_tokens
|
||||
SET status = 'bound',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'redeeming'
|
||||
AND redeemed_at IS NULL;
|
||||
|
||||
-- 2) 旧的 pending 索引依赖 status='redeeming',必须替换为标记口径。
|
||||
DROP INDEX IF EXISTS idx_rock_tokens_redeem_next_retry_at;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_tokens_redeem_pending
|
||||
ON rock_tokens (redeem_next_retry_at)
|
||||
WHERE redeem_requested_at IS NOT NULL
|
||||
AND redeemed_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN rock_tokens.redeem_requested_at IS '提交上游兑换的时间(发货确认中标记,非状态)';
|
||||
COMMENT ON COLUMN rock_tokens.redeem_next_retry_at IS '下一次允许轮询上游的时间(指数退避)';
|
||||
COMMENT ON COLUMN rock_tokens.redeem_timeout_warned_at IS '超过等待上限仍未确认时写入的告警时间(仅标记)';
|
||||
@@ -23,11 +23,14 @@ const (
|
||||
RockStatusAvailable = "available" // 可用库存
|
||||
RockStatusUsing = "using" // 已分配给订单,正在提货扫码
|
||||
RockStatusBound = "bound" // 扫码绑定成功
|
||||
RockStatusRedeeming = "redeeming" // 上游官方异步处理中,等待确认发货结果
|
||||
RockStatusRedeemed = "redeemed" // 发货完成
|
||||
RockStatusMismatch = "mismatch" // UID 校验不符阻断(绑错)
|
||||
RockStatusAbnormal = "abnormal" // 异常/作废
|
||||
|
||||
// 虚拟筛选状态:不落库,仅用于列表查询换算。
|
||||
// RockStatusAnomaly 表示「历史流转中出现过异常」的 Token,不改变其主状态。
|
||||
RockStatusAnomaly = "anomaly"
|
||||
|
||||
RockSKUBlackTshirt = "rock_black_tshirt"
|
||||
RockSKUBlueSportswear = "rock_blue_sportswear"
|
||||
|
||||
@@ -106,8 +109,10 @@ type RockToken struct {
|
||||
BoundAt *time.Time `json:"bound_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
|
||||
// 异步兑换(上游 pending)跟踪字段。
|
||||
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 首次提交兑换的时间,用于 15 分钟超时告警
|
||||
// 发货确认中(上游异步兑奖)标记字段。
|
||||
// 注意:这不是状态。Token 主状态保持 bound,pending 期间只写这些标记,
|
||||
// 用 redeem_requested_at 非空且 redeemed_at 为空来判定「发货确认中」。
|
||||
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 提交兑换的时间,pending 起点
|
||||
RedeemCheckedAt *time.Time `json:"redeem_checked_at,omitempty"` // 最近一次查询上游结果的时间
|
||||
RedeemAttempts int `gorm:"not null;default:0" json:"redeem_attempts"`
|
||||
RedeemNextRetryAt *time.Time `gorm:"index" json:"redeem_next_retry_at,omitempty"`
|
||||
@@ -125,6 +130,13 @@ type RockToken struct {
|
||||
UsingDanger bool `gorm:"-" json:"using_danger"`
|
||||
BoundDays float64 `gorm:"-" json:"bound_days"`
|
||||
UsingDays float64 `gorm:"-" json:"using_days"`
|
||||
|
||||
// 异常标记(派生,不落库):来自状态流转历史,不改变主状态。
|
||||
HasAnomaly bool `gorm:"-" json:"has_anomaly"`
|
||||
AnomalyType string `gorm:"-" json:"anomaly_type,omitempty"`
|
||||
AnomalyReason string `gorm:"-" json:"anomaly_reason,omitempty"`
|
||||
AnomalyAt *time.Time `gorm:"-" json:"anomaly_at,omitempty"`
|
||||
AnomalyCount int `gorm:"-" json:"anomaly_count"`
|
||||
}
|
||||
|
||||
func (RockToken) TableName() string {
|
||||
|
||||
@@ -61,7 +61,6 @@ func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.Fulfill
|
||||
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
model.RockStatusRedeeming,
|
||||
model.RockStatusMismatch,
|
||||
model.RockStatusAbnormal,
|
||||
}).
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 异常判定口径(与产品确认一致):
|
||||
// 1. 状态流转历史中出现过 mismatch / abnormal(绑定错误、上游失效);
|
||||
// 2. 状态流转原因或备注中出现失败类关键字(兑换失败、刷新失败、卡密不存在、超时等);
|
||||
// 3. 只要命中任意一条即为「异常」,不改变 Token 主状态。
|
||||
//
|
||||
// 采用子查询而不是落库字段,保证历史数据无需迁移即可被筛选到。
|
||||
func rockTokenAnomalyExistsSQL() string {
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM rock_token_status_logs l
|
||||
WHERE l.token_id = rock_tokens.id
|
||||
AND (
|
||||
l.to_status IN ('mismatch', 'abnormal')
|
||||
OR l.reason LIKE '%失败%'
|
||||
OR l.reason LIKE '%不存在%'
|
||||
OR l.reason LIKE '%异常%'
|
||||
OR l.reason LIKE '%超时%'
|
||||
OR l.reason LIKE '%拦截%'
|
||||
OR l.reason LIKE '%不符%'
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
// rockAnomalyRecentSQL 取每个 Token 最近一条异常日志,供批量补齐派生字段。
|
||||
const rockAnomalyRecentSQL = `
|
||||
SELECT DISTINCT ON (l.token_id)
|
||||
l.token_id,
|
||||
l.to_status,
|
||||
l.reason,
|
||||
l.created_at
|
||||
FROM rock_token_status_logs l
|
||||
WHERE l.token_id IN (?)
|
||||
AND (
|
||||
l.to_status IN ('mismatch', 'abnormal')
|
||||
OR l.reason LIKE '%失败%'
|
||||
OR l.reason LIKE '%不存在%'
|
||||
OR l.reason LIKE '%异常%'
|
||||
OR l.reason LIKE '%超时%'
|
||||
OR l.reason LIKE '%拦截%'
|
||||
OR l.reason LIKE '%不符%'
|
||||
)
|
||||
ORDER BY l.token_id, l.id DESC
|
||||
`
|
||||
|
||||
const rockAnomalyCountSQL = `
|
||||
SELECT l.token_id, COUNT(*) AS total
|
||||
FROM rock_token_status_logs l
|
||||
WHERE l.token_id IN (?)
|
||||
AND (
|
||||
l.to_status IN ('mismatch', 'abnormal')
|
||||
OR l.reason LIKE '%失败%'
|
||||
OR l.reason LIKE '%不存在%'
|
||||
OR l.reason LIKE '%异常%'
|
||||
OR l.reason LIKE '%超时%'
|
||||
OR l.reason LIKE '%拦截%'
|
||||
OR l.reason LIKE '%不符%'
|
||||
)
|
||||
GROUP BY l.token_id
|
||||
`
|
||||
|
||||
type rockAnomalyRecentRow struct {
|
||||
TokenID uint `gorm:"column:token_id"`
|
||||
ToStatus string `gorm:"column:to_status"`
|
||||
Reason string `gorm:"column:reason"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
type rockAnomalyCountRow struct {
|
||||
TokenID uint `gorm:"column:token_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
|
||||
// fillRockTokenAnomalyFlags 为一页 Token 批量补齐异常标记,避免 N+1 查询。
|
||||
func (s *RockService) fillRockTokenAnomalyFlags(ctx context.Context, tokens []model.RockToken) {
|
||||
if len(tokens) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]uint, 0, len(tokens))
|
||||
for i := range tokens {
|
||||
if tokens[i].ID != 0 {
|
||||
ids = append(ids, tokens[i].ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var recents []rockAnomalyRecentRow
|
||||
if err := s.db.WithContext(ctx).Raw(rockAnomalyRecentSQL, ids).Scan(&recents).Error; err != nil {
|
||||
return
|
||||
}
|
||||
var counts []rockAnomalyCountRow
|
||||
if err := s.db.WithContext(ctx).Raw(rockAnomalyCountSQL, ids).Scan(&counts).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
recentMap := make(map[uint]rockAnomalyRecentRow, len(recents))
|
||||
for _, row := range recents {
|
||||
recentMap[row.TokenID] = row
|
||||
}
|
||||
countMap := make(map[uint]int64, len(counts))
|
||||
for _, row := range counts {
|
||||
countMap[row.TokenID] = row.Total
|
||||
}
|
||||
|
||||
for i := range tokens {
|
||||
id := tokens[i].ID
|
||||
row, ok := recentMap[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
at := row.CreatedAt
|
||||
tokens[i].HasAnomaly = true
|
||||
tokens[i].AnomalyType = rockAnomalyTypeFromLog(row.ToStatus, row.Reason)
|
||||
tokens[i].AnomalyReason = strings.TrimSpace(row.Reason)
|
||||
tokens[i].AnomalyAt = &at
|
||||
tokens[i].AnomalyCount = int(countMap[id])
|
||||
}
|
||||
}
|
||||
|
||||
// rockAnomalyTypeFromLog 把状态流转记录归成给用户看的异常分类。
|
||||
func rockAnomalyTypeFromLog(toStatus, reason string) string {
|
||||
switch strings.TrimSpace(toStatus) {
|
||||
case model.RockStatusMismatch:
|
||||
return "UID 不一致"
|
||||
case model.RockStatusAbnormal:
|
||||
return "上游失效"
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
switch {
|
||||
case strings.Contains(reason, "不存在"):
|
||||
return "卡密不存在"
|
||||
case strings.Contains(reason, "超时"):
|
||||
return "确认超时"
|
||||
case strings.Contains(reason, "兑换") || strings.Contains(reason, "发货"):
|
||||
return "兑换失败"
|
||||
case strings.Contains(reason, "刷新"):
|
||||
return "刷新失败"
|
||||
case strings.Contains(reason, "拦截") || strings.Contains(reason, "不符"):
|
||||
return "UID 不一致"
|
||||
case reason != "":
|
||||
return "异常"
|
||||
default:
|
||||
return "异常"
|
||||
}
|
||||
}
|
||||
|
||||
// rockTokenAnomalyFilter 让 applyRockTokenListFilters 复用同一套口径。
|
||||
func rockTokenAnomalyFilter(db *gorm.DB) *gorm.DB {
|
||||
return db.Where(rockTokenAnomalyExistsSQL())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// 异常是派生标记:只依据历史流转,不改变 Token 主状态。
|
||||
func TestListTokens_AnomalyFilterIsDerivedAndKeepsStatus(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
now := time.Now()
|
||||
anomalyToken := model.RockToken{
|
||||
UpstreamTokenID: "ANOMALY-1",
|
||||
Token: "jwt-anomaly-1",
|
||||
RedeemCode: "CODE-ANOMALY-1",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: "FO-ANOMALY-1",
|
||||
ExpectedUID: "3829515948",
|
||||
BoundAt: &now,
|
||||
}
|
||||
normalToken := model.RockToken{
|
||||
UpstreamTokenID: "ANOMALY-2",
|
||||
Token: "jwt-anomaly-2",
|
||||
RedeemCode: "CODE-ANOMALY-2",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: "FO-ANOMALY-2",
|
||||
ExpectedUID: "3829515949",
|
||||
BoundAt: &now,
|
||||
}
|
||||
if err := db.Create(&anomalyToken).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&normalToken).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
// 只给第一条写入 mismatch 流转历史。
|
||||
if err := db.Create(&model.RockTokenStatusLog{
|
||||
TokenID: anomalyToken.ID,
|
||||
UpstreamTokenID: anomalyToken.UpstreamTokenID,
|
||||
EventType: rockTokenEventStatusChanged,
|
||||
FromStatus: model.RockStatusUsing,
|
||||
ToStatus: model.RockStatusMismatch,
|
||||
Source: rockTokenSourceBindCheck,
|
||||
Reason: "预期UID(3829515948) 与 扫码UID(舱y/3829515950) 不一致",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建状态日志失败: %v", err)
|
||||
}
|
||||
|
||||
list, total, err := svc.ListTokens(context.Background(), 1, 20, model.RockStatusAnomaly, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询异常列表失败: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 {
|
||||
t.Fatalf("异常筛选应只命中 1 条,实际 total=%d len=%d", total, len(list))
|
||||
}
|
||||
got := list[0]
|
||||
if got.ID != anomalyToken.ID {
|
||||
t.Fatalf("命中的 Token 不正确: %d", got.ID)
|
||||
}
|
||||
// 关键:筛选不改变主状态,也不写派生状态。
|
||||
if got.Status != model.RockStatusBound {
|
||||
t.Fatalf("异常筛选不应改变主状态,实际 %s", got.Status)
|
||||
}
|
||||
if !got.HasAnomaly || got.AnomalyType != "UID 不一致" || got.AnomalyCount != 1 {
|
||||
t.Fatalf("异常派生字段不正确: %+v", got)
|
||||
}
|
||||
if got.AnomalyReason == "" || got.AnomalyAt == nil {
|
||||
t.Fatalf("异常原因与时间应补齐: %+v", got)
|
||||
}
|
||||
|
||||
// 普通列表也应带上异常标记,且正常 Token 不带。
|
||||
all, _, err := svc.ListTokens(context.Background(), 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询全部失败: %v", err)
|
||||
}
|
||||
var flagged, clean int
|
||||
for i := range all {
|
||||
if all[i].HasAnomaly {
|
||||
flagged++
|
||||
} else {
|
||||
clean++
|
||||
}
|
||||
}
|
||||
if flagged != 1 || clean != 1 {
|
||||
t.Fatalf("异常标记数量不正确: flagged=%d clean=%d", flagged, clean)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常 Token 不应被异常筛选命中。
|
||||
func TestListTokens_AnomalyFilterExcludesCleanTokens(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "CLEAN-1",
|
||||
Token: "jwt-clean-1",
|
||||
RedeemCode: "CODE-CLEAN-1",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusAvailable,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.RockTokenStatusLog{
|
||||
TokenID: token.ID,
|
||||
UpstreamTokenID: token.UpstreamTokenID,
|
||||
EventType: rockTokenEventCreated,
|
||||
ToStatus: model.RockStatusAvailable,
|
||||
Source: rockTokenSourcePurchase,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建状态日志失败: %v", err)
|
||||
}
|
||||
_ = now
|
||||
|
||||
list, total, err := svc.ListTokens(context.Background(), 1, 20, model.RockStatusAnomaly, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if total != 0 || len(list) != 0 {
|
||||
t.Fatalf("正常 Token 不应命中异常筛选,实际 total=%d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计徽标按同一口径计数。
|
||||
func TestGetStats_AnomalyStockCountsDerivedAnomalies(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
if _, err := svc.GetConfig(context.Background()); err != nil {
|
||||
t.Fatalf("初始化配置失败: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "STAT-1",
|
||||
Token: "jwt-stat-1",
|
||||
RedeemCode: "CODE-STAT-1",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusBound,
|
||||
BoundAt: &now,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.RockTokenStatusLog{
|
||||
TokenID: token.ID,
|
||||
UpstreamTokenID: token.UpstreamTokenID,
|
||||
EventType: rockTokenEventStatusChanged,
|
||||
FromStatus: model.RockStatusUsing,
|
||||
ToStatus: model.RockStatusAbnormal,
|
||||
Source: rockTokenSourceRefresh,
|
||||
Reason: "上游卡密不存在(刷新自动同步)",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建状态日志失败: %v", err)
|
||||
}
|
||||
|
||||
stats, err := svc.GetStats(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("获取统计失败: %v", err)
|
||||
}
|
||||
if stats.AnomalyStock != 1 {
|
||||
t.Fatalf("异常徽标应统计 1 条,实际 %d", stats.AnomalyStock)
|
||||
}
|
||||
// 异常只做标记:Token 主状态仍是 bound,因此正常计入已绑定库存。
|
||||
if stats.BoundStock != 1 {
|
||||
t.Fatalf("异常不应改变主状态统计,bound 应为 1,实际 %d", stats.BoundStock)
|
||||
}
|
||||
if stats.AbnormalStock != 0 {
|
||||
t.Fatalf("异常标记不应把 Token 计入已上报失效,实际 %d", stats.AbnormalStock)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
model.RockStatusRedeeming,
|
||||
model.RockStatusMismatch,
|
||||
model.RockStatusRedeemed,
|
||||
model.RockStatusAbnormal,
|
||||
@@ -61,7 +60,6 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
switch token.Status {
|
||||
case model.RockStatusAbnormal:
|
||||
return nil, nil, errors.New(rockOrderTokenLockedMessage)
|
||||
case model.RockStatusRedeeming:
|
||||
return nil, nil, errors.New("该订单已提交发货,上游官方处理中,不能再次领取提货号")
|
||||
case model.RockStatusRedeemed:
|
||||
return nil, nil, errors.New("该订单已发货,不能再次领取提货号")
|
||||
|
||||
@@ -84,7 +84,8 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
}
|
||||
|
||||
// 已提交发货(上游异步处理中)的 Token 不再参与扫码绑定轮询。
|
||||
if token.Status == model.RockStatusRedeeming {
|
||||
// 判定依据是标记而不是状态:主状态仍是 bound,但已提交兑换且未拿到履约结果。
|
||||
if rockTokenRedeemPending(&token) {
|
||||
return &CheckBindingResult{
|
||||
Bound: true,
|
||||
RoleName: token.ActualRoleName,
|
||||
|
||||
@@ -27,7 +27,7 @@ type TokenBindingRefreshResult struct {
|
||||
|
||||
func canRefreshRockBinding(status string) bool {
|
||||
switch status {
|
||||
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeeming, model.RockStatusMismatch:
|
||||
case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -169,15 +169,14 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
|
||||
case upstreamBound:
|
||||
// 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
|
||||
// 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
|
||||
// 但本地已进入 redeeming 时不能降级回 bound,必须保持发货处理中。
|
||||
if token.Status == model.RockStatusRedeeming {
|
||||
updates["status"] = model.RockStatusRedeeming
|
||||
// 发货确认中(有 redeem 标记未完成)时只刷新标记,状态保持 bound。
|
||||
if rockTokenRedeemPending(&token) {
|
||||
updates["redeem_checked_at"] = now
|
||||
if msg := strings.TrimSpace(ref.Message); msg != "" {
|
||||
updates["redeem_last_message"] = msg
|
||||
}
|
||||
result.Status = model.RockStatusRedeeming
|
||||
result.Message = "上游仍未确认发货,保持发货处理中"
|
||||
result.Status = model.RockStatusBound
|
||||
result.Message = "上游仍未确认发货,保持发货确认中"
|
||||
break
|
||||
}
|
||||
updates["status"] = model.RockStatusBound
|
||||
@@ -198,9 +197,10 @@ func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, act
|
||||
}
|
||||
|
||||
default:
|
||||
if token.Status == model.RockStatusRedeeming {
|
||||
if rockTokenRedeemPending(&token) {
|
||||
updates["redeem_checked_at"] = now
|
||||
result.Message = "上游仍未确认发货,保持发货处理中"
|
||||
result.Status = model.RockStatusBound
|
||||
result.Message = "上游仍未确认发货,保持发货确认中"
|
||||
} else {
|
||||
result.Message = "上游仍未绑定,本地状态保持不变"
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
canRefreshUpstream := existing.Source == "package" &&
|
||||
strings.TrimSpace(existing.AssignedOrderNo) == "" &&
|
||||
existing.Status != model.RockStatusAvailable &&
|
||||
existing.Status != model.RockStatusRedeeming
|
||||
!rockTokenRedeemPending(&existing)
|
||||
if canRefreshUpstream {
|
||||
updates["status"] = status
|
||||
updates["restocked_at"] = nil // 上游包同步回库不属于「释放回库存」,不产生优先权
|
||||
@@ -182,8 +182,6 @@ func normalizeRockPackageStatus(status string) string {
|
||||
return model.RockStatusUsing
|
||||
case model.RockStatusBound:
|
||||
return model.RockStatusBound
|
||||
case model.RockStatusRedeeming:
|
||||
return model.RockStatusRedeeming
|
||||
case model.RockStatusRedeemed:
|
||||
return model.RockStatusRedeemed
|
||||
case model.RockStatusMismatch:
|
||||
|
||||
@@ -160,8 +160,8 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar
|
||||
|
||||
trimmedStatus := strings.TrimSpace(nextStatus)
|
||||
if trimmedStatus != "" {
|
||||
if token.Status == model.RockStatusRedeeming && trimmedStatus != model.RockStatusAbnormal {
|
||||
return nil, fmt.Errorf("发货处理中的 Token 只能标记为已上报失效,不能改回其他状态")
|
||||
if rockTokenRedeemPending(&token) && trimmedStatus != model.RockStatusAbnormal {
|
||||
return nil, fmt.Errorf("发货确认中的 Token 只能标记为已上报失效,不能改回其他状态")
|
||||
}
|
||||
switch trimmedStatus {
|
||||
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
|
||||
|
||||
@@ -25,8 +25,9 @@ type RockRedeemResult struct {
|
||||
|
||||
// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤)。
|
||||
//
|
||||
// 上游 0666.pro 是异步兑奖:HTTP 200 + status=pending 表示「官方处理中」,
|
||||
// 此时绝不能写 redeemed 终态,而是进入 redeeming 并交给后台任务轮询确认。
|
||||
// 上游 0666.pro 是异步兑奖:HTTP 200 + status=pending 表示「官方处理中」。
|
||||
// 此时 Token 主状态保持 bound,只写 redeem_* 标记字段等待后台确认,
|
||||
// 绝不写 redeemed 终态,也不新增中间状态。
|
||||
func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string, recorder *RockDeliveryRecorder) (*RockRedeemResult, error) {
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
@@ -36,10 +37,10 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换")
|
||||
}
|
||||
|
||||
// 已进入 redeeming 的 Token 不重复提交兑换,直接返回处理中,避免重复发放。
|
||||
// 发货确认中(待确认)的 Token 不重复提交兑换,直接返回处理中,避免重复发放。
|
||||
var token model.RockToken
|
||||
err = s.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{model.RockStatusBound, model.RockStatusRedeeming}).
|
||||
Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound).
|
||||
First(&token).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -52,7 +53,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
}
|
||||
ctx = withRockDeliveryRecorder(ctx, recorder)
|
||||
|
||||
if token.Status == model.RockStatusRedeeming {
|
||||
if rockTokenRedeemPending(&token) {
|
||||
msg := strings.TrimSpace(token.RedeemLastMessage)
|
||||
if msg == "" {
|
||||
msg = "上游官方处理中,请稍后刷新查看结果"
|
||||
@@ -132,7 +133,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
if redeemResp.IsPending() {
|
||||
msg := redeemResp.FailureMessage("官方处理中,请稍后刷新查看结果")
|
||||
now := time.Now()
|
||||
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
|
||||
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
|
||||
if recorder != nil {
|
||||
recorder.Internal(ctx, "redeem_result", "pending", msg, map[string]interface{}{
|
||||
"upstream_status": redeemResp.Status,
|
||||
@@ -154,7 +155,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
}
|
||||
// 刷新失败不直接否认可信的成功响应,但也不立即写终态;交给后台轮询确认。
|
||||
msg := "上游已受理,正在等待发货结果确认"
|
||||
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
|
||||
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
|
||||
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
|
||||
}
|
||||
if ref != nil && !rockUpstreamRedeemed(ref) {
|
||||
@@ -165,7 +166,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
"redeemed_at": ref.Token.RedeemedAt,
|
||||
})
|
||||
}
|
||||
token = s.markTokenRedeeming(ctx, token, targetSkin, msg, recorder)
|
||||
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
|
||||
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
|
||||
}
|
||||
|
||||
@@ -214,17 +215,15 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: false, Message: "发货成功", TraceID: recorderTraceID(recorder)}, nil
|
||||
}
|
||||
|
||||
// markTokenRedeeming 把 Token 置为「发货处理中」,记录 pending 起点与首次轮询时间。
|
||||
func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) model.RockToken {
|
||||
// markTokenRedeemPending 记录「发货确认中」标记(不改变 Token 状态)。
|
||||
func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) model.RockToken {
|
||||
now := time.Now()
|
||||
requestedAt := token.RedeemRequestedAt
|
||||
if requestedAt == nil {
|
||||
requestedAt = &now
|
||||
}
|
||||
nextRetry := now.Add(30 * time.Second)
|
||||
fromStatus := token.Status
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusRedeeming,
|
||||
"target_skin": targetSkin,
|
||||
"target_currency": "幸运币",
|
||||
"redeem_requested_at": requestedAt,
|
||||
@@ -235,17 +234,13 @@ func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockTo
|
||||
"redeem_last_error": "",
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeeming,
|
||||
rockTokenSourceRedeem, strings.TrimSpace(message), false, nil)
|
||||
if recorder != nil {
|
||||
recorder.Internal(ctx, "state_change", "pending", strings.TrimSpace(message), map[string]interface{}{
|
||||
"from_status": fromStatus,
|
||||
"to_status": model.RockStatusRedeeming,
|
||||
recorder.Internal(ctx, "redeem_pending", "pending", strings.TrimSpace(message), map[string]interface{}{
|
||||
"status": token.Status,
|
||||
"redeem_requested_at": requestedAt.Format("2006-01-02 15:04:05"),
|
||||
"next_retry_at": nextRetry.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
token.Status = model.RockStatusRedeeming
|
||||
token.TargetSkin = targetSkin
|
||||
token.TargetCurrency = "幸运币"
|
||||
token.RedeemRequestedAt = requestedAt
|
||||
@@ -257,6 +252,18 @@ func (s *RockService) markTokenRedeeming(ctx context.Context, token model.RockTo
|
||||
return token
|
||||
}
|
||||
|
||||
// rockTokenRedeemPending 判断 Token 是否处于「发货确认中」:
|
||||
// 已提交兑换、还没拿到履约结果、主状态仍是 bound。
|
||||
func rockTokenRedeemPending(token *model.RockToken) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
if token.RedeemRequestedAt == nil || token.RedeemedAt != nil {
|
||||
return false
|
||||
}
|
||||
return token.Status == model.RockStatusBound
|
||||
}
|
||||
|
||||
func recorderTraceID(recorder *RockDeliveryRecorder) string {
|
||||
if recorder == nil {
|
||||
return ""
|
||||
|
||||
@@ -37,7 +37,7 @@ func (s *RockService) SetRedeemConfirmHook(hook func(ctx context.Context, order
|
||||
s.redeemConfirmHook = hook
|
||||
}
|
||||
|
||||
// RunRedeemConfirmWorker 启动 pending 兑换确认任务:每 20 秒扫描一次 redeeming Token。
|
||||
// RunRedeemConfirmWorker 启动发货结果确认任务:每 20 秒扫描一次「发货确认中」标记。
|
||||
//
|
||||
// 频控保护:上游 110001 时全局冷却 60 秒,且每个 Token 使用指数退避,
|
||||
// 避免把 0666.pro 的公开刷新接口打成持续频控。
|
||||
@@ -66,7 +66,10 @@ func (s *RockService) RunRedeemConfirmWorker(ctx context.Context) {
|
||||
|
||||
var errRockRedeemBusy = errors.New("发货结果确认任务正在执行")
|
||||
|
||||
// ProcessRedeemingTokens 扫描并确认一批 pending 兑换。可被后台任务或测试直接调用。
|
||||
// ProcessRedeemingTokens 扫描并确认一批「发货确认中」的 Token。
|
||||
//
|
||||
// 判定口径是标记而非状态:status=bound + 已提交兑换 + 尚未拿到履约结果。
|
||||
// 可被后台任务或测试直接调用。
|
||||
func (s *RockService) ProcessRedeemingTokens(ctx context.Context) (*RockRedeemConfirmSummary, error) {
|
||||
if !rockRedeemWorkerRunning.CompareAndSwap(false, true) {
|
||||
return nil, errRockRedeemBusy
|
||||
@@ -76,7 +79,9 @@ func (s *RockService) ProcessRedeemingTokens(ctx context.Context) (*RockRedeemCo
|
||||
now := time.Now()
|
||||
var tokens []model.RockToken
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ?", model.RockStatusRedeeming).
|
||||
Where("status = ?", model.RockStatusBound).
|
||||
Where("redeem_requested_at IS NOT NULL").
|
||||
Where("redeemed_at IS NULL").
|
||||
Where("(redeem_next_retry_at IS NULL OR redeem_next_retry_at <= ?)", now).
|
||||
Order("redeem_next_retry_at ASC").
|
||||
Limit(rockRedeemBatchSize).
|
||||
@@ -187,7 +192,7 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// 仍在官方处理中:保持 redeeming,指数退避。
|
||||
// 仍在官方处理中:保持 bound + 标记,指数退避。
|
||||
waitingMessage := strings.TrimSpace(ref.Message)
|
||||
if waitingMessage == "" {
|
||||
waitingMessage = "官方处理中,请稍后刷新查看结果"
|
||||
@@ -210,16 +215,13 @@ func (s *RockService) confirmRedeemingToken(ctx context.Context, token *model.Ro
|
||||
})
|
||||
}
|
||||
|
||||
// 超过 15 分钟仍未确认:保持处理中,仅告警一次。
|
||||
// 超过等待上限仍未确认:保持处理中,仅告警一次(不做状态流转)。
|
||||
if token.RedeemRequestedAt != nil && now.Sub(*token.RedeemRequestedAt) >= rockRedeemTimeout && token.RedeemTimeoutWarnedAt == nil {
|
||||
warnAt := now
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(token).Updates(map[string]interface{}{
|
||||
"redeem_timeout_warned_at": warnAt,
|
||||
"redeem_last_message": waitingMessage + "(已超过 15 分钟未确认,已告警)",
|
||||
}).Error
|
||||
writeRockTokenStatusLog(s.db.WithContext(ctx), token, rockTokenEventStatusChanged,
|
||||
model.RockStatusRedeeming, model.RockStatusRedeeming, rockTokenSourceRedeem,
|
||||
"上游超过 15 分钟未确认发货,已触发告警", false, nil)
|
||||
if recorder != nil {
|
||||
recorder.Internal(ctx, "redeem_timeout", "pending", "上游超过 15 分钟未确认发货", nil)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ func setRockTestBearerToken(t *testing.T, svc *RockService, bearer string) {
|
||||
}
|
||||
|
||||
// 上游返回 pending(HTTP 200 + status=pending + token.status=bound)时:
|
||||
// 本地必须进入 redeeming,绝不能写 redeemed 终态或履约流水。
|
||||
func TestRedeemTokenForOrder_PendingKeepsRedeeming(t *testing.T) {
|
||||
// Token 主状态必须保持 bound,只写发货确认标记,绝不写 redeemed 终态或履约流水。
|
||||
func TestRedeemTokenForOrder_PendingKeepsBoundWithMarkers(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")
|
||||
@@ -104,8 +104,8 @@ func TestRedeemTokenForOrder_PendingKeepsRedeeming(t *testing.T) {
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusRedeeming {
|
||||
t.Fatalf("pending 后状态应为 redeeming,实际为 %s", saved.Status)
|
||||
if saved.Status != model.RockStatusBound {
|
||||
t.Fatalf("pending 后状态应保持 bound,实际为 %s", saved.Status)
|
||||
}
|
||||
if saved.RedeemedAt != nil {
|
||||
t.Fatalf("pending 不能写 redeemed_at,实际为 %v", saved.RedeemedAt)
|
||||
@@ -196,8 +196,8 @@ func TestRedeemTokenForOrder_UnexpectedPayloadDoesNotMarkRedeemed(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// 已进入 redeeming 的 Token 再次提交必须直接返回处理中,不能重复调用上游兑换。
|
||||
func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
|
||||
// 发货确认中(已提交兑换未确认)的 Token 再次提交必须直接返回处理中,不能重复调用上游兑换。
|
||||
func TestRedeemTokenForOrder_PendingDoesNotResubmit(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
var redeemCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -225,7 +225,7 @@ func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
|
||||
Token: "jwt-179386",
|
||||
RedeemCode: "CODE-REDEEMING",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusRedeeming,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: "FO-REDEEMING-1",
|
||||
ExpectedUID: "3829515950",
|
||||
ActualRoleName: "舱x",
|
||||
@@ -247,7 +247,7 @@ func TestRedeemTokenForOrder_RedeemingDoesNotResubmit(t *testing.T) {
|
||||
t.Fatalf("重复提交应返回 Pending=true: %+v", result)
|
||||
}
|
||||
if redeemCalls != 0 {
|
||||
t.Fatalf("redeeming 状态不应再次调用上游兑换,实际调用 %d 次", redeemCalls)
|
||||
t.Fatalf("发货确认中不应再次调用上游兑换,实际调用 %d 次", redeemCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ func TestProcessRedeemingTokens_ConfirmsAndSyncsOrder(t *testing.T) {
|
||||
Token: "jwt-179384",
|
||||
RedeemCode: "CODE-CONFIRM",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusRedeeming,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: "FO-CONFIRM-1",
|
||||
ExpectedUID: "3829515948",
|
||||
ActualRoleName: "舱z",
|
||||
@@ -343,7 +343,7 @@ func TestProcessRedeemingTokens_ConfirmsAndSyncsOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上游仍为 bound 时应保持 redeeming,并按指数退避延后下一次查询。
|
||||
// 上游仍为 bound 时应保持发货确认标记,并按指数退避延后下一次查询。
|
||||
func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -376,7 +376,7 @@ func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
|
||||
Token: "jwt-179387",
|
||||
RedeemCode: "CODE-STILL-PENDING",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusRedeeming,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: "FO-STILL-PENDING",
|
||||
ExpectedUID: "3829515948",
|
||||
RedeemRequestedAt: &requested,
|
||||
@@ -398,8 +398,8 @@ func TestProcessRedeemingTokens_StaysPendingAndBacksOff(t *testing.T) {
|
||||
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||
t.Fatalf("读取 Token 失败: %v", err)
|
||||
}
|
||||
if saved.Status != model.RockStatusRedeeming {
|
||||
t.Fatalf("状态应保持 redeeming,实际 %s", saved.Status)
|
||||
if saved.Status != model.RockStatusBound {
|
||||
t.Fatalf("状态应保持 bound(发货确认中只写标记),实际 %s", saved.Status)
|
||||
}
|
||||
if saved.RedeemAttempts != 1 {
|
||||
t.Fatalf("轮询次数应为 1,实际 %d", saved.RedeemAttempts)
|
||||
|
||||
@@ -44,20 +44,20 @@ type RockStatsResponse struct {
|
||||
AvailableStock int64 `json:"available_stock"`
|
||||
UsingStock int64 `json:"using_stock"`
|
||||
BoundStock int64 `json:"bound_stock"`
|
||||
RedeemingStock int64 `json:"redeeming_stock"` // 上游官方处理中
|
||||
RedeemedStock int64 `json:"redeemed_stock"`
|
||||
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
|
||||
AbnormalStock int64 `json:"abnormal_stock"`
|
||||
DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
|
||||
// 过期预警计数:仅统计对应主状态(使用中 / 已绑定 / 发货处理中),用于筛选栏徽标。
|
||||
UsingOverdueStock int64 `json:"using_overdue_stock"`
|
||||
UsingDangerStock int64 `json:"using_danger_stock"`
|
||||
BoundOverdueStock int64 `json:"bound_overdue_stock"`
|
||||
RedeemingTimeoutStock int64 `json:"redeeming_timeout_stock"`
|
||||
UpstreamPoints float64 `json:"upstream_points"`
|
||||
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
||||
TokenWarningStock int `json:"token_warning_stock"`
|
||||
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
||||
// 过期预警计数:仅统计对应主状态(使用中 / 已绑定),用于筛选栏徽标。
|
||||
UsingOverdueStock int64 `json:"using_overdue_stock"`
|
||||
UsingDangerStock int64 `json:"using_danger_stock"`
|
||||
BoundOverdueStock int64 `json:"bound_overdue_stock"`
|
||||
// 异常标记计数(派生筛选,不是状态):历史流转中出现过异常的 Token 数。
|
||||
AnomalyStock int64 `json:"anomaly_stock"`
|
||||
UpstreamPoints float64 `json:"upstream_points"`
|
||||
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
||||
TokenWarningStock int `json:"token_warning_stock"`
|
||||
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
||||
// 自动刷新绑定错误开关状态,供筛选栏 Switch 回显
|
||||
AutoRefreshMismatchEnabled bool `json:"auto_refresh_mismatch_enabled"`
|
||||
// 自动补货冷却状态:供页面提示“下次可补货时间”
|
||||
@@ -365,8 +365,6 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
|
||||
stats.UsingStock = r.Total
|
||||
case model.RockStatusBound:
|
||||
stats.BoundStock = r.Total
|
||||
case model.RockStatusRedeeming:
|
||||
stats.RedeemingStock = r.Total
|
||||
case model.RockStatusRedeemed:
|
||||
stats.RedeemedStock = r.Total
|
||||
case model.RockStatusMismatch:
|
||||
@@ -404,8 +402,8 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||
Where("status = ? AND redeem_requested_at IS NOT NULL AND redeem_requested_at <= ?", model.RockStatusRedeeming, now.Add(-rockRedeemTimeout)).
|
||||
Count(&stats.RedeemingTimeoutStock).Error; err != nil {
|
||||
Where(rockTokenAnomalyExistsSQL()).
|
||||
Count(&stats.AnomalyStock).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -592,6 +592,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
|
||||
}
|
||||
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
|
||||
s.prepareRockTokenList(ctx, tokens)
|
||||
s.fillRockTokenAnomalyFlags(ctx, tokens)
|
||||
|
||||
return tokens, total, nil
|
||||
}
|
||||
|
||||
@@ -23,10 +23,9 @@ const (
|
||||
)
|
||||
|
||||
var rockTokenDangerousStatuses = map[string]bool{
|
||||
model.RockStatusUsing: true,
|
||||
model.RockStatusBound: true,
|
||||
model.RockStatusRedeeming: true,
|
||||
model.RockStatusMismatch: true,
|
||||
model.RockStatusUsing: true,
|
||||
model.RockStatusBound: true,
|
||||
model.RockStatusMismatch: true,
|
||||
}
|
||||
|
||||
// RockTokenDeleteStatusCount 是删除预览中的状态分布。
|
||||
@@ -68,7 +67,7 @@ type RockTokenForceRequiredError struct {
|
||||
}
|
||||
|
||||
func (e *RockTokenForceRequiredError) Error() string {
|
||||
return fmt.Sprintf("本次操作包含 %d 条使用中/已绑定/发货处理中/绑定错误的 Token,必须二次确认后强制删除", e.Dangerous)
|
||||
return fmt.Sprintf("本次操作包含 %d 条使用中/已绑定/绑定错误的 Token,必须二次确认后强制删除", e.Dangerous)
|
||||
}
|
||||
|
||||
// RockTokenDeleteConflictError 表示预览后数据已变化。
|
||||
@@ -85,9 +84,9 @@ func (e *RockTokenDeleteConflictError) Error() string {
|
||||
|
||||
func isKnownRockTokenStatus(status string) bool {
|
||||
switch status {
|
||||
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeeming,
|
||||
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
|
||||
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal,
|
||||
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue:
|
||||
rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue, model.RockStatusAnomaly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -165,6 +164,9 @@ func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time
|
||||
case rockTokenStatusBoundOverdue:
|
||||
db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?",
|
||||
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays))
|
||||
case model.RockStatusAnomaly:
|
||||
// 异常是派生筛选:只看历史流转,不限定主状态,也不改状态。
|
||||
db = rockTokenAnomalyFilter(db)
|
||||
default:
|
||||
if value := strings.TrimSpace(status); value != "" {
|
||||
db = db.Where("status = ?", value)
|
||||
@@ -187,7 +189,6 @@ func rockTokenStatusCountsFromRows(rows []model.RockToken) []RockTokenDeleteStat
|
||||
model.RockStatusAvailable,
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
model.RockStatusRedeeming,
|
||||
model.RockStatusRedeemed,
|
||||
model.RockStatusMismatch,
|
||||
model.RockStatusAbnormal,
|
||||
|
||||
@@ -81,6 +81,7 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
|
||||
}
|
||||
}
|
||||
applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
|
||||
s.fillRockTokenAnomalyFlags(ctx, tokens)
|
||||
return tokens, total, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -597,7 +597,6 @@ export interface RockStats {
|
||||
available_stock: number;
|
||||
using_stock: number;
|
||||
bound_stock: number;
|
||||
redeeming_stock?: number;
|
||||
redeemed_stock: number;
|
||||
mismatch_stock: number;
|
||||
abnormal_stock: number;
|
||||
@@ -605,7 +604,7 @@ export interface RockStats {
|
||||
using_overdue_stock?: number;
|
||||
using_danger_stock?: number;
|
||||
bound_overdue_stock?: number;
|
||||
redeeming_timeout_stock?: number;
|
||||
anomaly_stock?: number;
|
||||
auto_refresh_mismatch_enabled?: boolean;
|
||||
upstream_points: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
@@ -674,7 +673,7 @@ export interface RockTokenItem {
|
||||
using_at?: string;
|
||||
bound_at?: string;
|
||||
redeemed_at?: string;
|
||||
// 上游异步兑奖(pending)跟踪字段
|
||||
// 上游异步兑奖(发货确认中)标记字段,不是状态
|
||||
redeem_requested_at?: string;
|
||||
redeem_checked_at?: string;
|
||||
redeem_attempts?: number;
|
||||
@@ -682,6 +681,12 @@ export interface RockTokenItem {
|
||||
redeem_last_message?: string;
|
||||
redeem_last_error?: string;
|
||||
redeem_timeout_warned_at?: string;
|
||||
// 异常标记(派生字段,来自历史流转,不改变主状态)
|
||||
has_anomaly?: boolean;
|
||||
anomaly_type?: string;
|
||||
anomaly_reason?: string;
|
||||
anomaly_at?: string;
|
||||
anomaly_count?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
|
||||
@@ -64,16 +64,6 @@ export function RockTokenFilterBar({
|
||||
<Radio.Button value="available">可用</Radio.Button>
|
||||
<Radio.Button value="using">使用中</Radio.Button>
|
||||
<Radio.Button value="bound">已绑定</Radio.Button>
|
||||
<Radio.Button value="redeeming">
|
||||
<Badge
|
||||
count={(stats?.redeeming_stock ?? 0) + (stats?.redeeming_timeout_stock ?? 0)}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: (stats?.redeeming_timeout_stock ?? 0) > 0 ? '#ef4444' : '#6366f1' }}
|
||||
>
|
||||
<span>发货处理中</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="redeemed">已发货</Radio.Button>
|
||||
<Radio.Button value="mismatch">
|
||||
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
|
||||
@@ -110,6 +100,17 @@ export function RockTokenFilterBar({
|
||||
<span>绑定超时</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
{/* 异常放在最后:派生筛选,只看历史流转,不改变主状态 */}
|
||||
<Radio.Button value="anomaly">
|
||||
<Badge
|
||||
count={stats?.anomaly_stock ?? 0}
|
||||
offset={[6, -2]}
|
||||
size="small"
|
||||
style={{ backgroundColor: '#b91c1c' }}
|
||||
>
|
||||
<span>异常</span>
|
||||
</Badge>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Space size={10} style={{ flex: '0 0 auto', flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -99,8 +99,6 @@ export function RockTokenTable({
|
||||
message.warning(res.message || '上游绑定状态需要人工核对')
|
||||
} else if (res.status === 'redeemed') {
|
||||
message.success(res.message || '上游已发货,本地状态已同步为已交付')
|
||||
} else if (res.status === 'redeeming') {
|
||||
message.info(res.message || '上游仍未确认发货,保持发货处理中')
|
||||
} else if (res.bound) {
|
||||
const role = res.role_name || res.role_id || '-'
|
||||
message.success(res.message || `刷新成功:上游已绑定「${role}」`)
|
||||
|
||||
@@ -28,7 +28,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
redeeming: '发货处理中',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
@@ -36,7 +35,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
bound_overdue: '绑定超时',
|
||||
}
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return STATUS_LABELS[status] || status || '-'
|
||||
@@ -50,8 +49,6 @@ function statusTagColor(status: string) {
|
||||
return 'processing'
|
||||
case 'bound':
|
||||
return 'cyan'
|
||||
case 'redeeming':
|
||||
return 'geekblue'
|
||||
case 'redeemed':
|
||||
return 'purple'
|
||||
case 'mismatch':
|
||||
@@ -225,7 +222,7 @@ export function TokenDeleteModal({
|
||||
<Text type="secondary"> · 其中可用库存 {available} 条</Text>
|
||||
)}
|
||||
{dangerous > 0 && (
|
||||
<Text type="danger"> · 使用中/已绑定/发货处理中/绑定错误 {dangerous} 条</Text>
|
||||
<Text type="danger"> · 使用中/已绑定/绑定错误 {dangerous} 条</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -250,7 +247,7 @@ export function TokenDeleteModal({
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={`包含 ${dangerous} 条进行中的 Token`}
|
||||
description="使用中/已绑定/发货处理中/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
|
||||
description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
redeeming: '发货处理中',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
@@ -44,7 +43,6 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'available', label: '可用库存' },
|
||||
{ value: 'using', label: '使用中' },
|
||||
{ value: 'bound', label: '已绑定' },
|
||||
{ value: 'redeeming', label: '发货处理中' },
|
||||
{ value: 'redeemed', label: '已发货' },
|
||||
{ value: 'mismatch', label: '绑定错误' },
|
||||
{ value: 'abnormal', label: '已上报失效' },
|
||||
@@ -52,7 +50,7 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'bound_overdue', label: '绑定超时' },
|
||||
]
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
const SKIP_REASONS: Record<string, string> = {
|
||||
not_deleted: '已不在回收站',
|
||||
@@ -72,8 +70,6 @@ function statusTagColor(status: string) {
|
||||
return 'processing'
|
||||
case 'bound':
|
||||
return 'cyan'
|
||||
case 'redeeming':
|
||||
return 'geekblue'
|
||||
case 'redeemed':
|
||||
return 'purple'
|
||||
case 'mismatch':
|
||||
|
||||
@@ -435,12 +435,11 @@ export function TokenStatusLogDrawer({
|
||||
当前状态:{rockTokenStatusLabel(token.status)}
|
||||
{token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''}
|
||||
</Text>
|
||||
{token.status === 'redeeming' && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{token.redeem_last_message || '上游官方处理中,系统会自动确认发货结果'}
|
||||
{token.redeem_requested_at
|
||||
? ` · 提交于 ${formatDateTime(token.redeem_requested_at)}`
|
||||
: ''}
|
||||
{token.has_anomaly && (
|
||||
<Text style={{ fontSize: 12, color: '#b91c1c' }}>
|
||||
⚠️ 历史异常:{token.anomaly_type || '异常'}
|
||||
{token.anomaly_count ? ` · 共 ${token.anomaly_count} 次` : ''}
|
||||
{token.anomaly_at ? ` · 最近 ${formatDateTime(token.anomaly_at)}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -40,6 +40,45 @@ function serverElapsedDays(serverDays: number | undefined, at?: string): number
|
||||
return elapsedDaysSince(at)
|
||||
}
|
||||
|
||||
// 异常标记:独立小标签,不改变主状态。hover 展示最近一次异常原因与时间。
|
||||
function AnomalyBadge({ record }: { record: RockTokenItem }) {
|
||||
const reason = (record.anomaly_reason || '').trim()
|
||||
const type = (record.anomaly_type || '').trim()
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ maxWidth: 320, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{type || '历史异常'}
|
||||
{record.anomaly_count ? ` · 共 ${record.anomaly_count} 次` : ''}
|
||||
</div>
|
||||
{reason || '历史流转中出现过异常'}
|
||||
{record.anomaly_at ? (
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>
|
||||
最近:{formatDateTime(record.anomaly_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
padding: '0 6px',
|
||||
color: '#b91c1c',
|
||||
background: '#fff1f0',
|
||||
border: '1px solid #ffa39e',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
⚠️ 异常
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
|
||||
// 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。
|
||||
export function TokenInfoCell({ record }: { record: RockTokenItem }) {
|
||||
@@ -153,114 +192,106 @@ export function buildTokenColumns({
|
||||
dataIndex: 'status',
|
||||
width: 165,
|
||||
render: (status, record) => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return (
|
||||
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 可用库存
|
||||
</Tag>
|
||||
)
|
||||
case 'using': {
|
||||
const days = serverElapsedDays(record.using_days, record.using_at)
|
||||
const usingOverdue = record.using_overdue ?? (days !== null && days >= usingWarnDays)
|
||||
const usingDanger = record.using_danger ?? (days !== null && days >= usingDangerDays)
|
||||
const displayDays = days === null ? 0 : floorDays(days)
|
||||
if (usingDanger) {
|
||||
const statusTag = (() => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return (
|
||||
<Tag color="success" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 可用库存
|
||||
</Tag>
|
||||
)
|
||||
case 'using': {
|
||||
const days = serverElapsedDays(record.using_days, record.using_at)
|
||||
const usingOverdue = record.using_overdue ?? (days !== null && days >= usingWarnDays)
|
||||
const usingDanger = record.using_danger ?? (days !== null && days >= usingDangerDays)
|
||||
const displayDays = days === null ? 0 : floorDays(days)
|
||||
if (usingDanger) {
|
||||
return (
|
||||
<Tag
|
||||
color="error"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
|
||||
>
|
||||
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (usingOverdue) {
|
||||
return (
|
||||
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中 · {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'bound': {
|
||||
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
|
||||
const boundOverdue =
|
||||
record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
|
||||
if (boundOverdue) {
|
||||
const displayDays = boundDays === null ? 0 : floorDays(boundDays)
|
||||
return (
|
||||
<Tag
|
||||
color="volcano"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
|
||||
>
|
||||
⏱ 绑定超时 {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已授权绑定
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'redeemed':
|
||||
return (
|
||||
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已交付发货
|
||||
</Tag>
|
||||
)
|
||||
case 'mismatch':
|
||||
return (
|
||||
<Tag
|
||||
color="error"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}
|
||||
>
|
||||
⚠️ 使用中超时 {displayDays.toFixed(1)} 天
|
||||
⚠️ 绑定错误 (阻断)
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (usingOverdue) {
|
||||
return (
|
||||
<Tag color="warning" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中 · {displayDays.toFixed(1)} 天
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="processing" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 扫码使用中
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
case 'bound': {
|
||||
const boundDays = serverElapsedDays(record.bound_days, record.bound_at)
|
||||
const boundOverdue = record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays)
|
||||
if (boundOverdue) {
|
||||
const displayDays = boundDays === null ? 0 : floorDays(boundDays)
|
||||
case 'abnormal':
|
||||
return (
|
||||
<Tag
|
||||
color="volcano"
|
||||
style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff7a45' }}
|
||||
color="default"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
color: '#b91c1c',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fca5a5',
|
||||
}}
|
||||
>
|
||||
⏱ 绑定超时 {displayDays.toFixed(1)} 天
|
||||
🚫 已上报失效
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color="cyan" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已授权绑定
|
||||
</Tag>
|
||||
)
|
||||
default:
|
||||
return <Tag>{status}</Tag>
|
||||
}
|
||||
case 'redeeming': {
|
||||
// pending 起点以 redeem_requested_at 为准,按分钟展示等待时长。
|
||||
const pendingDays = elapsedDaysSince(record.redeem_requested_at)
|
||||
const pendingMinutes = pendingDays === null ? null : Math.floor(pendingDays * 24 * 60)
|
||||
const timeoutWarned = Boolean(record.redeem_timeout_warned_at)
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
record.redeem_last_message ||
|
||||
'上游官方处理中,系统会自动轮询确认发货结果'
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color={timeoutWarned ? 'volcano' : 'geekblue'}
|
||||
style={{ fontWeight: 600, padding: '2px 8px' }}
|
||||
>
|
||||
{timeoutWarned
|
||||
? `⏱ 发货确认超时${pendingMinutes !== null ? ` ${pendingMinutes} 分钟` : ''}`
|
||||
: '⏳ 发货处理中'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
case 'redeemed':
|
||||
return (
|
||||
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
|
||||
● 已交付发货
|
||||
</Tag>
|
||||
)
|
||||
case 'mismatch':
|
||||
return (
|
||||
<Tag color="error" style={{ fontWeight: 600, padding: '2px 8px', border: '1px solid #ff4d4f' }}>
|
||||
⚠️ 绑定错误 (阻断)
|
||||
</Tag>
|
||||
)
|
||||
case 'abnormal':
|
||||
return (
|
||||
<Tag
|
||||
color="default"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
color: '#b91c1c',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fca5a5',
|
||||
}}
|
||||
>
|
||||
🚫 已上报失效
|
||||
</Tag>
|
||||
)
|
||||
default:
|
||||
return <Tag>{status}</Tag>
|
||||
}
|
||||
})()
|
||||
|
||||
// 异常是独立标记:不替换主状态,只在后面追加一个小标签。
|
||||
if (!record.has_anomaly) return statusTag
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{statusTag}
|
||||
<AnomalyBadge record={record} />
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -293,16 +324,11 @@ export function buildTokenColumns({
|
||||
{usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
|
||||
</div>
|
||||
)}
|
||||
{record.status === 'redeeming' && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: record.redeem_timeout_warned_at ? '#dc2626' : '#4338ca',
|
||||
}}
|
||||
>
|
||||
<span>⏳ 提交发货: </span>
|
||||
{formatDateTime(record.redeem_requested_at)}
|
||||
{record.redeem_attempts ? `(已查询 ${record.redeem_attempts} 次)` : ''}
|
||||
{record.anomaly_at && (
|
||||
<div style={{ fontSize: 11, color: '#b91c1c' }}>
|
||||
<span>⚠️ 异常: </span>
|
||||
{formatDateTime(record.anomaly_at)}
|
||||
{record.anomaly_type ? `(${record.anomaly_type})` : ''}
|
||||
</div>
|
||||
)}
|
||||
{record.mismatch_at && (
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '扫码使用中',
|
||||
bound: '已授权绑定',
|
||||
redeeming: '发货处理中',
|
||||
redeemed: '已交付发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
|
||||
Reference in New Issue
Block a user