This commit is contained in:
Denis 2026-03-30 21:00:35 +03:00
commit b340a339c3
32 changed files with 6767 additions and 0 deletions

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=game_admin_user
DB_PASSWORD=game_admin_password
DB_NAME=game_admin
ADMINER_PORT=8081
DEFAULT_ADMIN_EMAIL=admin@admin.com
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
JWT_SECRET=change-this-to-a-random-secret-key
SERVER_PORT=8080
GRPC_PORT=50051
GRPC_HMAC_SECRET=super_secret_key
BASE_URL=http://localhost:8080
UPLOAD_DIR=uploads

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
.env
.env.*
!.env.example
# IDE and OS files
.idea/
.vscode/
.DS_Store
Thumbs.db
# Go build artifacts
bin/
dist/
build/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Logs and coverage
*.log
coverage.out
coverage.html
# Local runtime data
uploads/
tmp/
temp/

698
API.md Normal file
View File

@ -0,0 +1,698 @@
# Backend API
Base URL: `http://localhost:8080`
All protected endpoints require:
```http
Authorization: Bearer <token>
```
## Access Rules
- Public:
- `POST /api/auth/login`
- `POST /api/auth/register`
- `GET /api/invites/validate/:code`
- Authorized users:
- `GET /api/auth/me`
- read endpoints for games, modules, languages, leaderboards, notifications
- `admin` / `moderator`:
- create/update/delete for games, modules inside games, variables, notifications, leaderboards, groups, languages, invites, image uploads
## Auth
### Login
`POST /api/auth/login`
```json
{
"email": "admin@admin.com",
"password": "admin123"
}
```
### Register
`POST /api/auth/register`
```json
{
"email": "user@example.com",
"username": "player1",
"password": "secret123",
"invite_code": "abcd1234"
}
```
### Current User
`GET /api/auth/me`
## Games
### List Games
`GET /api/games`
Each game includes connected modules.
Example response:
```json
[
{
"id": 5,
"name": "Black & White",
"slug": "b&d",
"description": "Sandbox",
"image_url": "",
"is_active": true,
"modules": [
{
"id": 1,
"key": "variables",
"name": "Variables"
},
{
"id": 2,
"key": "notification",
"name": "Notification"
}
]
}
]
```
Supports search by game `name` or `slug`:
- `GET /api/games?search=black`
- `GET /api/games?search=b&d`
### Get Game
`GET /api/games/:id`
Useful when client is inside a module page and needs the current game `slug` to restore game search state on exit.
Example response:
```json
{
"id": 5,
"name": "Black & White",
"slug": "b&d",
"description": "Sandbox",
"image_url": "",
"is_active": true,
"modules": [
{
"id": 1,
"key": "variables",
"name": "Variables"
},
{
"id": 2,
"key": "notification",
"name": "Notification"
}
]
}
```
### Create Game
`POST /api/games`
```json
{
"name": "My Game",
"slug": "my-game",
"description": "Description",
"image_url": ""
}
```
### Update Game
`PUT /api/games/:id`
### Delete Game
`DELETE /api/games/:id`
## Modules
### List Available Modules
`GET /api/modules`
Default modules:
- `variables`
- `notification`
- `leaderboard`
### List Game Modules
`GET /api/games/:id/modules`
### Connect Module To Game
`POST /api/games/:id/modules`
```json
{
"module_key": "variables"
}
```
### Disconnect Module From Game
`DELETE /api/games/:id/modules/:module_key`
## Languages
Default seeded languages:
- `en` / `English`
- `ru` / `Русский`
### List Languages
`GET /api/languages`
### Get Language
`GET /api/languages/:id`
### Create Language
`POST /api/languages`
```json
{
"code": "de",
"name": "Deutsch"
}
```
### Update Language
`PUT /api/languages/:id`
### Delete Language
`DELETE /api/languages/:id`
## User-Game Connections
### Search Users In Game
`GET /api/games/:id/users?search=...`
Returns only users connected to game `:id`.
Search behavior:
- if `search` is an integer, search by user `id`
- otherwise search by `username` or `email`
Examples:
- `GET /api/games/5/users`
- `GET /api/games/5/users?search=12`
- `GET /api/games/5/users?search=denis`
- `GET /api/games/5/users?search=denis@example.com`
Example response:
```json
[
{
"id": 12,
"email": "denis@example.com",
"username": "denis",
"role": "user",
"is_active": true,
"created_at": "2026-03-23T20:00:00Z",
"updated_at": "2026-03-23T20:00:00Z"
}
]
```
### List User Games
`GET /api/users/:id/games`
### Connect Game To User
`POST /api/users/:id/games`
```json
{
"game_id": 5
}
```
### Disconnect Game From User
`DELETE /api/users/:id/games/:game_id`
## Variables
All variable endpoints work only if the game has module `variables`.
### List Variables
`GET /api/games/:id/variables`
### Create Variable
`POST /api/games/:id/variables`
Number variable:
```json
{
"key": "max_bet",
"type": "number",
"number_value": 100
}
```
String variable:
```json
{
"key": "welcome_text",
"type": "string",
"string_value": "hello"
}
```
Table variable with numbers:
```json
{
"key": "rates",
"type": "table",
"table_value_type": "number",
"items": [
{ "key": "bronze", "number_value": 1.1 },
{ "key": "silver", "number_value": 1.3 }
]
}
```
Table variable with strings:
```json
{
"key": "titles",
"type": "table",
"table_value_type": "string",
"items": [
{ "key": "bronze", "string_value": "Bronze" },
{ "key": "silver", "string_value": "Silver" }
]
}
```
Vector variable with numbers:
```json
{
"key": "steps",
"type": "vector",
"table_value_type": "number",
"items": [
{ "index": 0, "number_value": 10 },
{ "index": 1, "number_value": 20 }
]
}
```
Vector variable with strings:
```json
{
"key": "messages",
"type": "vector",
"table_value_type": "string",
"items": [
{ "index": 0, "string_value": "hello" },
{ "index": 1, "string_value": "world" }
]
}
```
### Update Variable
`PUT /api/games/:id/variables/:var_id`
### Delete Variable
`DELETE /api/games/:id/variables/:var_id`
### Variable Rules
- `type = number` uses only `number_value`
- `type = string` uses only `string_value`
- `type = table` uses only `table_value_type` and `items`
- `type = vector` uses only `table_value_type` and `items`
- all table items must use the same value type
- all vector items must use `index` instead of `key`
- vector indexes must be unique and `>= 0`
- variable `key` is unique inside one game
## Images
### Upload Image
`POST /api/images`
Content type: `multipart/form-data`
Form field:
- `file`
Example response:
```json
{
"name": "f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
"path": "/uploads/images/f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
"url": "http://localhost:8080/uploads/images/f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
"content_type": "image/png"
}
```
Static file access:
`GET /uploads/images/:filename`
## Notifications
All notification endpoints work only if the game has module `notification`.
Notification structure:
- one `notification` has:
- `name`
- multilingual `descriptions`
- shared custom `variables`
- many `entries`
- each `entry` has its own:
- `time_second`
- `image`
- `login`
- values for all shared custom variables
Macros returned by API always include:
- `{{time_second}}`
- `{{image}}`
- `{{login}}`
- all custom variable keys
### List Notifications
`GET /api/games/:id/notifications`
### Get Notification
`GET /api/games/:id/notifications/:notification_id`
### Create Notification
`POST /api/games/:id/notifications`
```json
{
"name": "Welcome bonus",
"descriptions": [
{
"language_id": 1,
"description": "Hello {{login}}, promo {{promo_code}} after {{time_second}} seconds"
},
{
"language_id": 2,
"description": "Привет {{login}}, промокод {{promo_code}} через {{time_second}} секунд"
}
],
"variables": [
{ "key": "promo_code" },
{ "key": "reward" }
],
"entries": [
{
"time_second": 30,
"image": "http://localhost:8080/uploads/images/a.png",
"login": "Denis",
"variables": [
{ "key": "promo_code", "value": "WELCOME30" },
{ "key": "reward", "value": "100" }
]
},
{
"time_second": 60,
"image": "http://localhost:8080/uploads/images/b.png",
"login": "Alex",
"variables": [
{ "key": "promo_code", "value": "WELCOME60" },
{ "key": "reward", "value": "200" }
]
}
]
}
```
### Update Notification
`PUT /api/games/:id/notifications/:notification_id`
### Delete Notification
`DELETE /api/games/:id/notifications/:notification_id`
### Notification Rules
- `name` is required
- `descriptions` must contain at least one item
- `entries` must contain at least one item
- one `language_id` cannot repeat inside `descriptions`
- top-level `variables` define the shared custom variable set
- reserved variable keys are forbidden:
- `time_second`
- `image`
- `login`
- each entry must contain:
- `time_second >= 0`
- `image`
- `login`
- full set of custom variable values
- entry variable keys must exactly match top-level custom variable keys
## Leaderboards
All leaderboard endpoints work only if the game has module `leaderboard`.
### List Game Leaderboards
`GET /api/games/:id/leaderboards`
### Get Leaderboard
`GET /api/games/:id/leaderboards/:leaderboard_id`
### Create Leaderboard
`POST /api/games/:id/leaderboards`
```json
{
"key": "top_balance",
"name": "Top Balance",
"sort_order": "desc",
"period_type": "all_time",
"is_active": true
}
```
`sort_order`:
- `asc`
- `desc`
`period_type`:
- `all_time`
- `daily`
- `weekly`
- `monthly`
### Update Leaderboard
`PUT /api/games/:id/leaderboards/:leaderboard_id`
### Delete Leaderboard
`DELETE /api/games/:id/leaderboards/:leaderboard_id`
### List Leaderboard Groups
`GET /api/leaderboards/:leaderboard_id/groups`
### Create Group
`POST /api/leaderboards/:leaderboard_id/groups`
```json
{
"key": "vip",
"name": "VIP",
"is_default": false
}
```
### Update Group
`PUT /api/leaderboard-groups/:group_id`
### Delete Group
`DELETE /api/leaderboard-groups/:group_id`
### Add Group Member
`POST /api/leaderboard-groups/:group_id/members`
```json
{
"user_id": 12
}
```
User must already be connected to the same game.
### Delete Group Member
`DELETE /api/leaderboard-groups/:group_id/members/:user_id`
### Save Score
`POST /api/leaderboards/:leaderboard_id/scores`
```json
{
"user_game_id": 5,
"score": 1200
}
```
If a score already exists for this user in this leaderboard, it is updated.
### Get Rankings
`GET /api/leaderboards/:leaderboard_id/rankings`
Query params:
- `group_id` optional
- `limit` optional, default `50`, max `200`
Example:
`GET /api/leaderboards/1/rankings?group_id=2&limit=20`
Response:
```json
{
"leaderboard": {
"id": 1,
"game_id": 5,
"key": "top_balance",
"name": "Top Balance",
"sort_order": "desc",
"period_type": "all_time",
"is_active": true
},
"items": [
{
"rank": 1,
"user_id": 12,
"user_game_id": 5,
"username": "arnold",
"score": 1200
},
{
"rank": 2,
"user_id": 17,
"user_game_id": 8,
"username": "german",
"score": 950
}
]
}
```
### Leaderboard Rules
- leaderboard `key` is unique inside one game
- group `key` is unique inside one leaderboard
- score is stored once per `leaderboard_id + user_game_id`
- groups do not duplicate scores, they only filter ranking members
## Balance
### Top Up Balance
`POST /api/user-games/:ug_id/topup`
```json
{
"amount": 100,
"comment": "manual topup"
}
```
### List Transactions
`GET /api/user-games/:ug_id/transactions`
## Invites
### Validate Invite
`GET /api/invites/validate/:code`
### Create Invite
`POST /api/invites`
```json
{
"role": "user",
"max_uses": 10
}
```
### List Invites
`GET /api/invites`
### Delete Invite
`DELETE /api/invites/:code`
## Common Error Patterns
- `400` invalid path param or invalid request body
- `401` invalid or missing token
- `403` module is not enabled for the target game, or role is insufficient
- `404` entity not found
- `409` duplicate key / duplicate connection / duplicate membership

232
AUTH_ARCHITECTURE.md Normal file
View File

@ -0,0 +1,232 @@
# Auth Architecture
## Goal
One user should authenticate once in `master` and then access other game services without re-login.
For this project, the recommended approach is:
- `master` is the single authentication provider
- user authentication uses JWT access tokens
- game services validate the same user JWT
- internal service-to-service calls use a separate auth mechanism
Do not use one shared secret or one "same key for everything" approach for both users and services.
## Recommended Model
### 1. User authentication
The user authenticates in `master`:
- via Telegram `initData` for TG game/webapp
- or via regular login if needed for admin panel
`master` verifies the user and issues:
- short-lived `access token` JWT
- optionally a `refresh token`
This `access token` is then sent to all game services in:
```http
Authorization: Bearer <access_token>
```
### 2. Validation in other services
Each game service:
- does not log the user in again
- does not store the user's password or Telegram auth secret
- only validates the JWT from `master`
This gives SSO behavior across all games and services.
### 3. Service-to-service authentication
Calls between backend services should use a separate mechanism:
- service JWT
- or HMAC
- or mTLS
Do not rely on a user JWT as the only trust mechanism between services.
## Why This Is Better
- the user logs in once
- all services trust one source of identity
- auth logic stays in one place
- compromised game service should not be able to mint user tokens
- easier role and permission management
## Token Strategy
### Access token
Use a short lifetime:
- `15m` to `30m` for user access token
Include claims like:
- `sub`: user ID
- `role`: `user`, `admin`, `moderator`
- `iss`: issuer, for example `master-auth`
- `aud`: target audience, for example `game-services`
- `exp`
- `iat`
Example payload:
```json
{
"sub": "123",
"role": "user",
"iss": "master-auth",
"aud": ["game-services"],
"iat": 1735689600,
"exp": 1735690500
}
```
### Refresh token
Use if you want silent re-login:
- lifetime `7d` to `30d`
- store server-side or make it revocable
Refresh token should be handled only by `master`.
## Signing Algorithm
Recommended:
- `RS256` or `EdDSA`
Why:
- `master` signs with a private key
- other services verify with a public key
- game services cannot issue tokens even if compromised
Avoid using one shared `HS256` secret across many services if you plan to scale.
## Telegram Login Flow
### Option A. Telegram WebApp / Mini App
Flow:
1. Client gets `initData` from Telegram.
2. Client sends `initData` to `master`.
3. `master` verifies Telegram signature.
4. `master` finds or creates the user.
5. `master` issues JWT access token.
6. Client uses that JWT in all other services.
### Option B. Telegram bot deep-link or custom auth
Same principle:
1. Telegram proves identity to `master`.
2. `master` converts that identity into your internal user.
3. `master` issues your platform JWT.
Telegram should only be the initial identity proof. After that, your platform should use its own JWT.
## Authorization Model
Authentication and authorization should be separated:
- authentication: who the user is
- authorization: what the user can do
Minimum authorization data in JWT:
- user ID
- role
If needed later, add:
- game access list
- scopes
- tenant/project ID
Do not put large mutable permission lists into JWT if they change often.
## How This Fits Current Project
Current state in this repo:
- HTTP API already uses JWT
- gRPC wallet currently uses HMAC metadata
Recommended target state:
- keep JWT for user-facing HTTP and user-facing gRPC
- keep HMAC or service JWT for trusted backend-to-backend calls
That means:
- admin panel and game client use user JWT
- wallet or internal processors may still use HMAC/service auth for internal calls
## Practical Architecture
### Public auth endpoints in `master`
Suggested endpoints:
- `POST /api/auth/telegram`
- `POST /api/auth/refresh`
- `POST /api/auth/logout`
- `GET /api/auth/me`
### Shared verification in services
Every service should have middleware/interceptor that:
- reads `Authorization: Bearer <token>`
- verifies signature
- checks `iss`, `aud`, `exp`
- extracts `sub` and `role`
- places user context into request context
## Recommended Rollout For This Repo
### Stage 1
- keep existing HTTP JWT auth
- add Telegram login endpoint in `master`
- issue JWT after Telegram verification
### Stage 2
- add JWT verification middleware for any external game-facing services
- keep current gRPC HMAC only for internal trusted integrations
### Stage 3
- if wallet becomes user-facing over gRPC, add JWT gRPC interceptor
- optionally keep HMAC/service JWT for internal callers only
## What Not To Do
- do not make each game service perform Telegram login separately
- do not duplicate user auth logic in every service
- do not use one shared secret for both user tokens and service authentication
- do not issue long-lived access tokens without refresh/revocation strategy
## Final Recommendation
Best approach for your case:
- authenticate the player once in `master`
- issue a platform JWT there
- reuse that JWT across all game services
- use separate service authentication for internal backend calls
This is the cleanest and safest SSO model for a TG game ecosystem.

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/main.go
FROM alpine:3.18
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

