feat: Rock 回库支持上游校验与强制释放,新增 Token 状态历史
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user