feat: Rock 回库支持上游校验与强制释放,新增 Token 状态历史

This commit is contained in:
2026-11-09 13:38:57 +08:00
parent b90252c093
commit 38cbb37a08
26 changed files with 1376 additions and 140 deletions
@@ -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);
+65 -7
View File
@@ -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
+19
View File
@@ -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 {
+2
View File
@@ -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)
+19 -6
View File
@@ -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
}
@@ -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
+23 -3
View File
@@ -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
@@ -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,
@@ -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 {
+8
View File
@@ -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
+51 -26
View File
@@ -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)
}
+152 -31
View File
@@ -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
}
@@ -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"),
},
})
}))
}
+16 -2
View File
@@ -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
})
}
@@ -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,
@@ -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
}
@@ -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,
@@ -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)
@@ -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
}
+10 -1
View File
@@ -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
+36 -3
View File
@@ -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`)
@@ -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<RockReleaseCheckResult | null>(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 (
<Modal
open={open}
title={`释放回库存 · #${token.id}`}
width={isMobile ? 'calc(100vw - 24px)' : 640}
style={isMobile ? { top: 16 } : undefined}
onCancel={submitting ? undefined : onCancel}
onOk={handleSubmit}
okText={
submitting
? '释放中…'
: needForce
? '确认强制释放'
: '确认释放回库存'
}
cancelText="取消"
okButtonProps={{ danger: needForce, disabled: !canSubmit, loading: submitting }}
confirmLoading={submitting}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Descriptions
size="small"
column={1}
bordered
items={[
{
key: 'token',
label: '提货链接',
children: (
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
#{token.id} / {token.upstream_token_id || '-'} /{' '}
{token.redeem_code || '-'}
</Text>
),
},
{
key: 'order',
label: '关联工单',
children: token.assigned_order_no ? (
<Text code>{token.assigned_order_no}</Text>
) : (
<Text type="secondary">未关联订单</Text>
),
},
{
key: 'status',
label: '本地状态',
children: <Tag color="processing">{rockTokenStatusLabel(token.status)}</Tag>,
},
]}
/>
{loadingCheck && (
<Alert type="info" showIcon message="正在向上游确认该提货链接的实时状态…" />
)}
{checkError && (
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
message="释放前校验失败"
description={checkError}
/>
)}
{check?.can_release && (
<Alert
type="success"
showIcon
icon={<SafetyCertificateOutlined />}
message={`上游确认:${rockUpstreamStatusLabel(check.upstream_status)},可以安全释放`}
description={check.message}
/>
)}
{needForce && check && (
<>
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
message={`红色警告:${rockUpstreamStatusLabel(check.upstream_status)},不允许直接释放`}
description={
<Space direction="vertical" size={4}>
<span>{check.message}</span>
{roleText && (
<span>
上游绑定角色:<Text strong>{roleText}</Text>
</span>
)}
<span>
继续强制释放会把这条链接放回可用库存,下一单可能分配到同一链接,
导致买家无法正常提货或与已发货订单冲突。请务必确认该订单已退款、
买家不会再使用,并已核实上游卡密可以重新分配。
</span>
</Space>
}
/>
<div>
<Text type="danger">
确认承担上述风险后,请输入 <Text code>{FORCE_CONFIRM_TEXT}</Text> 解锁强制释放:
</Text>
<Input
value={forceText}
onChange={(e) => setForceText(e.target.value)}
placeholder={FORCE_CONFIRM_TEXT}
style={{ marginTop: 6 }}
status="error"
autoComplete="off"
/>
</div>
</>
)}
{!needForce && !checkError && check && (
<Alert
type="warning"
showIcon
message="释放前请确认该订单已退款或不会再履约"
description="释放后原发货页若仍打开,买家再次获取二维码会重新占用一条库存;请同步告知买家不要再操作。"
/>
)}
{submitError && <Alert type="error" showIcon message={submitError} />}
</Space>
</Modal>
)
}
@@ -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<number | null>(null)
const [openingUpstreamId, setOpeningUpstreamId] = useState<number | null>(null)
const [releaseTarget, setReleaseTarget] = useState<RockTokenItem | null>(null)
const [statusLogTarget, setStatusLogTarget] = useState<RockTokenItem | null>(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({
},
}}
/>
<ReleaseTokenModal
open={Boolean(releaseTarget)}
token={releaseTarget}
onCancel={() => setReleaseTarget(null)}
onReleased={() => {
onReloadData()
onReloadTokens()
}}
/>
<TokenStatusLogDrawer
open={Boolean(statusLogTarget)}
token={statusLogTarget}
onClose={() => setStatusLogTarget(null)}
/>
</div>
)
}
@@ -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<string, { label: string; color: string }> = {
created: { label: '入库', color: 'green' },
status_changed: { label: '状态变更', color: 'blue' },
deleted: { label: '删除', color: 'red' },
restored: { label: '恢复', color: 'orange' },
}
const SOURCE_LABELS: Record<string, string> = {
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<RockTokenStatusLog[]>([])
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 (
<Drawer
open={open}
onClose={onClose}
width={isMobile ? '100%' : 600}
title={token ? `状态历史 · #${token.id}` : '状态历史'}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={load}
>
刷新
</Button>
}
>
{token && (
<div
style={{
marginBottom: 16,
padding: 10,
borderRadius: 8,
background: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{token.upstream_token_id || '-'} / {token.redeem_code || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
当前状态:{rockTokenStatusLabel(token.status)}
{token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''}
</Text>
</Space>
</div>
)}
{error && (
<Alert
type="error"
showIcon
message={error}
style={{ marginBottom: 12 }}
action={
<Button size="small" onClick={load}>
重试
</Button>
}
/>
)}
{loading ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Spin />
</div>
) : logs.length === 0 ? (
<Empty description="暂无状态历史记录" />
) : (
<Timeline
items={logs.map((log) => ({
key: log.id,
color: eventColor(log.event_type),
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space size={[6, 4]} wrap>
<Tag color={eventColor(log.event_type)}>{eventLabel(log.event_type)}</Tag>
<Text strong style={{ fontSize: 13 }}>
{transitionText(log)}
</Text>
{log.forced && (
<Tag color="red" style={{ fontWeight: 600 }}>
强制
</Tag>
)}
<Tag>{sourceLabel(log.source)}</Tag>
</Space>
{log.reason && (
<Paragraph
type="secondary"
style={{ margin: 0, fontSize: 12, wordBreak: 'break-word' }}
>
{log.reason}
</Paragraph>
)}
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDateTime(log.created_at)} · 操作人{' '}
{log.actor_user_id ? `#${log.actor_user_id}` : '系统/自动'}
</Text>
</Space>
),
}))}
/>
)}
</Drawer>
)
}
@@ -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<RockTokenItem> {
@@ -500,9 +503,19 @@ export function buildTokenColumns({
</span>
</Tooltip>
)
const historyButton = (
<Button
size="small"
type="text"
icon={<HistoryOutlined />}
onClick={() => onViewStatusLogs(record)}
>
历史
</Button>
)
if (record.status === 'mismatch') {
return (
<Space direction="vertical" size={4}>
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
<Button
@@ -542,12 +555,13 @@ export function buildTokenColumns({
解冻重置
</Button>
</Popconfirm>
{historyButton}
</Space>
)
}
if (record.status === 'abnormal') {
return (
<Space direction="vertical" size={4}>
<Space size={4} wrap>
<Text type="secondary" style={{ fontSize: 11 }}>已失效作废</Text>
{upstreamButton}
<Popconfirm
@@ -562,42 +576,35 @@ export function buildTokenColumns({
恢复回库存
</Button>
</Popconfirm>
{historyButton}
</Space>
)
}
if (record.status === 'using') {
const canRelease = !record.bound_at && !record.actual_role_id
return (
<Space direction="vertical" size={4}>
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
{canRelease && (
<Popconfirm
title="释放这条未绑定的提货链接?"
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
okText="确认释放"
cancelText="取消"
onConfirm={() => onReleaseToken(record.id)}
>
<Button size="small" icon={<UndoOutlined />}>
释放回库存
</Button>
</Popconfirm>
)}
<Button size="small" icon={<UndoOutlined />} onClick={() => onReleaseToken(record)}>
释放回库存
</Button>
{historyButton}
</Space>
)
}
if (record.status === 'bound') {
return (
<Space direction="vertical" size={4}>
<Space size={4} wrap>
{refreshButton}
{upstreamButton}
{historyButton}
</Space>
)
}
return (
<Space direction="vertical" size={4}>
<Space size={4} wrap>
{upstreamButton}
{historyButton}
</Space>
)
},
+32
View File
@@ -4,3 +4,35 @@ export interface UidEditState {
scanned: string
roleName: string
}
// Token 主状态中文名,状态历史与释放弹窗共用。
export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
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<string, string> = {
unbound: '上游未绑定',
bound: '上游已扫码绑定',
redeemed: '上游已发货',
abnormal: '上游已上报失效',
not_found: '上游卡密不存在',
check_failed: '上游状态校验失败',
}
export function rockUpstreamStatusLabel(status?: string): string {
if (!status) return '未知状态'
return ROCK_UPSTREAM_STATUS_LABELS[status] || status
}