164
cmd/main.go Normal file
View File

@ -0,0 +1,164 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net"
"os"
"path/filepath"
walletpb "game-admin/gen"
"game-admin/internal/config"
"game-admin/internal/grpcserver"
"game-admin/internal/handlers"
"game-admin/internal/middleware"
"game-admin/internal/migrations"
"game-admin/internal/models"
"game-admin/internal/wallet"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/extra/bundebug"
"google.golang.org/grpc"
)
func main() {
cfg := config.Load()
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
connector := pgdriver.NewConnector(pgdriver.WithDSN(dsn))
sqldb := sql.OpenDB(connector)
db := bun.NewDB(sqldb, pgdialect.New())
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
if err := os.MkdirAll(filepath.Join(cfg.UploadDir, "images"), 0o755); err != nil {
log.Fatal("Upload directory init failed:", err)
}
if err := migrations.Migrate(context.Background(), db, cfg); err != nil {
log.Fatal("Migration failed:", err)
}
authH := handlers.NewAuthHandler(db, cfg)
userH := handlers.NewUserHandler(db, cfg)
gameH := handlers.NewGameHandler(db, cfg)
inviteH := handlers.NewInviteHandler(db, cfg)
languageH := handlers.NewLanguageHandler(db, cfg)
imageH := handlers.NewImageHandler(cfg)
walletService := wallet.NewService(db)
r := gin.Default()
r.Static("/uploads", cfg.UploadDir)
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
api := r.Group("/api")
{
api.POST("/auth/login", authH.Login)
api.POST("/auth/register", authH.Register)
api.GET("/invites/validate/:code", inviteH.Validate)
}
auth := api.Group("", middleware.AuthRequired(cfg))
{
auth.GET("/auth/me", authH.Me)
auth.GET("/games", gameH.List)
auth.GET("/games/:id", gameH.GetByID)
auth.GET("/modules", gameH.ListModules)
auth.GET("/languages", languageH.List)
auth.GET("/languages/:id", languageH.GetByID)
auth.GET("/games/:id/leaderboards", gameH.ListLeaderboards)
auth.GET("/games/:id/leaderboards/:leaderboard_id", gameH.GetLeaderboard)
auth.GET("/leaderboards/:leaderboard_id/groups", gameH.ListLeaderboardGroups)
auth.GET("/leaderboards/:leaderboard_id/rankings", gameH.GetLeaderboardRankings)
auth.GET("/games/:id/notifications", gameH.ListNotifications)
auth.GET("/games/:id/notifications/:notification_id", gameH.GetNotification)
admin := auth.Group("", middleware.RoleRequired(models.RoleAdmin, models.RoleModerator))
{
admin.GET("/users", userH.List)
admin.GET("/users/:id", userH.GetByID)
admin.GET("/games/:id/users", userH.ListByGame)
admin.PATCH("/users/:id/role", userH.UpdateRole)
admin.PATCH("/users/:id/toggle-active", userH.ToggleActive)
admin.POST("/games", gameH.Create)
admin.PUT("/games/:id", gameH.Update)
admin.DELETE("/games/:id", gameH.Delete)
admin.GET("/games/:id/modules", gameH.ListGameModules)
admin.POST("/games/:id/modules", gameH.ConnectModule)
admin.DELETE("/games/:id/modules/:module_key", gameH.DisconnectModule)
admin.POST("/games/:id/leaderboards", gameH.CreateLeaderboard)
admin.PUT("/games/:id/leaderboards/:leaderboard_id", gameH.UpdateLeaderboard)
admin.DELETE("/games/:id/leaderboards/:leaderboard_id", gameH.DeleteLeaderboard)
admin.POST("/leaderboards/:leaderboard_id/groups", gameH.CreateLeaderboardGroup)
admin.PUT("/leaderboard-groups/:group_id", gameH.UpdateLeaderboardGroup)
admin.DELETE("/leaderboard-groups/:group_id", gameH.DeleteLeaderboardGroup)
admin.POST("/leaderboard-groups/:group_id/members", gameH.AddLeaderboardGroupMember)
admin.DELETE("/leaderboard-groups/:group_id/members/:user_id", gameH.DeleteLeaderboardGroupMember)
admin.POST("/leaderboards/:leaderboard_id/scores", gameH.UpsertLeaderboardScore)
admin.POST("/games/:id/notifications", gameH.CreateNotification)
admin.PUT("/games/:id/notifications/:notification_id", gameH.UpdateNotification)
admin.DELETE("/games/:id/notifications/:notification_id", gameH.DeleteNotification)
admin.GET("/games/:id/variables", gameH.ListVariables)
admin.POST("/games/:id/variables", gameH.CreateVariable)
admin.PUT("/games/:id/variables/:var_id", gameH.UpdateVariable)
admin.DELETE("/games/:id/variables/:var_id", gameH.DeleteVariable)
admin.POST("/images", imageH.Upload)
admin.POST("/languages", languageH.Create)
admin.PUT("/languages/:id", languageH.Update)
admin.DELETE("/languages/:id", languageH.Delete)
admin.GET("/users/:id/games", gameH.UserGames)
admin.POST("/users/:id/games", gameH.ConnectGame)
admin.DELETE("/users/:id/games/:game_id", gameH.DisconnectGame)
admin.POST("/user-games/:ug_id/topup", gameH.TopUp)
admin.GET("/user-games/:ug_id/transactions", gameH.Transactions)
admin.POST("/invites", inviteH.Create)
admin.GET("/invites", inviteH.List)
admin.DELETE("/invites/:code", inviteH.Delete)
}
}
grpcListener, err := net.Listen("tcp", ":"+cfg.GRPCPort)
if err != nil {
log.Fatal("gRPC listen error:", err)
}
grpcSrv := grpc.NewServer(
grpc.UnaryInterceptor(grpcserver.HMACUnaryServerInterceptor(cfg.GRPCHMACSecret)),
)
walletpb.RegisterWalletServiceServer(grpcSrv, grpcserver.NewWalletServer(walletService))
go func() {
log.Printf("gRPC server starting on :%s", cfg.GRPCPort)
if err := grpcSrv.Serve(grpcListener); err != nil {
log.Fatal("gRPC serve error:", err)
}
}()
log.Printf("HTTP server starting on :%s", cfg.ServerPort)
if err := r.Run(":" + cfg.ServerPort); err != nil {
log.Fatal(err)
}
}

33
docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
services:
postgres:
image: postgres:16
container_name: backend-postgres
restart: unless-stopped
ports:
- "${DB_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${DB_NAME:-game_admin}
POSTGRES_USER: ${DB_USER:-game_admin_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-game_admin_password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-game_admin_user} -d ${DB_NAME:-game_admin}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
adminer:
image: adminer:4
container_name: backend-adminer
restart: unless-stopped
depends_on:
- postgres
ports:
- "${ADMINER_PORT:-8081}:8080"
environment:
ADMINER_DEFAULT_SERVER: postgres
volumes:
postgres_data:

155
examples/client.example.txt Normal file
View File

@ -0,0 +1,155 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"log"
"sort"
"strconv"
"strings"
"time"
walletpb "_gen"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
const (
sharedSecret = "super_secret_key"
serviceName = "game-service"
)
func main() {
conn, err := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(hmacUnaryClientInterceptor(sharedSecret, serviceName)),
)
if err != nil {
log.Fatalf("dial error: %v", err)
}
defer conn.Close()
client := walletpb.NewWalletServiceClient(conn)
ctx := context.Background()
1. reserve
reserveResp, err := client.Reserve(ctx, &walletpb.ReserveRequest{
RequestId: "req-1",
UserId: "user-1",
GameId: "slot-42",
RoundId: "round-1001",
Amount: 1000, 10.00
Currency: "EUR",
})
if err != nil {
log.Fatalf("reserve error: %v", err)
}
log.Printf("reserve: tx=%s status=%s available=%d reserved=%d",
reserveResp.TransactionId,
reserveResp.Status,
reserveResp.AvailableBalance,
reserveResp.ReservedBalance,
)
Тут игра “сыграла”. Допустим пользователь выиграл 25.00
confirmResp, err := client.Confirm(ctx, &walletpb.ConfirmRequest{
RequestId: "req-2",
TransactionId: reserveResp.TransactionId,
RoundId: "round-1001",
WinAmount: 2500,
})
if err != nil {
log.Fatalf("confirm error: %v", err)
}
log.Printf("confirm: tx=%s status=%s available=%d reserved=%d",
confirmResp.TransactionId,
confirmResp.Status,
confirmResp.AvailableBalance,
confirmResp.ReservedBalance,
)
txResp, err := client.GetTransaction(ctx, &walletpb.GetTransactionRequest{
TransactionId: reserveResp.TransactionId,
})
if err != nil {
log.Fatalf("get tx error: %v", err)
}
log.Printf("transaction: id=%s status=%s amount=%d win=%d",
txResp.TransactionId, txResp.Status, txResp.Amount, txResp.WinAmount)
}
---------- HMAC client interceptor ----------
func hmacUnaryClientInterceptor(secret, service string) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
req any,
reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.New(nil)
} else {
md = md.Copy()
}
md.Set("x-service-name", service)
md.Set("x-timestamp", timestamp)
payload := buildSigningPayload(service, method, timestamp, md)
signature := computeHMAC(payload, secret)
md.Set("x-signature", signature)
ctx = metadata.NewOutgoingContext(ctx, md)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func computeHMAC(payload, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
func buildSigningPayload(serviceName, method, timestamp string, md metadata.MD) string {
var parts []string
parts = append(parts,
"service="+serviceName,
"method="+method,
"timestamp="+timestamp,
)
var keys []string
for k := range md {
if strings.ToLower(k) == "x-signature" {
continue
}
if strings.HasPrefix(strings.ToLower(k), ":") {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
vals := md.Get(k)
sort.Strings(vals)
parts = append(parts, k+"="+strings.Join(vals, ","))
}
return strings.Join(parts, "|")
}

685
gen/wallet.pb.go Normal file
View File

@ -0,0 +1,685 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// source: proto/wallet.proto
package walletpb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ReserveRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
GameId string `protobuf:"bytes,3,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
RoundId string `protobuf:"bytes,4,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
Amount int64 `protobuf:"varint,5,opt,name=amount,proto3" json:"amount,omitempty"` // в минорных единицах, например cents
Currency string `protobuf:"bytes,6,opt,name=currency,proto3" json:"currency,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ReserveRequest) Reset() {
*x = ReserveRequest{}
mi := &file_proto_wallet_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ReserveRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ReserveRequest) ProtoMessage() {}
func (x *ReserveRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ReserveRequest.ProtoReflect.Descriptor instead.
func (*ReserveRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{0}
}
func (x *ReserveRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ReserveRequest) GetUserId() string {
if x != nil {
return x.UserId
}
return ""
}
func (x *ReserveRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
func (x *ReserveRequest) GetRoundId() string {
if x != nil {
return x.RoundId
}
return ""
}
func (x *ReserveRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *ReserveRequest) GetCurrency() string {
if x != nil {
return x.Currency
}
return ""
}
type ReserveResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // reserved / rejected
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ReserveResponse) Reset() {
*x = ReserveResponse{}
mi := &file_proto_wallet_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ReserveResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ReserveResponse) ProtoMessage() {}
func (x *ReserveResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ReserveResponse.ProtoReflect.Descriptor instead.
func (*ReserveResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{1}
}
func (x *ReserveResponse) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *ReserveResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *ReserveResponse) GetAvailableBalance() int64 {
if x != nil {
return x.AvailableBalance
}
return 0
}
func (x *ReserveResponse) GetReservedBalance() int64 {
if x != nil {
return x.ReservedBalance
}
return 0
}
type ConfirmRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
RoundId string `protobuf:"bytes,3,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
WinAmount int64 `protobuf:"varint,4,opt,name=win_amount,json=winAmount,proto3" json:"win_amount,omitempty"` // 0 если проигрыш
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConfirmRequest) Reset() {
*x = ConfirmRequest{}
mi := &file_proto_wallet_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConfirmRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfirmRequest) ProtoMessage() {}
func (x *ConfirmRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConfirmRequest.ProtoReflect.Descriptor instead.
func (*ConfirmRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{2}
}
func (x *ConfirmRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ConfirmRequest) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *ConfirmRequest) GetRoundId() string {
if x != nil {
return x.RoundId
}
return ""
}
func (x *ConfirmRequest) GetWinAmount() int64 {
if x != nil {
return x.WinAmount
}
return 0
}
type ConfirmResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // confirmed / already_confirmed
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ConfirmResponse) Reset() {
*x = ConfirmResponse{}
mi := &file_proto_wallet_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ConfirmResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfirmResponse) ProtoMessage() {}
func (x *ConfirmResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ConfirmResponse.ProtoReflect.Descriptor instead.
func (*ConfirmResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{3}
}
func (x *ConfirmResponse) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *ConfirmResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *ConfirmResponse) GetAvailableBalance() int64 {
if x != nil {
return x.AvailableBalance
}
return 0
}
func (x *ConfirmResponse) GetReservedBalance() int64 {
if x != nil {
return x.ReservedBalance
}
return 0
}
type CancelRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CancelRequest) Reset() {
*x = CancelRequest{}
mi := &file_proto_wallet_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CancelRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CancelRequest) ProtoMessage() {}
func (x *CancelRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead.
func (*CancelRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{4}
}
func (x *CancelRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *CancelRequest) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *CancelRequest) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
type CancelResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // canceled / already_canceled
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CancelResponse) Reset() {
*x = CancelResponse{}
mi := &file_proto_wallet_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CancelResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CancelResponse) ProtoMessage() {}
func (x *CancelResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CancelResponse.ProtoReflect.Descriptor instead.
func (*CancelResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{5}
}
func (x *CancelResponse) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *CancelResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *CancelResponse) GetAvailableBalance() int64 {
if x != nil {
return x.AvailableBalance
}
return 0
}
func (x *CancelResponse) GetReservedBalance() int64 {
if x != nil {
return x.ReservedBalance
}
return 0
}
type GetTransactionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetTransactionRequest) Reset() {
*x = GetTransactionRequest{}
mi := &file_proto_wallet_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetTransactionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetTransactionRequest) ProtoMessage() {}
func (x *GetTransactionRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetTransactionRequest.ProtoReflect.Descriptor instead.
func (*GetTransactionRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{6}
}
func (x *GetTransactionRequest) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
type GetTransactionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
RoundId string `protobuf:"bytes,3,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
WinAmount int64 `protobuf:"varint,5,opt,name=win_amount,json=winAmount,proto3" json:"win_amount,omitempty"`
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` // reserved / confirmed / canceled
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetTransactionResponse) Reset() {
*x = GetTransactionResponse{}
mi := &file_proto_wallet_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetTransactionResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetTransactionResponse) ProtoMessage() {}
func (x *GetTransactionResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetTransactionResponse.ProtoReflect.Descriptor instead.
func (*GetTransactionResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_proto_rawDescGZIP(), []int{7}
}
func (x *GetTransactionResponse) GetTransactionId() string {
if x != nil {
return x.TransactionId
}
return ""
}
func (x *GetTransactionResponse) GetUserId() string {
if x != nil {
return x.UserId
}
return ""
}
func (x *GetTransactionResponse) GetRoundId() string {
if x != nil {
return x.RoundId
}
return ""
}
func (x *GetTransactionResponse) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *GetTransactionResponse) GetWinAmount() int64 {
if x != nil {
return x.WinAmount
}
return 0
}
func (x *GetTransactionResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
var File_proto_wallet_proto protoreflect.FileDescriptor
const file_proto_wallet_proto_rawDesc = "" +
"\n" +
"\x12proto/wallet.proto\x12\x06wallet\"\xb0\x01\n" +
"\x0eReserveRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" +
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x17\n" +
"\agame_id\x18\x03 \x01(\tR\x06gameId\x12\x19\n" +
"\bround_id\x18\x04 \x01(\tR\aroundId\x12\x16\n" +
"\x06amount\x18\x05 \x01(\x03R\x06amount\x12\x1a\n" +
"\bcurrency\x18\x06 \x01(\tR\bcurrency\"\xa8\x01\n" +
"\x0fReserveResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\"\x90\x01\n" +
"\x0eConfirmRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12%\n" +
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x19\n" +
"\bround_id\x18\x03 \x01(\tR\aroundId\x12\x1d\n" +
"\n" +
"win_amount\x18\x04 \x01(\x03R\twinAmount\"\xa8\x01\n" +
"\x0fConfirmResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\"m\n" +
"\rCancelRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12%\n" +
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x16\n" +
"\x06reason\x18\x03 \x01(\tR\x06reason\"\xa7\x01\n" +
"\x0eCancelResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\">\n" +
"\x15GetTransactionRequest\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\"\xc2\x01\n" +
"\x16GetTransactionResponse\x12%\n" +
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x17\n" +
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x19\n" +
"\bround_id\x18\x03 \x01(\tR\aroundId\x12\x16\n" +
"\x06amount\x18\x04 \x01(\x03R\x06amount\x12\x1d\n" +
"\n" +
"win_amount\x18\x05 \x01(\x03R\twinAmount\x12\x16\n" +
"\x06status\x18\x06 \x01(\tR\x06status2\x91\x02\n" +
"\rWalletService\x12:\n" +
"\aReserve\x12\x16.wallet.ReserveRequest\x1a\x17.wallet.ReserveResponse\x12:\n" +
"\aConfirm\x12\x16.wallet.ConfirmRequest\x1a\x17.wallet.ConfirmResponse\x127\n" +
"\x06Cancel\x12\x15.wallet.CancelRequest\x1a\x16.wallet.CancelResponse\x12O\n" +
"\x0eGetTransaction\x12\x1d.wallet.GetTransactionRequest\x1a\x1e.wallet.GetTransactionResponseB\x0fZ\rgen/;walletpbb\x06proto3"
var (
file_proto_wallet_proto_rawDescOnce sync.Once
file_proto_wallet_proto_rawDescData []byte
)
func file_proto_wallet_proto_rawDescGZIP() []byte {
file_proto_wallet_proto_rawDescOnce.Do(func() {
file_proto_wallet_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_wallet_proto_rawDesc), len(file_proto_wallet_proto_rawDesc)))
})
return file_proto_wallet_proto_rawDescData
}
var file_proto_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_proto_wallet_proto_goTypes = []any{
(*ReserveRequest)(nil), // 0: wallet.ReserveRequest
(*ReserveResponse)(nil), // 1: wallet.ReserveResponse
(*ConfirmRequest)(nil), // 2: wallet.ConfirmRequest
(*ConfirmResponse)(nil), // 3: wallet.ConfirmResponse
(*CancelRequest)(nil), // 4: wallet.CancelRequest
(*CancelResponse)(nil), // 5: wallet.CancelResponse
(*GetTransactionRequest)(nil), // 6: wallet.GetTransactionRequest
(*GetTransactionResponse)(nil), // 7: wallet.GetTransactionResponse
}
var file_proto_wallet_proto_depIdxs = []int32{
0, // 0: wallet.WalletService.Reserve:input_type -> wallet.ReserveRequest
2, // 1: wallet.WalletService.Confirm:input_type -> wallet.ConfirmRequest
4, // 2: wallet.WalletService.Cancel:input_type -> wallet.CancelRequest
6, // 3: wallet.WalletService.GetTransaction:input_type -> wallet.GetTransactionRequest
1, // 4: wallet.WalletService.Reserve:output_type -> wallet.ReserveResponse
3, // 5: wallet.WalletService.Confirm:output_type -> wallet.ConfirmResponse
5, // 6: wallet.WalletService.Cancel:output_type -> wallet.CancelResponse
7, // 7: wallet.WalletService.GetTransaction:output_type -> wallet.GetTransactionResponse
4, // [4:8] is the sub-list for method output_type
0, // [0:4] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_wallet_proto_init() }
func file_proto_wallet_proto_init() {
if File_proto_wallet_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_proto_rawDesc), len(file_proto_wallet_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_wallet_proto_goTypes,
DependencyIndexes: file_proto_wallet_proto_depIdxs,
MessageInfos: file_proto_wallet_proto_msgTypes,
}.Build()
File_proto_wallet_proto = out.File
file_proto_wallet_proto_goTypes = nil
file_proto_wallet_proto_depIdxs = nil
}

