新功能(#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
This commit is contained in:
2026-07-08 22:33:18 -07:00
committed by GitHub
parent a46fb83054
commit ce3babcc33
27 changed files with 3599 additions and 18 deletions
+268
View File
@@ -0,0 +1,268 @@
// 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"`
}