新功能(#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:
+141
-20
@@ -43,9 +43,9 @@ type LotteryPrizeConfig struct {
|
||||
|
||||
// LotteryUserStatus reports whether the user can currently draw.
|
||||
type LotteryUserStatus struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
|
||||
Eligible bool `json:"eligible"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
|
||||
}
|
||||
|
||||
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
|
||||
@@ -82,6 +82,11 @@ type LotteryClaimStatus struct {
|
||||
Required bool `json:"required"`
|
||||
AutoClaimed bool `json:"auto_claimed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// ExpiresAt 是领奖窗口截止时间(Unix 秒;0 表示不适用,例如自动奖)。
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
// ClaimFormSchema 是人工奖的领奖表单 JSON Schema(前端据此动态渲染)。
|
||||
// nil 表示不适用(自动奖 / 谢谢参与)。
|
||||
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||
}
|
||||
|
||||
// DrawLotteryResponse is what /draw returns.
|
||||
@@ -111,6 +116,25 @@ type LotteryRecord struct {
|
||||
Prize *DrawnPrize `json:"prize"`
|
||||
DispatchState string `json:"dispatch_state"`
|
||||
DrawnAt int64 `json:"drawn_at"`
|
||||
// Claim 是人工奖的工单详情;自动奖 / 未中奖时为 nil。Stage 2 新增。
|
||||
Claim *LotteryRecordClaim `json:"claim,omitempty"`
|
||||
}
|
||||
|
||||
// LotteryRecordClaim 是 GET /records 里"人工奖工单"的用户视图。
|
||||
// 状态:pending_claim / reviewing / paying / paid / rejected / expired。
|
||||
// - 用户在 pending_claim 或 rejected 时可再次提交(前端读 ClaimFormSchema 渲染)。
|
||||
// - paying / paid / expired 时前端只展示状态与打款/发货结果。
|
||||
type LotteryRecordClaim struct {
|
||||
Status string `json:"status"`
|
||||
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TxHash string `json:"tx_hash,omitempty"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
// ClaimFormSchema 仅当 status 允许再提交(pending_claim / rejected)时下发。
|
||||
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||
}
|
||||
|
||||
// GetLotteryRecordsResponse is the paginated payload.
|
||||
@@ -119,14 +143,20 @@ type GetLotteryRecordsResponse struct {
|
||||
List []LotteryRecord `json:"list"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeRequest is Stage 2 spec; Stage 1 always returns 4010.
|
||||
// ClaimLotteryPrizeRequest is Stage 2: submit claim data for a manual prize.
|
||||
type ClaimLotteryPrizeRequest struct {
|
||||
DrawId int64 `json:"draw_id" validate:"required"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
DrawId int64 `json:"draw_id" validate:"required"`
|
||||
ClaimData json.RawMessage `json:"claim_data"`
|
||||
// Input 是 Stage 1 骨架里预留的旧字段名,为了不破坏前端契约保留。
|
||||
// Deprecated: Prefer ClaimData for new callers.
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused.
|
||||
type ClaimLotteryPrizeResponse struct{}
|
||||
// ClaimLotteryPrizeResponse mirrors Stage 2 shape.
|
||||
type ClaimLotteryPrizeResponse struct {
|
||||
Status string `json:"status"`
|
||||
SubmittedAt int64 `json:"submitted_at"`
|
||||
}
|
||||
|
||||
// ---- Admin API --------------------------------------------------------------
|
||||
|
||||
@@ -148,28 +178,28 @@ type AdminLotteryActivity struct {
|
||||
|
||||
// CreateAdminLotteryActivityRequest creates a new activity in "draft" status.
|
||||
type CreateAdminLotteryActivityRequest struct {
|
||||
Title string `json:"title" validate:"required,max=128"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at" validate:"required"`
|
||||
EndAt int64 `json:"end_at" validate:"required"`
|
||||
Title string `json:"title" validate:"required,max=128"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at" validate:"required"`
|
||||
EndAt int64 `json:"end_at" validate:"required"`
|
||||
// GridSize:0 由 logic 层兜底为 9(不能在 json tag 里写 default=… ——
|
||||
// staticcheck SA5008 会拒;encoding/json 也不认这个选项)。
|
||||
GridSize int `json:"grid_size"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
// UnmetAction:空字符串由 logic 层兜底为 "block"。
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryActivityRequest updates mutable fields.
|
||||
type UpdateAdminLotteryActivityRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartAt int64 `json:"start_at,omitempty"`
|
||||
EndAt int64 `json:"end_at,omitempty"`
|
||||
GridSize int `json:"grid_size,omitempty"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartAt int64 `json:"start_at,omitempty"`
|
||||
EndAt int64 `json:"end_at,omitempty"`
|
||||
GridSize int `json:"grid_size,omitempty"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryActivitiesRequest paginates admin listings.
|
||||
@@ -266,3 +296,94 @@ type GrantAdminLotteryChanceRequest struct {
|
||||
Amount int `json:"amount" validate:"required,min=1"`
|
||||
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
||||
}
|
||||
|
||||
// ---- Stage 2 Admin Claims --------------------------------------------------
|
||||
|
||||
// ListAdminLotteryClaimsRequest lists claims filtered by type/status/activity.
|
||||
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20)。
|
||||
type ListAdminLotteryClaimsRequest struct {
|
||||
Type string `form:"type,omitempty"`
|
||||
Status string `form:"status,omitempty"`
|
||||
ActivityId int64 `form:"activity_id,omitempty"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
From int64 `form:"from,omitempty"` // Unix 秒
|
||||
To int64 `form:"to,omitempty"` // Unix 秒
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaim is the admin-facing view of one claim row.
|
||||
type AdminLotteryClaim struct {
|
||||
Id int64 `json:"id"`
|
||||
DrawId int64 `json:"draw_id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
User AdminLotteryClaimUser `json:"user"`
|
||||
Prize AdminLotteryClaimPrize `json:"prize"`
|
||||
Status string `json:"status"`
|
||||
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
ReviewedBy int64 `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt int64 `json:"reviewed_at,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
TxHash string `json:"tx_hash,omitempty"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimUser 是 claim 列表里附带的用户简况。
|
||||
type AdminLotteryClaimUser struct {
|
||||
Id int64 `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimPrize 是 claim 列表里附带的奖品简况(含 config,供审核判断)。
|
||||
type AdminLotteryClaimPrize struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryClaimsResponse pages.
|
||||
type ListAdminLotteryClaimsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
Claims []AdminLotteryClaim `json:"claims"`
|
||||
}
|
||||
|
||||
// AdminApproveClaimRequest 转 reviewing → paying。
|
||||
type AdminApproveClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// AdminRejectClaimRequest 转 reviewing/paying → rejected,reason 必填。
|
||||
type AdminRejectClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Reason string `json:"reason" validate:"required,max=512"`
|
||||
}
|
||||
|
||||
// AdminMarkPaidClaimRequest 转 paying → paid。
|
||||
// tx_hash / delivery_ref 至少填一个;crypto 必填 tx_hash,physical 必填
|
||||
// delivery_ref,manual_other 至少填一个(业务层做类型检查)。
|
||||
// paid_at 可选,缺省时用服务端 now。
|
||||
type AdminMarkPaidClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
TxHash string `json:"tx_hash,omitempty" validate:"max=128"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty" validate:"max=128"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"` // Unix 秒
|
||||
}
|
||||
|
||||
// AdminLotteryClaimsSummary 是 /claims/summary 的响应。overdue = 待用户填的
|
||||
// pending_claim 中已过期的条数(不重叠 status=expired)。
|
||||
type AdminLotteryClaimsSummary struct {
|
||||
Crypto AdminLotteryClaimsStatusCount `json:"crypto"`
|
||||
Physical AdminLotteryClaimsStatusCount `json:"physical"`
|
||||
ManualOther AdminLotteryClaimsStatusCount `json:"manual_other"`
|
||||
Overdue int64 `json:"overdue"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimsStatusCount 是各类型的关键状态计数。
|
||||
type AdminLotteryClaimsStatusCount struct {
|
||||
Reviewing int64 `json:"reviewing"`
|
||||
Paying int64 `json:"paying"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user