新功能(#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:
@@ -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 → 1;Size<=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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user