diff --git a/backend/internal/database/migrations/024_rock_token_status_logs.sql b/backend/internal/database/migrations/024_rock_token_status_logs.sql new file mode 100644 index 0000000..fde58fb --- /dev/null +++ b/backend/internal/database/migrations/024_rock_token_status_logs.sql @@ -0,0 +1,19 @@ +-- 024: Rock token lifecycle/status logs +CREATE TABLE IF NOT EXISTS rock_token_status_logs ( + id BIGSERIAL PRIMARY KEY, + token_id BIGINT NOT NULL, + upstream_token_id VARCHAR(64) NOT NULL DEFAULT '', + event_type VARCHAR(32) NOT NULL, + from_status VARCHAR(32) NOT NULL DEFAULT '', + to_status VARCHAR(32) NOT NULL DEFAULT '', + source VARCHAR(32) NOT NULL DEFAULT '', + reason VARCHAR(255) NOT NULL DEFAULT '', + forced BOOLEAN NOT NULL DEFAULT FALSE, + actor_user_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_rock_token_status_logs_token_id + ON rock_token_status_logs (token_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_rock_token_status_logs_created_at + ON rock_token_status_logs (created_at DESC); diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index 415bb69..84be5e6 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -22,6 +22,14 @@ func NewRockHandler(rockSvc *service.RockService) *RockHandler { return &RockHandler{rockSvc: rockSvc} } +// actorPtr 把 0 值用户 ID 转成 nil,便于审计字段留空。 +func actorPtr(id uint) *uint { + if id == 0 { + return nil + } + return &id +} + // GetConfig 获取 Rock 平台配置 func (h *RockHandler) GetConfig(c *gin.Context) { cfg, err := h.rockSvc.GetConfig(c.Request.Context()) @@ -395,25 +403,75 @@ func (h *RockHandler) ResetToken(c *gin.Context) { response.BadRequest(c, "无效的 Token ID") return } - if err := h.rockSvc.ResetMismatchToken(c.Request.Context(), uint(idUint)); err != nil { + if err := h.rockSvc.ResetMismatchToken(c.Request.Context(), uint(idUint), actorPtr(middleware.GetUserID(c))); err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, gin.H{"message": "重置成功,Token 已重新释放至可用库存"}) } -// ReleaseToken 把尚未扫码绑定的使用中链接释放回可用库存 +// CheckReleaseToken 释放前校验上游状态,供人工确认弹窗展示。 +func (h *RockHandler) CheckReleaseToken(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.CheckReleaseToken(c.Request.Context(), uint(idUint)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + +type releaseTokenReq struct { + Force bool `json:"force"` + ForceConfirmText string `json:"force_confirm_text"` +} + +// ListTokenStatusLogs 查询 Token 的生命周期/状态流转历史。 +func (h *RockHandler) ListTokenStatusLogs(c *gin.Context) { + idUint, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "无效的 Token ID") + return + } + logs, err := h.rockSvc.ListTokenStatusLogs(c.Request.Context(), uint(idUint)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"list": logs}) +} + +// ReleaseToken 把使用中的提货链接释放回可用库存;上游异常时需人工强制确认。 func (h *RockHandler) ReleaseToken(c *gin.Context) { idUint, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "无效的 Token ID") return } - if err := h.rockSvc.ReleaseUnboundUsingToken(c.Request.Context(), uint(idUint)); err != nil { + var req releaseTokenReq + _ = c.ShouldBindJSON(&req) + if req.Force && strings.TrimSpace(req.ForceConfirmText) != "强制释放" { + response.BadRequest(c, "请输入「强制释放」确认本次操作") + return + } + actorID := middleware.GetUserID(c) + var actor *uint + if actorID != 0 { + actor = &actorID + } + if err := h.rockSvc.ReleaseUnboundUsingToken(c.Request.Context(), uint(idUint), req.Force, actor); err != nil { response.BadRequest(c, err.Error()) return } - response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"}) + message := "已释放回可用库存,该提货链接可以再次分配" + if req.Force { + message = "已强制释放回可用库存,请关注下一单使用情况" + } + response.OK(c, gin.H{"message": message}) } // RefreshStaleTokens 一键刷新异常 Token(绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条)。 @@ -446,7 +504,7 @@ func (h *RockHandler) RefreshBinding(c *gin.Context) { response.BadRequest(c, "无效的 Token ID") return } - result, err := h.rockSvc.RefreshTokenBinding(c.Request.Context(), uint(idUint)) + result, err := h.rockSvc.RefreshTokenBindingBy(c.Request.Context(), uint(idUint), actorPtr(middleware.GetUserID(c))) if err != nil { response.BadRequest(c, err.Error()) return @@ -486,7 +544,7 @@ func (h *RockHandler) UpdateRemark(c *gin.Context) { response.BadRequest(c, "参数解析失败") return } - token, err := h.rockSvc.UpdateTokenRemark(c.Request.Context(), uint(idUint), req.Remark, req.Status) + token, err := h.rockSvc.UpdateTokenRemark(c.Request.Context(), uint(idUint), req.Remark, req.Status, actorPtr(middleware.GetUserID(c))) if err != nil { response.BadRequest(c, err.Error()) return @@ -510,7 +568,7 @@ func (h *RockHandler) MarkAbnormal(c *gin.Context) { } var req markAbnormalReq _ = c.ShouldBindJSON(&req) - token, err := h.rockSvc.MarkTokenAbnormal(c.Request.Context(), uint(idUint), req.Remark) + token, err := h.rockSvc.MarkTokenAbnormal(c.Request.Context(), uint(idUint), req.Remark, actorPtr(middleware.GetUserID(c))) if err != nil { response.BadRequest(c, err.Error()) return diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index 0491854..8b60ad8 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -113,6 +113,25 @@ func (RockToken) TableName() string { return "rock_tokens" } +// RockTokenStatusLog 记录 Token 的生命周期与状态流转,供状态历史时间线使用。 +type RockTokenStatusLog struct { + ID uint `gorm:"primaryKey" json:"id"` + TokenID uint `gorm:"not null;index" json:"token_id"` + UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"` + EventType string `gorm:"size:32;not null" json:"event_type"` // created / status_changed / deleted / restored + FromStatus string `gorm:"size:32;not null;default:''" json:"from_status"` + ToStatus string `gorm:"size:32;not null;default:''" json:"to_status"` + Source string `gorm:"size:32;not null;default:''" json:"source"` + Reason string `gorm:"size:255;not null;default:''" json:"reason"` + Forced bool `gorm:"not null;default:false" json:"forced"` + ActorUserID *uint `gorm:"index" json:"actor_user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (RockTokenStatusLog) TableName() string { + return "rock_token_status_logs" +} + // RockPurchaseLog 是一次采购尝试的主记录。 // 每一次真正进入采购流程的尝试都会落一条,包含降级原因与各阶段汇总。 type RockPurchaseLog struct { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 5d377eb..c53b540 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -298,6 +298,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/release-check", h.Rock.CheckReleaseToken) + platform.GET("/platform/rock/tokens/:id/status-logs", h.Rock.ListTokenStatusLogs) platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding) platform.POST("/platform/rock/token-batches/refresh-stale", h.Rock.RefreshStaleTokens) platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink) diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index ea79db9..0a37c19 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -351,12 +351,25 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder Where("assigned_order_no = ? AND status = ?", order.OrderNo, model.RockStatusUsing). First(&token).Error if err == nil { - _ = p.rockService.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusAvailable, - "assigned_order_no": "", - "expected_uid": "", - "using_at": nil, - }).Error + fromStatus := token.Status + if updateErr := p.rockService.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "bound_at": nil, + "using_at": nil, + "redeemed_at": nil, + "upstream_fulfill_id": 0, + }).Error; updateErr == nil { + writeRockTokenStatusChange(p.rockService.db.WithContext(ctx), &token, fromStatus, model.RockStatusAvailable, + rockTokenSourceOrderCancel, order.OrderNo, false, nil) + } } return nil } diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index b8f8f31..8bf3000 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -263,7 +263,7 @@ func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) { t.Fatalf("一个订单只能占用一个提货号,实际 %d", assignedCount) } // 4. 客服确认买家输错,执行人工解冻重置 - err = rockSvc.ResetMismatchToken(context.Background(), token.ID) + err = rockSvc.ResetMismatchToken(context.Background(), token.ID, nil) if err != nil { t.Fatalf("reset mismatch token: %v", err) } @@ -598,7 +598,7 @@ func TestReleaseUnboundUsingToken(t *testing.T) { // 历史残留与快照一致,视为买家还没扫,允许释放。 boundRoleID = "111" bindChangedAt = "2026-10-01 10:00:00" - if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), token.ID); err != nil { + if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), token.ID, false, nil); err != nil { t.Fatalf("release ghost snapshot: %v", err) } var released model.RockToken @@ -648,7 +648,7 @@ func TestReleaseUnboundUsingToken(t *testing.T) { } boundRoleID = "3688825401" bindChangedAt = "2026-10-05 21:10:00" - if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { + if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID, false, nil); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { t.Fatalf("已绑定链接必须拒绝释放,实际: %v", err) } var stillUsing model.RockToken @@ -680,7 +680,7 @@ func TestReleaseUnboundUsingToken(t *testing.T) { boundRoleID = "1900505843" bindChangedAt = "" deliveryTime = "2026-09-27T14:38:51.23" - if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), stale.ID); err != nil { + if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), stale.ID, false, nil); err != nil { t.Fatalf("历史残留应允许释放: %v", err) } @@ -701,7 +701,7 @@ func TestReleaseUnboundUsingToken(t *testing.T) { boundRoleID = "4744920014" bindChangedAt = "2026-10-05 21:52:30" deliveryTime = "2026-10-05T21:52:40.61" - if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { + if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID, false, nil); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err) } } @@ -1024,7 +1024,7 @@ func TestRockService_TokenRemarkAndAbnormal(t *testing.T) { } // 1. 测试单纯更新备注 - updated, err := rockSvc.UpdateTokenRemark(context.Background(), token.ID, "买家联系客服核对中", "") + updated, err := rockSvc.UpdateTokenRemark(context.Background(), token.ID, "买家联系客服核对中", "", nil) if err != nil { t.Fatalf("update remark failed: %v", err) } @@ -1033,7 +1033,7 @@ func TestRockService_TokenRemarkAndAbnormal(t *testing.T) { } // 2. 测试快捷标记为已上报失效 (MarkTokenAbnormal) - abnormalToken, err := rockSvc.MarkTokenAbnormal(context.Background(), token.ID, "已上报0868客服 (卡密绑定错误废弃)") + abnormalToken, err := rockSvc.MarkTokenAbnormal(context.Background(), token.ID, "已上报0868客服 (卡密绑定错误废弃)", nil) if err != nil { t.Fatalf("mark abnormal failed: %v", err) } @@ -1054,7 +1054,7 @@ func TestRockService_TokenRemarkAndAbnormal(t *testing.T) { } // 4. 从 abnormal 状态解冻重置回 available - if err := rockSvc.ResetMismatchToken(context.Background(), token.ID); err != nil { + if err := rockSvc.ResetMismatchToken(context.Background(), token.ID, nil); err != nil { t.Fatalf("reset abnormal token error: %v", err) } var resetToken model.RockToken diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go index 1cb3a17..3a42f5a 100644 --- a/backend/internal/service/rock_assign.go +++ b/backend/internal/service/rock_assign.go @@ -73,14 +73,19 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected token.MismatchReason = "" token.MismatchAt = nil token.BoundAt = &now - _ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ + if updateErr := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ "expected_uid": trimmedUID, "status": model.RockStatusBound, "mismatch_reason": "", "mismatch_at": nil, "bound_at": now, "updated_at": now, - }).Error + }).Error; updateErr != nil { + log.Printf("[RockService] 买家确认更换 UID 失败: order=%s uid=%s err=%v", orderNo, trimmedUID, updateErr) + } else { + writeRockTokenStatusChange(s.db.WithContext(ctx), &token, model.RockStatusMismatch, model.RockStatusBound, + rockTokenSourceAssign, "订单 "+orderNo+" 确认扫码 UID 后解除绑定错误", false, nil) + } log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) } else { roleDesc := token.ActualRoleName @@ -118,11 +123,26 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected } now := time.Now() + // 新一轮分配前清掉上一轮可能残留的绑定信息,避免“未使用”的链接带上旧角色/旧时间。 + token.ActualRoleName = "" + token.ActualRoleID = "" + token.ActualChannel = "" + token.BoundAt = nil + token.RedeemedAt = nil + token.UpstreamFulfillID = 0 + token.MismatchAt = nil + token.MismatchReason = "" + token.InitialBoundSnapshot = "" token.Status = model.RockStatusUsing token.AssignedOrderNo = orderNo token.ExpectedUID = strings.TrimSpace(expectedUID) token.UsingAt = &now - return tx.Save(&token).Error + if err := tx.Save(&token).Error; err != nil { + return err + } + writeRockTokenStatusChange(tx, &token, model.RockStatusAvailable, model.RockStatusUsing, + rockTokenSourceAssign, orderNo, false, nil) + return nil }) if err != nil { return nil, nil, err diff --git a/backend/internal/service/rock_bindcheck.go b/backend/internal/service/rock_bindcheck.go index 1afdfd4..a451281 100644 --- a/backend/internal/service/rock_bindcheck.go +++ b/backend/internal/service/rock_bindcheck.go @@ -54,6 +54,8 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint "bound_at": now, "updated_at": now, }).Error + writeRockTokenStatusChange(s.db.WithContext(ctx), &token, model.RockStatusMismatch, model.RockStatusBound, + rockTokenSourceBindCheck, "买家确认正确 UID 后解除绑定错误", false, nil) return &CheckBindingResult{ Bound: true, RoleName: token.ActualRoleName, @@ -168,6 +170,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint now := time.Now() if trimmedExpected != "" && actualRoleID != trimmedExpected { // 严禁发货!跃迁为 mismatch + fromStatus := token.Status mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID) _ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusMismatch, @@ -177,6 +180,8 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint "mismatch_at": now, "mismatch_reason": mismatchReason, }).Error + writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusMismatch, + rockTokenSourceBindCheck, mismatchReason, false, nil) roleDesc := actualRoleName if roleDesc == "" { @@ -194,6 +199,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint } // 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称 + fromStatus := token.Status _ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusBound, "actual_role_name": actualRoleName, @@ -201,6 +207,8 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint "actual_channel": actualChannel, "bound_at": now, }).Error + writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusBound, + rockTokenSourceBindCheck, fmt.Sprintf("扫码绑定成功 UID %s", actualRoleID), false, nil) return &CheckBindingResult{ Bound: true, diff --git a/backend/internal/service/rock_binding_refresh.go b/backend/internal/service/rock_binding_refresh.go index 797cfcf..0982735 100644 --- a/backend/internal/service/rock_binding_refresh.go +++ b/backend/internal/service/rock_binding_refresh.go @@ -9,6 +9,7 @@ import ( "time" "affiliate_dash/internal/model" + "gorm.io/gorm" ) // TokenBindingRefreshResult 是人工“刷新绑定状态”的返回结果。 @@ -42,6 +43,15 @@ func canRefreshRockBinding(status string) bool { // - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记; // - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。 func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) { + return s.refreshTokenBinding(ctx, tokenID, nil) +} + +// RefreshTokenBindingBy 与 RefreshTokenBinding 相同,额外记录操作人。 +func (s *RockService) RefreshTokenBindingBy(ctx context.Context, tokenID uint, actorUserID *uint) (*TokenBindingRefreshResult, error) { + return s.refreshTokenBinding(ctx, tokenID, actorUserID) +} + +func (s *RockService) refreshTokenBinding(ctx context.Context, tokenID uint, actorUserID *uint) (*TokenBindingRefreshResult, error) { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err @@ -91,14 +101,14 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T } if upstreamNotFound { return s.markTokenAbnormalByRefresh(ctx, &token, - "上游卡密不存在(刷新自动同步)", "上游未找到该 Token,已标记为已上报失效") + "上游卡密不存在(刷新自动同步)", "上游未找到该 Token,已标记为已上报失效", actorUserID) } now := time.Now() upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status)) if upstreamStatus == model.RockStatusAbnormal { return s.markTokenAbnormalByRefresh(ctx, &token, - "上游状态异常(刷新自动同步)", "上游状态异常,已标记为已上报失效") + "上游状态异常(刷新自动同步)", "上游状态异常,已标记为已上报失效", actorUserID) } originalStatus := token.Status @@ -185,17 +195,22 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T (upstreamBound && actualRoleID != "" && actualRoleID != originalRoleID) || (upstreamBound && actualChannel != "" && actualChannel != originalChannel) - if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil { + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&token).Updates(updates).Error; err != nil { + return err + } + writeRockTokenStatusChange(tx, &token, originalStatus, result.Status, + rockTokenSourceRefresh, result.Message, false, actorUserID) + return writeAudit(tx, nil, actorUserID, 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, + }) + }); 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 } @@ -232,7 +247,7 @@ func isRockTokenNotFoundError(err error) bool { // 备注策略:原备注为空时直接写入;已有备注时用换行追加系统原因,保留人工内容, // 且相同原因不会重复追加。 // 已交付(status=redeemed 或 redeemed_at 非空)的 Token 不会被降级。 -func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *model.RockToken, remark, message string) (*TokenBindingRefreshResult, error) { +func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *model.RockToken, remark, message string, actorUserID *uint) (*TokenBindingRefreshResult, error) { if token == nil { return nil, errors.New("Token 不存在") } @@ -240,6 +255,7 @@ func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *mod return nil, errors.New("该 Token 已有发货记录,不能标记为已上报失效") } + fromStatus := token.Status updates := map[string]interface{}{ "status": model.RockStatusAbnormal, "updated_at": time.Now(), @@ -248,26 +264,33 @@ func (s *RockService) markTokenAbnormalByRefresh(ctx context.Context, token *mod switch { case existingRemark == "": updates["remark"] = remark + token.Remark = remark case !strings.Contains(existingRemark, remark): updates["remark"] = existingRemark + "\n" + remark + token.Remark = existingRemark + "\n" + remark } - if err := s.db.WithContext(ctx).Model(token).Updates(updates).Error; err != nil { + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(token).Updates(updates).Error; err != nil { + return err + } + writeRockTokenStatusChange(tx, token, fromStatus, model.RockStatusAbnormal, + rockTokenSourceRefresh, message, false, actorUserID) + return writeAudit(tx, nil, actorUserID, nil, "rock_token.refresh_binding", "rock_token", + fmt.Sprintf("%d", token.ID), map[string]interface{}{ + "status": model.RockStatusAbnormal, + "message": message, + }) + }); err != nil { return nil, err } s.refreshCache.Delete(token.UpstreamTokenID) - result := &TokenBindingRefreshResult{ + return &TokenBindingRefreshResult{ Status: model.RockStatusAbnormal, Changed: true, Warning: true, Message: message, - } - _ = 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, - "message": result.Message, - }) - return result, nil + }, nil } func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time { diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go index 9271ae6..9810a07 100644 --- a/backend/internal/service/rock_package.go +++ b/backend/internal/service/rock_package.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strconv" "strings" "time" @@ -79,6 +80,8 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka if err := tx.Create(&rec).Error; err != nil { return err } + writeRockTokenStatusLog(tx, &rec, rockTokenEventCreated, "", rec.Status, + rockTokenSourcePackageSync, fmt.Sprintf("导入上游包 %d", packageID), false, nil) inserted++ continue } @@ -117,6 +120,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka // 打包导入且没有本地订单的记录:允许随上游刷新展示字段; // 但手工释放为 available 的记录保持 available,避免全量导入把可售库存回退。 + oldStatus := existing.Status canRefreshUpstream := existing.Source == "package" && strings.TrimSpace(existing.AssignedOrderNo) == "" && existing.Status != model.RockStatusAvailable @@ -157,6 +161,10 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka if err := tx.Model(&existing).Updates(updates).Error; err != nil { return err } + if canRefreshUpstream && oldStatus != status { + writeRockTokenStatusChange(tx, &existing, oldStatus, status, + rockTokenSourcePackageSync, fmt.Sprintf("上游包 %d 同步", packageID), false, nil) + } updated++ } return nil diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go index abc9329..6de2098 100644 --- a/backend/internal/service/rock_recover.go +++ b/backend/internal/service/rock_recover.go @@ -99,7 +99,7 @@ func isDigits(value string) bool { } // ResetMismatchToken 客服人工重置解冻绑定错误或作废的 Token -func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error { +func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint, actorUserID *uint) error { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return err @@ -109,33 +109,51 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro return fmt.Errorf("仅 mismatch 或 abnormal 状态的 Token 允许人工解冻重置") } + oldStatus := token.Status s.refreshCache.Delete(token.UpstreamTokenID) - // 重置回 available,清空订单关联与异常标记 - return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusAvailable, - "assigned_order_no": "", - "expected_uid": "", - "actual_role_name": "", - "actual_role_id": "", - "actual_channel": "", - "initial_bound_snapshot": "", - "mismatch_at": nil, - "mismatch_reason": "", - "using_at": nil, - "updated_at": time.Now(), - }).Error + // 重置回 available,清空订单关联与异常标记。 + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "bound_at": nil, + "using_at": nil, + "redeemed_at": nil, + "upstream_fulfill_id": 0, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + writeRockTokenStatusChange(tx, &token, oldStatus, model.RockStatusAvailable, + rockTokenSourceReset, "人工解冻重置回库存", false, actorUserID) + return writeAudit(tx, nil, actorUserID, nil, "rock_token.reset", "rock_token", + fmt.Sprintf("%d", token.ID), map[string]interface{}{ + "from_status": oldStatus, + "to_status": model.RockStatusAvailable, + }) + }) } // UpdateTokenRemark 更新 Token 备注,并支持联动流转状态(例如上报后置为 abnormal 失效) -func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remark string, nextStatus string) (*model.RockToken, error) { +func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remark string, nextStatus string, actorUserID *uint) (*model.RockToken, error) { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err } + oldStatus := token.Status + trimmedRemark := strings.TrimSpace(remark) + token.Remark = trimmedRemark updates := map[string]interface{}{ - "remark": strings.TrimSpace(remark), + "remark": trimmedRemark, "updated_at": time.Now(), } @@ -156,24 +174,31 @@ func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remar } } - if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&token).Updates(updates).Error; err != nil { + return err + } + writeRockTokenStatusChange(tx, &token, oldStatus, token.Status, + rockTokenSourceManual, "人工更新备注: "+trimmedRemark, false, actorUserID) + _ = writeAudit(tx, nil, actorUserID, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ + "remark": token.Remark, + "from_status": oldStatus, + "to_status": token.Status, + }) + return nil + }) + if err != nil { return nil, err } - token.Remark = updates["remark"].(string) - - _ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ - "remark": token.Remark, - "status": token.Status, - }) return &token, nil } // MarkTokenAbnormal 快捷标记为上报失效(算作废,不可再用) -func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reason string) (*model.RockToken, error) { +func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reason string, actorUserID *uint) (*model.RockToken, error) { remark := strings.TrimSpace(reason) if remark == "" { remark = "已上报上游 (失效作废)" } - return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal) + return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal, actorUserID) } diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go index ea23878..fc0c8a6 100644 --- a/backend/internal/service/rock_redeem.go +++ b/backend/internal/service/rock_redeem.go @@ -11,6 +11,7 @@ import ( "affiliate_dash/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤) @@ -78,6 +79,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp } // 状态跃迁至 redeemed + fromStatus := token.Status _ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusRedeemed, "upstream_fulfill_id": fulfillID, @@ -86,61 +88,180 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp "target_currency_quantity": 1, "redeemed_at": now, }).Error + writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeemed, + rockTokenSourceRedeem, "自动发货: "+targetSkin, false, nil) return redeemResp, nil } -// ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。 -// 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。 -func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint) error { +// RockReleaseCheckResult 是释放回库存前的上游校验结果。 +type RockReleaseCheckResult struct { + CanRelease bool `json:"can_release"` + UpstreamStatus string `json:"upstream_status"` // unbound / bound / redeemed / abnormal / not_found / check_failed + RoleName string `json:"role_name,omitempty"` + RoleID string `json:"role_id,omitempty"` + Message string `json:"message"` +} + +// RockReleaseRequiresForceError 表示上游状态不允许直接释放,需要人工强制确认。 +type RockReleaseRequiresForceError struct { + Check RockReleaseCheckResult +} + +func (e *RockReleaseRequiresForceError) Error() string { + return e.Check.Message +} + +// CheckReleaseToken 在执行释放前校验上游状态,供前端展示人工确认弹窗。 +func (s *RockService) CheckReleaseToken(ctx context.Context, tokenID uint) (*RockReleaseCheckResult, error) { + _, check, err := s.checkReleaseToken(ctx, tokenID) + if err != nil { + return nil, err + } + return check, nil +} + +// checkReleaseToken 加载 Token 并调用上游确认是否允许回库。 +func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*model.RockToken, *RockReleaseCheckResult, error) { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { - return err + return nil, nil, err } if token.Status != model.RockStatusUsing { - return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存") - } - if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" { - return fmt.Errorf("该链接已扫码绑定,不能释放回库存") + return nil, nil, fmt.Errorf("仅扫码使用中的链接可以释放回库存") } cfg, err := s.GetConfig(ctx) if err != nil { - return err + return nil, nil, err } if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" { - return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定") + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "check_failed", + Message: "缺少上游凭证,无法确认是否可以释放", + }, nil } ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) if err != nil { - return fmt.Errorf("确认上游绑定状态失败: %w", err) + if isRockTokenNotFoundError(err) { + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "not_found", + Message: "上游卡密不存在(可能已失效)", + }, nil + } + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "check_failed", + Message: "确认上游状态失败: " + err.Error(), + }, nil } - if rockBuyerNewlyBound(&token, ref) { - return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID()) + if ref == nil { + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "check_failed", + Message: "上游未返回状态,无法确认是否可以释放", + }, nil } - s.refreshCache.Delete(token.UpstreamTokenID) - orderNo := token.AssignedOrderNo - if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ - "status": model.RockStatusAvailable, - "assigned_order_no": "", - "expected_uid": "", - "actual_role_name": "", - "actual_role_id": "", - "actual_channel": "", - "initial_bound_snapshot": "", - "mismatch_at": nil, - "mismatch_reason": "", - "bound_at": nil, - "using_at": nil, - "updated_at": time.Now(), - }).Error; err != nil { + upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status)) + roleName := ref.GetRoleName() + roleID := ref.GetRoleID() + switch { + case upstreamStatus == model.RockStatusAbnormal: + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "abnormal", + RoleName: roleName, + RoleID: roleID, + Message: "上游状态异常(已上报失效)", + }, nil + case upstreamStatus == model.RockStatusRedeemed || strings.TrimSpace(ref.Token.RedeemedAt) != "": + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "redeemed", + RoleName: roleName, + RoleID: roleID, + Message: "上游已发货", + }, nil + case rockBuyerNewlyBound(&token, ref): + return &token, &RockReleaseCheckResult{ + UpstreamStatus: "bound", + RoleName: roleName, + RoleID: roleID, + Message: fmt.Sprintf("上游已扫码绑定角色「%s」(%s)", displayRockRole(roleName, roleID), roleID), + }, nil + default: + return &token, &RockReleaseCheckResult{ + CanRelease: true, + UpstreamStatus: "unbound", + Message: "上游未绑定,可以释放回库存", + }, nil + } +} + +// ReleaseUnboundUsingToken 把使用中的提货链接释放回可用库存。 +// +// 非强制释放前必须通过上游校验;force=true 表示人工已确认上游状态并承担风险。 +func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint, force bool, actorUserID *uint) error { + _, check, err := s.checkReleaseToken(ctx, tokenID) + if err != nil { + return err + } + if !check.CanRelease && !force { + return &RockReleaseRequiresForceError{Check: *check} + } + + reason := "人工释放(上游未绑定)" + if force { + reason = fmt.Sprintf("人工强制释放(上游状态: %s)", check.UpstreamStatus) + } + + var released model.RockToken + var orderNo string + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var token model.RockToken + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil { + return err + } + if token.Status != model.RockStatusUsing { + return fmt.Errorf("仅扫码使用中的链接可以释放回库存") + } + orderNo = token.AssignedOrderNo + if err := tx.Model(&token).Updates(map[string]interface{}{ + "status": model.RockStatusAvailable, + "assigned_order_no": "", + "expected_uid": "", + "actual_role_name": "", + "actual_role_id": "", + "actual_channel": "", + "initial_bound_snapshot": "", + "mismatch_at": nil, + "mismatch_reason": "", + "bound_at": nil, + "using_at": nil, + "redeemed_at": nil, + "upstream_fulfill_id": 0, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + released = token + writeRockTokenStatusChange(tx, &token, model.RockStatusUsing, model.RockStatusAvailable, + rockTokenSourceRelease, reason, force, actorUserID) + return writeAudit(tx, nil, actorUserID, nil, "rock_token.release", "rock_token", + fmt.Sprintf("%d", token.ID), map[string]interface{}{ + "order_no": orderNo, + "forced": force, + "upstream_status": check.UpstreamStatus, + "upstream_role_id": check.RoleID, + "reason": reason, + }) + }) + if err != nil { return err } - s.detachReleasedRockToken(ctx, orderNo, token.ID) - log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo) + s.refreshCache.Delete(released.UpstreamTokenID) + s.detachReleasedRockToken(ctx, orderNo, released.ID) + log.Printf("[RockService] 人工释放库存: token_id=%d order=%s forced=%v upstream=%s", + released.ID, orderNo, force, check.UpstreamStatus) return nil } diff --git a/backend/internal/service/rock_release_test.go b/backend/internal/service/rock_release_test.go new file mode 100644 index 0000000..32a1646 --- /dev/null +++ b/backend/internal/service/rock_release_test.go @@ -0,0 +1,268 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" +) + +// TestCheckReleaseToken_UpstreamStatuses 覆盖释放前上游校验的 6 种结果。 +func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) { + db := newRockTestDB(t) + + mode := "unbound" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch mode { + case "bound": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": map[string]interface{}{ + "status": "bound", + "bound_account": "买家新角色", + "bound_account_id": "5550001", + "bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), + }, + }) + case "redeemed": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": map[string]interface{}{ + "status": "redeemed", + "redeemed_at": "2026-11-07 22:44:00", + }, + }) + case "abnormal": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": map[string]interface{}{"status": "abnormal"}, + }) + case "not_found": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"token 不存在"}`)) + case "check_failed": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": map[string]interface{}{"status": "using"}, + }) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + claimedAt := time.Now().Add(-30 * time.Minute) + cases := []struct { + name string + mode string + wantStatus string + wantCan bool + }{ + {name: "unbound", mode: "unbound", wantStatus: "unbound", wantCan: true}, + {name: "bound", mode: "bound", wantStatus: "bound", wantCan: false}, + {name: "redeemed", mode: "redeemed", wantStatus: "redeemed", wantCan: false}, + {name: "abnormal", mode: "abnormal", wantStatus: "abnormal", wantCan: false}, + {name: "not_found", mode: "not_found", wantStatus: "not_found", wantCan: false}, + {name: "check_failed", mode: "check_failed", wantStatus: "check_failed", wantCan: false}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mode = tc.mode + token := model.RockToken{ + UpstreamTokenID: fmt.Sprintf("release-check-%d", i), + Token: "jwt", + RedeemCode: fmt.Sprintf("CODE-CHECK-%d", i), + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: fmt.Sprintf("ORDER-CHECK-%d", i), + ExpectedUID: "5550001", + ClaimedAt: &claimedAt, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + + res, err := svc.CheckReleaseToken(context.Background(), token.ID) + if err != nil { + t.Fatalf("校验释放失败: %v", err) + } + if res.UpstreamStatus != tc.wantStatus || res.CanRelease != tc.wantCan { + t.Fatalf("校验结果不符: status=%s can_release=%v message=%s", res.UpstreamStatus, res.CanRelease, res.Message) + } + if res.Message == "" { + t.Fatalf("校验结果必须带提示文案") + } + }) + } +} + +// TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus 验证非强制释放被上游拦截时状态不变。 +func TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus(t *testing.T) { + db := newRockTestDB(t) + server := newBoundReleaseServer(t) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + claimedAt := time.Now().Add(-30 * time.Minute) + token := model.RockToken{ + UpstreamTokenID: "release-force-1", + Token: "jwt", + RedeemCode: "CODE-FORCE-1", + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: "ORDER-FORCE-1", + ExpectedUID: "5550001", + ClaimedAt: &claimedAt, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + + err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, false, nil) + var forceErr *RockReleaseRequiresForceError + if !errors.As(err, &forceErr) { + t.Fatalf("非强制释放应返回 RockReleaseRequiresForceError,实际: %v", err) + } + if forceErr.Check.UpstreamStatus != "bound" || forceErr.Check.CanRelease { + t.Fatalf("校验结果不符: %+v", forceErr.Check) + } + + var stored model.RockToken + if err := db.First(&stored, token.ID).Error; err != nil { + t.Fatalf("重新读取 Token 失败: %v", err) + } + if stored.Status != model.RockStatusUsing || stored.AssignedOrderNo != "ORDER-FORCE-1" { + t.Fatalf("拒绝释放后状态不应变化: status=%s order=%s", stored.Status, stored.AssignedOrderNo) + } + var logCount int64 + db.Model(&model.RockTokenStatusLog{}).Where("token_id = ?", token.ID).Count(&logCount) + if logCount != 0 { + t.Fatalf("拒绝释放不应写状态日志,实际 %d 条", logCount) + } +} + +// TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs 验证人工强制释放清空残留字段并留下追溯记录。 +func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) { + db := newRockTestDB(t) + server := newBoundReleaseServer(t) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL)) + setRockTestBearer(t, svc, "bearer-test") + + now := time.Now() + claimedAt := now.Add(-30 * time.Minute) + token := model.RockToken{ + UpstreamTokenID: "release-force-2", + Token: "jwt", + RedeemCode: "CODE-FORCE-2", + Color: model.RockColorBlack, + Status: model.RockStatusUsing, + AssignedOrderNo: "ORDER-FORCE-2", + ExpectedUID: "5550001", + ActualRoleName: "残留角色", + ActualRoleID: "5550001", + ActualChannel: "安卓-微信", + InitialBoundSnapshot: `{"bound":true,"role_id":"5550001"}`, + UpstreamFulfillID: 98765, + MismatchAt: &now, + MismatchReason: "历史不一致", + ClaimedAt: &claimedAt, + UsingAt: &now, + BoundAt: &now, + RedeemedAt: &now, + } + if err := db.Create(&token).Error; err != nil { + t.Fatalf("创建 Token 失败: %v", err) + } + if err := db.Create(&model.FulfillmentOrder{ + OrderNo: "ORDER-FORCE-2", + ProductSKU: model.RockSKUBlackTshirt, + ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d}`, token.ID), + }).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + + actor := uint(42) + if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, &actor); err != nil { + t.Fatalf("强制释放失败: %v", err) + } + + var released model.RockToken + if err := db.First(&released, token.ID).Error; err != nil { + t.Fatalf("读取释放后的 Token 失败: %v", err) + } + if released.Status != model.RockStatusAvailable { + t.Fatalf("释放后应为 available,实际 %s", released.Status) + } + if released.AssignedOrderNo != "" || released.ExpectedUID != "" || + released.ActualRoleName != "" || released.ActualRoleID != "" || released.ActualChannel != "" || + released.InitialBoundSnapshot != "" || released.MismatchReason != "" { + t.Fatalf("释放后绑定字段未清空: %+v", released) + } + if released.MismatchAt != nil || released.UsingAt != nil || released.BoundAt != nil || released.RedeemedAt != nil { + t.Fatalf("释放后时间字段未清空: using=%v bound=%v redeemed=%v mismatch=%v", + released.UsingAt, released.BoundAt, released.RedeemedAt, released.MismatchAt) + } + if released.UpstreamFulfillID != 0 { + t.Fatalf("释放后 upstream_fulfill_id 应清空,实际 %d", released.UpstreamFulfillID) + } + + var logs []model.RockTokenStatusLog + if err := db.Where("token_id = ?", token.ID).Order("id DESC").Find(&logs).Error; err != nil { + t.Fatalf("读取状态日志失败: %v", err) + } + if len(logs) != 1 { + t.Fatalf("强制释放应写 1 条状态日志,实际 %d 条", len(logs)) + } + entry := logs[0] + if entry.EventType != rockTokenEventStatusChanged || entry.FromStatus != model.RockStatusUsing || + entry.ToStatus != model.RockStatusAvailable || entry.Source != rockTokenSourceRelease || !entry.Forced { + t.Fatalf("状态日志字段不符: %+v", entry) + } + if !strings.Contains(entry.Reason, "强制释放") || !strings.Contains(entry.Reason, "bound") { + t.Fatalf("状态日志原因应包含强制标记与上游状态: %s", entry.Reason) + } + if entry.ActorUserID == nil || *entry.ActorUserID != actor { + t.Fatalf("状态日志操作人不符: %+v", entry.ActorUserID) + } + + var audit model.AuditLog + if err := db.Where("action = ? AND entity_id = ?", "rock_token.release", fmt.Sprintf("%d", token.ID)). + First(&audit).Error; err != nil { + t.Fatalf("未找到释放审计日志: %v", err) + } + if audit.ActorUserID == nil || *audit.ActorUserID != actor { + t.Fatalf("审计日志操作人不符: %+v", audit.ActorUserID) + } + if !strings.Contains(audit.Metadata, `"forced":true`) || !strings.Contains(audit.Metadata, `"upstream_status":"bound"`) { + t.Fatalf("审计日志元数据不符: %s", audit.Metadata) + } +} + +// newBoundReleaseServer 返回一个「上游已扫码绑定」的 mock,用于强制释放场景。 +func newBoundReleaseServer(t *testing.T) *httptest.Server { + t.Helper() + return 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": "5550001", + "bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"), + }, + }) + })) +} diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index e3a71ec..13cc87f 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -268,7 +268,14 @@ func (s *RockService) insertClaimedTokens(ctx context.Context, claimed []RockCla return nil, errors.New("没有可入库的 Token") } if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return tx.Create(&tokens).Error + if err := tx.Create(&tokens).Error; err != nil { + return err + } + for i := range tokens { + writeRockTokenStatusLog(tx, &tokens[i], rockTokenEventCreated, "", tokens[i].Status, + rockTokenSourcePurchase, "采购入库", false, nil) + } + return nil }); err != nil { return nil, err } @@ -306,7 +313,14 @@ func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockCla return nil } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return tx.Create(&missing).Error + if err := tx.Create(&missing).Error; err != nil { + return err + } + for i := range missing { + writeRockTokenStatusLog(tx, &missing[i], rockTokenEventCreated, "", missing[i].Status, + rockTokenSourcePurchase, "采购补录", false, nil) + } + return nil }) } diff --git a/backend/internal/service/rock_token_delete.go b/backend/internal/service/rock_token_delete.go index 39c708b..2601bf9 100644 --- a/backend/internal/service/rock_token_delete.go +++ b/backend/internal/service/rock_token_delete.go @@ -275,6 +275,14 @@ func (s *RockService) DeleteRockTokens(ctx context.Context, ids []uint, force bo return deleteResult.Error } result.Deleted = int(deleteResult.RowsAffected) + // 软删除成功后补记生命周期日志,便于回收站/历史追溯。 + for _, row := range rows { + reason := "批量删除" + if force { + reason = "批量删除(强制)" + } + writeRockTokenStatusLog(tx, &row, rockTokenEventDeleted, row.Status, "", rockTokenSourceDelete, reason, force, &actorUserID) + } metadata := map[string]interface{}{ "requested": result.Requested, "deleted": result.Deleted, @@ -340,6 +348,13 @@ func (s *RockService) DeleteAllRockTokens(ctx context.Context, status, search st return deleteResult.Error } result.Deleted = int(deleteResult.RowsAffected) + for _, row := range rows { + reason := "按筛选条件批量删除" + if force { + reason = "按筛选条件批量删除(强制)" + } + writeRockTokenStatusLog(tx, &row, rockTokenEventDeleted, row.Status, "", rockTokenSourceDelete, reason, force, &actorUserID) + } metadata := map[string]interface{}{ "requested": result.Requested, "deleted": result.Deleted, diff --git a/backend/internal/service/rock_token_delete_test.go b/backend/internal/service/rock_token_delete_test.go index 9d14569..dee117c 100644 --- a/backend/internal/service/rock_token_delete_test.go +++ b/backend/internal/service/rock_token_delete_test.go @@ -78,6 +78,14 @@ func TestDeleteRockTokens_SoftDeletesAndHidesFromList(t *testing.T) { if err := db.First(&active, available.ID).Error; err == nil { t.Fatal("普通查询不应返回已软删除 Token") } + + var logs []model.RockTokenStatusLog + if err := db.Where("token_id = ?", available.ID).Find(&logs).Error; err != nil { + t.Fatalf("读取删除状态日志失败: %v", err) + } + if len(logs) != 1 || logs[0].EventType != rockTokenEventDeleted || logs[0].Source != rockTokenSourceDelete || logs[0].Forced { + t.Fatalf("删除状态日志不符: %+v", logs) + } _ = redeemed } diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go index 981aecf..0e063ae 100644 --- a/backend/internal/service/rock_token_restore.go +++ b/backend/internal/service/rock_token_restore.go @@ -207,6 +207,9 @@ func (s *RockService) RestoreRockTokens(ctx context.Context, ids []uint, actorUs } result.Restored = len(restorable) result.StatusCounts = rockTokenStatusCountsFromRows(restorable) + for _, row := range restorable { + writeRockTokenStatusLog(tx, &row, rockTokenEventRestored, "", row.Status, rockTokenSourceRestore, "从回收站恢复", false, &actorUserID) + } metadata := map[string]interface{}{ "requested": result.Requested, "restored": result.Restored, diff --git a/backend/internal/service/rock_token_restore_test.go b/backend/internal/service/rock_token_restore_test.go index 14eb845..27eba2c 100644 --- a/backend/internal/service/rock_token_restore_test.go +++ b/backend/internal/service/rock_token_restore_test.go @@ -59,6 +59,14 @@ func TestRestoreRockTokens_UnhidesDeletedToken(t *testing.T) { t.Fatalf("恢复结果不符: %+v", result) } + var restoreLogs []model.RockTokenStatusLog + if err := db.Where("token_id = ? AND event_type = ?", available.ID, rockTokenEventRestored).Find(&restoreLogs).Error; err != nil { + t.Fatalf("读取恢复状态日志失败: %v", err) + } + if len(restoreLogs) != 1 || restoreLogs[0].EventType != rockTokenEventRestored || restoreLogs[0].Source != rockTokenSourceRestore { + t.Fatalf("恢复状态日志不符: %+v", restoreLogs) + } + _, total, err = svc.ListTokens(ctx, 1, 20, "", "") if err != nil { t.Fatalf("查询列表失败: %v", err) diff --git a/backend/internal/service/rock_token_status_log.go b/backend/internal/service/rock_token_status_log.go new file mode 100644 index 0000000..9eb95a2 --- /dev/null +++ b/backend/internal/service/rock_token_status_log.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "log" + "strings" + + "affiliate_dash/internal/model" + "gorm.io/gorm" +) + +// Token 生命周期事件类型。 +const ( + rockTokenEventCreated = "created" + rockTokenEventStatusChanged = "status_changed" + rockTokenEventDeleted = "deleted" + rockTokenEventRestored = "restored" +) + +// Token 状态来源,用于前端展示流转原因。 +const ( + rockTokenSourceAssign = "assign" + rockTokenSourceBindCheck = "bindcheck" + rockTokenSourceRedeem = "redeem" + rockTokenSourceRefresh = "refresh" + rockTokenSourceRelease = "release" + rockTokenSourceReset = "reset" + rockTokenSourceManual = "manual" + rockTokenSourceOrderCancel = "order_cancel" + rockTokenSourcePurchase = "purchase" + rockTokenSourcePackageSync = "package_sync" + rockTokenSourceDelete = "delete" + rockTokenSourceRestore = "restore" +) + +// writeRockTokenStatusLog 记录 Token 生命周期/状态变化。 +// +// 采用 best-effort:日志写入失败只打印错误,不影响主流程(避免因为审计表异常阻断发货)。 +func writeRockTokenStatusLog(tx *gorm.DB, token *model.RockToken, eventType, from, to, source, reason string, forced bool, actorUserID *uint) { + if tx == nil || token == nil || token.ID == 0 { + return + } + entry := &model.RockTokenStatusLog{ + TokenID: token.ID, + UpstreamTokenID: strings.TrimSpace(token.UpstreamTokenID), + EventType: eventType, + FromStatus: from, + ToStatus: to, + Source: source, + Reason: truncateRunes(strings.TrimSpace(reason), 240), + Forced: forced, + ActorUserID: actorUserID, + } + if err := tx.Create(entry).Error; err != nil { + log.Printf("[Rock] 写入 Token 状态日志失败 token=%d %s->%s: %v", token.ID, from, to, err) + } +} + +// writeRockTokenStatusChange 仅在状态确实发生变化时写日志。 +func writeRockTokenStatusChange(tx *gorm.DB, token *model.RockToken, from, to, source, reason string, forced bool, actorUserID *uint) { + if strings.TrimSpace(from) == strings.TrimSpace(to) { + return + } + writeRockTokenStatusLog(tx, token, rockTokenEventStatusChanged, from, to, source, reason, forced, actorUserID) +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +// ListTokenStatusLogs 返回指定 Token 的状态历史(最新在前)。 +func (s *RockService) ListTokenStatusLogs(ctx context.Context, tokenID uint) ([]model.RockTokenStatusLog, error) { + var token model.RockToken + if err := s.db.WithContext(ctx).Unscoped().First(&token, tokenID).Error; err != nil { + return nil, err + } + var logs []model.RockTokenStatusLog + if err := s.db.WithContext(ctx). + Where("token_id = ?", tokenID). + Order("id DESC"). + Limit(200). + Find(&logs).Error; err != nil { + return nil, err + } + return logs, nil +} diff --git a/backend/internal/service/rock_using_at_test.go b/backend/internal/service/rock_using_at_test.go index 1582fdf..7dd0ca0 100644 --- a/backend/internal/service/rock_using_at_test.go +++ b/backend/internal/service/rock_using_at_test.go @@ -65,7 +65,16 @@ func TestAssignTokenForOrder_SetsUsingAtAndReleaseClears(t *testing.T) { t.Fatalf("数据库中 using_at 未写入") } - if err := svc.ReleaseUnboundUsingToken(context.Background(), assigned.ID); err != nil { + var assignLogs []model.RockTokenStatusLog + if err := db.Where("token_id = ?", assigned.ID).Find(&assignLogs).Error; err != nil { + t.Fatalf("读取分配状态日志失败: %v", err) + } + if len(assignLogs) != 1 || assignLogs[0].Source != rockTokenSourceAssign || + assignLogs[0].FromStatus != model.RockStatusAvailable || assignLogs[0].ToStatus != model.RockStatusUsing { + t.Fatalf("分配状态日志不符: %+v", assignLogs) + } + + if err := svc.ReleaseUnboundUsingToken(context.Background(), assigned.ID, false, nil); err != nil { t.Fatalf("释放未绑定 Token 失败: %v", err) } var released model.RockToken diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 32cd879..2b0c9e9 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -785,6 +785,28 @@ export interface RockTokenRestoreResult { skipped_items: RockTokenRestoreSkipItem[]; } +export interface RockReleaseCheckResult { + can_release: boolean + upstream_status: string + role_name?: string + role_id?: string + message: string +} + +export interface RockTokenStatusLog { + id: number + token_id: number + upstream_token_id: string + event_type: string + from_status: string + to_status: string + source: string + reason: string + forced: boolean + actor_user_id?: number | null + created_at: string +} + export interface RockStaleRefreshResult { scanned: number; fixed: number; @@ -921,10 +943,21 @@ export const rockApi = { .then((r) => r.data.data as { message?: string; status?: string }), resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data), - releaseToken: (id: number) => + checkReleaseToken: (id: number) => request - .post(`/platform/rock/tokens/${id}/release`) - .then((r) => r.data.data), + .post(`/platform/rock/tokens/${id}/release-check`) + .then((r) => r.data.data as RockReleaseCheckResult), + releaseToken: ( + id: number, + data?: { force?: boolean; force_confirm_text?: string }, + ) => + request + .post(`/platform/rock/tokens/${id}/release`, data ?? {}) + .then((r) => r.data.data as { message: string }), + listTokenStatusLogs: (id: number) => + request + .get(`/platform/rock/tokens/${id}/status-logs`) + .then((r) => r.data.data as { list: RockTokenStatusLog[] }), refreshTokenBinding: (id: number) => request .post(`/platform/rock/tokens/${id}/refresh-binding`) diff --git a/frontend/src/pages/platformRock/ReleaseTokenModal.tsx b/frontend/src/pages/platformRock/ReleaseTokenModal.tsx new file mode 100644 index 0000000..f8484e0 --- /dev/null +++ b/frontend/src/pages/platformRock/ReleaseTokenModal.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from 'react' +import { Alert, Descriptions, Grid, Input, Modal, Space, Tag, Typography, message } from 'antd' +import { ExclamationCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import { + rockApi, + type RockReleaseCheckResult, + type RockTokenItem, +} from '../../api' +import { + rockTokenStatusLabel, + rockUpstreamStatusLabel, +} from './types' + +const { Text } = Typography + +export interface ReleaseTokenModalProps { + open: boolean + token: RockTokenItem | null + onCancel: () => void + onReleased: () => void +} + +const FORCE_CONFIRM_TEXT = '强制释放' + +export function ReleaseTokenModal({ + open, + token, + onCancel, + onReleased, +}: ReleaseTokenModalProps) { + const screens = Grid.useBreakpoint() + const isMobile = !screens.md + const [check, setCheck] = useState(null) + const [loadingCheck, setLoadingCheck] = useState(false) + const [checkError, setCheckError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState('') + const [forceText, setForceText] = useState('') + + useEffect(() => { + if (!open || !token) return + let cancelled = false + setCheck(null) + setCheckError('') + setSubmitError('') + setForceText('') + setLoadingCheck(true) + rockApi + .checkReleaseToken(token.id) + .then((res) => { + if (!cancelled) setCheck(res) + }) + .catch((e) => { + if (!cancelled) { + setCheckError(e instanceof Error ? e.message : '释放前校验失败') + } + }) + .finally(() => { + if (!cancelled) setLoadingCheck(false) + }) + return () => { + cancelled = true + } + }, [open, token]) + + if (!token) return null + + const needForce = Boolean(check && !check.can_release) + const forceConfirmed = forceText.trim() === FORCE_CONFIRM_TEXT + const canSubmit = + Boolean(check) && !loadingCheck && !submitting && (!needForce || forceConfirmed) + + const handleSubmit = async () => { + if (!canSubmit) return + setSubmitting(true) + setSubmitError('') + try { + const res = await rockApi.releaseToken( + token.id, + needForce + ? { force: true, force_confirm_text: forceText.trim() } + : { force: false }, + ) + message.success(res.message || '已释放回可用库存,该提货链接可以再次分配') + onReleased() + onCancel() + } catch (e) { + setSubmitError(e instanceof Error ? e.message : '释放失败') + } finally { + setSubmitting(false) + } + } + + const roleText = + check && (check.role_name || check.role_id) + ? `${check.role_name || '未知角色'}(${check.role_id || '-'})` + : '' + + return ( + + + + #{token.id} / {token.upstream_token_id || '-'} /{' '} + {token.redeem_code || '-'} + + ), + }, + { + key: 'order', + label: '关联工单', + children: token.assigned_order_no ? ( + {token.assigned_order_no} + ) : ( + 未关联订单 + ), + }, + { + key: 'status', + label: '本地状态', + children: {rockTokenStatusLabel(token.status)}, + }, + ]} + /> + + {loadingCheck && ( + + )} + + {checkError && ( + } + message="释放前校验失败" + description={checkError} + /> + )} + + {check?.can_release && ( + } + message={`上游确认:${rockUpstreamStatusLabel(check.upstream_status)},可以安全释放`} + description={check.message} + /> + )} + + {needForce && check && ( + <> + } + message={`红色警告:${rockUpstreamStatusLabel(check.upstream_status)},不允许直接释放`} + description={ + + {check.message} + {roleText && ( + + 上游绑定角色:{roleText} + + )} + + 继续强制释放会把这条链接放回可用库存,下一单可能分配到同一链接, + 导致买家无法正常提货或与已发货订单冲突。请务必确认该订单已退款、 + 买家不会再使用,并已核实上游卡密可以重新分配。 + + + } + /> +
+ + 确认承担上述风险后,请输入 {FORCE_CONFIRM_TEXT} 解锁强制释放: + + setForceText(e.target.value)} + placeholder={FORCE_CONFIRM_TEXT} + style={{ marginTop: 6 }} + status="error" + autoComplete="off" + /> +
+ + )} + + {!needForce && !checkError && check && ( + + )} + + {submitError && } +
+
+ ) +} diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index 6cf8773..2e7fed6 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -7,6 +7,8 @@ import { rockApi } from '../../api' import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../../utils/voucher' import { formatDateTime } from '../../utils/time' import { buildTokenColumns } from './tokenColumns' +import { ReleaseTokenModal } from './ReleaseTokenModal' +import { TokenStatusLogDrawer } from './TokenStatusLogDrawer' import type { UidEditState } from './types' const { Text } = Typography @@ -66,6 +68,8 @@ export function RockTokenTable({ }: RockTokenTableProps) { const [refreshingTokenId, setRefreshingTokenId] = useState(null) const [openingUpstreamId, setOpeningUpstreamId] = useState(null) + const [releaseTarget, setReleaseTarget] = useState(null) + const [statusLogTarget, setStatusLogTarget] = useState(null) const handleOpenUpstream = async (id: number) => { // 先同步打开空白页,避免异步请求后 window.open 被浏览器拦截。 @@ -146,18 +150,6 @@ export function RockTokenTable({ } } - // 买家未扫码绑定时,把占用中的提货链接放回库存 - const handleReleaseToken = async (id: number) => { - try { - await rockApi.releaseToken(id) - message.success('已释放回可用库存,该提货链接可以再次分配') - onReloadData() - onReloadTokens() - } catch (e) { - message.error('释放失败: ' + (e instanceof Error ? e.message : '未知错误')) - } - } - // 复制/生成发货凭证图片 const handleCopyVoucher = async (record: RockTokenItem) => { setCopyingVoucherId(record.id) @@ -234,7 +226,8 @@ export function RockTokenTable({ onEditUid, onViewRawData, onResetToken: handleResetToken, - onReleaseToken: handleReleaseToken, + onReleaseToken: setReleaseTarget, + onViewStatusLogs: setStatusLogTarget, onUpdateRemark: handleUpdateRemark, onMarkAbnormal: handleMarkAbnormal, }) @@ -299,6 +292,22 @@ export function RockTokenTable({ }, }} /> + + setReleaseTarget(null)} + onReleased={() => { + onReloadData() + onReloadTokens() + }} + /> + + setStatusLogTarget(null)} + /> ) } diff --git a/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx new file mode 100644 index 0000000..cd5e9c3 --- /dev/null +++ b/frontend/src/pages/platformRock/TokenStatusLogDrawer.tsx @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useState } from 'react' +import { Alert, Button, Drawer, Empty, Grid, Space, Spin, Tag, Timeline, Typography } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { rockApi, type RockTokenItem, type RockTokenStatusLog } from '../../api' +import { formatDateTime } from '../../utils/time' +import { rockTokenStatusLabel } from './types' + +const { Text, Paragraph } = Typography + +export interface TokenStatusLogDrawerProps { + open: boolean + token: RockTokenItem | null + onClose: () => void +} + +const EVENT_META: Record = { + created: { label: '入库', color: 'green' }, + status_changed: { label: '状态变更', color: 'blue' }, + deleted: { label: '删除', color: 'red' }, + restored: { label: '恢复', color: 'orange' }, +} + +const SOURCE_LABELS: Record = { + assign: '分配订单', + bindcheck: '扫码检测', + redeem: '自动发货', + refresh: '刷新上游', + release: '释放回库存', + reset: '人工重置', + manual: '人工更新', + order_cancel: '订单取消', + purchase: '采购入库', + package_sync: '上游包同步', + delete: '删除', + restore: '回收站恢复', +} + +function eventLabel(eventType: string): string { + return EVENT_META[eventType]?.label || eventType || '事件' +} + +function eventColor(eventType: string): string { + return EVENT_META[eventType]?.color || 'gray' +} + +function sourceLabel(source: string): string { + if (!source) return '未知来源' + return SOURCE_LABELS[source] || source +} + +function transitionText(log: RockTokenStatusLog): string { + const from = log.from_status + ? rockTokenStatusLabel(log.from_status) + : log.event_type === 'restored' + ? '回收站' + : '新增' + const to = log.to_status + ? rockTokenStatusLabel(log.to_status) + : log.event_type === 'deleted' + ? '已删除' + : '—' + return `${from} → ${to}` +} + +export function TokenStatusLogDrawer({ + open, + token, + onClose, +}: TokenStatusLogDrawerProps) { + const screens = Grid.useBreakpoint() + const isMobile = !screens.md + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const load = useCallback(async () => { + if (!token) return + setLoading(true) + setError('') + try { + const res = await rockApi.listTokenStatusLogs(token.id) + setLogs(res.list || []) + } catch (e) { + setError(e instanceof Error ? e.message : '加载状态历史失败') + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { + if (!open || !token) return + setLogs([]) + load() + }, [open, token, load]) + + return ( + } + loading={loading} + onClick={load} + > + 刷新 + + } + > + {token && ( +
+ + + {token.upstream_token_id || '-'} / {token.redeem_code || '-'} + + + 当前状态:{rockTokenStatusLabel(token.status)} + {token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''} + + +
+ )} + + {error && ( + + 重试 + + } + /> + )} + + {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( + + ) : ( + ({ + key: log.id, + color: eventColor(log.event_type), + children: ( + + + {eventLabel(log.event_type)} + + {transitionText(log)} + + {log.forced && ( + + 强制 + + )} + {sourceLabel(log.source)} + + {log.reason && ( + + {log.reason} + + )} + + {formatDateTime(log.created_at)} · 操作人{' '} + {log.actor_user_id ? `#${log.actor_user_id}` : '系统/自动'} + + + ), + }))} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index 305e5ea..3a9ffc1 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -4,6 +4,7 @@ import { ExportOutlined, EyeOutlined, FileTextOutlined, + HistoryOutlined, StopOutlined, SyncOutlined, UndoOutlined, @@ -88,7 +89,8 @@ export interface TokenColumnHandlers { onEditUid: (state: UidEditState) => void onViewRawData: (record: RockTokenItem) => void onResetToken: (id: number) => void - onReleaseToken: (id: number) => void + onReleaseToken: (record: RockTokenItem) => void + onViewStatusLogs: (record: RockTokenItem) => void onUpdateRemark: (id: number, remark: string, status?: string) => void onMarkAbnormal: (id: number, remark?: string) => void } @@ -108,6 +110,7 @@ export function buildTokenColumns({ onViewRawData, onResetToken, onReleaseToken, + onViewStatusLogs, onUpdateRemark, onMarkAbnormal, }: TokenColumnHandlers): ColumnsType { @@ -500,9 +503,19 @@ export function buildTokenColumns({ ) + const historyButton = ( + + ) if (record.status === 'mismatch') { return ( - + {refreshButton} {upstreamButton} - - )} + + {historyButton} ) } if (record.status === 'bound') { return ( - + {refreshButton} {upstreamButton} + {historyButton} ) } return ( - + {upstreamButton} + {historyButton} ) }, diff --git a/frontend/src/pages/platformRock/types.ts b/frontend/src/pages/platformRock/types.ts index f049775..3b4d5b8 100644 --- a/frontend/src/pages/platformRock/types.ts +++ b/frontend/src/pages/platformRock/types.ts @@ -4,3 +4,35 @@ export interface UidEditState { scanned: string roleName: string } + +// Token 主状态中文名,状态历史与释放弹窗共用。 +export const ROCK_TOKEN_STATUS_LABELS: Record = { + available: '可用库存', + using: '扫码使用中', + bound: '已授权绑定', + redeemed: '已交付发货', + mismatch: '绑定错误', + abnormal: '已上报失效', + using_overdue: '使用超时', + bound_overdue: '绑定超时', +} + +export function rockTokenStatusLabel(status?: string): string { + if (!status) return '—' + return ROCK_TOKEN_STATUS_LABELS[status] || status +} + +// 上游释放校验状态中文名(release-check 返回的 upstream_status)。 +export const ROCK_UPSTREAM_STATUS_LABELS: Record = { + unbound: '上游未绑定', + bound: '上游已扫码绑定', + redeemed: '上游已发货', + abnormal: '上游已上报失效', + not_found: '上游卡密不存在', + check_failed: '上游状态校验失败', +} + +export function rockUpstreamStatusLabel(status?: string): string { + if (!status) return '未知状态' + return ROCK_UPSTREAM_STATUS_LABELS[status] || status +}