新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成
Closes HIF-3 Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。 架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。 - 迁移 02158_admin_action_log:后台写操作审计 - xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge) - feature flag config.Lottery.Enable 默认 false,合并后线上零副作用 - draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize - 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010) - 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant - audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要 - QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl 测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100% 100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁) CI 全绿:构建/Vet/测试 + golangci-lint
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user