235
gen/wallet_grpc.pb.go Normal file
View File

@ -0,0 +1,235 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: proto/wallet.proto
package walletpb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
WalletService_Reserve_FullMethodName = "/wallet.WalletService/Reserve"
WalletService_Confirm_FullMethodName = "/wallet.WalletService/Confirm"
WalletService_Cancel_FullMethodName = "/wallet.WalletService/Cancel"
WalletService_GetTransaction_FullMethodName = "/wallet.WalletService/GetTransaction"
)
// WalletServiceClient is the client API for WalletService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type WalletServiceClient interface {
Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*ReserveResponse, error)
Confirm(ctx context.Context, in *ConfirmRequest, opts ...grpc.CallOption) (*ConfirmResponse, error)
Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelResponse, error)
GetTransaction(ctx context.Context, in *GetTransactionRequest, opts ...grpc.CallOption) (*GetTransactionResponse, error)
}
type walletServiceClient struct {
cc grpc.ClientConnInterface
}
func NewWalletServiceClient(cc grpc.ClientConnInterface) WalletServiceClient {
return &walletServiceClient{cc}
}
func (c *walletServiceClient) Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*ReserveResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReserveResponse)
err := c.cc.Invoke(ctx, WalletService_Reserve_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) Confirm(ctx context.Context, in *ConfirmRequest, opts ...grpc.CallOption) (*ConfirmResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConfirmResponse)
err := c.cc.Invoke(ctx, WalletService_Confirm_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CancelResponse)
err := c.cc.Invoke(ctx, WalletService_Cancel_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetTransaction(ctx context.Context, in *GetTransactionRequest, opts ...grpc.CallOption) (*GetTransactionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetTransactionResponse)
err := c.cc.Invoke(ctx, WalletService_GetTransaction_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletServiceServer is the server API for WalletService service.
// All implementations must embed UnimplementedWalletServiceServer
// for forward compatibility.
type WalletServiceServer interface {
Reserve(context.Context, *ReserveRequest) (*ReserveResponse, error)
Confirm(context.Context, *ConfirmRequest) (*ConfirmResponse, error)
Cancel(context.Context, *CancelRequest) (*CancelResponse, error)
GetTransaction(context.Context, *GetTransactionRequest) (*GetTransactionResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
// UnimplementedWalletServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedWalletServiceServer struct{}
func (UnimplementedWalletServiceServer) Reserve(context.Context, *ReserveRequest) (*ReserveResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Reserve not implemented")
}
func (UnimplementedWalletServiceServer) Confirm(context.Context, *ConfirmRequest) (*ConfirmResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Confirm not implemented")
}
func (UnimplementedWalletServiceServer) Cancel(context.Context, *CancelRequest) (*CancelResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Cancel not implemented")
}
func (UnimplementedWalletServiceServer) GetTransaction(context.Context, *GetTransactionRequest) (*GetTransactionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetTransaction not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
// UnsafeWalletServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to WalletServiceServer will
// result in compilation errors.
type UnsafeWalletServiceServer interface {
mustEmbedUnimplementedWalletServiceServer()
}
func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServer) {
// If the following call panics, it indicates UnimplementedWalletServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&WalletService_ServiceDesc, srv)
}
func _WalletService_Reserve_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReserveRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).Reserve(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_Reserve_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).Reserve(ctx, req.(*ReserveRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_Confirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConfirmRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).Confirm(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_Confirm_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).Confirm(ctx, req.(*ConfirmRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_Cancel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CancelRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).Cancel(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_Cancel_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).Cancel(ctx, req.(*CancelRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetTransaction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetTransactionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetTransaction(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetTransaction_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetTransaction(ctx, req.(*GetTransactionRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var WalletService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "wallet.WalletService",
HandlerType: (*WalletServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Reserve",
Handler: _WalletService_Reserve_Handler,
},
{
MethodName: "Confirm",
Handler: _WalletService_Confirm_Handler,
},
{
MethodName: "Cancel",
Handler: _WalletService_Cancel_Handler,
},
{
MethodName: "GetTransaction",
Handler: _WalletService_GetTransaction_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet.proto",
}

51
go.mod Normal file
View File

@ -0,0 +1,51 @@
module game-admin
go 1.23
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/uptrace/bun v1.1.16
github.com/uptrace/bun/dialect/pgdialect v1.1.16
github.com/uptrace/bun/driver/pgdriver v1.1.16
github.com/uptrace/bun/extra/bundebug v1.1.16
golang.org/x/crypto v0.21.0
google.golang.org/grpc v1.64.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/sasl v0.3.1 // indirect
)

123
go.sum Normal file
View File

@ -0,0 +1,123 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A=
github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8=
github.com/uptrace/bun/dialect/pgdialect v1.1.16 h1:eUPZ+YCJ69BA+W1X1ZmpOJSkv1oYtinr0zCXf7zCo5g=
github.com/uptrace/bun/dialect/pgdialect v1.1.16/go.mod h1:KQjfx/r6JM0OXfbv0rFrxAbdkPD7idK8VitnjIV9fZI=
github.com/uptrace/bun/driver/pgdriver v1.1.16 h1:b/NiSXk6Ldw7KLfMLbOqIkm4odHd7QiNOCPLqPFJjK4=
github.com/uptrace/bun/driver/pgdriver v1.1.16/go.mod h1:Rmfbc+7lx1z/umjMyAxkOHK81LgnGj71XC5YpA6k1vU=
github.com/uptrace/bun/extra/bundebug v1.1.16 h1:SgicRQGtnjhrIhlYOxdkOm1Em4s6HykmT3JblHnoTBM=
github.com/uptrace/bun/extra/bundebug v1.1.16/go.mod h1:SkiOkfUirBiO1Htc4s5bQKEq+JSeU1TkBVpMsPz2ePM=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

52
internal/config/config.go Normal file
View File

@ -0,0 +1,52 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DefaultAdminEmail string
DefaultAdminUsername string
DefaultAdminPassword string
JWTSecret string
ServerPort string
GRPCPort string
GRPCHMACSecret string
BaseURL string
UploadDir string
}
func Load() *Config {
_ = godotenv.Load()
return &Config{
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "game_admin_user"),
DBPassword: getEnv("DB_PASSWORD", "game_admin_password"),
DBName: getEnv("DB_NAME", "game_admin"),
DefaultAdminEmail: getEnv("DEFAULT_ADMIN_EMAIL", "admin@admin.com"),
DefaultAdminUsername: getEnv("DEFAULT_ADMIN_USERNAME", "admin"),
DefaultAdminPassword: getEnv("DEFAULT_ADMIN_PASSWORD", "admin123"),
JWTSecret: getEnv("JWT_SECRET", "super-secret-key-change-me"),
ServerPort: getEnv("SERVER_PORT", "8080"),
GRPCPort: getEnv("GRPC_PORT", "50051"),
GRPCHMACSecret: getEnv("GRPC_HMAC_SECRET", "super_secret_key"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
UploadDir: getEnv("UPLOAD_DIR", "uploads"),
}
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}

View File

@ -0,0 +1,94 @@
package grpcserver
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func HMACUnaryServerInterceptor(secret string) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
serviceName := firstMD(md, "x-service-name")
timestamp := firstMD(md, "x-timestamp")
signature := firstMD(md, "x-signature")
if serviceName == "" || timestamp == "" || signature == "" {
return nil, status.Error(codes.Unauthenticated, "missing auth metadata")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid timestamp")
}
if delta := time.Now().Unix() - ts; delta > 60 || delta < -60 {
return nil, status.Error(codes.Unauthenticated, "timestamp expired")
}
payload := buildSigningPayload(serviceName, info.FullMethod, timestamp, md)
expected := computeHMAC(payload, secret)
if !hmac.Equal([]byte(expected), []byte(signature)) {
return nil, status.Error(codes.Unauthenticated, "bad signature")
}
return handler(ctx, req)
}
}
func firstMD(md metadata.MD, key string) string {
values := md.Get(key)
if len(values) == 0 {
return ""
}
return values[0]
}
func computeHMAC(payload, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
func buildSigningPayload(serviceName, method, timestamp string, md metadata.MD) string {
parts := []string{
"service=" + serviceName,
"method=" + method,
"timestamp=" + timestamp,
}
keys := make([]string, 0, len(md))
for key := range md {
lowerKey := strings.ToLower(key)
if lowerKey == "x-signature" || strings.HasPrefix(lowerKey, ":") {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
values := md.Get(key)
sort.Strings(values)
parts = append(parts, key+"="+strings.Join(values, ","))
}
return strings.Join(parts, "|")
}

View File

@ -0,0 +1,118 @@
package grpcserver
import (
"context"
"errors"
"strconv"
walletpb "game-admin/gen"
"game-admin/internal/wallet"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type WalletServer struct {
walletpb.UnimplementedWalletServiceServer
service *wallet.Service
}
func NewWalletServer(service *wallet.Service) *WalletServer {
return &WalletServer{service: service}
}
func (s *WalletServer) Reserve(ctx context.Context, req *walletpb.ReserveRequest) (*walletpb.ReserveResponse, error) {
transaction, responseStatus, balances, err := s.service.Reserve(
ctx,
req.GetRequestId(),
req.GetUserId(),
req.GetGameId(),
req.GetRoundId(),
req.GetAmount(),
req.GetCurrency(),
)
if err != nil {
return nil, mapWalletError(err)
}
return &walletpb.ReserveResponse{
TransactionId: transaction.ID,
Status: responseStatus,
AvailableBalance: balances.Available,
ReservedBalance: balances.Reserved,
}, nil
}
func (s *WalletServer) Confirm(ctx context.Context, req *walletpb.ConfirmRequest) (*walletpb.ConfirmResponse, error) {
transaction, responseStatus, balances, err := s.service.Confirm(
ctx,
req.GetTransactionId(),
req.GetRoundId(),
req.GetWinAmount(),
)
if err != nil {
return nil, mapWalletError(err)
}
return &walletpb.ConfirmResponse{
TransactionId: transaction.ID,
Status: responseStatus,
AvailableBalance: balances.Available,
ReservedBalance: balances.Reserved,
}, nil
}
func (s *WalletServer) Cancel(ctx context.Context, req *walletpb.CancelRequest) (*walletpb.CancelResponse, error) {
transaction, responseStatus, balances, err := s.service.Cancel(ctx, req.GetTransactionId())
if err != nil {
return nil, mapWalletError(err)
}
return &walletpb.CancelResponse{
TransactionId: transaction.ID,
Status: responseStatus,
AvailableBalance: balances.Available,
ReservedBalance: balances.Reserved,
}, nil
}
func (s *WalletServer) GetTransaction(ctx context.Context, req *walletpb.GetTransactionRequest) (*walletpb.GetTransactionResponse, error) {
transaction, err := s.service.GetTransaction(ctx, req.GetTransactionId())
if err != nil {
return nil, mapWalletError(err)
}
return &walletpb.GetTransactionResponse{
TransactionId: transaction.ID,
UserId: strconv.FormatInt(transaction.UserID, 10),
RoundId: transaction.RoundID,
Amount: transaction.Amount,
WinAmount: transaction.WinAmount,
Status: transaction.Status,
}, nil
}
func mapWalletError(err error) error {
switch {
case errors.Is(err, wallet.ErrInvalidAmount),
errors.Is(err, wallet.ErrMissingRequiredFields),
errors.Is(err, wallet.ErrInvalidUserID),
errors.Is(err, wallet.ErrInvalidGameID),
errors.Is(err, wallet.ErrMissingTransactionID),
errors.Is(err, wallet.ErrMissingRoundID),
errors.Is(err, wallet.ErrInvalidWinAmount),
errors.Is(err, wallet.ErrRoundMismatch):
return status.Error(codes.InvalidArgument, err.Error())
case errors.Is(err, wallet.ErrAccountNotFound),
errors.Is(err, wallet.ErrTransactionNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, wallet.ErrInsufficientFunds),
errors.Is(err, wallet.ErrAlreadyCanceled),
errors.Is(err, wallet.ErrAlreadyConfirmed),
errors.Is(err, wallet.ErrInvalidTransactionState):
return status.Error(codes.FailedPrecondition, err.Error())
default:
return status.Error(codes.Internal, err.Error())
}
}

128
internal/handlers/auth.go Normal file
View File

@ -0,0 +1,128 @@
package handlers
import (
"net/http"
"time"
"game-admin/internal/config"
"game-admin/internal/middleware"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
)
type AuthHandler struct {
db *bun.DB
cfg *config.Config
}
func NewAuthHandler(db *bun.DB, cfg *config.Config) *AuthHandler {
return &AuthHandler{db: db, cfg: cfg}
}
func (h *AuthHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user := new(models.User)
err := h.db.NewSelect().Model(user).Where("email = ?", req.Email).Scan(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
token, err := middleware.GenerateToken(user, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
}
func (h *AuthHandler) Register(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Determine role from invite code
role := models.RoleUser
if req.InviteCode != "" {
invite := new(models.InviteLink)
err := h.db.NewSelect().Model(invite).Where("code = ?", req.InviteCode).Scan(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid invite code"})
return
}
if invite.UsedCount >= invite.MaxUses {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invite code exhausted"})
return
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invite code expired"})
return
}
role = invite.Role
// Increment usage
invite.UsedCount++
_, _ = h.db.NewUpdate().Model(invite).Column("used_count").WherePK().Exec(c)
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not hash password"})
return
}
user := &models.User{
Email: req.Email,
Username: req.Username,
Password: string(hashed),
Role: role,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(user).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
}
token, _ := middleware.GenerateToken(user, h.cfg)
c.JSON(http.StatusCreated, gin.H{
"token": token,
"user": user,
})
}
func (h *AuthHandler) Me(c *gin.Context) {
userID, _ := c.Get("user_id")
user := new(models.User)
err := h.db.NewSelect().Model(user).Where("id = ?", userID).Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}

View File

@ -0,0 +1,194 @@
package handlers
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
func (h *GameHandler) ListModules(c *gin.Context) {
var modules []models.Module
err := h.db.NewSelect().
Model(&modules).
OrderExpr("id ASC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, modules)
}
func (h *GameHandler) ListGameModules(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
exists, err := h.gameExists(c, gameID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
return
}
modulesByGame, err := h.loadModulesByGameIDs(c, []int64{gameID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, modulesByGame[gameID])
}
func (h *GameHandler) ConnectModule(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
var req models.ConnectGameModuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
exists, err := h.gameExists(c, gameID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
return
}
module := new(models.Module)
err = h.db.NewSelect().
Model(module).
Where(`"key" = ?`, strings.TrimSpace(req.ModuleKey)).
Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Module not found"})
return
}
gameModule := &models.GameModule{
GameID: gameID,
ModuleID: module.ID,
CreatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(gameModule).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Module already connected to game"})
return
}
c.JSON(http.StatusCreated, module)
}
func (h *GameHandler) DisconnectModule(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
moduleKey := c.Param("module_key")
module := new(models.Module)
err = h.db.NewSelect().
Model(module).
Where(`"key" = ?`, moduleKey).
Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Module not found"})
return
}
res, err := h.db.NewDelete().
Model((*models.GameModule)(nil)).
Where("game_id = ? AND module_id = ?", gameID, module.ID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Module is not connected to game"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Module disconnected"})
}
func (h *GameHandler) attachModulesToGames(c *gin.Context, games []models.Game) error {
if len(games) == 0 {
return nil
}
gameIDs := make([]int64, 0, len(games))
for _, game := range games {
gameIDs = append(gameIDs, game.ID)
}
modulesByGame, err := h.loadModulesByGameIDs(c, gameIDs)
if err != nil {
return err
}
for i := range games {
games[i].Modules = modulesByGame[games[i].ID]
}
return nil
}
func (h *GameHandler) loadModulesByGameIDs(c *gin.Context, gameIDs []int64) (map[int64][]models.Module, error) {
var gameModules []models.GameModule
err := h.db.NewSelect().
Model(&gameModules).
Relation("Module").
Where("gm.game_id IN (?)", bun.In(gameIDs)).
OrderExpr("gm.id ASC").
Scan(c)
if err != nil {
return nil, err
}
modulesByGame := make(map[int64][]models.Module, len(gameIDs))
for _, gameID := range gameIDs {
modulesByGame[gameID] = []models.Module{}
}
for _, gameModule := range gameModules {
if gameModule.Module == nil {
continue
}
modulesByGame[gameModule.GameID] = append(modulesByGame[gameModule.GameID], *gameModule.Module)
}
return modulesByGame, nil
}
func (h *GameHandler) gameHasModule(ctx context.Context, gameID int64, moduleKey string) (bool, error) {
return h.db.NewSelect().
Model((*models.GameModule)(nil)).
Join("JOIN modules AS m ON m.id = gm.module_id").
Where("gm.game_id = ?", gameID).
Where(`m."key" = ?`, moduleKey).
Exists(ctx)
}

View File

@ -0,0 +1,473 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
func (h *GameHandler) ListVariables(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
exists, err := h.gameExists(c, gameID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
return
}
hasModule, err := h.gameHasModule(c, gameID, "variables")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !hasModule {
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
return
}
var variables []models.GameVariable
err = h.db.NewSelect().
Model(&variables).
Relation("Items", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("gvi.id ASC")
}).
Where("gv.game_id = ?", gameID).
OrderExpr("gv.id DESC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range variables {
normalizeGameVariableResponse(&variables[i])
}
c.JSON(http.StatusOK, variables)
}
func (h *GameHandler) CreateVariable(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
var req models.UpsertGameVariableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertGameVariableRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
exists, err := h.gameExists(c, gameID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
return
}
hasModule, err := h.gameHasModule(c, gameID, "variables")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !hasModule {
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer tx.Rollback()
now := time.Now()
variable := &models.GameVariable{
GameID: gameID,
Key: strings.TrimSpace(req.Key),
Type: req.Type,
NumberValue: req.NumberValue,
StringValue: req.StringValue,
TableValueType: req.TableValueType,
CreatedAt: now,
UpdatedAt: now,
}
_, err = tx.NewInsert().Model(variable).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Game variable key already exists"})
return
}
if err := insertGameVariableItems(c, tx, variable.ID, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
created, err := h.getGameVariable(c, gameID, variable.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func (h *GameHandler) UpdateVariable(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
variableID, err := strconv.ParseInt(c.Param("var_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid variable id"})
return
}
var req models.UpsertGameVariableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertGameVariableRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hasModule, err := h.gameHasModule(c, gameID, "variables")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !hasModule {
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
return
}
existing, err := h.getGameVariable(c, gameID, variableID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Game variable not found"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer tx.Rollback()
numberValue, stringValue, tableValueType := dbValuesFromVariableRequest(req)
_, err = tx.NewUpdate().
Model((*models.GameVariable)(nil)).
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
Set("type = ?", req.Type).
Set("number_value = ?", numberValue).
Set("string_value = ?", stringValue).
Set("table_value_type = ?", tableValueType).
Set("updated_at = ?", time.Now()).
Where("id = ? AND game_id = ?", existing.ID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Could not update game variable"})
return
}
_, err = tx.NewDelete().
Model((*models.GameVariableItem)(nil)).
Where("game_variable_id = ?", existing.ID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := insertGameVariableItems(c, tx, existing.ID, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := h.getGameVariable(c, gameID, existing.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *GameHandler) DeleteVariable(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
variableID, err := strconv.ParseInt(c.Param("var_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid variable id"})
return
}
existing, err := h.getGameVariable(c, gameID, variableID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Game variable not found"})
return
}
hasModule, err := h.gameHasModule(c, gameID, "variables")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !hasModule {
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer tx.Rollback()
_, err = tx.NewDelete().
Model((*models.GameVariableItem)(nil)).
Where("game_variable_id = ?", existing.ID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = tx.NewDelete().
Model((*models.GameVariable)(nil)).
Where("id = ? AND game_id = ?", existing.ID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Game variable deleted"})
}
func (h *GameHandler) gameExists(ctx context.Context, gameID int64) (bool, error) {
return h.db.NewSelect().
Model((*models.Game)(nil)).
Where("id = ?", gameID).
Exists(ctx)
}
func (h *GameHandler) getGameVariable(ctx context.Context, gameID, variableID int64) (*models.GameVariable, error) {
variable := new(models.GameVariable)
err := h.db.NewSelect().
Model(variable).
Relation("Items", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("gvi.id ASC")
}).
Where("gv.id = ? AND gv.game_id = ?", variableID, gameID).
Scan(ctx)
if err != nil {
return nil, err
}
normalizeGameVariableResponse(variable)
return variable, nil
}
func insertGameVariableItems(ctx context.Context, tx bun.Tx, variableID int64, req models.UpsertGameVariableRequest) error {
if req.Type != models.GameVariableTypeTable && req.Type != models.GameVariableTypeVector {
return nil
}
for _, itemReq := range req.Items {
key := strings.TrimSpace(itemReq.Key)
if req.Type == models.GameVariableTypeVector {
key = strconv.FormatInt(*itemReq.Index, 10)
}
item := &models.GameVariableItem{
GameVariableID: variableID,
Key: key,
NumberValue: itemReq.NumberValue,
StringValue: itemReq.StringValue,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if _, err := tx.NewInsert().Model(item).Exec(ctx); err != nil {
return fmt.Errorf("could not save table items")
}
}
return nil
}
func validateUpsertGameVariableRequest(req *models.UpsertGameVariableRequest) error {
req.Key = strings.TrimSpace(req.Key)
if req.Key == "" {
return fmt.Errorf("key is required")
}
switch req.Type {
case models.GameVariableTypeNumber:
if req.NumberValue == nil {
return fmt.Errorf("number_value is required for number type")
}
if req.StringValue != nil || req.TableValueType != nil || len(req.Items) > 0 {
return fmt.Errorf("number type only accepts number_value")
}
case models.GameVariableTypeString:
if req.StringValue == nil {
return fmt.Errorf("string_value is required for string type")
}
if req.NumberValue != nil || req.TableValueType != nil || len(req.Items) > 0 {
return fmt.Errorf("string type only accepts string_value")
}
case models.GameVariableTypeTable:
if req.NumberValue != nil || req.StringValue != nil {
return fmt.Errorf("table type does not accept scalar values")
}
if req.TableValueType == nil {
return fmt.Errorf("table_value_type is required for table type")
}
if *req.TableValueType != models.GameVariableTableValueTypeNumber &&
*req.TableValueType != models.GameVariableTableValueTypeString {
return fmt.Errorf("unsupported table_value_type")
}
seenKeys := make(map[string]struct{}, len(req.Items))
for i := range req.Items {
req.Items[i].Key = strings.TrimSpace(req.Items[i].Key)
if req.Items[i].Key == "" {
return fmt.Errorf("items[%d].key is required", i)
}
if _, exists := seenKeys[req.Items[i].Key]; exists {
return fmt.Errorf("duplicate table item key: %s", req.Items[i].Key)
}
seenKeys[req.Items[i].Key] = struct{}{}
switch *req.TableValueType {
case models.GameVariableTableValueTypeNumber:
if req.Items[i].NumberValue == nil || req.Items[i].StringValue != nil {
return fmt.Errorf("items[%d] must contain only number_value", i)
}
case models.GameVariableTableValueTypeString:
if req.Items[i].StringValue == nil || req.Items[i].NumberValue != nil {
return fmt.Errorf("items[%d] must contain only string_value", i)
}
}
}
case models.GameVariableTypeVector:
if req.NumberValue != nil || req.StringValue != nil {
return fmt.Errorf("vector type does not accept scalar values")
}
if req.TableValueType == nil {
return fmt.Errorf("table_value_type is required for vector type")
}
if *req.TableValueType != models.GameVariableTableValueTypeNumber &&
*req.TableValueType != models.GameVariableTableValueTypeString {
return fmt.Errorf("unsupported table_value_type")
}
seenIndexes := make(map[int64]struct{}, len(req.Items))
for i := range req.Items {
if req.Items[i].Index == nil {
return fmt.Errorf("items[%d].index is required", i)
}
if *req.Items[i].Index < 0 {
return fmt.Errorf("items[%d].index must be >= 0", i)
}
if _, exists := seenIndexes[*req.Items[i].Index]; exists {
return fmt.Errorf("duplicate vector item index: %d", *req.Items[i].Index)
}
seenIndexes[*req.Items[i].Index] = struct{}{}
switch *req.TableValueType {
case models.GameVariableTableValueTypeNumber:
if req.Items[i].NumberValue == nil || req.Items[i].StringValue != nil {
return fmt.Errorf("items[%d] must contain only number_value", i)
}
case models.GameVariableTableValueTypeString:
if req.Items[i].StringValue == nil || req.Items[i].NumberValue != nil {
return fmt.Errorf("items[%d] must contain only string_value", i)
}
}
}
default:
return fmt.Errorf("unsupported variable type")
}
return nil
}
func dbValuesFromVariableRequest(req models.UpsertGameVariableRequest) (interface{}, interface{}, interface{}) {
var numberValue interface{}
if req.NumberValue != nil {
numberValue = *req.NumberValue
}
var stringValue interface{}
if req.StringValue != nil {
stringValue = *req.StringValue
}
var tableValueType interface{}
if req.TableValueType != nil {
tableValueType = string(*req.TableValueType)
}
return numberValue, stringValue, tableValueType
}
func normalizeGameVariableResponse(variable *models.GameVariable) {
if variable.Type != models.GameVariableTypeVector {
return
}
for i := range variable.Items {
index, err := strconv.ParseInt(variable.Items[i].Key, 10, 64)
if err != nil {
continue
}
variable.Items[i].Index = &index
variable.Items[i].Key = ""
}
}

249
internal/handlers/games.go Normal file
View File

@ -0,0 +1,249 @@
package handlers
import (
"net/http"
"strconv"
"time"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
type GameHandler struct {
db *bun.DB
cfg *config.Config
}
func NewGameHandler(db *bun.DB, cfg *config.Config) *GameHandler {
return &GameHandler{db: db, cfg: cfg}
}
func (h *GameHandler) List(c *gin.Context) {
var games []models.Game
query := h.db.NewSelect().Model(&games).OrderExpr("id DESC")
if s := c.Query("search"); s != "" {
like := "%" + s + "%"
query = query.Where("g.name LIKE ? OR g.slug LIKE ?", like, like)
}
err := query.Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.attachModulesToGames(c, games); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, games)
}
func (h *GameHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
game := new(models.Game)
err = h.db.NewSelect().
Model(game).
Where("g.id = ?", id).
Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
return
}
modulesByGame, err := h.loadModulesByGameIDs(c, []int64{game.ID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
game.Modules = modulesByGame[game.ID]
c.JSON(http.StatusOK, game)
}
func (h *GameHandler) Create(c *gin.Context) {
var req models.CreateGameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
game := &models.Game{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
ImageURL: req.ImageURL,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err := h.db.NewInsert().Model(game).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Game slug already exists"})
return
}
c.JSON(http.StatusCreated, game)
}
func (h *GameHandler) Update(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var req models.CreateGameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.db.NewUpdate().Model((*models.Game)(nil)).
Set("name = ?", req.Name).
Set("slug = ?", req.Slug).
Set("description = ?", req.Description).
Set("image_url = ?", req.ImageURL).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Game updated"})
}
func (h *GameHandler) Delete(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
_, err := h.db.NewDelete().Model((*models.Game)(nil)).Where("id = ?", id).Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Game deleted"})
}
// ─── User-Game connections ──────────────────────────────────
func (h *GameHandler) ConnectGame(c *gin.Context) {
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var req models.ConnectGameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ug := &models.UserGame{
UserID: userID,
GameID: req.GameID,
Balance: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err := h.db.NewInsert().Model(ug).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Game already connected"})
return
}
c.JSON(http.StatusCreated, ug)
}
func (h *GameHandler) DisconnectGame(c *gin.Context) {
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
gameID, _ := strconv.ParseInt(c.Param("game_id"), 10, 64)
_, err := h.db.NewDelete().Model((*models.UserGame)(nil)).
Where("user_id = ? AND game_id = ?", userID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Game disconnected"})
}
func (h *GameHandler) UserGames(c *gin.Context) {
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var userGames []models.UserGame
err := h.db.NewSelect().Model(&userGames).
Relation("Game").
Where("ug.user_id = ?", userID).
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, userGames)
}
func (h *GameHandler) TopUp(c *gin.Context) {
ugID, _ := strconv.ParseInt(c.Param("ug_id"), 10, 64)
adminID, _ := c.Get("user_id")
var req models.TopUpRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer tx.Rollback()
// Update balance
_, err = tx.NewUpdate().Model((*models.UserGame)(nil)).
Set("balance = balance + ?", req.Amount).
Set("updated_at = ?", time.Now()).
Where("id = ?", ugID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Log transaction
txn := &models.BalanceTransaction{
UserGameID: ugID,
Amount: req.Amount,
Type: "topup",
Comment: req.Comment,
AdminID: adminID.(int64),
CreatedAt: time.Now(),
}
_, err = tx.NewInsert().Model(txn).Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Balance topped up", "transaction": txn})
}
func (h *GameHandler) Transactions(c *gin.Context) {
ugID, _ := strconv.ParseInt(c.Param("ug_id"), 10, 64)
var txns []models.BalanceTransaction
err := h.db.NewSelect().Model(&txns).
Where("user_game_id = ?", ugID).
OrderExpr("id DESC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, txns)
}

View File

@ -0,0 +1,94 @@
package handlers
import (
"io"
"net/http"
"path/filepath"
"strings"
"game-admin/internal/config"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ImageHandler struct {
cfg *config.Config
}
func NewImageHandler(cfg *config.Config) *ImageHandler {
return &ImageHandler{cfg: cfg}
}
func (h *ImageHandler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
if fileHeader.Size == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is empty"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not open uploaded file"})
return
}
defer file.Close()
sniff := make([]byte, 512)
n, err := file.Read(sniff)
if err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read uploaded file"})
return
}
contentType := http.DetectContentType(sniff[:n])
if !strings.HasPrefix(contentType, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "only image files are allowed"})
return
}
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
if ext == "" {
ext = extensionFromContentType(contentType)
}
if ext == "" {
ext = ".bin"
}
fileName := uuid.NewString() + ext
relativePath := filepath.ToSlash(filepath.Join("images", fileName))
fullPath := filepath.Join(h.cfg.UploadDir, filepath.FromSlash(relativePath))
if err := c.SaveUploadedFile(fileHeader, fullPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save uploaded file"})
return
}
url := strings.TrimRight(h.cfg.BaseURL, "/") + "/uploads/" + relativePath
c.JSON(http.StatusCreated, gin.H{
"name": fileName,
"path": "/uploads/" + relativePath,
"url": url,
"content_type": contentType,
})
}
func extensionFromContentType(contentType string) string {
switch contentType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/svg+xml":
return ".svg"
default:
return ""
}
}

View File

@ -0,0 +1,98 @@
package handlers
import (
"net/http"
"time"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type InviteHandler struct {
db *bun.DB
cfg *config.Config
}
func NewInviteHandler(db *bun.DB, cfg *config.Config) *InviteHandler {
return &InviteHandler{db: db, cfg: cfg}
}
func (h *InviteHandler) Create(c *gin.Context) {
adminID, _ := c.Get("user_id")
var req models.CreateInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
code := uuid.New().String()[:8]
expires := time.Now().Add(7 * 24 * time.Hour)
invite := &models.InviteLink{
Code: code,
Role: req.Role,
MaxUses: req.MaxUses,
UsedCount: 0,
CreatedByID: adminID.(int64),
ExpiresAt: &expires,
CreatedAt: time.Now(),
}
_, err := h.db.NewInsert().Model(invite).Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"invite": invite,
"link": h.cfg.BaseURL + "/register?invite=" + code,
})
}
func (h *InviteHandler) List(c *gin.Context) {
var invites []models.InviteLink
err := h.db.NewSelect().Model(&invites).
Relation("CreatedBy").
OrderExpr("id DESC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, invites)
}
func (h *InviteHandler) Delete(c *gin.Context) {
code := c.Param("code")
_, err := h.db.NewDelete().Model((*models.InviteLink)(nil)).Where("code = ?", code).Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Invite deleted"})
}
func (h *InviteHandler) Validate(c *gin.Context) {
code := c.Param("code")
invite := new(models.InviteLink)
err := h.db.NewSelect().Model(invite).Where("code = ?", code).Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid invite code"})
return
}
if invite.UsedCount >= invite.MaxUses {
c.JSON(http.StatusGone, gin.H{"error": "Invite code exhausted"})
return
}
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
c.JSON(http.StatusGone, gin.H{"error": "Invite code expired"})
return
}
c.JSON(http.StatusOK, gin.H{"valid": true, "role": invite.Role})
}

View File

@ -0,0 +1,174 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
type LanguageHandler struct {
db *bun.DB
cfg *config.Config
}
func NewLanguageHandler(db *bun.DB, cfg *config.Config) *LanguageHandler {
return &LanguageHandler{db: db, cfg: cfg}
}
func (h *LanguageHandler) List(c *gin.Context) {
var languages []models.Language
err := h.db.NewSelect().
Model(&languages).
OrderExpr("id ASC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, languages)
}
func (h *LanguageHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
return
}
language := new(models.Language)
err = h.db.NewSelect().
Model(language).
Where("id = ?", id).
Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
return
}
c.JSON(http.StatusOK, language)
}
func (h *LanguageHandler) Create(c *gin.Context) {
var req models.UpsertLanguageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
code, name, err := normalizeLanguageRequest(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
language := &models.Language{
Code: code,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(language).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Language code already exists"})
return
}
c.JSON(http.StatusCreated, language)
}
func (h *LanguageHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
return
}
var req models.UpsertLanguageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
code, name, err := normalizeLanguageRequest(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := h.db.NewUpdate().
Model((*models.Language)(nil)).
Set("code = ?", code).
Set("name = ?", name).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Could not update language"})
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
return
}
updated := new(models.Language)
err = h.db.NewSelect().
Model(updated).
Where("id = ?", id).
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *LanguageHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
return
}
res, err := h.db.NewDelete().
Model((*models.Language)(nil)).
Where("id = ?", id).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Language deleted"})
}
func normalizeLanguageRequest(req models.UpsertLanguageRequest) (string, string, error) {
code := strings.ToLower(strings.TrimSpace(req.Code))
name := strings.TrimSpace(req.Name)
if code == "" {
return "", "", errors.New("code is required")
}
if name == "" {
return "", "", errors.New("name is required")
}
return code, name, nil
}

View File

@ -0,0 +1,748 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
func (h *GameHandler) ListLeaderboards(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
var leaderboards []models.Leaderboard
err = h.db.NewSelect().
Model(&leaderboards).
Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("lbg.id ASC")
}).
Where("lb.game_id = ?", gameID).
OrderExpr("lb.id DESC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, leaderboards)
}
func (h *GameHandler) GetLeaderboard(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
leaderboard, err := h.getLeaderboard(c, gameID, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
c.JSON(http.StatusOK, leaderboard)
}
func (h *GameHandler) CreateLeaderboard(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
var req models.UpsertLeaderboardRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertLeaderboardRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
leaderboard := &models.Leaderboard{
GameID: gameID,
Key: strings.TrimSpace(req.Key),
Name: strings.TrimSpace(req.Name),
SortOrder: req.SortOrder,
PeriodType: req.PeriodType,
IsActive: isActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(leaderboard).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard key already exists"})
return
}
created, err := h.getLeaderboard(c, gameID, leaderboard.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func (h *GameHandler) UpdateLeaderboard(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
var req models.UpsertLeaderboardRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertLeaderboardRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
current, err := h.getLeaderboard(c, gameID, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
isActive := current.IsActive
if req.IsActive != nil {
isActive = *req.IsActive
}
_, err = h.db.NewUpdate().
Model((*models.Leaderboard)(nil)).
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
Set("name = ?", strings.TrimSpace(req.Name)).
Set("sort_order = ?", req.SortOrder).
Set("period_type = ?", req.PeriodType).
Set("is_active = ?", isActive).
Set("updated_at = ?", time.Now()).
Where("id = ? AND game_id = ?", leaderboardID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard"})
return
}
updated, err := h.getLeaderboard(c, gameID, leaderboardID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *GameHandler) DeleteLeaderboard(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
if _, err := h.getLeaderboard(c, gameID, leaderboardID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var groups []models.LeaderboardGroup
if err := tx.NewSelect().Model(&groups).Where("leaderboard_id = ?", leaderboardID).Scan(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(groups) > 0 {
groupIDs := make([]int64, 0, len(groups))
for _, group := range groups {
groupIDs = append(groupIDs, group.ID)
}
if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id IN (?)", bun.In(groupIDs)).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := tx.NewDelete().Model((*models.LeaderboardScore)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := tx.NewDelete().Model((*models.Leaderboard)(nil)).Where("id = ? AND game_id = ?", leaderboardID, gameID).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed = true
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard deleted"})
}
func (h *GameHandler) ListLeaderboardGroups(c *gin.Context) {
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
var groups []models.LeaderboardGroup
err = h.db.NewSelect().
Model(&groups).
Relation("Members", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("lbgm.id ASC")
}).
Where("lbg.leaderboard_id = ?", leaderboardID).
OrderExpr("lbg.id ASC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
func (h *GameHandler) CreateLeaderboardGroup(c *gin.Context) {
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
var req models.UpsertLeaderboardGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertLeaderboardGroupRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
isDefault := false
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
group := &models.LeaderboardGroup{
LeaderboardID: leaderboardID,
Key: strings.TrimSpace(req.Key),
Name: strings.TrimSpace(req.Name),
IsDefault: isDefault,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(group).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard group key already exists"})
return
}
c.JSON(http.StatusCreated, group)
}
func (h *GameHandler) UpdateLeaderboardGroup(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
return
}
group, leaderboard, err := h.getLeaderboardGroup(c, groupID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
return
}
var req models.UpsertLeaderboardGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertLeaderboardGroupRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
isDefault := group.IsDefault
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
_, err = h.db.NewUpdate().
Model((*models.LeaderboardGroup)(nil)).
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
Set("name = ?", strings.TrimSpace(req.Name)).
Set("is_default = ?", isDefault).
Set("updated_at = ?", time.Now()).
Where("id = ?", groupID).
Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard group"})
return
}
updated, _, err := h.getLeaderboardGroup(c, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *GameHandler) DeleteLeaderboardGroup(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
return
}
_, leaderboard, err := h.getLeaderboardGroup(c, groupID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ?", groupID).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("id = ?", groupID).Exec(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed = true
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard group deleted"})
}
func (h *GameHandler) AddLeaderboardGroupMember(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
return
}
group, leaderboard, err := h.getLeaderboardGroup(c, groupID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
var req models.AddLeaderboardGroupMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureUserBelongsToGame(c, req.UserID, leaderboard.GameID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
member := &models.LeaderboardGroupMember{
GroupID: group.ID,
UserID: req.UserID,
CreatedAt: time.Now(),
}
_, err = h.db.NewInsert().Model(member).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already in group"})
return
}
c.JSON(http.StatusCreated, member)
}
func (h *GameHandler) DeleteLeaderboardGroupMember(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
return
}
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
return
}
_, leaderboard, err := h.getLeaderboardGroup(c, groupID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
res, err := h.db.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ? AND user_id = ?", groupID, userID).Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Group member not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Group member deleted"})
}
func (h *GameHandler) UpsertLeaderboardScore(c *gin.Context) {
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
var req models.UpsertLeaderboardScoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureUserGameBelongsToGame(c, req.UserGameID, leaderboard.GameID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
now := time.Now()
score := &models.LeaderboardScore{
LeaderboardID: leaderboardID,
UserGameID: req.UserGameID,
Score: req.Score,
CreatedAt: now,
UpdatedAt: now,
}
_, err = h.db.NewInsert().Model(score).
On("CONFLICT (leaderboard_id, user_game_id) DO UPDATE").
Set("score = EXCLUDED.score").
Set("updated_at = EXCLUDED.updated_at").
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard score saved"})
}
func (h *GameHandler) GetLeaderboardRankings(c *gin.Context) {
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
return
}
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
return
}
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
renderLeaderboardModuleError(c, err)
return
}
limit := 50
if rawLimit := c.Query("limit"); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid limit"})
return
}
if parsed > 200 {
parsed = 200
}
limit = parsed
}
query := h.db.NewSelect().
TableExpr("leaderboard_scores AS lbs").
ColumnExpr("lbs.user_game_id AS user_game_id").
ColumnExpr("ug.user_id AS user_id").
ColumnExpr("u.username AS username").
ColumnExpr("lbs.score AS score").
Join("JOIN user_games AS ug ON ug.id = lbs.user_game_id").
Join("JOIN users AS u ON u.id = ug.user_id").
Where("lbs.leaderboard_id = ?", leaderboardID)
if rawGroupID := c.Query("group_id"); rawGroupID != "" {
groupID, err := strconv.ParseInt(rawGroupID, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group_id"})
return
}
group, _, err := h.getLeaderboardGroup(c, groupID)
if err != nil || group.LeaderboardID != leaderboardID {
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
return
}
query = query.Join("JOIN leaderboard_group_members AS lbgm ON lbgm.user_id = ug.user_id").
Where("lbgm.group_id = ?", groupID)
}
switch leaderboard.SortOrder {
case models.LeaderboardSortOrderAsc:
query = query.OrderExpr("lbs.score ASC, lbs.updated_at ASC")
default:
query = query.OrderExpr("lbs.score DESC, lbs.updated_at ASC")
}
var rows []models.LeaderboardRankItem
if err := query.Limit(limit).Scan(c, &rows); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range rows {
rows[i].Rank = int64(i + 1)
}
c.JSON(http.StatusOK, gin.H{
"leaderboard": leaderboard,
"items": rows,
})
}
func (h *GameHandler) ensureLeaderboardModuleEnabled(c *gin.Context, gameID int64) error {
exists, err := h.gameExists(c, gameID)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("game_not_found")
}
hasModule, err := h.gameHasModule(c, gameID, "leaderboard")
if err != nil {
return err
}
if !hasModule {
return fmt.Errorf("leaderboard_module_disabled")
}
return nil
}
func renderLeaderboardModuleError(c *gin.Context, err error) {
switch err.Error() {
case "game_not_found":
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
case "leaderboard_module_disabled":
c.JSON(http.StatusForbidden, gin.H{"error": "Leaderboard module is not enabled for this game"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func validateUpsertLeaderboardRequest(req *models.UpsertLeaderboardRequest) error {
req.Key = strings.TrimSpace(req.Key)
req.Name = strings.TrimSpace(req.Name)
if req.Key == "" {
return fmt.Errorf("key is required")
}
if req.Name == "" {
return fmt.Errorf("name is required")
}
if req.SortOrder != models.LeaderboardSortOrderAsc && req.SortOrder != models.LeaderboardSortOrderDesc {
return fmt.Errorf("sort_order must be asc or desc")
}
switch req.PeriodType {
case models.LeaderboardPeriodAllTime, models.LeaderboardPeriodDaily, models.LeaderboardPeriodWeekly, models.LeaderboardPeriodMonthly:
default:
return fmt.Errorf("unsupported period_type")
}
return nil
}
func validateUpsertLeaderboardGroupRequest(req *models.UpsertLeaderboardGroupRequest) error {
req.Key = strings.TrimSpace(req.Key)
req.Name = strings.TrimSpace(req.Name)
if req.Key == "" {
return fmt.Errorf("key is required")
}
if req.Name == "" {
return fmt.Errorf("name is required")
}
return nil
}
func (h *GameHandler) getLeaderboard(c *gin.Context, gameID, leaderboardID int64) (*models.Leaderboard, error) {
leaderboard := new(models.Leaderboard)
err := h.db.NewSelect().
Model(leaderboard).
Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("lbg.id ASC")
}).
Where("lb.id = ? AND lb.game_id = ?", leaderboardID, gameID).
Scan(c)
if err != nil {
return nil, err
}
return leaderboard, nil
}
func (h *GameHandler) getLeaderboardByID(c *gin.Context, leaderboardID int64) (*models.Leaderboard, error) {
leaderboard := new(models.Leaderboard)
err := h.db.NewSelect().
Model(leaderboard).
Where("id = ?", leaderboardID).
Scan(c)
if err != nil {
return nil, err
}
return leaderboard, nil
}
func (h *GameHandler) getLeaderboardGroup(c *gin.Context, groupID int64) (*models.LeaderboardGroup, *models.Leaderboard, error) {
group := new(models.LeaderboardGroup)
err := h.db.NewSelect().Model(group).Where("id = ?", groupID).Scan(c)
if err != nil {
return nil, nil, err
}
leaderboard, err := h.getLeaderboardByID(c, group.LeaderboardID)
if err != nil {
return nil, nil, err
}
return group, leaderboard, nil
}
func (h *GameHandler) ensureUserBelongsToGame(c *gin.Context, userID, gameID int64) error {
exists, err := h.db.NewSelect().
Model((*models.UserGame)(nil)).
Where("user_id = ? AND game_id = ?", userID, gameID).
Exists(c)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("user is not connected to this game")
}
return nil
}
func (h *GameHandler) ensureUserGameBelongsToGame(c *gin.Context, userGameID, gameID int64) error {
exists, err := h.db.NewSelect().
Model((*models.UserGame)(nil)).
Where("id = ? AND game_id = ?", userGameID, gameID).
Exists(c)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("user_game does not belong to this game")
}
return nil
}

View File

@ -0,0 +1,681 @@
package handlers
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
var defaultNotificationMacroKeys = []string{"time_second", "image", "login"}
func (h *GameHandler) ListNotifications(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
renderNotificationModuleError(c, err)
return
}
var notifications []models.Notification
err = h.db.NewSelect().
Model(&notifications).
Relation("Descriptions", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("Language").OrderExpr("nd.id ASC")
}).
Relation("Variables", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("nvd.id ASC")
}).
Relation("Entries", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("ne.id ASC")
}).
Where("n.game_id = ?", gameID).
OrderExpr("n.id DESC").
Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.loadNotificationEntryVariables(c, notifications); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range notifications {
prepareNotificationResponse(&notifications[i])
}
c.JSON(http.StatusOK, notifications)
}
func (h *GameHandler) GetNotification(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
return
}
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
renderNotificationModuleError(c, err)
return
}
notification, err := h.getNotification(c, gameID, notificationID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
c.JSON(http.StatusOK, notification)
}
func (h *GameHandler) CreateNotification(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
var req models.UpsertNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertNotificationRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
renderNotificationModuleError(c, err)
return
}
if err := h.ensureLanguagesExist(c, req.Descriptions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
now := time.Now()
notification := &models.Notification{
GameID: gameID,
Name: strings.TrimSpace(req.Name),
CreatedAt: now,
UpdatedAt: now,
}
_, err = tx.NewInsert().Model(notification).Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Notification already exists for this game"})
return
}
if err := saveNotificationDetails(c, tx, notification.ID, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed = true
created, err := h.getNotification(c, gameID, notification.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func (h *GameHandler) UpdateNotification(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
return
}
var req models.UpsertNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUpsertNotificationRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
renderNotificationModuleError(c, err)
return
}
if err := h.ensureLanguagesExist(c, req.Descriptions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := h.getNotification(c, gameID, notificationID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
_, err = tx.NewUpdate().
Model((*models.Notification)(nil)).
Set("name = ?", strings.TrimSpace(req.Name)).
Set("updated_at = ?", time.Now()).
Where("id = ? AND game_id = ?", notificationID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Could not update notification"})
return
}
if err := deleteNotificationDetails(c, tx, notificationID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := saveNotificationDetails(c, tx, notificationID, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed = true
updated, err := h.getNotification(c, gameID, notificationID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *GameHandler) DeleteNotification(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
notificationID, err := strconv.ParseInt(c.Param("notification_id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
return
}
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
renderNotificationModuleError(c, err)
return
}
if _, err := h.getNotification(c, gameID, notificationID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
tx, err := h.db.BeginTx(c, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if err := deleteNotificationDetails(c, tx, notificationID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, err = tx.NewDelete().
Model((*models.Notification)(nil)).
Where("id = ? AND game_id = ?", notificationID, gameID).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
committed = true
c.JSON(http.StatusOK, gin.H{"message": "Notification deleted"})
}
func (h *GameHandler) ensureNotificationModuleEnabled(c *gin.Context, gameID int64) error {
exists, err := h.gameExists(c, gameID)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("game_not_found")
}
hasModule, err := h.gameHasModule(c, gameID, "notification")
if err != nil {
return err
}
if !hasModule {
return fmt.Errorf("notification_module_disabled")
}
return nil
}
func renderNotificationModuleError(c *gin.Context, err error) {
switch err.Error() {
case "game_not_found":
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
case "notification_module_disabled":
c.JSON(http.StatusForbidden, gin.H{"error": "Notification module is not enabled for this game"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func (h *GameHandler) ensureLanguagesExist(c *gin.Context, descriptions []models.NotificationDescriptionRequest) error {
languageIDs := make([]int64, 0, len(descriptions))
seen := make(map[int64]struct{}, len(descriptions))
for _, description := range descriptions {
if _, ok := seen[description.LanguageID]; ok {
continue
}
seen[description.LanguageID] = struct{}{}
languageIDs = append(languageIDs, description.LanguageID)
}
var languages []models.Language
err := h.db.NewSelect().
Model(&languages).
Where("id IN (?)", bun.In(languageIDs)).
Scan(c)
if err != nil {
return err
}
if len(languages) != len(languageIDs) {
return fmt.Errorf("one or more languages do not exist")
}
return nil
}
func (h *GameHandler) getNotification(c *gin.Context, gameID, notificationID int64) (*models.Notification, error) {
notification := new(models.Notification)
err := h.db.NewSelect().
Model(notification).
Relation("Descriptions", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("Language").OrderExpr("nd.id ASC")
}).
Relation("Variables", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("nvd.id ASC")
}).
Relation("Entries", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.OrderExpr("ne.id ASC")
}).
Where("n.id = ? AND n.game_id = ?", notificationID, gameID).
Scan(c)
if err != nil {
return nil, err
}
if err := h.loadNotificationEntryVariablesForOne(c, notification); err != nil {
return nil, err
}
prepareNotificationResponse(notification)
return notification, nil
}
func (h *GameHandler) loadNotificationEntryVariables(c *gin.Context, notifications []models.Notification) error {
entryIDs := make([]int64, 0)
for i := range notifications {
for j := range notifications[i].Entries {
entryIDs = append(entryIDs, notifications[i].Entries[j].ID)
}
}
if len(entryIDs) == 0 {
return nil
}
var entryVariables []models.NotificationEntryVariable
err := h.db.NewSelect().
Model(&entryVariables).
Relation("Variable").
Where("nev.notification_entry_id IN (?)", bun.In(entryIDs)).
OrderExpr("nev.id ASC").
Scan(c)
if err != nil {
return err
}
variablesByEntry := make(map[int64][]models.NotificationEntryVariable, len(entryIDs))
for _, item := range entryVariables {
if item.Variable != nil {
item.Key = item.Variable.Key
}
variablesByEntry[item.NotificationEntryID] = append(variablesByEntry[item.NotificationEntryID], item)
}
for i := range notifications {
for j := range notifications[i].Entries {
notifications[i].Entries[j].Variables = variablesByEntry[notifications[i].Entries[j].ID]
}
}
return nil
}
func (h *GameHandler) loadNotificationEntryVariablesForOne(c *gin.Context, notification *models.Notification) error {
entryIDs := make([]int64, 0, len(notification.Entries))
for i := range notification.Entries {
entryIDs = append(entryIDs, notification.Entries[i].ID)
}
if len(entryIDs) == 0 {
return nil
}
var entryVariables []models.NotificationEntryVariable
err := h.db.NewSelect().
Model(&entryVariables).
Relation("Variable").
Where("nev.notification_entry_id IN (?)", bun.In(entryIDs)).
OrderExpr("nev.id ASC").
Scan(c)
if err != nil {
return err
}
variablesByEntry := make(map[int64][]models.NotificationEntryVariable, len(entryIDs))
for _, item := range entryVariables {
if item.Variable != nil {
item.Key = item.Variable.Key
}
variablesByEntry[item.NotificationEntryID] = append(variablesByEntry[item.NotificationEntryID], item)
}
for i := range notification.Entries {
notification.Entries[i].Variables = variablesByEntry[notification.Entries[i].ID]
}
return nil
}
func deleteNotificationDetails(c *gin.Context, tx bun.Tx, notificationID int64) error {
var entries []models.NotificationEntry
if err := tx.NewSelect().
Model(&entries).
Where("notification_id = ?", notificationID).
Scan(c); err != nil {
return err
}
if len(entries) > 0 {
entryIDs := make([]int64, 0, len(entries))
for _, entry := range entries {
entryIDs = append(entryIDs, entry.ID)
}
if _, err := tx.NewDelete().
Model((*models.NotificationEntryVariable)(nil)).
Where("notification_entry_id IN (?)", bun.In(entryIDs)).
Exec(c); err != nil {
return err
}
}
if _, err := tx.NewDelete().
Model((*models.NotificationEntry)(nil)).
Where("notification_id = ?", notificationID).
Exec(c); err != nil {
return err
}
if _, err := tx.NewDelete().
Model((*models.NotificationVariableDef)(nil)).
Where("notification_id = ?", notificationID).
Exec(c); err != nil {
return err
}
if _, err := tx.NewDelete().
Model((*models.NotificationDescription)(nil)).
Where("notification_id = ?", notificationID).
Exec(c); err != nil {
return err
}
return nil
}
func saveNotificationDetails(c *gin.Context, tx bun.Tx, notificationID int64, req models.UpsertNotificationRequest) error {
now := time.Now()
for _, item := range req.Descriptions {
description := &models.NotificationDescription{
NotificationID: notificationID,
LanguageID: item.LanguageID,
Description: strings.TrimSpace(item.Description),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := tx.NewInsert().Model(description).Exec(c); err != nil {
return fmt.Errorf("could not save notification descriptions")
}
}
variableIDs := make(map[string]int64, len(req.Variables))
for _, item := range req.Variables {
variable := &models.NotificationVariableDef{
NotificationID: notificationID,
Key: strings.TrimSpace(item.Key),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := tx.NewInsert().Model(variable).Exec(c); err != nil {
return fmt.Errorf("could not save notification variable definitions")
}
variableIDs[variable.Key] = variable.ID
}
for _, entryReq := range req.Entries {
entry := &models.NotificationEntry{
NotificationID: notificationID,
TimeSecond: entryReq.TimeSecond,
Image: strings.TrimSpace(entryReq.Image),
Login: strings.TrimSpace(entryReq.Login),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := tx.NewInsert().Model(entry).Exec(c); err != nil {
return fmt.Errorf("could not save notification entries")
}
for _, variableReq := range entryReq.Variables {
entryVariable := &models.NotificationEntryVariable{
NotificationEntryID: entry.ID,
NotificationVariableID: variableIDs[strings.TrimSpace(variableReq.Key)],
Value: strings.TrimSpace(variableReq.Value),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := tx.NewInsert().Model(entryVariable).Exec(c); err != nil {
return fmt.Errorf("could not save notification entry variables")
}
}
}
return nil
}
func validateUpsertNotificationRequest(req *models.UpsertNotificationRequest) error {
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return fmt.Errorf("name is required")
}
if len(req.Descriptions) == 0 {
return fmt.Errorf("at least one description is required")
}
if len(req.Entries) == 0 {
return fmt.Errorf("at least one notification entry is required")
}
seenLanguages := make(map[int64]struct{}, len(req.Descriptions))
for i := range req.Descriptions {
req.Descriptions[i].Description = strings.TrimSpace(req.Descriptions[i].Description)
if req.Descriptions[i].LanguageID <= 0 {
return fmt.Errorf("descriptions[%d].language_id is required", i)
}
if req.Descriptions[i].Description == "" {
return fmt.Errorf("descriptions[%d].description is required", i)
}
if _, exists := seenLanguages[req.Descriptions[i].LanguageID]; exists {
return fmt.Errorf("duplicate description language_id: %d", req.Descriptions[i].LanguageID)
}
seenLanguages[req.Descriptions[i].LanguageID] = struct{}{}
}
variableKeys := make([]string, 0, len(req.Variables))
expectedKeys := make(map[string]struct{}, len(req.Variables))
for i := range req.Variables {
req.Variables[i].Key = strings.TrimSpace(req.Variables[i].Key)
if req.Variables[i].Key == "" {
return fmt.Errorf("variables[%d].key is required", i)
}
for _, reserved := range defaultNotificationMacroKeys {
if req.Variables[i].Key == reserved {
return fmt.Errorf("variables[%d].key uses reserved macro name: %s", i, reserved)
}
}
if _, exists := expectedKeys[req.Variables[i].Key]; exists {
return fmt.Errorf("duplicate variable key: %s", req.Variables[i].Key)
}
expectedKeys[req.Variables[i].Key] = struct{}{}
variableKeys = append(variableKeys, req.Variables[i].Key)
}
for i := range req.Entries {
req.Entries[i].Image = strings.TrimSpace(req.Entries[i].Image)
req.Entries[i].Login = strings.TrimSpace(req.Entries[i].Login)
if req.Entries[i].TimeSecond < 0 {
return fmt.Errorf("entries[%d].time_second must be >= 0", i)
}
if req.Entries[i].Image == "" {
return fmt.Errorf("entries[%d].image is required", i)
}
if req.Entries[i].Login == "" {
return fmt.Errorf("entries[%d].login is required", i)
}
entrySeen := make(map[string]struct{}, len(req.Entries[i].Variables))
for j := range req.Entries[i].Variables {
req.Entries[i].Variables[j].Key = strings.TrimSpace(req.Entries[i].Variables[j].Key)
req.Entries[i].Variables[j].Value = strings.TrimSpace(req.Entries[i].Variables[j].Value)
if req.Entries[i].Variables[j].Key == "" {
return fmt.Errorf("entries[%d].variables[%d].key is required", i, j)
}
if _, exists := expectedKeys[req.Entries[i].Variables[j].Key]; !exists {
return fmt.Errorf("entries[%d].variables[%d].key is not declared in notification variables", i, j)
}
if _, exists := entrySeen[req.Entries[i].Variables[j].Key]; exists {
return fmt.Errorf("entries[%d] has duplicate variable key: %s", i, req.Entries[i].Variables[j].Key)
}
entrySeen[req.Entries[i].Variables[j].Key] = struct{}{}
}
if len(entrySeen) != len(expectedKeys) {
missing := make([]string, 0, len(expectedKeys)-len(entrySeen))
for _, key := range variableKeys {
if _, exists := entrySeen[key]; !exists {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return fmt.Errorf("entries[%d] is missing variable values for: %s", i, strings.Join(missing, ", "))
}
}
}
return nil
}
func prepareNotificationResponse(notification *models.Notification) {
sort.SliceStable(notification.Descriptions, func(i, j int) bool {
return notification.Descriptions[i].LanguageID < notification.Descriptions[j].LanguageID
})
sort.SliceStable(notification.Variables, func(i, j int) bool {
return notification.Variables[i].ID < notification.Variables[j].ID
})
sort.SliceStable(notification.Entries, func(i, j int) bool {
return notification.Entries[i].ID < notification.Entries[j].ID
})
macros := make([]string, 0, len(defaultNotificationMacroKeys)+len(notification.Variables))
for _, key := range defaultNotificationMacroKeys {
macros = append(macros, "{{"+key+"}}")
}
for _, variable := range notification.Variables {
macros = append(macros, "{{"+variable.Key+"}}")
}
notification.Macros = macros
for i := range notification.Entries {
sort.SliceStable(notification.Entries[i].Variables, func(left, right int) bool {
return notification.Entries[i].Variables[left].ID < notification.Entries[i].Variables[right].ID
})
for j := range notification.Entries[i].Variables {
if notification.Entries[i].Variables[j].Variable != nil {
notification.Entries[i].Variables[j].Key = notification.Entries[i].Variables[j].Variable.Key
}
}
}
}

127
internal/handlers/users.go Normal file
View File

@ -0,0 +1,127 @@
package handlers
import (
"net/http"
"strconv"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/uptrace/bun"
)
type UserHandler struct {
db *bun.DB
cfg *config.Config
}
func NewUserHandler(db *bun.DB, cfg *config.Config) *UserHandler {
return &UserHandler{db: db, cfg: cfg}
}
func (h *UserHandler) List(c *gin.Context) {
var users []models.User
query := h.db.NewSelect().Model(&users).OrderExpr("id DESC")
// Search
if s := c.Query("search"); s != "" {
like := "%" + s + "%"
query = query.Where("username LIKE ? OR email LIKE ?", like, like)
}
// Role filter
if r := c.Query("role"); r != "" {
query = query.Where("role = ?", r)
}
err := query.Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
func (h *UserHandler) ListByGame(c *gin.Context) {
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
return
}
var users []models.User
query := h.db.NewSelect().
Model(&users).
Join("JOIN user_games AS ug ON ug.user_id = u.id").
Where("ug.game_id = ?", gameID).
OrderExpr("u.id DESC").
Distinct()
if s := c.Query("search"); s != "" {
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
query = query.Where("u.id = ?", id)
} else {
like := "%" + s + "%"
query = query.Where("u.username LIKE ? OR u.email LIKE ?", like, like)
}
}
err = query.Scan(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
func (h *UserHandler) GetByID(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
user := new(models.User)
err := h.db.NewSelect().Model(user).
Relation("UserGames", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("Game")
}).
Where("u.id = ?", id).
Scan(c)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
func (h *UserHandler) UpdateRole(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var req models.UpdateUserRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.db.NewUpdate().Model((*models.User)(nil)).
Set("role = ?", req.Role).
Where("id = ?", id).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Role updated"})
}
func (h *UserHandler) ToggleActive(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
_, err := h.db.NewUpdate().Model((*models.User)(nil)).
Set("is_active = NOT is_active").
Where("id = ?", id).
Exec(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Toggled"})
}

View File

@ -0,0 +1,78 @@
package middleware
import (
"net/http"
"strings"
"time"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
Role models.Role `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(user *models.User, cfg *config.Config) (string, error) {
claims := Claims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(72 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func AuthRequired(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"})
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(parts[1], claims, func(t *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
c.Set("user_id", claims.UserID)
c.Set("user_email", claims.Email)
c.Set("user_role", claims.Role)
c.Set("username", claims.Username)
c.Next()
}
}
func RoleRequired(roles ...models.Role) gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get("user_role")
userRole := role.(models.Role)
for _, r := range roles {
if userRole == r {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
}
}

View File

@ -0,0 +1,145 @@
package migrations
import (
"context"
"fmt"
"time"
"game-admin/internal/config"
"game-admin/internal/models"
"github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
)
func Migrate(ctx context.Context, db *bun.DB, cfg *config.Config) error {
tables := []interface{}{
(*models.User)(nil),
(*models.Game)(nil),
(*models.Language)(nil),
(*models.Module)(nil),
(*models.GameModule)(nil),
(*models.GameVariable)(nil),
(*models.GameVariableItem)(nil),
(*models.Notification)(nil),
(*models.NotificationDescription)(nil),
(*models.NotificationVariableDef)(nil),
(*models.NotificationEntry)(nil),
(*models.NotificationEntryVariable)(nil),
(*models.Leaderboard)(nil),
(*models.LeaderboardGroup)(nil),
(*models.LeaderboardGroupMember)(nil),
(*models.LeaderboardScore)(nil),
(*models.UserGame)(nil),
(*models.InviteLink)(nil),
(*models.BalanceTransaction)(nil),
(*models.Transaction)(nil),
}
for _, model := range tables {
_, err := db.NewCreateTable().
Model(model).
IfNotExists().
Exec(ctx)
if err != nil {
return fmt.Errorf("create table: %w", err)
}
}
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_games_unique ON user_games (user_id, game_id)")
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_languages_code_unique ON languages ("code")`)
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_key_unique ON modules ("key")`)
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_game_modules_unique ON game_modules (game_id, module_id)")
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_game_variables_unique ON game_variables (game_id, "key")`)
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_game_variable_items_unique ON game_variable_items (game_variable_id, item_key)")
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_unique ON notifications (game_id, name)")
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_descriptions_unique ON notification_descriptions (notification_id, language_id)")
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_variable_defs_unique ON notification_variable_defs (notification_id, "key")`)
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_entry_variables_unique ON notification_entry_variables (notification_entry_id, notification_variable_id)")
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboards_unique ON leaderboards (game_id, "key")`)
_, _ = db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_groups_unique ON leaderboard_groups (leaderboard_id, "key")`)
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_group_members_unique ON leaderboard_group_members (group_id, user_id)")
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_scores_unique ON leaderboard_scores (leaderboard_id, user_game_id)")
_, _ = db.ExecContext(ctx,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_request_id_unique ON transactions (request_id)")
_, _ = db.ExecContext(ctx,
"CREATE INDEX IF NOT EXISTS idx_transactions_user_game_status ON transactions (user_id, game_id, status)")
// Seed a default admin only when there are no users yet.
userCount, err := db.NewSelect().Model((*models.User)(nil)).Count(ctx)
if err != nil {
return fmt.Errorf("count users: %w", err)
}
if userCount == 0 {
hashed, err := bcrypt.GenerateFromPassword([]byte(cfg.DefaultAdminPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash default admin password: %w", err)
}
admin := &models.User{
Email: cfg.DefaultAdminEmail,
Username: cfg.DefaultAdminUsername,
Password: string(hashed),
Role: models.RoleAdmin,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = db.NewInsert().Model(admin).Exec(ctx)
if err != nil {
return fmt.Errorf("seed default admin: %w", err)
}
fmt.Printf("default admin user seeded: %s / %s\n", cfg.DefaultAdminEmail, cfg.DefaultAdminPassword)
}
games := []models.Game{
{Name: "Black & White", Slug: "b&d", Description: "Sandbox", IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
for _, g := range games {
exists, _ := db.NewSelect().Model((*models.Game)(nil)).Where("slug = ?", g.Slug).Exists(ctx)
if !exists {
_, _ = db.NewInsert().Model(&g).Exec(ctx)
}
}
defaultModules := []models.Module{
{Key: "variables", Name: "Variables", CreatedAt: time.Now(), UpdatedAt: time.Now()},
{Key: "notification", Name: "Notification", CreatedAt: time.Now(), UpdatedAt: time.Now()},
{Key: "leaderboard", Name: "Leaderboard", CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
for _, module := range defaultModules {
exists, _ := db.NewSelect().Model((*models.Module)(nil)).Where(`"key" = ?`, module.Key).Exists(ctx)
if !exists {
_, _ = db.NewInsert().Model(&module).Exec(ctx)
}
}
defaultLanguages := []models.Language{
{Code: "en", Name: "English", CreatedAt: time.Now(), UpdatedAt: time.Now()},
{Code: "ru", Name: "Русский", CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
for _, language := range defaultLanguages {
exists, _ := db.NewSelect().Model((*models.Language)(nil)).Where("code = ?", language.Code).Exists(ctx)
if !exists {
_, _ = db.NewInsert().Model(&language).Exec(ctx)
}
}
fmt.Println("migrations complete")
return nil
}

438
internal/models/models.go Normal file
View File

@ -0,0 +1,438 @@
package models
import (
"time"
"github.com/uptrace/bun"
)
// ─── Roles ──────────────────────────────────────────────────
type Role string
const (
RoleAdmin Role = "admin"
RoleModerator Role = "moderator"
RoleUser Role = "user"
)
// ─── User ───────────────────────────────────────────────────
type User struct {
bun.BaseModel `bun:"table:users,alias:u"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Email string `bun:"email,notnull,unique" json:"email"`
Username string `bun:"username,notnull,unique" json:"username"`
Password string `bun:"password,notnull" json:"-"`
Role Role `bun:"role,notnull,default:'user'" json:"role"`
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
UserGames []*UserGame `bun:"rel:has-many,join:id=user_id" json:"user_games,omitempty"`
}
// ─── Game ───────────────────────────────────────────────────
type Game struct {
bun.BaseModel `bun:"table:games,alias:g"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,notnull,unique" json:"name"`
Slug string `bun:"slug,notnull,unique" json:"slug"`
Description string `bun:"description" json:"description"`
ImageURL string `bun:"image_url" json:"image_url"`
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Variables []*GameVariable `bun:"rel:has-many,join:id=game_id" json:"variables,omitempty"`
Notifications []Notification `bun:"rel:has-many,join:id=game_id" json:"notifications,omitempty"`
Leaderboards []Leaderboard `bun:"rel:has-many,join:id=game_id" json:"leaderboards,omitempty"`
Modules []Module `bun:"-" json:"modules,omitempty"`
}
type GameVariableType string
const (
GameVariableTypeNumber GameVariableType = "number"
GameVariableTypeString GameVariableType = "string"
GameVariableTypeTable GameVariableType = "table"
GameVariableTypeVector GameVariableType = "vector"
)
type GameVariableTableValueType string
const (
GameVariableTableValueTypeNumber GameVariableTableValueType = "number"
GameVariableTableValueTypeString GameVariableTableValueType = "string"
)
type GameVariable struct {
bun.BaseModel `bun:"table:game_variables,alias:gv"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
Key string `bun:"key,notnull" json:"key"`
Type GameVariableType `bun:"type,notnull" json:"type"`
NumberValue *float64 `bun:"number_value" json:"number_value,omitempty"`
StringValue *string `bun:"string_value" json:"string_value,omitempty"`
TableValueType *GameVariableTableValueType `bun:"table_value_type" json:"table_value_type,omitempty"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
Items []*GameVariableItem `bun:"rel:has-many,join:id=game_variable_id" json:"items,omitempty"`
}
type GameVariableItem struct {
bun.BaseModel `bun:"table:game_variable_items,alias:gvi"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GameVariableID int64 `bun:"game_variable_id,notnull" json:"game_variable_id"`
Key string `bun:"item_key,notnull" json:"key,omitempty"`
Index *int64 `bun:"-" json:"index,omitempty"`
NumberValue *float64 `bun:"number_value" json:"number_value,omitempty"`
StringValue *string `bun:"string_value" json:"string_value,omitempty"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Variable *GameVariable `bun:"rel:belongs-to,join:game_variable_id=id" json:"-"`
}
type Module struct {
bun.BaseModel `bun:"table:modules,alias:m"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Key string `bun:"key,notnull,unique" json:"key"`
Name string `bun:"name,notnull" json:"name"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
}
type Language struct {
bun.BaseModel `bun:"table:languages,alias:l"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Code string `bun:"code,notnull,unique" json:"code"`
Name string `bun:"name,notnull" json:"name"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
}
type GameModule struct {
bun.BaseModel `bun:"table:game_modules,alias:gm"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
ModuleID int64 `bun:"module_id,notnull" json:"module_id"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"-"`
Module *Module `bun:"rel:belongs-to,join:module_id=id" json:"module,omitempty"`
}
type Notification struct {
bun.BaseModel `bun:"table:notifications,alias:n"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
Name string `bun:"name,notnull" json:"name"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
Descriptions []NotificationDescription `bun:"rel:has-many,join:id=notification_id" json:"descriptions,omitempty"`
Variables []NotificationVariableDef `bun:"rel:has-many,join:id=notification_id" json:"variables,omitempty"`
Entries []NotificationEntry `bun:"rel:has-many,join:id=notification_id" json:"entries,omitempty"`
Macros []string `bun:"-" json:"macros,omitempty"`
}
type NotificationDescription struct {
bun.BaseModel `bun:"table:notification_descriptions,alias:nd"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
LanguageID int64 `bun:"language_id,notnull" json:"language_id"`
Description string `bun:"description,notnull" json:"description"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Language *Language `bun:"rel:belongs-to,join:language_id=id" json:"language,omitempty"`
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
}
type NotificationVariableDef struct {
bun.BaseModel `bun:"table:notification_variable_defs,alias:nvd"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
Key string `bun:"key,notnull" json:"key"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
}
type NotificationEntry struct {
bun.BaseModel `bun:"table:notification_entries,alias:ne"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
TimeSecond int64 `bun:"time_second,notnull" json:"time_second"`
Image string `bun:"image,notnull" json:"image"`
Login string `bun:"login,notnull" json:"login"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
Variables []NotificationEntryVariable `bun:"rel:has-many,join:id=notification_entry_id" json:"variables,omitempty"`
}
type NotificationEntryVariable struct {
bun.BaseModel `bun:"table:notification_entry_variables,alias:nev"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
NotificationEntryID int64 `bun:"notification_entry_id,notnull" json:"notification_entry_id"`
NotificationVariableID int64 `bun:"notification_variable_id,notnull" json:"notification_variable_id"`
Value string `bun:"value,notnull" json:"value"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Key string `bun:"-" json:"key,omitempty"`
Entry *NotificationEntry `bun:"rel:belongs-to,join:notification_entry_id=id" json:"-"`
Variable *NotificationVariableDef `bun:"rel:belongs-to,join:notification_variable_id=id" json:"-"`
}
type LeaderboardSortOrder string
const (
LeaderboardSortOrderDesc LeaderboardSortOrder = "desc"
LeaderboardSortOrderAsc LeaderboardSortOrder = "asc"
)
type LeaderboardPeriodType string
const (
LeaderboardPeriodAllTime LeaderboardPeriodType = "all_time"
LeaderboardPeriodDaily LeaderboardPeriodType = "daily"
LeaderboardPeriodWeekly LeaderboardPeriodType = "weekly"
LeaderboardPeriodMonthly LeaderboardPeriodType = "monthly"
)
type Leaderboard struct {
bun.BaseModel `bun:"table:leaderboards,alias:lb"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
Key string `bun:"key,notnull" json:"key"`
Name string `bun:"name,notnull" json:"name"`
SortOrder LeaderboardSortOrder `bun:"sort_order,notnull" json:"sort_order"`
PeriodType LeaderboardPeriodType `bun:"period_type,notnull" json:"period_type"`
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
Groups []LeaderboardGroup `bun:"rel:has-many,join:id=leaderboard_id" json:"groups,omitempty"`
}
type LeaderboardGroup struct {
bun.BaseModel `bun:"table:leaderboard_groups,alias:lbg"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
LeaderboardID int64 `bun:"leaderboard_id,notnull" json:"leaderboard_id"`
Key string `bun:"key,notnull" json:"key"`
Name string `bun:"name,notnull" json:"name"`
IsDefault bool `bun:"is_default,notnull,default:false" json:"is_default"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Leaderboard *Leaderboard `bun:"rel:belongs-to,join:leaderboard_id=id" json:"leaderboard,omitempty"`
Members []LeaderboardGroupMember `bun:"rel:has-many,join:id=group_id" json:"members,omitempty"`
}
type LeaderboardGroupMember struct {
bun.BaseModel `bun:"table:leaderboard_group_members,alias:lbgm"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
GroupID int64 `bun:"group_id,notnull" json:"group_id"`
UserID int64 `bun:"user_id,notnull" json:"user_id"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
Group *LeaderboardGroup `bun:"rel:belongs-to,join:group_id=id" json:"-"`
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
}
type LeaderboardScore struct {
bun.BaseModel `bun:"table:leaderboard_scores,alias:lbs"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
LeaderboardID int64 `bun:"leaderboard_id,notnull" json:"leaderboard_id"`
UserGameID int64 `bun:"user_game_id,notnull" json:"user_game_id"`
Score float64 `bun:"score,notnull" json:"score"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
Leaderboard *Leaderboard `bun:"rel:belongs-to,join:leaderboard_id=id" json:"leaderboard,omitempty"`
UserGame *UserGame `bun:"rel:belongs-to,join:user_game_id=id" json:"user_game,omitempty"`
}
type LeaderboardRankItem struct {
Rank int64 `json:"rank"`
UserID int64 `json:"user_id"`
UserGameID int64 `json:"user_game_id"`
Username string `json:"username"`
Score float64 `json:"score"`
}
// ─── UserGame (pivot — user's connected games + balance) ────
type UserGame struct {
bun.BaseModel `bun:"table:user_games,alias:ug"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
UserID int64 `bun:"user_id,notnull" json:"user_id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
Balance float64 `bun:"balance,notnull,default:0" json:"balance"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
}
// ─── Invite Link ────────────────────────────────────────────
type InviteLink struct {
bun.BaseModel `bun:"table:invite_links,alias:il"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Code string `bun:"code,notnull,unique" json:"code"`
Role Role `bun:"role,notnull,default:'user'" json:"role"`
MaxUses int `bun:"max_uses,notnull,default:1" json:"max_uses"`
UsedCount int `bun:"used_count,notnull,default:0" json:"used_count"`
CreatedByID int64 `bun:"created_by_id,notnull" json:"created_by_id"`
ExpiresAt *time.Time `bun:"expires_at" json:"expires_at"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
CreatedBy *User `bun:"rel:belongs-to,join:created_by_id=id" json:"created_by,omitempty"`
}
// ─── Balance Transaction Log ────────────────────────────────
type BalanceTransaction struct {
bun.BaseModel `bun:"table:balance_transactions,alias:bt"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
UserGameID int64 `bun:"user_game_id,notnull" json:"user_game_id"`
Amount float64 `bun:"amount,notnull" json:"amount"`
Type string `bun:"type,notnull" json:"type"` // "topup" | "withdraw"
Comment string `bun:"comment" json:"comment"`
AdminID int64 `bun:"admin_id,notnull" json:"admin_id"`
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
}
// ─── Request / Response DTOs ────────────────────────────────
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required,min=3"`
Password string `json:"password" binding:"required,min=6"`
InviteCode string `json:"invite_code"`
}
type TopUpRequest struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
Comment string `json:"comment"`
}
type CreateInviteRequest struct {
Role Role `json:"role" binding:"required"`
MaxUses int `json:"max_uses" binding:"required,gt=0"`
}
type CreateGameRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
}
type GameVariableItemRequest struct {
Key string `json:"key"`
Index *int64 `json:"index"`
NumberValue *float64 `json:"number_value"`
StringValue *string `json:"string_value"`
}
type UpsertGameVariableRequest struct {
Key string `json:"key" binding:"required"`
Type GameVariableType `json:"type" binding:"required"`
NumberValue *float64 `json:"number_value"`
StringValue *string `json:"string_value"`
TableValueType *GameVariableTableValueType `json:"table_value_type"`
Items []GameVariableItemRequest `json:"items"`
}
type ConnectGameModuleRequest struct {
ModuleKey string `json:"module_key" binding:"required"`
}
type UpsertLanguageRequest struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
}
type NotificationDescriptionRequest struct {
LanguageID int64 `json:"language_id" binding:"required"`
Description string `json:"description" binding:"required"`
}
type NotificationVariableRequest struct {
Key string `json:"key" binding:"required"`
}
type NotificationEntryVariableRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
type NotificationEntryRequest struct {
TimeSecond int64 `json:"time_second"`
Image string `json:"image" binding:"required"`
Login string `json:"login" binding:"required"`
Variables []NotificationEntryVariableRequest `json:"variables"`
}
type UpsertNotificationRequest struct {
Name string `json:"name" binding:"required"`
Descriptions []NotificationDescriptionRequest `json:"descriptions" binding:"required"`
Variables []NotificationVariableRequest `json:"variables"`
Entries []NotificationEntryRequest `json:"entries" binding:"required"`
}
type UpsertLeaderboardRequest struct {
Key string `json:"key" binding:"required"`
Name string `json:"name" binding:"required"`
SortOrder LeaderboardSortOrder `json:"sort_order" binding:"required"`
PeriodType LeaderboardPeriodType `json:"period_type" binding:"required"`
IsActive *bool `json:"is_active"`
}
type UpsertLeaderboardGroupRequest struct {
Key string `json:"key" binding:"required"`
Name string `json:"name" binding:"required"`
IsDefault *bool `json:"is_default"`
}
type AddLeaderboardGroupMemberRequest struct {
UserID int64 `json:"user_id" binding:"required"`
}
type UpsertLeaderboardScoreRequest struct {
UserGameID int64 `json:"user_game_id" binding:"required"`
Score float64 `json:"score" binding:"required"`
}
type ConnectGameRequest struct {
GameID int64 `json:"game_id" binding:"required"`
}
type UpdateUserRoleRequest struct {
Role Role `json:"role" binding:"required"`
}

View File

@ -0,0 +1,22 @@
package models
import (
"time"
"github.com/uptrace/bun"
)
type Transaction struct {
bun.BaseModel `bun:"table:transactions,alias:t"`
ID string `bun:"id,pk,type:varchar(64)" json:"id"`
RequestID string `bun:"request_id,notnull" json:"request_id"`
UserID int64 `bun:"user_id,notnull" json:"user_id"`
GameID int64 `bun:"game_id,notnull" json:"game_id"`
RoundID string `bun:"round_id,notnull" json:"round_id"`
Amount int64 `bun:"amount,notnull" json:"amount"`
WinAmount int64 `bun:"win_amount,notnull,default:0" json:"win_amount"`
Currency string `bun:"currency,notnull" json:"currency"`
Status string `bun:"status,notnull" json:"status"` // reserved / confirmed / canceled
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
}

20
internal/wallet/errors.go Normal file
View File

@ -0,0 +1,20 @@
package wallet
import "errors"
var (
ErrInvalidAmount = errors.New("amount must be > 0")
ErrMissingRequiredFields = errors.New("missing required fields")
ErrInvalidUserID = errors.New("user_id must be numeric")
ErrInvalidGameID = errors.New("game_id must be numeric")
ErrAccountNotFound = errors.New("user account not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrMissingTransactionID = errors.New("missing transaction_id")
ErrMissingRoundID = errors.New("missing round_id")
ErrInvalidWinAmount = errors.New("win_amount must be >= 0")
ErrTransactionNotFound = errors.New("transaction not found")
ErrRoundMismatch = errors.New("round_id does not match transaction")
ErrAlreadyCanceled = errors.New("transaction already canceled")
ErrAlreadyConfirmed = errors.New("transaction already confirmed")
ErrInvalidTransactionState = errors.New("invalid transaction state")
)

331
internal/wallet/service.go Normal file
View File

@ -0,0 +1,331 @@
package wallet
import (
"context"
"database/sql"
"errors"
"math"
"strconv"
"time"
"game-admin/internal/models"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
const (
StatusReserved = "reserved"
StatusConfirmed = "confirmed"
StatusCanceled = "canceled"
StatusAlreadyConfirmed = "already_confirmed"
StatusAlreadyCanceled = "already_canceled"
)
type Balances struct {
Available int64
Reserved int64
}
type Service struct {
db *bun.DB
}
func NewService(db *bun.DB) *Service {
return &Service{db: db}
}
func (s *Service) Reserve(ctx context.Context, requestID, userIDRaw, gameIDRaw, roundID string, amount int64, currency string) (*models.Transaction, string, Balances, error) {
if amount <= 0 {
return nil, "", Balances{}, ErrInvalidAmount
}
if requestID == "" || userIDRaw == "" || gameIDRaw == "" || roundID == "" {
return nil, "", Balances{}, ErrMissingRequiredFields
}
userID, err := strconv.ParseInt(userIDRaw, 10, 64)
if err != nil {
return nil, "", Balances{}, ErrInvalidUserID
}
gameID, err := strconv.ParseInt(gameIDRaw, 10, 64)
if err != nil {
return nil, "", Balances{}, ErrInvalidGameID
}
var transaction *models.Transaction
var balances Balances
status := StatusReserved
err = s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
existing := new(models.Transaction)
err := tx.NewSelect().
Model(existing).
Where("request_id = ?", requestID).
Scan(ctx)
if err == nil {
transaction = existing
balances, err = s.getBalancesTx(ctx, tx, existing.UserID, existing.GameID)
return err
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
userGame, err := s.getUserGameTx(ctx, tx, userID, gameID, true)
if err != nil {
return err
}
available := moneyToMinorUnits(userGame.Balance)
reserved, err := s.sumReservedTx(ctx, tx, userID, gameID)
if err != nil {
return err
}
if available < amount {
return ErrInsufficientFunds
}
_, err = tx.NewUpdate().
Model((*models.UserGame)(nil)).
Set("balance = ?", minorUnitsToMoney(available-amount)).
Set("updated_at = ?", time.Now()).
Where("id = ?", userGame.ID).
Exec(ctx)
if err != nil {
return err
}
transaction = &models.Transaction{
ID: uuid.NewString(),
RequestID: requestID,
UserID: userID,
GameID: gameID,
RoundID: roundID,
Amount: amount,
WinAmount: 0,
Currency: currency,
Status: StatusReserved,
CreatedAt: time.Now(),
}
if _, err := tx.NewInsert().Model(transaction).Exec(ctx); err != nil {
return err
}
balances = Balances{
Available: available - amount,
Reserved: reserved + amount,
}
return nil
})
return transaction, status, balances, err
}
func (s *Service) Confirm(ctx context.Context, transactionID, roundID string, winAmount int64) (*models.Transaction, string, Balances, error) {
if transactionID == "" {
return nil, "", Balances{}, ErrMissingTransactionID
}
if roundID == "" {
return nil, "", Balances{}, ErrMissingRoundID
}
if winAmount < 0 {
return nil, "", Balances{}, ErrInvalidWinAmount
}
var transaction *models.Transaction
var balances Balances
status := StatusConfirmed
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
current, err := s.getTransactionTx(ctx, tx, transactionID, true)
if err != nil {
return err
}
if current.RoundID != roundID {
return ErrRoundMismatch
}
userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true)
if err != nil {
return err
}
switch current.Status {
case StatusConfirmed:
status = StatusAlreadyConfirmed
case StatusCanceled:
return ErrAlreadyCanceled
case StatusReserved:
available := moneyToMinorUnits(userGame.Balance)
_, err = tx.NewUpdate().
Model((*models.UserGame)(nil)).
Set("balance = ?", minorUnitsToMoney(available+winAmount)).
Set("updated_at = ?", time.Now()).
Where("id = ?", userGame.ID).
Exec(ctx)
if err != nil {
return err
}
current.WinAmount = winAmount
current.Status = StatusConfirmed
if _, err := tx.NewUpdate().
Model(current).
Column("win_amount", "status").
WherePK().
Exec(ctx); err != nil {
return err
}
default:
return ErrInvalidTransactionState
}
transaction = current
balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID)
return err
})
return transaction, status, balances, err
}
func (s *Service) Cancel(ctx context.Context, transactionID string) (*models.Transaction, string, Balances, error) {
if transactionID == "" {
return nil, "", Balances{}, ErrMissingTransactionID
}
var transaction *models.Transaction
var balances Balances
status := StatusCanceled
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
current, err := s.getTransactionTx(ctx, tx, transactionID, true)
if err != nil {
return err
}
userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true)
if err != nil {
return err
}
switch current.Status {
case StatusCanceled:
status = StatusAlreadyCanceled
case StatusConfirmed:
return ErrAlreadyConfirmed
case StatusReserved:
available := moneyToMinorUnits(userGame.Balance)
_, err = tx.NewUpdate().
Model((*models.UserGame)(nil)).
Set("balance = ?", minorUnitsToMoney(available+current.Amount)).
Set("updated_at = ?", time.Now()).
Where("id = ?", userGame.ID).
Exec(ctx)
if err != nil {
return err
}
current.Status = StatusCanceled
if _, err := tx.NewUpdate().
Model(current).
Column("status").
WherePK().
Exec(ctx); err != nil {
return err
}
default:
return ErrInvalidTransactionState
}
transaction = current
balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID)
return err
})
return transaction, status, balances, err
}
func (s *Service) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) {
if transactionID == "" {
return nil, ErrMissingTransactionID
}
return s.getTransactionTx(ctx, s.db, transactionID, false)
}
func (s *Service) getTransactionTx(ctx context.Context, db bun.IDB, transactionID string, lock bool) (*models.Transaction, error) {
transaction := new(models.Transaction)
query := db.NewSelect().
Model(transaction).
Where("id = ?", transactionID)
if lock {
query = query.For("UPDATE")
}
if err := query.Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTransactionNotFound
}
return nil, err
}
return transaction, nil
}
func (s *Service) getUserGameTx(ctx context.Context, db bun.IDB, userID, gameID int64, lock bool) (*models.UserGame, error) {
userGame := new(models.UserGame)
query := db.NewSelect().
Model(userGame).
Where("user_id = ? AND game_id = ?", userID, gameID)
if lock {
query = query.For("UPDATE")
}
if err := query.Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return nil, err
}
return userGame, nil
}
func (s *Service) getBalancesTx(ctx context.Context, db bun.IDB, userID, gameID int64) (Balances, error) {
userGame, err := s.getUserGameTx(ctx, db, userID, gameID, false)
if err != nil {
return Balances{}, err
}
reserved, err := s.sumReservedTx(ctx, db, userID, gameID)
if err != nil {
return Balances{}, err
}
return Balances{
Available: moneyToMinorUnits(userGame.Balance),
Reserved: reserved,
}, nil
}
func (s *Service) sumReservedTx(ctx context.Context, db bun.IDB, userID, gameID int64) (int64, error) {
var reserved int64
if err := db.NewSelect().
Model((*models.Transaction)(nil)).
ColumnExpr("COALESCE(SUM(amount), 0)").
Where("user_id = ? AND game_id = ? AND status = ?", userID, gameID, StatusReserved).
Scan(ctx, &reserved); err != nil {
return 0, err
}
return reserved, nil
}
func moneyToMinorUnits(amount float64) int64 {
return int64(math.Round(amount * 100))
}
func minorUnitsToMoney(amount int64) float64 {
return float64(amount) / 100
}

