// admin_claims.go 实现 Stage 2 后台工单接口: // // GET /v1/admin/lottery/claims — 分页列表 // POST /v1/admin/lottery/claims/approve — reviewing → paying // POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected // POST /v1/admin/lottery/claims/mark-paid — paying → paid // GET /v1/admin/lottery/claims/summary — 工作台状态计数 // // 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验, // RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。 package lottery import ( "context" "encoding/json" "strings" "time" "github.com/perfect-panel/server/internal/logic/audit" modelLottery "github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/gorm" ) // audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。 const ( ActionLotteryClaimApprove = "lottery.claim.approve" ActionLotteryClaimReject = "lottery.claim.reject" ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid" ) // ---- ListLotteryClaims ---------------------------------------------------- type ListLotteryClaimsLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic { return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } // ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。 // user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。 func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) { if currentAdminId(l.ctx) == 0 { return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) } page, size := req.Page, req.Size if page <= 0 { page = 1 } if size <= 0 || size > 200 { size = 20 } db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{}) if t := strings.TrimSpace(req.Type); t != "" { db = db.Where("prize_type = ?", t) } if s := strings.TrimSpace(req.Status); s != "" { db = db.Where("status = ?", s) } if req.ActivityId > 0 { db = db.Where("activity_id = ?", req.ActivityId) } if req.UserId > 0 { db = db.Where("user_id = ?", req.UserId) } if req.From > 0 { db = db.Where("created_at >= ?", time.Unix(req.From, 0)) } if req.To > 0 { db = db.Where("created_at < ?", time.Unix(req.To, 0)) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } var rows []modelLottery.Claim if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } // 附加:一次性拉快照 + 用户信息,避免 N+1。 drawIds := make([]int64, 0, len(rows)) userIds := make([]int64, 0, len(rows)) for _, c := range rows { drawIds = append(drawIds, c.DrawId) userIds = append(userIds, c.UserId) } snaps, err := l.loadSnapshots(drawIds) if err != nil { return nil, err } users, err := l.loadUsers(userIds) if err != nil { return nil, err } resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))} for _, c := range rows { resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId])) } return resp, nil } func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) { if len(drawIds) == 0 { return nil, nil } var snaps []modelLottery.PrizeSnapshot if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps)) for _, s := range snaps { out[s.DrawId] = s } return out, nil } func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) { if len(ids) == 0 { return nil, nil } // email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录, // 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。 type row struct { UserId int64 Email string } var rows []row if err := l.svcCtx.DB.WithContext(l.ctx). Table("user_auth_methods"). Select("user_id AS user_id, auth_identifier AS email"). Where("auth_type = ? AND user_id IN ?", "email", ids). Order("created_at ASC"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } out := make(map[int64]string, len(rows)) for _, r := range rows { if _, exists := out[r.UserId]; exists { continue } out[r.UserId] = r.Email } return out, nil } // ---- ApproveLotteryClaim -------------------------------------------------- type ApproveLotteryClaimLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic { return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } // ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。 func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error { actor := currentAdminId(l.ctx) if actor == 0 { return xerr.NewErrCode(xerr.ErrorTokenInvalid) } now := time.Now() body, _ := json.Marshal(req) return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { res := tx.Model(&modelLottery.Claim{}). Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing). Updates(map[string]any{ "status": modelLottery.ClaimStatusPaying, "reviewed_by": actor, "reviewed_at": now, "reject_reason": "", }) if res.Error != nil { return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error()) } if res.RowsAffected == 0 { return xerr.NewErrCode(xerr.LotteryClaimStateInvalid) } ip, ua := requestMeta(l.ctx) return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ ActorUserId: actor, Action: ActionLotteryClaimApprove, TargetIds: int64ToStr(req.Id), RequestBody: body, IP: ip, UserAgent: ua, }) }) } // ---- RejectLotteryClaim --------------------------------------------------- type RejectLotteryClaimLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic { return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } // RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在 // 剩余窗口内可再次提交。 func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error { actor := currentAdminId(l.ctx) if actor == 0 { return xerr.NewErrCode(xerr.ErrorTokenInvalid) } reason := strings.TrimSpace(req.Reason) if reason == "" { return xerr.NewErrCode(xerr.InvalidParams) } now := time.Now() body, _ := json.Marshal(req) return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { res := tx.Model(&modelLottery.Claim{}). Where("id = ? AND status IN ?", req.Id, []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}). Updates(map[string]any{ "status": modelLottery.ClaimStatusRejected, "reviewed_by": actor, "reviewed_at": now, "reject_reason": reason, }) if res.Error != nil { return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error()) } if res.RowsAffected == 0 { return xerr.NewErrCode(xerr.LotteryClaimStateInvalid) } ip, ua := requestMeta(l.ctx) return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ ActorUserId: actor, Action: ActionLotteryClaimReject, TargetIds: int64ToStr(req.Id), RequestBody: body, IP: ip, UserAgent: ua, }) }) } // ---- MarkPaidLotteryClaim ------------------------------------------------- type MarkPaidLotteryClaimLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic { return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } // MarkPaidLotteryClaim paying → paid。 // 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。 // paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。 func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error { actor := currentAdminId(l.ctx) if actor == 0 { return xerr.NewErrCode(xerr.ErrorTokenInvalid) } txHash := strings.TrimSpace(req.TxHash) deliveryRef := strings.TrimSpace(req.DeliveryRef) now := time.Now() paidAt := now if req.PaidAt > 0 { paidAt = time.Unix(req.PaidAt, 0) } body, _ := json.Marshal(req) return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { // 先取当前 claim 用于类型强校验 var claim modelLottery.Claim if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return xerr.NewErrCode(xerr.LotteryClaimStateInvalid) } return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil { return err } // CAS 推进 status。 res := tx.Model(&modelLottery.Claim{}). Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying). Updates(map[string]any{ "status": modelLottery.ClaimStatusPaid, "reviewed_by": actor, "reviewed_at": now, "tx_hash": txHash, "delivery_ref": deliveryRef, "paid_at": paidAt, }) if res.Error != nil { return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error()) } if res.RowsAffected == 0 { return xerr.NewErrCode(xerr.LotteryClaimStateInvalid) } // 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。 if err := tx.Model(&modelLottery.Draw{}). Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim). Updates(map[string]any{ "dispatch_state": modelLottery.DispatchStatePaid, "dispatched_at": paidAt, }).Error; err != nil { return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } ip, ua := requestMeta(l.ctx) return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ ActorUserId: actor, Action: ActionLotteryClaimMarkPaid, TargetIds: int64ToStr(req.Id), RequestBody: body, IP: ip, UserAgent: ua, }) }) } // validateMarkPaidByType 强制不同奖品类型的最少凭证: // - crypto: tx_hash 必填 // - physical: delivery_ref 必填 // - manual_other: tx_hash 或 delivery_ref 至少一个 // // 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。 func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error { switch prizeType { case modelLottery.PrizeTypeCrypto: if txHash == "" { return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash") } case modelLottery.PrizeTypePhysical: if deliveryRef == "" { return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref") } case modelLottery.PrizeTypeManualOther: if txHash == "" && deliveryRef == "" { return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref") } default: return xerr.NewErrCode(xerr.LotteryClaimStateInvalid) } return nil } // ---- ClaimsSummary -------------------------------------------------------- type LotteryClaimsSummaryLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic { return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } // LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。 func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) { if currentAdminId(l.ctx) == 0 { return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) } type row struct { PrizeType string Status string Cnt int64 } var rows []row if err := l.svcCtx.DB.WithContext(l.ctx). Model(&modelLottery.Claim{}). Select("prize_type, status, COUNT(*) AS cnt"). Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}). Group("prize_type, status"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } summary := &types.AdminLotteryClaimsSummary{} for _, r := range rows { bucket := bucketByType(summary, r.PrizeType) if bucket == nil { continue } switch r.Status { case modelLottery.ClaimStatusReviewing: bucket.Reviewing = r.Cnt case modelLottery.ClaimStatusPaying: bucket.Paying = r.Cnt } } // overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。 if err := l.svcCtx.DB.WithContext(l.ctx). Model(&modelLottery.Claim{}). Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()). Count(&summary.Overdue).Error; err != nil { return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } return summary, nil } func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount { switch prizeType { case modelLottery.PrizeTypeCrypto: return &s.Crypto case modelLottery.PrizeTypePhysical: return &s.Physical case modelLottery.PrizeTypeManualOther: return &s.ManualOther } return nil } // ---- helpers --------------------------------------------------------------- // claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。 func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim { view := types.AdminLotteryClaim{ Id: c.Id, DrawId: c.DrawId, ActivityId: c.ActivityId, Status: c.Status, ExpiresAt: c.ExpiresAt.Unix(), ReviewedBy: c.ReviewedBy, RejectReason: c.RejectReason, TxHash: c.TxHash, DeliveryRef: c.DeliveryRef, CreatedAt: c.CreatedAt.Unix(), User: types.AdminLotteryClaimUser{ Id: c.UserId, Email: email, }, Prize: types.AdminLotteryClaimPrize{ Type: snap.Type, Name: snap.Name, Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")), }, } if snap.Type == "" { // snapshot 未命中,用 claim.prize_type 兜底 view.Prize.Type = c.PrizeType } if c.ClaimData != "" { view.ClaimData = json.RawMessage(c.ClaimData) } if c.SubmittedAt != nil { view.SubmittedAt = c.SubmittedAt.Unix() } if c.ReviewedAt != nil { view.ReviewedAt = c.ReviewedAt.Unix() } if c.PaidAt != nil { view.PaidAt = c.PaidAt.Unix() } return view }