From b340a339c3eca917bfe80a22ddd6ab5e75c8117a Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 30 Mar 2026 21:00:35 +0300 Subject: [PATCH] init --- .env.example | 15 + .gitignore | 31 ++ API.md | 698 ++++++++++++++++++++++++++ AUTH_ARCHITECTURE.md | 232 +++++++++ Dockerfile | 13 + cmd/main.go | 164 ++++++ docker-compose.yml | 33 ++ examples/client.example.txt | 155 ++++++ gen/wallet.pb.go | 685 +++++++++++++++++++++++++ gen/wallet_grpc.pb.go | 235 +++++++++ go.mod | 51 ++ go.sum | 123 +++++ internal/config/config.go | 52 ++ internal/grpcserver/auth.go | 94 ++++ internal/grpcserver/wallet.go | 118 +++++ internal/handlers/auth.go | 128 +++++ internal/handlers/game_modules.go | 194 ++++++++ internal/handlers/game_variables.go | 473 ++++++++++++++++++ internal/handlers/games.go | 249 +++++++++ internal/handlers/images.go | 94 ++++ internal/handlers/invites.go | 98 ++++ internal/handlers/languages.go | 174 +++++++ internal/handlers/leaderboards.go | 748 ++++++++++++++++++++++++++++ internal/handlers/notifications.go | 681 +++++++++++++++++++++++++ internal/handlers/users.go | 127 +++++ internal/middleware/auth.go | 78 +++ internal/migrations/migrate.go | 145 ++++++ internal/models/models.go | 438 ++++++++++++++++ internal/models/transaction.go | 22 + internal/wallet/errors.go | 20 + internal/wallet/service.go | 331 ++++++++++++ proto/wallet.proto | 68 +++ 32 files changed, 6767 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 API.md create mode 100644 AUTH_ARCHITECTURE.md create mode 100644 Dockerfile create mode 100644 cmd/main.go create mode 100644 docker-compose.yml create mode 100644 examples/client.example.txt create mode 100644 gen/wallet.pb.go create mode 100644 gen/wallet_grpc.pb.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/grpcserver/auth.go create mode 100644 internal/grpcserver/wallet.go create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/game_modules.go create mode 100644 internal/handlers/game_variables.go create mode 100644 internal/handlers/games.go create mode 100644 internal/handlers/images.go create mode 100644 internal/handlers/invites.go create mode 100644 internal/handlers/languages.go create mode 100644 internal/handlers/leaderboards.go create mode 100644 internal/handlers/notifications.go create mode 100644 internal/handlers/users.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/migrations/migrate.go create mode 100644 internal/models/models.go create mode 100644 internal/models/transaction.go create mode 100644 internal/wallet/errors.go create mode 100644 internal/wallet/service.go create mode 100644 proto/wallet.proto diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd65b9f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a0e46c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..2c615c0 --- /dev/null +++ b/API.md @@ -0,0 +1,698 @@ +# Backend API + +Base URL: `http://localhost:8080` + +All protected endpoints require: + +```http +Authorization: Bearer +``` + +## 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 diff --git a/AUTH_ARCHITECTURE.md b/AUTH_ARCHITECTURE.md new file mode 100644 index 0000000..22a9588 --- /dev/null +++ b/AUTH_ARCHITECTURE.md @@ -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 +``` + +### 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 ` +- 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1790bd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..e3b0bd7 --- /dev/null +++ b/cmd/main.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b1e042 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/examples/client.example.txt b/examples/client.example.txt new file mode 100644 index 0000000..2b7eff1 --- /dev/null +++ b/examples/client.example.txt @@ -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, "|") +} \ No newline at end of file diff --git a/gen/wallet.pb.go b/gen/wallet.pb.go new file mode 100644 index 0000000..7b2c386 --- /dev/null +++ b/gen/wallet.pb.go @@ -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 +} diff --git a/gen/wallet_grpc.pb.go b/gen/wallet_grpc.pb.go new file mode 100644 index 0000000..5b48e18 --- /dev/null +++ b/gen/wallet_grpc.pb.go @@ -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", +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f8b7ad --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a98fa6e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..64ecfcc --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/grpcserver/auth.go b/internal/grpcserver/auth.go new file mode 100644 index 0000000..ee37712 --- /dev/null +++ b/internal/grpcserver/auth.go @@ -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, "|") +} diff --git a/internal/grpcserver/wallet.go b/internal/grpcserver/wallet.go new file mode 100644 index 0000000..48dbbf8 --- /dev/null +++ b/internal/grpcserver/wallet.go @@ -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()) + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..4e04ac4 --- /dev/null +++ b/internal/handlers/auth.go @@ -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) +} diff --git a/internal/handlers/game_modules.go b/internal/handlers/game_modules.go new file mode 100644 index 0000000..31e059a --- /dev/null +++ b/internal/handlers/game_modules.go @@ -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) +} diff --git a/internal/handlers/game_variables.go b/internal/handlers/game_variables.go new file mode 100644 index 0000000..0472adb --- /dev/null +++ b/internal/handlers/game_variables.go @@ -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 = "" + } +} diff --git a/internal/handlers/games.go b/internal/handlers/games.go new file mode 100644 index 0000000..ce8b3ee --- /dev/null +++ b/internal/handlers/games.go @@ -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) +} diff --git a/internal/handlers/images.go b/internal/handlers/images.go new file mode 100644 index 0000000..39e13da --- /dev/null +++ b/internal/handlers/images.go @@ -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 "" + } +} diff --git a/internal/handlers/invites.go b/internal/handlers/invites.go new file mode 100644 index 0000000..4879e3d --- /dev/null +++ b/internal/handlers/invites.go @@ -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}) +} diff --git a/internal/handlers/languages.go b/internal/handlers/languages.go new file mode 100644 index 0000000..80ee67e --- /dev/null +++ b/internal/handlers/languages.go @@ -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 +} diff --git a/internal/handlers/leaderboards.go b/internal/handlers/leaderboards.go new file mode 100644 index 0000000..686b6ef --- /dev/null +++ b/internal/handlers/leaderboards.go @@ -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 +} diff --git a/internal/handlers/notifications.go b/internal/handlers/notifications.go new file mode 100644 index 0000000..4de5c1d --- /dev/null +++ b/internal/handlers/notifications.go @@ -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(¬ifications). + 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(¬ifications[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 + } + } + } +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..f482eaa --- /dev/null +++ b/internal/handlers/users.go @@ -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"}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..b5af0f6 --- /dev/null +++ b/internal/middleware/auth.go @@ -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"}) + } +} diff --git a/internal/migrations/migrate.go b/internal/migrations/migrate.go new file mode 100644 index 0000000..dfeb367 --- /dev/null +++ b/internal/migrations/migrate.go @@ -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 +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..c6d8ab0 --- /dev/null +++ b/internal/models/models.go @@ -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"` +} diff --git a/internal/models/transaction.go b/internal/models/transaction.go new file mode 100644 index 0000000..fd86ad9 --- /dev/null +++ b/internal/models/transaction.go @@ -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"` +} diff --git a/internal/wallet/errors.go b/internal/wallet/errors.go new file mode 100644 index 0000000..7469d40 --- /dev/null +++ b/internal/wallet/errors.go @@ -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") +) diff --git a/internal/wallet/service.go b/internal/wallet/service.go new file mode 100644 index 0000000..80b85dd --- /dev/null +++ b/internal/wallet/service.go @@ -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 +} diff --git a/proto/wallet.proto b/proto/wallet.proto new file mode 100644 index 0000000..6dd2a1c --- /dev/null +++ b/proto/wallet.proto @@ -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 +} \ No newline at end of file