68
proto/wallet.proto Normal file
View File

@ -0,0 +1,68 @@
syntax = "proto3";
package wallet;
option go_package = "gen/;walletpb";
service WalletService {
rpc Reserve(ReserveRequest) returns (ReserveResponse);
rpc Confirm(ConfirmRequest) returns (ConfirmResponse);
rpc Cancel(CancelRequest) returns (CancelResponse);
rpc GetTransaction(GetTransactionRequest) returns (GetTransactionResponse);
}
message ReserveRequest {
string request_id = 1;
string user_id = 2;
string game_id = 3;
string round_id = 4;
int64 amount = 5; // в минорных единицах, например cents
string currency = 6;
}
message ReserveResponse {
string transaction_id = 1;
string status = 2; // reserved / rejected
int64 available_balance = 3;
int64 reserved_balance = 4;
}
message ConfirmRequest {
string request_id = 1;
string transaction_id = 2;
string round_id = 3;
int64 win_amount = 4; // 0 если проигрыш
}
message ConfirmResponse {
string transaction_id = 1;
string status = 2; // confirmed / already_confirmed
int64 available_balance = 3;
int64 reserved_balance = 4;
}
message CancelRequest {
string request_id = 1;
string transaction_id = 2;
string reason = 3;
}
message CancelResponse {
string transaction_id = 1;
string status = 2; // canceled / already_canceled
int64 available_balance = 3;
int64 reserved_balance = 4;
}
message GetTransactionRequest {
string transaction_id = 1;
}
message GetTransactionResponse {
string transaction_id = 1;
string user_id = 2;
string round_id = 3;
int64 amount = 4;
int64 win_amount = 5;
string status = 6; // reserved / confirmed / canceled
}