Files
hi-server/internal/types/lottery.go
T
shanshanzhong147 ce3babcc33 新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成
Closes HIF-3

Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。

架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。

- 迁移 02158_admin_action_log:后台写操作审计
- xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge)
- feature flag config.Lottery.Enable 默认 false,合并后线上零副作用
- draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize
- 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010)
- 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant
- audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要
- QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl

测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100%
100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁)
CI 全绿:构建/Vet/测试 + golangci-lint
2026-07-08 22:33:18 -07:00

269 lines
10 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.
// Types for lottery Stage 1 user & admin APIs. Kept in a separate file so
// future `goctl` regenerations of types.go do not clobber them (same pattern
// as internal/types/subscribe.go).
//
// Field names mirror the shape declared in HIF-3 (Stage 1 spec) and the
// architect's PR C brief. Do NOT rename without updating the .api files and
// notifying frontend.
package types
import "encoding/json"
// ---- User API ---------------------------------------------------------------
// GetLotteryConfigRequest is the query for GET /api/v1/lottery/config.
type GetLotteryConfigRequest struct {
ActivityId int64 `form:"activity_id" validate:"required"`
}
// LotteryActivityConfig is the activity snapshot returned to the user.
type LotteryActivityConfig struct {
Id int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
Status string `json:"status"`
GridSize int `json:"grid_size"`
Prizes []LotteryPrizeConfig `json:"prizes"`
}
// LotteryPrizeConfig is the slot-facing view of a prize (no weight / stock
// exposed — those are admin-only).
type LotteryPrizeConfig struct {
Slot int `json:"slot"`
Id int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
SoldOut bool `json:"sold_out"`
}
// 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"`
}
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
type LotteryUnmetReason struct {
Rule string `json:"rule"`
Hint string `json:"hint"`
Current int64 `json:"current,omitempty"`
Required int64 `json:"required,omitempty"`
}
// GetLotteryConfigResponse is the envelope for GET /config.
type GetLotteryConfigResponse struct {
Activity LotteryActivityConfig `json:"activity"`
User LotteryUserStatus `json:"user"`
}
// DrawLotteryRequest is the body for POST /api/v1/lottery/draw.
type DrawLotteryRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
ClientNonce string `json:"client_nonce" validate:"required,max=64"`
}
// DrawnPrize is a slim prize view returned on draw success.
type DrawnPrize struct {
Slot int `json:"slot"`
Id int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
}
// LotteryClaimStatus tells the frontend whether more action is needed.
type LotteryClaimStatus struct {
Required bool `json:"required"`
AutoClaimed bool `json:"auto_claimed"`
Message string `json:"message,omitempty"`
}
// DrawLotteryResponse is what /draw returns.
type DrawLotteryResponse struct {
DrawId int64 `json:"draw_id"`
IsWin bool `json:"is_win"`
Prize *DrawnPrize `json:"prize"`
Claim LotteryClaimStatus `json:"claim"`
ChancesRemaining int64 `json:"chances_remaining"`
}
// GetLotteryRecordsRequest paginates over the user's draws.
// Page/Size 默认 by logic 层(Page<=0 → 1Size<=0||>200 → 20);tag 里不设
// default 以避免 staticcheck 与 Gin binding 的语义冲突。
type GetLotteryRecordsRequest struct {
ActivityId int64 `form:"activity_id"`
Status string `form:"status"`
Page int `form:"page"`
Size int `form:"size"`
}
// LotteryRecord is one row in the records list.
type LotteryRecord struct {
DrawId int64 `json:"draw_id"`
ActivityId int64 `json:"activity_id"`
IsWin bool `json:"is_win"`
Prize *DrawnPrize `json:"prize"`
DispatchState string `json:"dispatch_state"`
DrawnAt int64 `json:"drawn_at"`
}
// GetLotteryRecordsResponse is the paginated payload.
type GetLotteryRecordsResponse struct {
Total int64 `json:"total"`
List []LotteryRecord `json:"list"`
}
// ClaimLotteryPrizeRequest is Stage 2 spec; Stage 1 always returns 4010.
type ClaimLotteryPrizeRequest struct {
DrawId int64 `json:"draw_id" validate:"required"`
Input json.RawMessage `json:"input,omitempty"`
}
// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused.
type ClaimLotteryPrizeResponse struct{}
// ---- Admin API --------------------------------------------------------------
// AdminLotteryActivity is the full admin view of an activity.
type AdminLotteryActivity struct {
Id int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
Status string `json:"status"`
GridSize int `json:"grid_size"`
Eligibility json.RawMessage `json:"eligibility"`
ChanceSources json.RawMessage `json:"chance_sources"`
UnmetAction string `json:"unmet_action"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// 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"`
// GridSize0 由 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"`
}
// 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"`
}
// ListAdminLotteryActivitiesRequest paginates admin listings.
// Page/Size 默认 by logic 层。
type ListAdminLotteryActivitiesRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Status string `form:"status,omitempty"`
Search string `form:"search,omitempty"`
}
// ListAdminLotteryActivitiesResponse pages.
type ListAdminLotteryActivitiesResponse struct {
Total int64 `json:"total"`
List []AdminLotteryActivity `json:"list"`
}
// AdminActivityIdRequest is used by GET /detail, publish, pause, delete.
type AdminActivityIdRequest struct {
Id int64 `form:"id" json:"id" validate:"required"`
}
// UpdateAdminLotteryRulesRequest overwrites eligibility / chance_sources /
// unmet_action in a single call. Validated against rule caps before persist.
type UpdateAdminLotteryRulesRequest struct {
Id int64 `json:"id" validate:"required"`
Eligibility json.RawMessage `json:"eligibility"`
ChanceSources json.RawMessage `json:"chance_sources"`
UnmetAction string `json:"unmet_action,omitempty"`
}
// AdminLotteryPrize is the admin view of a prize (includes weight + stock).
type AdminLotteryPrize struct {
Id int64 `json:"id"`
ActivityId int64 `json:"activity_id"`
Slot int `json:"slot"`
Type string `json:"type"`
Name string `json:"name"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
Weight int `json:"weight"`
TotalStock *int64 `json:"total_stock"`
RemainingStock *int64 `json:"remaining_stock"`
IsFallback bool `json:"is_fallback"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// CreateAdminLotteryPrizeRequest is nested under /activities/{id}/prizes.
type CreateAdminLotteryPrizeRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
Slot int `json:"slot"`
Type string `json:"type" validate:"required,max=32"`
Name string `json:"name" validate:"required,max=128"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
Weight int `json:"weight"`
TotalStock *int64 `json:"total_stock,omitempty"`
IsFallback bool `json:"is_fallback"`
}
// UpdateAdminLotteryPrizeRequest updates mutable prize fields.
type UpdateAdminLotteryPrizeRequest struct {
Id int64 `json:"id" validate:"required"`
Slot *int `json:"slot,omitempty"`
Name string `json:"name,omitempty"`
IconUrl string `json:"icon_url,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
Weight *int `json:"weight,omitempty"`
TotalStock *int64 `json:"total_stock,omitempty"`
IsFallback *bool `json:"is_fallback,omitempty"`
}
// ListAdminLotteryPrizesRequest lists prizes for an activity.
type ListAdminLotteryPrizesRequest struct {
ActivityId int64 `form:"activity_id" validate:"required"`
}
// ListAdminLotteryPrizesResponse pages.
type ListAdminLotteryPrizesResponse struct {
List []AdminLotteryPrize `json:"list"`
}
// AdminPrizeIdRequest is used by DELETE / GET single.
type AdminPrizeIdRequest struct {
Id int64 `form:"id" json:"id" validate:"required"`
}
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
// activity. sourceRef doubles as idempotency key.
type GrantAdminLotteryChanceRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
UserId int64 `json:"user_id" validate:"required"`
Amount int `json:"amount" validate:"required,min=1"`
SourceRef string `json:"source_ref" validate:"required,max=128"`
}