feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md). Foundation (phase 1) - Migrations 000103-000106: games extended with organization, slug, status, entry cost and result rules, old games archived (not deleted); budgets, versioned reward configs, sessions and session rewards; the ledger types GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs. - AuditLogger writes in the caller's transaction only. - enakgame.limit.user_daily and global_daily organization settings. Games and sessions (phases 2-4) - Admin /marketing/enakgame: games, reward config versions (immutable but for status, one ACTIVE per game), budgets with non-overlapping global periods and a daily job opening the next month. - Customer /customer/enakgame: start (Idempotency-Key, entry cost and config frozen on the session), complete (result validation, reward engine, max_reward cap, daily limits via game_reward_counters, one GAME_REWARD per budget), automatic refunds for system errors and deactivated games, and a session job. - Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand), rounded down. Vouchers and budgets (phases 5-6) - Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution; Economy Guard counters. - STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action; realized cost traced through the lots to the budget that paid the reward. - Budget metrics: realized cost, forecast, exposure and status. Migrations 000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY. Events (phase 7) - Migration 000111: game events, each with its own EVENT budget. Event extras stack per PRD §16 defaults, with event and per-customer limits. External vouchers (phase 8) - VoucherProvider contract, two-step PENDING redemption and a recovery job, tested with a fake provider. No provider adapter is registered yet, so EXTERNAL vouchers stay out of the catalog. Not yet decided before release: reward rounding, event stacking, budget exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have not been run on any shared database. Also fixes a leftover PAYMENT filter in a wallet test and a data race in a test PIN fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,462 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrEnakGameRejected wraps every reason an EnakGame request is refused: a field out
|
||||
// of bounds, a slug in use, an archived game, a retired configuration. The message
|
||||
// says which.
|
||||
var ErrEnakGameRejected = errors.New("enakgame request refused")
|
||||
|
||||
const (
|
||||
enakGameDefaultSessionTTL = 600
|
||||
enakGameMaxSessionTTL = 24 * 60 * 60
|
||||
enakGamePageLimit = 20
|
||||
enakGameMaxPageLimit = 100
|
||||
)
|
||||
|
||||
var enakGameSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
func enakGameRejected(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrEnakGameRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// EnakGameAdminProcessor manages an organization's EnakGame games and their reward
|
||||
// configurations (docs/rfc-enakgame.md §5.1, §5.2, §11). Every change is written to
|
||||
// audit_logs in the same transaction (§13).
|
||||
type EnakGameAdminProcessor struct {
|
||||
games repository.EnakGameRepository
|
||||
audit *AuditLogger
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewEnakGameAdminProcessor(games repository.EnakGameRepository, audit *AuditLogger, tx TxRunner) *EnakGameAdminProcessor {
|
||||
return &EnakGameAdminProcessor{games: games, audit: audit, tx: tx}
|
||||
}
|
||||
|
||||
func (p *EnakGameAdminProcessor) record(ctx context.Context, organizationID, actor uuid.UUID, entityType string, entityID uuid.UUID, action string, before, after any, reason *string) error {
|
||||
return p.audit.Record(ctx, AuditEntry{
|
||||
OrganizationID: organizationID,
|
||||
ActorType: constants.AuditActorUser,
|
||||
ActorID: &actor,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
Action: action,
|
||||
Before: before,
|
||||
After: after,
|
||||
Reason: reason,
|
||||
Source: constants.AuditSourceAdminAPI,
|
||||
})
|
||||
}
|
||||
|
||||
// EnakGameInputFrom is a game's current values as an input, for a change that only
|
||||
// sends the fields it changes.
|
||||
func EnakGameInputFrom(game *models.EnakGame) models.EnakGameInput {
|
||||
return models.EnakGameInput{
|
||||
Name: game.Name, Type: game.Type, Slug: game.Slug, Description: game.Description,
|
||||
ThumbnailURL: game.ThumbnailURL, GameURL: game.GameURL, Version: game.Version, Status: game.Status,
|
||||
EntryCost: game.EntryCost, SessionTTLSeconds: game.SessionTTLSeconds, ResultRules: game.ResultRules,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *EnakGameAdminProcessor) CreateGame(ctx context.Context, organizationID, actor uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) {
|
||||
if in.Type == "" {
|
||||
in.Type = string(entities.GameTypeMinigame)
|
||||
}
|
||||
if in.Status == "" {
|
||||
in.Status = constants.GameStatusDraft
|
||||
}
|
||||
if in.SessionTTLSeconds == 0 {
|
||||
in.SessionTTLSeconds = enakGameDefaultSessionTTL
|
||||
}
|
||||
if err := validateEnakGameInput(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch in.Status {
|
||||
case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive:
|
||||
default:
|
||||
return nil, enakGameRejected("a new game must be DRAFT, ACTIVE or INACTIVE")
|
||||
}
|
||||
|
||||
game := &entities.Game{OrganizationID: &organizationID, Status: in.Status}
|
||||
applyEnakGameInput(game, in)
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.games.CreateGame(ctx, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.record(ctx, organizationID, actor, constants.AuditEntityGame, game.ID, "CREATED", nil, enakGameModel(game), nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, enakGameError(err)
|
||||
}
|
||||
return enakGameModel(game), nil
|
||||
}
|
||||
|
||||
func (p *EnakGameAdminProcessor) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*models.EnakGame, error) {
|
||||
game, err := p.games.GetGame(ctx, organizationID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enakGameModel(game), nil
|
||||
}
|
||||
|
||||
func (p *EnakGameAdminProcessor) ListGames(ctx context.Context, organizationID uuid.UUID, q models.EnakGameListQuery) (*models.PaginatedResponse[models.EnakGame], error) {
|
||||
page, limit := enakGamePage(q.Page, q.Limit)
|
||||
statuses := []string{constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive}
|
||||
if q.Status != "" {
|
||||
status := strings.ToUpper(strings.TrimSpace(q.Status))
|
||||
if !isEnakGameStatus(status) {
|
||||
return nil, enakGameRejected("unknown status %q", q.Status)
|
||||
}
|
||||
statuses = []string{status}
|
||||
}
|
||||
games, total, err := p.games.ListGames(ctx, repository.EnakGameFilter{
|
||||
OrganizationID: organizationID, Statuses: statuses, Search: strings.TrimSpace(q.Search),
|
||||
Offset: (page - 1) * limit, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]models.EnakGame, 0, len(games))
|
||||
for i := range games {
|
||||
items = append(items, *enakGameModel(&games[i]))
|
||||
}
|
||||
return &models.PaginatedResponse[models.EnakGame]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil
|
||||
}
|
||||
|
||||
// UpdateGame changes everything but the status, which has its own endpoint. An
|
||||
// archived game never changes.
|
||||
func (p *EnakGameAdminProcessor) UpdateGame(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) {
|
||||
if err := validateEnakGameInput(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var after *models.EnakGame
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
game, err := p.games.LockGame(ctx, organizationID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game.Status == constants.GameStatusArchived {
|
||||
return enakGameRejected("an archived game cannot change")
|
||||
}
|
||||
before := enakGameModel(game)
|
||||
applyEnakGameInput(game, in)
|
||||
if err := p.games.UpdateGame(ctx, game); err != nil {
|
||||
return err
|
||||
}
|
||||
after = enakGameModel(game)
|
||||
return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "UPDATED", before, after, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, enakGameError(err)
|
||||
}
|
||||
return p.GetGame(ctx, organizationID, after.ID)
|
||||
}
|
||||
|
||||
// SetGameStatus moves a game between DRAFT, ACTIVE and INACTIVE, or archives it for
|
||||
// good. Sessions still open on a game leaving ACTIVE are refunded by the session job
|
||||
// (§7.3), not here.
|
||||
func (p *EnakGameAdminProcessor) SetGameStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameStatusInput) (*models.EnakGame, error) {
|
||||
status := strings.ToUpper(strings.TrimSpace(in.Status))
|
||||
if !isEnakGameStatus(status) {
|
||||
return nil, enakGameRejected("status must be DRAFT, ACTIVE, INACTIVE or ARCHIVED")
|
||||
}
|
||||
if err := validateReason(in.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
game, err := p.games.LockGame(ctx, organizationID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game.Status == status {
|
||||
return nil
|
||||
}
|
||||
if game.Status == constants.GameStatusArchived {
|
||||
return enakGameRejected("an archived game cannot change")
|
||||
}
|
||||
if err := p.games.SetGameStatus(ctx, organizationID, id, status); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "STATUS_CHANGED",
|
||||
map[string]string{"status": game.Status}, map[string]string{"status": status}, in.Reason)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, enakGameError(err)
|
||||
}
|
||||
return p.GetGame(ctx, organizationID, id)
|
||||
}
|
||||
|
||||
// CreateRewardConfig stores a new DRAFT version of a game's reward configuration. A
|
||||
// configuration is never edited (D7): a change is a new version, activated on its
|
||||
// own.
|
||||
func (p *EnakGameAdminProcessor) CreateRewardConfig(ctx context.Context, organizationID, actor, gameID uuid.UUID, in models.GameRewardConfigInput) (*models.GameRewardConfig, error) {
|
||||
rewardType := strings.ToUpper(strings.TrimSpace(in.RewardType))
|
||||
calculator, err := RewardCalculatorFor(rewardType)
|
||||
if err != nil {
|
||||
return nil, enakGameRejected("%v", err)
|
||||
}
|
||||
if len(in.Rules) == 0 {
|
||||
return nil, enakGameRejected("rules are required")
|
||||
}
|
||||
if err := calculator.Validate(in.Rules); err != nil {
|
||||
return nil, enakGameRejected("%v", err)
|
||||
}
|
||||
if in.MaxReward < 0 {
|
||||
return nil, enakGameRejected("max_reward must be 0 or more")
|
||||
}
|
||||
if err := validateReason(in.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules bytes.Buffer
|
||||
if err := json.Compact(&rules, in.Rules); err != nil {
|
||||
return nil, enakGameRejected("rules: %v", err)
|
||||
}
|
||||
|
||||
config := &entities.GameRewardConfig{
|
||||
OrganizationID: organizationID, GameID: gameID, RewardType: rewardType,
|
||||
Rules: entities.JSONDocument(rules.Bytes()), MaxReward: in.MaxReward,
|
||||
Status: constants.GameRewardConfigStatusDraft, EffectiveAt: in.EffectiveAt, CreatedBy: actor, Reason: in.Reason,
|
||||
}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
// The game lock also orders versions of the same game one after the other.
|
||||
game, err := p.games.LockGame(ctx, organizationID, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game.Status == constants.GameStatusArchived {
|
||||
return enakGameRejected("an archived game cannot change")
|
||||
}
|
||||
if err := p.games.CreateRewardConfig(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED",
|
||||
nil, rewardConfigModel(config), in.Reason)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, enakGameError(err)
|
||||
}
|
||||
return rewardConfigModel(config), nil
|
||||
}
|
||||
|
||||
// ActivateRewardConfig makes a DRAFT version the game's active configuration and
|
||||
// retires the one active before, in one transaction. Activating the active version
|
||||
// again changes nothing. A retired version stays retired: going back means a new
|
||||
// version with the old rules.
|
||||
func (p *EnakGameAdminProcessor) ActivateRewardConfig(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameRewardConfigActivateInput) (*models.GameRewardConfig, error) {
|
||||
if err := validateReason(in.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var activated *entities.GameRewardConfig
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
config, err := p.games.GetRewardConfig(ctx, organizationID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Activations of the same game wait for each other here, so exactly one
|
||||
// version ends up active.
|
||||
game, err := p.games.LockGame(ctx, organizationID, config.GameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game.Status == constants.GameStatusArchived {
|
||||
return enakGameRejected("an archived game cannot change")
|
||||
}
|
||||
if config, err = p.games.GetRewardConfig(ctx, organizationID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
activated = config
|
||||
switch config.Status {
|
||||
case constants.GameRewardConfigStatusActive:
|
||||
return nil
|
||||
case constants.GameRewardConfigStatusRetired:
|
||||
return enakGameRejected("version %d is retired; create a new version instead", config.Version)
|
||||
}
|
||||
|
||||
current, err := p.games.GetActiveRewardConfig(ctx, organizationID, config.GameID)
|
||||
if err != nil && !errors.Is(err, repository.ErrGameRewardConfigNotFound) {
|
||||
return err
|
||||
}
|
||||
if current != nil {
|
||||
if _, err := p.moveRewardConfig(ctx, organizationID, actor, current, constants.GameRewardConfigStatusRetired, "RETIRED", in.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
activated, err = p.moveRewardConfig(ctx, organizationID, actor, config, constants.GameRewardConfigStatusActive, "ACTIVATED", in.Reason)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, enakGameError(err)
|
||||
}
|
||||
return rewardConfigModel(activated), nil
|
||||
}
|
||||
|
||||
func (p *EnakGameAdminProcessor) moveRewardConfig(ctx context.Context, organizationID, actor uuid.UUID, config *entities.GameRewardConfig, to, action string, reason *string) (*entities.GameRewardConfig, error) {
|
||||
moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, config.ID, config.Status, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !moved {
|
||||
return nil, enakGameRejected("version %d changed meanwhile; try again", config.Version)
|
||||
}
|
||||
before := config.Status
|
||||
next := *config
|
||||
next.Status = to
|
||||
err = p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, action,
|
||||
map[string]string{"status": before}, map[string]string{"status": to}, reason)
|
||||
return &next, err
|
||||
}
|
||||
|
||||
// ListRewardConfigs returns every version of a game's configuration, newest first.
|
||||
func (p *EnakGameAdminProcessor) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]models.GameRewardConfig, error) {
|
||||
if _, err := p.games.GetGame(ctx, organizationID, gameID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs, err := p.games.ListRewardConfigs(ctx, organizationID, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.GameRewardConfig, 0, len(configs))
|
||||
for i := range configs {
|
||||
out = append(out, *rewardConfigModel(&configs[i]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// enakGameError turns a slug collision into a refusal; anything else passes as is.
|
||||
func enakGameError(err error) error {
|
||||
if errors.Is(err, repository.ErrEnakGameSlugTaken) {
|
||||
return enakGameRejected("another game already uses this slug")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isEnakGameStatus(s string) bool {
|
||||
switch s {
|
||||
case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive, constants.GameStatusArchived:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateReason(reason *string) error {
|
||||
if reason != nil && len(*reason) > 255 {
|
||||
return enakGameRejected("reason must be at most 255 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEnakGameInput(in *models.EnakGameInput) error {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Slug = strings.TrimSpace(in.Slug)
|
||||
in.Type = strings.ToUpper(strings.TrimSpace(in.Type))
|
||||
in.Status = strings.ToUpper(strings.TrimSpace(in.Status))
|
||||
switch {
|
||||
case in.Name == "" || len(in.Name) > 255:
|
||||
return enakGameRejected("name is required, at most 255 characters")
|
||||
case in.Type != string(entities.GameTypeSpin) && in.Type != string(entities.GameTypeRaffle) && in.Type != string(entities.GameTypeMinigame):
|
||||
return enakGameRejected("type must be SPIN, RAFFLE or MINIGAME")
|
||||
case len(in.Slug) > 100 || !enakGameSlugPattern.MatchString(in.Slug):
|
||||
return enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters")
|
||||
case in.EntryCost < 1:
|
||||
// PRD §10.1: no game is free.
|
||||
return enakGameRejected("entry_cost must be at least 1")
|
||||
case in.SessionTTLSeconds < 1 || in.SessionTTLSeconds > enakGameMaxSessionTTL:
|
||||
return enakGameRejected("session_ttl_seconds must be between 1 and %d", enakGameMaxSessionTTL)
|
||||
}
|
||||
for field, value := range map[string]*string{"thumbnail_url": in.ThumbnailURL, "game_url": in.GameURL} {
|
||||
if value != nil && len(*value) > 500 {
|
||||
return enakGameRejected("%s must be at most 500 characters", field)
|
||||
}
|
||||
}
|
||||
if in.Version != nil && len(*in.Version) > 50 {
|
||||
return enakGameRejected("version must be at most 50 characters")
|
||||
}
|
||||
return validateResultRules(in.ResultRules)
|
||||
}
|
||||
|
||||
func validateResultRules(r entities.GameResultRules) error {
|
||||
switch {
|
||||
case r.MaxScore != nil && *r.MaxScore < 0:
|
||||
return enakGameRejected("result_rules.max_score must be 0 or more")
|
||||
case r.MinDurationSeconds != nil && *r.MinDurationSeconds < 0:
|
||||
return enakGameRejected("result_rules.min_duration_seconds must be 0 or more")
|
||||
case r.MaxScorePerSecond != nil && !(*r.MaxScorePerSecond > 0):
|
||||
return enakGameRejected("result_rules.max_score_per_second must be more than 0")
|
||||
case r.DailyRewardLimit != nil && *r.DailyRewardLimit < 0:
|
||||
return enakGameRejected("result_rules.daily_reward_limit must be 0 or more")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, outcome := range r.Outcomes {
|
||||
if strings.TrimSpace(outcome) == "" {
|
||||
return enakGameRejected("result_rules.outcomes cannot hold an empty outcome")
|
||||
}
|
||||
if seen[outcome] {
|
||||
return enakGameRejected("result_rules.outcomes lists %q twice", outcome)
|
||||
}
|
||||
seen[outcome] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyEnakGameInput(game *entities.Game, in models.EnakGameInput) {
|
||||
slug := in.Slug
|
||||
game.Name = in.Name
|
||||
game.Type = entities.GameType(in.Type)
|
||||
game.Slug = &slug
|
||||
game.Description = in.Description
|
||||
game.ThumbnailURL = in.ThumbnailURL
|
||||
game.GameURL = in.GameURL
|
||||
game.Version = in.Version
|
||||
game.EntryCost = in.EntryCost
|
||||
game.SessionTTLSeconds = in.SessionTTLSeconds
|
||||
game.ResultRules = in.ResultRules
|
||||
}
|
||||
|
||||
func enakGameModel(g *entities.Game) *models.EnakGame {
|
||||
m := &models.EnakGame{
|
||||
ID: g.ID, Name: g.Name, Type: string(g.Type), Description: g.Description, ThumbnailURL: g.ThumbnailURL,
|
||||
GameURL: g.GameURL, Version: g.Version, Status: g.Status, EntryCost: g.EntryCost,
|
||||
SessionTTLSeconds: g.SessionTTLSeconds, ResultRules: g.ResultRules, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
if g.Slug != nil {
|
||||
m.Slug = *g.Slug
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig {
|
||||
return &models.GameRewardConfig{
|
||||
ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules),
|
||||
MaxReward: c.MaxReward, Status: c.Status, EffectiveAt: c.EffectiveAt, CreatedBy: c.CreatedBy,
|
||||
Reason: c.Reason, CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func enakGamePage(page, limit int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > enakGameMaxPageLimit {
|
||||
limit = enakGamePageLimit
|
||||
}
|
||||
return page, limit
|
||||
}
|
||||
|
||||
func enakGamePagination(page, limit int, total int64) models.Pagination {
|
||||
return models.Pagination{Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit))}
|
||||
}
|
||||
Reference in New Issue
Block a user