// 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:]) }