ce3babcc33
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
687 lines
22 KiB
Go
687 lines
22 KiB
Go
// 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:])
|
|
}
|