feat:rock增加软删除和恢复删除
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- 020_rock_token_soft_delete.sql
|
||||
-- Rock Token 软删除:保留数据,仅从列表/统计/分配中隐藏
|
||||
ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_tokens_deleted_at ON rock_tokens (deleted_at);
|
||||
|
||||
-- 软删除后允许同一上游 Token 重新入库,同时保持未删除记录唯一
|
||||
DROP INDEX IF EXISTS idx_rock_tokens_upstream_token_id;
|
||||
CREATE UNIQUE INDEX idx_rock_tokens_upstream_token_id
|
||||
ON rock_tokens (upstream_token_id)
|
||||
WHERE upstream_token_id <> '' AND deleted_at IS NULL;
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -182,6 +183,132 @@ func (h *RockHandler) ImportAllUpstreamPackages(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// PreviewTokenDelete 预览当前筛选条件下的 Token 删除风险。
|
||||
func (h *RockHandler) PreviewTokenDelete(c *gin.Context) {
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
preview, err := h.rockSvc.PreviewRockTokenDelete(c.Request.Context(), status, search)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, preview)
|
||||
}
|
||||
|
||||
type batchDeleteTokensReq struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
Force bool `json:"force"`
|
||||
ForceConfirmText string `json:"force_confirm_text"`
|
||||
}
|
||||
|
||||
// BatchDeleteTokens 软删除勾选的 Token。
|
||||
func (h *RockHandler) BatchDeleteTokens(c *gin.Context) {
|
||||
var req batchDeleteTokensReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
response.BadRequest(c, "请选择要删除的 Token")
|
||||
return
|
||||
}
|
||||
if req.Force && strings.TrimSpace(req.ForceConfirmText) != "强制删除" {
|
||||
response.BadRequest(c, "包含使用中/已绑定/绑定错误的 Token,请输入「强制删除」确认")
|
||||
return
|
||||
}
|
||||
result, err := h.rockSvc.DeleteRockTokens(c.Request.Context(), req.IDs, req.Force, middleware.GetUserID(c))
|
||||
if err != nil {
|
||||
writeRockDeleteError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
type deleteAllTokensReq struct {
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
ExpectedCount int64 `json:"expected_count"`
|
||||
Force bool `json:"force"`
|
||||
ConfirmText string `json:"confirm_text"`
|
||||
ForceConfirmText string `json:"force_confirm_text"`
|
||||
}
|
||||
|
||||
// DeleteAllTokens 按当前筛选条件软删除全部 Token。
|
||||
func (h *RockHandler) DeleteAllTokens(c *gin.Context) {
|
||||
var req deleteAllTokensReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ConfirmText) != "全部删除" {
|
||||
response.BadRequest(c, "请输入「全部删除」确认本次操作")
|
||||
return
|
||||
}
|
||||
if req.ExpectedCount <= 0 {
|
||||
response.BadRequest(c, "缺少删除预览数量,请刷新后重试")
|
||||
return
|
||||
}
|
||||
if req.Force && strings.TrimSpace(req.ForceConfirmText) != "强制删除" {
|
||||
response.BadRequest(c, "包含使用中/已绑定/绑定错误的 Token,请输入「强制删除」确认")
|
||||
return
|
||||
}
|
||||
result, err := h.rockSvc.DeleteAllRockTokens(c.Request.Context(), req.Status, req.Search, req.ExpectedCount, req.Force, middleware.GetUserID(c))
|
||||
if err != nil {
|
||||
writeRockDeleteError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func writeRockDeleteError(c *gin.Context, err error) {
|
||||
var forceErr *service.RockTokenForceRequiredError
|
||||
if errors.As(err, &forceErr) {
|
||||
response.Fail(c, http.StatusBadRequest, 400, forceErr.Error())
|
||||
return
|
||||
}
|
||||
var conflictErr *service.RockTokenDeleteConflictError
|
||||
if errors.As(err, &conflictErr) {
|
||||
response.Fail(c, http.StatusConflict, 409, conflictErr.Error())
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, err.Error())
|
||||
}
|
||||
|
||||
// ListDeletedTokens 分页查询回收站中的 Token(软删除记录)。
|
||||
func (h *RockHandler) ListDeletedTokens(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
|
||||
tokens, total, err := h.rockSvc.ListDeletedRockTokens(c.Request.Context(), page, pageSize, status, search)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"list": tokens,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
type restoreTokensReq struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
// RestoreTokens 恢复回收站中的 Token;冲突记录会被跳过并返回原因。
|
||||
func (h *RockHandler) RestoreTokens(c *gin.Context) {
|
||||
var req restoreTokensReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
response.BadRequest(c, "请选择要恢复的 Token")
|
||||
return
|
||||
}
|
||||
result, err := h.rockSvc.RestoreRockTokens(c.Request.Context(), req.IDs, middleware.GetUserID(c))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// ListTokens 分页获取 Token 库存列表
|
||||
func (h *RockHandler) ListTokens(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
@@ -2,6 +2,8 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Rock 相关常量
|
||||
@@ -63,38 +65,40 @@ func (RockConfig) TableName() string {
|
||||
|
||||
// RockToken 通用 Token 本地库存池记录
|
||||
type RockToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
|
||||
Token string `gorm:"type:text;not null" json:"-"`
|
||||
RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"`
|
||||
Color string `gorm:"size:32;not null;default:'black';index" json:"color"`
|
||||
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
|
||||
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
|
||||
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
|
||||
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
|
||||
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
|
||||
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
|
||||
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
|
||||
ExpectedUID string `gorm:"size:64" json:"expected_uid"`
|
||||
ActualRoleName string `gorm:"size:128" json:"actual_role_name"`
|
||||
ActualRoleID string `gorm:"size:64" json:"actual_role_id"`
|
||||
ActualChannel string `gorm:"size:64" json:"actual_channel"`
|
||||
InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON
|
||||
UpstreamFulfillID int64 `json:"upstream_fulfill_id"`
|
||||
TargetSkin string `gorm:"size:128" json:"target_skin"`
|
||||
TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"`
|
||||
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
|
||||
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
|
||||
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
|
||||
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
|
||||
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
|
||||
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
|
||||
BoundAt *time.Time `json:"bound_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
|
||||
Token string `gorm:"type:text;not null" json:"-"`
|
||||
RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"`
|
||||
Color string `gorm:"size:32;not null;default:'black';index" json:"color"`
|
||||
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
|
||||
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
|
||||
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
|
||||
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
|
||||
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
|
||||
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
|
||||
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
|
||||
ExpectedUID string `gorm:"size:64" json:"expected_uid"`
|
||||
ActualRoleName string `gorm:"size:128" json:"actual_role_name"`
|
||||
ActualRoleID string `gorm:"size:64" json:"actual_role_id"`
|
||||
ActualChannel string `gorm:"size:64" json:"actual_channel"`
|
||||
InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON
|
||||
UpstreamFulfillID int64 `json:"upstream_fulfill_id"`
|
||||
TargetSkin string `gorm:"size:128" json:"target_skin"`
|
||||
TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"`
|
||||
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
|
||||
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
|
||||
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
|
||||
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
|
||||
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
|
||||
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
|
||||
BoundAt *time.Time `json:"bound_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
DeletedAtText string `gorm:"-" json:"deleted_at,omitempty"` // 回收站展示用,仅管理端响应返回
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (RockToken) TableName() string {
|
||||
|
||||
@@ -298,6 +298,11 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
|
||||
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
||||
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
||||
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
|
||||
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
|
||||
platform.POST("/platform/rock/token-batches/delete-all", h.Rock.DeleteAllTokens)
|
||||
platform.GET("/platform/rock/token-batches/deleted", h.Rock.ListDeletedTokens)
|
||||
platform.POST("/platform/rock/token-batches/restore", h.Rock.RestoreTokens)
|
||||
platform.POST("/platform/rock/tokens/:id/remark", h.Rock.UpdateRemark)
|
||||
platform.POST("/platform/rock/tokens/:id/abnormal", h.Rock.MarkAbnormal)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
||||
// 若角色名或区服为空,尝试从关联的 rock_tokens 表反查并自动回填
|
||||
if (roleName == "" || gameChannel == "") && s.fulfillment != nil && s.fulfillment.db != nil {
|
||||
var rockToken model.RockToken
|
||||
if err := s.fulfillment.db.Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil {
|
||||
if err := s.fulfillment.db.Unscoped().Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil {
|
||||
patches := map[string]interface{}{}
|
||||
if roleName == "" && rockToken.ActualRoleName != "" {
|
||||
roleName = rockToken.ActualRoleName
|
||||
|
||||
@@ -57,7 +57,7 @@ func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.Fulfill
|
||||
|
||||
if orderNo != "" {
|
||||
var assigned model.RockToken
|
||||
findErr := p.rockService.db.WithContext(ctx).
|
||||
findErr := p.rockService.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
@@ -253,7 +253,7 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder
|
||||
}
|
||||
|
||||
var token model.RockToken
|
||||
_ = p.rockService.db.WithContext(ctx).Where("assigned_order_no = ?", order.OrderNo).Order("id DESC").First(&token).Error
|
||||
_ = p.rockService.db.WithContext(ctx).Unscoped().Where("assigned_order_no = ?", order.OrderNo).Order("id DESC").First(&token).Error
|
||||
|
||||
fulfillIDStr := ""
|
||||
if token.UpstreamFulfillID > 0 {
|
||||
@@ -309,7 +309,7 @@ func (p *RockProvider) SyncStatus(ctx context.Context, order *model.FulfillmentO
|
||||
}
|
||||
|
||||
var token model.RockToken
|
||||
err := p.rockService.db.WithContext(ctx).
|
||||
err := p.rockService.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ?", order.OrderNo).
|
||||
Order("id DESC").
|
||||
First(&token).Error
|
||||
@@ -347,11 +347,11 @@ func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder
|
||||
|
||||
// 若订单尚未扫码绑定成功,将处于 using 状态的 Token 安全重置回 available
|
||||
var token model.RockToken
|
||||
err := p.rockService.db.WithContext(ctx).
|
||||
err := p.rockService.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ? AND status = ?", order.OrderNo, model.RockStatusUsing).
|
||||
First(&token).Error
|
||||
if err == nil {
|
||||
_ = p.rockService.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = p.rockService.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusAvailable,
|
||||
"assigned_order_no": "",
|
||||
"expected_uid": "",
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
var token model.RockToken
|
||||
|
||||
// 1. 该订单已经占用过提货号时,禁止再领新号。
|
||||
err = s.db.WithContext(ctx).
|
||||
err = s.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
@@ -73,7 +73,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
token.MismatchReason = ""
|
||||
token.MismatchAt = nil
|
||||
token.BoundAt = &now
|
||||
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"expected_uid": trimmedUID,
|
||||
"status": model.RockStatusBound,
|
||||
"mismatch_reason": "",
|
||||
@@ -99,7 +99,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
}
|
||||
if trimmedUID != "" && token.ExpectedUID != trimmedUID {
|
||||
token.ExpectedUID = trimmedUID
|
||||
_ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("expected_uid", trimmedUID).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
}
|
||||
snapBytes, _ := json.Marshal(snap)
|
||||
token.InitialBoundSnapshot = string(snapBytes)
|
||||
_ = s.db.WithContext(ctx).Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
}
|
||||
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Unscoped().First(&token, tokenID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
token.MismatchReason = ""
|
||||
token.MismatchAt = nil
|
||||
token.BoundAt = &now
|
||||
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"expected_uid": trimmedExpected,
|
||||
"status": model.RockStatusBound,
|
||||
"mismatch_reason": "",
|
||||
@@ -169,7 +169,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
if trimmedExpected != "" && actualRoleID != trimmedExpected {
|
||||
// 严禁发货!跃迁为 mismatch
|
||||
mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID)
|
||||
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusMismatch,
|
||||
"actual_role_name": actualRoleName,
|
||||
"actual_role_id": actualRoleID,
|
||||
@@ -194,7 +194,7 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
}
|
||||
|
||||
// 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称
|
||||
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusBound,
|
||||
"actual_role_name": actualRoleName,
|
||||
"actual_role_id": actualRoleID,
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo s
|
||||
return "", ""
|
||||
}
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Unscoped().First(&token, tokenID).Error; err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if token.Status != model.RockStatusUsing || strings.TrimSpace(token.RedeemCode) == "" {
|
||||
|
||||
@@ -39,7 +39,7 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
redeemedAt := parseRockPackageTime(pt.RedeemedAt)
|
||||
|
||||
var existing model.RockToken
|
||||
findErr := tx.Where("upstream_token_id = ?", upstreamID).First(&existing).Error
|
||||
findErr := tx.Unscoped().Where("upstream_token_id = ?", upstreamID).First(&existing).Error
|
||||
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
insertStatus := status
|
||||
if _, ok := forceAvailableIDs[upstreamID]; ok {
|
||||
@@ -85,6 +85,11 @@ func (s *RockService) importPackageDetail(ctx context.Context, detail *RockPacka
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
// 已软删除的 Token 视为“用户主动清理过”,再次导入时不复活、不覆盖。
|
||||
if existing.DeletedAt.Valid {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
|
||||
@@ -111,7 +111,7 @@ func (s *RockService) ImportAllUpstreamPackages(ctx context.Context, maxPackages
|
||||
}
|
||||
importedIDs := make([]int64, 0, len(ids))
|
||||
if len(ids) > 0 {
|
||||
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||
if err := s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).
|
||||
Where("package_id IN ?", ids).
|
||||
Group("package_id").
|
||||
Pluck("package_id", &importedIDs).Error; err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
}
|
||||
|
||||
var token model.RockToken
|
||||
err = s.db.WithContext(ctx).
|
||||
err = s.db.WithContext(ctx).Unscoped().
|
||||
Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound).
|
||||
First(&token).Error
|
||||
if err != nil {
|
||||
@@ -55,7 +55,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
if ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID); refErr == nil && ref != nil {
|
||||
actualRoleName = ref.GetRoleName()
|
||||
if actualRoleName != "" {
|
||||
_ = s.db.WithContext(ctx).Model(&token).Update("actual_role_name", actualRoleName).Error
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("actual_role_name", actualRoleName).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, exp
|
||||
}
|
||||
|
||||
// 状态跃迁至 redeemed
|
||||
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusRedeemed,
|
||||
"upstream_fulfill_id": fulfillID,
|
||||
"target_skin": targetSkin,
|
||||
|
||||
@@ -38,6 +38,7 @@ type RockStatsResponse struct {
|
||||
RedeemedStock int64 `json:"redeemed_stock"`
|
||||
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
|
||||
AbnormalStock int64 `json:"abnormal_stock"`
|
||||
DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
|
||||
UpstreamPoints float64 `json:"upstream_points"`
|
||||
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
||||
TokenWarningStock int `json:"token_warning_stock"`
|
||||
@@ -315,5 +316,13 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
|
||||
}
|
||||
}
|
||||
|
||||
var deletedCount int64
|
||||
if err := s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Count(&deletedCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.DeletedStock = deletedCount
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func (s *RockService) ensureClaimedTokens(ctx context.Context, claimed []RockCla
|
||||
}
|
||||
|
||||
var existing []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||
if err := s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).
|
||||
Where("upstream_token_id IN ?", ids).
|
||||
Pluck("upstream_token_id", &existing).Error; err != nil {
|
||||
return err
|
||||
@@ -491,15 +491,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
db := s.db.WithContext(ctx).Model(&model.RockToken{})
|
||||
if strings.TrimSpace(status) != "" {
|
||||
db = db.Where("status = ?", strings.TrimSpace(status))
|
||||
}
|
||||
if strings.TrimSpace(search) != "" {
|
||||
term := "%" + strings.TrimSpace(search) + "%"
|
||||
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR CAST(package_id AS TEXT) LIKE ? OR token LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
|
||||
strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term, term, term, term)
|
||||
}
|
||||
db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
rockTokenBatchDeleteMax = 500
|
||||
rockTokenDeleteAllMax = 5000
|
||||
)
|
||||
|
||||
var rockTokenDangerousStatuses = map[string]bool{
|
||||
model.RockStatusUsing: true,
|
||||
model.RockStatusBound: true,
|
||||
model.RockStatusMismatch: true,
|
||||
}
|
||||
|
||||
// RockTokenDeleteStatusCount 是删除预览中的状态分布。
|
||||
type RockTokenDeleteStatusCount struct {
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// RockTokenDeletePreview 是删除前的风险预览。
|
||||
type RockTokenDeletePreview struct {
|
||||
Total int64 `json:"total"`
|
||||
Dangerous int64 `json:"dangerous"`
|
||||
Available int64 `json:"available"`
|
||||
RequiresForce bool `json:"requires_force"`
|
||||
StatusCounts []RockTokenDeleteStatusCount `json:"status_counts"`
|
||||
DangerousItems []RockTokenDeleteItem `json:"dangerous_items"`
|
||||
}
|
||||
|
||||
// RockTokenDeleteItem 是删除风险提示中的单条记录。
|
||||
type RockTokenDeleteItem struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AssignedOrderNo string `json:"assigned_order_no,omitempty"`
|
||||
}
|
||||
|
||||
// RockTokenDeleteResult 是软删除结果。
|
||||
type RockTokenDeleteResult struct {
|
||||
Requested int `json:"requested"`
|
||||
Deleted int `json:"deleted"`
|
||||
Forced bool `json:"forced"`
|
||||
StatusCounts []RockTokenDeleteStatusCount `json:"status_counts"`
|
||||
}
|
||||
|
||||
// RockTokenForceRequiredError 表示本次删除包含使用中/已绑定/绑定错误 Token,需要强制确认。
|
||||
type RockTokenForceRequiredError struct {
|
||||
Dangerous int64 `json:"dangerous"`
|
||||
StatusCounts []RockTokenDeleteStatusCount `json:"status_counts"`
|
||||
DangerousItems []RockTokenDeleteItem `json:"dangerous_items"`
|
||||
}
|
||||
|
||||
func (e *RockTokenForceRequiredError) Error() string {
|
||||
return fmt.Sprintf("本次操作包含 %d 条使用中/已绑定/绑定错误的 Token,必须二次确认后强制删除", e.Dangerous)
|
||||
}
|
||||
|
||||
// RockTokenDeleteConflictError 表示预览后数据已变化。
|
||||
type RockTokenDeleteConflictError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *RockTokenDeleteConflictError) Error() string {
|
||||
if strings.TrimSpace(e.Message) != "" {
|
||||
return e.Message
|
||||
}
|
||||
return "删除条件对应的数据已变化,请刷新后重试"
|
||||
}
|
||||
|
||||
func isKnownRockTokenStatus(status string) bool {
|
||||
switch status {
|
||||
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
|
||||
model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// applyRockTokenListFilters 与 Token 列表使用同一套筛选条件,避免“看到多少、删多少”不一致。
|
||||
func applyRockTokenListFilters(db *gorm.DB, status, search string) *gorm.DB {
|
||||
if value := strings.TrimSpace(status); value != "" {
|
||||
db = db.Where("status = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(search); value != "" {
|
||||
term := "%" + value + "%"
|
||||
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR CAST(package_id AS TEXT) LIKE ? OR token LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
|
||||
value, value, term, term, term, term, term, term, term, term, term, term)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func rockTokenStatusCountsFromRows(rows []model.RockToken) []RockTokenDeleteStatusCount {
|
||||
counts := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
counts[row.Status]++
|
||||
}
|
||||
order := []string{
|
||||
model.RockStatusAvailable,
|
||||
model.RockStatusUsing,
|
||||
model.RockStatusBound,
|
||||
model.RockStatusRedeemed,
|
||||
model.RockStatusMismatch,
|
||||
model.RockStatusAbnormal,
|
||||
}
|
||||
result := make([]RockTokenDeleteStatusCount, 0, len(counts))
|
||||
for _, status := range order {
|
||||
if count := counts[status]; count > 0 {
|
||||
result = append(result, RockTokenDeleteStatusCount{Status: status, Count: count})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rockTokenDangerousCount(rows []model.RockToken) int64 {
|
||||
var total int64
|
||||
for _, row := range rows {
|
||||
if rockTokenDangerousStatuses[row.Status] {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func rockTokenDangerousItems(rows []model.RockToken, limit int) []RockTokenDeleteItem {
|
||||
items := make([]RockTokenDeleteItem, 0, limit)
|
||||
for _, row := range rows {
|
||||
if !rockTokenDangerousStatuses[row.Status] {
|
||||
continue
|
||||
}
|
||||
items = append(items, RockTokenDeleteItem{
|
||||
ID: row.ID,
|
||||
Status: row.Status,
|
||||
AssignedOrderNo: row.AssignedOrderNo,
|
||||
})
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// PreviewRockTokenDelete 返回筛选条件下的删除风险预览,不修改任何数据。
|
||||
func (s *RockService) PreviewRockTokenDelete(ctx context.Context, status, search string) (*RockTokenDeletePreview, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if !isKnownRockTokenStatus(status) {
|
||||
return nil, errors.New("无效的 Token 状态筛选")
|
||||
}
|
||||
var rows []model.RockToken
|
||||
query := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
|
||||
if err := query.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var available int64
|
||||
for _, row := range rows {
|
||||
if row.Status == model.RockStatusAvailable {
|
||||
available++
|
||||
}
|
||||
}
|
||||
dangerous := rockTokenDangerousCount(rows)
|
||||
return &RockTokenDeletePreview{
|
||||
Total: int64(len(rows)),
|
||||
Dangerous: dangerous,
|
||||
Available: available,
|
||||
RequiresForce: dangerous > 0,
|
||||
StatusCounts: rockTokenStatusCountsFromRows(rows),
|
||||
DangerousItems: rockTokenDangerousItems(rows, 10),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteRockTokens 软删除指定 Token;包含危险状态时必须 force=true。
|
||||
func (s *RockService) DeleteRockTokens(ctx context.Context, ids []uint, force bool, actorUserID uint) (*RockTokenDeleteResult, error) {
|
||||
unique := make([]uint, 0, len(ids))
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil, errors.New("请选择要删除的 Token")
|
||||
}
|
||||
if len(unique) > rockTokenBatchDeleteMax {
|
||||
return nil, fmt.Errorf("单次最多删除 %d 条 Token", rockTokenBatchDeleteMax)
|
||||
}
|
||||
|
||||
result := &RockTokenDeleteResult{Requested: len(unique), Forced: force}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.RockToken
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", unique).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) != len(unique) {
|
||||
return &RockTokenDeleteConflictError{Message: "部分 Token 不存在或已被删除,请刷新列表后重试"}
|
||||
}
|
||||
result.StatusCounts = rockTokenStatusCountsFromRows(rows)
|
||||
dangerous := rockTokenDangerousCount(rows)
|
||||
if dangerous > 0 && !force {
|
||||
return &RockTokenForceRequiredError{
|
||||
Dangerous: dangerous,
|
||||
StatusCounts: result.StatusCounts,
|
||||
DangerousItems: rockTokenDangerousItems(rows, 10),
|
||||
}
|
||||
}
|
||||
|
||||
deleteResult := tx.Where("id IN ?", unique).Delete(&model.RockToken{})
|
||||
if deleteResult.Error != nil {
|
||||
return deleteResult.Error
|
||||
}
|
||||
result.Deleted = int(deleteResult.RowsAffected)
|
||||
metadata := map[string]interface{}{
|
||||
"requested": result.Requested,
|
||||
"deleted": result.Deleted,
|
||||
"forced": force,
|
||||
"status_counts": result.StatusCounts,
|
||||
"ids": unique,
|
||||
}
|
||||
return writeAudit(tx, nil, &actorUserID, nil, "rock_token.batch_delete", "rock_token",
|
||||
fmt.Sprintf("batch:%d", len(unique)), metadata)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteAllRockTokens 按当前筛选条件软删除全部 Token;包含危险状态时必须 force=true。
|
||||
func (s *RockService) DeleteAllRockTokens(ctx context.Context, status, search string, expectedCount int64, force bool, actorUserID uint) (*RockTokenDeleteResult, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if !isKnownRockTokenStatus(status) {
|
||||
return nil, errors.New("无效的 Token 状态筛选")
|
||||
}
|
||||
|
||||
result := &RockTokenDeleteResult{Forced: force}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.RockToken
|
||||
query := applyRockTokenListFilters(tx.Model(&model.RockToken{}), status, search)
|
||||
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) > rockTokenDeleteAllMax {
|
||||
return fmt.Errorf("单次最多删除 %d 条 Token,请缩小筛选范围", rockTokenDeleteAllMax)
|
||||
}
|
||||
if expectedCount > 0 && int64(len(rows)) != expectedCount {
|
||||
return &RockTokenDeleteConflictError{Message: fmt.Sprintf("筛选结果已变化(预览 %d 条,当前 %d 条),请刷新后重试", expectedCount, len(rows))}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return errors.New("当前筛选条件下没有可删除的 Token")
|
||||
}
|
||||
result.Requested = len(rows)
|
||||
result.StatusCounts = rockTokenStatusCountsFromRows(rows)
|
||||
dangerous := rockTokenDangerousCount(rows)
|
||||
if dangerous > 0 && !force {
|
||||
return &RockTokenForceRequiredError{
|
||||
Dangerous: dangerous,
|
||||
StatusCounts: result.StatusCounts,
|
||||
DangerousItems: rockTokenDangerousItems(rows, 10),
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
deleteResult := tx.Where("id IN ?", ids).Delete(&model.RockToken{})
|
||||
if deleteResult.Error != nil {
|
||||
return deleteResult.Error
|
||||
}
|
||||
result.Deleted = int(deleteResult.RowsAffected)
|
||||
metadata := map[string]interface{}{
|
||||
"requested": result.Requested,
|
||||
"deleted": result.Deleted,
|
||||
"forced": force,
|
||||
"status_filter": status,
|
||||
"search": strings.TrimSpace(search),
|
||||
"status_counts": result.StatusCounts,
|
||||
}
|
||||
return writeAudit(tx, nil, &actorUserID, nil, "rock_token.delete_all", "rock_token",
|
||||
fmt.Sprintf("filter:%s:%s", status, strings.TrimSpace(search)), metadata)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func createDeleteTestToken(t *testing.T, svc *RockService, upstreamID, status string) model.RockToken {
|
||||
t.Helper()
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: upstreamID,
|
||||
Token: "tk-" + upstreamID,
|
||||
RedeemCode: "rc-" + upstreamID,
|
||||
Color: model.RockColorBlack,
|
||||
Status: status,
|
||||
}
|
||||
if status == model.RockStatusUsing || status == model.RockStatusBound || status == model.RockStatusMismatch {
|
||||
token.AssignedOrderNo = "ORDER-" + upstreamID
|
||||
}
|
||||
if err := svc.db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestDeleteRockTokens_SoftDeletesAndHidesFromList(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
available := createDeleteTestToken(t, svc, "90001", model.RockStatusAvailable)
|
||||
redeemed := createDeleteTestToken(t, svc, "90002", model.RockStatusRedeemed)
|
||||
|
||||
result, err := svc.DeleteRockTokens(ctx, []uint{available.ID}, false, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("删除 Token 失败: %v", err)
|
||||
}
|
||||
if result.Deleted != 1 || result.Requested != 1 {
|
||||
t.Fatalf("删除数量不符: %+v", result)
|
||||
}
|
||||
|
||||
_, total, err := svc.ListTokens(ctx, 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询列表失败: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("软删除后列表应只剩 1 条,实际 %d", total)
|
||||
}
|
||||
|
||||
stats, err := svc.GetStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("查询统计失败: %v", err)
|
||||
}
|
||||
if stats.AvailableStock != 0 || stats.RedeemedStock != 1 {
|
||||
t.Fatalf("统计未排除软删除: available=%d redeemed=%d", stats.AvailableStock, stats.RedeemedStock)
|
||||
}
|
||||
|
||||
var all []model.RockToken
|
||||
if err := db.Unscoped().Order("id ASC").Find(&all).Error; err != nil {
|
||||
t.Fatalf("Unscoped 查询失败: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("软删除不应物理删除,实际记录数 %d", len(all))
|
||||
}
|
||||
var deleted model.RockToken
|
||||
if err := db.Unscoped().First(&deleted, available.ID).Error; err != nil {
|
||||
t.Fatalf("读取已删除记录失败: %v", err)
|
||||
}
|
||||
if !deleted.DeletedAt.Valid {
|
||||
t.Fatal("available Token 未写入 deleted_at")
|
||||
}
|
||||
|
||||
var active model.RockToken
|
||||
if err := db.First(&active, available.ID).Error; err == nil {
|
||||
t.Fatal("普通查询不应返回已软删除 Token")
|
||||
}
|
||||
_ = redeemed
|
||||
}
|
||||
|
||||
func TestDeleteRockTokens_RequiresForceForDangerousStatuses(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
using := createDeleteTestToken(t, svc, "91001", model.RockStatusUsing)
|
||||
bound := createDeleteTestToken(t, svc, "91002", model.RockStatusBound)
|
||||
mismatch := createDeleteTestToken(t, svc, "91003", model.RockStatusMismatch)
|
||||
ids := []uint{using.ID, bound.ID, mismatch.ID}
|
||||
|
||||
if _, err := svc.DeleteRockTokens(ctx, ids, false, 7); err == nil {
|
||||
t.Fatal("包含危险状态时未强制确认应失败")
|
||||
} else {
|
||||
var forceErr *RockTokenForceRequiredError
|
||||
if !errors.As(err, &forceErr) {
|
||||
t.Fatalf("错误类型不是 ForceRequired: %T %v", err, err)
|
||||
}
|
||||
if forceErr.Dangerous != 3 {
|
||||
t.Fatalf("危险数量不符: %d", forceErr.Dangerous)
|
||||
}
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := db.Model(&model.RockToken{}).Count(&activeCount).Error; err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if activeCount != 3 {
|
||||
t.Fatalf("强制确认失败时不应删除,实际剩余 %d", activeCount)
|
||||
}
|
||||
|
||||
result, err := svc.DeleteRockTokens(ctx, ids, true, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("强制删除失败: %v", err)
|
||||
}
|
||||
if result.Deleted != 3 {
|
||||
t.Fatalf("强制删除数量不符: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewAndDeleteAllRockTokens_Filtered(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
createDeleteTestToken(t, svc, "92001", model.RockStatusRedeemed)
|
||||
createDeleteTestToken(t, svc, "92002", model.RockStatusRedeemed)
|
||||
createDeleteTestToken(t, svc, "92003", model.RockStatusAvailable)
|
||||
|
||||
preview, err := svc.PreviewRockTokenDelete(ctx, model.RockStatusRedeemed, "")
|
||||
if err != nil {
|
||||
t.Fatalf("预览失败: %v", err)
|
||||
}
|
||||
if preview.Total != 2 || preview.Dangerous != 0 || preview.Available != 0 {
|
||||
t.Fatalf("预览数量不符: %+v", preview)
|
||||
}
|
||||
|
||||
// 预览数量与当前数据不一致时必须拒绝,避免误删。
|
||||
if _, err := svc.DeleteAllRockTokens(ctx, model.RockStatusRedeemed, "", 1, false, 7); err == nil {
|
||||
t.Fatal("expected_count 不一致时应拒绝")
|
||||
} else {
|
||||
var conflict *RockTokenDeleteConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("错误类型不是 Conflict: %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := svc.DeleteAllRockTokens(ctx, model.RockStatusRedeemed, "", 2, false, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("删除全部失败: %v", err)
|
||||
}
|
||||
if result.Deleted != 2 {
|
||||
t.Fatalf("删除数量不符: %+v", result)
|
||||
}
|
||||
|
||||
_, total, err := svc.ListTokens(ctx, 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询列表失败: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("按状态删除后应剩 1 条 available,实际 %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewDeleteAll_ReportsDangerousAndAvailable(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
createDeleteTestToken(t, svc, "93001", model.RockStatusAvailable)
|
||||
createDeleteTestToken(t, svc, "93002", model.RockStatusBound)
|
||||
createDeleteTestToken(t, svc, "93003", model.RockStatusRedeemed)
|
||||
|
||||
preview, err := svc.PreviewRockTokenDelete(ctx, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("预览失败: %v", err)
|
||||
}
|
||||
if preview.Total != 3 || preview.Dangerous != 1 || preview.Available != 1 || !preview.RequiresForce {
|
||||
t.Fatalf("预览结果不符: %+v", preview)
|
||||
}
|
||||
if len(preview.DangerousItems) != 1 || preview.DangerousItems[0].Status != model.RockStatusBound {
|
||||
t.Fatalf("危险明细不符: %+v", preview.DangerousItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPackageDetail_DoesNotResurrectDeletedToken(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
packageID := int64(12345)
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "94001",
|
||||
Token: "tk-94001",
|
||||
RedeemCode: "rc-94001",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusAvailable,
|
||||
PackageID: &packageID,
|
||||
Source: "package",
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("创建 Token 失败: %v", err)
|
||||
}
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{token.ID}, false, 7); err != nil {
|
||||
t.Fatalf("软删除失败: %v", err)
|
||||
}
|
||||
|
||||
var detail RockPackageDetailResponse
|
||||
raw := `{
|
||||
"package": {"id": 12345, "share_code": "share-test"},
|
||||
"tokens": [
|
||||
{"id": 94001, "token": "tk-94001", "redeem_code": "rc-94001", "color": "black", "status": "available", "created_at": "2026-10-31 10:00:00"}
|
||||
]
|
||||
}`
|
||||
if err := json.Unmarshal([]byte(raw), &detail); err != nil {
|
||||
t.Fatalf("解析包详情失败: %v", err)
|
||||
}
|
||||
|
||||
inserted, updated, skipped, err := svc.importPackageDetail(ctx, &detail, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("导入包详情失败: %v", err)
|
||||
}
|
||||
if inserted != 0 || updated != 0 || skipped != 1 {
|
||||
t.Fatalf("已删除 Token 不应被导入: inserted=%d updated=%d skipped=%d", inserted, updated, skipped)
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := db.Model(&model.RockToken{}).Where("upstream_token_id = ?", "94001").Count(&activeCount).Error; err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if activeCount != 0 {
|
||||
t.Fatalf("已删除 Token 被重新创建,active=%d", activeCount)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Unscoped().Model(&model.RockToken{}).Where("upstream_token_id = ?", "94001").Count(&total).Error; err != nil {
|
||||
t.Fatalf("Unscoped 统计失败: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("软删除记录应保留一条,实际 %d", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const rockTokenBatchRestoreMax = 500
|
||||
|
||||
// 恢复被跳过的原因。
|
||||
const (
|
||||
RockRestoreSkipNotDeleted = "not_deleted"
|
||||
RockRestoreSkipDuplicateUpstream = "duplicate_upstream"
|
||||
RockRestoreSkipOrderConflict = "order_conflict"
|
||||
)
|
||||
|
||||
// RockTokenRestoreSkipItem 表示恢复时被跳过的记录及原因。
|
||||
type RockTokenRestoreSkipItem struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RockTokenRestoreResult 是批量恢复结果。
|
||||
type RockTokenRestoreResult struct {
|
||||
Requested int `json:"requested"`
|
||||
Restored int `json:"restored"`
|
||||
Skipped int `json:"skipped"`
|
||||
StatusCounts []RockTokenDeleteStatusCount `json:"status_counts"`
|
||||
SkippedItems []RockTokenRestoreSkipItem `json:"skipped_items"`
|
||||
}
|
||||
|
||||
// ListDeletedRockTokens 分页查询回收站(deleted_at 非空)中的 Token。
|
||||
func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
status = strings.TrimSpace(status)
|
||||
if !isKnownRockTokenStatus(status) {
|
||||
return nil, 0, errors.New("无效的 Token 状态筛选")
|
||||
}
|
||||
|
||||
db := applyRockTokenListFilters(
|
||||
s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).Where("deleted_at IS NOT NULL"),
|
||||
status, search,
|
||||
)
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var tokens []model.RockToken
|
||||
if err := db.Order("deleted_at DESC, id DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&tokens).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range tokens {
|
||||
tokens[i].TokenCode = tokens[i].Token
|
||||
if tokens[i].DeletedAt.Valid {
|
||||
tokens[i].DeletedAtText = tokens[i].DeletedAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return tokens, total, nil
|
||||
}
|
||||
|
||||
// RestoreRockTokens 恢复已软删除的 Token。
|
||||
// 同一上游编号已在用、或同一订单已有在用 Token 时跳过该条,其余正常恢复(部分成功)。
|
||||
func (s *RockService) RestoreRockTokens(ctx context.Context, ids []uint, actorUserID uint) (*RockTokenRestoreResult, error) {
|
||||
unique := make([]uint, 0, len(ids))
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil, errors.New("请选择要恢复的 Token")
|
||||
}
|
||||
if len(unique) > rockTokenBatchRestoreMax {
|
||||
return nil, fmt.Errorf("单次最多恢复 %d 条 Token", rockTokenBatchRestoreMax)
|
||||
}
|
||||
|
||||
result := &RockTokenRestoreResult{Requested: len(unique), SkippedItems: []RockTokenRestoreSkipItem{}}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.RockToken
|
||||
if err := tx.Unscoped().Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", unique).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
found := make(map[uint]model.RockToken, len(rows))
|
||||
for _, row := range rows {
|
||||
found[row.ID] = row
|
||||
}
|
||||
|
||||
// 先按请求顺序过滤出仍在回收站里的记录。
|
||||
candidates := make([]model.RockToken, 0, len(unique))
|
||||
for _, id := range unique {
|
||||
row, ok := found[id]
|
||||
if !ok || !row.DeletedAt.Valid {
|
||||
reason := RockRestoreSkipNotDeleted
|
||||
item := RockTokenRestoreSkipItem{ID: id, Reason: reason}
|
||||
if ok {
|
||||
item.Status = row.Status
|
||||
}
|
||||
result.SkippedItems = append(result.SkippedItems, item)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, row)
|
||||
}
|
||||
|
||||
// 冲突 1:同一 upstream_token_id 已存在未删除记录(部分唯一索引会拒绝恢复)。
|
||||
upstreamIDs := make([]string, 0, len(candidates))
|
||||
for _, row := range candidates {
|
||||
if value := strings.TrimSpace(row.UpstreamTokenID); value != "" {
|
||||
upstreamIDs = append(upstreamIDs, value)
|
||||
}
|
||||
}
|
||||
activeUpstream := make(map[string]struct{}, len(upstreamIDs))
|
||||
if len(upstreamIDs) > 0 {
|
||||
var values []string
|
||||
if err := tx.Model(&model.RockToken{}).
|
||||
Where("upstream_token_id IN ?", upstreamIDs).
|
||||
Pluck("upstream_token_id", &values).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
activeUpstream[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 冲突 2:同一订单已经有其它在用/已发货 Token,恢复会造成一单两号。
|
||||
orderNos := make([]string, 0, len(candidates))
|
||||
for _, row := range candidates {
|
||||
if value := strings.TrimSpace(row.AssignedOrderNo); value != "" {
|
||||
orderNos = append(orderNos, value)
|
||||
}
|
||||
}
|
||||
activeOrders := make(map[string]struct{}, len(orderNos))
|
||||
if len(orderNos) > 0 {
|
||||
var values []string
|
||||
if err := tx.Model(&model.RockToken{}).
|
||||
Where("assigned_order_no IN ?", orderNos).
|
||||
Pluck("assigned_order_no", &values).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
activeOrders[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
restorable := make([]model.RockToken, 0, len(candidates))
|
||||
for _, row := range candidates {
|
||||
if upstream := strings.TrimSpace(row.UpstreamTokenID); upstream != "" {
|
||||
if _, conflict := activeUpstream[upstream]; conflict {
|
||||
result.SkippedItems = append(result.SkippedItems, RockTokenRestoreSkipItem{
|
||||
ID: row.ID, Status: row.Status, Reason: RockRestoreSkipDuplicateUpstream,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if orderNo := strings.TrimSpace(row.AssignedOrderNo); orderNo != "" {
|
||||
if _, conflict := activeOrders[orderNo]; conflict {
|
||||
result.SkippedItems = append(result.SkippedItems, RockTokenRestoreSkipItem{
|
||||
ID: row.ID, Status: row.Status, Reason: RockRestoreSkipOrderConflict,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
restorable = append(restorable, row)
|
||||
}
|
||||
|
||||
if len(restorable) > 0 {
|
||||
restoreIDs := make([]uint, 0, len(restorable))
|
||||
for _, row := range restorable {
|
||||
restoreIDs = append(restoreIDs, row.ID)
|
||||
}
|
||||
if err := tx.Unscoped().Model(&model.RockToken{}).
|
||||
Where("id IN ?", restoreIDs).
|
||||
Updates(map[string]interface{}{"deleted_at": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.Restored = len(restorable)
|
||||
result.StatusCounts = rockTokenStatusCountsFromRows(restorable)
|
||||
metadata := map[string]interface{}{
|
||||
"requested": result.Requested,
|
||||
"restored": result.Restored,
|
||||
"skipped": len(result.SkippedItems),
|
||||
"status_counts": result.StatusCounts,
|
||||
"ids": restoreIDs,
|
||||
"skipped_items": result.SkippedItems,
|
||||
}
|
||||
if err := writeAudit(tx, nil, &actorUserID, nil, "rock_token.batch_restore", "rock_token",
|
||||
fmt.Sprintf("batch:%d", len(restoreIDs)), metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result.Skipped = len(result.SkippedItems)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func restoreSkipReasons(items []RockTokenRestoreSkipItem) map[uint]string {
|
||||
reasons := make(map[uint]string, len(items))
|
||||
for _, item := range items {
|
||||
reasons[item.ID] = item.Reason
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func TestRestoreRockTokens_UnhidesDeletedToken(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
available := createDeleteTestToken(t, svc, "95001", model.RockStatusAvailable)
|
||||
redeemed := createDeleteTestToken(t, svc, "95002", model.RockStatusRedeemed)
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{available.ID, redeemed.ID}, false, 7); err != nil {
|
||||
t.Fatalf("软删除失败: %v", err)
|
||||
}
|
||||
|
||||
deleted, total, err := svc.ListDeletedRockTokens(ctx, 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询回收站失败: %v", err)
|
||||
}
|
||||
if total != 2 || len(deleted) != 2 {
|
||||
t.Fatalf("回收站应有 2 条,实际 total=%d len=%d", total, len(deleted))
|
||||
}
|
||||
var deletedAtSet bool
|
||||
for _, row := range deleted {
|
||||
if row.DeletedAt.Valid && row.DeletedAtText != "" {
|
||||
deletedAtSet = true
|
||||
}
|
||||
}
|
||||
if !deletedAtSet {
|
||||
t.Fatal("回收站列表应带出 deleted_at 字符串")
|
||||
}
|
||||
|
||||
stats, err := svc.GetStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("查询统计失败: %v", err)
|
||||
}
|
||||
if stats.DeletedStock != 2 || stats.AvailableStock != 0 {
|
||||
t.Fatalf("删除后统计不符: deleted=%d available=%d", stats.DeletedStock, stats.AvailableStock)
|
||||
}
|
||||
|
||||
result, err := svc.RestoreRockTokens(ctx, []uint{available.ID}, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if result.Requested != 1 || result.Restored != 1 || result.Skipped != 0 {
|
||||
t.Fatalf("恢复结果不符: %+v", result)
|
||||
}
|
||||
|
||||
_, total, err = svc.ListTokens(ctx, 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询列表失败: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("恢复后列表应有 1 条,实际 %d", total)
|
||||
}
|
||||
|
||||
stats, err = svc.GetStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("查询统计失败: %v", err)
|
||||
}
|
||||
if stats.AvailableStock != 1 || stats.DeletedStock != 1 {
|
||||
t.Fatalf("恢复后统计不符: available=%d deleted=%d", stats.AvailableStock, stats.DeletedStock)
|
||||
}
|
||||
|
||||
// 状态筛选同样作用于回收站。
|
||||
deleted, total, err = svc.ListDeletedRockTokens(ctx, 1, 20, model.RockStatusRedeemed, "")
|
||||
if err != nil {
|
||||
t.Fatalf("按状态查询回收站失败: %v", err)
|
||||
}
|
||||
if total != 1 || len(deleted) != 1 || deleted[0].ID != redeemed.ID {
|
||||
t.Fatalf("回收站状态筛选不符: total=%d %+v", total, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRockTokens_SkipsActiveRecords(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
active := createDeleteTestToken(t, svc, "96001", model.RockStatusAvailable)
|
||||
usingToken := createDeleteTestToken(t, svc, "96002", model.RockStatusUsing)
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{usingToken.ID}, true, 7); err != nil {
|
||||
t.Fatalf("强制删除失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.RestoreRockTokens(ctx, []uint{active.ID, usingToken.ID, 999999}, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if result.Requested != 3 || result.Restored != 1 || result.Skipped != 2 {
|
||||
t.Fatalf("部分恢复结果不符: %+v", result)
|
||||
}
|
||||
if len(result.StatusCounts) != 1 || result.StatusCounts[0].Status != model.RockStatusUsing {
|
||||
t.Fatalf("恢复状态分布不符: %+v", result.StatusCounts)
|
||||
}
|
||||
reasons := restoreSkipReasons(result.SkippedItems)
|
||||
if reasons[active.ID] != RockRestoreSkipNotDeleted {
|
||||
t.Fatalf("未删除记录应跳过 not_deleted,实际 %q", reasons[active.ID])
|
||||
}
|
||||
if reasons[999999] != RockRestoreSkipNotDeleted {
|
||||
t.Fatalf("不存在的记录应跳过 not_deleted,实际 %q", reasons[999999])
|
||||
}
|
||||
|
||||
var restored model.RockToken
|
||||
if err := db.First(&restored, usingToken.ID).Error; err != nil {
|
||||
t.Fatalf("恢复后的 Token 应可普通查询: %v", err)
|
||||
}
|
||||
if restored.Status != model.RockStatusUsing {
|
||||
t.Fatalf("恢复后状态应保持 using,实际 %s", restored.Status)
|
||||
}
|
||||
|
||||
deletedRows, total, err := svc.ListDeletedRockTokens(ctx, 1, 20, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询回收站失败: %v", err)
|
||||
}
|
||||
if total != 0 || len(deletedRows) != 0 {
|
||||
t.Fatalf("回收站应已清空,实际 %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRockTokens_SkipsOrderConflict(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
old := createDeleteTestToken(t, svc, "97001", model.RockStatusBound)
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, true, 7); err != nil {
|
||||
t.Fatalf("强制删除失败: %v", err)
|
||||
}
|
||||
|
||||
// 订单还没有被别的 Token 占用时,恢复应当成功。
|
||||
result, err := svc.RestoreRockTokens(ctx, []uint{old.ID}, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("无冲突恢复失败: %v", err)
|
||||
}
|
||||
if result.Restored != 1 || result.Skipped != 0 {
|
||||
t.Fatalf("无冲突恢复结果不符: %+v", result)
|
||||
}
|
||||
|
||||
// 删除后同一订单被新 Token 接管,再恢复旧 Token 必须跳过。
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, true, 7); err != nil {
|
||||
t.Fatalf("再次删除失败: %v", err)
|
||||
}
|
||||
fresh := model.RockToken{
|
||||
UpstreamTokenID: "97002",
|
||||
Token: "tk-97002",
|
||||
RedeemCode: "rc-97002",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusBound,
|
||||
AssignedOrderNo: old.AssignedOrderNo,
|
||||
}
|
||||
if err := db.Create(&fresh).Error; err != nil {
|
||||
t.Fatalf("创建新 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
result, err = svc.RestoreRockTokens(ctx, []uint{old.ID}, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("冲突恢复应返回部分成功而不是错误: %v", err)
|
||||
}
|
||||
if result.Restored != 0 || result.Skipped != 1 {
|
||||
t.Fatalf("订单冲突应跳过: %+v", result)
|
||||
}
|
||||
if reasons := restoreSkipReasons(result.SkippedItems); reasons[old.ID] != RockRestoreSkipOrderConflict {
|
||||
t.Fatalf("跳过原因应为 order_conflict,实际 %q", reasons[old.ID])
|
||||
}
|
||||
|
||||
var stillDeleted model.RockToken
|
||||
if err := db.Unscoped().First(&stillDeleted, old.ID).Error; err != nil {
|
||||
t.Fatalf("读取旧记录失败: %v", err)
|
||||
}
|
||||
if !stillDeleted.DeletedAt.Valid {
|
||||
t.Fatal("订单冲突时不应恢复旧 Token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRockTokens_SkipsDuplicateUpstream(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
svc := NewRockService(db, NewRockClient("https://example.com"))
|
||||
ctx := context.Background()
|
||||
|
||||
old := createDeleteTestToken(t, svc, "98001", model.RockStatusAvailable)
|
||||
if _, err := svc.DeleteRockTokens(ctx, []uint{old.ID}, false, 7); err != nil {
|
||||
t.Fatalf("软删除失败: %v", err)
|
||||
}
|
||||
|
||||
// 部分唯一索引允许已删除记录与新记录共用同一个 upstream_token_id。
|
||||
fresh := model.RockToken{
|
||||
UpstreamTokenID: "98001",
|
||||
Token: "tk-98001-b",
|
||||
RedeemCode: "rc-98001-b",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusAvailable,
|
||||
}
|
||||
if err := db.Create(&fresh).Error; err != nil {
|
||||
t.Fatalf("创建同编号新 Token 失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.RestoreRockTokens(ctx, []uint{old.ID}, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("恢复失败: %v", err)
|
||||
}
|
||||
if result.Restored != 0 || result.Skipped != 1 {
|
||||
t.Fatalf("上游编号冲突应跳过: %+v", result)
|
||||
}
|
||||
if reasons := restoreSkipReasons(result.SkippedItems); reasons[old.ID] != RockRestoreSkipDuplicateUpstream {
|
||||
t.Fatalf("跳过原因应为 duplicate_upstream,实际 %q", reasons[old.ID])
|
||||
}
|
||||
}
|
||||
@@ -591,6 +591,7 @@ export interface RockStats {
|
||||
redeemed_stock: number;
|
||||
mismatch_stock: number;
|
||||
abnormal_stock: number;
|
||||
deleted_stock?: number;
|
||||
upstream_points: number;
|
||||
auto_delivery_enabled: boolean;
|
||||
token_warning_stock: number;
|
||||
@@ -658,6 +659,7 @@ export interface RockTokenItem {
|
||||
redeemed_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RockPurchaseResult {
|
||||
@@ -730,6 +732,47 @@ export interface ImportAllResult {
|
||||
results: PackageImportItem[];
|
||||
}
|
||||
|
||||
export interface RockTokenDeleteStatusCount {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RockTokenDeleteItem {
|
||||
id: number;
|
||||
status: string;
|
||||
assigned_order_no?: string;
|
||||
}
|
||||
|
||||
export interface RockTokenDeletePreview {
|
||||
total: number;
|
||||
dangerous: number;
|
||||
available: number;
|
||||
requires_force: boolean;
|
||||
status_counts: RockTokenDeleteStatusCount[];
|
||||
dangerous_items: RockTokenDeleteItem[];
|
||||
}
|
||||
|
||||
export interface RockTokenDeleteResult {
|
||||
requested: number;
|
||||
deleted: number;
|
||||
forced: boolean;
|
||||
status_counts: RockTokenDeleteStatusCount[];
|
||||
}
|
||||
|
||||
export interface RockTokenRestoreSkipItem {
|
||||
id: number;
|
||||
status?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RockTokenRestoreResult {
|
||||
requested: number;
|
||||
restored: number;
|
||||
skipped: number;
|
||||
status_counts: RockTokenDeleteStatusCount[];
|
||||
skipped_items: RockTokenRestoreSkipItem[];
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
@@ -788,6 +831,48 @@ export const rockApi = {
|
||||
page_size: number;
|
||||
},
|
||||
),
|
||||
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
||||
request
|
||||
.get("/platform/rock/token-batches/preview", { params })
|
||||
.then((r) => r.data.data as RockTokenDeletePreview),
|
||||
batchDeleteTokens: (data: {
|
||||
ids: number[];
|
||||
force?: boolean;
|
||||
force_confirm_text?: string;
|
||||
}) =>
|
||||
request
|
||||
.post("/platform/rock/token-batches/batch-delete", data)
|
||||
.then((r) => r.data.data as RockTokenDeleteResult),
|
||||
deleteAllTokens: (data: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
expected_count: number;
|
||||
force?: boolean;
|
||||
confirm_text: string;
|
||||
force_confirm_text?: string;
|
||||
}) =>
|
||||
request
|
||||
.post("/platform/rock/token-batches/delete-all", data)
|
||||
.then((r) => r.data.data as RockTokenDeleteResult),
|
||||
listDeletedTokens: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}) =>
|
||||
request.get("/platform/rock/token-batches/deleted", { params }).then(
|
||||
(r) =>
|
||||
r.data.data as {
|
||||
list: RockTokenItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
},
|
||||
),
|
||||
restoreTokens: (data: { ids: number[] }) =>
|
||||
request
|
||||
.post("/platform/rock/token-batches/restore", data)
|
||||
.then((r) => r.data.data as RockTokenRestoreResult),
|
||||
correctExpectedUid: (id: number, expectedUid: string) =>
|
||||
request
|
||||
.post(`/platform/rock/tokens/${id}/expected-uid`, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, Button, Form, Modal, Space, message } from 'antd'
|
||||
import { HistoryOutlined, ImportOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api'
|
||||
import { rockApi, type RockConfig, type RockStats, type RockTokenDeleteResult, type RockTokenItem } from '../api'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { RockMetricCards } from './platformRock/RockMetricCards'
|
||||
import { RockStrategyCard } from './platformRock/RockStrategyCard'
|
||||
@@ -10,6 +10,8 @@ import { RockTokenTable } from './platformRock/RockTokenTable'
|
||||
import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal'
|
||||
import { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer'
|
||||
import { ImportAllPackagesModal } from './platformRock/ImportAllPackagesModal'
|
||||
import { TokenDeleteModal, type TokenDeleteMode } from './platformRock/TokenDeleteModal'
|
||||
import { TokenRecycleBinDrawer } from './platformRock/TokenRecycleBinDrawer'
|
||||
import { UidEditModal } from './platformRock/UidEditModal'
|
||||
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
|
||||
import type { UidEditState } from './platformRock/types'
|
||||
@@ -38,6 +40,10 @@ export default function PlatformRock() {
|
||||
const [purchaseLogOpen, setPurchaseLogOpen] = useState(false)
|
||||
const [importPackageOpen, setImportPackageOpen] = useState(false)
|
||||
const [rawDataRecord, setRawDataRecord] = useState<RockTokenItem | null>(null)
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([])
|
||||
const [selectedTokens, setSelectedTokens] = useState<RockTokenItem[]>([])
|
||||
const [tokenDeleteMode, setTokenDeleteMode] = useState<TokenDeleteMode | null>(null)
|
||||
const [recycleOpen, setRecycleOpen] = useState(false)
|
||||
|
||||
// 凭证图片操作状态
|
||||
const [copyingVoucherId, setCopyingVoucherId] = useState<number | null>(null)
|
||||
@@ -115,6 +121,11 @@ export default function PlatformRock() {
|
||||
loadTokens()
|
||||
}, [loadTokens])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTokenIds([])
|
||||
setSelectedTokens([])
|
||||
}, [filterStatus, searchKeyword])
|
||||
|
||||
// 一键切换自动发货总开关 (Kill Switch)
|
||||
const handleToggleAutoDelivery = async (checked: boolean) => {
|
||||
Modal.confirm({
|
||||
@@ -257,10 +268,24 @@ export default function PlatformRock() {
|
||||
}
|
||||
|
||||
const handlePageChange = (nextPage: number, nextPageSize: number) => {
|
||||
setSelectedTokenIds([])
|
||||
setSelectedTokens([])
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
}
|
||||
|
||||
const handleTokenDeleted = (result: RockTokenDeleteResult) => {
|
||||
setTokenDeleteMode(null)
|
||||
setSelectedTokenIds([])
|
||||
setSelectedTokens([])
|
||||
setPage(1)
|
||||
message.success(
|
||||
`已软删除 ${result.deleted} 条 Token${result.forced ? '(含强制确认的进行中 Token)' : ''}`,
|
||||
)
|
||||
loadData()
|
||||
loadTokens()
|
||||
}
|
||||
|
||||
const handleUidValueChange = (value: string) => {
|
||||
setUidEdit((current) => (current ? { ...current, value } : current))
|
||||
}
|
||||
@@ -372,6 +397,15 @@ export default function PlatformRock() {
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalTokens={totalTokens}
|
||||
selectedTokenIds={selectedTokenIds}
|
||||
onSelectedTokenIdsChange={(ids, rows) => {
|
||||
setSelectedTokenIds(ids)
|
||||
setSelectedTokens(rows)
|
||||
}}
|
||||
onBatchDelete={() => setTokenDeleteMode('batch')}
|
||||
onDeleteAll={() => setTokenDeleteMode('all')}
|
||||
deletedCount={stats?.deleted_stock ?? 0}
|
||||
onOpenRecycleBin={() => setRecycleOpen(true)}
|
||||
copyingVoucherId={copyingVoucherId}
|
||||
setCopyingVoucherId={setCopyingVoucherId}
|
||||
setPreviewVoucherUrl={setPreviewVoucherUrl}
|
||||
@@ -451,6 +485,26 @@ export default function PlatformRock() {
|
||||
loadTokens()
|
||||
}}
|
||||
/>
|
||||
|
||||
<TokenDeleteModal
|
||||
open={tokenDeleteMode !== null}
|
||||
mode={tokenDeleteMode ?? 'batch'}
|
||||
selectedTokens={selectedTokens}
|
||||
selectedTokenIds={selectedTokenIds}
|
||||
filterStatus={filterStatus}
|
||||
searchKeyword={searchKeyword}
|
||||
onCancel={() => setTokenDeleteMode(null)}
|
||||
onDeleted={handleTokenDeleted}
|
||||
/>
|
||||
|
||||
<TokenRecycleBinDrawer
|
||||
open={recycleOpen}
|
||||
onClose={() => setRecycleOpen(false)}
|
||||
onRestored={() => {
|
||||
loadData()
|
||||
loadTokens()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Table, message } from 'antd'
|
||||
import { Button, Space, Table, Typography, message } from 'antd'
|
||||
import { DeleteOutlined, InboxOutlined } from '@ant-design/icons'
|
||||
import type { RockTokenItem } from '../../api'
|
||||
import { rockApi } from '../../api'
|
||||
import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../../utils/voucher'
|
||||
@@ -8,6 +9,8 @@ import { formatDateTime } from '../../utils/time'
|
||||
import { buildTokenColumns } from './tokenColumns'
|
||||
import type { UidEditState } from './types'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface RockTokenTableProps {
|
||||
tokens: RockTokenItem[]
|
||||
loadingTokens: boolean
|
||||
@@ -16,6 +19,13 @@ export interface RockTokenTableProps {
|
||||
totalTokens: number
|
||||
usingWarnDays: number
|
||||
usingDangerDays: number
|
||||
selectedTokenIds: number[]
|
||||
onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void
|
||||
onBatchDelete: () => void
|
||||
onDeleteAll: () => void
|
||||
deletedCount: number
|
||||
onOpenRecycleBin: () => void
|
||||
bulkDeleting?: boolean
|
||||
copyingVoucherId: number | null
|
||||
setCopyingVoucherId: Dispatch<SetStateAction<number | null>>
|
||||
setPreviewVoucherUrl: Dispatch<SetStateAction<string>>
|
||||
@@ -35,6 +45,13 @@ export function RockTokenTable({
|
||||
totalTokens,
|
||||
usingWarnDays,
|
||||
usingDangerDays,
|
||||
selectedTokenIds,
|
||||
onSelectedTokenIdsChange,
|
||||
onBatchDelete,
|
||||
onDeleteAll,
|
||||
deletedCount,
|
||||
onOpenRecycleBin,
|
||||
bulkDeleting,
|
||||
copyingVoucherId,
|
||||
setCopyingVoucherId,
|
||||
setPreviewVoucherUrl,
|
||||
@@ -220,23 +237,65 @@ export function RockTokenTable({
|
||||
})
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={tokens}
|
||||
loading={loadingTokens}
|
||||
scroll={{ x: 1700 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: totalTokens,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total) => `共 ${total} 条 Token 记录`,
|
||||
onChange: (p, ps) => {
|
||||
onPageChange(p, ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Text type="secondary">已选 {selectedTokenIds.length} 条</Text>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!selectedTokenIds.length || Boolean(bulkDeleting)}
|
||||
onClick={onBatchDelete}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
disabled={Boolean(bulkDeleting) || totalTokens === 0}
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
按当前筛选全部删除
|
||||
</Button>
|
||||
<Button icon={<InboxOutlined />} onClick={onOpenRecycleBin}>
|
||||
已删除{deletedCount > 0 ? `(${deletedCount})` : ''}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={tokens}
|
||||
loading={loadingTokens}
|
||||
scroll={{ x: 1700 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedTokenIds,
|
||||
preserveSelectedRowKeys: true,
|
||||
onChange: (keys, rows) => {
|
||||
onSelectedTokenIdsChange(keys.map((key) => Number(key)), rows)
|
||||
},
|
||||
getCheckboxProps: () => ({ disabled: Boolean(bulkDeleting) }),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: totalTokens,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total) => `共 ${total} 条 Token 记录`,
|
||||
onChange: (p, ps) => {
|
||||
onPageChange(p, ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Checkbox, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
rockApi,
|
||||
type RockTokenDeletePreview,
|
||||
type RockTokenDeleteResult,
|
||||
type RockTokenDeleteStatusCount,
|
||||
type RockTokenItem,
|
||||
} from '../../api'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export type TokenDeleteMode = 'batch' | 'all'
|
||||
|
||||
export interface TokenDeleteModalProps {
|
||||
open: boolean
|
||||
mode: TokenDeleteMode
|
||||
selectedTokens: RockTokenItem[]
|
||||
selectedTokenIds: number[]
|
||||
filterStatus: string
|
||||
searchKeyword: string
|
||||
onCancel: () => void
|
||||
onDeleted: (result: RockTokenDeleteResult) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
}
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return STATUS_LABELS[status] || status || '-'
|
||||
}
|
||||
|
||||
function statusTagColor(status: string) {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success'
|
||||
case 'using':
|
||||
return 'processing'
|
||||
case 'bound':
|
||||
return 'cyan'
|
||||
case 'redeemed':
|
||||
return 'purple'
|
||||
case 'mismatch':
|
||||
return 'error'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function countSelectedByStatus(tokens: RockTokenItem[]) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const token of tokens) {
|
||||
counts.set(token.status, (counts.get(token.status) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries()).map(([status, count]) => ({ status, count }))
|
||||
}
|
||||
|
||||
function renderStatusCounts(counts: RockTokenDeleteStatusCount[]) {
|
||||
if (!counts.length) return <Text type="secondary">无</Text>
|
||||
return (
|
||||
<Space size={[6, 6]} wrap>
|
||||
{counts.map((item) => (
|
||||
<Tag key={item.status} color={statusTagColor(item.status)}>
|
||||
{statusLabel(item.status)} {item.count}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
export function TokenDeleteModal({
|
||||
open,
|
||||
mode,
|
||||
selectedTokens,
|
||||
selectedTokenIds,
|
||||
filterStatus,
|
||||
searchKeyword,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
}: TokenDeleteModalProps) {
|
||||
const [preview, setPreview] = useState<RockTokenDeletePreview | null>(null)
|
||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [allConfirmText, setAllConfirmText] = useState('')
|
||||
const [forceConfirmText, setForceConfirmText] = useState('')
|
||||
|
||||
const selectedStatusCounts = useMemo(
|
||||
() => countSelectedByStatus(selectedTokens),
|
||||
[selectedTokens],
|
||||
)
|
||||
const selectedDangerous = useMemo(
|
||||
() => selectedTokens.filter((token) => DANGEROUS_STATUSES.has(token.status)).length,
|
||||
[selectedTokens],
|
||||
)
|
||||
const selectedAvailable = useMemo(
|
||||
() => selectedTokens.filter((token) => token.status === 'available').length,
|
||||
[selectedTokens],
|
||||
)
|
||||
|
||||
const total = mode === 'batch' ? selectedTokenIds.length : preview?.total ?? 0
|
||||
const dangerous = mode === 'batch' ? selectedDangerous : preview?.dangerous ?? 0
|
||||
const available = mode === 'batch' ? selectedAvailable : preview?.available ?? 0
|
||||
const statusCounts = mode === 'batch' ? selectedStatusCounts : preview?.status_counts ?? []
|
||||
const requiresForce = dangerous > 0
|
||||
const filterLabel =
|
||||
filterStatus && filterStatus !== 'all' ? statusLabel(filterStatus) : '全部状态'
|
||||
const canSubmit =
|
||||
total > 0 &&
|
||||
acknowledged &&
|
||||
!submitting &&
|
||||
(!requiresForce || forceConfirmText.trim() === '强制删除') &&
|
||||
(mode === 'batch' || allConfirmText.trim() === '全部删除')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAcknowledged(false)
|
||||
setAllConfirmText('')
|
||||
setForceConfirmText('')
|
||||
setError('')
|
||||
if (mode === 'all') {
|
||||
setLoadingPreview(true)
|
||||
setPreview(null)
|
||||
rockApi
|
||||
.previewTokenDelete({
|
||||
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
|
||||
search: searchKeyword || undefined,
|
||||
})
|
||||
.then((data) => setPreview(data))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '预览失败'))
|
||||
.finally(() => setLoadingPreview(false))
|
||||
}
|
||||
}, [open, mode, filterStatus, searchKeyword])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const result =
|
||||
mode === 'batch'
|
||||
? await rockApi.batchDeleteTokens({
|
||||
ids: selectedTokenIds,
|
||||
force: requiresForce,
|
||||
force_confirm_text: requiresForce ? forceConfirmText.trim() : undefined,
|
||||
})
|
||||
: await rockApi.deleteAllTokens({
|
||||
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
|
||||
search: searchKeyword || undefined,
|
||||
expected_count: preview?.total ?? 0,
|
||||
force: requiresForce,
|
||||
confirm_text: allConfirmText.trim(),
|
||||
force_confirm_text: requiresForce ? forceConfirmText.trim() : undefined,
|
||||
})
|
||||
onDeleted(result)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '删除失败')
|
||||
if (mode === 'all') {
|
||||
rockApi
|
||||
.previewTokenDelete({
|
||||
status: filterStatus && filterStatus !== 'all' ? filterStatus : undefined,
|
||||
search: searchKeyword || undefined,
|
||||
})
|
||||
.then((data) => setPreview(data))
|
||||
.catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === 'batch' ? '批量软删除 Token' : '按当前筛选全部软删除'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onCancel={submitting ? undefined : onCancel}
|
||||
okText={submitting ? '删除中…' : '确认软删除'}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, disabled: !canSubmit, loading: submitting }}
|
||||
onOk={handleSubmit}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="软删除只影响本地列表、统计与库存分配"
|
||||
description="不会删除或退回 0666.pro 上游 Token,数据仍保留在数据库;删除后全量导入不会把已删除的 Token 重新拉回。"
|
||||
/>
|
||||
|
||||
{mode === 'all' && (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
删除范围:{filterLabel}
|
||||
{searchKeyword ? ` · 搜索「${searchKeyword}」` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingPreview ? (
|
||||
<Text type="secondary">正在统计当前筛选结果…</Text>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text strong>共 {total} 条</Text>
|
||||
{available > 0 && (
|
||||
<Text type="secondary"> · 其中可用库存 {available} 条</Text>
|
||||
)}
|
||||
{dangerous > 0 && (
|
||||
<Text type="danger"> · 使用中/已绑定/绑定错误 {dangerous} 条</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">状态分布:</Text>
|
||||
{renderStatusCounts(statusCounts)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{available > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`将删除 ${available} 条可用库存`}
|
||||
description="本地可用库存会立即减少;当前开启了自动补货,后续触发库存检查时可能自动补货,把库存补回来。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{dangerous > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={`包含 ${dangerous} 条进行中的 Token`}
|
||||
description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert type="error" showIcon message={error} />}
|
||||
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
>
|
||||
我已确认上述影响,同意软删除
|
||||
</Checkbox>
|
||||
|
||||
{mode === 'all' && (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
请输入 <Text code>全部删除</Text> 确认按当前筛选删除:
|
||||
</Text>
|
||||
<Input
|
||||
value={allConfirmText}
|
||||
onChange={(e) => setAllConfirmText(e.target.value)}
|
||||
placeholder="全部删除"
|
||||
style={{ marginTop: 6 }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresForce && (
|
||||
<div>
|
||||
<Text type="danger">
|
||||
本次包含进行中的 Token,请再输入 <Text code>强制删除</Text> 确认:
|
||||
</Text>
|
||||
<Input
|
||||
value={forceConfirmText}
|
||||
onChange={(e) => setForceConfirmText(e.target.value)}
|
||||
placeholder="强制删除"
|
||||
style={{ marginTop: 6 }}
|
||||
autoComplete="off"
|
||||
status="error"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { UndoOutlined } from '@ant-design/icons'
|
||||
import { rockApi, type RockTokenItem, type RockTokenRestoreResult } from '../../api'
|
||||
import { formatDateTime } from '../../utils/time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface TokenRecycleBinDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onRestored: (result: RockTokenRestoreResult) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '使用中',
|
||||
bound: '已绑定',
|
||||
redeemed: '已发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'available', label: '可用库存' },
|
||||
{ value: 'using', label: '使用中' },
|
||||
{ value: 'bound', label: '已绑定' },
|
||||
{ value: 'redeemed', label: '已发货' },
|
||||
{ value: 'mismatch', label: '绑定错误' },
|
||||
{ value: 'abnormal', label: '已上报失效' },
|
||||
]
|
||||
|
||||
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
|
||||
|
||||
const SKIP_REASONS: Record<string, string> = {
|
||||
not_deleted: '已不在回收站',
|
||||
duplicate_upstream: '上游编号已有在用 Token',
|
||||
order_conflict: '同一订单已有在用 Token',
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return STATUS_LABELS[status] || status || '-'
|
||||
}
|
||||
|
||||
function statusTagColor(status: string) {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success'
|
||||
case 'using':
|
||||
return 'processing'
|
||||
case 'bound':
|
||||
return 'cyan'
|
||||
case 'redeemed':
|
||||
return 'purple'
|
||||
case 'mismatch':
|
||||
return 'error'
|
||||
case 'abnormal':
|
||||
return 'default'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function countByStatus(rows: RockTokenItem[]) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
counts.set(row.status, (counts.get(row.status) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries()).map(([status, count]) => ({ status, count }))
|
||||
}
|
||||
|
||||
function errorText(e: unknown, fallback: string) {
|
||||
return e instanceof Error ? e.message : fallback
|
||||
}
|
||||
|
||||
export function TokenRecycleBinDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onRestored,
|
||||
}: TokenRecycleBinDrawerProps) {
|
||||
const [rows, setRows] = useState<RockTokenItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [status, setStatus] = useState('all')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [selectedRows, setSelectedRows] = useState<RockTokenItem[]>([])
|
||||
const [confirmRows, setConfirmRows] = useState<RockTokenItem[] | null>(null)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [lastResult, setLastResult] = useState<RockTokenRestoreResult | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await rockApi.listDeletedTokens({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: status === 'all' ? undefined : status,
|
||||
search: searchKeyword || undefined,
|
||||
})
|
||||
setRows(res.list || [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (e) {
|
||||
message.error('加载回收站失败: ' + errorText(e, '未知错误'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, status, searchKeyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
load()
|
||||
}, [open, load])
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIds([])
|
||||
setSelectedRows([])
|
||||
setConfirmRows(null)
|
||||
setAcknowledged(false)
|
||||
setLastResult(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const openConfirm = (target: RockTokenItem[]) => {
|
||||
if (!target.length) return
|
||||
setAcknowledged(false)
|
||||
setConfirmRows(target)
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!confirmRows || !acknowledged || restoring) return
|
||||
setRestoring(true)
|
||||
try {
|
||||
const result = await rockApi.restoreTokens({ ids: confirmRows.map((row) => row.id) })
|
||||
if (result.skipped > 0) {
|
||||
message.warning(`已恢复 ${result.restored} 条,跳过 ${result.skipped} 条`)
|
||||
} else {
|
||||
message.success(`已恢复 ${result.restored} 条 Token`)
|
||||
}
|
||||
setLastResult(result.skipped > 0 ? result : null)
|
||||
setConfirmRows(null)
|
||||
setAcknowledged(false)
|
||||
setSelectedIds([])
|
||||
setSelectedRows([])
|
||||
onRestored(result)
|
||||
// 当前页被恢复空时回退一页,否则原地刷新。
|
||||
if (page > 1 && result.restored >= rows.length) {
|
||||
setPage(page - 1)
|
||||
} else {
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('恢复失败: ' + errorText(e, '未知错误'))
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStatusCounts = countByStatus(confirmRows || [])
|
||||
const confirmDangerous = (confirmRows || []).filter((row) =>
|
||||
DANGEROUS_STATUSES.has(row.status),
|
||||
).length
|
||||
|
||||
const columns: ColumnsType<RockTokenItem> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: '0666 编号',
|
||||
dataIndex: 'upstream_token_id',
|
||||
width: 180,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (value: string) => (
|
||||
<Tag color={statusTagColor(value)}>{statusLabel(value)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'assigned_order_no',
|
||||
width: 200,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'actual_role_name',
|
||||
width: 140,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '删除时间',
|
||||
dataIndex: 'deleted_at',
|
||||
width: 170,
|
||||
render: (value?: string | null) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 96,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={restoring}
|
||||
onClick={() => openConfirm([record])}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
width={1100}
|
||||
title="回收站 · 已删除 Token"
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="恢复说明"
|
||||
description="恢复会把 Token 放回列表与统计,状态保持删除前的值;若同一上游编号已在用、或同一订单已有在用 Token,该条会被自动跳过,避免产生重复数据。"
|
||||
/>
|
||||
|
||||
{lastResult && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setLastResult(null)}
|
||||
message={`已恢复 ${lastResult.restored} 条,跳过 ${lastResult.skipped} 条`}
|
||||
description={
|
||||
<Space direction="vertical" size={2}>
|
||||
{lastResult.skipped_items.map((item) => (
|
||||
<Text key={item.id} type="secondary">
|
||||
#{item.id} {SKIP_REASONS[item.reason] || item.reason}
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索 ID / 0666 编号 / 订单号 / UID"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearch={(value) => {
|
||||
setSearchKeyword(value.trim())
|
||||
setPage(1)
|
||||
setSelectedIds([])
|
||||
setSelectedRows([])
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
setSelectedIds([])
|
||||
setSelectedRows([])
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">共 {total} 条已删除记录</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={!selectedIds.length || restoring}
|
||||
onClick={() => openConfirm(selectedRows)}
|
||||
>
|
||||
批量恢复{selectedIds.length ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys, selected) => {
|
||||
setSelectedIds(keys.map((key) => Number(key)))
|
||||
setSelectedRows(selected)
|
||||
},
|
||||
getCheckboxProps: () => ({ disabled: restoring }),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (value) => `回收站共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSelectedIds([])
|
||||
setSelectedRows([])
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={Boolean(confirmRows?.length)}
|
||||
title={`恢复 ${confirmRows?.length ?? 0} 条 Token`}
|
||||
onCancel={() => {
|
||||
if (restoring) return
|
||||
setConfirmRows(null)
|
||||
setAcknowledged(false)
|
||||
}}
|
||||
okText={restoring ? '恢复中…' : '确认恢复'}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !acknowledged, loading: restoring }}
|
||||
onOk={handleRestore}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text type="secondary">状态分布:</Text>
|
||||
<Space size={[6, 6]} wrap style={{ marginTop: 6 }}>
|
||||
{confirmStatusCounts.map((item) => (
|
||||
<Tag key={item.status} color={statusTagColor(item.status)}>
|
||||
{statusLabel(item.status)} {item.count}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{confirmDangerous > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`包含 ${confirmDangerous} 条使用中/已绑定/绑定错误的 Token`}
|
||||
description="恢复后会重新出现在进行中统计里,并可能重新关联原订单;请确认对应订单已处理或已人工接管。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text type="secondary">
|
||||
同一上游编号或同一订单已被其它 Token 占用时,该条会自动跳过,不会产生重复数据。
|
||||
</Text>
|
||||
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
>
|
||||
我已确认恢复后的状态与影响
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user