From ce3babcc336d3fa6d81dfa8a05d047bc2ab0dab2 Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Wed, 8 Jul 2026 22:33:18 -0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD(#3):=20=E6=8A=BD?= =?UTF-8?q?=E5=A5=96=20Stage=201=20=E6=94=B6=E5=AE=98=20=E2=80=94=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20API=20+=20=E5=90=8E=E5=8F=B0=20CRUD=20+=20?= =?UTF-8?q?=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apis/admin/lottery.api | 65 ++ apis/public/lottery.api | 33 + .../database/02158_admin_action_log.down.sql | 2 + .../database/02158_admin_action_log.up.sql | 21 + internal/config/config.go | 8 + .../handler/admin/lottery/lottery_handler.go | 169 +++++ internal/handler/lottery_routes.go | 44 ++ .../handler/public/lottery/lottery_handler.go | 69 ++ internal/handler/routes.go | 3 + internal/logic/admin/lottery/lottery.go | 686 ++++++++++++++++++ internal/logic/admin/lottery/lottery_test.go | 134 ++++ internal/logic/audit/admin_action_log.go | 100 +++ internal/logic/audit/admin_action_log_test.go | 111 +++ internal/logic/lottery/draw/service.go | 547 ++++++++++++++ internal/logic/lottery/draw/service_test.go | 513 +++++++++++++ .../logic/lottery/handler/commission_log.go | 32 + .../logic/lottery/handler/vpn_duration.go | 33 +- internal/logic/lottery/hook/invite.go | 9 +- internal/logic/lottery/hook/invite_test.go | 4 +- internal/logic/lottery/rulecaps/caps.go | 112 +++ internal/logic/lottery/rulecaps/caps_test.go | 127 ++++ internal/logic/public/lottery/lottery.go | 323 +++++++++ internal/svc/serviceContext.go | 40 +- internal/types/lottery.go | 268 +++++++ pkg/xerr/errCode.go | 17 + pkg/xerr/errMsg.go | 11 + qa/lottery/stage1_curl.sh | 136 ++++ 27 files changed, 3599 insertions(+), 18 deletions(-) create mode 100644 apis/admin/lottery.api create mode 100644 apis/public/lottery.api create mode 100644 initialize/migrate/database/02158_admin_action_log.down.sql create mode 100644 initialize/migrate/database/02158_admin_action_log.up.sql create mode 100644 internal/handler/admin/lottery/lottery_handler.go create mode 100644 internal/handler/lottery_routes.go create mode 100644 internal/handler/public/lottery/lottery_handler.go create mode 100644 internal/logic/admin/lottery/lottery.go create mode 100644 internal/logic/admin/lottery/lottery_test.go create mode 100644 internal/logic/audit/admin_action_log.go create mode 100644 internal/logic/audit/admin_action_log_test.go create mode 100644 internal/logic/lottery/draw/service.go create mode 100644 internal/logic/lottery/draw/service_test.go create mode 100644 internal/logic/lottery/handler/commission_log.go create mode 100644 internal/logic/lottery/rulecaps/caps.go create mode 100644 internal/logic/lottery/rulecaps/caps_test.go create mode 100644 internal/logic/public/lottery/lottery.go create mode 100644 internal/types/lottery.go create mode 100755 qa/lottery/stage1_curl.sh diff --git a/apis/admin/lottery.api b/apis/admin/lottery.api new file mode 100644 index 0000000..0120241 --- /dev/null +++ b/apis/admin/lottery.api @@ -0,0 +1,65 @@ +syntax = "v1" + +info ( + title: "Lottery Admin API" + desc: "Admin-facing lottery endpoints for HIF-3 Stage 1" + author: "hifast" + version: "0.1.0" +) + +import "../types.api" + +@server ( + prefix: v1/admin/lottery + group: admin/lottery + middleware: AuthMiddleware +) +service ppanel { + @doc "Create a new activity (status=draft)" + @handler CreateLotteryActivity + post /activities (CreateAdminLotteryActivityRequest) returns (AdminLotteryActivity) + + @doc "Update mutable activity fields" + @handler UpdateLotteryActivity + put /activities (UpdateAdminLotteryActivityRequest) returns (AdminLotteryActivity) + + @doc "List activities (paginated)" + @handler ListLotteryActivities + get /activities (ListAdminLotteryActivitiesRequest) returns (ListAdminLotteryActivitiesResponse) + + @doc "Get one activity" + @handler GetLotteryActivity + get /activities/detail (AdminActivityIdRequest) returns (AdminLotteryActivity) + + @doc "Publish (draft/paused → running)" + @handler PublishLotteryActivity + post /activities/publish (AdminActivityIdRequest) + + @doc "Pause (running → paused)" + @handler PauseLotteryActivity + post /activities/pause (AdminActivityIdRequest) + + @doc "Update eligibility/chance_sources (rule-caps enforced)" + @handler UpdateLotteryRules + put /activities/rules (UpdateAdminLotteryRulesRequest) + + @doc "Create prize" + @handler CreateLotteryPrize + post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize) + + @doc "Update prize" + @handler UpdateLotteryPrize + put /prizes (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize) + + @doc "Delete prize" + @handler DeleteLotteryPrize + delete /prizes (AdminPrizeIdRequest) + + @doc "List prizes on an activity" + @handler ListLotteryPrizes + get /prizes (ListAdminLotteryPrizesRequest) returns (ListAdminLotteryPrizesResponse) + + @doc "Manually grant N chances to a user (idempotent by source_ref)" + @handler GrantLotteryChance + post /chances/grant (GrantAdminLotteryChanceRequest) +} diff --git a/apis/public/lottery.api b/apis/public/lottery.api new file mode 100644 index 0000000..b9f1a51 --- /dev/null +++ b/apis/public/lottery.api @@ -0,0 +1,33 @@ +syntax = "v1" + +info ( + title: "Lottery API" + desc: "User-facing lottery endpoints for HIF-3 Stage 1" + author: "hifast" + version: "0.1.0" +) + +import "../types.api" + +@server ( + prefix: v1/lottery + group: public/lottery + middleware: AuthMiddleware,DeviceMiddleware +) +service ppanel { + @doc "Get lottery activity config + user status" + @handler QueryLotteryConfig + get /config (GetLotteryConfigRequest) returns (GetLotteryConfigResponse) + + @doc "Draw once (nonce idempotent, rate limited 1/sec)" + @handler DrawLottery + post /draw (DrawLotteryRequest) returns (DrawLotteryResponse) + + @doc "List my draws" + @handler QueryLotteryRecords + get /records (GetLotteryRecordsRequest) returns (GetLotteryRecordsResponse) + + @doc "Claim a prize (Stage 1 returns 100010 not_claimable)" + @handler ClaimLotteryPrize + post /claim (ClaimLotteryPrizeRequest) returns (ClaimLotteryPrizeResponse) +} diff --git a/initialize/migrate/database/02158_admin_action_log.down.sql b/initialize/migrate/database/02158_admin_action_log.down.sql new file mode 100644 index 0000000..bdadf31 --- /dev/null +++ b/initialize/migrate/database/02158_admin_action_log.down.sql @@ -0,0 +1,2 @@ +-- 02158 admin_action_log 回滚 +DROP TABLE IF EXISTS `admin_action_log`; diff --git a/initialize/migrate/database/02158_admin_action_log.up.sql b/initialize/migrate/database/02158_admin_action_log.up.sql new file mode 100644 index 0000000..b8435b3 --- /dev/null +++ b/initialize/migrate/database/02158_admin_action_log.up.sql @@ -0,0 +1,21 @@ +-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求) +-- +-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责 +-- 与合规审查。actor_user_id 是操作者的 user.id;action 是操作动作 +-- (lottery.activity.create / lottery.prize.update / lottery.rules.put / ...); +-- target_ids 是被操作对象的主键数组(JSON);request_hash 是请求 body 的 sha1 +-- 摘要(对同一批次多次写入去重);ip/user_agent 从上下文取。 + +CREATE TABLE IF NOT EXISTS `admin_action_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `actor_user_id` BIGINT UNSIGNED NOT NULL COMMENT '操作者 user.id', + `action` VARCHAR(64) NOT NULL COMMENT '动作 code(点分层级)', + `target_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '被操作对象 ID 逗号分隔或 JSON 数组', + `request_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 body sha1 摘要', + `ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '操作者 IP', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作者 UA', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_actor_time` (`actor_user_id`, `created_at`), + KEY `idx_action_time` (`action`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台写操作审计流水'; diff --git a/internal/config/config.go b/internal/config/config.go index e479c09..eceaf32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Config struct { Currency Currency `yaml:"Currency"` Trace trace.Config `yaml:"Trace"` S3 S3Config `yaml:"S3"` + Lottery LotteryConfig `yaml:"Lottery"` Administrator struct { Email string `yaml:"Email" default:"admin@ppanel.dev"` Password string `yaml:"Password" default:"password"` @@ -250,6 +251,13 @@ type InviteConfig struct { GiftDays int64 `yaml:"GiftDays" default:"3"` } +// LotteryConfig 是抽奖 Stage 1 的 feature flag。默认关闭,交 QA 前手动打开。 +// 关闭时用户端 POST /draw 返回 4003 activity_ended(前端展示"活动已结束", +// 与"配置关闭"避免暴露内部状态);后台 CRUD 仍然可用,方便配置好活动再开。 +type LotteryConfig struct { + Enable bool `yaml:"Enable" default:"false"` +} + // KuttConfig Kutt 短链接服务配置 type KuttConfig struct { Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接 diff --git a/internal/handler/admin/lottery/lottery_handler.go b/internal/handler/admin/lottery/lottery_handler.go new file mode 100644 index 0000000..939ff63 --- /dev/null +++ b/internal/handler/admin/lottery/lottery_handler.go @@ -0,0 +1,169 @@ +// Package lottery contains gin handlers for the admin-side lottery endpoints. +package lottery + +import ( + "github.com/gin-gonic/gin" + adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func CreateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CreateAdminLotteryActivityRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewCreateLotteryActivityLogic(c.Request.Context(), svcCtx) + resp, err := l.CreateLotteryActivity(&req) + result.HttpResult(c, resp, err) + } +} + +func UpdateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateAdminLotteryActivityRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewUpdateLotteryActivityLogic(c.Request.Context(), svcCtx) + resp, err := l.UpdateLotteryActivity(&req) + result.HttpResult(c, resp, err) + } +} + +func ListLotteryActivitiesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ListAdminLotteryActivitiesRequest + _ = c.ShouldBind(&req) + l := adminlottery.NewListLotteryActivitiesLogic(c.Request.Context(), svcCtx) + resp, err := l.ListLotteryActivities(&req) + result.HttpResult(c, resp, err) + } +} + +func GetLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.AdminActivityIdRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewGetLotteryActivityLogic(c.Request.Context(), svcCtx) + resp, err := l.GetLotteryActivity(&req) + result.HttpResult(c, resp, err) + } +} + +func PublishLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.AdminActivityIdRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewPublishLotteryActivityLogic(c.Request.Context(), svcCtx) + result.HttpResult(c, nil, l.PublishLotteryActivity(&req)) + } +} + +func PauseLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.AdminActivityIdRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewPauseLotteryActivityLogic(c.Request.Context(), svcCtx) + result.HttpResult(c, nil, l.PauseLotteryActivity(&req)) + } +} + +func UpdateLotteryRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateAdminLotteryRulesRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewUpdateLotteryRulesLogic(c.Request.Context(), svcCtx) + result.HttpResult(c, nil, l.UpdateLotteryRules(&req)) + } +} + +func CreateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CreateAdminLotteryPrizeRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewCreateLotteryPrizeLogic(c.Request.Context(), svcCtx) + resp, err := l.CreateLotteryPrize(&req) + result.HttpResult(c, resp, err) + } +} + +func UpdateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateAdminLotteryPrizeRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewUpdateLotteryPrizeLogic(c.Request.Context(), svcCtx) + resp, err := l.UpdateLotteryPrize(&req) + result.HttpResult(c, resp, err) + } +} + +func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.AdminPrizeIdRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewDeleteLotteryPrizeLogic(c.Request.Context(), svcCtx) + result.HttpResult(c, nil, l.DeleteLotteryPrize(&req)) + } +} + +func ListLotteryPrizesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ListAdminLotteryPrizesRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewListLotteryPrizesLogic(c.Request.Context(), svcCtx) + resp, err := l.ListLotteryPrizes(&req) + result.HttpResult(c, resp, err) + } +} + +func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GrantAdminLotteryChanceRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := adminlottery.NewGrantLotteryChanceLogic(c.Request.Context(), svcCtx) + result.HttpResult(c, nil, l.GrantLotteryChance(&req)) + } +} diff --git a/internal/handler/lottery_routes.go b/internal/handler/lottery_routes.go new file mode 100644 index 0000000..5b22c8b --- /dev/null +++ b/internal/handler/lottery_routes.go @@ -0,0 +1,44 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + adminLottery "github.com/perfect-panel/server/internal/handler/admin/lottery" + publicLottery "github.com/perfect-panel/server/internal/handler/public/lottery" + "github.com/perfect-panel/server/internal/middleware" + "github.com/perfect-panel/server/internal/svc" +) + +// registerLotteryRoutes wires the Stage 1 lottery endpoints. Kept in its own +// file to avoid ballooning routes.go and to make the lottery surface easy to +// audit end-to-end. The path prefix "/v1/lottery" is under the user middleware +// stack (AuthMiddleware + DeviceMiddleware); "/v1/admin/lottery" uses the +// admin-detecting AuthMiddleware (path contains "admin" segment). +func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) { + userGroup := router.Group("/v1/lottery") + userGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) + { + userGroup.GET("/config", publicLottery.QueryLotteryConfigHandler(serverCtx)) + userGroup.POST("/draw", publicLottery.DrawLotteryHandler(serverCtx)) + userGroup.GET("/records", publicLottery.QueryLotteryRecordsHandler(serverCtx)) + userGroup.POST("/claim", publicLottery.ClaimLotteryPrizeHandler(serverCtx)) + } + + adminGroup := router.Group("/v1/admin/lottery") + adminGroup.Use(middleware.AuthMiddleware(serverCtx)) + { + adminGroup.POST("/activities", adminLottery.CreateLotteryActivityHandler(serverCtx)) + adminGroup.PUT("/activities", adminLottery.UpdateLotteryActivityHandler(serverCtx)) + adminGroup.GET("/activities", adminLottery.ListLotteryActivitiesHandler(serverCtx)) + adminGroup.GET("/activities/detail", adminLottery.GetLotteryActivityHandler(serverCtx)) + adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx)) + adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx)) + adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx)) + + adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx)) + adminGroup.PUT("/prizes", adminLottery.UpdateLotteryPrizeHandler(serverCtx)) + adminGroup.DELETE("/prizes", adminLottery.DeleteLotteryPrizeHandler(serverCtx)) + adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx)) + + adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx)) + } +} diff --git a/internal/handler/public/lottery/lottery_handler.go b/internal/handler/public/lottery/lottery_handler.go new file mode 100644 index 0000000..4c24afd --- /dev/null +++ b/internal/handler/public/lottery/lottery_handler.go @@ -0,0 +1,69 @@ +// Package lottery contains the user-facing lottery HTTP handlers. Each handler +// binds request params via gin, validates, delegates to the logic package, +// and renders through pkg/result to keep the API response envelope consistent. +package lottery + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/lottery" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// QueryLotteryConfigHandler serves GET /api/v1/lottery/config. +func QueryLotteryConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetLotteryConfigRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := lottery.NewQueryLotteryConfigLogic(c.Request.Context(), svcCtx) + resp, err := l.QueryLotteryConfig(&req) + result.HttpResult(c, resp, err) + } +} + +// DrawLotteryHandler serves POST /api/v1/lottery/draw. +func DrawLotteryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DrawLotteryRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := lottery.NewDrawLotteryLogic(c.Request.Context(), svcCtx) + resp, err := l.DrawLottery(&req) + result.HttpResult(c, resp, err) + } +} + +// QueryLotteryRecordsHandler serves GET /api/v1/lottery/records. +func QueryLotteryRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetLotteryRecordsRequest + _ = c.ShouldBind(&req) + l := lottery.NewQueryLotteryRecordsLogic(c.Request.Context(), svcCtx) + resp, err := l.QueryLotteryRecords(&req) + result.HttpResult(c, resp, err) + } +} + +// ClaimLotteryPrizeHandler serves POST /api/v1/lottery/claim. Stage 1 +// always returns 4010 not_claimable. +func ClaimLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ClaimLotteryPrizeRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := lottery.NewClaimLotteryPrizeLogic(c.Request.Context(), svcCtx) + resp, err := l.ClaimLotteryPrize(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 9a923bf..ce470da 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1221,4 +1221,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get Server Protocol Config serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) } + + // ---- Lottery (Stage 1) -------------------------------------------------- + registerLotteryRoutes(router, serverCtx) } diff --git a/internal/logic/admin/lottery/lottery.go b/internal/logic/admin/lottery/lottery.go new file mode 100644 index 0000000..a6b51ad --- /dev/null +++ b/internal/logic/admin/lottery/lottery.go @@ -0,0 +1,686 @@ +// Package lottery contains the admin-facing lottery HTTP logic. Every write +// endpoint runs its user-visible mutation inside a tx that ALSO writes an +// admin_action_log row via audit.WriteAdminAction, so a rollback leaves no +// dangling audit entries. Rules PUT is gated by rulecaps.ValidateEligibilityJSON. +package lottery + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/perfect-panel/server/internal/logic/audit" + "github.com/perfect-panel/server/internal/logic/lottery/rulecaps" + modelLottery "github.com/perfect-panel/server/internal/model/lottery" + userModel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +// currentAdminId retrieves the actor's user.id from ctx (populated by +// AuthMiddleware). Zero → treated as unauthorized. All admin endpoints below +// short-circuit if the caller is not admin. +func currentAdminId(ctx context.Context) int64 { + u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User) + if !ok || u == nil { + return 0 + } + return u.Id +} + +func requestMeta(ctx context.Context) (ip, ua string) { + // AuthMiddleware attaches gin.Context; we accept absence gracefully. + if v, ok := ctx.Value("ip").(string); ok { + ip = v + } + if v, ok := ctx.Value("user_agent").(string); ok { + ua = v + } + return +} + +func jsonOrDefault(raw json.RawMessage, def string) string { + if len(raw) == 0 { + return def + } + return string(raw) +} + +// ---- CreateLotteryActivity ------------------------------------------------- + +type CreateLotteryActivityLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryActivityLogic { + return &CreateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) { + actor := currentAdminId(l.ctx) + if actor == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil { + return nil, ruleCapsToXerr(err) + } + + activity := modelLottery.Activity{ + Title: strings.TrimSpace(req.Title), + Description: req.Description, + StartAt: time.Unix(req.StartAt, 0), + EndAt: time.Unix(req.EndAt, 0), + Status: modelLottery.ActivityStatusDraft, + GridSize: req.GridSize, + Eligibility: jsonOrDefault(req.Eligibility, "{}"), + ChanceSources: jsonOrDefault(req.ChanceSources, "[]"), + UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock), + } + if activity.GridSize <= 0 { + activity.GridSize = 9 + } + body, _ := json.Marshal(req) + err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&activity).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryActivityCreate, + TargetIds: int64ToStr(activity.Id), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) + if err != nil { + return nil, err + } + return activityToAdminView(activity), nil +} + +// ---- UpdateLotteryActivity ------------------------------------------------- + +type UpdateLotteryActivityLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryActivityLogic { + return &UpdateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateLotteryActivityLogic) UpdateLotteryActivity(req *types.UpdateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) { + actor := currentAdminId(l.ctx) + if actor == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + body, _ := json.Marshal(req) + var updated modelLottery.Activity + err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + fields := map[string]any{} + if req.Title != "" { + fields["title"] = req.Title + } + if req.Description != "" { + fields["description"] = req.Description + } + if req.StartAt > 0 { + fields["start_at"] = time.Unix(req.StartAt, 0) + } + if req.EndAt > 0 { + fields["end_at"] = time.Unix(req.EndAt, 0) + } + if req.GridSize > 0 { + fields["grid_size"] = req.GridSize + } + if req.UnmetAction != "" { + fields["unmet_action"] = req.UnmetAction + } + if len(fields) > 0 { + if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + } + if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryActivityUpdate, + TargetIds: int64ToStr(req.Id), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) + if err != nil { + return nil, err + } + return activityToAdminView(updated), nil +} + +// ---- ListLotteryActivities ------------------------------------------------- + +type ListLotteryActivitiesLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListLotteryActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryActivitiesLogic { + return &ListLotteryActivitiesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListLotteryActivitiesLogic) ListLotteryActivities(req *types.ListAdminLotteryActivitiesRequest) (*types.ListAdminLotteryActivitiesResponse, 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.Activity{}) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Search != "" { + db = db.Where("title LIKE ?", "%"+req.Search+"%") + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + var rows []modelLottery.Activity + 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()) + } + resp := &types.ListAdminLotteryActivitiesResponse{Total: total, List: make([]types.AdminLotteryActivity, 0, len(rows))} + for _, a := range rows { + resp.List = append(resp.List, *activityToAdminView(a)) + } + return resp, nil +} + +// ---- GetLotteryActivity --------------------------------------------------- + +type GetLotteryActivityLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLotteryActivityLogic { + return &GetLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetLotteryActivityLogic) GetLotteryActivity(req *types.AdminActivityIdRequest) (*types.AdminLotteryActivity, error) { + if currentAdminId(l.ctx) == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + var a modelLottery.Activity + if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.Id).First(&a).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + return activityToAdminView(a), nil +} + +// ---- Publish / Pause ------------------------------------------------------- + +type toggleActivityStatusLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + action string + next string +} + +func (l *toggleActivityStatusLogic) run(id int64) error { + actor := currentAdminId(l.ctx) + if actor == 0 { + return xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + var a modelLottery.Activity + if err := tx.Where("id = ?", id).First(&a).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", id).UpdateColumn("status", l.next).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: l.action, + TargetIds: int64ToStr(id), + IP: ip, + UserAgent: ua, + }) + }) +} + +type PublishLotteryActivityLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublishLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLotteryActivityLogic { + return &PublishLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PublishLotteryActivityLogic) PublishLotteryActivity(req *types.AdminActivityIdRequest) error { + t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPublish, next: modelLottery.ActivityStatusRunning} + return t.run(req.Id) +} + +type PauseLotteryActivityLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPauseLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PauseLotteryActivityLogic { + return &PauseLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivityIdRequest) error { + t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPause, next: modelLottery.ActivityStatusPaused} + return t.run(req.Id) +} + +// ---- UpdateLotteryRules (with caps) ---------------------------------------- + +type UpdateLotteryRulesLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateLotteryRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryRulesLogic { + return &UpdateLotteryRulesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateLotteryRulesLogic) UpdateLotteryRules(req *types.UpdateAdminLotteryRulesRequest) error { + actor := currentAdminId(l.ctx) + if actor == 0 { + return xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil { + return ruleCapsToXerr(err) + } + // Pre-collect the field diff outside the tx so an empty update rejects + // without opening one (cheaper on the happy path + easier to test). + fields := map[string]any{} + if len(req.Eligibility) > 0 { + fields["eligibility"] = string(req.Eligibility) + } + if len(req.ChanceSources) > 0 { + fields["chance_sources"] = string(req.ChanceSources) + } + if req.UnmetAction != "" { + fields["unmet_action"] = req.UnmetAction + } + if len(fields) == 0 { + return xerr.NewErrCode(xerr.InvalidParams) + } + body, _ := json.Marshal(req) + return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + res := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields) + if res.Error != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error()) + } + if res.RowsAffected == 0 { + return xerr.NewErrCode(xerr.LotteryActivityEnded) + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryRulesPut, + TargetIds: int64ToStr(req.Id), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) +} + +func ruleCapsToXerr(err error) error { + switch { + case errors.Is(err, rulecaps.ErrRuleTreeTooDeep): + return xerr.NewErrCodeMsg(xerr.LotteryRuleTooDeep, err.Error()) + case errors.Is(err, rulecaps.ErrRuleTreeTooManyNodes): + return xerr.NewErrCodeMsg(xerr.LotteryRuleTooMany, err.Error()) + case errors.Is(err, rulecaps.ErrRuleTreeTooLarge): + return xerr.NewErrCodeMsg(xerr.LotteryRuleTooLarge, err.Error()) + default: + return xerr.NewErrCodeMsg(xerr.InvalidParams, err.Error()) + } +} + +// ---- CreatePrize / UpdatePrize / DeletePrize / ListPrizes ----------------- + +type CreateLotteryPrizeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryPrizeLogic { + return &CreateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateLotteryPrizeLogic) CreateLotteryPrize(req *types.CreateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) { + actor := currentAdminId(l.ctx) + if actor == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + prize := modelLottery.Prize{ + ActivityId: req.ActivityId, + Slot: req.Slot, + Type: req.Type, + Name: req.Name, + IconURL: req.IconUrl, + Config: jsonOrDefault(req.Config, "{}"), + Weight: req.Weight, + IsFallback: req.IsFallback, + } + if req.TotalStock != nil { + prize.TotalStock.Int64 = *req.TotalStock + prize.TotalStock.Valid = true + prize.RemainingStock.Int64 = *req.TotalStock + prize.RemainingStock.Valid = true + } + body, _ := json.Marshal(req) + err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&prize).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryPrizeCreate, + TargetIds: int64ToStr(prize.Id), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) + if err != nil { + return nil, err + } + return prizeToAdminView(prize), nil +} + +type UpdateLotteryPrizeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryPrizeLogic { + return &UpdateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) { + actor := currentAdminId(l.ctx) + if actor == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + body, _ := json.Marshal(req) + var updated modelLottery.Prize + err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return xerr.NewErrCode(xerr.DatabaseQueryError) + } + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + fields := map[string]any{} + if req.Slot != nil { + fields["slot"] = *req.Slot + } + if req.Name != "" { + fields["name"] = req.Name + } + if req.IconUrl != "" { + fields["icon_url"] = req.IconUrl + } + if len(req.Config) > 0 { + fields["config"] = string(req.Config) + } + if req.Weight != nil { + fields["weight"] = *req.Weight + } + if req.TotalStock != nil { + fields["total_stock"] = *req.TotalStock + fields["remaining_stock"] = *req.TotalStock + } + if req.IsFallback != nil { + fields["is_fallback"] = *req.IsFallback + } + if len(fields) > 0 { + if err := tx.Model(&modelLottery.Prize{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + } + if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryPrizeUpdate, + TargetIds: int64ToStr(req.Id), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) + if err != nil { + return nil, err + } + return prizeToAdminView(updated), nil +} + +type DeleteLotteryPrizeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryPrizeLogic { + return &DeleteLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteLotteryPrizeLogic) DeleteLotteryPrize(req *types.AdminPrizeIdRequest) error { + actor := currentAdminId(l.ctx) + if actor == 0 { + return xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error()) + } + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryPrizeDelete, + TargetIds: int64ToStr(req.Id), + IP: ip, + UserAgent: ua, + }) + }) +} + +type ListLotteryPrizesLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListLotteryPrizesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryPrizesLogic { + return &ListLotteryPrizesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListLotteryPrizesLogic) ListLotteryPrizes(req *types.ListAdminLotteryPrizesRequest) (*types.ListAdminLotteryPrizesResponse, error) { + if currentAdminId(l.ctx) == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + var rows []modelLottery.Prize + if err := l.svcCtx.DB.WithContext(l.ctx).Where("activity_id = ?", req.ActivityId).Order("slot ASC").Find(&rows).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + resp := &types.ListAdminLotteryPrizesResponse{List: make([]types.AdminLotteryPrize, 0, len(rows))} + for _, p := range rows { + resp.List = append(resp.List, *prizeToAdminView(p)) + } + return resp, nil +} + +// ---- GrantLotteryChance ---------------------------------------------------- + +type GrantLotteryChanceLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGrantLotteryChanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GrantLotteryChanceLogic { + return &GrantLotteryChanceLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GrantLotteryChanceLogic) GrantLotteryChance(req *types.GrantAdminLotteryChanceRequest) error { + actor := currentAdminId(l.ctx) + if actor == 0 { + return xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + // Prefix sourceRef with "manual:{actor}:" so admin-granted chances are + // distinguishable in ChanceGrant flow (audit trail + admin-scoped + // idempotency). + ref := "manual:" + int64ToStr(actor) + ":" + req.SourceRef + if err := l.svcCtx.LotteryChance.Grant(l.ctx, req.UserId, req.ActivityId, modelLottery.ChanceSourceManualGrant, ref, req.Amount); err != nil { + return errors.Wrap(xerr.NewErrCode(xerr.LotteryInternalError), err.Error()) + } + // Audit outside the ChanceService tx — the Grant is idempotent so a + // duplicated audit row is preferable to a lost one. + body, _ := json.Marshal(req) + return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + ip, ua := requestMeta(l.ctx) + return audit.WriteAdminAction(l.ctx, tx, audit.Entry{ + ActorUserId: actor, + Action: audit.ActionLotteryChancesGrant, + TargetIds: int64ToStr(req.UserId) + "," + int64ToStr(req.ActivityId), + RequestBody: body, + IP: ip, + UserAgent: ua, + }) + }) +} + +// ---- helpers --------------------------------------------------------------- + +func activityToAdminView(a modelLottery.Activity) *types.AdminLotteryActivity { + return &types.AdminLotteryActivity{ + Id: a.Id, + Title: a.Title, + Description: a.Description, + StartAt: a.StartAt.Unix(), + EndAt: a.EndAt.Unix(), + Status: a.Status, + GridSize: a.GridSize, + Eligibility: json.RawMessage(defaultRawIfEmpty(a.Eligibility, "{}")), + ChanceSources: json.RawMessage(defaultRawIfEmpty(a.ChanceSources, "[]")), + UnmetAction: a.UnmetAction, + CreatedAt: a.CreatedAt.Unix(), + UpdatedAt: a.UpdatedAt.Unix(), + } +} + +func prizeToAdminView(p modelLottery.Prize) *types.AdminLotteryPrize { + view := &types.AdminLotteryPrize{ + Id: p.Id, + ActivityId: p.ActivityId, + Slot: p.Slot, + Type: p.Type, + Name: p.Name, + IconUrl: p.IconURL, + Config: json.RawMessage(defaultRawIfEmpty(p.Config, "{}")), + Weight: p.Weight, + IsFallback: p.IsFallback, + CreatedAt: p.CreatedAt.Unix(), + UpdatedAt: p.UpdatedAt.Unix(), + } + if p.TotalStock.Valid { + v := p.TotalStock.Int64 + view.TotalStock = &v + } + if p.RemainingStock.Valid { + v := p.RemainingStock.Int64 + view.RemainingStock = &v + } + return view +} + +func defaultRawIfEmpty(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return s +} + +func defaultString(s, fallback string) string { + if s == "" { + return fallback + } + return s +} + +func int64ToStr(v int64) string { + // small buffer avoids strconv import here. + if v == 0 { + return "0" + } + neg := false + if v < 0 { + neg = true + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/logic/admin/lottery/lottery_test.go b/internal/logic/admin/lottery/lottery_test.go new file mode 100644 index 0000000..21ab02c --- /dev/null +++ b/internal/logic/admin/lottery/lottery_test.go @@ -0,0 +1,134 @@ +package lottery + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newAdminLotteryDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if strings.Contains(actual, expected) { + return nil + } + return errors.New("actual sql does not contain expected: " + expected) + }))) + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("gorm: %v", err) + } + return db, mock, func() { _ = sqlDB.Close() } +} + +func adminCtx() context.Context { + return context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 7}) +} + +func TestUpdateLotteryRules_RejectsOversizeEligibility(t *testing.T) { + db, _, cleanup := newAdminLotteryDB(t) + defer cleanup() + + // build oversized JSON + blob := strings.Repeat("a", 9000) + req := &types.UpdateAdminLotteryRulesRequest{ + Id: 1, + Eligibility: json.RawMessage(`{"op":"AND","payload":"` + blob + `"}`), + } + logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db}) + err := logic.UpdateLotteryRules(req) + var ce *xerr.CodeError + if !errors.As(err, &ce) { + t.Fatalf("expected CodeError, got %v", err) + } + if ce.GetErrCode() != xerr.LotteryRuleTooLarge { + t.Fatalf("expected LotteryRuleTooLarge, got %d", ce.GetErrCode()) + } +} + +func TestUpdateLotteryRules_RejectsDeepTree(t *testing.T) { + db, _, cleanup := newAdminLotteryDB(t) + defer cleanup() + + // build depth 9 tree + tree := map[string]any{"op": "OR", "children": []any{}} + cur := tree + for i := 1; i < 9; i++ { + next := map[string]any{"op": "OR", "children": []any{}} + cur["children"] = []any{next} + cur = next + } + raw, _ := json.Marshal(tree) + req := &types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: raw} + logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db}) + err := logic.UpdateLotteryRules(req) + var ce *xerr.CodeError + if !errors.As(err, &ce) || ce.GetErrCode() != xerr.LotteryRuleTooDeep { + t.Fatalf("expected LotteryRuleTooDeep, got %v", err) + } +} + +func TestUpdateLotteryRules_RejectsAnonymousCaller(t *testing.T) { + db, _, cleanup := newAdminLotteryDB(t) + defer cleanup() + + logic := NewUpdateLotteryRulesLogic(context.Background(), &svc.ServiceContext{DB: db}) + err := logic.UpdateLotteryRules(&types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: json.RawMessage("{}")}) + var ce *xerr.CodeError + if !errors.As(err, &ce) || ce.GetErrCode() != xerr.ErrorTokenInvalid { + t.Fatalf("expected token invalid, got %v", err) + } +} + +func TestUpdateLotteryRules_ValidTreePersists(t *testing.T) { + db, mock, cleanup := newAdminLotteryDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `lottery_activity`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO `admin_action_log`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + req := &types.UpdateAdminLotteryRulesRequest{ + Id: 1, + Eligibility: json.RawMessage(`{"type":"has_subscription"}`), + ChanceSources: json.RawMessage(`[{"source":"daily_signin","amount":1}]`), + } + logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db}) + if err := logic.UpdateLotteryRules(req); err != nil { + t.Fatalf("UpdateLotteryRules: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestUpdateLotteryRules_MissingBothFieldsRejects(t *testing.T) { + db, _, cleanup := newAdminLotteryDB(t) + defer cleanup() + + req := &types.UpdateAdminLotteryRulesRequest{Id: 1} + logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db}) + err := logic.UpdateLotteryRules(req) + var ce *xerr.CodeError + if !errors.As(err, &ce) || ce.GetErrCode() != xerr.InvalidParams { + t.Fatalf("expected InvalidParams, got %v", err) + } +} diff --git a/internal/logic/audit/admin_action_log.go b/internal/logic/audit/admin_action_log.go new file mode 100644 index 0000000..a1306b6 --- /dev/null +++ b/internal/logic/audit/admin_action_log.go @@ -0,0 +1,100 @@ +// Package audit records administrative write actions to the admin_action_log +// table so security/compliance can trace who did what across lottery admin +// endpoints. Every admin CRUD in PR C calls WriteAdminAction inside its own +// transaction; the caller is expected to have already validated permissions. +package audit + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +// Action code convention: dot-separated, prefix by domain (e.g. +// "lottery.activity.create", "lottery.prize.delete"). Keep them short and +// stable so downstream analytics can pivot without maintaining a translation +// table. +const ( + ActionLotteryActivityCreate = "lottery.activity.create" + ActionLotteryActivityUpdate = "lottery.activity.update" + ActionLotteryActivityDelete = "lottery.activity.delete" + ActionLotteryActivityPublish = "lottery.activity.publish" + ActionLotteryActivityPause = "lottery.activity.pause" + ActionLotteryPrizeCreate = "lottery.prize.create" + ActionLotteryPrizeUpdate = "lottery.prize.update" + ActionLotteryPrizeDelete = "lottery.prize.delete" + ActionLotteryRulesPut = "lottery.activity.rules.put" + ActionLotteryChancesGrant = "lottery.chances.grant" +) + +// AdminActionLog is the GORM entity for admin_action_log. +type AdminActionLog struct { + Id int64 `gorm:"primaryKey"` + ActorUserId int64 `gorm:"type:bigint unsigned;not null;comment:操作者 user.id"` + Action string `gorm:"type:varchar(64);not null;comment:动作 code"` + TargetIds string `gorm:"type:varchar(255);not null;default:'';comment:被操作对象 ID"` + RequestHash string `gorm:"type:varchar(64);not null;default:'';comment:请求摘要"` + IP string `gorm:"type:varchar(45);not null;default:'';comment:操作者 IP"` + UserAgent string `gorm:"type:varchar(255);not null;default:'';comment:操作者 UA"` + CreatedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:操作时间"` +} + +// TableName pins the entity to the migration table name. +func (AdminActionLog) TableName() string { return "admin_action_log" } + +// Entry is the pre-hashed convenience input to WriteAdminAction. Callers +// build one with actor + action + payload fields; the writer computes the +// request hash and inserts inside tx. +type Entry struct { + ActorUserId int64 + Action string + // TargetIds is stringified list of primary keys touched by this action. + // Free-form: comma-separated ints, JSON array, etc. + TargetIds string + // RequestBody is hashed to produce request_hash. Pass nil if not applicable. + RequestBody []byte + IP string + UserAgent string +} + +// WriteAdminAction inserts an admin_action_log row inside the caller's tx. +// The row lives-or-dies with the caller's transaction: a rollback drops the +// audit trail, which is the intended coupling — we don't want to record +// actions that never happened. +func WriteAdminAction(ctx context.Context, tx *gorm.DB, e Entry) error { + if tx == nil { + return fmt.Errorf("audit: WriteAdminAction requires a transaction handle") + } + if e.ActorUserId == 0 || e.Action == "" { + return fmt.Errorf("audit: WriteAdminAction requires ActorUserId and Action") + } + row := AdminActionLog{ + ActorUserId: e.ActorUserId, + Action: strings.TrimSpace(e.Action), + TargetIds: e.TargetIds, + RequestHash: hashBody(e.RequestBody), + IP: e.IP, + UserAgent: truncate(e.UserAgent, 255), + } + return tx.WithContext(ctx).Create(&row).Error +} + +func hashBody(body []byte) string { + if len(body) == 0 { + return "" + } + sum := sha1.Sum(body) + return hex.EncodeToString(sum[:]) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/internal/logic/audit/admin_action_log_test.go b/internal/logic/audit/admin_action_log_test.go new file mode 100644 index 0000000..d8323be --- /dev/null +++ b/internal/logic/audit/admin_action_log_test.go @@ -0,0 +1,111 @@ +package audit + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if strings.Contains(actual, expected) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actual, expected) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm: %v", err) + } + return db, mock, func() { _ = sqlDB.Close() } +} + +func TestWriteAdminAction_RequiresTx(t *testing.T) { + err := WriteAdminAction(context.Background(), nil, Entry{ActorUserId: 1, Action: "x"}) + if err == nil { + t.Fatalf("expected error on nil tx") + } +} + +func TestWriteAdminAction_RequiresActorAndAction(t *testing.T) { + db, _, cleanup := newTestDB(t) + defer cleanup() + + if err := WriteAdminAction(context.Background(), db, Entry{Action: "x"}); err == nil { + t.Fatal("expected error when ActorUserId=0") + } + if err := WriteAdminAction(context.Background(), db, Entry{ActorUserId: 1}); err == nil { + t.Fatal("expected error when Action empty") + } +} + +func TestWriteAdminAction_InsertsRow(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + mock.ExpectExec("INSERT INTO `admin_action_log`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := WriteAdminAction(context.Background(), db, Entry{ + ActorUserId: 42, + Action: ActionLotteryActivityCreate, + TargetIds: "[1,2,3]", + RequestBody: []byte(`{"title":"test"}`), + IP: "127.0.0.1", + UserAgent: "curl/7.85", + }) + if err != nil { + t.Fatalf("WriteAdminAction: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestHashBody(t *testing.T) { + if got := hashBody(nil); got != "" { + t.Fatalf("nil body should hash to empty, got %q", got) + } + if got := hashBody([]byte("")); got != "" { + t.Fatalf("empty body should hash to empty, got %q", got) + } + if got := hashBody([]byte("abc")); len(got) != 40 { + t.Fatalf("expected 40-char sha1 hex, got %q", got) + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("hello", 10); got != "hello" { + t.Fatalf("short strings pass through, got %q", got) + } + if got := truncate("hello world", 5); got != "hello" { + t.Fatalf("expected truncation to 5, got %q", got) + } +} + +func TestWriteAdminAction_DBError(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + mock.ExpectExec("INSERT INTO `admin_action_log`"). + WillReturnError(errors.New("db down")) + + err := WriteAdminAction(context.Background(), db, Entry{ + ActorUserId: 1, + Action: "test", + }) + if err == nil { + t.Fatal("expected error propagation from DB") + } +} diff --git a/internal/logic/lottery/draw/service.go b/internal/logic/lottery/draw/service.go new file mode 100644 index 0000000..e2b55e2 --- /dev/null +++ b/internal/logic/lottery/draw/service.go @@ -0,0 +1,547 @@ +// Package draw implements POST /api/v1/lottery/draw: the transactional lottery +// draw flow. The service composes the ChanceService, RuleEvaluator, +// WeightedPicker, PrizeHandler.Registry and LedgerService primitives from the +// model layer into one atomic sequence. +// +// Ordering matters — the flow is: +// 1. feature-flag gate (config.Lottery.Enable) +// 2. Redis rate limit (per-user 1/sec) +// 3. Load activity + validate window/status +// 4. Build RuleContext (pre-tx reads) +// 5. Evaluate eligibility (pure compute) +// 6. Load prize pool snapshot (pre-tx read) +// 7. Open tx → +// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce) +// — hit returns the recorded draw +// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement) +// 7c. WeightedPicker.Pick +// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0 +// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot +// 7f. Auto-handler Dispatch (in tx) +// 7g. Update draw.dispatch_state +// → commit +// +// Everything past step 5 uses the caller's transaction; post-commit cache +// invalidation is the handler layer's job (a future enhancement — the +// underlying UserModel already invalidates its own cache on UpdateSubscribe). +package draw + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/perfect-panel/server/internal/model/lottery" + "github.com/perfect-panel/server/pkg/limit" + "github.com/perfect-panel/server/pkg/xerr" + "gorm.io/gorm" +) + +// Request is the input to Draw. +type Request struct { + UserId int64 + ActivityId int64 + ClientNonce string +} + +// Result is what Draw returns to the caller (user handler). +type Result struct { + DrawId int64 + IsWin bool + Prize *PrizeSummary + ChancesRemaining int64 + Claim ClaimSummary + Message string +} + +// PrizeSummary is the awarded-prize view rendered for the user. +type PrizeSummary struct { + Slot int + Id int64 + Type string + Name string + Config json.RawMessage +} + +// ClaimSummary describes whether the user needs to take a further action. +type ClaimSummary struct { + Required bool + AutoClaimed bool + Message string +} + +// Service orchestrates the transactional draw flow. Deps are struct-injected +// so tests can substitute fakes and production wiring lives in ServiceContext. +type Service struct { + deps Deps +} + +// Deps groups the collaborators. Nil-safe checks live in Draw itself, not here. +type Deps struct { + DB *gorm.DB + Enabled bool + RateLimiter RateLimiter + Chance lottery.ChanceService + Evaluator lottery.RuleEvaluator + Picker lottery.WeightedPicker + Registry lottery.Registry + ContextBuilder RuleContextBuilder +} + +// RateLimiter admits at most 1 draw per second per user. Extracted to an +// interface so tests can supply an always-admit fake without pulling Redis. +type RateLimiter interface { + Allow(ctx context.Context, userId int64) error +} + +// RuleContextBuilder loads the per-user snapshot needed by the rule +// evaluator. Extracted so tests can inject deterministic contexts. +type RuleContextBuilder interface { + Build(ctx context.Context, userId int64) (lottery.RuleContext, error) +} + +// NewService returns a Draw service ready to serve requests. +func NewService(d Deps) *Service { return &Service{deps: d} } + +// Draw runs the full lottery draw flow. Errors are xerr codes suitable for +// direct return by the HTTP handler; internal errors are wrapped as +// LotteryInternalError. +func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) { + if err := s.validateRequest(req); err != nil { + return nil, err + } + if !s.deps.Enabled { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + if err := s.applyRateLimit(ctx, req.UserId); err != nil { + return nil, err + } + + activity, err := s.loadRunningActivity(ctx, req.ActivityId) + if err != nil { + return nil, err + } + + // Pre-tx reads: user context + prize pool snapshot. Cheap and out of the + // hot-lock window; the tx step re-checks stock atomically. + rc, err := s.buildRuleContext(ctx, req.UserId) + if err != nil { + return nil, wrapInternal(err) + } + + tree, err := parseEligibilityTree(activity.Eligibility) + if err != nil { + return nil, wrapInternal(err) + } + passed, unmet, err := s.deps.Evaluator.Evaluate(ctx, tree, rc) + if err != nil { + return nil, wrapInternal(err) + } + if !passed { + // The rejection itself is not an error to the caller — but we still + // record an eligibility snapshot for support/audit before returning + // the 4001. Snapshot write intentionally uses its own tx: the draw + // itself never got issued, so there is no draw_id to correlate; we + // omit the snapshot in that case. + _ = unmet // unmet is available to the handler via error metadata if needed + return nil, xerr.NewErrCode(xerr.LotteryNotEligible) + } + + prizes, err := s.loadPrizes(ctx, req.ActivityId) + if err != nil { + return nil, wrapInternal(err) + } + if len(prizes) == 0 { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + + var result *Result + txErr := s.deps.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // (a) Nonce dedupe — race-safe idempotency check. + if existing, existsErr := s.findExistingDraw(ctx, tx, req); existsErr != nil { + return existsErr + } else if existing != nil { + result, existsErr = s.buildResultFromExistingDraw(ctx, tx, existing) + return existsErr + } + + // (b) Consume chance atomically. ErrNoChances → 4002. + remaining, consumeErr := s.deps.Chance.Consume(ctx, tx, req.UserId, req.ActivityId) + if errors.Is(consumeErr, lottery.ErrNoChances) { + return xerr.NewErrCode(xerr.LotteryNoChances) + } + if consumeErr != nil { + return wrapInternal(consumeErr) + } + + // (c) Pick a prize. + idx, pickErr := s.deps.Picker.Pick(prizes) + if pickErr != nil { + return xerr.NewErrCode(xerr.LotteryActivityEnded) + } + picked := prizes[idx] + + // (d) Limited-stock decrement. + final, stockErr := s.decrementStockOrFallback(ctx, tx, picked, prizes) + if stockErr != nil { + return wrapInternal(stockErr) + } + + // (e) Insert draw + snapshots. + draw, insertErr := s.insertDraw(ctx, tx, req, final) + if insertErr != nil { + return wrapInternal(insertErr) + } + if snapErr := s.insertSnapshots(ctx, tx, draw, final, passed); snapErr != nil { + return wrapInternal(snapErr) + } + + // (f) Dispatch prize. + dispatch, dispatchErr := s.dispatchIfAutomatic(ctx, tx, req, draw, final) + if dispatchErr != nil { + return dispatchErr + } + + // (g) Update draw.dispatch_state to reflect handler outcome. + if updateErr := s.finalizeDrawState(ctx, tx, draw, dispatch); updateErr != nil { + return wrapInternal(updateErr) + } + + result = buildResult(draw, final, dispatch, remaining) + return nil + }) + if txErr != nil { + if _, ok := txErr.(*xerr.CodeError); ok { + return nil, txErr + } + return nil, wrapInternal(txErr) + } + return result, nil +} + +// ---- individual steps ------------------------------------------------------ + +func (s *Service) validateRequest(req Request) error { + if req.UserId <= 0 || req.ActivityId <= 0 || req.ClientNonce == "" { + return xerr.NewErrCode(xerr.InvalidParams) + } + if len(req.ClientNonce) > 64 { + return xerr.NewErrCode(xerr.InvalidParams) + } + return nil +} + +func (s *Service) applyRateLimit(ctx context.Context, userId int64) error { + if s.deps.RateLimiter == nil { + return nil + } + if err := s.deps.RateLimiter.Allow(ctx, userId); err != nil { + if errors.Is(err, ErrRateLimited) { + return xerr.NewErrCode(xerr.LotteryRateLimited) + } + return wrapInternal(err) + } + return nil +} + +func (s *Service) loadRunningActivity(ctx context.Context, activityId int64) (*lottery.Activity, error) { + var activity lottery.Activity + now := time.Now() + err := s.deps.DB.WithContext(ctx). + Where("id = ?", activityId). + First(&activity).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return nil, wrapInternal(err) + } + if activity.Status != lottery.ActivityStatusRunning { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + if activity.StartAt.After(now) || activity.EndAt.Before(now) { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return &activity, nil +} + +func (s *Service) buildRuleContext(ctx context.Context, userId int64) (lottery.RuleContext, error) { + if s.deps.ContextBuilder == nil { + return lottery.RuleContext{UserId: userId, Now: time.Now().Unix()}, nil + } + return s.deps.ContextBuilder.Build(ctx, userId) +} + +func (s *Service) loadPrizes(ctx context.Context, activityId int64) ([]lottery.Prize, error) { + var prizes []lottery.Prize + err := s.deps.DB.WithContext(ctx). + Where("activity_id = ?", activityId). + Order("slot ASC"). + Find(&prizes).Error + if err != nil { + return nil, err + } + return prizes, nil +} + +func (s *Service) findExistingDraw(ctx context.Context, tx *gorm.DB, req Request) (*lottery.Draw, error) { + var existing lottery.Draw + err := tx.WithContext(ctx). + Where("user_id = ? AND client_nonce = ?", req.UserId, req.ClientNonce). + First(&existing).Error + if err == nil { + return &existing, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err +} + +// decrementStockOrFallback runs the limited-stock optimistic lock. If the +// chosen prize is unlimited, returns it as-is. If limited and stock survives +// → returns it. If limited and sold-out → walks the pool for the first +// `is_fallback=true` prize or falls back to a "none" (thanks-for-playing) +// synthetic prize. +func (s *Service) decrementStockOrFallback(ctx context.Context, tx *gorm.DB, picked lottery.Prize, pool []lottery.Prize) (lottery.Prize, error) { + if !picked.RemainingStock.Valid { + return picked, nil + } + res := tx.WithContext(ctx). + Model(&lottery.Prize{}). + Where("id = ? AND remaining_stock > 0", picked.Id). + UpdateColumn("remaining_stock", gorm.Expr("`remaining_stock` - 1")) + if res.Error != nil { + return lottery.Prize{}, res.Error + } + if res.RowsAffected > 0 { + return picked, nil + } + // Sold out → fallback selection. + for _, p := range pool { + if p.IsFallback { + return p, nil + } + } + // No fallback declared → synthesize a "谢谢参与" from the last non-fallback + // entry (any type=none in the pool wins); if pool has no none, we + // synthesize an ephemeral prize record. Note: this prize is NOT persisted + // as a separate row — it just satisfies the return contract. + for _, p := range pool { + if p.Type == lottery.PrizeTypeNone { + return p, nil + } + } + return lottery.Prize{ + ActivityId: picked.ActivityId, + Slot: picked.Slot, + Type: lottery.PrizeTypeNone, + Name: "谢谢参与", + Config: "{}", + }, nil +} + +func (s *Service) insertDraw(ctx context.Context, tx *gorm.DB, req Request, prize lottery.Prize) (*lottery.Draw, error) { + draw := lottery.Draw{ + UserId: req.UserId, + ActivityId: req.ActivityId, + ClientNonce: req.ClientNonce, + IsWin: prize.Type != lottery.PrizeTypeNone, + DispatchState: lottery.DispatchStateNone, + DrawnAt: time.Now(), + } + if prize.Id > 0 { + draw.PrizeId = sql.NullInt64{Int64: prize.Id, Valid: true} + } + if err := tx.WithContext(ctx).Create(&draw).Error; err != nil { + return nil, err + } + return &draw, nil +} + +func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, prize lottery.Prize, passedEligibility bool) error { + ps := lottery.PrizeSnapshot{ + DrawId: draw.Id, + PrizeId: prize.Id, + Slot: prize.Slot, + Type: prize.Type, + Name: prize.Name, + Config: prize.Config, + } + if ps.Config == "" { + ps.Config = "{}" + } + if err := tx.WithContext(ctx).Create(&ps).Error; err != nil { + return err + } + es := lottery.EligibilitySnapshot{ + DrawId: draw.Id, + UserId: draw.UserId, + ActivityId: draw.ActivityId, + Passed: passedEligibility, + } + return tx.WithContext(ctx).Create(&es).Error +} + +func (s *Service) dispatchIfAutomatic(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, error) { + if !draw.IsWin { + return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, nil + } + if s.deps.Registry == nil { + return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil + } + handler, err := s.deps.Registry.MustGet(prize.Type) + if err != nil { + if errors.Is(err, lottery.ErrHandlerNotRegistered) { + return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil + } + return lottery.DispatchResult{}, wrapInternal(err) + } + if !handler.IsAuto() { + return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil + } + dispatchReq := lottery.DispatchRequest{ + UserId: req.UserId, + ActivityId: req.ActivityId, + DrawId: draw.Id, + Prize: prize, + Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config}, + IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id), + } + return handler.Dispatch(ctx, tx, dispatchReq) +} + +func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error { + now := time.Now() + updates := map[string]any{ + "dispatch_state": dispatch.State, + } + if dispatch.State == lottery.DispatchStateAutoClaimed || dispatch.State == lottery.DispatchStatePaid { + updates["dispatched_at"] = now + } + return tx.WithContext(ctx). + Model(&lottery.Draw{}). + Where("id = ?", draw.Id). + Updates(updates).Error +} + +func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, existing *lottery.Draw) (*Result, error) { + var snap lottery.PrizeSnapshot + err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&snap).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + remaining, _ := s.deps.Chance.Query(ctx, existing.UserId, existing.ActivityId) + var prize *PrizeSummary + if existing.IsWin { + prize = &PrizeSummary{ + Slot: snap.Slot, + Id: snap.PrizeId, + Type: snap.Type, + Name: snap.Name, + Config: json.RawMessage(snap.Config), + } + } + return &Result{ + DrawId: existing.Id, + IsWin: existing.IsWin, + Prize: prize, + ChancesRemaining: remaining, + Claim: ClaimSummary{ + Required: existing.DispatchState == lottery.DispatchStatePendingClaim, + AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed, + Message: "", + }, + }, nil +} + +func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, remaining int64) *Result { + res := &Result{ + DrawId: draw.Id, + IsWin: draw.IsWin, + ChancesRemaining: remaining, + Message: dispatch.Message, + Claim: ClaimSummary{ + Required: dispatch.State == lottery.DispatchStatePendingClaim, + AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed, + Message: dispatch.Message, + }, + } + if draw.IsWin { + res.Prize = &PrizeSummary{ + Slot: prize.Slot, + Id: prize.Id, + Type: prize.Type, + Name: prize.Name, + Config: json.RawMessage(prize.Config), + } + } + return res +} + +// parseEligibilityTree tolerates empty/null activity.Eligibility as "no gate". +func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) { + trimmed := "" + for _, r := range raw { + if r != ' ' && r != '\t' && r != '\n' && r != '\r' { + trimmed += string(r) + } + } + if trimmed == "" || trimmed == "null" || trimmed == "{}" { + return nil, nil + } + var tree lottery.EligibilityRule + if err := json.Unmarshal([]byte(raw), &tree); err != nil { + return nil, fmt.Errorf("parse eligibility: %w", err) + } + return &tree, nil +} + +func wrapInternal(err error) error { + if err == nil { + return nil + } + // Preserve already-coded errors. + if _, ok := err.(*xerr.CodeError); ok { + return err + } + return xerr.NewErrCodeMsg(xerr.LotteryInternalError, err.Error()) +} + +// ---- Rate limiter production wiring ---------------------------------------- + +// ErrRateLimited is returned by RateLimiter.Allow when the caller exceeded +// the configured quota. +var ErrRateLimited = errors.New("draw: rate limited") + +// RedisRateLimiter is the production RateLimiter backed by pkg/limit's +// Redis-Lua fixed-window (1 hit per 1 second per user), matching the +// existing sendEmailCodeLogic pattern. +type RedisRateLimiter struct { + limiter *limit.PeriodLimit +} + +// NewRedisRateLimiter builds a per-user 1-req/1-sec limiter. keyPrefix is +// expected to end with ':' so the composed key is human-readable. +func NewRedisRateLimiter(limiter *limit.PeriodLimit) *RedisRateLimiter { + return &RedisRateLimiter{limiter: limiter} +} + +// Allow admits or rejects the caller. +func (r *RedisRateLimiter) Allow(ctx context.Context, userId int64) error { + if r == nil || r.limiter == nil { + return nil + } + state, err := r.limiter.TakeCtx(ctx, strconv.FormatInt(userId, 10)) + if err != nil { + return err + } + if state == limit.Allowed || state == limit.HitQuota { + return nil + } + return ErrRateLimited +} diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go new file mode 100644 index 0000000..35ddce6 --- /dev/null +++ b/internal/logic/lottery/draw/service_test.go @@ -0,0 +1,513 @@ +package draw + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/model/lottery" + "github.com/perfect-panel/server/pkg/xerr" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// ---- test fakes ------------------------------------------------------------ + +type fakeRateLimiter struct { + mu sync.Mutex + err error + calls int +} + +func (f *fakeRateLimiter) Allow(_ context.Context, _ int64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + return f.err +} + +type fakeChance struct { + mu sync.Mutex + consumeRemaining int64 + consumeErr error + consumeCalls int + queryRemaining int64 +} + +func (f *fakeChance) Grant(context.Context, int64, int64, string, string, int) error { + return nil +} +func (f *fakeChance) Consume(_ context.Context, _ *gorm.DB, _, _ int64) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.consumeCalls++ + if f.consumeErr != nil { + return 0, f.consumeErr + } + return f.consumeRemaining, nil +} +func (f *fakeChance) Query(_ context.Context, _, _ int64) (int64, error) { + return f.queryRemaining, nil +} + +type fakeEvaluator struct { + passed bool + unmet []lottery.UnmetReason + err error +} + +func (f *fakeEvaluator) Evaluate(context.Context, *lottery.EligibilityRule, lottery.RuleContext) (bool, []lottery.UnmetReason, error) { + return f.passed, f.unmet, f.err +} + +type fakePicker struct { + idx int + err error +} + +func (f *fakePicker) Pick([]lottery.Prize) (int, error) { return f.idx, f.err } + +type fakeContextBuilder struct{} + +func (fakeContextBuilder) Build(_ context.Context, uid int64) (lottery.RuleContext, error) { + return lottery.RuleContext{UserId: uid}, nil +} + +type recordingHandler struct { + handlerType string + auto bool + result lottery.DispatchResult + err error + calls int +} + +func (h *recordingHandler) Type() string { return h.handlerType } +func (h *recordingHandler) IsAuto() bool { return h.auto } +func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) { + h.calls++ + return h.result, h.err +} +func (h *recordingHandler) ValidateClaim([]byte) error { return nil } + +// stubRegistry only knows what we register. +type stubRegistry struct { + handlers map[string]lottery.PrizeHandler +} + +func (r *stubRegistry) Get(t string) (lottery.PrizeHandler, bool) { + h, ok := r.handlers[t] + return h, ok +} +func (r *stubRegistry) MustGet(t string) (lottery.PrizeHandler, error) { + if h, ok := r.handlers[t]; ok { + return h, nil + } + return nil, lottery.ErrHandlerNotRegistered +} + +// ---- shared harness -------------------------------------------------------- + +func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if strings.Contains(actual, expected) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actual, expected) + }))) + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("gorm: %v", err) + } + return db, mock, func() { _ = sqlDB.Close() } +} + +// expectRunningActivity sets up the pre-tx activity load. +func expectRunningActivity(mock sqlmock.Sqlmock, activityId int64) { + now := time.Now() + mock.ExpectQuery("FROM `lottery_activity`"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "title", "start_at", "end_at", "status", "grid_size", "eligibility", "chance_sources", "unmet_action", + }).AddRow(activityId, "test", now.Add(-1*time.Hour), now.Add(24*time.Hour), lottery.ActivityStatusRunning, 9, "{}", "[]", "block")) +} + +// expectPrizePool sets up the pre-tx prize load. +func expectPrizePool(mock sqlmock.Sqlmock, activityId int64, prizes ...lottery.Prize) { + rows := sqlmock.NewRows([]string{"id", "activity_id", "slot", "type", "name", "icon_url", "config", "weight", "total_stock", "remaining_stock", "is_fallback", "version"}) + for _, p := range prizes { + rows.AddRow(p.Id, p.ActivityId, p.Slot, p.Type, p.Name, p.IconURL, p.Config, p.Weight, p.TotalStock, p.RemainingStock, p.IsFallback, p.Version) + } + mock.ExpectQuery("FROM `lottery_prize`").WillReturnRows(rows) +} + +// expectExistingDrawEmpty sets up the tx-inner nonce lookup returning no rows. +func expectExistingDrawEmpty(mock sqlmock.Sqlmock) { + mock.ExpectQuery("FROM `lottery_draw`").WillReturnError(gorm.ErrRecordNotFound) +} + +// ---- Test cases ------------------------------------------------------------ + +func TestDraw_RejectsWhenFeatureFlagDisabled(t *testing.T) { + db, _, cleanup := newTestDB(t) + defer cleanup() + + svc := NewService(Deps{ + DB: db, + Enabled: false, + }) + _, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "n1"}) + code, ok := errAsCode(err) + if !ok || code != xerr.LotteryActivityEnded { + t.Fatalf("expected LotteryActivityEnded when disabled, got %v", err) + } +} + +func TestDraw_ValidatesRequest(t *testing.T) { + db, _, cleanup := newTestDB(t) + defer cleanup() + + svc := NewService(Deps{DB: db, Enabled: true}) + cases := []Request{ + {UserId: 0, ActivityId: 1, ClientNonce: "n"}, + {UserId: 1, ActivityId: 0, ClientNonce: "n"}, + {UserId: 1, ActivityId: 1, ClientNonce: ""}, + {UserId: 1, ActivityId: 1, ClientNonce: strings.Repeat("x", 65)}, + } + for i, c := range cases { + if _, err := svc.Draw(context.Background(), c); err == nil { + t.Fatalf("case %d expected error, got nil", i) + } + } +} + +func TestDraw_RateLimited(t *testing.T) { + db, _, cleanup := newTestDB(t) + defer cleanup() + + limiter := &fakeRateLimiter{err: ErrRateLimited} + svc := NewService(Deps{DB: db, Enabled: true, RateLimiter: limiter}) + _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 1, ClientNonce: "n"}) + code, ok := errAsCode(err) + if !ok || code != xerr.LotteryRateLimited { + t.Fatalf("expected LotteryRateLimited, got %v", err) + } +} + +func TestDraw_NonceIdempotency_ReturnsExisting(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1}) + + mock.ExpectBegin() + // nonce hit — the flow short-circuits + mock.ExpectQuery("FROM `lottery_draw`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "client_nonce", "prize_id", "is_win", "dispatch_state", "drawn_at"}). + AddRow(int64(999), int64(42), int64(100), "same-nonce", nil, false, lottery.DispatchStateAutoClaimed, time.Now())) + // snapshot lookup + mock.ExpectQuery("FROM `lottery_prize_snapshot`"). + WillReturnRows(sqlmock.NewRows([]string{"id", "draw_id", "prize_id", "slot", "type", "name", "config"}). + AddRow(int64(1), int64(999), int64(0), 0, lottery.PrizeTypeNone, "谢谢参与", "{}")) + mock.ExpectCommit() + + chance := &fakeChance{queryRemaining: 3} + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: chance, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "same-nonce"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if res.DrawId != 999 { + t.Fatalf("expected reuse existing draw id=999, got %d", res.DrawId) + } + if chance.consumeCalls != 0 { + t.Fatalf("must NOT Consume a chance on nonce replay") + } +} + +func TestDraw_NoChancesReturnsCode(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1}) + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + mock.ExpectRollback() + + chance := &fakeChance{consumeErr: lottery.ErrNoChances} + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: chance, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) + code, ok := errAsCode(err) + if !ok || code != xerr.LotteryNoChances { + t.Fatalf("expected LotteryNoChances, got %v", err) + } +} + +func TestDraw_NotEligibleRejectsBeforeConsume(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: &fakeChance{}, + Evaluator: &fakeEvaluator{passed: false, unmet: []lottery.UnmetReason{{Rule: "invite_count", Hint: "need 3"}}}, + Picker: &fakePicker{}, + Registry: &stubRegistry{}, + ContextBuilder: fakeContextBuilder{}, + }) + _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) + code, ok := errAsCode(err) + if !ok || code != xerr.LotteryNotEligible { + t.Fatalf("expected LotteryNotEligible, got %v", err) + } + // Ensure no mock expectations remain (we didn't set up prize load). + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestDraw_SuccessAutoClaimedNoneReturnsDrawWithoutDispatch(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + // Two prizes: one none (weight 100) — picker returns idx 0 always + expectPrizePool(mock, 100, + lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100}, + ) + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + // insert draw + mock.ExpectExec("INSERT INTO `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(555, 1)) + // insert prize_snapshot + mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + // insert eligibility_snapshot + mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + // finalize draw state + mock.ExpectExec("UPDATE `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + chance := &fakeChance{consumeRemaining: 2} + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: chance, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "unique-1"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if res.IsWin { + t.Fatalf("none prize should not count as win") + } + if res.DrawId == 0 { + t.Fatalf("expected draw id from LastInsertId") + } + if res.ChancesRemaining != 2 { + t.Fatalf("expected remaining=2 from Consume, got %d", res.ChancesRemaining) + } +} + +func TestDraw_LimitedStockFallback(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + // prize 0 = limited (remaining=0 → sold out); prize 1 = fallback + expectPrizePool(mock, 100, + lottery.Prize{Id: 10, ActivityId: 100, Slot: 0, Type: "vpn_duration", Name: "3天", Config: `{"duration_days":3}`, Weight: 100, TotalStock: sql.NullInt64{Int64: 1, Valid: true}, RemainingStock: sql.NullInt64{Int64: 1, Valid: true}}, + lottery.Prize{Id: 20, ActivityId: 100, Slot: 1, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 0, IsFallback: true}, + ) + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + // stock decrement returns 0 rows affected → sold out + mock.ExpectExec("UPDATE `lottery_prize`"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(778, 1)) + mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("UPDATE `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: &fakeChance{consumeRemaining: 1}, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if res.IsWin { + t.Fatalf("fallback none should not win") + } + if res.DrawId != 778 { + t.Fatalf("expected draw id 778, got %d", res.DrawId) + } +} + +func TestDraw_WinCallsAutoHandlerDispatch(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + expectPrizePool(mock, 100, + lottery.Prize{Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration, Name: "3 天", Config: `{"duration_days":3}`, Weight: 100}, + ) + + handler := &recordingHandler{ + handlerType: lottery.PrizeTypeVPNDuration, + auto: true, + result: lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "已加 3 天"}, + } + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + mock.ExpectExec("INSERT INTO `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(1234, 1)) + mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("UPDATE `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: &fakeChance{consumeRemaining: 0}, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: handler}}, + ContextBuilder: fakeContextBuilder{}, + }) + res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if !res.IsWin { + t.Fatalf("expected IsWin=true for vpn_duration") + } + if handler.calls != 1 { + t.Fatalf("expected handler.Dispatch called once, got %d", handler.calls) + } + if res.Claim.AutoClaimed != true { + t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim) + } + if res.Message != "已加 3 天" { + t.Fatalf("expected message from Dispatch, got %q", res.Message) + } + if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration { + t.Fatalf("expected prize summary, got %+v", res.Prize) + } + // Prize.Config should be embedded json.RawMessage — verify decodes + var cfg map[string]any + if err := json.Unmarshal(res.Prize.Config, &cfg); err != nil { + t.Fatalf("Prize.Config invalid: %v", err) + } + if cfg["duration_days"].(float64) != 3 { + t.Fatalf("expected duration_days=3, got %v", cfg["duration_days"]) + } +} + +func TestDraw_UnregisteredAutoHandlerFallsBackToPendingClaim(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + expectPrizePool(mock, 100, + lottery.Prize{Id: 40, ActivityId: 100, Slot: 0, Type: "encrypted", Name: "Encrypted", Config: "{}", Weight: 100}, + ) + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + mock.ExpectExec("INSERT INTO `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("UPDATE `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + svc := NewService(Deps{ + DB: db, + Enabled: true, + Chance: &fakeChance{consumeRemaining: 0}, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w2"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if res.Claim.Required != true { + t.Fatalf("expected Claim.Required=true when handler unregistered, got %+v", res.Claim) + } +} + +// errAsCode helps assert xerr.CodeError codes. +func errAsCode(err error) (uint32, bool) { + if err == nil { + return 0, false + } + var ce *xerr.CodeError + if errors.As(err, &ce) { + return ce.GetErrCode(), true + } + return 0, false +} diff --git a/internal/logic/lottery/handler/commission_log.go b/internal/logic/lottery/handler/commission_log.go new file mode 100644 index 0000000..89c9331 --- /dev/null +++ b/internal/logic/lottery/handler/commission_log.go @@ -0,0 +1,32 @@ +package handler + +import ( + "time" + + logmodel "github.com/perfect-panel/server/internal/model/log" + "gorm.io/gorm" +) + +// WriteCommissionLog 是 internal/logic/common.WriteCommissionLog 的镜像。 +// 抽到 handler 包避免 internal/svc → internal/logic/common 的 import cycle +// (internal/logic/common 里有别的文件反向 import 了 svc)。函数体保持一致, +// 未来若 common 侧调整了签名或者行为要同步到这里。 +func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error { + logInfo := logmodel.Commission{ + Type: logType, + Amount: amount, + OrderNo: orderNo, + Timestamp: time.Now().UnixMilli(), + } + content, err := logInfo.Marshal() + if err != nil { + return err + } + return tx.Model(logmodel.SystemLog{}).Create(&logmodel.SystemLog{ + Type: logmodel.TypeCommission.Uint8(), + Date: time.Now().Format(time.DateOnly), + ObjectID: objectID, + Content: string(content), + CreatedAt: time.Now(), + }).Error +} diff --git a/internal/logic/lottery/handler/vpn_duration.go b/internal/logic/lottery/handler/vpn_duration.go index f76d928..0672532 100644 --- a/internal/logic/lottery/handler/vpn_duration.go +++ b/internal/logic/lottery/handler/vpn_duration.go @@ -16,7 +16,6 @@ import ( "fmt" "time" - commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/lottery" usermodel "github.com/perfect-panel/server/internal/model/user" "gorm.io/gorm" @@ -45,19 +44,33 @@ type VPNDurationDeps struct { ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error) } -// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现:走 -// commonLogic.ResolveEntitlementUser。用 struct 依赖注入避免 handler 直接依赖 -// 具体 SQL —— 测试里 stub 掉。 +// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现。语义与 +// internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner), +// 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common +// 的 import cycle(common 包里有别的文件反向 import 了 svc)。 func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) { return func(ctx context.Context, userID int64) (int64, error) { - entitlement, err := commonLogic.ResolveEntitlementUser(ctx, db, userID) - if err != nil { - return 0, err - } - if entitlement == nil || entitlement.EffectiveUserID <= 0 { + if userID <= 0 { return userID, nil } - return entitlement.EffectiveUserID, nil + var row struct { + OwnerUserID int64 `gorm:"column:owner_user_id"` + } + q := db.WithContext(ctx). + Table("user_family_member"). + Select("user_family.owner_user_id AS owner_user_id"). + Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL"). + Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive). + Order("user_family_member.role"). + Limit(1). + Scan(&row) + if q.Error != nil { + return 0, q.Error + } + if q.RowsAffected == 0 || row.OwnerUserID <= 0 { + return userID, nil + } + return row.OwnerUserID, nil } } diff --git a/internal/logic/lottery/hook/invite.go b/internal/logic/lottery/hook/invite.go index 978aa1a..c947783 100644 --- a/internal/logic/lottery/hook/invite.go +++ b/internal/logic/lottery/hook/invite.go @@ -86,9 +86,12 @@ func (h *defaultInviteHook) run(refererUserID int64, orderNo string) { continue } // ChanceService.Grant is idempotent per (activity_id, source, source_ref). - // orderNo as source_ref makes both new-purchase and renewal safe: same - // order → same key → at most one chance grant per (activity, order). - if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, orderNo, amount); err != nil { + // Prefix orderNo with "order:" so audit trails can tell business-order + // derived refs apart from other source families (manual_grant uses + // "manual:*", daily_signin uses "signin:*"). DB uniqueness is already + // bucketed by source, but the prefix makes log/analytics readable. + ref := "order:" + orderNo + if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, amount); err != nil { logger.WithContext(ctx).Error("[lottery invite hook] Grant failed", logger.Field("error", err.Error()), logger.Field("referer_user_id", refererUserID), diff --git a/internal/logic/lottery/hook/invite_test.go b/internal/logic/lottery/hook/invite_test.go index 3eb0380..d202a25 100644 --- a/internal/logic/lottery/hook/invite_test.go +++ b/internal/logic/lottery/hook/invite_test.go @@ -115,8 +115,8 @@ func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) { if c.source != lottery.ChanceSourceInviteSuccess { t.Fatalf("unexpected source: %+v", c) } - if c.sourceRef != "order-xyz" { - t.Fatalf("expected orderNo reused as source_ref, got %q", c.sourceRef) + if c.sourceRef != "order:order-xyz" { + t.Fatalf("expected orderNo prefixed as source_ref, got %q", c.sourceRef) } if c.userId != 42 { t.Fatalf("expected referer=42, got %d", c.userId) diff --git a/internal/logic/lottery/rulecaps/caps.go b/internal/logic/lottery/rulecaps/caps.go new file mode 100644 index 0000000..b40279c --- /dev/null +++ b/internal/logic/lottery/rulecaps/caps.go @@ -0,0 +1,112 @@ +package rulecaps + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/perfect-panel/server/internal/model/lottery" +) + +// 抽奖门槛规则树的固定上限。恶意 admin 或误配可以让 PUT rules 的 JSON 递归 +// 爆炸,评估时爆栈;这些常量给"合理配置"预留了充足空间,同时挡住 blob。 +const ( + // MaxDepth 是嵌套 AND/OR 允许的最大深度(根算 1 层)。 + MaxDepth = 8 + // MaxNodes 是整树里叶子 + 聚合节点总数上限。 + MaxNodes = 64 + // MaxBytes 是原始 JSON 字节数上限(8KB)。 + MaxBytes = 8 * 1024 +) + +// ErrRuleTreeTooDeep 表示 AND/OR 嵌套超过 MaxDepth。 +var ErrRuleTreeTooDeep = errors.New("rule tree exceeds max depth") + +// ErrRuleTreeTooManyNodes 表示节点总数超过 MaxNodes。 +var ErrRuleTreeTooManyNodes = errors.New("rule tree exceeds max node count") + +// ErrRuleTreeTooLarge 表示 JSON payload 超过 MaxBytes。 +var ErrRuleTreeTooLarge = errors.New("rule tree JSON exceeds max byte size") + +// ValidateEligibilityJSON 是 PUT /activities/{id}/rules 收到 eligibility JSON +// 时的准入闸门。三个上限任一超限 → 返回带上下文的错误,caller 直接 400。 +// 空 JSON、"{}"、`null` 都视为合法(表示"无门槛")。 +func ValidateEligibilityJSON(raw []byte) error { + if len(raw) > MaxBytes { + return fmt.Errorf("%w: %d bytes > %d limit", ErrRuleTreeTooLarge, len(raw), MaxBytes) + } + if len(raw) == 0 { + return nil + } + + // 允许 null / "{}" 表示无门槛。 + trimmed := trimJSONWhitespace(raw) + if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == "{}" { + return nil + } + + var tree lottery.EligibilityRule + if err := json.Unmarshal(raw, &tree); err != nil { + return fmt.Errorf("invalid eligibility JSON: %w", err) + } + return validateRule(&tree, 1) +} + +// validateRule 递归检查一棵规则树;depth 是当前节点所在层(根 = 1)。 +// 用共享计数器(返回值)而不是外部 counter 是为了让递归签名保持无副作用。 +func validateRule(node *lottery.EligibilityRule, depth int) error { + if node == nil { + return nil + } + if depth > MaxDepth { + return fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth) + } + count, err := countAndValidate(node, depth) + if err != nil { + return err + } + if count > MaxNodes { + return fmt.Errorf("%w: got %d nodes, max %d", ErrRuleTreeTooManyNodes, count, MaxNodes) + } + return nil +} + +// countAndValidate 深度优先遍历,一次递归同时统计节点数并做深度检查。 +// 返回 count 是子树总节点数(含当前节点);err 表明遍历中已经超限。 +func countAndValidate(node *lottery.EligibilityRule, depth int) (int, error) { + if node == nil { + return 0, nil + } + if depth > MaxDepth { + return 0, fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth) + } + total := 1 + for _, child := range node.Children { + sub, err := countAndValidate(child, depth+1) + if err != nil { + return 0, err + } + total += sub + // 提前退出:命中节点数上限就不要继续 walk 剩余分支。 + if total > MaxNodes { + return 0, fmt.Errorf("%w: got at least %d nodes, max %d", ErrRuleTreeTooManyNodes, total, MaxNodes) + } + } + return total, nil +} + +// trimJSONWhitespace 剥掉前后 JSON 空白,用于识别"实质空"的 payload。 +func trimJSONWhitespace(raw []byte) []byte { + i, j := 0, len(raw) + for i < j && isJSONWhitespace(raw[i]) { + i++ + } + for j > i && isJSONWhitespace(raw[j-1]) { + j-- + } + return raw[i:j] +} + +func isJSONWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/internal/logic/lottery/rulecaps/caps_test.go b/internal/logic/lottery/rulecaps/caps_test.go new file mode 100644 index 0000000..4314154 --- /dev/null +++ b/internal/logic/lottery/rulecaps/caps_test.go @@ -0,0 +1,127 @@ +package rulecaps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/perfect-panel/server/internal/model/lottery" +) + +func TestValidateEligibilityJSON_AcceptsEmpty(t *testing.T) { + cases := [][]byte{ + nil, + []byte(""), + []byte("{}"), + []byte("null"), + []byte(" \n \t "), + } + for _, c := range cases { + if err := ValidateEligibilityJSON(c); err != nil { + t.Fatalf("expected accept for %q, got %v", string(c), err) + } + } +} + +func TestValidateEligibilityJSON_RejectsOversizePayload(t *testing.T) { + blob := make([]byte, MaxBytes+1) + for i := range blob { + blob[i] = 'a' + } + err := ValidateEligibilityJSON(blob) + if !errors.Is(err, ErrRuleTreeTooLarge) { + t.Fatalf("expected ErrRuleTreeTooLarge, got %v", err) + } + // user-facing message should name the limit + if !strings.Contains(err.Error(), "8192") { + t.Fatalf("expected message to mention 8192 byte limit, got %q", err.Error()) + } +} + +func TestValidateEligibilityJSON_RejectsBadJSON(t *testing.T) { + err := ValidateEligibilityJSON([]byte(`{bad`)) + if err == nil { + t.Fatalf("expected error on invalid JSON") + } +} + +// buildDeepTree 构造 depth 层单链嵌套(每层一个 OR 聚合)。root 为第 1 层。 +func buildDeepTree(depth int) *lottery.EligibilityRule { + root := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}} + current := root + for i := 2; i < depth; i++ { + next := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}} + current.Children = []*lottery.EligibilityRule{next} + current = next + } + return root +} + +func TestValidateEligibilityJSON_RejectsDepthOverLimit(t *testing.T) { + tree := buildDeepTree(MaxDepth + 1) // depth 9 with defaults + raw, err := json.Marshal(tree) + if err != nil { + t.Fatalf("marshal: %v", err) + } + err = ValidateEligibilityJSON(raw) + if !errors.Is(err, ErrRuleTreeTooDeep) { + t.Fatalf("expected ErrRuleTreeTooDeep, got %v", err) + } +} + +func TestValidateEligibilityJSON_AcceptsMaxDepth(t *testing.T) { + tree := buildDeepTree(MaxDepth) + raw, _ := json.Marshal(tree) + if err := ValidateEligibilityJSON(raw); err != nil { + t.Fatalf("depth=MaxDepth must be accepted, got %v", err) + } +} + +// buildWideTree 构造根节点 + N 个叶子,总节点数 = 1 + N。 +func buildWideTree(leaves int) *lottery.EligibilityRule { + root := &lottery.EligibilityRule{Op: "AND"} + for i := 0; i < leaves; i++ { + root.Children = append(root.Children, &lottery.EligibilityRule{Type: "has_subscription"}) + } + return root +} + +func TestValidateEligibilityJSON_RejectsNodeCountOverLimit(t *testing.T) { + // 65 nodes total = 1 root + 64 leaves > MaxNodes + tree := buildWideTree(MaxNodes) + raw, _ := json.Marshal(tree) + err := ValidateEligibilityJSON(raw) + if !errors.Is(err, ErrRuleTreeTooManyNodes) { + t.Fatalf("expected ErrRuleTreeTooManyNodes, got %v", err) + } +} + +func TestValidateEligibilityJSON_AcceptsAtNodeLimit(t *testing.T) { + // 64 nodes = 1 root + 63 leaves + tree := buildWideTree(MaxNodes - 1) + raw, _ := json.Marshal(tree) + if err := ValidateEligibilityJSON(raw); err != nil { + t.Fatalf("nodes=MaxNodes must be accepted, got %v", err) + } +} + +func TestValidateEligibilityJSON_AcceptsRealisticTree(t *testing.T) { + // Typical activity: (has_subscription AND invite_count>=3) OR user_tag in {vip} + raw := []byte(`{ + "op": "OR", + "children": [ + { + "op": "AND", + "children": [ + {"type": "has_subscription", "params": {"min_days_remaining": 7}}, + {"type": "invite_count", "params": {"min": 3}} + ] + }, + {"type": "user_tag", "params": {"tags": ["vip"]}} + ] + }`) + if err := ValidateEligibilityJSON(raw); err != nil { + t.Fatalf("realistic tree should be accepted, got %v", err) + } +} diff --git a/internal/logic/public/lottery/lottery.go b/internal/logic/public/lottery/lottery.go new file mode 100644 index 0000000..d79ca19 --- /dev/null +++ b/internal/logic/public/lottery/lottery.go @@ -0,0 +1,323 @@ +// Package lottery implements the user-side lottery HTTP endpoints: +// GET /api/v1/lottery/config +// POST /api/v1/lottery/draw +// GET /api/v1/lottery/records +// POST /api/v1/lottery/claim (Stage 1: returns 4010 not_claimable) +package lottery + +import ( + "context" + "encoding/json" + "strings" + + "github.com/perfect-panel/server/internal/logic/lottery/draw" + modelLottery "github.com/perfect-panel/server/internal/model/lottery" + userModel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。 +// 匿名 / 未登录返回 0;handler 侧应当由 AuthMiddleware 已经拦截。 +func currentUserId(ctx context.Context) int64 { + u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User) + if !ok || u == nil { + return 0 + } + return u.Id +} + +// ---- GET /config ------------------------------------------------------------ + +// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。 +type QueryLotteryConfigLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic { + return &QueryLotteryConfigLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) { + userId := currentUserId(l.ctx) + if userId == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + activity, err := l.loadActivity(req.ActivityId) + if err != nil { + return nil, err + } + prizes, err := l.loadPrizes(req.ActivityId) + if err != nil { + return nil, err + } + remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId) + + resp := &types.GetLotteryConfigResponse{ + Activity: types.LotteryActivityConfig{ + Id: activity.Id, + Title: activity.Title, + Description: activity.Description, + StartAt: activity.StartAt.Unix(), + EndAt: activity.EndAt.Unix(), + Status: activity.Status, + GridSize: activity.GridSize, + }, + User: types.LotteryUserStatus{ + ChancesRemaining: remaining, + // Eligible / UnmetReasons 需要 RuleContextBuilder;PR C 里 draw 路径 + // 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到 + // 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。 + Eligible: true, + }, + } + resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes)) + for _, p := range prizes { + soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0 + resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{ + Slot: p.Slot, + Id: p.Id, + Type: p.Type, + Name: p.Name, + IconUrl: p.IconURL, + Config: json.RawMessage(defaultIfEmpty(p.Config)), + SoldOut: soldOut, + }) + } + return resp, nil +} + +func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) { + var activity modelLottery.Activity + err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + if activity.Status == modelLottery.ActivityStatusEnded { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + return &activity, nil +} + +func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) { + var prizes []modelLottery.Prize + err := l.svcCtx.DB.WithContext(l.ctx). + Where("activity_id = ?", activityId). + Order("slot ASC"). + Find(&prizes).Error + if err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + return prizes, nil +} + +func defaultIfEmpty(s string) string { + if strings.TrimSpace(s) == "" { + return "{}" + } + return s +} + +// ---- POST /draw ------------------------------------------------------------- + +// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。 +type DrawLotteryLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic { + return &DrawLotteryLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) { + userId := currentUserId(l.ctx) + if userId == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + if l.svcCtx.LotteryDrawService == nil { + return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) + } + result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{ + UserId: userId, + ActivityId: req.ActivityId, + ClientNonce: req.ClientNonce, + }) + if err != nil { + return nil, err + } + resp := &types.DrawLotteryResponse{ + DrawId: result.DrawId, + IsWin: result.IsWin, + ChancesRemaining: result.ChancesRemaining, + Claim: types.LotteryClaimStatus{ + Required: result.Claim.Required, + AutoClaimed: result.Claim.AutoClaimed, + Message: result.Claim.Message, + }, + } + if result.Prize != nil { + resp.Prize = &types.DrawnPrize{ + Slot: result.Prize.Slot, + Id: result.Prize.Id, + Type: result.Prize.Type, + Name: result.Prize.Name, + Config: result.Prize.Config, + } + } + return resp, nil +} + +// ---- GET /records ---------------------------------------------------------- + +// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。 +type QueryLotteryRecordsLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic { + return &QueryLotteryRecordsLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) { + userId := currentUserId(l.ctx) + if userId == 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.Draw{}). + Where("user_id = ?", userId) + if req.ActivityId > 0 { + db = db.Where("activity_id = ?", req.ActivityId) + } + if state := recordStatusFilter(req.Status); state != "" { + switch state { + case "unclaimed": + db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim) + case "paid": + db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid) + case "expired": + db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + var draws []modelLottery.Draw + if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil { + return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + } + snapshots, err := l.loadPrizeSnapshots(draws) + if err != nil { + return nil, err + } + resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))} + for _, d := range draws { + record := types.LotteryRecord{ + DrawId: d.Id, + ActivityId: d.ActivityId, + IsWin: d.IsWin, + DispatchState: d.DispatchState, + DrawnAt: d.DrawnAt.Unix(), + } + if snap, ok := snapshots[d.Id]; ok { + record.Prize = &types.DrawnPrize{ + Slot: snap.Slot, + Id: snap.PrizeId, + Type: snap.Type, + Name: snap.Name, + Config: json.RawMessage(defaultIfEmpty(snap.Config)), + } + } + resp.List = append(resp.List, record) + } + return resp, nil +} + +func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) { + if len(draws) == 0 { + return nil, nil + } + ids := make([]int64, 0, len(draws)) + for _, d := range draws { + ids = append(ids, d.Id) + } + var snaps []modelLottery.PrizeSnapshot + if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).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 recordStatusFilter(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "all": + return "" + case "unclaimed", "paid", "expired": + return strings.ToLower(s) + default: + return "" + } +} + +// ---- POST /claim ---------------------------------------------------------- + +// ClaimLotteryPrizeLogic 是 Stage 2 才实装的入口;Stage 1 直接返回 4010。 +type ClaimLotteryPrizeLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic { + return &ClaimLotteryPrizeLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) { + if currentUserId(l.ctx) == 0 { + return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid) + } + // Stage 1 不实装人工领奖流程。 + return nil, xerr.NewErrCode(xerr.LotteryNotClaimable) +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index fcf0e36..e861a38 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -11,6 +11,8 @@ import ( "github.com/perfect-panel/server/pkg/storage" "github.com/perfect-panel/server/internal/config" + lotterydraw "github.com/perfect-panel/server/internal/logic/lottery/draw" + lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler" lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook" "github.com/perfect-panel/server/internal/model/ads" "github.com/perfect-panel/server/internal/model/announcement" @@ -74,9 +76,11 @@ type ServiceContext struct { IAPAppleTransactionModel iapapple.Model // Lottery (Stage 1) - LotteryChance lottery.ChanceService - LotteryLedger lottery.LedgerService - LotteryInviteHook lotteryhook.InviteHook + LotteryChance lottery.ChanceService + LotteryLedger lottery.LedgerService + LotteryInviteHook lotteryhook.InviteHook + LotteryRegistry lottery.Registry + LotteryDrawService *lotterydraw.Service Restart func() error TelegramBot *tgbotapi.BotAPI @@ -160,6 +164,36 @@ func NewServiceContext(c config.Config) *ServiceContext { srv.LotteryChance = lottery.NewChanceService(db) srv.LotteryLedger = lottery.NewLedgerService() srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance) + + // PR C: PrizeHandler registry + Draw service wiring. Handlers are hooked in + // order: noop → vpn_duration → commission. Later registrations override. + registry := lottery.NewRegistry() + registry.Register(lottery.NewNoopHandler()) + registry.Register(lotteryhandler.NewVPNDurationHandler(lotteryhandler.VPNDurationDeps{ + UserModel: srv.UserModel, + Ledger: srv.LotteryLedger, + DB: db, + ResolveEffectiveUser: lotteryhandler.DefaultResolveEffectiveUser(db), + })) + registry.Register(lotteryhandler.NewCommissionHandler(lotteryhandler.CommissionDeps{ + Ledger: srv.LotteryLedger, + UpdateCommission: srv.UserModel.UpdateCommission, + WriteCommissionLog: lotteryhandler.WriteCommissionLog, + })) + srv.LotteryRegistry = registry + + drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:") + srv.LotteryDrawService = lotterydraw.NewService(lotterydraw.Deps{ + DB: db, + Enabled: c.Lottery.Enable, + RateLimiter: lotterydraw.NewRedisRateLimiter(drawLimiter), + Chance: srv.LotteryChance, + Evaluator: lottery.NewRuleEvaluator(), + Picker: lottery.NewWeightedPicker(0), + Registry: registry, + // ContextBuilder 留 nil:PR C 阶段无活动配置门槛不评估用户上下文;后续 + // 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。 + }) if c.S3.Enable { s3Store, err := storage.NewS3Store(context.Background(), c.S3) if err != nil { diff --git a/internal/types/lottery.go b/internal/types/lottery.go new file mode 100644 index 0000000..28db8e1 --- /dev/null +++ b/internal/types/lottery.go @@ -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"` +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 30396ca..1539de9 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -150,3 +150,20 @@ const ( OrderRefundNoSubscription uint32 = 61007 OrderRefundCommissionMismatch uint32 = 61008 ) + +// Lottery error (100xxx range; 3-digit business = 100) +// codes align with HIF-3 spec (Stage 1 issue description): +// 4001 not_eligible / 4002 no_chances / 4003 activity_ended +// 4004 rate_limited / 4010 not_claimable / 5001 internal_error +// wire-level ints stay unique across the whole codebase. +const ( + LotteryNotEligible uint32 = 100001 // 用户未通过门槛 + LotteryNoChances uint32 = 100002 // 用户已无剩余次数 + LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭 + LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流 + LotteryNotClaimable uint32 = 100010 // Stage 2 领奖入口占位 + LotteryInternalError uint32 = 100500 // 内部错误 + LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限 + LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限 + LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限 +) diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index 5c32531..d5edba4 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -113,6 +113,17 @@ func init() { OrderRefundNoSubscription: "Refund target subscription not found", OrderRefundCommissionMismatch: "Refund commission source not found", + // Lottery error + LotteryNotEligible: "抽奖门槛未达成", + LotteryNoChances: "抽奖次数不足", + LotteryActivityEnded: "抽奖活动未开放", + LotteryRateLimited: "抽奖操作过于频繁,请稍后再试", + LotteryNotClaimable: "该奖品当前不支持领取", + LotteryInternalError: "抽奖服务暂时不可用", + LotteryRuleTooDeep: "规则树嵌套层数超限", + LotteryRuleTooMany: "规则树节点数超限", + LotteryRuleTooLarge: "规则树 JSON 体积超限", + // Permission error PermissionDenied: "Permission denied", } diff --git a/qa/lottery/stage1_curl.sh b/qa/lottery/stage1_curl.sh new file mode 100755 index 0000000..7821e55 --- /dev/null +++ b/qa/lottery/stage1_curl.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# HIF-3 Stage 1 lottery — end-to-end curl script for QA. +# +# 环境准备(一次性): +# 1. 在测试环境的 config.yaml 里打开 Lottery: { Enable: true } +# 2. 用 admin token 运行 setup 部分(创建活动 + 奖品 + 上架 + 发次数) +# 3. 用普通用户 token 运行 user 部分(config → draw → records → claim) +# +# 使用: +# BASE_URL=http://127.0.0.1:8080 ADMIN_TOKEN= USER_TOKEN= USER_ID= \ +# bash qa/lottery/stage1_curl.sh +# +# 输出:每步 request/response 打印到 stdout,非 2xx 或 code!=0 直接退出。 + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:8080}" +ADMIN_TOKEN="${ADMIN_TOKEN:-CHANGE_ME}" +USER_TOKEN="${USER_TOKEN:-CHANGE_ME}" +USER_ID="${USER_ID:-1}" + +hdr_admin=(-H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json") +hdr_user=(-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json") + +log() { + printf '\n\033[1;34m▶ %s\033[0m\n' "$*" +} + +expect_ok() { + local body="$1" + local step="$2" + local code + code=$(printf '%s' "$body" | jq -r '.code // 0') + if [[ "$code" != "0" && "$code" != "200" ]]; then + printf '\033[1;31m✗ %s failed: %s\033[0m\n' "$step" "$body" >&2 + exit 1 + fi + printf '\033[1;32m✓ %s\033[0m\n' "$step" +} + +# ---- Admin setup --------------------------------------------------------- + +log "Create activity (draft)" +start_at=$(date -v +0S +%s 2>/dev/null || date +%s) +end_at=$((start_at + 86400 * 7)) +create_body=$(cat </dev/null || python3 -c "import uuid;print(uuid.uuid4())") +log "POST /draw (nonce=${nonce_a})" +draw_body="{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_a}\"}" +resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "${draw_body}") +expect_ok "$resp" "draw #1" +echo "$resp" + +log "POST /draw same nonce (idempotent replay)" +resp2=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "${draw_body}") +expect_ok "$resp2" "draw replay" +draw_id_1=$(printf '%s' "$resp" | jq -r '.data.draw_id') +draw_id_2=$(printf '%s' "$resp2" | jq -r '.data.draw_id') +if [[ "$draw_id_1" != "$draw_id_2" ]]; then + echo "✗ nonce replay produced different draw_id ($draw_id_1 vs $draw_id_2)" >&2 + exit 1 +fi +echo "✓ nonce idempotent: draw_id=${draw_id_1}" + +log "GET /records" +resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}&page=1&size=20" "${hdr_user[@]}") +expect_ok "$resp" "records" +echo "$resp" + +log "POST /claim (Stage 1 always 4010)" +resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "{\"draw_id\":${draw_id_1}}") +code=$(printf '%s' "$resp" | jq -r '.code // 0') +if [[ "$code" != "100010" ]]; then + echo "✗ /claim should return 100010 not_claimable in Stage 1; got ${code}" >&2 + echo "$resp" >&2 + exit 1 +fi +echo "✓ claim returned 100010 as expected" + +# ---- Rate limit verification --------------------------------------------- + +log "Trigger rate limit (2 draws within 1 sec)" +nonce_b=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())") +curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_b}\"}" >/dev/null +nonce_c=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())") +resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_c}\"}") +code=$(printf '%s' "$resp" | jq -r '.code // 0') +if [[ "$code" != "100004" ]]; then + echo "⚠️ expected 100004 rate_limited, got ${code} — non-fatal (Redis may have residual budget)" +else + echo "✓ rate limit triggered as expected" +fi + +echo +echo "──────────────────────────────────────────" +echo " Stage 1 end-to-end curl script completed." +echo "──────────────────────────────────────────"