init
This commit is contained in:
commit
b340a339c3
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_USER=game_admin_user
|
||||
DB_PASSWORD=game_admin_password
|
||||
DB_NAME=game_admin
|
||||
ADMINER_PORT=8081
|
||||
DEFAULT_ADMIN_EMAIL=admin@admin.com
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
JWT_SECRET=change-this-to-a-random-secret-key
|
||||
SERVER_PORT=8080
|
||||
GRPC_PORT=50051
|
||||
GRPC_HMAC_SECRET=super_secret_key
|
||||
BASE_URL=http://localhost:8080
|
||||
UPLOAD_DIR=uploads
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE and OS files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Go build artifacts
|
||||
bin/
|
||||
dist/
|
||||
build/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Logs and coverage
|
||||
*.log
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# Local runtime data
|
||||
uploads/
|
||||
tmp/
|
||||
temp/
|
||||
698
API.md
Normal file
698
API.md
Normal file
@ -0,0 +1,698 @@
|
||||
# Backend API
|
||||
|
||||
Base URL: `http://localhost:8080`
|
||||
|
||||
All protected endpoints require:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Public:
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/register`
|
||||
- `GET /api/invites/validate/:code`
|
||||
- Authorized users:
|
||||
- `GET /api/auth/me`
|
||||
- read endpoints for games, modules, languages, leaderboards, notifications
|
||||
- `admin` / `moderator`:
|
||||
- create/update/delete for games, modules inside games, variables, notifications, leaderboards, groups, languages, invites, image uploads
|
||||
|
||||
## Auth
|
||||
|
||||
### Login
|
||||
|
||||
`POST /api/auth/login`
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "admin@admin.com",
|
||||
"password": "admin123"
|
||||
}
|
||||
```
|
||||
|
||||
### Register
|
||||
|
||||
`POST /api/auth/register`
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"username": "player1",
|
||||
"password": "secret123",
|
||||
"invite_code": "abcd1234"
|
||||
}
|
||||
```
|
||||
|
||||
### Current User
|
||||
|
||||
`GET /api/auth/me`
|
||||
|
||||
## Games
|
||||
|
||||
### List Games
|
||||
|
||||
`GET /api/games`
|
||||
|
||||
Each game includes connected modules.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Black & White",
|
||||
"slug": "b&d",
|
||||
"description": "Sandbox",
|
||||
"image_url": "",
|
||||
"is_active": true,
|
||||
"modules": [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "variables",
|
||||
"name": "Variables"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"key": "notification",
|
||||
"name": "Notification"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Supports search by game `name` or `slug`:
|
||||
|
||||
- `GET /api/games?search=black`
|
||||
- `GET /api/games?search=b&d`
|
||||
|
||||
### Get Game
|
||||
|
||||
`GET /api/games/:id`
|
||||
|
||||
Useful when client is inside a module page and needs the current game `slug` to restore game search state on exit.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Black & White",
|
||||
"slug": "b&d",
|
||||
"description": "Sandbox",
|
||||
"image_url": "",
|
||||
"is_active": true,
|
||||
"modules": [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "variables",
|
||||
"name": "Variables"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"key": "notification",
|
||||
"name": "Notification"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Create Game
|
||||
|
||||
`POST /api/games`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Game",
|
||||
"slug": "my-game",
|
||||
"description": "Description",
|
||||
"image_url": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Update Game
|
||||
|
||||
`PUT /api/games/:id`
|
||||
|
||||
### Delete Game
|
||||
|
||||
`DELETE /api/games/:id`
|
||||
|
||||
## Modules
|
||||
|
||||
### List Available Modules
|
||||
|
||||
`GET /api/modules`
|
||||
|
||||
Default modules:
|
||||
|
||||
- `variables`
|
||||
- `notification`
|
||||
- `leaderboard`
|
||||
|
||||
### List Game Modules
|
||||
|
||||
`GET /api/games/:id/modules`
|
||||
|
||||
### Connect Module To Game
|
||||
|
||||
`POST /api/games/:id/modules`
|
||||
|
||||
```json
|
||||
{
|
||||
"module_key": "variables"
|
||||
}
|
||||
```
|
||||
|
||||
### Disconnect Module From Game
|
||||
|
||||
`DELETE /api/games/:id/modules/:module_key`
|
||||
|
||||
## Languages
|
||||
|
||||
Default seeded languages:
|
||||
|
||||
- `en` / `English`
|
||||
- `ru` / `Русский`
|
||||
|
||||
### List Languages
|
||||
|
||||
`GET /api/languages`
|
||||
|
||||
### Get Language
|
||||
|
||||
`GET /api/languages/:id`
|
||||
|
||||
### Create Language
|
||||
|
||||
`POST /api/languages`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "de",
|
||||
"name": "Deutsch"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Language
|
||||
|
||||
`PUT /api/languages/:id`
|
||||
|
||||
### Delete Language
|
||||
|
||||
`DELETE /api/languages/:id`
|
||||
|
||||
## User-Game Connections
|
||||
|
||||
### Search Users In Game
|
||||
|
||||
`GET /api/games/:id/users?search=...`
|
||||
|
||||
Returns only users connected to game `:id`.
|
||||
|
||||
Search behavior:
|
||||
|
||||
- if `search` is an integer, search by user `id`
|
||||
- otherwise search by `username` or `email`
|
||||
|
||||
Examples:
|
||||
|
||||
- `GET /api/games/5/users`
|
||||
- `GET /api/games/5/users?search=12`
|
||||
- `GET /api/games/5/users?search=denis`
|
||||
- `GET /api/games/5/users?search=denis@example.com`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 12,
|
||||
"email": "denis@example.com",
|
||||
"username": "denis",
|
||||
"role": "user",
|
||||
"is_active": true,
|
||||
"created_at": "2026-03-23T20:00:00Z",
|
||||
"updated_at": "2026-03-23T20:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### List User Games
|
||||
|
||||
`GET /api/users/:id/games`
|
||||
|
||||
### Connect Game To User
|
||||
|
||||
`POST /api/users/:id/games`
|
||||
|
||||
```json
|
||||
{
|
||||
"game_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Disconnect Game From User
|
||||
|
||||
`DELETE /api/users/:id/games/:game_id`
|
||||
|
||||
## Variables
|
||||
|
||||
All variable endpoints work only if the game has module `variables`.
|
||||
|
||||
### List Variables
|
||||
|
||||
`GET /api/games/:id/variables`
|
||||
|
||||
### Create Variable
|
||||
|
||||
`POST /api/games/:id/variables`
|
||||
|
||||
Number variable:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "max_bet",
|
||||
"type": "number",
|
||||
"number_value": 100
|
||||
}
|
||||
```
|
||||
|
||||
String variable:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "welcome_text",
|
||||
"type": "string",
|
||||
"string_value": "hello"
|
||||
}
|
||||
```
|
||||
|
||||
Table variable with numbers:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "rates",
|
||||
"type": "table",
|
||||
"table_value_type": "number",
|
||||
"items": [
|
||||
{ "key": "bronze", "number_value": 1.1 },
|
||||
{ "key": "silver", "number_value": 1.3 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Table variable with strings:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "titles",
|
||||
"type": "table",
|
||||
"table_value_type": "string",
|
||||
"items": [
|
||||
{ "key": "bronze", "string_value": "Bronze" },
|
||||
{ "key": "silver", "string_value": "Silver" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Vector variable with numbers:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "steps",
|
||||
"type": "vector",
|
||||
"table_value_type": "number",
|
||||
"items": [
|
||||
{ "index": 0, "number_value": 10 },
|
||||
{ "index": 1, "number_value": 20 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Vector variable with strings:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "messages",
|
||||
"type": "vector",
|
||||
"table_value_type": "string",
|
||||
"items": [
|
||||
{ "index": 0, "string_value": "hello" },
|
||||
{ "index": 1, "string_value": "world" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Variable
|
||||
|
||||
`PUT /api/games/:id/variables/:var_id`
|
||||
|
||||
### Delete Variable
|
||||
|
||||
`DELETE /api/games/:id/variables/:var_id`
|
||||
|
||||
### Variable Rules
|
||||
|
||||
- `type = number` uses only `number_value`
|
||||
- `type = string` uses only `string_value`
|
||||
- `type = table` uses only `table_value_type` and `items`
|
||||
- `type = vector` uses only `table_value_type` and `items`
|
||||
- all table items must use the same value type
|
||||
- all vector items must use `index` instead of `key`
|
||||
- vector indexes must be unique and `>= 0`
|
||||
- variable `key` is unique inside one game
|
||||
|
||||
## Images
|
||||
|
||||
### Upload Image
|
||||
|
||||
`POST /api/images`
|
||||
|
||||
Content type: `multipart/form-data`
|
||||
|
||||
Form field:
|
||||
|
||||
- `file`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
|
||||
"path": "/uploads/images/f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
|
||||
"url": "http://localhost:8080/uploads/images/f6e7b0b2-6f8d-4a33-8fc8-2a2f8f6d8c4b.png",
|
||||
"content_type": "image/png"
|
||||
}
|
||||
```
|
||||
|
||||
Static file access:
|
||||
|
||||
`GET /uploads/images/:filename`
|
||||
|
||||
## Notifications
|
||||
|
||||
All notification endpoints work only if the game has module `notification`.
|
||||
|
||||
Notification structure:
|
||||
|
||||
- one `notification` has:
|
||||
- `name`
|
||||
- multilingual `descriptions`
|
||||
- shared custom `variables`
|
||||
- many `entries`
|
||||
- each `entry` has its own:
|
||||
- `time_second`
|
||||
- `image`
|
||||
- `login`
|
||||
- values for all shared custom variables
|
||||
|
||||
Macros returned by API always include:
|
||||
|
||||
- `{{time_second}}`
|
||||
- `{{image}}`
|
||||
- `{{login}}`
|
||||
- all custom variable keys
|
||||
|
||||
### List Notifications
|
||||
|
||||
`GET /api/games/:id/notifications`
|
||||
|
||||
### Get Notification
|
||||
|
||||
`GET /api/games/:id/notifications/:notification_id`
|
||||
|
||||
### Create Notification
|
||||
|
||||
`POST /api/games/:id/notifications`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Welcome bonus",
|
||||
"descriptions": [
|
||||
{
|
||||
"language_id": 1,
|
||||
"description": "Hello {{login}}, promo {{promo_code}} after {{time_second}} seconds"
|
||||
},
|
||||
{
|
||||
"language_id": 2,
|
||||
"description": "Привет {{login}}, промокод {{promo_code}} через {{time_second}} секунд"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{ "key": "promo_code" },
|
||||
{ "key": "reward" }
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"time_second": 30,
|
||||
"image": "http://localhost:8080/uploads/images/a.png",
|
||||
"login": "Denis",
|
||||
"variables": [
|
||||
{ "key": "promo_code", "value": "WELCOME30" },
|
||||
{ "key": "reward", "value": "100" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"time_second": 60,
|
||||
"image": "http://localhost:8080/uploads/images/b.png",
|
||||
"login": "Alex",
|
||||
"variables": [
|
||||
{ "key": "promo_code", "value": "WELCOME60" },
|
||||
{ "key": "reward", "value": "200" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Notification
|
||||
|
||||
`PUT /api/games/:id/notifications/:notification_id`
|
||||
|
||||
### Delete Notification
|
||||
|
||||
`DELETE /api/games/:id/notifications/:notification_id`
|
||||
|
||||
### Notification Rules
|
||||
|
||||
- `name` is required
|
||||
- `descriptions` must contain at least one item
|
||||
- `entries` must contain at least one item
|
||||
- one `language_id` cannot repeat inside `descriptions`
|
||||
- top-level `variables` define the shared custom variable set
|
||||
- reserved variable keys are forbidden:
|
||||
- `time_second`
|
||||
- `image`
|
||||
- `login`
|
||||
- each entry must contain:
|
||||
- `time_second >= 0`
|
||||
- `image`
|
||||
- `login`
|
||||
- full set of custom variable values
|
||||
- entry variable keys must exactly match top-level custom variable keys
|
||||
|
||||
## Leaderboards
|
||||
|
||||
All leaderboard endpoints work only if the game has module `leaderboard`.
|
||||
|
||||
### List Game Leaderboards
|
||||
|
||||
`GET /api/games/:id/leaderboards`
|
||||
|
||||
### Get Leaderboard
|
||||
|
||||
`GET /api/games/:id/leaderboards/:leaderboard_id`
|
||||
|
||||
### Create Leaderboard
|
||||
|
||||
`POST /api/games/:id/leaderboards`
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "top_balance",
|
||||
"name": "Top Balance",
|
||||
"sort_order": "desc",
|
||||
"period_type": "all_time",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
`sort_order`:
|
||||
|
||||
- `asc`
|
||||
- `desc`
|
||||
|
||||
`period_type`:
|
||||
|
||||
- `all_time`
|
||||
- `daily`
|
||||
- `weekly`
|
||||
- `monthly`
|
||||
|
||||
### Update Leaderboard
|
||||
|
||||
`PUT /api/games/:id/leaderboards/:leaderboard_id`
|
||||
|
||||
### Delete Leaderboard
|
||||
|
||||
`DELETE /api/games/:id/leaderboards/:leaderboard_id`
|
||||
|
||||
### List Leaderboard Groups
|
||||
|
||||
`GET /api/leaderboards/:leaderboard_id/groups`
|
||||
|
||||
### Create Group
|
||||
|
||||
`POST /api/leaderboards/:leaderboard_id/groups`
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "vip",
|
||||
"name": "VIP",
|
||||
"is_default": false
|
||||
}
|
||||
```
|
||||
|
||||
### Update Group
|
||||
|
||||
`PUT /api/leaderboard-groups/:group_id`
|
||||
|
||||
### Delete Group
|
||||
|
||||
`DELETE /api/leaderboard-groups/:group_id`
|
||||
|
||||
### Add Group Member
|
||||
|
||||
`POST /api/leaderboard-groups/:group_id/members`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 12
|
||||
}
|
||||
```
|
||||
|
||||
User must already be connected to the same game.
|
||||
|
||||
### Delete Group Member
|
||||
|
||||
`DELETE /api/leaderboard-groups/:group_id/members/:user_id`
|
||||
|
||||
### Save Score
|
||||
|
||||
`POST /api/leaderboards/:leaderboard_id/scores`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_game_id": 5,
|
||||
"score": 1200
|
||||
}
|
||||
```
|
||||
|
||||
If a score already exists for this user in this leaderboard, it is updated.
|
||||
|
||||
### Get Rankings
|
||||
|
||||
`GET /api/leaderboards/:leaderboard_id/rankings`
|
||||
|
||||
Query params:
|
||||
|
||||
- `group_id` optional
|
||||
- `limit` optional, default `50`, max `200`
|
||||
|
||||
Example:
|
||||
|
||||
`GET /api/leaderboards/1/rankings?group_id=2&limit=20`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"leaderboard": {
|
||||
"id": 1,
|
||||
"game_id": 5,
|
||||
"key": "top_balance",
|
||||
"name": "Top Balance",
|
||||
"sort_order": "desc",
|
||||
"period_type": "all_time",
|
||||
"is_active": true
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"rank": 1,
|
||||
"user_id": 12,
|
||||
"user_game_id": 5,
|
||||
"username": "arnold",
|
||||
"score": 1200
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"user_id": 17,
|
||||
"user_game_id": 8,
|
||||
"username": "german",
|
||||
"score": 950
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Leaderboard Rules
|
||||
|
||||
- leaderboard `key` is unique inside one game
|
||||
- group `key` is unique inside one leaderboard
|
||||
- score is stored once per `leaderboard_id + user_game_id`
|
||||
- groups do not duplicate scores, they only filter ranking members
|
||||
|
||||
## Balance
|
||||
|
||||
### Top Up Balance
|
||||
|
||||
`POST /api/user-games/:ug_id/topup`
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 100,
|
||||
"comment": "manual topup"
|
||||
}
|
||||
```
|
||||
|
||||
### List Transactions
|
||||
|
||||
`GET /api/user-games/:ug_id/transactions`
|
||||
|
||||
## Invites
|
||||
|
||||
### Validate Invite
|
||||
|
||||
`GET /api/invites/validate/:code`
|
||||
|
||||
### Create Invite
|
||||
|
||||
`POST /api/invites`
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "user",
|
||||
"max_uses": 10
|
||||
}
|
||||
```
|
||||
|
||||
### List Invites
|
||||
|
||||
`GET /api/invites`
|
||||
|
||||
### Delete Invite
|
||||
|
||||
`DELETE /api/invites/:code`
|
||||
|
||||
## Common Error Patterns
|
||||
|
||||
- `400` invalid path param or invalid request body
|
||||
- `401` invalid or missing token
|
||||
- `403` module is not enabled for the target game, or role is insufficient
|
||||
- `404` entity not found
|
||||
- `409` duplicate key / duplicate connection / duplicate membership
|
||||
232
AUTH_ARCHITECTURE.md
Normal file
232
AUTH_ARCHITECTURE.md
Normal file
@ -0,0 +1,232 @@
|
||||
# Auth Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
One user should authenticate once in `master` and then access other game services without re-login.
|
||||
|
||||
For this project, the recommended approach is:
|
||||
|
||||
- `master` is the single authentication provider
|
||||
- user authentication uses JWT access tokens
|
||||
- game services validate the same user JWT
|
||||
- internal service-to-service calls use a separate auth mechanism
|
||||
|
||||
Do not use one shared secret or one "same key for everything" approach for both users and services.
|
||||
|
||||
## Recommended Model
|
||||
|
||||
### 1. User authentication
|
||||
|
||||
The user authenticates in `master`:
|
||||
|
||||
- via Telegram `initData` for TG game/webapp
|
||||
- or via regular login if needed for admin panel
|
||||
|
||||
`master` verifies the user and issues:
|
||||
|
||||
- short-lived `access token` JWT
|
||||
- optionally a `refresh token`
|
||||
|
||||
This `access token` is then sent to all game services in:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 2. Validation in other services
|
||||
|
||||
Each game service:
|
||||
|
||||
- does not log the user in again
|
||||
- does not store the user's password or Telegram auth secret
|
||||
- only validates the JWT from `master`
|
||||
|
||||
This gives SSO behavior across all games and services.
|
||||
|
||||
### 3. Service-to-service authentication
|
||||
|
||||
Calls between backend services should use a separate mechanism:
|
||||
|
||||
- service JWT
|
||||
- or HMAC
|
||||
- or mTLS
|
||||
|
||||
Do not rely on a user JWT as the only trust mechanism between services.
|
||||
|
||||
## Why This Is Better
|
||||
|
||||
- the user logs in once
|
||||
- all services trust one source of identity
|
||||
- auth logic stays in one place
|
||||
- compromised game service should not be able to mint user tokens
|
||||
- easier role and permission management
|
||||
|
||||
## Token Strategy
|
||||
|
||||
### Access token
|
||||
|
||||
Use a short lifetime:
|
||||
|
||||
- `15m` to `30m` for user access token
|
||||
|
||||
Include claims like:
|
||||
|
||||
- `sub`: user ID
|
||||
- `role`: `user`, `admin`, `moderator`
|
||||
- `iss`: issuer, for example `master-auth`
|
||||
- `aud`: target audience, for example `game-services`
|
||||
- `exp`
|
||||
- `iat`
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "123",
|
||||
"role": "user",
|
||||
"iss": "master-auth",
|
||||
"aud": ["game-services"],
|
||||
"iat": 1735689600,
|
||||
"exp": 1735690500
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh token
|
||||
|
||||
Use if you want silent re-login:
|
||||
|
||||
- lifetime `7d` to `30d`
|
||||
- store server-side or make it revocable
|
||||
|
||||
Refresh token should be handled only by `master`.
|
||||
|
||||
## Signing Algorithm
|
||||
|
||||
Recommended:
|
||||
|
||||
- `RS256` or `EdDSA`
|
||||
|
||||
Why:
|
||||
|
||||
- `master` signs with a private key
|
||||
- other services verify with a public key
|
||||
- game services cannot issue tokens even if compromised
|
||||
|
||||
Avoid using one shared `HS256` secret across many services if you plan to scale.
|
||||
|
||||
## Telegram Login Flow
|
||||
|
||||
### Option A. Telegram WebApp / Mini App
|
||||
|
||||
Flow:
|
||||
|
||||
1. Client gets `initData` from Telegram.
|
||||
2. Client sends `initData` to `master`.
|
||||
3. `master` verifies Telegram signature.
|
||||
4. `master` finds or creates the user.
|
||||
5. `master` issues JWT access token.
|
||||
6. Client uses that JWT in all other services.
|
||||
|
||||
### Option B. Telegram bot deep-link or custom auth
|
||||
|
||||
Same principle:
|
||||
|
||||
1. Telegram proves identity to `master`.
|
||||
2. `master` converts that identity into your internal user.
|
||||
3. `master` issues your platform JWT.
|
||||
|
||||
Telegram should only be the initial identity proof. After that, your platform should use its own JWT.
|
||||
|
||||
## Authorization Model
|
||||
|
||||
Authentication and authorization should be separated:
|
||||
|
||||
- authentication: who the user is
|
||||
- authorization: what the user can do
|
||||
|
||||
Minimum authorization data in JWT:
|
||||
|
||||
- user ID
|
||||
- role
|
||||
|
||||
If needed later, add:
|
||||
|
||||
- game access list
|
||||
- scopes
|
||||
- tenant/project ID
|
||||
|
||||
Do not put large mutable permission lists into JWT if they change often.
|
||||
|
||||
## How This Fits Current Project
|
||||
|
||||
Current state in this repo:
|
||||
|
||||
- HTTP API already uses JWT
|
||||
- gRPC wallet currently uses HMAC metadata
|
||||
|
||||
Recommended target state:
|
||||
|
||||
- keep JWT for user-facing HTTP and user-facing gRPC
|
||||
- keep HMAC or service JWT for trusted backend-to-backend calls
|
||||
|
||||
That means:
|
||||
|
||||
- admin panel and game client use user JWT
|
||||
- wallet or internal processors may still use HMAC/service auth for internal calls
|
||||
|
||||
## Practical Architecture
|
||||
|
||||
### Public auth endpoints in `master`
|
||||
|
||||
Suggested endpoints:
|
||||
|
||||
- `POST /api/auth/telegram`
|
||||
- `POST /api/auth/refresh`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
|
||||
### Shared verification in services
|
||||
|
||||
Every service should have middleware/interceptor that:
|
||||
|
||||
- reads `Authorization: Bearer <token>`
|
||||
- verifies signature
|
||||
- checks `iss`, `aud`, `exp`
|
||||
- extracts `sub` and `role`
|
||||
- places user context into request context
|
||||
|
||||
## Recommended Rollout For This Repo
|
||||
|
||||
### Stage 1
|
||||
|
||||
- keep existing HTTP JWT auth
|
||||
- add Telegram login endpoint in `master`
|
||||
- issue JWT after Telegram verification
|
||||
|
||||
### Stage 2
|
||||
|
||||
- add JWT verification middleware for any external game-facing services
|
||||
- keep current gRPC HMAC only for internal trusted integrations
|
||||
|
||||
### Stage 3
|
||||
|
||||
- if wallet becomes user-facing over gRPC, add JWT gRPC interceptor
|
||||
- optionally keep HMAC/service JWT for internal callers only
|
||||
|
||||
## What Not To Do
|
||||
|
||||
- do not make each game service perform Telegram login separately
|
||||
- do not duplicate user auth logic in every service
|
||||
- do not use one shared secret for both user tokens and service authentication
|
||||
- do not issue long-lived access tokens without refresh/revocation strategy
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Best approach for your case:
|
||||
|
||||
- authenticate the player once in `master`
|
||||
- issue a platform JWT there
|
||||
- reuse that JWT across all game services
|
||||
- use separate service authentication for internal backend calls
|
||||
|
||||
This is the cleanest and safest SSO model for a TG game ecosystem.
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM golang:1.21-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/main.go
|
||||
|
||||
FROM alpine:3.18
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
164
cmd/main.go
Normal file
164
cmd/main.go
Normal file
@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
walletpb "game-admin/gen"
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/grpcserver"
|
||||
"game-admin/internal/handlers"
|
||||
"game-admin/internal/middleware"
|
||||
"game-admin/internal/migrations"
|
||||
"game-admin/internal/models"
|
||||
"game-admin/internal/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
"github.com/uptrace/bun/driver/pgdriver"
|
||||
"github.com/uptrace/bun/extra/bundebug"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
|
||||
|
||||
connector := pgdriver.NewConnector(pgdriver.WithDSN(dsn))
|
||||
sqldb := sql.OpenDB(connector)
|
||||
|
||||
db := bun.NewDB(sqldb, pgdialect.New())
|
||||
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(cfg.UploadDir, "images"), 0o755); err != nil {
|
||||
log.Fatal("Upload directory init failed:", err)
|
||||
}
|
||||
|
||||
if err := migrations.Migrate(context.Background(), db, cfg); err != nil {
|
||||
log.Fatal("Migration failed:", err)
|
||||
}
|
||||
|
||||
authH := handlers.NewAuthHandler(db, cfg)
|
||||
userH := handlers.NewUserHandler(db, cfg)
|
||||
gameH := handlers.NewGameHandler(db, cfg)
|
||||
inviteH := handlers.NewInviteHandler(db, cfg)
|
||||
languageH := handlers.NewLanguageHandler(db, cfg)
|
||||
imageH := handlers.NewImageHandler(cfg)
|
||||
walletService := wallet.NewService(db)
|
||||
|
||||
r := gin.Default()
|
||||
r.Static("/uploads", cfg.UploadDir)
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", authH.Login)
|
||||
api.POST("/auth/register", authH.Register)
|
||||
api.GET("/invites/validate/:code", inviteH.Validate)
|
||||
}
|
||||
|
||||
auth := api.Group("", middleware.AuthRequired(cfg))
|
||||
{
|
||||
auth.GET("/auth/me", authH.Me)
|
||||
|
||||
auth.GET("/games", gameH.List)
|
||||
auth.GET("/games/:id", gameH.GetByID)
|
||||
auth.GET("/modules", gameH.ListModules)
|
||||
auth.GET("/languages", languageH.List)
|
||||
auth.GET("/languages/:id", languageH.GetByID)
|
||||
auth.GET("/games/:id/leaderboards", gameH.ListLeaderboards)
|
||||
auth.GET("/games/:id/leaderboards/:leaderboard_id", gameH.GetLeaderboard)
|
||||
auth.GET("/leaderboards/:leaderboard_id/groups", gameH.ListLeaderboardGroups)
|
||||
auth.GET("/leaderboards/:leaderboard_id/rankings", gameH.GetLeaderboardRankings)
|
||||
auth.GET("/games/:id/notifications", gameH.ListNotifications)
|
||||
auth.GET("/games/:id/notifications/:notification_id", gameH.GetNotification)
|
||||
|
||||
admin := auth.Group("", middleware.RoleRequired(models.RoleAdmin, models.RoleModerator))
|
||||
{
|
||||
admin.GET("/users", userH.List)
|
||||
admin.GET("/users/:id", userH.GetByID)
|
||||
admin.GET("/games/:id/users", userH.ListByGame)
|
||||
admin.PATCH("/users/:id/role", userH.UpdateRole)
|
||||
admin.PATCH("/users/:id/toggle-active", userH.ToggleActive)
|
||||
|
||||
admin.POST("/games", gameH.Create)
|
||||
admin.PUT("/games/:id", gameH.Update)
|
||||
admin.DELETE("/games/:id", gameH.Delete)
|
||||
admin.GET("/games/:id/modules", gameH.ListGameModules)
|
||||
admin.POST("/games/:id/modules", gameH.ConnectModule)
|
||||
admin.DELETE("/games/:id/modules/:module_key", gameH.DisconnectModule)
|
||||
admin.POST("/games/:id/leaderboards", gameH.CreateLeaderboard)
|
||||
admin.PUT("/games/:id/leaderboards/:leaderboard_id", gameH.UpdateLeaderboard)
|
||||
admin.DELETE("/games/:id/leaderboards/:leaderboard_id", gameH.DeleteLeaderboard)
|
||||
admin.POST("/leaderboards/:leaderboard_id/groups", gameH.CreateLeaderboardGroup)
|
||||
admin.PUT("/leaderboard-groups/:group_id", gameH.UpdateLeaderboardGroup)
|
||||
admin.DELETE("/leaderboard-groups/:group_id", gameH.DeleteLeaderboardGroup)
|
||||
admin.POST("/leaderboard-groups/:group_id/members", gameH.AddLeaderboardGroupMember)
|
||||
admin.DELETE("/leaderboard-groups/:group_id/members/:user_id", gameH.DeleteLeaderboardGroupMember)
|
||||
admin.POST("/leaderboards/:leaderboard_id/scores", gameH.UpsertLeaderboardScore)
|
||||
admin.POST("/games/:id/notifications", gameH.CreateNotification)
|
||||
admin.PUT("/games/:id/notifications/:notification_id", gameH.UpdateNotification)
|
||||
admin.DELETE("/games/:id/notifications/:notification_id", gameH.DeleteNotification)
|
||||
admin.GET("/games/:id/variables", gameH.ListVariables)
|
||||
admin.POST("/games/:id/variables", gameH.CreateVariable)
|
||||
admin.PUT("/games/:id/variables/:var_id", gameH.UpdateVariable)
|
||||
admin.DELETE("/games/:id/variables/:var_id", gameH.DeleteVariable)
|
||||
admin.POST("/images", imageH.Upload)
|
||||
|
||||
admin.POST("/languages", languageH.Create)
|
||||
admin.PUT("/languages/:id", languageH.Update)
|
||||
admin.DELETE("/languages/:id", languageH.Delete)
|
||||
|
||||
admin.GET("/users/:id/games", gameH.UserGames)
|
||||
admin.POST("/users/:id/games", gameH.ConnectGame)
|
||||
admin.DELETE("/users/:id/games/:game_id", gameH.DisconnectGame)
|
||||
|
||||
admin.POST("/user-games/:ug_id/topup", gameH.TopUp)
|
||||
admin.GET("/user-games/:ug_id/transactions", gameH.Transactions)
|
||||
|
||||
admin.POST("/invites", inviteH.Create)
|
||||
admin.GET("/invites", inviteH.List)
|
||||
admin.DELETE("/invites/:code", inviteH.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
grpcListener, err := net.Listen("tcp", ":"+cfg.GRPCPort)
|
||||
if err != nil {
|
||||
log.Fatal("gRPC listen error:", err)
|
||||
}
|
||||
|
||||
grpcSrv := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(grpcserver.HMACUnaryServerInterceptor(cfg.GRPCHMACSecret)),
|
||||
)
|
||||
walletpb.RegisterWalletServiceServer(grpcSrv, grpcserver.NewWalletServer(walletService))
|
||||
|
||||
go func() {
|
||||
log.Printf("gRPC server starting on :%s", cfg.GRPCPort)
|
||||
if err := grpcSrv.Serve(grpcListener); err != nil {
|
||||
log.Fatal("gRPC serve error:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("HTTP server starting on :%s", cfg.ServerPort)
|
||||
if err := r.Run(":" + cfg.ServerPort); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: backend-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-game_admin}
|
||||
POSTGRES_USER: ${DB_USER:-game_admin_user}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-game_admin_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-game_admin_user} -d ${DB_NAME:-game_admin}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
adminer:
|
||||
image: adminer:4
|
||||
container_name: backend-adminer
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "${ADMINER_PORT:-8081}:8080"
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: postgres
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
155
examples/client.example.txt
Normal file
155
examples/client.example.txt
Normal file
@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
walletpb "_gen"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
const (
|
||||
sharedSecret = "super_secret_key"
|
||||
serviceName = "game-service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
conn, err := grpc.Dial(
|
||||
"localhost:50051",
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithUnaryInterceptor(hmacUnaryClientInterceptor(sharedSecret, serviceName)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("dial error: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := walletpb.NewWalletServiceClient(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
1. reserve
|
||||
reserveResp, err := client.Reserve(ctx, &walletpb.ReserveRequest{
|
||||
RequestId: "req-1",
|
||||
UserId: "user-1",
|
||||
GameId: "slot-42",
|
||||
RoundId: "round-1001",
|
||||
Amount: 1000, 10.00
|
||||
Currency: "EUR",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("reserve error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("reserve: tx=%s status=%s available=%d reserved=%d",
|
||||
reserveResp.TransactionId,
|
||||
reserveResp.Status,
|
||||
reserveResp.AvailableBalance,
|
||||
reserveResp.ReservedBalance,
|
||||
)
|
||||
|
||||
Тут игра “сыграла”. Допустим пользователь выиграл 25.00
|
||||
confirmResp, err := client.Confirm(ctx, &walletpb.ConfirmRequest{
|
||||
RequestId: "req-2",
|
||||
TransactionId: reserveResp.TransactionId,
|
||||
RoundId: "round-1001",
|
||||
WinAmount: 2500,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("confirm error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("confirm: tx=%s status=%s available=%d reserved=%d",
|
||||
confirmResp.TransactionId,
|
||||
confirmResp.Status,
|
||||
confirmResp.AvailableBalance,
|
||||
confirmResp.ReservedBalance,
|
||||
)
|
||||
|
||||
txResp, err := client.GetTransaction(ctx, &walletpb.GetTransactionRequest{
|
||||
TransactionId: reserveResp.TransactionId,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("get tx error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("transaction: id=%s status=%s amount=%d win=%d",
|
||||
txResp.TransactionId, txResp.Status, txResp.Amount, txResp.WinAmount)
|
||||
}
|
||||
|
||||
---------- HMAC client interceptor ----------
|
||||
|
||||
func hmacUnaryClientInterceptor(secret, service string) grpc.UnaryClientInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
method string,
|
||||
req any,
|
||||
reply any,
|
||||
cc *grpc.ClientConn,
|
||||
invoker grpc.UnaryInvoker,
|
||||
opts ...grpc.CallOption,
|
||||
) error {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
md = metadata.New(nil)
|
||||
} else {
|
||||
md = md.Copy()
|
||||
}
|
||||
|
||||
md.Set("x-service-name", service)
|
||||
md.Set("x-timestamp", timestamp)
|
||||
|
||||
payload := buildSigningPayload(service, method, timestamp, md)
|
||||
signature := computeHMAC(payload, secret)
|
||||
|
||||
md.Set("x-signature", signature)
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
return invoker(ctx, method, req, reply, cc, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
func computeHMAC(payload, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func buildSigningPayload(serviceName, method, timestamp string, md metadata.MD) string {
|
||||
var parts []string
|
||||
parts = append(parts,
|
||||
"service="+serviceName,
|
||||
"method="+method,
|
||||
"timestamp="+timestamp,
|
||||
)
|
||||
|
||||
var keys []string
|
||||
for k := range md {
|
||||
if strings.ToLower(k) == "x-signature" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(k), ":") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
vals := md.Get(k)
|
||||
sort.Strings(vals)
|
||||
parts = append(parts, k+"="+strings.Join(vals, ","))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
685
gen/wallet.pb.go
Normal file
685
gen/wallet.pb.go
Normal file
@ -0,0 +1,685 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v7.34.1
|
||||
// source: proto/wallet.proto
|
||||
|
||||
package walletpb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ReserveRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
GameId string `protobuf:"bytes,3,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
|
||||
RoundId string `protobuf:"bytes,4,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
|
||||
Amount int64 `protobuf:"varint,5,opt,name=amount,proto3" json:"amount,omitempty"` // в минорных единицах, например cents
|
||||
Currency string `protobuf:"bytes,6,opt,name=currency,proto3" json:"currency,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) Reset() {
|
||||
*x = ReserveRequest{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ReserveRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ReserveRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ReserveRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ReserveRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetRequestId() string {
|
||||
if x != nil {
|
||||
return x.RequestId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetUserId() string {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetGameId() string {
|
||||
if x != nil {
|
||||
return x.GameId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetRoundId() string {
|
||||
if x != nil {
|
||||
return x.RoundId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ReserveRequest) GetCurrency() string {
|
||||
if x != nil {
|
||||
return x.Currency
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ReserveResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // reserved / rejected
|
||||
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
|
||||
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) Reset() {
|
||||
*x = ReserveResponse{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ReserveResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ReserveResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ReserveResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ReserveResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) GetAvailableBalance() int64 {
|
||||
if x != nil {
|
||||
return x.AvailableBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ReserveResponse) GetReservedBalance() int64 {
|
||||
if x != nil {
|
||||
return x.ReservedBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConfirmRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
RoundId string `protobuf:"bytes,3,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
|
||||
WinAmount int64 `protobuf:"varint,4,opt,name=win_amount,json=winAmount,proto3" json:"win_amount,omitempty"` // 0 если проигрыш
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) Reset() {
|
||||
*x = ConfirmRequest{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ConfirmRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ConfirmRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ConfirmRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ConfirmRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) GetRequestId() string {
|
||||
if x != nil {
|
||||
return x.RequestId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) GetRoundId() string {
|
||||
if x != nil {
|
||||
return x.RoundId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfirmRequest) GetWinAmount() int64 {
|
||||
if x != nil {
|
||||
return x.WinAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConfirmResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // confirmed / already_confirmed
|
||||
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
|
||||
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) Reset() {
|
||||
*x = ConfirmResponse{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ConfirmResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ConfirmResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ConfirmResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ConfirmResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) GetAvailableBalance() int64 {
|
||||
if x != nil {
|
||||
return x.AvailableBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ConfirmResponse) GetReservedBalance() int64 {
|
||||
if x != nil {
|
||||
return x.ReservedBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type CancelRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CancelRequest) Reset() {
|
||||
*x = CancelRequest{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CancelRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CancelRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CancelRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CancelRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *CancelRequest) GetRequestId() string {
|
||||
if x != nil {
|
||||
return x.RequestId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CancelRequest) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CancelRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // canceled / already_canceled
|
||||
AvailableBalance int64 `protobuf:"varint,3,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"`
|
||||
ReservedBalance int64 `protobuf:"varint,4,opt,name=reserved_balance,json=reservedBalance,proto3" json:"reserved_balance,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CancelResponse) Reset() {
|
||||
*x = CancelResponse{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CancelResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CancelResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CancelResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CancelResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CancelResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetAvailableBalance() int64 {
|
||||
if x != nil {
|
||||
return x.AvailableBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetReservedBalance() int64 {
|
||||
if x != nil {
|
||||
return x.ReservedBalance
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetTransactionRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetTransactionRequest) Reset() {
|
||||
*x = GetTransactionRequest{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetTransactionRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetTransactionRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetTransactionRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetTransactionRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetTransactionRequest) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *GetTransactionRequest) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetTransactionResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
RoundId string `protobuf:"bytes,3,opt,name=round_id,json=roundId,proto3" json:"round_id,omitempty"`
|
||||
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
WinAmount int64 `protobuf:"varint,5,opt,name=win_amount,json=winAmount,proto3" json:"win_amount,omitempty"`
|
||||
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` // reserved / confirmed / canceled
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) Reset() {
|
||||
*x = GetTransactionResponse{}
|
||||
mi := &file_proto_wallet_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetTransactionResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetTransactionResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_wallet_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetTransactionResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetTransactionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_proto_wallet_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetUserId() string {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetRoundId() string {
|
||||
if x != nil {
|
||||
return x.RoundId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetWinAmount() int64 {
|
||||
if x != nil {
|
||||
return x.WinAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GetTransactionResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_proto_wallet_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_wallet_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x12proto/wallet.proto\x12\x06wallet\"\xb0\x01\n" +
|
||||
"\x0eReserveRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x17\n" +
|
||||
"\agame_id\x18\x03 \x01(\tR\x06gameId\x12\x19\n" +
|
||||
"\bround_id\x18\x04 \x01(\tR\aroundId\x12\x16\n" +
|
||||
"\x06amount\x18\x05 \x01(\x03R\x06amount\x12\x1a\n" +
|
||||
"\bcurrency\x18\x06 \x01(\tR\bcurrency\"\xa8\x01\n" +
|
||||
"\x0fReserveResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
|
||||
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
|
||||
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\"\x90\x01\n" +
|
||||
"\x0eConfirmRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12%\n" +
|
||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x19\n" +
|
||||
"\bround_id\x18\x03 \x01(\tR\aroundId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"win_amount\x18\x04 \x01(\x03R\twinAmount\"\xa8\x01\n" +
|
||||
"\x0fConfirmResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
|
||||
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
|
||||
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\"m\n" +
|
||||
"\rCancelRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12%\n" +
|
||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x16\n" +
|
||||
"\x06reason\x18\x03 \x01(\tR\x06reason\"\xa7\x01\n" +
|
||||
"\x0eCancelResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12+\n" +
|
||||
"\x11available_balance\x18\x03 \x01(\x03R\x10availableBalance\x12)\n" +
|
||||
"\x10reserved_balance\x18\x04 \x01(\x03R\x0freservedBalance\">\n" +
|
||||
"\x15GetTransactionRequest\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\"\xc2\x01\n" +
|
||||
"\x16GetTransactionResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x19\n" +
|
||||
"\bround_id\x18\x03 \x01(\tR\aroundId\x12\x16\n" +
|
||||
"\x06amount\x18\x04 \x01(\x03R\x06amount\x12\x1d\n" +
|
||||
"\n" +
|
||||
"win_amount\x18\x05 \x01(\x03R\twinAmount\x12\x16\n" +
|
||||
"\x06status\x18\x06 \x01(\tR\x06status2\x91\x02\n" +
|
||||
"\rWalletService\x12:\n" +
|
||||
"\aReserve\x12\x16.wallet.ReserveRequest\x1a\x17.wallet.ReserveResponse\x12:\n" +
|
||||
"\aConfirm\x12\x16.wallet.ConfirmRequest\x1a\x17.wallet.ConfirmResponse\x127\n" +
|
||||
"\x06Cancel\x12\x15.wallet.CancelRequest\x1a\x16.wallet.CancelResponse\x12O\n" +
|
||||
"\x0eGetTransaction\x12\x1d.wallet.GetTransactionRequest\x1a\x1e.wallet.GetTransactionResponseB\x0fZ\rgen/;walletpbb\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_wallet_proto_rawDescOnce sync.Once
|
||||
file_proto_wallet_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_wallet_proto_rawDescGZIP() []byte {
|
||||
file_proto_wallet_proto_rawDescOnce.Do(func() {
|
||||
file_proto_wallet_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_wallet_proto_rawDesc), len(file_proto_wallet_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_wallet_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
||||
var file_proto_wallet_proto_goTypes = []any{
|
||||
(*ReserveRequest)(nil), // 0: wallet.ReserveRequest
|
||||
(*ReserveResponse)(nil), // 1: wallet.ReserveResponse
|
||||
(*ConfirmRequest)(nil), // 2: wallet.ConfirmRequest
|
||||
(*ConfirmResponse)(nil), // 3: wallet.ConfirmResponse
|
||||
(*CancelRequest)(nil), // 4: wallet.CancelRequest
|
||||
(*CancelResponse)(nil), // 5: wallet.CancelResponse
|
||||
(*GetTransactionRequest)(nil), // 6: wallet.GetTransactionRequest
|
||||
(*GetTransactionResponse)(nil), // 7: wallet.GetTransactionResponse
|
||||
}
|
||||
var file_proto_wallet_proto_depIdxs = []int32{
|
||||
0, // 0: wallet.WalletService.Reserve:input_type -> wallet.ReserveRequest
|
||||
2, // 1: wallet.WalletService.Confirm:input_type -> wallet.ConfirmRequest
|
||||
4, // 2: wallet.WalletService.Cancel:input_type -> wallet.CancelRequest
|
||||
6, // 3: wallet.WalletService.GetTransaction:input_type -> wallet.GetTransactionRequest
|
||||
1, // 4: wallet.WalletService.Reserve:output_type -> wallet.ReserveResponse
|
||||
3, // 5: wallet.WalletService.Confirm:output_type -> wallet.ConfirmResponse
|
||||
5, // 6: wallet.WalletService.Cancel:output_type -> wallet.CancelResponse
|
||||
7, // 7: wallet.WalletService.GetTransaction:output_type -> wallet.GetTransactionResponse
|
||||
4, // [4:8] is the sub-list for method output_type
|
||||
0, // [0:4] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_wallet_proto_init() }
|
||||
func file_proto_wallet_proto_init() {
|
||||
if File_proto_wallet_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_proto_rawDesc), len(file_proto_wallet_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 8,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_proto_wallet_proto_goTypes,
|
||||
DependencyIndexes: file_proto_wallet_proto_depIdxs,
|
||||
MessageInfos: file_proto_wallet_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_wallet_proto = out.File
|
||||
file_proto_wallet_proto_goTypes = nil
|
||||
file_proto_wallet_proto_depIdxs = nil
|
||||
}
|
||||
235
gen/wallet_grpc.pb.go
Normal file
235
gen/wallet_grpc.pb.go
Normal file
@ -0,0 +1,235 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v7.34.1
|
||||
// source: proto/wallet.proto
|
||||
|
||||
package walletpb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
WalletService_Reserve_FullMethodName = "/wallet.WalletService/Reserve"
|
||||
WalletService_Confirm_FullMethodName = "/wallet.WalletService/Confirm"
|
||||
WalletService_Cancel_FullMethodName = "/wallet.WalletService/Cancel"
|
||||
WalletService_GetTransaction_FullMethodName = "/wallet.WalletService/GetTransaction"
|
||||
)
|
||||
|
||||
// WalletServiceClient is the client API for WalletService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type WalletServiceClient interface {
|
||||
Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*ReserveResponse, error)
|
||||
Confirm(ctx context.Context, in *ConfirmRequest, opts ...grpc.CallOption) (*ConfirmResponse, error)
|
||||
Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelResponse, error)
|
||||
GetTransaction(ctx context.Context, in *GetTransactionRequest, opts ...grpc.CallOption) (*GetTransactionResponse, error)
|
||||
}
|
||||
|
||||
type walletServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewWalletServiceClient(cc grpc.ClientConnInterface) WalletServiceClient {
|
||||
return &walletServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*ReserveResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ReserveResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_Reserve_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) Confirm(ctx context.Context, in *ConfirmRequest, opts ...grpc.CallOption) (*ConfirmResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConfirmResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_Confirm_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) Cancel(ctx context.Context, in *CancelRequest, opts ...grpc.CallOption) (*CancelResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CancelResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_Cancel_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetTransaction(ctx context.Context, in *GetTransactionRequest, opts ...grpc.CallOption) (*GetTransactionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetTransactionResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetTransaction_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WalletServiceServer is the server API for WalletService service.
|
||||
// All implementations must embed UnimplementedWalletServiceServer
|
||||
// for forward compatibility.
|
||||
type WalletServiceServer interface {
|
||||
Reserve(context.Context, *ReserveRequest) (*ReserveResponse, error)
|
||||
Confirm(context.Context, *ConfirmRequest) (*ConfirmResponse, error)
|
||||
Cancel(context.Context, *CancelRequest) (*CancelResponse, error)
|
||||
GetTransaction(context.Context, *GetTransactionRequest) (*GetTransactionResponse, error)
|
||||
mustEmbedUnimplementedWalletServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedWalletServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedWalletServiceServer struct{}
|
||||
|
||||
func (UnimplementedWalletServiceServer) Reserve(context.Context, *ReserveRequest) (*ReserveResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Reserve not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) Confirm(context.Context, *ConfirmRequest) (*ConfirmResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Confirm not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) Cancel(context.Context, *CancelRequest) (*CancelResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Cancel not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetTransaction(context.Context, *GetTransactionRequest) (*GetTransactionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetTransaction not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
|
||||
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeWalletServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to WalletServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeWalletServiceServer interface {
|
||||
mustEmbedUnimplementedWalletServiceServer()
|
||||
}
|
||||
|
||||
func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedWalletServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&WalletService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _WalletService_Reserve_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReserveRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).Reserve(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_Reserve_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).Reserve(ctx, req.(*ReserveRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_Confirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConfirmRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).Confirm(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_Confirm_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).Confirm(ctx, req.(*ConfirmRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_Cancel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CancelRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).Cancel(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_Cancel_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).Cancel(ctx, req.(*CancelRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetTransaction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetTransactionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetTransaction(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetTransaction_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetTransaction(ctx, req.(*GetTransactionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "wallet.WalletService",
|
||||
HandlerType: (*WalletServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "Reserve",
|
||||
Handler: _WalletService_Reserve_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Confirm",
|
||||
Handler: _WalletService_Confirm_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Cancel",
|
||||
Handler: _WalletService_Cancel_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetTransaction",
|
||||
Handler: _WalletService_GetTransaction_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/wallet.proto",
|
||||
}
|
||||
51
go.mod
Normal file
51
go.mod
Normal file
@ -0,0 +1,51 @@
|
||||
module game-admin
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/uptrace/bun v1.1.16
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.1.16
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.16
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.16
|
||||
golang.org/x/crypto v0.21.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/fatih/color v1.15.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mellium.im/sasl v0.3.1 // indirect
|
||||
)
|
||||
123
go.sum
Normal file
123
go.sum
Normal file
@ -0,0 +1,123 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A=
|
||||
github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.1.16 h1:eUPZ+YCJ69BA+W1X1ZmpOJSkv1oYtinr0zCXf7zCo5g=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.1.16/go.mod h1:KQjfx/r6JM0OXfbv0rFrxAbdkPD7idK8VitnjIV9fZI=
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.16 h1:b/NiSXk6Ldw7KLfMLbOqIkm4odHd7QiNOCPLqPFJjK4=
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.16/go.mod h1:Rmfbc+7lx1z/umjMyAxkOHK81LgnGj71XC5YpA6k1vU=
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.16 h1:SgicRQGtnjhrIhlYOxdkOm1Em4s6HykmT3JblHnoTBM=
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.16/go.mod h1:SkiOkfUirBiO1Htc4s5bQKEq+JSeU1TkBVpMsPz2ePM=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
|
||||
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
52
internal/config/config.go
Normal file
52
internal/config/config.go
Normal file
@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DefaultAdminEmail string
|
||||
DefaultAdminUsername string
|
||||
DefaultAdminPassword string
|
||||
JWTSecret string
|
||||
ServerPort string
|
||||
GRPCPort string
|
||||
GRPCHMACSecret string
|
||||
BaseURL string
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
_ = godotenv.Load()
|
||||
|
||||
return &Config{
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", "game_admin_user"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "game_admin_password"),
|
||||
DBName: getEnv("DB_NAME", "game_admin"),
|
||||
DefaultAdminEmail: getEnv("DEFAULT_ADMIN_EMAIL", "admin@admin.com"),
|
||||
DefaultAdminUsername: getEnv("DEFAULT_ADMIN_USERNAME", "admin"),
|
||||
DefaultAdminPassword: getEnv("DEFAULT_ADMIN_PASSWORD", "admin123"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "super-secret-key-change-me"),
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
GRPCPort: getEnv("GRPC_PORT", "50051"),
|
||||
GRPCHMACSecret: getEnv("GRPC_HMAC_SECRET", "super_secret_key"),
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
||||
UploadDir: getEnv("UPLOAD_DIR", "uploads"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
94
internal/grpcserver/auth.go
Normal file
94
internal/grpcserver/auth.go
Normal file
@ -0,0 +1,94 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func HMACUnaryServerInterceptor(secret string) grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req any,
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (any, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
|
||||
serviceName := firstMD(md, "x-service-name")
|
||||
timestamp := firstMD(md, "x-timestamp")
|
||||
signature := firstMD(md, "x-signature")
|
||||
if serviceName == "" || timestamp == "" || signature == "" {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing auth metadata")
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid timestamp")
|
||||
}
|
||||
if delta := time.Now().Unix() - ts; delta > 60 || delta < -60 {
|
||||
return nil, status.Error(codes.Unauthenticated, "timestamp expired")
|
||||
}
|
||||
|
||||
payload := buildSigningPayload(serviceName, info.FullMethod, timestamp, md)
|
||||
expected := computeHMAC(payload, secret)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return nil, status.Error(codes.Unauthenticated, "bad signature")
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
func firstMD(md metadata.MD, key string) string {
|
||||
values := md.Get(key)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func computeHMAC(payload, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func buildSigningPayload(serviceName, method, timestamp string, md metadata.MD) string {
|
||||
parts := []string{
|
||||
"service=" + serviceName,
|
||||
"method=" + method,
|
||||
"timestamp=" + timestamp,
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(md))
|
||||
for key := range md {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if lowerKey == "x-signature" || strings.HasPrefix(lowerKey, ":") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
values := md.Get(key)
|
||||
sort.Strings(values)
|
||||
parts = append(parts, key+"="+strings.Join(values, ","))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
118
internal/grpcserver/wallet.go
Normal file
118
internal/grpcserver/wallet.go
Normal file
@ -0,0 +1,118 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
walletpb "game-admin/gen"
|
||||
"game-admin/internal/wallet"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type WalletServer struct {
|
||||
walletpb.UnimplementedWalletServiceServer
|
||||
|
||||
service *wallet.Service
|
||||
}
|
||||
|
||||
func NewWalletServer(service *wallet.Service) *WalletServer {
|
||||
return &WalletServer{service: service}
|
||||
}
|
||||
|
||||
func (s *WalletServer) Reserve(ctx context.Context, req *walletpb.ReserveRequest) (*walletpb.ReserveResponse, error) {
|
||||
transaction, responseStatus, balances, err := s.service.Reserve(
|
||||
ctx,
|
||||
req.GetRequestId(),
|
||||
req.GetUserId(),
|
||||
req.GetGameId(),
|
||||
req.GetRoundId(),
|
||||
req.GetAmount(),
|
||||
req.GetCurrency(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWalletError(err)
|
||||
}
|
||||
|
||||
return &walletpb.ReserveResponse{
|
||||
TransactionId: transaction.ID,
|
||||
Status: responseStatus,
|
||||
AvailableBalance: balances.Available,
|
||||
ReservedBalance: balances.Reserved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WalletServer) Confirm(ctx context.Context, req *walletpb.ConfirmRequest) (*walletpb.ConfirmResponse, error) {
|
||||
transaction, responseStatus, balances, err := s.service.Confirm(
|
||||
ctx,
|
||||
req.GetTransactionId(),
|
||||
req.GetRoundId(),
|
||||
req.GetWinAmount(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWalletError(err)
|
||||
}
|
||||
|
||||
return &walletpb.ConfirmResponse{
|
||||
TransactionId: transaction.ID,
|
||||
Status: responseStatus,
|
||||
AvailableBalance: balances.Available,
|
||||
ReservedBalance: balances.Reserved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WalletServer) Cancel(ctx context.Context, req *walletpb.CancelRequest) (*walletpb.CancelResponse, error) {
|
||||
transaction, responseStatus, balances, err := s.service.Cancel(ctx, req.GetTransactionId())
|
||||
if err != nil {
|
||||
return nil, mapWalletError(err)
|
||||
}
|
||||
|
||||
return &walletpb.CancelResponse{
|
||||
TransactionId: transaction.ID,
|
||||
Status: responseStatus,
|
||||
AvailableBalance: balances.Available,
|
||||
ReservedBalance: balances.Reserved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WalletServer) GetTransaction(ctx context.Context, req *walletpb.GetTransactionRequest) (*walletpb.GetTransactionResponse, error) {
|
||||
transaction, err := s.service.GetTransaction(ctx, req.GetTransactionId())
|
||||
if err != nil {
|
||||
return nil, mapWalletError(err)
|
||||
}
|
||||
|
||||
return &walletpb.GetTransactionResponse{
|
||||
TransactionId: transaction.ID,
|
||||
UserId: strconv.FormatInt(transaction.UserID, 10),
|
||||
RoundId: transaction.RoundID,
|
||||
Amount: transaction.Amount,
|
||||
WinAmount: transaction.WinAmount,
|
||||
Status: transaction.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapWalletError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, wallet.ErrInvalidAmount),
|
||||
errors.Is(err, wallet.ErrMissingRequiredFields),
|
||||
errors.Is(err, wallet.ErrInvalidUserID),
|
||||
errors.Is(err, wallet.ErrInvalidGameID),
|
||||
errors.Is(err, wallet.ErrMissingTransactionID),
|
||||
errors.Is(err, wallet.ErrMissingRoundID),
|
||||
errors.Is(err, wallet.ErrInvalidWinAmount),
|
||||
errors.Is(err, wallet.ErrRoundMismatch):
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, wallet.ErrAccountNotFound),
|
||||
errors.Is(err, wallet.ErrTransactionNotFound):
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, wallet.ErrInsufficientFunds),
|
||||
errors.Is(err, wallet.ErrAlreadyCanceled),
|
||||
errors.Is(err, wallet.ErrAlreadyConfirmed),
|
||||
errors.Is(err, wallet.ErrInvalidTransactionState):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
default:
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
}
|
||||
128
internal/handlers/auth.go
Normal file
128
internal/handlers/auth.go
Normal file
@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/middleware"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *bun.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *bun.DB, cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user := new(models.User)
|
||||
err := h.db.NewSelect().Model(user).Where("email = ?", req.Email).Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := middleware.GenerateToken(user, h.cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req models.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine role from invite code
|
||||
role := models.RoleUser
|
||||
if req.InviteCode != "" {
|
||||
invite := new(models.InviteLink)
|
||||
err := h.db.NewSelect().Model(invite).Where("code = ?", req.InviteCode).Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid invite code"})
|
||||
return
|
||||
}
|
||||
if invite.UsedCount >= invite.MaxUses {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invite code exhausted"})
|
||||
return
|
||||
}
|
||||
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invite code expired"})
|
||||
return
|
||||
}
|
||||
role = invite.Role
|
||||
|
||||
// Increment usage
|
||||
invite.UsedCount++
|
||||
_, _ = h.db.NewUpdate().Model(invite).Column("used_count").WherePK().Exec(c)
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = h.db.NewInsert().Model(user).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
token, _ := middleware.GenerateToken(user, h.cfg)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
user := new(models.User)
|
||||
err := h.db.NewSelect().Model(user).Where("id = ?", userID).Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
194
internal/handlers/game_modules.go
Normal file
194
internal/handlers/game_modules.go
Normal file
@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func (h *GameHandler) ListModules(c *gin.Context) {
|
||||
var modules []models.Module
|
||||
err := h.db.NewSelect().
|
||||
Model(&modules).
|
||||
OrderExpr("id ASC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, modules)
|
||||
}
|
||||
|
||||
func (h *GameHandler) ListGameModules(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.gameExists(c, gameID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
modulesByGame, err := h.loadModulesByGameIDs(c, []int64{gameID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, modulesByGame[gameID])
|
||||
}
|
||||
|
||||
func (h *GameHandler) ConnectModule(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.ConnectGameModuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.gameExists(c, gameID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
module := new(models.Module)
|
||||
err = h.db.NewSelect().
|
||||
Model(module).
|
||||
Where(`"key" = ?`, strings.TrimSpace(req.ModuleKey)).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
gameModule := &models.GameModule{
|
||||
GameID: gameID,
|
||||
ModuleID: module.ID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_, err = h.db.NewInsert().Model(gameModule).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Module already connected to game"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, module)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DisconnectModule(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
moduleKey := c.Param("module_key")
|
||||
module := new(models.Module)
|
||||
err = h.db.NewSelect().
|
||||
Model(module).
|
||||
Where(`"key" = ?`, moduleKey).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.db.NewDelete().
|
||||
Model((*models.GameModule)(nil)).
|
||||
Where("game_id = ? AND module_id = ?", gameID, module.ID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Module is not connected to game"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Module disconnected"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) attachModulesToGames(c *gin.Context, games []models.Game) error {
|
||||
if len(games) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
gameIDs := make([]int64, 0, len(games))
|
||||
for _, game := range games {
|
||||
gameIDs = append(gameIDs, game.ID)
|
||||
}
|
||||
|
||||
modulesByGame, err := h.loadModulesByGameIDs(c, gameIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range games {
|
||||
games[i].Modules = modulesByGame[games[i].ID]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) loadModulesByGameIDs(c *gin.Context, gameIDs []int64) (map[int64][]models.Module, error) {
|
||||
var gameModules []models.GameModule
|
||||
err := h.db.NewSelect().
|
||||
Model(&gameModules).
|
||||
Relation("Module").
|
||||
Where("gm.game_id IN (?)", bun.In(gameIDs)).
|
||||
OrderExpr("gm.id ASC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modulesByGame := make(map[int64][]models.Module, len(gameIDs))
|
||||
for _, gameID := range gameIDs {
|
||||
modulesByGame[gameID] = []models.Module{}
|
||||
}
|
||||
|
||||
for _, gameModule := range gameModules {
|
||||
if gameModule.Module == nil {
|
||||
continue
|
||||
}
|
||||
modulesByGame[gameModule.GameID] = append(modulesByGame[gameModule.GameID], *gameModule.Module)
|
||||
}
|
||||
|
||||
return modulesByGame, nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) gameHasModule(ctx context.Context, gameID int64, moduleKey string) (bool, error) {
|
||||
return h.db.NewSelect().
|
||||
Model((*models.GameModule)(nil)).
|
||||
Join("JOIN modules AS m ON m.id = gm.module_id").
|
||||
Where("gm.game_id = ?", gameID).
|
||||
Where(`m."key" = ?`, moduleKey).
|
||||
Exists(ctx)
|
||||
}
|
||||
473
internal/handlers/game_variables.go
Normal file
473
internal/handlers/game_variables.go
Normal file
@ -0,0 +1,473 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func (h *GameHandler) ListVariables(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.gameExists(c, gameID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
hasModule, err := h.gameHasModule(c, gameID, "variables")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hasModule {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
|
||||
return
|
||||
}
|
||||
|
||||
var variables []models.GameVariable
|
||||
err = h.db.NewSelect().
|
||||
Model(&variables).
|
||||
Relation("Items", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.OrderExpr("gvi.id ASC")
|
||||
}).
|
||||
Where("gv.game_id = ?", gameID).
|
||||
OrderExpr("gv.id DESC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for i := range variables {
|
||||
normalizeGameVariableResponse(&variables[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, variables)
|
||||
}
|
||||
|
||||
func (h *GameHandler) CreateVariable(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertGameVariableRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertGameVariableRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.gameExists(c, gameID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
hasModule, err := h.gameHasModule(c, gameID, "variables")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hasModule {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
variable := &models.GameVariable{
|
||||
GameID: gameID,
|
||||
Key: strings.TrimSpace(req.Key),
|
||||
Type: req.Type,
|
||||
NumberValue: req.NumberValue,
|
||||
StringValue: req.StringValue,
|
||||
TableValueType: req.TableValueType,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err = tx.NewInsert().Model(variable).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Game variable key already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := insertGameVariableItems(c, tx, variable.ID, req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.getGameVariable(c, gameID, variable.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *GameHandler) UpdateVariable(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
variableID, err := strconv.ParseInt(c.Param("var_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid variable id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertGameVariableRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertGameVariableRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hasModule, err := h.gameHasModule(c, gameID, "variables")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hasModule {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.getGameVariable(c, gameID, variableID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game variable not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
numberValue, stringValue, tableValueType := dbValuesFromVariableRequest(req)
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Model((*models.GameVariable)(nil)).
|
||||
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
|
||||
Set("type = ?", req.Type).
|
||||
Set("number_value = ?", numberValue).
|
||||
Set("string_value = ?", stringValue).
|
||||
Set("table_value_type = ?", tableValueType).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ? AND game_id = ?", existing.ID, gameID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Could not update game variable"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.NewDelete().
|
||||
Model((*models.GameVariableItem)(nil)).
|
||||
Where("game_variable_id = ?", existing.ID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := insertGameVariableItems(c, tx, existing.ID, req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.getGameVariable(c, gameID, existing.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DeleteVariable(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
variableID, err := strconv.ParseInt(c.Param("var_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid variable id"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.getGameVariable(c, gameID, variableID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game variable not found"})
|
||||
return
|
||||
}
|
||||
|
||||
hasModule, err := h.gameHasModule(c, gameID, "variables")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hasModule {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Variables module is not enabled for this game"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.NewDelete().
|
||||
Model((*models.GameVariableItem)(nil)).
|
||||
Where("game_variable_id = ?", existing.ID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.NewDelete().
|
||||
Model((*models.GameVariable)(nil)).
|
||||
Where("id = ? AND game_id = ?", existing.ID, gameID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Game variable deleted"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) gameExists(ctx context.Context, gameID int64) (bool, error) {
|
||||
return h.db.NewSelect().
|
||||
Model((*models.Game)(nil)).
|
||||
Where("id = ?", gameID).
|
||||
Exists(ctx)
|
||||
}
|
||||
|
||||
func (h *GameHandler) getGameVariable(ctx context.Context, gameID, variableID int64) (*models.GameVariable, error) {
|
||||
variable := new(models.GameVariable)
|
||||
err := h.db.NewSelect().
|
||||
Model(variable).
|
||||
Relation("Items", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.OrderExpr("gvi.id ASC")
|
||||
}).
|
||||
Where("gv.id = ? AND gv.game_id = ?", variableID, gameID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeGameVariableResponse(variable)
|
||||
return variable, nil
|
||||
}
|
||||
|
||||
func insertGameVariableItems(ctx context.Context, tx bun.Tx, variableID int64, req models.UpsertGameVariableRequest) error {
|
||||
if req.Type != models.GameVariableTypeTable && req.Type != models.GameVariableTypeVector {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, itemReq := range req.Items {
|
||||
key := strings.TrimSpace(itemReq.Key)
|
||||
if req.Type == models.GameVariableTypeVector {
|
||||
key = strconv.FormatInt(*itemReq.Index, 10)
|
||||
}
|
||||
item := &models.GameVariableItem{
|
||||
GameVariableID: variableID,
|
||||
Key: key,
|
||||
NumberValue: itemReq.NumberValue,
|
||||
StringValue: itemReq.StringValue,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if _, err := tx.NewInsert().Model(item).Exec(ctx); err != nil {
|
||||
return fmt.Errorf("could not save table items")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpsertGameVariableRequest(req *models.UpsertGameVariableRequest) error {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
if req.Key == "" {
|
||||
return fmt.Errorf("key is required")
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case models.GameVariableTypeNumber:
|
||||
if req.NumberValue == nil {
|
||||
return fmt.Errorf("number_value is required for number type")
|
||||
}
|
||||
if req.StringValue != nil || req.TableValueType != nil || len(req.Items) > 0 {
|
||||
return fmt.Errorf("number type only accepts number_value")
|
||||
}
|
||||
case models.GameVariableTypeString:
|
||||
if req.StringValue == nil {
|
||||
return fmt.Errorf("string_value is required for string type")
|
||||
}
|
||||
if req.NumberValue != nil || req.TableValueType != nil || len(req.Items) > 0 {
|
||||
return fmt.Errorf("string type only accepts string_value")
|
||||
}
|
||||
case models.GameVariableTypeTable:
|
||||
if req.NumberValue != nil || req.StringValue != nil {
|
||||
return fmt.Errorf("table type does not accept scalar values")
|
||||
}
|
||||
if req.TableValueType == nil {
|
||||
return fmt.Errorf("table_value_type is required for table type")
|
||||
}
|
||||
if *req.TableValueType != models.GameVariableTableValueTypeNumber &&
|
||||
*req.TableValueType != models.GameVariableTableValueTypeString {
|
||||
return fmt.Errorf("unsupported table_value_type")
|
||||
}
|
||||
seenKeys := make(map[string]struct{}, len(req.Items))
|
||||
for i := range req.Items {
|
||||
req.Items[i].Key = strings.TrimSpace(req.Items[i].Key)
|
||||
if req.Items[i].Key == "" {
|
||||
return fmt.Errorf("items[%d].key is required", i)
|
||||
}
|
||||
if _, exists := seenKeys[req.Items[i].Key]; exists {
|
||||
return fmt.Errorf("duplicate table item key: %s", req.Items[i].Key)
|
||||
}
|
||||
seenKeys[req.Items[i].Key] = struct{}{}
|
||||
|
||||
switch *req.TableValueType {
|
||||
case models.GameVariableTableValueTypeNumber:
|
||||
if req.Items[i].NumberValue == nil || req.Items[i].StringValue != nil {
|
||||
return fmt.Errorf("items[%d] must contain only number_value", i)
|
||||
}
|
||||
case models.GameVariableTableValueTypeString:
|
||||
if req.Items[i].StringValue == nil || req.Items[i].NumberValue != nil {
|
||||
return fmt.Errorf("items[%d] must contain only string_value", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
case models.GameVariableTypeVector:
|
||||
if req.NumberValue != nil || req.StringValue != nil {
|
||||
return fmt.Errorf("vector type does not accept scalar values")
|
||||
}
|
||||
if req.TableValueType == nil {
|
||||
return fmt.Errorf("table_value_type is required for vector type")
|
||||
}
|
||||
if *req.TableValueType != models.GameVariableTableValueTypeNumber &&
|
||||
*req.TableValueType != models.GameVariableTableValueTypeString {
|
||||
return fmt.Errorf("unsupported table_value_type")
|
||||
}
|
||||
seenIndexes := make(map[int64]struct{}, len(req.Items))
|
||||
for i := range req.Items {
|
||||
if req.Items[i].Index == nil {
|
||||
return fmt.Errorf("items[%d].index is required", i)
|
||||
}
|
||||
if *req.Items[i].Index < 0 {
|
||||
return fmt.Errorf("items[%d].index must be >= 0", i)
|
||||
}
|
||||
if _, exists := seenIndexes[*req.Items[i].Index]; exists {
|
||||
return fmt.Errorf("duplicate vector item index: %d", *req.Items[i].Index)
|
||||
}
|
||||
seenIndexes[*req.Items[i].Index] = struct{}{}
|
||||
|
||||
switch *req.TableValueType {
|
||||
case models.GameVariableTableValueTypeNumber:
|
||||
if req.Items[i].NumberValue == nil || req.Items[i].StringValue != nil {
|
||||
return fmt.Errorf("items[%d] must contain only number_value", i)
|
||||
}
|
||||
case models.GameVariableTableValueTypeString:
|
||||
if req.Items[i].StringValue == nil || req.Items[i].NumberValue != nil {
|
||||
return fmt.Errorf("items[%d] must contain only string_value", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported variable type")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dbValuesFromVariableRequest(req models.UpsertGameVariableRequest) (interface{}, interface{}, interface{}) {
|
||||
var numberValue interface{}
|
||||
if req.NumberValue != nil {
|
||||
numberValue = *req.NumberValue
|
||||
}
|
||||
|
||||
var stringValue interface{}
|
||||
if req.StringValue != nil {
|
||||
stringValue = *req.StringValue
|
||||
}
|
||||
|
||||
var tableValueType interface{}
|
||||
if req.TableValueType != nil {
|
||||
tableValueType = string(*req.TableValueType)
|
||||
}
|
||||
|
||||
return numberValue, stringValue, tableValueType
|
||||
}
|
||||
|
||||
func normalizeGameVariableResponse(variable *models.GameVariable) {
|
||||
if variable.Type != models.GameVariableTypeVector {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range variable.Items {
|
||||
index, err := strconv.ParseInt(variable.Items[i].Key, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
variable.Items[i].Index = &index
|
||||
variable.Items[i].Key = ""
|
||||
}
|
||||
}
|
||||
249
internal/handlers/games.go
Normal file
249
internal/handlers/games.go
Normal file
@ -0,0 +1,249 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type GameHandler struct {
|
||||
db *bun.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewGameHandler(db *bun.DB, cfg *config.Config) *GameHandler {
|
||||
return &GameHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *GameHandler) List(c *gin.Context) {
|
||||
var games []models.Game
|
||||
query := h.db.NewSelect().Model(&games).OrderExpr("id DESC")
|
||||
|
||||
if s := c.Query("search"); s != "" {
|
||||
like := "%" + s + "%"
|
||||
query = query.Where("g.name LIKE ? OR g.slug LIKE ?", like, like)
|
||||
}
|
||||
|
||||
err := query.Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.attachModulesToGames(c, games); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, games)
|
||||
}
|
||||
|
||||
func (h *GameHandler) GetByID(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
game := new(models.Game)
|
||||
err = h.db.NewSelect().
|
||||
Model(game).
|
||||
Where("g.id = ?", id).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
modulesByGame, err := h.loadModulesByGameIDs(c, []int64{game.ID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
game.Modules = modulesByGame[game.ID]
|
||||
|
||||
c.JSON(http.StatusOK, game)
|
||||
}
|
||||
|
||||
func (h *GameHandler) Create(c *gin.Context) {
|
||||
var req models.CreateGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
game := &models.Game{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
ImageURL: req.ImageURL,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, err := h.db.NewInsert().Model(game).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Game slug already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, game)
|
||||
}
|
||||
|
||||
func (h *GameHandler) Update(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var req models.CreateGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.NewUpdate().Model((*models.Game)(nil)).
|
||||
Set("name = ?", req.Name).
|
||||
Set("slug = ?", req.Slug).
|
||||
Set("description = ?", req.Description).
|
||||
Set("image_url = ?", req.ImageURL).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", id).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Game updated"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) Delete(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
_, err := h.db.NewDelete().Model((*models.Game)(nil)).Where("id = ?", id).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Game deleted"})
|
||||
}
|
||||
|
||||
// ─── User-Game connections ──────────────────────────────────
|
||||
|
||||
func (h *GameHandler) ConnectGame(c *gin.Context) {
|
||||
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var req models.ConnectGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ug := &models.UserGame{
|
||||
UserID: userID,
|
||||
GameID: req.GameID,
|
||||
Balance: 0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, err := h.db.NewInsert().Model(ug).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Game already connected"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, ug)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DisconnectGame(c *gin.Context) {
|
||||
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
gameID, _ := strconv.ParseInt(c.Param("game_id"), 10, 64)
|
||||
|
||||
_, err := h.db.NewDelete().Model((*models.UserGame)(nil)).
|
||||
Where("user_id = ? AND game_id = ?", userID, gameID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Game disconnected"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) UserGames(c *gin.Context) {
|
||||
userID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
var userGames []models.UserGame
|
||||
err := h.db.NewSelect().Model(&userGames).
|
||||
Relation("Game").
|
||||
Where("ug.user_id = ?", userID).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, userGames)
|
||||
}
|
||||
|
||||
func (h *GameHandler) TopUp(c *gin.Context) {
|
||||
ugID, _ := strconv.ParseInt(c.Param("ug_id"), 10, 64)
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req models.TopUpRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update balance
|
||||
_, err = tx.NewUpdate().Model((*models.UserGame)(nil)).
|
||||
Set("balance = balance + ?", req.Amount).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", ugID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Log transaction
|
||||
txn := &models.BalanceTransaction{
|
||||
UserGameID: ugID,
|
||||
Amount: req.Amount,
|
||||
Type: "topup",
|
||||
Comment: req.Comment,
|
||||
AdminID: adminID.(int64),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_, err = tx.NewInsert().Model(txn).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Balance topped up", "transaction": txn})
|
||||
}
|
||||
|
||||
func (h *GameHandler) Transactions(c *gin.Context) {
|
||||
ugID, _ := strconv.ParseInt(c.Param("ug_id"), 10, 64)
|
||||
|
||||
var txns []models.BalanceTransaction
|
||||
err := h.db.NewSelect().Model(&txns).
|
||||
Where("user_game_id = ?", ugID).
|
||||
OrderExpr("id DESC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, txns)
|
||||
}
|
||||
94
internal/handlers/images.go
Normal file
94
internal/handlers/images.go
Normal file
@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"game-admin/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ImageHandler struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewImageHandler(cfg *config.Config) *ImageHandler {
|
||||
return &ImageHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
if fileHeader.Size == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
sniff := make([]byte, 512)
|
||||
n, err := file.Read(sniff)
|
||||
if err != nil && err != io.EOF {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read uploaded file"})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(sniff[:n])
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only image files are allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
if ext == "" {
|
||||
ext = extensionFromContentType(contentType)
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
|
||||
fileName := uuid.NewString() + ext
|
||||
relativePath := filepath.ToSlash(filepath.Join("images", fileName))
|
||||
fullPath := filepath.Join(h.cfg.UploadDir, filepath.FromSlash(relativePath))
|
||||
if err := c.SaveUploadedFile(fileHeader, fullPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save uploaded file"})
|
||||
return
|
||||
}
|
||||
|
||||
url := strings.TrimRight(h.cfg.BaseURL, "/") + "/uploads/" + relativePath
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"name": fileName,
|
||||
"path": "/uploads/" + relativePath,
|
||||
"url": url,
|
||||
"content_type": contentType,
|
||||
})
|
||||
}
|
||||
|
||||
func extensionFromContentType(contentType string) string {
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/svg+xml":
|
||||
return ".svg"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
98
internal/handlers/invites.go
Normal file
98
internal/handlers/invites.go
Normal file
@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type InviteHandler struct {
|
||||
db *bun.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewInviteHandler(db *bun.DB, cfg *config.Config) *InviteHandler {
|
||||
return &InviteHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *InviteHandler) Create(c *gin.Context) {
|
||||
adminID, _ := c.Get("user_id")
|
||||
|
||||
var req models.CreateInviteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
code := uuid.New().String()[:8]
|
||||
expires := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
invite := &models.InviteLink{
|
||||
Code: code,
|
||||
Role: req.Role,
|
||||
MaxUses: req.MaxUses,
|
||||
UsedCount: 0,
|
||||
CreatedByID: adminID.(int64),
|
||||
ExpiresAt: &expires,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err := h.db.NewInsert().Model(invite).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"invite": invite,
|
||||
"link": h.cfg.BaseURL + "/register?invite=" + code,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *InviteHandler) List(c *gin.Context) {
|
||||
var invites []models.InviteLink
|
||||
err := h.db.NewSelect().Model(&invites).
|
||||
Relation("CreatedBy").
|
||||
OrderExpr("id DESC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, invites)
|
||||
}
|
||||
|
||||
func (h *InviteHandler) Delete(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
_, err := h.db.NewDelete().Model((*models.InviteLink)(nil)).Where("code = ?", code).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Invite deleted"})
|
||||
}
|
||||
|
||||
func (h *InviteHandler) Validate(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
invite := new(models.InviteLink)
|
||||
err := h.db.NewSelect().Model(invite).Where("code = ?", code).Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid invite code"})
|
||||
return
|
||||
}
|
||||
if invite.UsedCount >= invite.MaxUses {
|
||||
c.JSON(http.StatusGone, gin.H{"error": "Invite code exhausted"})
|
||||
return
|
||||
}
|
||||
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now()) {
|
||||
c.JSON(http.StatusGone, gin.H{"error": "Invite code expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true, "role": invite.Role})
|
||||
}
|
||||
174
internal/handlers/languages.go
Normal file
174
internal/handlers/languages.go
Normal file
@ -0,0 +1,174 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type LanguageHandler struct {
|
||||
db *bun.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewLanguageHandler(db *bun.DB, cfg *config.Config) *LanguageHandler {
|
||||
return &LanguageHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *LanguageHandler) List(c *gin.Context) {
|
||||
var languages []models.Language
|
||||
err := h.db.NewSelect().
|
||||
Model(&languages).
|
||||
OrderExpr("id ASC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, languages)
|
||||
}
|
||||
|
||||
func (h *LanguageHandler) GetByID(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
|
||||
return
|
||||
}
|
||||
|
||||
language := new(models.Language)
|
||||
err = h.db.NewSelect().
|
||||
Model(language).
|
||||
Where("id = ?", id).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, language)
|
||||
}
|
||||
|
||||
func (h *LanguageHandler) Create(c *gin.Context) {
|
||||
var req models.UpsertLanguageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
code, name, err := normalizeLanguageRequest(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
language := &models.Language{
|
||||
Code: code,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = h.db.NewInsert().Model(language).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Language code already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, language)
|
||||
}
|
||||
|
||||
func (h *LanguageHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLanguageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
code, name, err := normalizeLanguageRequest(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.db.NewUpdate().
|
||||
Model((*models.Language)(nil)).
|
||||
Set("code = ?", code).
|
||||
Set("name = ?", name).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", id).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Could not update language"})
|
||||
return
|
||||
}
|
||||
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
|
||||
return
|
||||
}
|
||||
|
||||
updated := new(models.Language)
|
||||
err = h.db.NewSelect().
|
||||
Model(updated).
|
||||
Where("id = ?", id).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *LanguageHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid language id"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.db.NewDelete().
|
||||
Model((*models.Language)(nil)).
|
||||
Where("id = ?", id).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Language not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Language deleted"})
|
||||
}
|
||||
|
||||
func normalizeLanguageRequest(req models.UpsertLanguageRequest) (string, string, error) {
|
||||
code := strings.ToLower(strings.TrimSpace(req.Code))
|
||||
name := strings.TrimSpace(req.Name)
|
||||
|
||||
if code == "" {
|
||||
return "", "", errors.New("code is required")
|
||||
}
|
||||
if name == "" {
|
||||
return "", "", errors.New("name is required")
|
||||
}
|
||||
|
||||
return code, name, nil
|
||||
}
|
||||
748
internal/handlers/leaderboards.go
Normal file
748
internal/handlers/leaderboards.go
Normal file
@ -0,0 +1,748 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func (h *GameHandler) ListLeaderboards(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var leaderboards []models.Leaderboard
|
||||
err = h.db.NewSelect().
|
||||
Model(&leaderboards).
|
||||
Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.OrderExpr("lbg.id ASC")
|
||||
}).
|
||||
Where("lb.game_id = ?", gameID).
|
||||
OrderExpr("lb.id DESC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, leaderboards)
|
||||
}
|
||||
|
||||
func (h *GameHandler) GetLeaderboard(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
leaderboard, err := h.getLeaderboard(c, gameID, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, leaderboard)
|
||||
}
|
||||
|
||||
func (h *GameHandler) CreateLeaderboard(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLeaderboardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertLeaderboardRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
|
||||
leaderboard := &models.Leaderboard{
|
||||
GameID: gameID,
|
||||
Key: strings.TrimSpace(req.Key),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
SortOrder: req.SortOrder,
|
||||
PeriodType: req.PeriodType,
|
||||
IsActive: isActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, err = h.db.NewInsert().Model(leaderboard).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard key already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.getLeaderboard(c, gameID, leaderboard.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *GameHandler) UpdateLeaderboard(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLeaderboardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertLeaderboardRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
current, err := h.getLeaderboard(c, gameID, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
|
||||
isActive := current.IsActive
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
|
||||
_, err = h.db.NewUpdate().
|
||||
Model((*models.Leaderboard)(nil)).
|
||||
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
|
||||
Set("name = ?", strings.TrimSpace(req.Name)).
|
||||
Set("sort_order = ?", req.SortOrder).
|
||||
Set("period_type = ?", req.PeriodType).
|
||||
Set("is_active = ?", isActive).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ? AND game_id = ?", leaderboardID, gameID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard"})
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.getLeaderboard(c, gameID, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DeleteLeaderboard(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, gameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err := h.getLeaderboard(c, gameID, leaderboardID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var groups []models.LeaderboardGroup
|
||||
if err := tx.NewSelect().Model(&groups).Where("leaderboard_id = ?", leaderboardID).Scan(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
groupIDs := make([]int64, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
groupIDs = append(groupIDs, group.ID)
|
||||
}
|
||||
if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id IN (?)", bun.In(groupIDs)).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := tx.NewDelete().Model((*models.LeaderboardScore)(nil)).Where("leaderboard_id = ?", leaderboardID).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := tx.NewDelete().Model((*models.Leaderboard)(nil)).Where("id = ? AND game_id = ?", leaderboardID, gameID).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard deleted"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) ListLeaderboardGroups(c *gin.Context) {
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var groups []models.LeaderboardGroup
|
||||
err = h.db.NewSelect().
|
||||
Model(&groups).
|
||||
Relation("Members", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.OrderExpr("lbgm.id ASC")
|
||||
}).
|
||||
Where("lbg.leaderboard_id = ?", leaderboardID).
|
||||
OrderExpr("lbg.id ASC").
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
func (h *GameHandler) CreateLeaderboardGroup(c *gin.Context) {
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLeaderboardGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertLeaderboardGroupRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
isDefault := false
|
||||
if req.IsDefault != nil {
|
||||
isDefault = *req.IsDefault
|
||||
}
|
||||
|
||||
group := &models.LeaderboardGroup{
|
||||
LeaderboardID: leaderboardID,
|
||||
Key: strings.TrimSpace(req.Key),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
IsDefault: isDefault,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, err = h.db.NewInsert().Model(group).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Leaderboard group key already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, group)
|
||||
}
|
||||
|
||||
func (h *GameHandler) UpdateLeaderboardGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
|
||||
return
|
||||
}
|
||||
|
||||
group, leaderboard, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLeaderboardGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateUpsertLeaderboardGroupRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
isDefault := group.IsDefault
|
||||
if req.IsDefault != nil {
|
||||
isDefault = *req.IsDefault
|
||||
}
|
||||
|
||||
_, err = h.db.NewUpdate().
|
||||
Model((*models.LeaderboardGroup)(nil)).
|
||||
Set(`"key" = ?`, strings.TrimSpace(req.Key)).
|
||||
Set("name = ?", strings.TrimSpace(req.Name)).
|
||||
Set("is_default = ?", isDefault).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", groupID).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Could not update leaderboard group"})
|
||||
return
|
||||
}
|
||||
|
||||
updated, _, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DeleteLeaderboardGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
|
||||
return
|
||||
}
|
||||
_, leaderboard, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.BeginTx(c, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tx.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ?", groupID).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := tx.NewDelete().Model((*models.LeaderboardGroup)(nil)).Where("id = ?", groupID).Exec(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard group deleted"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) AddLeaderboardGroupMember(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
|
||||
return
|
||||
}
|
||||
group, leaderboard, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AddLeaderboardGroupMemberRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureUserBelongsToGame(c, req.UserID, leaderboard.GameID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
member := &models.LeaderboardGroupMember{
|
||||
GroupID: group.ID,
|
||||
UserID: req.UserID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_, err = h.db.NewInsert().Model(member).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already in group"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, member)
|
||||
}
|
||||
|
||||
func (h *GameHandler) DeleteLeaderboardGroupMember(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("group_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group id"})
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||
return
|
||||
}
|
||||
_, leaderboard, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.db.NewDelete().Model((*models.LeaderboardGroupMember)(nil)).Where("group_id = ? AND user_id = ?", groupID, userID).Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Group member not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Group member deleted"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) UpsertLeaderboardScore(c *gin.Context) {
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpsertLeaderboardScoreRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.ensureUserGameBelongsToGame(c, req.UserGameID, leaderboard.GameID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
score := &models.LeaderboardScore{
|
||||
LeaderboardID: leaderboardID,
|
||||
UserGameID: req.UserGameID,
|
||||
Score: req.Score,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err = h.db.NewInsert().Model(score).
|
||||
On("CONFLICT (leaderboard_id, user_game_id) DO UPDATE").
|
||||
Set("score = EXCLUDED.score").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Leaderboard score saved"})
|
||||
}
|
||||
|
||||
func (h *GameHandler) GetLeaderboardRankings(c *gin.Context) {
|
||||
leaderboardID, err := strconv.ParseInt(c.Param("leaderboard_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid leaderboard id"})
|
||||
return
|
||||
}
|
||||
leaderboard, err := h.getLeaderboardByID(c, leaderboardID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard not found"})
|
||||
return
|
||||
}
|
||||
if err := h.ensureLeaderboardModuleEnabled(c, leaderboard.GameID); err != nil {
|
||||
renderLeaderboardModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if rawLimit := c.Query("limit"); rawLimit != "" {
|
||||
parsed, err := strconv.Atoi(rawLimit)
|
||||
if err != nil || parsed <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid limit"})
|
||||
return
|
||||
}
|
||||
if parsed > 200 {
|
||||
parsed = 200
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
|
||||
query := h.db.NewSelect().
|
||||
TableExpr("leaderboard_scores AS lbs").
|
||||
ColumnExpr("lbs.user_game_id AS user_game_id").
|
||||
ColumnExpr("ug.user_id AS user_id").
|
||||
ColumnExpr("u.username AS username").
|
||||
ColumnExpr("lbs.score AS score").
|
||||
Join("JOIN user_games AS ug ON ug.id = lbs.user_game_id").
|
||||
Join("JOIN users AS u ON u.id = ug.user_id").
|
||||
Where("lbs.leaderboard_id = ?", leaderboardID)
|
||||
|
||||
if rawGroupID := c.Query("group_id"); rawGroupID != "" {
|
||||
groupID, err := strconv.ParseInt(rawGroupID, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group_id"})
|
||||
return
|
||||
}
|
||||
group, _, err := h.getLeaderboardGroup(c, groupID)
|
||||
if err != nil || group.LeaderboardID != leaderboardID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Leaderboard group not found"})
|
||||
return
|
||||
}
|
||||
query = query.Join("JOIN leaderboard_group_members AS lbgm ON lbgm.user_id = ug.user_id").
|
||||
Where("lbgm.group_id = ?", groupID)
|
||||
}
|
||||
|
||||
switch leaderboard.SortOrder {
|
||||
case models.LeaderboardSortOrderAsc:
|
||||
query = query.OrderExpr("lbs.score ASC, lbs.updated_at ASC")
|
||||
default:
|
||||
query = query.OrderExpr("lbs.score DESC, lbs.updated_at ASC")
|
||||
}
|
||||
|
||||
var rows []models.LeaderboardRankItem
|
||||
if err := query.Limit(limit).Scan(c, &rows); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].Rank = int64(i + 1)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"leaderboard": leaderboard,
|
||||
"items": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *GameHandler) ensureLeaderboardModuleEnabled(c *gin.Context, gameID int64) error {
|
||||
exists, err := h.gameExists(c, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("game_not_found")
|
||||
}
|
||||
hasModule, err := h.gameHasModule(c, gameID, "leaderboard")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasModule {
|
||||
return fmt.Errorf("leaderboard_module_disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderLeaderboardModuleError(c *gin.Context, err error) {
|
||||
switch err.Error() {
|
||||
case "game_not_found":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"})
|
||||
case "leaderboard_module_disabled":
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Leaderboard module is not enabled for this game"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpsertLeaderboardRequest(req *models.UpsertLeaderboardRequest) error {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Key == "" {
|
||||
return fmt.Errorf("key is required")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if req.SortOrder != models.LeaderboardSortOrderAsc && req.SortOrder != models.LeaderboardSortOrderDesc {
|
||||
return fmt.Errorf("sort_order must be asc or desc")
|
||||
}
|
||||
switch req.PeriodType {
|
||||
case models.LeaderboardPeriodAllTime, models.LeaderboardPeriodDaily, models.LeaderboardPeriodWeekly, models.LeaderboardPeriodMonthly:
|
||||
default:
|
||||
return fmt.Errorf("unsupported period_type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpsertLeaderboardGroupRequest(req *models.UpsertLeaderboardGroupRequest) error {
|
||||
req.Key = strings.TrimSpace(req.Key)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Key == "" {
|
||||
return fmt.Errorf("key is required")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) getLeaderboard(c *gin.Context, gameID, leaderboardID int64) (*models.Leaderboard, error) {
|
||||
leaderboard := new(models.Leaderboard)
|
||||
err := h.db.NewSelect().
|
||||
Model(leaderboard).
|
||||
Relation("Groups", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.OrderExpr("lbg.id ASC")
|
||||
}).
|
||||
Where("lb.id = ? AND lb.game_id = ?", leaderboardID, gameID).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaderboard, nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) getLeaderboardByID(c *gin.Context, leaderboardID int64) (*models.Leaderboard, error) {
|
||||
leaderboard := new(models.Leaderboard)
|
||||
err := h.db.NewSelect().
|
||||
Model(leaderboard).
|
||||
Where("id = ?", leaderboardID).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaderboard, nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) getLeaderboardGroup(c *gin.Context, groupID int64) (*models.LeaderboardGroup, *models.Leaderboard, error) {
|
||||
group := new(models.LeaderboardGroup)
|
||||
err := h.db.NewSelect().Model(group).Where("id = ?", groupID).Scan(c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
leaderboard, err := h.getLeaderboardByID(c, group.LeaderboardID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return group, leaderboard, nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) ensureUserBelongsToGame(c *gin.Context, userID, gameID int64) error {
|
||||
exists, err := h.db.NewSelect().
|
||||
Model((*models.UserGame)(nil)).
|
||||
Where("user_id = ? AND game_id = ?", userID, gameID).
|
||||
Exists(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("user is not connected to this game")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *GameHandler) ensureUserGameBelongsToGame(c *gin.Context, userGameID, gameID int64) error {
|
||||
exists, err := h.db.NewSelect().
|
||||
Model((*models.UserGame)(nil)).
|
||||
Where("id = ? AND game_id = ?", userGameID, gameID).
|
||||
Exists(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("user_game does not belong to this game")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
681
internal/handlers/notifications.go
Normal file
681
internal/handlers/notifications.go
Normal file
@ -0,0 +1,681 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var defaultNotificationMacroKeys = []string{"time_second", "image", "login"}
|
||||
|
||||
func (h *GameHandler) ListNotifications(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ensureNotificationModuleEnabled(c, gameID); err != nil {
|
||||
renderNotificationModuleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var notifications []models.Notification
|
||||
err = h.db.NewSelect().
|
||||
Model(¬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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
internal/handlers/users.go
Normal file
127
internal/handlers/users.go
Normal file
@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
db *bun.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewUserHandler(db *bun.DB, cfg *config.Config) *UserHandler {
|
||||
return &UserHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
var users []models.User
|
||||
query := h.db.NewSelect().Model(&users).OrderExpr("id DESC")
|
||||
|
||||
// Search
|
||||
if s := c.Query("search"); s != "" {
|
||||
like := "%" + s + "%"
|
||||
query = query.Where("username LIKE ? OR email LIKE ?", like, like)
|
||||
}
|
||||
// Role filter
|
||||
if r := c.Query("role"); r != "" {
|
||||
query = query.Where("role = ?", r)
|
||||
}
|
||||
|
||||
err := query.Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListByGame(c *gin.Context) {
|
||||
gameID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game id"})
|
||||
return
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
query := h.db.NewSelect().
|
||||
Model(&users).
|
||||
Join("JOIN user_games AS ug ON ug.user_id = u.id").
|
||||
Where("ug.game_id = ?", gameID).
|
||||
OrderExpr("u.id DESC").
|
||||
Distinct()
|
||||
|
||||
if s := c.Query("search"); s != "" {
|
||||
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
query = query.Where("u.id = ?", id)
|
||||
} else {
|
||||
like := "%" + s + "%"
|
||||
query = query.Where("u.username LIKE ? OR u.email LIKE ?", like, like)
|
||||
}
|
||||
}
|
||||
|
||||
err = query.Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetByID(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
user := new(models.User)
|
||||
err := h.db.NewSelect().Model(user).
|
||||
Relation("UserGames", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("Game")
|
||||
}).
|
||||
Where("u.id = ?", id).
|
||||
Scan(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateRole(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
var req models.UpdateUserRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.NewUpdate().Model((*models.User)(nil)).
|
||||
Set("role = ?", req.Role).
|
||||
Where("id = ?", id).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Role updated"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) ToggleActive(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
_, err := h.db.NewUpdate().Model((*models.User)(nil)).
|
||||
Set("is_active = NOT is_active").
|
||||
Where("id = ?", id).
|
||||
Exec(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Toggled"})
|
||||
}
|
||||
78
internal/middleware/auth.go
Normal file
78
internal/middleware/auth.go
Normal file
@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role models.Role `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(user *models.User, cfg *config.Config) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(72 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func AuthRequired(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"})
|
||||
return
|
||||
}
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(parts[1], claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_email", claims.Email)
|
||||
c.Set("user_role", claims.Role)
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RoleRequired(roles ...models.Role) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("user_role")
|
||||
userRole := role.(models.Role)
|
||||
for _, r := range roles {
|
||||
if userRole == r {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
}
|
||||
}
|
||||
145
internal/migrations/migrate.go
Normal file
145
internal/migrations/migrate.go
Normal file
@ -0,0 +1,145 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/config"
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Migrate(ctx context.Context, db *bun.DB, cfg *config.Config) error {
|
||||
tables := []interface{}{
|
||||
(*models.User)(nil),
|
||||
(*models.Game)(nil),
|
||||
(*models.Language)(nil),
|
||||
(*models.Module)(nil),
|
||||
(*models.GameModule)(nil),
|
||||
(*models.GameVariable)(nil),
|
||||
(*models.GameVariableItem)(nil),
|
||||
(*models.Notification)(nil),
|
||||
(*models.NotificationDescription)(nil),
|
||||
(*models.NotificationVariableDef)(nil),
|
||||
(*models.NotificationEntry)(nil),
|
||||
(*models.NotificationEntryVariable)(nil),
|
||||
(*models.Leaderboard)(nil),
|
||||
(*models.LeaderboardGroup)(nil),
|
||||
(*models.LeaderboardGroupMember)(nil),
|
||||
(*models.LeaderboardScore)(nil),
|
||||
(*models.UserGame)(nil),
|
||||
(*models.InviteLink)(nil),
|
||||
(*models.BalanceTransaction)(nil),
|
||||
(*models.Transaction)(nil),
|
||||
}
|
||||
|
||||
for _, model := range tables {
|
||||
_, err := db.NewCreateTable().
|
||||
Model(model).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_games_unique ON user_games (user_id, game_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_languages_code_unique ON languages ("code")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_key_unique ON modules ("key")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_game_modules_unique ON game_modules (game_id, module_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_game_variables_unique ON game_variables (game_id, "key")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_game_variable_items_unique ON game_variable_items (game_variable_id, item_key)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_unique ON notifications (game_id, name)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_descriptions_unique ON notification_descriptions (notification_id, language_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_variable_defs_unique ON notification_variable_defs (notification_id, "key")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_entry_variables_unique ON notification_entry_variables (notification_entry_id, notification_variable_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboards_unique ON leaderboards (game_id, "key")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_groups_unique ON leaderboard_groups (leaderboard_id, "key")`)
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_group_members_unique ON leaderboard_group_members (group_id, user_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_scores_unique ON leaderboard_scores (leaderboard_id, user_game_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_request_id_unique ON transactions (request_id)")
|
||||
_, _ = db.ExecContext(ctx,
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_user_game_status ON transactions (user_id, game_id, status)")
|
||||
|
||||
// Seed a default admin only when there are no users yet.
|
||||
userCount, err := db.NewSelect().Model((*models.User)(nil)).Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if userCount == 0 {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(cfg.DefaultAdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash default admin password: %w", err)
|
||||
}
|
||||
|
||||
admin := &models.User{
|
||||
Email: cfg.DefaultAdminEmail,
|
||||
Username: cfg.DefaultAdminUsername,
|
||||
Password: string(hashed),
|
||||
Role: models.RoleAdmin,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, err = db.NewInsert().Model(admin).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed default admin: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("default admin user seeded: %s / %s\n", cfg.DefaultAdminEmail, cfg.DefaultAdminPassword)
|
||||
}
|
||||
|
||||
games := []models.Game{
|
||||
{Name: "Black & White", Slug: "b&d", Description: "Sandbox", IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
}
|
||||
for _, g := range games {
|
||||
exists, _ := db.NewSelect().Model((*models.Game)(nil)).Where("slug = ?", g.Slug).Exists(ctx)
|
||||
if !exists {
|
||||
_, _ = db.NewInsert().Model(&g).Exec(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
defaultModules := []models.Module{
|
||||
{Key: "variables", Name: "Variables", CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
{Key: "notification", Name: "Notification", CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
{Key: "leaderboard", Name: "Leaderboard", CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
}
|
||||
for _, module := range defaultModules {
|
||||
exists, _ := db.NewSelect().Model((*models.Module)(nil)).Where(`"key" = ?`, module.Key).Exists(ctx)
|
||||
if !exists {
|
||||
_, _ = db.NewInsert().Model(&module).Exec(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
defaultLanguages := []models.Language{
|
||||
{Code: "en", Name: "English", CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
{Code: "ru", Name: "Русский", CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
}
|
||||
for _, language := range defaultLanguages {
|
||||
exists, _ := db.NewSelect().Model((*models.Language)(nil)).Where("code = ?", language.Code).Exists(ctx)
|
||||
if !exists {
|
||||
_, _ = db.NewInsert().Model(&language).Exec(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("migrations complete")
|
||||
return nil
|
||||
}
|
||||
438
internal/models/models.go
Normal file
438
internal/models/models.go
Normal file
@ -0,0 +1,438 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// ─── Roles ──────────────────────────────────────────────────
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleModerator Role = "moderator"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
// ─── User ───────────────────────────────────────────────────
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Email string `bun:"email,notnull,unique" json:"email"`
|
||||
Username string `bun:"username,notnull,unique" json:"username"`
|
||||
Password string `bun:"password,notnull" json:"-"`
|
||||
Role Role `bun:"role,notnull,default:'user'" json:"role"`
|
||||
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
UserGames []*UserGame `bun:"rel:has-many,join:id=user_id" json:"user_games,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Game ───────────────────────────────────────────────────
|
||||
type Game struct {
|
||||
bun.BaseModel `bun:"table:games,alias:g"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Name string `bun:"name,notnull,unique" json:"name"`
|
||||
Slug string `bun:"slug,notnull,unique" json:"slug"`
|
||||
Description string `bun:"description" json:"description"`
|
||||
ImageURL string `bun:"image_url" json:"image_url"`
|
||||
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Variables []*GameVariable `bun:"rel:has-many,join:id=game_id" json:"variables,omitempty"`
|
||||
Notifications []Notification `bun:"rel:has-many,join:id=game_id" json:"notifications,omitempty"`
|
||||
Leaderboards []Leaderboard `bun:"rel:has-many,join:id=game_id" json:"leaderboards,omitempty"`
|
||||
Modules []Module `bun:"-" json:"modules,omitempty"`
|
||||
}
|
||||
|
||||
type GameVariableType string
|
||||
|
||||
const (
|
||||
GameVariableTypeNumber GameVariableType = "number"
|
||||
GameVariableTypeString GameVariableType = "string"
|
||||
GameVariableTypeTable GameVariableType = "table"
|
||||
GameVariableTypeVector GameVariableType = "vector"
|
||||
)
|
||||
|
||||
type GameVariableTableValueType string
|
||||
|
||||
const (
|
||||
GameVariableTableValueTypeNumber GameVariableTableValueType = "number"
|
||||
GameVariableTableValueTypeString GameVariableTableValueType = "string"
|
||||
)
|
||||
|
||||
type GameVariable struct {
|
||||
bun.BaseModel `bun:"table:game_variables,alias:gv"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
Key string `bun:"key,notnull" json:"key"`
|
||||
Type GameVariableType `bun:"type,notnull" json:"type"`
|
||||
NumberValue *float64 `bun:"number_value" json:"number_value,omitempty"`
|
||||
StringValue *string `bun:"string_value" json:"string_value,omitempty"`
|
||||
TableValueType *GameVariableTableValueType `bun:"table_value_type" json:"table_value_type,omitempty"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
|
||||
Items []*GameVariableItem `bun:"rel:has-many,join:id=game_variable_id" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type GameVariableItem struct {
|
||||
bun.BaseModel `bun:"table:game_variable_items,alias:gvi"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GameVariableID int64 `bun:"game_variable_id,notnull" json:"game_variable_id"`
|
||||
Key string `bun:"item_key,notnull" json:"key,omitempty"`
|
||||
Index *int64 `bun:"-" json:"index,omitempty"`
|
||||
NumberValue *float64 `bun:"number_value" json:"number_value,omitempty"`
|
||||
StringValue *string `bun:"string_value" json:"string_value,omitempty"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Variable *GameVariable `bun:"rel:belongs-to,join:game_variable_id=id" json:"-"`
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
bun.BaseModel `bun:"table:modules,alias:m"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Key string `bun:"key,notnull,unique" json:"key"`
|
||||
Name string `bun:"name,notnull" json:"name"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
}
|
||||
|
||||
type Language struct {
|
||||
bun.BaseModel `bun:"table:languages,alias:l"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Code string `bun:"code,notnull,unique" json:"code"`
|
||||
Name string `bun:"name,notnull" json:"name"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
}
|
||||
|
||||
type GameModule struct {
|
||||
bun.BaseModel `bun:"table:game_modules,alias:gm"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
ModuleID int64 `bun:"module_id,notnull" json:"module_id"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
|
||||
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"-"`
|
||||
Module *Module `bun:"rel:belongs-to,join:module_id=id" json:"module,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
bun.BaseModel `bun:"table:notifications,alias:n"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
Name string `bun:"name,notnull" json:"name"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
|
||||
Descriptions []NotificationDescription `bun:"rel:has-many,join:id=notification_id" json:"descriptions,omitempty"`
|
||||
Variables []NotificationVariableDef `bun:"rel:has-many,join:id=notification_id" json:"variables,omitempty"`
|
||||
Entries []NotificationEntry `bun:"rel:has-many,join:id=notification_id" json:"entries,omitempty"`
|
||||
Macros []string `bun:"-" json:"macros,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationDescription struct {
|
||||
bun.BaseModel `bun:"table:notification_descriptions,alias:nd"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
|
||||
LanguageID int64 `bun:"language_id,notnull" json:"language_id"`
|
||||
Description string `bun:"description,notnull" json:"description"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
Language *Language `bun:"rel:belongs-to,join:language_id=id" json:"language,omitempty"`
|
||||
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
|
||||
}
|
||||
|
||||
type NotificationVariableDef struct {
|
||||
bun.BaseModel `bun:"table:notification_variable_defs,alias:nvd"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
|
||||
Key string `bun:"key,notnull" json:"key"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
|
||||
}
|
||||
|
||||
type NotificationEntry struct {
|
||||
bun.BaseModel `bun:"table:notification_entries,alias:ne"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
NotificationID int64 `bun:"notification_id,notnull" json:"notification_id"`
|
||||
TimeSecond int64 `bun:"time_second,notnull" json:"time_second"`
|
||||
Image string `bun:"image,notnull" json:"image"`
|
||||
Login string `bun:"login,notnull" json:"login"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
Notification *Notification `bun:"rel:belongs-to,join:notification_id=id" json:"-"`
|
||||
Variables []NotificationEntryVariable `bun:"rel:has-many,join:id=notification_entry_id" json:"variables,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationEntryVariable struct {
|
||||
bun.BaseModel `bun:"table:notification_entry_variables,alias:nev"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
NotificationEntryID int64 `bun:"notification_entry_id,notnull" json:"notification_entry_id"`
|
||||
NotificationVariableID int64 `bun:"notification_variable_id,notnull" json:"notification_variable_id"`
|
||||
Value string `bun:"value,notnull" json:"value"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
Key string `bun:"-" json:"key,omitempty"`
|
||||
Entry *NotificationEntry `bun:"rel:belongs-to,join:notification_entry_id=id" json:"-"`
|
||||
Variable *NotificationVariableDef `bun:"rel:belongs-to,join:notification_variable_id=id" json:"-"`
|
||||
}
|
||||
|
||||
type LeaderboardSortOrder string
|
||||
|
||||
const (
|
||||
LeaderboardSortOrderDesc LeaderboardSortOrder = "desc"
|
||||
LeaderboardSortOrderAsc LeaderboardSortOrder = "asc"
|
||||
)
|
||||
|
||||
type LeaderboardPeriodType string
|
||||
|
||||
const (
|
||||
LeaderboardPeriodAllTime LeaderboardPeriodType = "all_time"
|
||||
LeaderboardPeriodDaily LeaderboardPeriodType = "daily"
|
||||
LeaderboardPeriodWeekly LeaderboardPeriodType = "weekly"
|
||||
LeaderboardPeriodMonthly LeaderboardPeriodType = "monthly"
|
||||
)
|
||||
|
||||
type Leaderboard struct {
|
||||
bun.BaseModel `bun:"table:leaderboards,alias:lb"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
Key string `bun:"key,notnull" json:"key"`
|
||||
Name string `bun:"name,notnull" json:"name"`
|
||||
SortOrder LeaderboardSortOrder `bun:"sort_order,notnull" json:"sort_order"`
|
||||
PeriodType LeaderboardPeriodType `bun:"period_type,notnull" json:"period_type"`
|
||||
IsActive bool `bun:"is_active,notnull,default:true" json:"is_active"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
|
||||
Groups []LeaderboardGroup `bun:"rel:has-many,join:id=leaderboard_id" json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
type LeaderboardGroup struct {
|
||||
bun.BaseModel `bun:"table:leaderboard_groups,alias:lbg"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
LeaderboardID int64 `bun:"leaderboard_id,notnull" json:"leaderboard_id"`
|
||||
Key string `bun:"key,notnull" json:"key"`
|
||||
Name string `bun:"name,notnull" json:"name"`
|
||||
IsDefault bool `bun:"is_default,notnull,default:false" json:"is_default"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Leaderboard *Leaderboard `bun:"rel:belongs-to,join:leaderboard_id=id" json:"leaderboard,omitempty"`
|
||||
Members []LeaderboardGroupMember `bun:"rel:has-many,join:id=group_id" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type LeaderboardGroupMember struct {
|
||||
bun.BaseModel `bun:"table:leaderboard_group_members,alias:lbgm"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
GroupID int64 `bun:"group_id,notnull" json:"group_id"`
|
||||
UserID int64 `bun:"user_id,notnull" json:"user_id"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
|
||||
Group *LeaderboardGroup `bun:"rel:belongs-to,join:group_id=id" json:"-"`
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type LeaderboardScore struct {
|
||||
bun.BaseModel `bun:"table:leaderboard_scores,alias:lbs"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
LeaderboardID int64 `bun:"leaderboard_id,notnull" json:"leaderboard_id"`
|
||||
UserGameID int64 `bun:"user_game_id,notnull" json:"user_game_id"`
|
||||
Score float64 `bun:"score,notnull" json:"score"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
Leaderboard *Leaderboard `bun:"rel:belongs-to,join:leaderboard_id=id" json:"leaderboard,omitempty"`
|
||||
UserGame *UserGame `bun:"rel:belongs-to,join:user_game_id=id" json:"user_game,omitempty"`
|
||||
}
|
||||
|
||||
type LeaderboardRankItem struct {
|
||||
Rank int64 `json:"rank"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserGameID int64 `json:"user_game_id"`
|
||||
Username string `json:"username"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// ─── UserGame (pivot — user's connected games + balance) ────
|
||||
type UserGame struct {
|
||||
bun.BaseModel `bun:"table:user_games,alias:ug"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
UserID int64 `bun:"user_id,notnull" json:"user_id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
Balance float64 `bun:"balance,notnull,default:0" json:"balance"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updated_at"`
|
||||
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
|
||||
Game *Game `bun:"rel:belongs-to,join:game_id=id" json:"game,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Invite Link ────────────────────────────────────────────
|
||||
type InviteLink struct {
|
||||
bun.BaseModel `bun:"table:invite_links,alias:il"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Code string `bun:"code,notnull,unique" json:"code"`
|
||||
Role Role `bun:"role,notnull,default:'user'" json:"role"`
|
||||
MaxUses int `bun:"max_uses,notnull,default:1" json:"max_uses"`
|
||||
UsedCount int `bun:"used_count,notnull,default:0" json:"used_count"`
|
||||
CreatedByID int64 `bun:"created_by_id,notnull" json:"created_by_id"`
|
||||
ExpiresAt *time.Time `bun:"expires_at" json:"expires_at"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
|
||||
CreatedBy *User `bun:"rel:belongs-to,join:created_by_id=id" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Balance Transaction Log ────────────────────────────────
|
||||
type BalanceTransaction struct {
|
||||
bun.BaseModel `bun:"table:balance_transactions,alias:bt"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
UserGameID int64 `bun:"user_game_id,notnull" json:"user_game_id"`
|
||||
Amount float64 `bun:"amount,notnull" json:"amount"`
|
||||
Type string `bun:"type,notnull" json:"type"` // "topup" | "withdraw"
|
||||
Comment string `bun:"comment" json:"comment"`
|
||||
AdminID int64 `bun:"admin_id,notnull" json:"admin_id"`
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
}
|
||||
|
||||
// ─── Request / Response DTOs ────────────────────────────────
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required,min=3"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
|
||||
type TopUpRequest struct {
|
||||
Amount float64 `json:"amount" binding:"required,gt=0"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
type CreateInviteRequest struct {
|
||||
Role Role `json:"role" binding:"required"`
|
||||
MaxUses int `json:"max_uses" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
type CreateGameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
type GameVariableItemRequest struct {
|
||||
Key string `json:"key"`
|
||||
Index *int64 `json:"index"`
|
||||
NumberValue *float64 `json:"number_value"`
|
||||
StringValue *string `json:"string_value"`
|
||||
}
|
||||
|
||||
type UpsertGameVariableRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type GameVariableType `json:"type" binding:"required"`
|
||||
NumberValue *float64 `json:"number_value"`
|
||||
StringValue *string `json:"string_value"`
|
||||
TableValueType *GameVariableTableValueType `json:"table_value_type"`
|
||||
Items []GameVariableItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
type ConnectGameModuleRequest struct {
|
||||
ModuleKey string `json:"module_key" binding:"required"`
|
||||
}
|
||||
|
||||
type UpsertLanguageRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
type NotificationDescriptionRequest struct {
|
||||
LanguageID int64 `json:"language_id" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
}
|
||||
|
||||
type NotificationVariableRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
|
||||
type NotificationEntryVariableRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type NotificationEntryRequest struct {
|
||||
TimeSecond int64 `json:"time_second"`
|
||||
Image string `json:"image" binding:"required"`
|
||||
Login string `json:"login" binding:"required"`
|
||||
Variables []NotificationEntryVariableRequest `json:"variables"`
|
||||
}
|
||||
|
||||
type UpsertNotificationRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Descriptions []NotificationDescriptionRequest `json:"descriptions" binding:"required"`
|
||||
Variables []NotificationVariableRequest `json:"variables"`
|
||||
Entries []NotificationEntryRequest `json:"entries" binding:"required"`
|
||||
}
|
||||
|
||||
type UpsertLeaderboardRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
SortOrder LeaderboardSortOrder `json:"sort_order" binding:"required"`
|
||||
PeriodType LeaderboardPeriodType `json:"period_type" binding:"required"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type UpsertLeaderboardGroupRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type AddLeaderboardGroupMemberRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
}
|
||||
|
||||
type UpsertLeaderboardScoreRequest struct {
|
||||
UserGameID int64 `json:"user_game_id" binding:"required"`
|
||||
Score float64 `json:"score" binding:"required"`
|
||||
}
|
||||
|
||||
type ConnectGameRequest struct {
|
||||
GameID int64 `json:"game_id" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateUserRoleRequest struct {
|
||||
Role Role `json:"role" binding:"required"`
|
||||
}
|
||||
22
internal/models/transaction.go
Normal file
22
internal/models/transaction.go
Normal file
@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type Transaction struct {
|
||||
bun.BaseModel `bun:"table:transactions,alias:t"`
|
||||
|
||||
ID string `bun:"id,pk,type:varchar(64)" json:"id"`
|
||||
RequestID string `bun:"request_id,notnull" json:"request_id"`
|
||||
UserID int64 `bun:"user_id,notnull" json:"user_id"`
|
||||
GameID int64 `bun:"game_id,notnull" json:"game_id"`
|
||||
RoundID string `bun:"round_id,notnull" json:"round_id"`
|
||||
Amount int64 `bun:"amount,notnull" json:"amount"`
|
||||
WinAmount int64 `bun:"win_amount,notnull,default:0" json:"win_amount"`
|
||||
Currency string `bun:"currency,notnull" json:"currency"`
|
||||
Status string `bun:"status,notnull" json:"status"` // reserved / confirmed / canceled
|
||||
CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"created_at"`
|
||||
}
|
||||
20
internal/wallet/errors.go
Normal file
20
internal/wallet/errors.go
Normal file
@ -0,0 +1,20 @@
|
||||
package wallet
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidAmount = errors.New("amount must be > 0")
|
||||
ErrMissingRequiredFields = errors.New("missing required fields")
|
||||
ErrInvalidUserID = errors.New("user_id must be numeric")
|
||||
ErrInvalidGameID = errors.New("game_id must be numeric")
|
||||
ErrAccountNotFound = errors.New("user account not found")
|
||||
ErrInsufficientFunds = errors.New("insufficient funds")
|
||||
ErrMissingTransactionID = errors.New("missing transaction_id")
|
||||
ErrMissingRoundID = errors.New("missing round_id")
|
||||
ErrInvalidWinAmount = errors.New("win_amount must be >= 0")
|
||||
ErrTransactionNotFound = errors.New("transaction not found")
|
||||
ErrRoundMismatch = errors.New("round_id does not match transaction")
|
||||
ErrAlreadyCanceled = errors.New("transaction already canceled")
|
||||
ErrAlreadyConfirmed = errors.New("transaction already confirmed")
|
||||
ErrInvalidTransactionState = errors.New("invalid transaction state")
|
||||
)
|
||||
331
internal/wallet/service.go
Normal file
331
internal/wallet/service.go
Normal file
@ -0,0 +1,331 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"game-admin/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusReserved = "reserved"
|
||||
StatusConfirmed = "confirmed"
|
||||
StatusCanceled = "canceled"
|
||||
StatusAlreadyConfirmed = "already_confirmed"
|
||||
StatusAlreadyCanceled = "already_canceled"
|
||||
)
|
||||
|
||||
type Balances struct {
|
||||
Available int64
|
||||
Reserved int64
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *bun.DB
|
||||
}
|
||||
|
||||
func NewService(db *bun.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func (s *Service) Reserve(ctx context.Context, requestID, userIDRaw, gameIDRaw, roundID string, amount int64, currency string) (*models.Transaction, string, Balances, error) {
|
||||
if amount <= 0 {
|
||||
return nil, "", Balances{}, ErrInvalidAmount
|
||||
}
|
||||
if requestID == "" || userIDRaw == "" || gameIDRaw == "" || roundID == "" {
|
||||
return nil, "", Balances{}, ErrMissingRequiredFields
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(userIDRaw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", Balances{}, ErrInvalidUserID
|
||||
}
|
||||
|
||||
gameID, err := strconv.ParseInt(gameIDRaw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", Balances{}, ErrInvalidGameID
|
||||
}
|
||||
|
||||
var transaction *models.Transaction
|
||||
var balances Balances
|
||||
status := StatusReserved
|
||||
|
||||
err = s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
existing := new(models.Transaction)
|
||||
err := tx.NewSelect().
|
||||
Model(existing).
|
||||
Where("request_id = ?", requestID).
|
||||
Scan(ctx)
|
||||
if err == nil {
|
||||
transaction = existing
|
||||
balances, err = s.getBalancesTx(ctx, tx, existing.UserID, existing.GameID)
|
||||
return err
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
userGame, err := s.getUserGameTx(ctx, tx, userID, gameID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
available := moneyToMinorUnits(userGame.Balance)
|
||||
reserved, err := s.sumReservedTx(ctx, tx, userID, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if available < amount {
|
||||
return ErrInsufficientFunds
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Model((*models.UserGame)(nil)).
|
||||
Set("balance = ?", minorUnitsToMoney(available-amount)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", userGame.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transaction = &models.Transaction{
|
||||
ID: uuid.NewString(),
|
||||
RequestID: requestID,
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
RoundID: roundID,
|
||||
Amount: amount,
|
||||
WinAmount: 0,
|
||||
Currency: currency,
|
||||
Status: StatusReserved,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if _, err := tx.NewInsert().Model(transaction).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
balances = Balances{
|
||||
Available: available - amount,
|
||||
Reserved: reserved + amount,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return transaction, status, balances, err
|
||||
}
|
||||
|
||||
func (s *Service) Confirm(ctx context.Context, transactionID, roundID string, winAmount int64) (*models.Transaction, string, Balances, error) {
|
||||
if transactionID == "" {
|
||||
return nil, "", Balances{}, ErrMissingTransactionID
|
||||
}
|
||||
if roundID == "" {
|
||||
return nil, "", Balances{}, ErrMissingRoundID
|
||||
}
|
||||
if winAmount < 0 {
|
||||
return nil, "", Balances{}, ErrInvalidWinAmount
|
||||
}
|
||||
|
||||
var transaction *models.Transaction
|
||||
var balances Balances
|
||||
status := StatusConfirmed
|
||||
|
||||
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
current, err := s.getTransactionTx(ctx, tx, transactionID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.RoundID != roundID {
|
||||
return ErrRoundMismatch
|
||||
}
|
||||
|
||||
userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch current.Status {
|
||||
case StatusConfirmed:
|
||||
status = StatusAlreadyConfirmed
|
||||
case StatusCanceled:
|
||||
return ErrAlreadyCanceled
|
||||
case StatusReserved:
|
||||
available := moneyToMinorUnits(userGame.Balance)
|
||||
_, err = tx.NewUpdate().
|
||||
Model((*models.UserGame)(nil)).
|
||||
Set("balance = ?", minorUnitsToMoney(available+winAmount)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", userGame.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current.WinAmount = winAmount
|
||||
current.Status = StatusConfirmed
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(current).
|
||||
Column("win_amount", "status").
|
||||
WherePK().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrInvalidTransactionState
|
||||
}
|
||||
|
||||
transaction = current
|
||||
balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID)
|
||||
return err
|
||||
})
|
||||
|
||||
return transaction, status, balances, err
|
||||
}
|
||||
|
||||
func (s *Service) Cancel(ctx context.Context, transactionID string) (*models.Transaction, string, Balances, error) {
|
||||
if transactionID == "" {
|
||||
return nil, "", Balances{}, ErrMissingTransactionID
|
||||
}
|
||||
|
||||
var transaction *models.Transaction
|
||||
var balances Balances
|
||||
status := StatusCanceled
|
||||
|
||||
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
current, err := s.getTransactionTx(ctx, tx, transactionID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userGame, err := s.getUserGameTx(ctx, tx, current.UserID, current.GameID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch current.Status {
|
||||
case StatusCanceled:
|
||||
status = StatusAlreadyCanceled
|
||||
case StatusConfirmed:
|
||||
return ErrAlreadyConfirmed
|
||||
case StatusReserved:
|
||||
available := moneyToMinorUnits(userGame.Balance)
|
||||
_, err = tx.NewUpdate().
|
||||
Model((*models.UserGame)(nil)).
|
||||
Set("balance = ?", minorUnitsToMoney(available+current.Amount)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", userGame.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current.Status = StatusCanceled
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(current).
|
||||
Column("status").
|
||||
WherePK().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrInvalidTransactionState
|
||||
}
|
||||
|
||||
transaction = current
|
||||
balances, err = s.getBalancesTx(ctx, tx, current.UserID, current.GameID)
|
||||
return err
|
||||
})
|
||||
|
||||
return transaction, status, balances, err
|
||||
}
|
||||
|
||||
func (s *Service) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) {
|
||||
if transactionID == "" {
|
||||
return nil, ErrMissingTransactionID
|
||||
}
|
||||
|
||||
return s.getTransactionTx(ctx, s.db, transactionID, false)
|
||||
}
|
||||
|
||||
func (s *Service) getTransactionTx(ctx context.Context, db bun.IDB, transactionID string, lock bool) (*models.Transaction, error) {
|
||||
transaction := new(models.Transaction)
|
||||
query := db.NewSelect().
|
||||
Model(transaction).
|
||||
Where("id = ?", transactionID)
|
||||
if lock {
|
||||
query = query.For("UPDATE")
|
||||
}
|
||||
|
||||
if err := query.Scan(ctx); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrTransactionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
func (s *Service) getUserGameTx(ctx context.Context, db bun.IDB, userID, gameID int64, lock bool) (*models.UserGame, error) {
|
||||
userGame := new(models.UserGame)
|
||||
query := db.NewSelect().
|
||||
Model(userGame).
|
||||
Where("user_id = ? AND game_id = ?", userID, gameID)
|
||||
if lock {
|
||||
query = query.For("UPDATE")
|
||||
}
|
||||
|
||||
if err := query.Scan(ctx); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userGame, nil
|
||||
}
|
||||
|
||||
func (s *Service) getBalancesTx(ctx context.Context, db bun.IDB, userID, gameID int64) (Balances, error) {
|
||||
userGame, err := s.getUserGameTx(ctx, db, userID, gameID, false)
|
||||
if err != nil {
|
||||
return Balances{}, err
|
||||
}
|
||||
|
||||
reserved, err := s.sumReservedTx(ctx, db, userID, gameID)
|
||||
if err != nil {
|
||||
return Balances{}, err
|
||||
}
|
||||
|
||||
return Balances{
|
||||
Available: moneyToMinorUnits(userGame.Balance),
|
||||
Reserved: reserved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) sumReservedTx(ctx context.Context, db bun.IDB, userID, gameID int64) (int64, error) {
|
||||
var reserved int64
|
||||
if err := db.NewSelect().
|
||||
Model((*models.Transaction)(nil)).
|
||||
ColumnExpr("COALESCE(SUM(amount), 0)").
|
||||
Where("user_id = ? AND game_id = ? AND status = ?", userID, gameID, StatusReserved).
|
||||
Scan(ctx, &reserved); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return reserved, nil
|
||||
}
|
||||
|
||||
func moneyToMinorUnits(amount float64) int64 {
|
||||
return int64(math.Round(amount * 100))
|
||||
}
|
||||
|
||||
func minorUnitsToMoney(amount int64) float64 {
|
||||
return float64(amount) / 100
|
||||
}
|
||||
68
proto/wallet.proto
Normal file
68
proto/wallet.proto
Normal file
@ -0,0 +1,68 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package wallet;
|
||||
|
||||
option go_package = "gen/;walletpb";
|
||||
|
||||
service WalletService {
|
||||
rpc Reserve(ReserveRequest) returns (ReserveResponse);
|
||||
rpc Confirm(ConfirmRequest) returns (ConfirmResponse);
|
||||
rpc Cancel(CancelRequest) returns (CancelResponse);
|
||||
rpc GetTransaction(GetTransactionRequest) returns (GetTransactionResponse);
|
||||
}
|
||||
|
||||
message ReserveRequest {
|
||||
string request_id = 1;
|
||||
string user_id = 2;
|
||||
string game_id = 3;
|
||||
string round_id = 4;
|
||||
int64 amount = 5; // в минорных единицах, например cents
|
||||
string currency = 6;
|
||||
}
|
||||
|
||||
message ReserveResponse {
|
||||
string transaction_id = 1;
|
||||
string status = 2; // reserved / rejected
|
||||
int64 available_balance = 3;
|
||||
int64 reserved_balance = 4;
|
||||
}
|
||||
|
||||
message ConfirmRequest {
|
||||
string request_id = 1;
|
||||
string transaction_id = 2;
|
||||
string round_id = 3;
|
||||
int64 win_amount = 4; // 0 если проигрыш
|
||||
}
|
||||
|
||||
message ConfirmResponse {
|
||||
string transaction_id = 1;
|
||||
string status = 2; // confirmed / already_confirmed
|
||||
int64 available_balance = 3;
|
||||
int64 reserved_balance = 4;
|
||||
}
|
||||
|
||||
message CancelRequest {
|
||||
string request_id = 1;
|
||||
string transaction_id = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message CancelResponse {
|
||||
string transaction_id = 1;
|
||||
string status = 2; // canceled / already_canceled
|
||||
int64 available_balance = 3;
|
||||
int64 reserved_balance = 4;
|
||||
}
|
||||
|
||||
message GetTransactionRequest {
|
||||
string transaction_id = 1;
|
||||
}
|
||||
|
||||
message GetTransactionResponse {
|
||||
string transaction_id = 1;
|
||||
string user_id = 2;
|
||||
string round_id = 3;
|
||||
int64 amount = 4;
|
||||
int64 win_amount = 5;
|
||||
string status = 6; // reserved / confirmed / canceled
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user