Files
hi-server/internal/logic/admin/lottery/admin_claims.go
T
shanshanzhong147 07409eb602 新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
2026-07-10 04:01:34 -07:00

472 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 → rejectedexpires_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
}
}
// overduepending_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
}