feat:rock增加软删除和恢复删除

This commit is contained in:
2026-10-31 16:48:47 +08:00
parent 2d1ff1b520
commit 909867b9fb
23 changed files with 2116 additions and 82 deletions
@@ -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;
+127
View File
@@ -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"))
+36 -32
View File
@@ -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 {
+5
View File
@@ -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)
}
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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": "",
+4 -4
View File
@@ -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
}
}
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -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) == "" {
+6 -1
View File
@@ -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 {
+3 -3
View File
@@ -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,
+9
View File
@@ -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
}
+2 -10
View File
@@ -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])
}
}
+85
View File
@@ -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`, {
+55 -1
View File
@@ -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>
</>
)
}