Files
hi-server/internal/logic/admin/lottery/lottery.go
T
shanshanzhong147 2c1ee78bc4 修复(#4): 奖品更新支持修改 type(原来 type 被静默忽略)
UpdateLotteryPrize 之前可改字段漏了 type,导致 PUT 带 type 不生效、
只能删了重建。补上 UpdateAdminLotteryPrizeRequest.Type + fields["type"]。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:03:42 -07:00

743 lines
24 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) {
// AdminMetaMiddleware populates these keys on the request context after
// AuthMiddleware runs. Absent middleware (unit tests, non-admin paths)
// → empty strings, which is the intended defensive default.
if v, ok := ctx.Value(constant.CtxKeyIP).(string); ok {
ip = v
}
if v, ok := ctx.Value(constant.CtxKeyUserAgent).(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 {
// HIF-4 F8: 布局 A 3×3 挖中心 → 8 个奖品格。前端约定中心是"点击抽奖"按钮,
// 不渲染为奖品;后台仍允许挂 slot=4 但前端会忽略。
activity.GridSize = 8
}
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)
}
// ---- DeleteLotteryActivity -------------------------------------------------
type DeleteLotteryActivityLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryActivityLogic {
return &DeleteLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
// DeleteLotteryActivity 软删活动 + 硬删其奖品(同事务)。运行中的活动禁止删除,
// 需先暂停,避免误删正在进行的抽奖。历史抽奖记录/快照保留(独立于奖品行)。
func (l *DeleteLotteryActivityLogic) DeleteLotteryActivity(req *types.AdminActivityIdRequest) 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 = ?", req.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 a.Status == modelLottery.ActivityStatusRunning {
return xerr.NewErrCodeMsg(xerr.InvalidParams, "运行中的活动请先暂停再删除")
}
// 软删活动(Activity 有 gorm.DeletedAt)。
if err := tx.Delete(&modelLottery.Activity{}, req.Id).Error; err != nil {
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
}
// 硬删奖品(Prize 无软删字段),避免残留孤儿奖品。
if err := tx.Where("activity_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.ActionLotteryActivityDelete,
TargetIds: int64ToStr(req.Id),
IP: ip,
UserAgent: ua,
})
})
}
// ---- 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.Type != "" {
fields["type"] = req.Type
}
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:])
}