新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成

Closes HIF-3

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

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

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

测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100%
100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁)
CI 全绿:构建/Vet/测试 + golangci-lint
This commit is contained in:
2026-07-08 22:33:18 -07:00
committed by GitHub
parent a46fb83054
commit ce3babcc33
27 changed files with 3599 additions and 18 deletions
+65
View File
@@ -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)
}
+33
View File
@@ -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)
}
@@ -0,0 +1,2 @@
-- 02158 admin_action_log 回滚
DROP TABLE IF EXISTS `admin_action_log`;
@@ -0,0 +1,21 @@
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
--
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
-- 与合规审查。actor_user_id 是操作者的 user.idaction 是操作动作
-- 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='后台写操作审计流水';
+8
View File
@@ -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 短链接
@@ -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))
}
}
+44
View File
@@ -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))
}
}
@@ -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)
}
}
+3
View File
@@ -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)
}
+686
View File
@@ -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:])
}
@@ -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)
}
}
+100
View File
@@ -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]
}
@@ -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")
}
}
+547
View File
@@ -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
}
+513
View File
@@ -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
}
@@ -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
}
+23 -10
View File
@@ -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 cyclecommon 包里有别的文件反向 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
}
}
+6 -3
View File
@@ -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),
+2 -2
View File
@@ -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)
+112
View File
@@ -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'
}
@@ -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)
}
}
+323
View File
@@ -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 上下文。
// 匿名 / 未登录返回 0handler 侧应当由 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 需要 RuleContextBuilderPR 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)
}
+37 -3
View File
@@ -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 留 nilPR C 阶段无活动配置门槛不评估用户上下文;后续
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
})
if c.S3.Enable {
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
if err != nil {
+268
View File
@@ -0,0 +1,268 @@
// Types for lottery Stage 1 user & admin APIs. Kept in a separate file so
// future `goctl` regenerations of types.go do not clobber them (same pattern
// as internal/types/subscribe.go).
//
// Field names mirror the shape declared in HIF-3 (Stage 1 spec) and the
// architect's PR C brief. Do NOT rename without updating the .api files and
// notifying frontend.
package types
import "encoding/json"
// ---- User API ---------------------------------------------------------------
// GetLotteryConfigRequest is the query for GET /api/v1/lottery/config.
type GetLotteryConfigRequest struct {
ActivityId int64 `form:"activity_id" validate:"required"`
}
// LotteryActivityConfig is the activity snapshot returned to the user.
type LotteryActivityConfig struct {
Id int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
Status string `json:"status"`
GridSize int `json:"grid_size"`
Prizes []LotteryPrizeConfig `json:"prizes"`
}
// LotteryPrizeConfig is the slot-facing view of a prize (no weight / stock
// exposed — those are admin-only).
type LotteryPrizeConfig struct {
Slot int `json:"slot"`
Id int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
SoldOut bool `json:"sold_out"`
}
// LotteryUserStatus reports whether the user can currently draw.
type LotteryUserStatus struct {
Eligible bool `json:"eligible"`
ChancesRemaining int64 `json:"chances_remaining"`
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
}
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
type LotteryUnmetReason struct {
Rule string `json:"rule"`
Hint string `json:"hint"`
Current int64 `json:"current,omitempty"`
Required int64 `json:"required,omitempty"`
}
// GetLotteryConfigResponse is the envelope for GET /config.
type GetLotteryConfigResponse struct {
Activity LotteryActivityConfig `json:"activity"`
User LotteryUserStatus `json:"user"`
}
// DrawLotteryRequest is the body for POST /api/v1/lottery/draw.
type DrawLotteryRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
ClientNonce string `json:"client_nonce" validate:"required,max=64"`
}
// DrawnPrize is a slim prize view returned on draw success.
type DrawnPrize struct {
Slot int `json:"slot"`
Id int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
}
// LotteryClaimStatus tells the frontend whether more action is needed.
type LotteryClaimStatus struct {
Required bool `json:"required"`
AutoClaimed bool `json:"auto_claimed"`
Message string `json:"message,omitempty"`
}
// DrawLotteryResponse is what /draw returns.
type DrawLotteryResponse struct {
DrawId int64 `json:"draw_id"`
IsWin bool `json:"is_win"`
Prize *DrawnPrize `json:"prize"`
Claim LotteryClaimStatus `json:"claim"`
ChancesRemaining int64 `json:"chances_remaining"`
}
// GetLotteryRecordsRequest paginates over the user's draws.
// Page/Size 默认 by logic 层(Page<=0 → 1Size<=0||>200 → 20);tag 里不设
// default 以避免 staticcheck 与 Gin binding 的语义冲突。
type GetLotteryRecordsRequest struct {
ActivityId int64 `form:"activity_id"`
Status string `form:"status"`
Page int `form:"page"`
Size int `form:"size"`
}
// LotteryRecord is one row in the records list.
type LotteryRecord struct {
DrawId int64 `json:"draw_id"`
ActivityId int64 `json:"activity_id"`
IsWin bool `json:"is_win"`
Prize *DrawnPrize `json:"prize"`
DispatchState string `json:"dispatch_state"`
DrawnAt int64 `json:"drawn_at"`
}
// GetLotteryRecordsResponse is the paginated payload.
type GetLotteryRecordsResponse struct {
Total int64 `json:"total"`
List []LotteryRecord `json:"list"`
}
// ClaimLotteryPrizeRequest is Stage 2 spec; Stage 1 always returns 4010.
type ClaimLotteryPrizeRequest struct {
DrawId int64 `json:"draw_id" validate:"required"`
Input json.RawMessage `json:"input,omitempty"`
}
// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused.
type ClaimLotteryPrizeResponse struct{}
// ---- Admin API --------------------------------------------------------------
// AdminLotteryActivity is the full admin view of an activity.
type AdminLotteryActivity struct {
Id int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
Status string `json:"status"`
GridSize int `json:"grid_size"`
Eligibility json.RawMessage `json:"eligibility"`
ChanceSources json.RawMessage `json:"chance_sources"`
UnmetAction string `json:"unmet_action"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// CreateAdminLotteryActivityRequest creates a new activity in "draft" status.
type CreateAdminLotteryActivityRequest struct {
Title string `json:"title" validate:"required,max=128"`
Description string `json:"description"`
StartAt int64 `json:"start_at" validate:"required"`
EndAt int64 `json:"end_at" validate:"required"`
// GridSize0 由 logic 层兜底为 9(不能在 json tag 里写 default=… ——
// staticcheck SA5008 会拒;encoding/json 也不认这个选项)。
GridSize int `json:"grid_size"`
Eligibility json.RawMessage `json:"eligibility"`
ChanceSources json.RawMessage `json:"chance_sources"`
// UnmetAction:空字符串由 logic 层兜底为 "block"。
UnmetAction string `json:"unmet_action"`
}
// UpdateAdminLotteryActivityRequest updates mutable fields.
type UpdateAdminLotteryActivityRequest struct {
Id int64 `json:"id" validate:"required"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
StartAt int64 `json:"start_at,omitempty"`
EndAt int64 `json:"end_at,omitempty"`
GridSize int `json:"grid_size,omitempty"`
UnmetAction string `json:"unmet_action,omitempty"`
}
// ListAdminLotteryActivitiesRequest paginates admin listings.
// Page/Size 默认 by logic 层。
type ListAdminLotteryActivitiesRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Status string `form:"status,omitempty"`
Search string `form:"search,omitempty"`
}
// ListAdminLotteryActivitiesResponse pages.
type ListAdminLotteryActivitiesResponse struct {
Total int64 `json:"total"`
List []AdminLotteryActivity `json:"list"`
}
// AdminActivityIdRequest is used by GET /detail, publish, pause, delete.
type AdminActivityIdRequest struct {
Id int64 `form:"id" json:"id" validate:"required"`
}
// UpdateAdminLotteryRulesRequest overwrites eligibility / chance_sources /
// unmet_action in a single call. Validated against rule caps before persist.
type UpdateAdminLotteryRulesRequest struct {
Id int64 `json:"id" validate:"required"`
Eligibility json.RawMessage `json:"eligibility"`
ChanceSources json.RawMessage `json:"chance_sources"`
UnmetAction string `json:"unmet_action,omitempty"`
}
// AdminLotteryPrize is the admin view of a prize (includes weight + stock).
type AdminLotteryPrize struct {
Id int64 `json:"id"`
ActivityId int64 `json:"activity_id"`
Slot int `json:"slot"`
Type string `json:"type"`
Name string `json:"name"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
Weight int `json:"weight"`
TotalStock *int64 `json:"total_stock"`
RemainingStock *int64 `json:"remaining_stock"`
IsFallback bool `json:"is_fallback"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// CreateAdminLotteryPrizeRequest is nested under /activities/{id}/prizes.
type CreateAdminLotteryPrizeRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
Slot int `json:"slot"`
Type string `json:"type" validate:"required,max=32"`
Name string `json:"name" validate:"required,max=128"`
IconUrl string `json:"icon_url"`
Config json.RawMessage `json:"config"`
Weight int `json:"weight"`
TotalStock *int64 `json:"total_stock,omitempty"`
IsFallback bool `json:"is_fallback"`
}
// UpdateAdminLotteryPrizeRequest updates mutable prize fields.
type UpdateAdminLotteryPrizeRequest struct {
Id int64 `json:"id" validate:"required"`
Slot *int `json:"slot,omitempty"`
Name string `json:"name,omitempty"`
IconUrl string `json:"icon_url,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
Weight *int `json:"weight,omitempty"`
TotalStock *int64 `json:"total_stock,omitempty"`
IsFallback *bool `json:"is_fallback,omitempty"`
}
// ListAdminLotteryPrizesRequest lists prizes for an activity.
type ListAdminLotteryPrizesRequest struct {
ActivityId int64 `form:"activity_id" validate:"required"`
}
// ListAdminLotteryPrizesResponse pages.
type ListAdminLotteryPrizesResponse struct {
List []AdminLotteryPrize `json:"list"`
}
// AdminPrizeIdRequest is used by DELETE / GET single.
type AdminPrizeIdRequest struct {
Id int64 `form:"id" json:"id" validate:"required"`
}
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
// activity. sourceRef doubles as idempotency key.
type GrantAdminLotteryChanceRequest struct {
ActivityId int64 `json:"activity_id" validate:"required"`
UserId int64 `json:"user_id" validate:"required"`
Amount int `json:"amount" validate:"required,min=1"`
SourceRef string `json:"source_ref" validate:"required,max=128"`
}
+17
View File
@@ -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 体积超限
)
+11
View File
@@ -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",
}
+136
View File
@@ -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=<jwt> USER_TOKEN=<jwt> USER_ID=<int> \
# 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 <<JSON
{"title":"QA Stage 1 抽奖","description":"e2e","start_at":${start_at},"end_at":${end_at},"grid_size":9,
"eligibility":{},"chance_sources":[{"source":"manual_grant","amount":1}],"unmet_action":"block"}
JSON
)
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities" "${hdr_admin[@]}" -d "${create_body}")
expect_ok "$resp" "activity create"
echo "$resp"
ACTIVITY_ID=$(printf '%s' "$resp" | jq -r '.data.id')
log "Add prize (vpn_duration 3 天, unlimited)"
prize_body=$(cat <<JSON
{"activity_id":${ACTIVITY_ID},"slot":0,"type":"vpn_duration","name":"3 天","icon_url":"",
"config":{"duration_days":3},"weight":50,"is_fallback":false}
JSON
)
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "${prize_body}")
expect_ok "$resp" "prize vpn_duration"
echo "$resp"
log "Add fallback prize (none / 谢谢参与)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"slot\":1,\"type\":\"none\",\"name\":\"谢谢参与\",\"config\":{},\"weight\":50,\"is_fallback\":true}")
expect_ok "$resp" "prize fallback"
echo "$resp"
log "Publish activity"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities/publish" "${hdr_admin[@]}" -d "{\"id\":${ACTIVITY_ID}}")
expect_ok "$resp" "publish"
log "Manually grant 2 chances to user ${USER_ID}"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/chances/grant" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"user_id\":${USER_ID},\"amount\":2,\"source_ref\":\"qa-e2e-1\"}")
expect_ok "$resp" "chances grant"
# ---- User flow -----------------------------------------------------------
log "GET /config"
resp=$(curl -sS "${BASE_URL}/v1/lottery/config?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
expect_ok "$resp" "config query"
echo "$resp"
nonce_a=$(uuidgen 2>/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 "──────────────────────────────────────────"