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,410 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// EnakGameAdminHandler serves /marketing/enakgame (docs/rfc-enakgame.md §11). Every
|
||||
// request works on the admin's own organization.
|
||||
type EnakGameAdminHandler struct {
|
||||
service service.EnakGameAdminService
|
||||
}
|
||||
|
||||
func NewEnakGameAdminHandler(s service.EnakGameAdminService) *EnakGameAdminHandler {
|
||||
return &EnakGameAdminHandler{service: s}
|
||||
}
|
||||
|
||||
// voucherCodeUploadLimit caps a CSV of codes: 50 000 lines of up to 255 characters.
|
||||
const voucherCodeUploadLimit = 16 << 20
|
||||
|
||||
// pathID reads a UUID path parameter, answering 400 when it is not one.
|
||||
func pathID(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// rawBody reads the request body, answering 400 when it cannot.
|
||||
func rawBody(c *gin.Context, method string) ([]byte, bool) {
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "cannot read the request body"),
|
||||
}), method)
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func bindQuery(c *gin.Context, q any, method string) bool {
|
||||
if err := c.ShouldBindQuery(q); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateGame is POST /marketing/enakgame/games.
|
||||
func (h *EnakGameAdminHandler) CreateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateGame"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateGame(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListGames is GET /marketing/enakgame/games?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListGames(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListGames"
|
||||
var q models.EnakGameListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetGame is GET /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) GetGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetGame(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateGame is PUT /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateGame(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetGameStatus is PUT /marketing/enakgame/games/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetGameStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetGameStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetGameStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateRewardConfig is POST /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) CreateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ListRewardConfigs is GET /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) ListRewardConfigs(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListRewardConfigs"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListRewardConfigs(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// ActivateRewardConfig is POST /marketing/enakgame/reward-configs/:id/activate.
|
||||
func (h *EnakGameAdminHandler) ActivateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ActivateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ActivateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateBudget is POST /marketing/enakgame/budgets.
|
||||
func (h *EnakGameAdminHandler) CreateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateBudget"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateBudget(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListBudgets is GET /marketing/enakgame/budgets?scope=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListBudgets(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListBudgets"
|
||||
var q models.GameBudgetListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListBudgets(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetBudget is GET /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) GetBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateBudget is PUT /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateBudget(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// DeleteBudget is DELETE /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) DeleteBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::DeleteBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.DeleteBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// BudgetMetrics is GET /marketing/enakgame/budgets/:id/metrics.
|
||||
func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::BudgetMetrics"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// CreateVoucher is POST /marketing/enakgame/vouchers.
|
||||
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateVoucher"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateVoucher(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListVouchers is GET /marketing/enakgame/vouchers?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVouchers"
|
||||
var q models.VoucherListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetVoucher is GET /marketing/enakgame/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateVoucher is PUT /marketing/enakgame/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateVoucher(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetVoucherStatus is PUT /marketing/enakgame/vouchers/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetVoucherStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetVoucherStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ImportVoucherCodes is POST /marketing/enakgame/vouchers/:id/codes, with the CSV as
|
||||
// a multipart file named "file" or as the request body.
|
||||
func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ImportVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var data []byte
|
||||
if strings.HasPrefix(c.ContentType(), "multipart/") {
|
||||
file, err := c.FormFile("file")
|
||||
if err == nil {
|
||||
var f multipart.File
|
||||
if f, err = file.Open(); err == nil {
|
||||
data, err = io.ReadAll(io.LimitReader(f, voucherCodeUploadLimit))
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "send the CSV as a file named \"file\""),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
} else if data, ok = rawBody(c, method); !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ImportVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, data), method)
|
||||
}
|
||||
|
||||
// ListVoucherCodes is GET /marketing/enakgame/vouchers/:id/codes?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q models.VoucherCodeListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, q), method)
|
||||
}
|
||||
|
||||
// CreateEvent is POST /marketing/enakgame/events.
|
||||
func (h *EnakGameAdminHandler) CreateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateEvent"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateEvent(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListEvents is GET /marketing/enakgame/events?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListEvents(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListEvents"
|
||||
var q models.GameEventListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListEvents(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetEvent is GET /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) GetEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetEvent(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateEvent is PUT /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateEvent(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetEventStatus is PUT /marketing/enakgame/events/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetEventStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetEventStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetEventStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
Reference in New Issue
Block a user