From 909867b9fb7747142231095c9c4b38b702fb7afb Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 31 Oct 2026 16:48:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:rock=E5=A2=9E=E5=8A=A0=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=92=8C=E6=81=A2=E5=A4=8D=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/020_rock_token_soft_delete.sql | 11 + backend/internal/handler/rock.go | 127 ++++++ backend/internal/model/rock.go | 68 +-- backend/internal/router/router.go | 5 + backend/internal/service/delivery_query.go | 2 +- backend/internal/service/provider_rock.go | 10 +- backend/internal/service/rock_assign.go | 8 +- backend/internal/service/rock_bindcheck.go | 8 +- backend/internal/service/rock_bindlink.go | 2 +- backend/internal/service/rock_package.go | 7 +- .../internal/service/rock_package_import.go | 2 +- backend/internal/service/rock_redeem.go | 6 +- backend/internal/service/rock_service.go | 9 + backend/internal/service/rock_stock.go | 12 +- backend/internal/service/rock_token_delete.go | 303 ++++++++++++++ .../service/rock_token_delete_test.go | 243 +++++++++++ .../internal/service/rock_token_restore.go | 226 ++++++++++ .../service/rock_token_restore_test.go | 221 ++++++++++ frontend/src/api/index.ts | 85 ++++ frontend/src/pages/PlatformRock.tsx | 56 ++- .../src/pages/platformRock/RockTokenTable.tsx | 97 ++++- .../pages/platformRock/TokenDeleteModal.tsx | 294 +++++++++++++ .../platformRock/TokenRecycleBinDrawer.tsx | 396 ++++++++++++++++++ 23 files changed, 2116 insertions(+), 82 deletions(-) create mode 100644 backend/internal/database/migrations/020_rock_token_soft_delete.sql create mode 100644 backend/internal/service/rock_token_delete.go create mode 100644 backend/internal/service/rock_token_delete_test.go create mode 100644 backend/internal/service/rock_token_restore.go create mode 100644 backend/internal/service/rock_token_restore_test.go create mode 100644 frontend/src/pages/platformRock/TokenDeleteModal.tsx create mode 100644 frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx diff --git a/backend/internal/database/migrations/020_rock_token_soft_delete.sql b/backend/internal/database/migrations/020_rock_token_soft_delete.sql new file mode 100644 index 0000000..0b9690e --- /dev/null +++ b/backend/internal/database/migrations/020_rock_token_soft_delete.sql @@ -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; diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index 1cd72b7..d4b0741 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -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")) diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index c52513c..9b8721b 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -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 { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 76e96bb..8a6e0fc 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } diff --git a/backend/internal/service/delivery_query.go b/backend/internal/service/delivery_query.go index f9a9d87..718eeb6 100644 --- a/backend/internal/service/delivery_query.go +++ b/backend/internal/service/delivery_query.go @@ -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 diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index 043cb86..ea79db9 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -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": "", diff --git a/backend/internal/service/rock_assign.go b/backend/internal/service/rock_assign.go index 1c3d344..1cb3a17 100644 --- a/backend/internal/service/rock_assign.go +++ b/backend/internal/service/rock_assign.go @@ -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 } } diff --git a/backend/internal/service/rock_bindcheck.go b/backend/internal/service/rock_bindcheck.go index 03c0fa9..1afdfd4 100644 --- a/backend/internal/service/rock_bindcheck.go +++ b/backend/internal/service/rock_bindcheck.go @@ -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, diff --git a/backend/internal/service/rock_bindlink.go b/backend/internal/service/rock_bindlink.go index ba09e67..bd79574 100644 --- a/backend/internal/service/rock_bindlink.go +++ b/backend/internal/service/rock_bindlink.go @@ -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) == "" { diff --git a/backend/internal/service/rock_package.go b/backend/internal/service/rock_package.go index f7a6363..9271ae6 100644 --- a/backend/internal/service/rock_package.go +++ b/backend/internal/service/rock_package.go @@ -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, diff --git a/backend/internal/service/rock_package_import.go b/backend/internal/service/rock_package_import.go index e191a56..4d6cdd3 100644 --- a/backend/internal/service/rock_package_import.go +++ b/backend/internal/service/rock_package_import.go @@ -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 { diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go index 8572d3f..ea23878 100644 --- a/backend/internal/service/rock_redeem.go +++ b/backend/internal/service/rock_redeem.go @@ -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, diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index aff9a8e..dcde463 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -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 } diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index be1e87b..ac894b5 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -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 { diff --git a/backend/internal/service/rock_token_delete.go b/backend/internal/service/rock_token_delete.go new file mode 100644 index 0000000..a925139 --- /dev/null +++ b/backend/internal/service/rock_token_delete.go @@ -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 +} diff --git a/backend/internal/service/rock_token_delete_test.go b/backend/internal/service/rock_token_delete_test.go new file mode 100644 index 0000000..9d14569 --- /dev/null +++ b/backend/internal/service/rock_token_delete_test.go @@ -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) + } +} diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go new file mode 100644 index 0000000..6ead60f --- /dev/null +++ b/backend/internal/service/rock_token_restore.go @@ -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 +} diff --git a/backend/internal/service/rock_token_restore_test.go b/backend/internal/service/rock_token_restore_test.go new file mode 100644 index 0000000..14eb845 --- /dev/null +++ b/backend/internal/service/rock_token_restore_test.go @@ -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]) + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 92fbd92..e6c1a11 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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`, { diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index ac9bba1..c771c39 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -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(null) + const [selectedTokenIds, setSelectedTokenIds] = useState([]) + const [selectedTokens, setSelectedTokens] = useState([]) + const [tokenDeleteMode, setTokenDeleteMode] = useState(null) + const [recycleOpen, setRecycleOpen] = useState(false) // 凭证图片操作状态 const [copyingVoucherId, setCopyingVoucherId] = useState(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() }} /> + + setTokenDeleteMode(null)} + onDeleted={handleTokenDeleted} + /> + + setRecycleOpen(false)} + onRestored={() => { + loadData() + loadTokens() + }} + /> ) } diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index ed49443..b7557ba 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -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> setPreviewVoucherUrl: Dispatch> @@ -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 ( - `共 ${total} 条 Token 记录`, - onChange: (p, ps) => { - onPageChange(p, ps) - }, - }} - /> +
+
+ + 已选 {selectedTokenIds.length} 条 + + + + +
+
{ + 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) + }, + }} + /> + ) } diff --git a/frontend/src/pages/platformRock/TokenDeleteModal.tsx b/frontend/src/pages/platformRock/TokenDeleteModal.tsx new file mode 100644 index 0000000..f8855ed --- /dev/null +++ b/frontend/src/pages/platformRock/TokenDeleteModal.tsx @@ -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 = { + 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() + 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 无 + return ( + + {counts.map((item) => ( + + {statusLabel(item.status)} {item.count} + + ))} + + ) +} + +export function TokenDeleteModal({ + open, + mode, + selectedTokens, + selectedTokenIds, + filterStatus, + searchKeyword, + onCancel, + onDeleted, +}: TokenDeleteModalProps) { + const [preview, setPreview] = useState(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 ( + + + + + {mode === 'all' && ( +
+ + 删除范围:{filterLabel} + {searchKeyword ? ` · 搜索「${searchKeyword}」` : ''} + +
+ )} + + {loadingPreview ? ( + 正在统计当前筛选结果… + ) : ( + <> +
+ 共 {total} 条 + {available > 0 && ( + · 其中可用库存 {available} 条 + )} + {dangerous > 0 && ( + · 使用中/已绑定/绑定错误 {dangerous} 条 + )} +
+
+ 状态分布: + {renderStatusCounts(statusCounts)} +
+ + )} + + {available > 0 && ( + + )} + + {dangerous > 0 && ( + } + message={`包含 ${dangerous} 条进行中的 Token`} + description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。" + /> + )} + + {error && } + + setAcknowledged(e.target.checked)} + > + 我已确认上述影响,同意软删除 + + + {mode === 'all' && ( +
+ + 请输入 全部删除 确认按当前筛选删除: + + setAllConfirmText(e.target.value)} + placeholder="全部删除" + style={{ marginTop: 6 }} + autoComplete="off" + /> +
+ )} + + {requiresForce && ( +
+ + 本次包含进行中的 Token,请再输入 强制删除 确认: + + setForceConfirmText(e.target.value)} + placeholder="强制删除" + style={{ marginTop: 6 }} + autoComplete="off" + status="error" + /> +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx new file mode 100644 index 0000000..44d51d5 --- /dev/null +++ b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx @@ -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 = { + 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 = { + 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() + 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([]) + 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([]) + const [selectedRows, setSelectedRows] = useState([]) + const [confirmRows, setConfirmRows] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) + const [restoring, setRestoring] = useState(false) + const [lastResult, setLastResult] = useState(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 = [ + { 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) => ( + {statusLabel(value)} + ), + }, + { + 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) => ( + + ), + }, + ] + + return ( + <> + + + + + {lastResult && ( + setLastResult(null)} + message={`已恢复 ${lastResult.restored} 条,跳过 ${lastResult.skipped} 条`} + description={ + + {lastResult.skipped_items.map((item) => ( + + #{item.id} {SKIP_REASONS[item.reason] || item.reason} + + ))} + + } + /> + )} + + + setSearchInput(e.target.value)} + onSearch={(value) => { + setSearchKeyword(value.trim()) + setPage(1) + setSelectedIds([]) + setSelectedRows([]) + }} + allowClear + style={{ width: 320 }} + /> +
{ + 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) + }, + }} + /> + + + + { + if (restoring) return + setConfirmRows(null) + setAcknowledged(false) + }} + okText={restoring ? '恢复中…' : '确认恢复'} + cancelText="取消" + okButtonProps={{ disabled: !acknowledged, loading: restoring }} + onOk={handleRestore} + width={560} + destroyOnHidden + > + +
+ 状态分布: + + {confirmStatusCounts.map((item) => ( + + {statusLabel(item.status)} {item.count} + + ))} + +
+ + {confirmDangerous > 0 && ( + + )} + + + 同一上游编号或同一订单已被其它 Token 占用时,该条会自动跳过,不会产生重复数据。 + + + setAcknowledged(e.target.checked)} + > + 我已确认恢复后的状态与影响 + +
+
+ + ) +}