新功能(#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%
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||
// - bucketByType 的类型 → 状态桶映射
|
||||
// - claimToAdminView 的 nullable 字段渲染
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestValidateMarkPaidByType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prizeType string
|
||||
txHash string
|
||||
deliveryRef string
|
||||
wantErr bool
|
||||
}{
|
||||
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||
{"unknown type", "auto_hallucinated", "", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketByType(t *testing.T) {
|
||||
s := &types.AdminLotteryClaimsSummary{}
|
||||
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||
t.Fatal("crypto bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||
t.Fatal("physical bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||
t.Fatal("manual_other bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, "unknown") != nil {
|
||||
t.Fatal("unknown type must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
claim := modelLottery.Claim{
|
||||
Id: 42,
|
||||
DrawId: 1234,
|
||||
UserId: 88,
|
||||
ActivityId: 100,
|
||||
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||
Status: modelLottery.ClaimStatusPaid,
|
||||
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||
SubmittedAt: &submittedAt,
|
||||
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||
TxHash: "0xabcdef",
|
||||
PaidAt: &paidAt,
|
||||
}
|
||||
snap := modelLottery.PrizeSnapshot{
|
||||
Type: modelLottery.PrizeTypeCrypto,
|
||||
Name: "1 BTC",
|
||||
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||
}
|
||||
view := claimToAdminView(claim, snap, "user@example.com")
|
||||
|
||||
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||
t.Fatalf("view IDs wrong: %+v", view)
|
||||
}
|
||||
if view.User.Email != "user@example.com" {
|
||||
t.Fatalf("Email = %q", view.User.Email)
|
||||
}
|
||||
if view.Status != modelLottery.ClaimStatusPaid {
|
||||
t.Fatalf("Status = %q", view.Status)
|
||||
}
|
||||
if view.SubmittedAt != submittedAt.Unix() {
|
||||
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||
}
|
||||
if view.PaidAt != paidAt.Unix() {
|
||||
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||
}
|
||||
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||
}
|
||||
if len(view.ClaimData) == 0 {
|
||||
t.Fatal("ClaimData must be included when non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||
claim := modelLottery.Claim{
|
||||
Id: 1,
|
||||
DrawId: 2,
|
||||
PrizeType: modelLottery.PrizeTypePhysical,
|
||||
Status: modelLottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user