新功能(#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:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/gorm"
|
||||
@@ -45,19 +44,33 @@ type VPNDurationDeps struct {
|
||||
ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现:走
|
||||
// commonLogic.ResolveEntitlementUser。用 struct 依赖注入避免 handler 直接依赖
|
||||
// 具体 SQL —— 测试里 stub 掉。
|
||||
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现。语义与
|
||||
// internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner),
|
||||
// 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common
|
||||
// 的 import cycle(common 包里有别的文件反向 import 了 svc)。
|
||||
func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) {
|
||||
return func(ctx context.Context, userID int64) (int64, error) {
|
||||
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, db, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if entitlement == nil || entitlement.EffectiveUserID <= 0 {
|
||||
if userID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
return entitlement.EffectiveUserID, nil
|
||||
var row struct {
|
||||
OwnerUserID int64 `gorm:"column:owner_user_id"`
|
||||
}
|
||||
q := db.WithContext(ctx).
|
||||
Table("user_family_member").
|
||||
Select("user_family.owner_user_id AS owner_user_id").
|
||||
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL").
|
||||
Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive).
|
||||
Order("user_family_member.role").
|
||||
Limit(1).
|
||||
Scan(&row)
|
||||
if q.Error != nil {
|
||||
return 0, q.Error
|
||||
}
|
||||
if q.RowsAffected == 0 || row.OwnerUserID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
return row.OwnerUserID, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package lottery implements the user-side lottery HTTP endpoints:
|
||||
// GET /api/v1/lottery/config
|
||||
// POST /api/v1/lottery/draw
|
||||
// GET /api/v1/lottery/records
|
||||
// POST /api/v1/lottery/claim (Stage 1: returns 4010 not_claimable)
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。
|
||||
// 匿名 / 未登录返回 0;handler 侧应当由 AuthMiddleware 已经拦截。
|
||||
func currentUserId(ctx context.Context) int64 {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||
if !ok || u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.Id
|
||||
}
|
||||
|
||||
// ---- GET /config ------------------------------------------------------------
|
||||
|
||||
// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。
|
||||
type QueryLotteryConfigLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic {
|
||||
return &QueryLotteryConfigLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
activity, err := l.loadActivity(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes, err := l.loadPrizes(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId)
|
||||
|
||||
resp := &types.GetLotteryConfigResponse{
|
||||
Activity: types.LotteryActivityConfig{
|
||||
Id: activity.Id,
|
||||
Title: activity.Title,
|
||||
Description: activity.Description,
|
||||
StartAt: activity.StartAt.Unix(),
|
||||
EndAt: activity.EndAt.Unix(),
|
||||
Status: activity.Status,
|
||||
GridSize: activity.GridSize,
|
||||
},
|
||||
User: types.LotteryUserStatus{
|
||||
ChancesRemaining: remaining,
|
||||
// Eligible / UnmetReasons 需要 RuleContextBuilder;PR C 里 draw 路径
|
||||
// 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到
|
||||
// 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。
|
||||
Eligible: true,
|
||||
},
|
||||
}
|
||||
resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes))
|
||||
for _, p := range prizes {
|
||||
soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0
|
||||
resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{
|
||||
Slot: p.Slot,
|
||||
Id: p.Id,
|
||||
Type: p.Type,
|
||||
Name: p.Name,
|
||||
IconUrl: p.IconURL,
|
||||
Config: json.RawMessage(defaultIfEmpty(p.Config)),
|
||||
SoldOut: soldOut,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) {
|
||||
var activity modelLottery.Activity
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if activity.Status == modelLottery.ActivityStatusEnded {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) {
|
||||
var prizes []modelLottery.Prize
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Where("activity_id = ?", activityId).
|
||||
Order("slot ASC").
|
||||
Find(&prizes).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "{}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- POST /draw -------------------------------------------------------------
|
||||
|
||||
// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。
|
||||
type DrawLotteryLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic {
|
||||
return &DrawLotteryLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if l.svcCtx.LotteryDrawService == nil {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{
|
||||
UserId: userId,
|
||||
ActivityId: req.ActivityId,
|
||||
ClientNonce: req.ClientNonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.DrawLotteryResponse{
|
||||
DrawId: result.DrawId,
|
||||
IsWin: result.IsWin,
|
||||
ChancesRemaining: result.ChancesRemaining,
|
||||
Claim: types.LotteryClaimStatus{
|
||||
Required: result.Claim.Required,
|
||||
AutoClaimed: result.Claim.AutoClaimed,
|
||||
Message: result.Claim.Message,
|
||||
},
|
||||
}
|
||||
if result.Prize != nil {
|
||||
resp.Prize = &types.DrawnPrize{
|
||||
Slot: result.Prize.Slot,
|
||||
Id: result.Prize.Id,
|
||||
Type: result.Prize.Type,
|
||||
Name: result.Prize.Name,
|
||||
Config: result.Prize.Config,
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GET /records ----------------------------------------------------------
|
||||
|
||||
// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。
|
||||
type QueryLotteryRecordsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic {
|
||||
return &QueryLotteryRecordsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Draw{}).
|
||||
Where("user_id = ?", userId)
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if state := recordStatusFilter(req.Status); state != "" {
|
||||
switch state {
|
||||
case "unclaimed":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim)
|
||||
case "paid":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid)
|
||||
case "expired":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var draws []modelLottery.Draw
|
||||
if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
snapshots, err := l.loadPrizeSnapshots(draws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
|
||||
for _, d := range draws {
|
||||
record := types.LotteryRecord{
|
||||
DrawId: d.Id,
|
||||
ActivityId: d.ActivityId,
|
||||
IsWin: d.IsWin,
|
||||
DispatchState: d.DispatchState,
|
||||
DrawnAt: d.DrawnAt.Unix(),
|
||||
}
|
||||
if snap, ok := snapshots[d.Id]; ok {
|
||||
record.Prize = &types.DrawnPrize{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
|
||||
}
|
||||
}
|
||||
resp.List = append(resp.List, record)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(draws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(draws))
|
||||
for _, d := range draws {
|
||||
ids = append(ids, d.Id)
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recordStatusFilter(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "all":
|
||||
return ""
|
||||
case "unclaimed", "paid", "expired":
|
||||
return strings.ToLower(s)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- POST /claim ----------------------------------------------------------
|
||||
|
||||
// ClaimLotteryPrizeLogic 是 Stage 2 才实装的入口;Stage 1 直接返回 4010。
|
||||
type ClaimLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic {
|
||||
return &ClaimLotteryPrizeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(_ *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||
if currentUserId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
// Stage 1 不实装人工领奖流程。
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/storage"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
lotterydraw "github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
|
||||
"github.com/perfect-panel/server/internal/model/ads"
|
||||
"github.com/perfect-panel/server/internal/model/announcement"
|
||||
@@ -74,9 +76,11 @@ type ServiceContext struct {
|
||||
IAPAppleTransactionModel iapapple.Model
|
||||
|
||||
// Lottery (Stage 1)
|
||||
LotteryChance lottery.ChanceService
|
||||
LotteryLedger lottery.LedgerService
|
||||
LotteryInviteHook lotteryhook.InviteHook
|
||||
LotteryChance lottery.ChanceService
|
||||
LotteryLedger lottery.LedgerService
|
||||
LotteryInviteHook lotteryhook.InviteHook
|
||||
LotteryRegistry lottery.Registry
|
||||
LotteryDrawService *lotterydraw.Service
|
||||
|
||||
Restart func() error
|
||||
TelegramBot *tgbotapi.BotAPI
|
||||
@@ -160,6 +164,36 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||
srv.LotteryChance = lottery.NewChanceService(db)
|
||||
srv.LotteryLedger = lottery.NewLedgerService()
|
||||
srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance)
|
||||
|
||||
// PR C: PrizeHandler registry + Draw service wiring. Handlers are hooked in
|
||||
// order: noop → vpn_duration → commission. Later registrations override.
|
||||
registry := lottery.NewRegistry()
|
||||
registry.Register(lottery.NewNoopHandler())
|
||||
registry.Register(lotteryhandler.NewVPNDurationHandler(lotteryhandler.VPNDurationDeps{
|
||||
UserModel: srv.UserModel,
|
||||
Ledger: srv.LotteryLedger,
|
||||
DB: db,
|
||||
ResolveEffectiveUser: lotteryhandler.DefaultResolveEffectiveUser(db),
|
||||
}))
|
||||
registry.Register(lotteryhandler.NewCommissionHandler(lotteryhandler.CommissionDeps{
|
||||
Ledger: srv.LotteryLedger,
|
||||
UpdateCommission: srv.UserModel.UpdateCommission,
|
||||
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
||||
}))
|
||||
srv.LotteryRegistry = registry
|
||||
|
||||
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
||||
srv.LotteryDrawService = lotterydraw.NewService(lotterydraw.Deps{
|
||||
DB: db,
|
||||
Enabled: c.Lottery.Enable,
|
||||
RateLimiter: lotterydraw.NewRedisRateLimiter(drawLimiter),
|
||||
Chance: srv.LotteryChance,
|
||||
Evaluator: lottery.NewRuleEvaluator(),
|
||||
Picker: lottery.NewWeightedPicker(0),
|
||||
Registry: registry,
|
||||
// ContextBuilder 留 nil:PR C 阶段无活动配置门槛不评估用户上下文;后续
|
||||
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
|
||||
})
|
||||
if c.S3.Enable {
|
||||
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// Types for lottery Stage 1 user & admin APIs. Kept in a separate file so
|
||||
// future `goctl` regenerations of types.go do not clobber them (same pattern
|
||||
// as internal/types/subscribe.go).
|
||||
//
|
||||
// Field names mirror the shape declared in HIF-3 (Stage 1 spec) and the
|
||||
// architect's PR C brief. Do NOT rename without updating the .api files and
|
||||
// notifying frontend.
|
||||
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ---- User API ---------------------------------------------------------------
|
||||
|
||||
// GetLotteryConfigRequest is the query for GET /api/v1/lottery/config.
|
||||
type GetLotteryConfigRequest struct {
|
||||
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||
}
|
||||
|
||||
// LotteryActivityConfig is the activity snapshot returned to the user.
|
||||
type LotteryActivityConfig struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at"`
|
||||
EndAt int64 `json:"end_at"`
|
||||
Status string `json:"status"`
|
||||
GridSize int `json:"grid_size"`
|
||||
Prizes []LotteryPrizeConfig `json:"prizes"`
|
||||
}
|
||||
|
||||
// LotteryPrizeConfig is the slot-facing view of a prize (no weight / stock
|
||||
// exposed — those are admin-only).
|
||||
type LotteryPrizeConfig struct {
|
||||
Slot int `json:"slot"`
|
||||
Id int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
SoldOut bool `json:"sold_out"`
|
||||
}
|
||||
|
||||
// LotteryUserStatus reports whether the user can currently draw.
|
||||
type LotteryUserStatus struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
|
||||
}
|
||||
|
||||
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
|
||||
type LotteryUnmetReason struct {
|
||||
Rule string `json:"rule"`
|
||||
Hint string `json:"hint"`
|
||||
Current int64 `json:"current,omitempty"`
|
||||
Required int64 `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// GetLotteryConfigResponse is the envelope for GET /config.
|
||||
type GetLotteryConfigResponse struct {
|
||||
Activity LotteryActivityConfig `json:"activity"`
|
||||
User LotteryUserStatus `json:"user"`
|
||||
}
|
||||
|
||||
// DrawLotteryRequest is the body for POST /api/v1/lottery/draw.
|
||||
type DrawLotteryRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
ClientNonce string `json:"client_nonce" validate:"required,max=64"`
|
||||
}
|
||||
|
||||
// DrawnPrize is a slim prize view returned on draw success.
|
||||
type DrawnPrize struct {
|
||||
Slot int `json:"slot"`
|
||||
Id int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// LotteryClaimStatus tells the frontend whether more action is needed.
|
||||
type LotteryClaimStatus struct {
|
||||
Required bool `json:"required"`
|
||||
AutoClaimed bool `json:"auto_claimed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// DrawLotteryResponse is what /draw returns.
|
||||
type DrawLotteryResponse struct {
|
||||
DrawId int64 `json:"draw_id"`
|
||||
IsWin bool `json:"is_win"`
|
||||
Prize *DrawnPrize `json:"prize"`
|
||||
Claim LotteryClaimStatus `json:"claim"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
}
|
||||
|
||||
// GetLotteryRecordsRequest paginates over the user's draws.
|
||||
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20);tag 里不设
|
||||
// default 以避免 staticcheck 与 Gin binding 的语义冲突。
|
||||
type GetLotteryRecordsRequest struct {
|
||||
ActivityId int64 `form:"activity_id"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// LotteryRecord is one row in the records list.
|
||||
type LotteryRecord struct {
|
||||
DrawId int64 `json:"draw_id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
IsWin bool `json:"is_win"`
|
||||
Prize *DrawnPrize `json:"prize"`
|
||||
DispatchState string `json:"dispatch_state"`
|
||||
DrawnAt int64 `json:"drawn_at"`
|
||||
}
|
||||
|
||||
// GetLotteryRecordsResponse is the paginated payload.
|
||||
type GetLotteryRecordsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []LotteryRecord `json:"list"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeRequest is Stage 2 spec; Stage 1 always returns 4010.
|
||||
type ClaimLotteryPrizeRequest struct {
|
||||
DrawId int64 `json:"draw_id" validate:"required"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeResponse mirrors Stage 2 shape; Stage 1 unused.
|
||||
type ClaimLotteryPrizeResponse struct{}
|
||||
|
||||
// ---- Admin API --------------------------------------------------------------
|
||||
|
||||
// AdminLotteryActivity is the full admin view of an activity.
|
||||
type AdminLotteryActivity struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at"`
|
||||
EndAt int64 `json:"end_at"`
|
||||
Status string `json:"status"`
|
||||
GridSize int `json:"grid_size"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateAdminLotteryActivityRequest creates a new activity in "draft" status.
|
||||
type CreateAdminLotteryActivityRequest struct {
|
||||
Title string `json:"title" validate:"required,max=128"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at" validate:"required"`
|
||||
EndAt int64 `json:"end_at" validate:"required"`
|
||||
// GridSize:0 由 logic 层兜底为 9(不能在 json tag 里写 default=… ——
|
||||
// staticcheck SA5008 会拒;encoding/json 也不认这个选项)。
|
||||
GridSize int `json:"grid_size"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
// UnmetAction:空字符串由 logic 层兜底为 "block"。
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryActivityRequest updates mutable fields.
|
||||
type UpdateAdminLotteryActivityRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartAt int64 `json:"start_at,omitempty"`
|
||||
EndAt int64 `json:"end_at,omitempty"`
|
||||
GridSize int `json:"grid_size,omitempty"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryActivitiesRequest paginates admin listings.
|
||||
// Page/Size 默认 by logic 层。
|
||||
type ListAdminLotteryActivitiesRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Status string `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryActivitiesResponse pages.
|
||||
type ListAdminLotteryActivitiesResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []AdminLotteryActivity `json:"list"`
|
||||
}
|
||||
|
||||
// AdminActivityIdRequest is used by GET /detail, publish, pause, delete.
|
||||
type AdminActivityIdRequest struct {
|
||||
Id int64 `form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryRulesRequest overwrites eligibility / chance_sources /
|
||||
// unmet_action in a single call. Validated against rule caps before persist.
|
||||
type UpdateAdminLotteryRulesRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLotteryPrize is the admin view of a prize (includes weight + stock).
|
||||
type AdminLotteryPrize struct {
|
||||
Id int64 `json:"id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
Slot int `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Weight int `json:"weight"`
|
||||
TotalStock *int64 `json:"total_stock"`
|
||||
RemainingStock *int64 `json:"remaining_stock"`
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateAdminLotteryPrizeRequest is nested under /activities/{id}/prizes.
|
||||
type CreateAdminLotteryPrizeRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
Slot int `json:"slot"`
|
||||
Type string `json:"type" validate:"required,max=32"`
|
||||
Name string `json:"name" validate:"required,max=128"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Weight int `json:"weight"`
|
||||
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryPrizeRequest updates mutable prize fields.
|
||||
type UpdateAdminLotteryPrizeRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IconUrl string `json:"icon_url,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Weight *int `json:"weight,omitempty"`
|
||||
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||
IsFallback *bool `json:"is_fallback,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryPrizesRequest lists prizes for an activity.
|
||||
type ListAdminLotteryPrizesRequest struct {
|
||||
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryPrizesResponse pages.
|
||||
type ListAdminLotteryPrizesResponse struct {
|
||||
List []AdminLotteryPrize `json:"list"`
|
||||
}
|
||||
|
||||
// AdminPrizeIdRequest is used by DELETE / GET single.
|
||||
type AdminPrizeIdRequest struct {
|
||||
Id int64 `form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
|
||||
// activity. sourceRef doubles as idempotency key.
|
||||
type GrantAdminLotteryChanceRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Amount int `json:"amount" validate:"required,min=1"`
|
||||
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
||||
}
|
||||
Reference in New Issue
Block a user