diff --git a/ADMIN_SETUP.md b/ADMIN_SETUP.md new file mode 100644 index 0000000..a7e5996 --- /dev/null +++ b/ADMIN_SETUP.md @@ -0,0 +1,114 @@ +# Admin User Setup Guide + +This guide explains how to create an admin user in the database. + +## Prerequisites + +- Access to the MySQL database +- Spring Boot application running (to generate password hash) + +## Method 1: Using Spring Boot Application + +1. Create a simple test class or use the Spring Boot shell to generate a password hash: + +```java +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); +String hashedPassword = encoder.encode("your-secure-password"); +System.out.println(hashedPassword); +``` + +2. Connect to your MySQL database and run: + +```sql +-- Insert a new admin user into the admins table +INSERT INTO admins ( + username, + password_hash, + role +) VALUES ( + 'admin', + '$2a$10$YourGeneratedHashHere', + 'ROLE_ADMIN' +); +``` + +## Method 2: Using Online BCrypt Generator + +1. Use an online BCrypt generator (e.g., https://bcrypt-generator.com/) +2. Enter your desired password +3. Copy the generated hash +4. Use it in the SQL UPDATE/INSERT statement above + +## Method 3: Using Command Line (if bcrypt-cli is installed) + +```bash +bcrypt-cli hash "your-password" 10 +``` + +## Security Best Practices + +1. **Use Strong Passwords**: Minimum 12 characters with mix of letters, numbers, and symbols +2. **Change Default Credentials**: Never use default usernames/passwords in production +3. **Limit Admin Users**: Only create admin accounts for trusted personnel +4. **Regular Audits**: Periodically review admin users and their activity +5. **JWT Secret**: Ensure `APP_ADMIN_JWT_SECRET` in application.yml is set to a secure random string (minimum 32 characters) + +## Generate JWT Secret + +You can generate a secure JWT secret using: + +```bash +# Using OpenSSL +openssl rand -base64 32 + +# Or using Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +Then set it in your environment variable or application.yml: + +```yaml +app: + admin: + jwt: + secret: ${APP_ADMIN_JWT_SECRET:your-generated-secret-here} +``` + +## Testing Admin Login + +After setting up an admin user, test the login: + +```bash +curl -X POST https://win-spin.live/api/admin/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"your-password"}' +``` + +You should receive a response with a JWT token: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "username": "admin" +} +``` + +## Troubleshooting + +### "Invalid credentials" error +- Verify the password hash was generated correctly +- Check that the `username` in the `admins` table matches exactly (case-sensitive) +- Ensure the admin has `role = 'ROLE_ADMIN'` in the `admins` table + +### "Access Denied" after login +- Verify the JWT token is being sent in the Authorization header: `Bearer ` +- Check backend logs for authentication errors +- Verify CORS configuration includes your admin domain + +### Password hash format +- BCrypt hashes should start with `$2a$`, `$2b$`, or `$2y$` +- The hash should be 60 characters long +- Example format: `$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy` + diff --git a/APPLICATION_OVERVIEW.md b/APPLICATION_OVERVIEW.md new file mode 100644 index 0000000..c602d61 --- /dev/null +++ b/APPLICATION_OVERVIEW.md @@ -0,0 +1,1024 @@ +# Lottery Application - Complete Overview + +## Table of Contents +1. [Application Overview](#application-overview) +2. [Architecture](#architecture) +3. [Core Features](#core-features) +4. [Game Mechanics](#game-mechanics) +5. [Referral System](#referral-system) +6. [Tasks System](#tasks-system) +7. [Payment System](#payment-system) +8. [User Management](#user-management) +9. [Feature Switches](#feature-switches) +10. [Lottery Bot System](#lottery-bot-system) +11. [Database Schema](#database-schema) +12. [API Endpoints](#api-endpoints) + +--- + +## Application Overview + +The Lottery Application is a Telegram Mini App that provides a real-time lottery game experience. Users can participate in lottery rounds across multiple game rooms, complete tasks to earn rewards, refer friends to earn commissions, and manage their balance through Telegram Stars payments. + +**Technology Stack:** +- **Backend:** Java 17, Spring Boot 3.2.0, MySQL, WebSocket (STOMP) +- **Frontend:** React, Vite, WebSocket client +- **Authentication:** Telegram Mini App initData validation, JWT for admin panel +- **Payment:** Crypto deposits (external API), optional Telegram Stars (legacy); feature switch for deposits +- **Payout:** Crypto withdrawals (external API), STARS/GIFT (Telegram); feature switch for withdrawals +- **Infrastructure:** Docker, Nginx, VPS deployment + +**Domain:** `win-spin.live` + +--- + +## Architecture + +### Backend Architecture +- **Spring Boot REST API** with WebSocket support for real-time game updates +- **Dual Authentication System:** + - Telegram initData validation for app users (via `AuthInterceptor`) + - JWT-based authentication for admin panel (via Spring Security) +- **Session Management:** Bearer token-based sessions stored in `sessions` table +- **Real-time Communication:** WebSocket (STOMP) for game state broadcasts + +### Frontend Architecture +- **Main App (lottery-fe):** React SPA (Vite) deployed at `win-spin.live`. Path `/` shows a home/landing screen; `/auth` (or other paths) loads the authenticated app. Screens: Main (game), Game History, FAQ, Support, Support Chat, Referral, Transaction History, Store, Payment Options/Confirmation/Error, Tasks, Payout, Stars Payout Confirmation, Daily Bonus. Balance and user state from `GET /api/users/current`; game join and room state via WebSocket. i18n and Telegram WebApp integration. +- **Admin Panel (lottery-admin):** React SPA (Vite, React Router, TanStack Query) at `win-spin.live/{secret-path}/`. Routes: Login, Dashboard (stats), Rooms (list + RoomDetail), Feature Switchers, Configurations, Bots, Users (list + UserDetail), Payments, Payouts, Support Tickets (list + TicketDetail, Quick Answers), Masters, Analytics (User, Game, Financial, Referral). Role-based visibility: ROLE_ADMIN (all), ROLE_PAYOUT_SUPPORT (payouts), ROLE_TICKETS_SUPPORT (tickets + quick answers). +- **WebSocket Client (lottery-fe):** Real-time game state via STOMP; subscribe to `/topic/room/{roomNumber}/state`, send join via `/app/game/join`. + +### Database +- **MySQL** with Flyway migrations +- **Normalized Schema:** User data split across multiple tables (A, B, D) +- **Performance:** Indexed for high concurrency (1000+ concurrent users) + +--- + +## Core Features + +### 1. Real-Time Lottery Game +- **3 Game Rooms** with different bet limits: + - Room 1: 1-50 tickets + - Room 2: 10-500 tickets + - Room 3: 100-5000 tickets +- **Game Phases:** + - `WAITING`: Waiting for at least 2 players + - `COUNTDOWN`: 5-second countdown after 2nd player joins + - `SPINNING`: 5-second spinning animation + - `RESOLUTION`: Winner announcement +- **Winner Selection:** Random selection based on ticket distribution (weighted lottery) +- **House Commission:** 20% of total pool (80% distributed to winner) + +### 2. User Balance System +- **Balance A:** Main balance (used for betting, can be withdrawn) +- **Balance B:** Secondary balance (bonuses, rewards) +- **Currency:** Internal "tickets" system (1 ticket = 1,000,000 in bigint format) +- **Deposit Conversion:** 1 USD = 1,000 tickets (crypto deposits); legacy Telegram Stars conversion no longer used for new deposits +- **Withdrawal Limit:** Only winnings since last deposit can be withdrawn; tracked in `total_win_after_deposit` (reset on deposit, reduced when payout created) + +### 3. Telegram Integration +- **Telegram Mini App:** Full integration with Telegram WebView +- **Telegram Stars Payments:** Deposit via Telegram Stars +- **Telegram Bot:** Handles webhooks, channel membership verification +- **Avatar Management:** Downloads and stores user avatars from Telegram + +### 4. Multi-Language Support +- **i18n:** Full internationalization support +- **Languages:** Multiple language codes stored per user +- **Localization Service:** Dynamic message translation + +### 5. Support System +- **Support Tickets:** Users can create tickets +- **Support Messages:** Threaded conversations per ticket +- **Status Tracking:** OPENED/CLOSED status + +### 6. Admin Panel +- **Secret Path Access:** Accessible via `win-spin.live/{secret-path}/` +- **Role-Based Access:** `ROLE_ADMIN` (full access), `ROLE_PAYOUT_SUPPORT` (payouts only), `ROLE_TICKETS_SUPPORT` (support tickets and quick answers). Admins table stores `role` per account. +- **Dashboard:** Stats overview (total/new users, active players 24h/7d/30d, revenue and payouts in Stars and crypto USD, net revenue, rounds count, open support tickets). Served by `GET /api/admin/dashboard/stats`. +- **Rooms:** List all game rooms; room detail by room number (participants, phase, totals); repair round for stuck rooms (`POST /api/admin/rooms/{roomNumber}/repair`). +- **Configurations:** App configuration view for admins (`GET /api/admin/configurations`). +- **Bots:** Lottery bot configs management—create/update/delete bot configs (linked to real users), shuffle bot order. Scheduler (when `lottery_bot_scheduler_enabled` is on) auto-joins bots into joinable rounds per time window and persona. +- **User Management:** Paginated users list; user detail (profile, balance, transactions, game rounds, tasks); ban user; manual balance adjustment. +- **Feature Switchers:** Runtime toggles for remote_bet_enabled, payment_enabled, payout_enabled, lottery_bot_scheduler_enabled. +- **Payments:** List payments with filters (userId, status); crypto and legacy records. +- **Payouts:** List payouts with filters (userId, status, type); manual complete/cancel for PROCESSING or WAITING (CRYPTO status synced by cron). +- **Support Tickets:** List tickets, ticket detail with messages; close ticket. **Quick Answers:** Per-admin templates for fast replies (CRUD); access for ROLE_ADMIN and ROLE_TICKETS_SUPPORT. +- **Masters:** Referral masters view. +- **Analytics:** Revenue time series (crypto USD) and activity metrics (new users, rounds, revenue by period) for charts; range 7d/30d/90d/1y/all. +- **JWT Authentication:** Separate admin authentication system. +- **Admin Table:** Separate `admins` table; optional `user_id` linking to `db_users_a` for support messages. + +--- + +## Game Mechanics + +### Game Flow + +1. **User Joins Room:** + - User selects a room (1, 2, or 3) + - User places a bet (within room limits) + - Balance A is deducted immediately + - User becomes a participant + +2. **Round Start:** + - When 2nd player joins, countdown starts (5 seconds) + - During countdown, more players can join + - After countdown, spinning phase begins (5 seconds) + +3. **Winner Selection:** + - Winner selected randomly based on ticket distribution + - Probability = (user_tickets / total_tickets) + - House takes 20% commission + - Winner receives 80% of total pool + +4. **Round Resolution:** + - Winner's balance credited + - Referral commissions processed + - Round marked as completed + - New round starts automatically + +### Bet Limits Per Room + +| Room | Min Bet | Max Bet | Max Bet Per User | +|------|---------|---------|------------------| +| 1 | 1 | 50 | 50 | +| 2 | 10 | 500 | 500 | +| 3 | 100 | 5000 | 5000 | + +**Note:** All amounts stored in bigint format (1 ticket = 1,000,000) + +### Game Phases + +- **WAITING:** Room waiting for players (minimum 2 required) +- **COUNTDOWN:** 5-second countdown after 2nd player joins +- **SPINNING:** 5-second spinning animation (no new bets allowed) +- **RESOLUTION:** Winner announced, payouts processed + +### Real-Time Updates + +- **WebSocket Broadcasts:** All room state changes broadcast to connected clients +- **State Synchronization:** Clients receive full room state on connection +- **Participant Updates:** Real-time participant list and bet totals + +--- + +## Referral System + +### 3-Tier Referral Structure + +The referral system tracks up to 5 levels of referrers, but commissions are paid to the first 3 levels: + +**Referral Chain:** +- `referer_id_1`: Direct referrer (Level 1) +- `referer_id_2`: Referrer's referrer (Level 2) +- `referer_id_3`: Referrer's referrer's referrer (Level 3) +- `referer_id_4`: Level 4 (tracked but no commission) +- `referer_id_5`: Level 5 (tracked but no commission) + +### Commission Rates + +#### When Referral Wins: +- **All Levels:** 1% of referral's net profit +- **Net Profit Calculation:** (total_pool - house_commission) - referral_bet + +#### When Referral Loses: +- **Level 1:** 4% of referral's bet +- **Level 2:** 2% of referral's bet +- **Level 3:** 1% of referral's bet + +### Referral Statistics + +Each user tracks: +- **referals_1 to referals_5:** Count of direct referrals at each level +- **from_referals_1 to from_referals_5:** Total commissions earned from each level +- **to_referer_1 to to_referer_5:** Total commissions paid to referrers + +### Third Bet Bonus + +- Special bonus given to referrers when their referral makes their 3rd bet +- Uses `rounds_played` column (not transaction count) +- Prevents issues with transaction cleanup cron jobs + +--- + +## Tasks System + +### Task Types + +1. **Referral Tasks (`referral`):** + - Invite 1, 3, 7, 15, 30, 50, 100 friends + - Rewards: 2, 5, 15, 25, 40, 60, 150 Stars respectively + - One-time rewards (can only claim each tier once) + +2. **Follow Task (`follow`):** + - Follow the news channel + - Reward: 5 Stars + - Verified via Telegram Bot API + +3. **Deposit Task (`other`):** + - Top up balance: $5 + - Reward: 100 Stars + - Based on `deposit_total` field + +4. **Daily Bonus (`daily`):** + - Claim 1 free ticket every 24 hours + - Reward: 1 ticket + - Tracked in `user_daily_bonus_claims` table + +### Task Completion Logic + +- **Referral Tasks:** Counts `referals_1` from `db_users_d` +- **Follow Task:** Verified via Telegram Bot API channel membership check +- **Deposit Task:** Checks `deposit_total >= requirement` +- **Daily Bonus:** Checks if last claim was > 24 hours ago + +### Task Rewards + +- Rewards credited to **Balance A** (main balance) +- Transaction recorded in `transactions` table with type `TASK_BONUS` +- Task marked as claimed in `user_task_claims` table + +--- + +## Payment System + +### Crypto Deposits (Primary) + +Deposits are handled via an external crypto API (e.g. spin-passim.tech). The backend syncs deposit methods periodically and provides deposit addresses on demand; balance is credited when the 3rd party sends a completion webhook. + +**Deposit Flow:** +1. User enters USD amount on Store screen (min 2, max 10,000 USD; max 2 decimal places). +2. User selects a crypto method on Payment Options (methods from `crypto_deposit_methods`, synced every 10 min from external API). +3. Backend calls external API `POST api/v1/deposit-address` (pid, amountUsd, userData); no payment record is created yet. +4. User is shown wallet address and exact amount in coins to send (Payment Confirmation screen). +5. When the 3rd party detects the deposit, it calls `POST /api/deposit_webhook/{token}` with `user_id` and `usd_amount`. +6. Backend creates a COMPLETED payment record, credits balance (1 USD = 1,000 tickets in DB units: 1 ticket = 1,000,000), updates deposit_total/deposit_count, resets total_win_after_deposit, and creates a DEPOSIT transaction. + +**Payment Record (crypto):** +- `usd_amount`: decimal (e.g. 1.25 USD) +- `tickets_amount`: bigint (1 USD = 1,000 tickets → 1,000,000,000 in DB) +- `stars_amount`: 0 for crypto +- `status`: PENDING (if invoice created but not used), COMPLETED, FAILED, CANCELLED + +**Feature Switch:** When `payment_enabled` is off, deposit-related endpoints return 503 and the Store shows "deposits temporarily unavailable". + +### Telegram Stars (Legacy) + +- Creating new payments via Stars (invoice link) is no longer supported; the app uses crypto-only for new deposits. +- Telegram payment webhook is still processed for backward compatibility if a legacy payment is completed. +- Legacy conversion: Stars × 0.9 × 1,000,000 for tickets (no longer used for new flows). + +### Payout System + +**Payout Types:** +- **CRYPTO:** Primary. User selects withdrawal method (e.g. TON, LTC, TRX), enters wallet and amount in tickets. Backend calls external API `POST api/v1/withdrawal`; on success creates payout with `payment_id`, deducts balance, and a cron job syncs status from `GET api/v1/withdrawals-info/{payment_id}`. +- **STARS:** Convert tickets to Telegram Stars (fixed amounts; admin may process manually). +- **GIFT:** Convert to Telegram gifts (HEART, BEAR, etc.); admin may process manually. + +**Crypto Payout Flow:** +1. User opens Payout screen; backend returns withdrawal methods from `crypto_withdrawal_methods` (synced every 30 min from external API). +2. User selects method, enters wallet and amount (tickets). Frontend may call `GET withdrawal-method-details?pid=` for rate_usd and fee to show "You will receive". +3. User submits; backend acquires per-user lock, calls external API POST withdrawal, then creates payout (type CRYPTO) with payment_id, crypto_name, amount_coins, commission_coins, amount_to_send, wallet, status PROCESSING, and deducts balance. +4. Cron job (every minute) fetches PROCESSING/WAITING CRYPTO payouts with payment_id, calls withdrawals-info API, and updates status: -1→PROCESSING, 0→WAITING, 1→COMPLETED (marks completed, updates withdraw_total/count), 2→CANCELLED (refunds balance, creates cancellation transaction). + +**Payout Statuses:** +- `PROCESSING`: Created or being processed by external provider +- `WAITING`: Awaiting confirmation (from API status 0) +- `COMPLETED`: Resolved; user withdraw_total/count updated +- `CANCELLED`: Refunded to balance_a + +**Withdrawal Rules:** +- At least one deposit required before any withdrawal. +- Withdrawal amount cannot exceed `total_win_after_deposit` (winnings since last deposit). +- Crypto withdrawal amount must have at most 2 decimal places (in ticket units). +- One withdrawal at a time per user (in-memory lock). + +**Feature Switch:** When `payout_enabled` is off, withdrawal-related endpoints return 503 and the Payout screen shows "withdrawals temporarily unavailable". + +--- + +## User Management + +### User Data Structure + +User data is split across 3 tables for normalization: + +**`db_users_a` (User Authentication & Profile):** +- Basic user info (Telegram ID, name, language, country) +- Registration and login timestamps +- Ban status +- Avatar URL + +**`db_users_b` (User Balance & Financial):** +- Balance A (main balance) +- Balance B (bonus balance) +- Deposit/withdraw totals and counts +- Rounds played counter +- **total_win_after_deposit:** Total winnings since last deposit (bigint). Reset to 0 on each deposit; incremented when user wins a round; reduced when user creates a payout. Used to enforce withdrawal limit (only this amount can be withdrawn). + +**`db_users_d` (User Referral Data):** +- Referral chain (5 levels) +- Referral statistics +- Commission tracking + +### User Sessions + +- **Session Management:** Bearer token-based +- **Multi-Device Support:** Up to 5 active sessions per user +- **Session Expiration:** Configurable TTL +- **Automatic Cleanup:** Cron job removes expired sessions + +### User Authentication + +- **Telegram initData Validation:** Validates Telegram Mini App authentication +- **Session Creation:** Creates session token on first login +- **Session Validation:** `AuthInterceptor` validates all API requests + +- **Current User API:** Returns `paymentEnabled` and `payoutEnabled` from feature switches (used by frontend to show/hide or disable Store and Payout). + +--- + +## Feature Switches + +Runtime toggles stored in `feature_switches` table. Changes take effect immediately without restart. Configurable from the admin panel. + +| Key | Description | +|-----|-------------| +| `remote_bet_enabled` | When on, 3rd party can register users to rounds via GET `/api/remotebet/{token}?user_id=&room=&amount=`. When off, endpoint returns 503. | +| `payment_enabled` | When on, deposits (Store, Payment Options, deposit-address, create invoice) are allowed. When off, related API endpoints return 503 and Store shows "deposits temporarily unavailable". Default: 1. | +| `payout_enabled` | When on, withdrawals (Payout screen, withdrawal-methods, withdrawal-method-details, crypto-withdrawal) are allowed. When off, related endpoints return 503 and Payout shows "withdrawals temporarily unavailable". Default: 1. | +| `lottery_bot_scheduler_enabled` | When on, the lottery bot scheduler auto-joins configured bots (from `lottery_bot_configs`) into joinable rounds within their time windows and persona. When off, scheduler skips registration. Default: 1. | + +--- + +## Lottery Bot System + +Bots are **real users** (from `db_users_a`/`db_users_b`) with a config row in `lottery_bot_configs`. The scheduler runs periodically and, when `lottery_bot_scheduler_enabled` is on, registers these users into active rounds via the same join flow as real players (balance deducted, etc.). + +### Bot Config (per user) + +- **Rooms:** Flags `room_1`, `room_2`, `room_3` (which rooms the bot can play). +- **Time window (UTC):** `time_utc_start`, `time_utc_end`—bot only plays within this window. +- **Bet range:** `bet_min`, `bet_max` in bigint (1 ticket = 1,000,000). +- **Persona:** `conservative`, `aggressive`, or `balanced`—used by the bet decision service to choose bet size and timing. +- **Active:** Toggle to enable/disable this bot without deleting the config. + +One config per user; `user_id` is unique. Admin can create/update/delete configs and shuffle bot order via the Bots page. + +### Scheduler + +- When the feature switch is on, a scheduled job evaluates active configs, checks time windows and room state, and places bets for bots using the bet decision service (persona-based logic). +- Bots participate in the same game flow as human players (rounds, referral logic, etc.). + +--- + +## Database Schema + +### Core Tables + +#### `sessions` +User session management for Bearer token authentication. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `session_id_hash` | VARCHAR(255) | Hashed session ID (unique) | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `created_at` | TIMESTAMP | Session creation time | +| `expires_at` | TIMESTAMP | Session expiration time | + +**Indexes:** +- `idx_session_hash` on `session_id_hash` +- `idx_expires_at` on `expires_at` +- `idx_user_id` on `user_id` +- `idx_user_created` on `(user_id, created_at)` + +#### `db_users_a` +User authentication and profile information. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `screen_name` | VARCHAR(75) | User's screen name | +| `telegram_id` | BIGINT | Telegram user ID (unique) | +| `telegram_name` | VARCHAR(33) | Telegram username | +| `is_premium` | INT | Telegram Premium status (0/1) | +| `language_code` | VARCHAR(2) | User's language code | +| `country_code` | VARCHAR(2) | User's country code | +| `device_code` | VARCHAR(5) | Device type code | +| `ip` | VARBINARY(16) | User's IP address (IPv4/IPv6) | +| `date_reg` | INT | Registration timestamp (Unix) | +| `date_login` | INT | Last login timestamp (Unix) | +| `banned` | INT | Ban status (0/1) | +| `avatar_url` | VARCHAR(500) | Avatar URL with cache busting | +| `last_telegram_file_id` | VARCHAR(255) | Telegram file ID for avatar | + +**Indexes:** +- Primary key on `id` +- Unique key on `telegram_id` +- Index on `telegram_name` +- Index on `ip` + +#### `db_users_b` +User balance and financial information. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key (matches `db_users_a.id`) | +| `balance_a` | BIGINT UNSIGNED | Main balance (tickets in bigint format) | +| `balance_b` | BIGINT UNSIGNED | Bonus balance (tickets in bigint format) | +| `deposit_total` | BIGINT | Total deposits (in bigint format) | +| `deposit_count` | INT | Number of deposits | +| `withdraw_total` | BIGINT | Total withdrawals (in bigint format) | +| `withdraw_count` | INT | Number of withdrawals | +| `rounds_played` | INT | Number of rounds user participated in | +| `total_win_after_deposit` | BIGINT | Winnings since last deposit (1 ticket = 1,000,000). Reset on deposit; increased on win; decreased when payout created. Withdrawal limit. | + +**Indexes:** +- Primary key on `id` + +**Note:** `balance_a` and `balance_b` store values in bigint format where 1 ticket = 1,000,000 + +#### `db_users_d` +User referral data and statistics. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key (matches `db_users_a.id`) | +| `referer_id_1` | INT | Level 1 referrer ID | +| `referer_id_2` | INT | Level 2 referrer ID | +| `referer_id_3` | INT | Level 3 referrer ID | +| `referer_id_4` | INT | Level 4 referrer ID | +| `referer_id_5` | INT | Level 5 referrer ID | +| `master_id` | INT | Master referrer ID | +| `referals_1` | INT | Count of level 1 referrals | +| `referals_2` | INT | Count of level 2 referrals | +| `referals_3` | INT | Count of level 3 referrals | +| `referals_4` | INT | Count of level 4 referrals | +| `referals_5` | INT | Count of level 5 referrals | +| `from_referals_1` | BIGINT | Commissions earned from level 1 | +| `from_referals_2` | BIGINT | Commissions earned from level 2 | +| `from_referals_3` | BIGINT | Commissions earned from level 3 | +| `from_referals_4` | BIGINT | Commissions earned from level 4 | +| `from_referals_5` | BIGINT | Commissions earned from level 5 | +| `to_referer_1` | BIGINT | Commissions paid to level 1 referrer | +| `to_referer_2` | BIGINT | Commissions paid to level 2 referrer | +| `to_referer_3` | BIGINT | Commissions paid to level 3 referrer | +| `to_referer_4` | BIGINT | Commissions paid to level 4 referrer | +| `to_referer_5` | BIGINT | Commissions paid to level 5 referrer | + +**Indexes:** +- Primary key on `id` +- Indexes on `referer_id_1` through `referer_id_5` +- Index on `master_id` + +### Game Tables + +#### `game_rooms` +Active game rooms (Room 1, 2, 3). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `room_number` | INT | Room number (1, 2, or 3), unique | +| `current_phase` | VARCHAR(20) | Current game phase (WAITING, COUNTDOWN, SPINNING, RESOLUTION) | +| `countdown_end_at` | TIMESTAMP | Countdown end time (NULL if not counting down) | +| `total_bet` | BIGINT UNSIGNED | Total tickets bet in current round | +| `registered_players` | INT | Number of players in current round | +| `created_at` | TIMESTAMP | Room creation time | +| `updated_at` | TIMESTAMP | Last update time | + +**Indexes:** +- Primary key on `id` +- Unique key on `room_number` +- Index on `current_phase` +- Index on `countdown_end_at` + +#### `game_rounds` +Completed game rounds history. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `room_id` | INT | Foreign key to `game_rooms.id` | +| `phase` | VARCHAR(20) | Final phase (usually RESOLUTION) | +| `total_bet` | BIGINT UNSIGNED | Total tickets/bet in the round | +| `winner_user_id` | INT | Winner's user ID (NULL if no winner) | +| `winner_bet` | BIGINT UNSIGNED | Winner's ticket/bet count | +| `commission` | BIGINT UNSIGNED | House commission (20% of total) | +| `payout` | BIGINT UNSIGNED | Winner's payout (80% of total) | +| `started_at` | TIMESTAMP | Round start time | +| `countdown_started_at` | TIMESTAMP | Countdown start time | +| `countdown_ended_at` | TIMESTAMP | Countdown end time | +| `resolved_at` | TIMESTAMP | Resolution time | +| `created_at` | TIMESTAMP | Record creation time | + +**Indexes:** +- Primary key on `id` +- Index on `room_id` +- Index on `winner_user_id` +- Index on `resolved_at` +- Foreign key to `game_rooms(id)` + +#### `game_round_participants` +Participants in each game round. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `round_id` | BIGINT | Foreign key to `game_rounds.id` | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `tickets` | BIGINT UNSIGNED | User's total tickets in this round | +| `joined_at` | TIMESTAMP | When user joined the round | + +**Indexes:** +- Primary key on `id` +- Index on `round_id` +- Index on `user_id` +- Composite index on `(round_id, user_id)` +- Foreign keys to `game_rounds(id)` and `db_users_a(id)` + +### Task Tables + +#### `tasks` +Available tasks for users to complete. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `type` | VARCHAR(20) | Task type: `referral`, `follow`, `other`, `daily` | +| `requirement` | INT | Requirement value (e.g., number of referrals, deposit amount) | +| `reward_amount` | BIGINT | Reward amount in bigint format | +| `reward_type` | VARCHAR(20) | Reward type: `Stars`, `Tickets`, `Power` | +| `display_order` | INT | Display order in UI | +| `title` | VARCHAR(255) | Task title | +| `description` | TEXT | Task description | + +**Indexes:** +- Primary key on `id` +- Index on `type` +- Index on `display_order` + +**Default Tasks:** +- **Referral:** Invite 1, 3, 7, 15, 30, 50, 100 friends (rewards: 2, 5, 15, 25, 40, 60, 150 Stars) +- **Follow:** Follow news channel (reward: 5 Stars) +- **Deposit:** Top up $5 (reward: 100 Stars) +- **Daily:** Daily bonus (reward: 1 ticket, claimable every 24h) + +#### `user_task_claims` +Tracks which tasks users have claimed. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `task_id` | INT | Foreign key to `tasks.id` | +| `claimed_at` | TIMESTAMP | When task was claimed | + +**Indexes:** +- Primary key on `id` +- Unique composite index on `(user_id, task_id)` +- Index on `user_id` +- Index on `task_id` +- Foreign keys to `db_users_a(id)` and `tasks(id)` + +#### `user_daily_bonus_claims` +Tracks daily bonus claims (optimized table to avoid JOINs). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `avatar_url` | VARCHAR(255) | User's avatar URL (denormalized) | +| `screen_name` | VARCHAR(75) | User's screen name (denormalized) | +| `claimed_at` | TIMESTAMP | Claim time | + +**Indexes:** +- Primary key on `id` +- Index on `user_id` +- Index on `claimed_at` (DESC) +- Foreign key to `db_users_a(id)` + +### Feature Switches Table + +#### `feature_switches` +Runtime feature toggles (e.g. remote bet, deposits, withdrawals). Admin can change without restart. + +| Column | Type | Description | +|--------|------|-------------| +| `key` | VARCHAR(64) | Primary key (e.g. `remote_bet_enabled`, `payment_enabled`, `payout_enabled`) | +| `enabled` | TINYINT(1) | 0 or 1 | +| `updated_at` | TIMESTAMP | Last update | + +### Crypto Deposit Tables + +#### `crypto_deposit_config` +Single row (id=1): minimum_deposit (decimal), methods_hash for sync skip when unchanged. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key (1) | +| `methods_hash` | VARCHAR(255) | Hash from external API; skip sync when unchanged | +| `minimum_deposit` | DECIMAL(10,2) | Minimum USD (e.g. 2.50) | +| `updated_at` | TIMESTAMP | Last update | + +#### `crypto_deposit_methods` +One row per active deposit method (synced from external API every 10 min). `pid` = icon filename without extension (e.g. 235.png). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key | +| `pid` | INT | External API PID (unique) | +| `name` | VARCHAR(100) | e.g. TON, USDT | +| `network` | VARCHAR(50) | e.g. TON, TRC20 | +| `example` | VARCHAR(255) | Example address | +| `min_deposit_sum` | DECIMAL(10,2) | Min USD for this method | +| `updated_at` | TIMESTAMP | Last update | + +### Crypto Withdrawal Tables + +#### `crypto_withdrawal_methods` +One row per active withdrawal method (synced from external API every 30 min). `pid` = method id; `icon_id` = icon filename (e.g. 30.png). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key | +| `pid` | INT | Method ID (unique) | +| `name` | VARCHAR(50) | Ticker (e.g. LTC, TON) | +| `network` | VARCHAR(100) | Network name | +| `icon_id` | VARCHAR(20) | Icon filename without extension | +| `min_withdrawal` | DECIMAL(10,2) | Min withdrawal USD | +| `updated_at` | TIMESTAMP | Last update | + +### Payment Tables + +#### `payments` +Payment records (crypto and legacy Telegram Stars). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `order_id` | VARCHAR(255) | Unique order ID | +| `stars_amount` | INT | Telegram Stars (0 for crypto) | +| `usd_amount` | DECIMAL(20,2) | USD amount (crypto; e.g. 1.25) | +| `tickets_amount` | BIGINT UNSIGNED | Tickets in bigint (1 USD = 1,000 tickets → 1,000,000,000 per USD) | +| `status` | VARCHAR(20) | `PENDING`, `COMPLETED`, `FAILED`, `CANCELLED` | +| `telegram_payment_charge_id` | VARCHAR(255) | Telegram (legacy) | +| `telegram_provider_payment_charge_id` | VARCHAR(255) | Telegram (legacy) | +| `created_at` | TIMESTAMP | Creation time | +| `completed_at` | TIMESTAMP | Completion time | + +**Indexes:** +- Primary key on `id` +- Unique key on `order_id` +- Index on `user_id` +- Index on `status` +- Index on `created_at` +- Composite index on `(user_id, status)` +- Foreign key to `db_users_a(id)` + +#### `payouts` +Withdrawal/payout requests (CRYPTO, STARS, GIFT). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `username` | VARCHAR(255) | User's username (for STARS/GIFT) | +| `wallet` | VARCHAR(120) | Wallet address (CRYPTO) | +| `type` | VARCHAR(20) | `CRYPTO`, `STARS`, `GIFT` | +| `gift_name` | VARCHAR(50) | Gift type for GIFT (HEART, BEAR, etc.) | +| `crypto_name` | VARCHAR(20) | Ticker for CRYPTO (e.g. TRX, TON) | +| `total` | BIGINT UNSIGNED | Tickets in bigint format | +| `stars_amount` | INT | Stars amount (0 for CRYPTO) | +| `usd_amount` | DECIMAL(20,2) | USD equivalent (CRYPTO) | +| `amount_coins` | VARCHAR(50) | Withdrawal amount in coins (from API) | +| `commission_coins` | VARCHAR(50) | Commission in coins (from API) | +| `amount_to_send` | VARCHAR(50) | Final amount to send (from API) | +| `payment_id` | INT | Crypto API payment id (for status sync) | +| `quantity` | INT | Quantity (e.g. gifts); default 1 | +| `status` | VARCHAR(20) | `PROCESSING`, `WAITING`, `COMPLETED`, `CANCELLED` | +| `created_at` | TIMESTAMP | Request time | +| `updated_at` | TIMESTAMP | Last update (touched by cron sync) | +| `resolved_at` | TIMESTAMP | Resolution time | + +**Indexes:** +- Primary key on `id` +- Index on `user_id` +- Index on `status` +- Index on `type` +- Index on `created_at` +- Index on `updated_at` +- Composite indexes for CRYPTO status sync +- Foreign key to `db_users_a(id)` + +#### `transactions` +Transaction history (all balance changes). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `amount` | BIGINT | Amount in bigint format (positive=credit, negative=debit) | +| `type` | VARCHAR(50) | Type: `DEPOSIT`, `WITHDRAWAL`, `WIN`, `LOSS`, `TASK_BONUS`, `CANCELLATION_OF_WITHDRAWAL` | +| `task_id` | INT | Task ID for `TASK_BONUS` type (nullable) | +| `round_id` | BIGINT | Round ID for `WIN`/`LOSS` type (nullable) | +| `created_at` | TIMESTAMP | Transaction time | + +**Indexes:** +- Primary key on `id` +- Composite index on `(user_id, created_at DESC)` +- Composite index on `(user_id, type)` +- Foreign key to `db_users_a(id)` + +**Note:** Transactions older than 30 days are automatically cleaned up by cron job. + +### Support Tables + +#### `support_tickets` +User support tickets. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `subject` | VARCHAR(100) | Ticket subject | +| `status` | ENUM | Status: `OPENED`, `CLOSED` | +| `created_at` | TIMESTAMP | Ticket creation time | +| `updated_at` | TIMESTAMP | Last update time | + +**Indexes:** +- Primary key on `id` +- Index on `user_id` +- Index on `status` +- Composite index on `(user_id, status)` +- Index on `created_at` +- Foreign key to `db_users_a(id)` + +#### `support_messages` +Messages within support tickets. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGINT | Primary key, auto-increment | +| `ticket_id` | BIGINT | Foreign key to `support_tickets.id` | +| `user_id` | INT | Foreign key to `db_users_a.id` | +| `message` | VARCHAR(2000) | Message content | +| `created_at` | TIMESTAMP | Message time | + +**Indexes:** +- Primary key on `id` +- Index on `ticket_id` +- Index on `user_id` +- Composite index on `(ticket_id, created_at)` +- Foreign keys to `support_tickets(id)` and `db_users_a(id)` + +### Admin Tables + +#### `admins` +Admin user accounts (separate from regular users). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `user_id` | INT | Optional FK to `db_users_a.id` (for support messages) | +| `username` | VARCHAR(50) | Admin username (unique) | +| `password_hash` | VARCHAR(255) | BCrypt hashed password | +| `role` | VARCHAR(20) | Role: `ROLE_ADMIN`, `ROLE_PAYOUT_SUPPORT`, `ROLE_TICKETS_SUPPORT` | +| `created_at` | TIMESTAMP | Account creation time | +| `updated_at` | TIMESTAMP | Last update time | + +**Indexes:** +- Primary key on `id` +- Unique key on `username` +- Index on `role` +- Index on `user_id` + +#### `lottery_bot_configs` +Bot behaviour config: one row per user; user must exist in `db_users_a`. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `user_id` | INT | FK to `db_users_a.id` (unique) | +| `room_1` | TINYINT(1) | Can play room 1 (0/1) | +| `room_2` | TINYINT(1) | Can play room 2 (0/1) | +| `room_3` | TINYINT(1) | Can play room 3 (0/1) | +| `time_utc_start` | TIME | Start of active window (UTC) | +| `time_utc_end` | TIME | End of active window (UTC) | +| `bet_min` | BIGINT | Min bet in bigint (1 ticket = 1,000,000) | +| `bet_max` | BIGINT | Max bet in bigint | +| `persona` | VARCHAR(20) | `conservative`, `aggressive`, `balanced` | +| `active` | TINYINT(1) | 1 = enabled, 0 = disabled | +| `created_at` | TIMESTAMP | Creation time | +| `updated_at` | TIMESTAMP | Last update time | + +**Indexes:** +- Unique key on `user_id` +- Index on `(active, room_1, room_2, room_3)` + +#### `quick_answers` +Admin quick-response templates for support (per admin). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | INT | Primary key, auto-increment | +| `admin_id` | INT | FK to `admins.id` | +| `text` | TEXT | Template text | +| `created_at` | TIMESTAMP | Creation time | +| `updated_at` | TIMESTAMP | Last update time | + +**Indexes:** +- Primary key on `id` +- Index on `admin_id` +- Index on `(admin_id, created_at)` + +--- + +## API Endpoints + +### Public Endpoints + +- `POST /api/auth/tma/session` - Create session from Telegram initData +- `GET /api/check_user/{token}/{telegramId}` - Check user info (external API). Token is configured via `APP_CHECK_USER_TOKEN` (set on server only, not in repo). +- `POST /api/deposit_webhook/{token}` - 3rd party deposit completion (no auth). Body: `user_id`, `usd_amount`. Token via `APP_DEPOSIT_WEBHOOK_TOKEN`. + +### Authenticated Endpoints (Bearer Token) + +#### Game (REST) +- `GET /api/game/room/{roomNumber}/completed-rounds` - Last 10 completed rounds for a room +- `GET /api/game/history` - Get game history for current user +- **Join and room state:** Via WebSocket (see WebSocket section below), not REST. + +#### User +- `GET /api/users/current` - Get current user info (includes balance, `paymentEnabled`, `payoutEnabled` from feature switches) +- `GET /api/users/referrals` - Get referral info +- `POST /api/users/deposit` - Legacy/optional deposit flow + +#### Tasks +- `GET /api/tasks?type=` - Get available tasks (query: `type` = referral, follow, other, etc.) +- `GET /api/tasks/daily-bonus` - Daily bonus status +- `GET /api/tasks/daily-bonus/recent-claims` - Recent daily bonus claims (optional query: `timezone`) +- `POST /api/tasks/claim` - Claim task reward (body: `taskId`) + +#### Payments (deposits) +- `GET /api/payments/minimum-deposit` - Min deposit USD from config (503 if payment disabled) +- `GET /api/payments/deposit-methods` - Crypto deposit methods from DB (503 if payment disabled) +- `POST /api/payments/deposit-address` - Get crypto deposit address/amount from external API (pid, usdAmount) +- `POST /api/payments/create` - Create payment invoice (crypto: usdAmount; legacy Stars no longer supported) (503 if payment disabled) +- `POST /api/payments/cancel` - Cancel payment by orderId + +#### Payouts (withdrawals) +- `GET /api/payments/withdrawal-methods` - Crypto withdrawal methods from DB (503 if payout disabled) +- `GET /api/payments/withdrawal-method-details?pid=` - Rate/fee for method from external API (503 if payout disabled) +- `POST /api/payments/crypto-withdrawal` - Create crypto withdrawal (pid, wallet, total in tickets); creates payout and deducts balance (503 if payout disabled) +- `POST /api/payouts` - Request STARS or GIFT payout +- `GET /api/payouts/history` - Last 20 payouts for current user + +#### Support +- `GET /api/support/tickets` - Get user's tickets +- `POST /api/support/tickets` - Create ticket +- `GET /api/support/tickets/{ticketId}` - Get ticket with messages +- `POST /api/support/tickets/{ticketId}/messages` - Send message +- `POST /api/support/tickets/{ticketId}/close` - Close ticket + +#### Transactions +- `GET /api/transactions` - Get current user's transaction history + +### Admin Endpoints (JWT Token) + +All admin paths are under `/api/admin/`. Role requirements: dashboard, rooms, configurations, bots, users, payments, payouts, feature-switches, analytics require ADMIN; payouts also allow ROLE_PAYOUT_SUPPORT; tickets and quick-answers allow ROLE_TICKETS_SUPPORT. + +- `POST /api/admin/login` - Admin login +- `GET /api/admin/dashboard/stats` - Dashboard stats (users, active players, revenue/payouts in Stars and crypto USD, rounds, support tickets) +- `GET /api/admin/rooms` - List all game rooms +- `GET /api/admin/rooms/{roomNumber}` - Room detail (participants, phase, etc.) +- `POST /api/admin/rooms/{roomNumber}/repair` - Repair stuck round +- `GET /api/admin/configurations` - App configurations +- `GET /api/admin/bots` - List lottery bot configs +- `GET /api/admin/bots/{id}` - Get bot config by id +- `POST /api/admin/bots` - Create bot config +- `PUT /api/admin/bots/{id}` - Update bot config +- `DELETE /api/admin/bots/{id}` - Delete bot config +- `POST /api/admin/bots/shuffle?roomNumber=` - Shuffle bot time windows for room (query: `roomNumber` = 2 or 3) +- `GET /api/admin/users` - Paginated users list (50 per page; sort supported) +- `GET /api/admin/users/{id}` - User detail +- `GET /api/admin/users/{id}/transactions` - User transactions +- `GET /api/admin/users/{id}/game-rounds` - User game rounds +- `GET /api/admin/users/{id}/tasks` - User task claims +- `PATCH /api/admin/users/{id}/ban` - Ban/unban user (body: `banned`) +- `POST /api/admin/users/{id}/balance/adjust` - Manual balance adjustment +- `GET /api/admin/feature-switches` - List all feature switches +- `PATCH /api/admin/feature-switches/{key}` - Update feature switch (body: `enabled`) +- `GET /api/admin/payments` - Paginated payments (filter by userId, status) +- `GET /api/admin/payouts` - Paginated payouts (filter by userId, status, type) +- `POST /api/admin/payouts/{id}/complete` - Mark payout COMPLETED +- `POST /api/admin/payouts/{id}/cancel` - Mark payout CANCELLED (refund) +- `GET /api/admin/analytics/revenue` - Revenue/payout time series (query: `range` = 7d, 30d, 90d, 1y, all) +- `GET /api/admin/analytics/activity` - Activity metrics for charts +- `GET /api/admin/quick-answers` - List quick answers for current admin +- `POST /api/admin/quick-answers` - Create quick answer (body: `text`) +- `PUT /api/admin/quick-answers/{id}` - Update quick answer (own only) +- `DELETE /api/admin/quick-answers/{id}` - Delete quick answer (own only) + +### WebSocket + +- **Endpoint:** `/ws` (STOMP over SockJS or WebSocket) +- **Subscribe:** `/topic/room/{roomNumber}/state` - Room state updates (full state on subscribe; broadcasts on phase/participant changes) +- **Send (join round):** `/app/game/join` - Payload: roomNumber, amount (tickets in bigint). Join is handled via WebSocket, not REST. + +--- + +## Key Business Logic + +### Balance Format + +All monetary values in balance/transaction/payout totals are stored in **bigint format** with 6 decimal places: +- 1 Ticket = 1,000,000 (in database) +- **Crypto deposits:** 1 USD = 1,000 tickets → 1,000,000,000 in database per USD +- Legacy: 1 Telegram Star = 900,000 (0.9 conversion; no longer used for new deposits) +- Example: 50 tickets = 50,000,000 in database + +### Winner Selection Algorithm + +1. Calculate total tickets in round +2. Generate random number between 0 and total_tickets +3. Iterate through participants, accumulating ticket counts +4. First participant whose accumulated tickets >= random number wins +5. This creates a weighted lottery (more tickets = higher chance) + +### House Commission + +- **Rate:** 20% of total pool +- **Calculation:** `commission = total_tickets × 0.2` +- **Winner Payout:** `payout = total_tickets - commission` + +### Referral Commission Calculation + +**When Referral Wins:** +``` +net_profit = (total_pool - house_commission) - referral_bet +commission = net_profit × 0.01 // 1% for all levels +``` + +**When Referral Loses:** +``` +Level 1: commission = referral_bet × 0.04 // 4% +Level 2: commission = referral_bet × 0.02 // 2% +Level 3: commission = referral_bet × 0.01 // 1% +``` + +### Data Cleanup + +- **Transactions:** Cleaned up after 30 days (cron job) +- **Game Rounds:** Old rounds cleaned up periodically +- **Sessions:** Expired sessions cleaned up in batches + +### Crypto API Integration + +- **Deposits:** External API base URL and API key via `app.crypto-api.base-url` and `app.crypto-api.api-key`. Deposit methods synced every 10 min (cron); deposit address on demand (POST deposit-address). Completion via `POST /api/deposit_webhook/{token}` (token: `app.deposit-webhook.token`). +- **Withdrawals:** Withdrawal methods synced every 30 min (cron). POST withdrawal creates payout; status sync cron every minute calls GET withdrawals-info for PROCESSING/WAITING CRYPTO payouts and updates status (COMPLETED/CANCELLED trigger balance and transaction updates). + +--- + +## Security Features + +1. **Telegram Authentication:** Validates Telegram Mini App initData +2. **Session Management:** Secure Bearer token sessions +3. **Rate Limiting:** Per-user and per-endpoint rate limiting +4. **CORS Protection:** Configured for specific origins +5. **Admin Authentication:** Separate JWT-based admin system +6. **Input Validation:** All user inputs validated +7. **SQL Injection Protection:** JPA/Hibernate parameterized queries +8. **XSS Protection:** Content Security Policy headers + +--- + +## Performance Optimizations + +1. **Database Indexes:** Comprehensive indexing for fast queries +2. **Connection Pooling:** HikariCP with optimized pool size +3. **WebSocket Keep-Alive:** Efficient real-time communication +4. **Batch Operations:** Bulk cleanup operations +5. **Caching:** In-memory caching for active game rounds +6. **Nginx Caching:** Static asset caching (1 year TTL) +7. **Composite Indexes:** Optimized for common query patterns + +--- + +## Deployment + +- **Backend:** Docker container on VPS +- **Frontend:** Static files served by Nginx +- **Admin Panel:** Static files at `win-spin.live/{secret-path}/` +- **Database:** MySQL on VPS +- **SSL:** Let's Encrypt certificates +- **Domain:** `win-spin.live` + +--- + +This document provides a comprehensive overview of the Lottery Application. For specific implementation details, refer to the source code in the respective service classes and repositories. + + diff --git a/BACKUP_SETUP.md b/BACKUP_SETUP.md new file mode 100644 index 0000000..ed0b682 --- /dev/null +++ b/BACKUP_SETUP.md @@ -0,0 +1,327 @@ +# Database Backup Setup Guide + +This guide explains how to set up automated database backups from your main VPS to your backup VPS. + +## Overview + +- **Main VPS**: 37.1.206.220 (production server) +- **Backup VPS**: 5.45.77.77 (backup storage) +- **Backup Location**: `/raid/backup/acc_260182/` on backup VPS +- **Database**: MySQL 8.0 in Docker container `lottery-mysql` +- **Database Name**: `lottery_db` + +## Prerequisites + +1. SSH access to both VPS servers +2. Root or sudo access on main VPS +3. Write access to `/raid/backup/acc_260182/` on backup VPS + +## Step 1: Set Up SSH Key Authentication + +To enable passwordless transfers, set up SSH key authentication between your main VPS and backup VPS. + +### On Main VPS (37.1.206.220): + +```bash +# Generate SSH key pair (if you don't have one) +ssh-keygen -t ed25519 -C "backup@lottery-main-vps" -f ~/.ssh/backup_key + +# Copy public key to backup VPS +ssh-copy-id -i ~/.ssh/backup_key.pub root@5.45.77.77 + +# Test connection +ssh -i ~/.ssh/backup_key root@5.45.77.77 "echo 'SSH connection successful'" +``` + +**Note**: If you already have an SSH key, you can use it instead. The script uses the default SSH key (`~/.ssh/id_rsa` or `~/.ssh/id_ed25519`). + +### Alternative: Use Existing SSH Key + +If you want to use an existing SSH key, you can either: +1. Use the default key (no changes needed) +2. Configure SSH to use a specific key by editing `~/.ssh/config`: + +```bash +cat >> ~/.ssh/config << EOF +Host backup-vps + HostName 5.45.77.77 + User root + IdentityFile ~/.ssh/backup_key +EOF +``` + +Then update the backup script to use `backup-vps` as the hostname. + +## Step 2: Configure Backup Script + +The backup script is located at `scripts/backup-database.sh`. It's already configured with: +- Backup VPS: `5.45.77.77` +- Backup path: `/raid/backup/acc_260182` +- MySQL container: `lottery-mysql` +- Database: `lottery_db` + +If you need to change the backup VPS user (default: `root`), edit the script: + +```bash +nano scripts/backup-database.sh +# Change: BACKUP_VPS_USER="root" to your user +``` + +## Step 3: Make Scripts Executable + +```bash +cd /opt/app/backend/lottery-be +chmod +x scripts/backup-database.sh +chmod +x scripts/restore-database.sh +``` + +## Step 4: Test Manual Backup + +Run a test backup to ensure everything works: + +```bash +# Test backup (keeps local copy for verification) +./scripts/backup-database.sh --keep-local + +# Check backup on remote VPS +ssh root@5.45.77.77 "ls -lh /raid/backup/acc_260182/ | tail -5" +``` + +## Step 5: Set Up Automated Backups (Cron) + +Set up a cron job to run backups automatically. Recommended schedule: **daily at 2 AM**. + +### Option A: Edit Crontab Directly + +```bash +# Edit root's crontab +sudo crontab -e + +# Add this line (daily at 2 AM): +0 2 * * * /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1 +``` + +### Option B: Create Cron Script + +Create a wrapper script for better logging: + +```bash +cat > /opt/app/backend/lottery-be/scripts/run-backup.sh << 'EOF' +#!/bin/bash +# Wrapper script for automated backups +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_FILE="/opt/app/logs/backup.log" + +# Ensure log directory exists +mkdir -p "$(dirname "$LOG_FILE")" + +# Run backup and log output +"${SCRIPT_DIR}/backup-database.sh" >> "$LOG_FILE" 2>&1 + +# Send email notification on failure (optional, requires mail setup) +if [ $? -ne 0 ]; then + echo "Backup failed at $(date)" | mail -s "Lottery DB Backup Failed" your-email@example.com +fi +EOF + +chmod +x /opt/app/backend/lottery-be/scripts/run-backup.sh +``` + +Then add to crontab: + +```bash +sudo crontab -e +# Add: +0 2 * * * /opt/app/backend/lottery-be/scripts/run-backup.sh +``` + +### Recommended Backup Schedules + +- **Daily at 2 AM**: `0 2 * * *` (recommended) +- **Twice daily (2 AM and 2 PM)**: `0 2,14 * * *` +- **Every 6 hours**: `0 */6 * * *` +- **Weekly (Sunday 2 AM)**: `0 2 * * 0` + +## Step 6: Verify Automated Backups + +After setting up cron, verify it's working: + +```bash +# Check cron job is scheduled +sudo crontab -l + +# Check backup logs +tail -f /opt/app/logs/backup.log + +# List recent backups on remote VPS +ssh root@5.45.77.77 "ls -lht /raid/backup/acc_260182/ | head -10" +``` + +## Backup Retention + +The backup script automatically: +- **Keeps last 30 days** of backups on remote VPS +- **Deletes local backups** after successful transfer (unless `--keep-local` is used) + +To change retention period, edit `scripts/backup-database.sh`: + +```bash +# Change this line: +ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +30 -delete" + +# To keep 60 days, change +30 to +60 +``` + +## Restoring from Backup + +### Restore from Remote Backup + +```bash +# Restore from backup VPS +./scripts/restore-database.sh 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_020000.sql.gz +``` + +### Restore from Local Backup + +```bash +# If you kept a local backup +./scripts/restore-database.sh /opt/app/backups/lottery_db_backup_20240101_020000.sql.gz +``` + +**⚠️ WARNING**: Restore will **DROP and RECREATE** the database. All existing data will be lost! + +## Backup Script Options + +```bash +# Standard backup (compressed, no local copy) +./scripts/backup-database.sh + +# Keep local copy after transfer +./scripts/backup-database.sh --keep-local + +# Don't compress backup (faster, but larger files) +./scripts/backup-database.sh --no-compress +``` + +## Monitoring Backups + +### Check Backup Status + +```bash +# View recent backup logs +tail -50 /opt/app/logs/backup.log + +# Count backups on remote VPS +ssh root@5.45.77.77 "ls -1 /raid/backup/acc_260182/lottery_db_backup_*.sql* | wc -l" + +# List all backups with sizes +ssh root@5.45.77.77 "ls -lh /raid/backup/acc_260182/lottery_db_backup_*.sql*" +``` + +### Backup Health Check Script + +Create a simple health check: + +```bash +cat > /opt/app/backend/lottery-be/scripts/check-backup-health.sh << 'EOF' +#!/bin/bash +# Check if backups are running successfully + +BACKUP_VPS="5.45.77.77" +BACKUP_PATH="/raid/backup/acc_260182" +DAYS_THRESHOLD=2 # Alert if no backup in last 2 days + +LAST_BACKUP=$(ssh root@${BACKUP_VPS} "ls -t ${BACKUP_PATH}/lottery_db_backup_*.sql* 2>/dev/null | head -1") + +if [ -z "$LAST_BACKUP" ]; then + echo "❌ ERROR: No backups found on backup VPS!" + exit 1 +fi + +LAST_BACKUP_DATE=$(ssh root@${BACKUP_VPS} "stat -c %Y ${LAST_BACKUP}") +CURRENT_DATE=$(date +%s) +DAYS_SINCE_BACKUP=$(( (CURRENT_DATE - LAST_BACKUP_DATE) / 86400 )) + +if [ $DAYS_SINCE_BACKUP -gt $DAYS_THRESHOLD ]; then + echo "⚠️ WARNING: Last backup is $DAYS_SINCE_BACKUP days old!" + exit 1 +else + echo "✅ Backup health OK: Last backup $DAYS_SINCE_BACKUP day(s) ago" + exit 0 +fi +EOF + +chmod +x /opt/app/backend/lottery-be/scripts/check-backup-health.sh +``` + +## Troubleshooting + +### SSH Connection Issues + +```bash +# Test SSH connection +ssh -v root@5.45.77.77 "echo 'test'" + +# Check SSH key permissions +chmod 600 ~/.ssh/id_rsa +chmod 644 ~/.ssh/id_rsa.pub +``` + +### Permission Denied on Backup VPS + +```bash +# Verify write access +ssh root@5.45.77.77 "touch /raid/backup/acc_260182/test && rm /raid/backup/acc_260182/test && echo 'Write access OK'" +``` + +### MySQL Container Not Running + +```bash +# Check container status +docker ps | grep lottery-mysql + +# Start container if needed +cd /opt/app/backend/lottery-be +docker-compose -f docker-compose.prod.yml up -d db +``` + +### Backup Script Permission Denied + +```bash +# Make script executable +chmod +x scripts/backup-database.sh +``` + +## Backup File Naming + +Backups are named with timestamp: `lottery_db_backup_YYYYMMDD_HHMMSS.sql.gz` + +Example: `lottery_db_backup_20240115_020000.sql.gz` + +## Disk Space Considerations + +- **Compressed backups**: Typically 10-50% of database size +- **Uncompressed backups**: Same size as database +- **30-day retention**: Plan for ~30x daily backup size + +Monitor disk space on backup VPS: + +```bash +ssh root@5.45.77.77 "df -h /raid/backup/acc_260182" +``` + +## Security Notes + +1. **SSH Keys**: Use SSH key authentication (no passwords) +2. **Secret File**: Database password is read from `/run/secrets/lottery-config.properties` (secure) +3. **Backup Files**: Contain sensitive data - ensure backup VPS is secure +4. **Permissions**: Backup script requires root access to read secrets + +## Next Steps + +1. ✅ Set up SSH key authentication +2. ✅ Test manual backup +3. ✅ Set up cron job for automated backups +4. ✅ Monitor backup logs for first few days +5. ✅ Test restore procedure (on test environment first!) + diff --git a/BACKUP_TROUBLESHOOTING.md b/BACKUP_TROUBLESHOOTING.md new file mode 100644 index 0000000..0090a23 --- /dev/null +++ b/BACKUP_TROUBLESHOOTING.md @@ -0,0 +1,492 @@ +# Backup Script Permission Denied - Troubleshooting Guide + +## Error Message +``` +/bin/sh: 1: /opt/app/backend/lottery-be/scripts/backup-database.sh: Permission denied +``` + +This error occurs when the system cannot execute the script, even if you've already run `chmod +x`. Here's a systematic approach to find the root cause. + +--- + +## Step 1: Verify File Permissions + +### Check Current Permissions +```bash +ls -la /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**Expected output:** +``` +-rwxr-xr-x 1 root root 5678 Jan 15 10:00 /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**What to look for:** +- The `x` (execute) permission should be present for owner, group, or others +- If you see `-rw-r--r--` (no `x`), the file is not executable + +**Fix if needed:** +```bash +chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +--- + +## Step 2: Check File System Mount Options + +The file system might be mounted with `noexec` flag, which prevents executing scripts. + +### Check Mount Options +```bash +mount | grep -E "(/opt|/app|/backend)" +``` + +**What to look for:** +- If you see `noexec` in the mount options, that's the problem +- Example of problematic mount: `/dev/sda1 on /opt type ext4 (rw,noexec,relatime)` + +**Fix:** +1. Check `/etc/fstab`: + ```bash + cat /etc/fstab | grep -E "(/opt|/app)" + ``` +2. If `noexec` is present, remove it and remount: + ```bash + # Edit fstab (remove noexec) + sudo nano /etc/fstab + + # Remount (if /opt is a separate partition) + sudo mount -o remount /opt + ``` +3. **Note:** If `/opt` is part of the root filesystem, you may need to reboot + +--- + +## Step 3: Check Line Endings (CRLF vs LF) + +Windows line endings (CRLF) can cause "Permission denied" errors on Linux. + +### Check Line Endings +```bash +file /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**Expected output:** +``` +/opt/app/backend/lottery-be/scripts/backup-database.sh: Bourne-Again shell script, ASCII text executable +``` + +**If you see:** +``` +/opt/app/backend/lottery-be/scripts/backup-database.sh: ASCII text, with CRLF line terminators +``` + +**Fix:** +```bash +# Convert CRLF to LF +dos2unix /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Or using sed +sed -i 's/\r$//' /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Or using tr +tr -d '\r' < /opt/app/backend/lottery-be/scripts/backup-database.sh > /tmp/backup-database.sh +mv /tmp/backup-database.sh /opt/app/backend/lottery-be/scripts/backup-database.sh +chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +--- + +## Step 4: Verify Shebang Line + +The shebang line must point to a valid interpreter. + +### Check Shebang +```bash +head -1 /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**Expected:** +```bash +#!/bin/bash +``` + +**Verify bash exists:** +```bash +which bash +ls -la /bin/bash +``` + +**If bash doesn't exist or path is wrong:** +```bash +# Find bash location +which bash +# or +whereis bash + +# Update shebang if needed (bash is usually at /bin/bash or /usr/bin/bash) +``` + +--- + +## Step 5: Check SELinux (if enabled) + +SELinux can block script execution even with correct permissions. + +### Check if SELinux is Enabled +```bash +getenforce +``` + +**Outputs:** +- `Enforcing` - SELinux is active and blocking +- `Permissive` - SELinux is active but only logging +- `Disabled` - SELinux is off + +### Check SELinux Context +```bash +ls -Z /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**Fix if SELinux is blocking:** +```bash +# Set correct context for shell scripts +chcon -t bin_t /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Or restore default context +restorecon -v /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Or temporarily set to permissive (for testing only) +setenforce 0 +``` + +--- + +## Step 6: Check AppArmor (if enabled) + +AppArmor can also block script execution. + +### Check AppArmor Status +```bash +aa-status +``` + +**If AppArmor is active and blocking:** +```bash +# Check AppArmor logs +sudo dmesg | grep -i apparmor +sudo journalctl -u apparmor | tail -20 + +# Temporarily disable for testing (not recommended for production) +sudo systemctl stop apparmor +``` + +--- + +## Step 7: Verify Cron Job User + +The cron job might be running as a different user than expected. + +### Check Cron Job +```bash +# Check root's crontab +sudo crontab -l + +# Check if cron job specifies a user +# Example: 0 2 * * * root /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +### Check Which User Runs Cron +```bash +# Check cron service logs +sudo journalctl -u cron | tail -20 + +# Or check syslog +sudo grep CRON /var/log/syslog | tail -10 +``` + +**Important:** The script requires root access (line 71-74 checks for EUID=0). Make sure cron runs as root: + +```bash +# Edit root's crontab (correct way) +sudo crontab -e + +# NOT user's crontab +# crontab -e # This runs as current user, not root +``` + +--- + +## Step 8: Test Script Execution Manually + +Test the script with the same user that cron uses. + +### Test as Root +```bash +# Test directly +sudo /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Test with bash explicitly +sudo bash /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Test with sh (if bash is not available) +sudo sh /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +**If manual execution works but cron doesn't:** +- The issue is likely with cron's environment or user context +- See Step 9 for cron environment issues + +--- + +## Step 9: Check Cron Environment + +Cron has a minimal environment. The script might need specific environment variables or paths. + +### Check Script Dependencies +The script uses: +- `docker` command +- `ssh` command +- `gzip` command +- `/run/secrets/lottery-config.properties` file + +### Verify Commands are in PATH +```bash +# Check if commands are accessible +which docker +which ssh +which gzip +which bash + +# If commands are not in standard PATH, update cron job: +# Add PATH to cron job: +0 2 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1 +``` + +### Test Cron Environment +Create a test cron job to see the environment: + +```bash +# Add to crontab +* * * * * env > /tmp/cron-env.txt + +# Wait 1 minute, then check +cat /tmp/cron-env.txt +``` + +--- + +## Step 10: Check Directory Permissions + +The directory containing the script must be executable. + +### Check Directory Permissions +```bash +ls -ld /opt/app/backend/lottery-be/scripts/ +``` + +**Expected:** +``` +drwxr-xr-x 2 root root 4096 Jan 15 10:00 /opt/app/backend/lottery-be/scripts/ +``` + +**If directory is not executable:** +```bash +chmod +x /opt/app/backend/lottery-be/scripts/ +``` + +--- + +## Step 11: Check for Hidden Characters + +Hidden characters or encoding issues can break the shebang. + +### View File in Hex +```bash +head -c 20 /opt/app/backend/lottery-be/scripts/backup-database.sh | od -c +``` + +**Expected:** +``` +0000000 # ! / b i n / b a s h \n +``` + +**If you see strange characters:** +```bash +# Recreate the shebang line +sed -i '1s/.*/#!\/bin\/bash/' /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +--- + +## Step 12: Comprehensive Diagnostic Script + +Run this diagnostic script to check all common issues: + +```bash +cat > /tmp/check-backup-script.sh << 'EOF' +#!/bin/bash +echo "=== Backup Script Diagnostic ===" +echo "" + +SCRIPT="/opt/app/backend/lottery-be/scripts/backup-database.sh" + +echo "1. File exists?" +[ -f "$SCRIPT" ] && echo " ✅ Yes" || echo " ❌ No" + +echo "2. File permissions:" +ls -la "$SCRIPT" + +echo "3. File is executable?" +[ -x "$SCRIPT" ] && echo " ✅ Yes" || echo " ❌ No" + +echo "4. Shebang line:" +head -1 "$SCRIPT" + +echo "5. Bash exists?" +[ -f /bin/bash ] && echo " ✅ Yes: /bin/bash" || [ -f /usr/bin/bash ] && echo " ✅ Yes: /usr/bin/bash" || echo " ❌ No" + +echo "6. Line endings:" +file "$SCRIPT" + +echo "7. Mount options for /opt:" +mount | grep -E "(/opt|/app)" || echo " (Not a separate mount)" + +echo "8. SELinux status:" +getenforce 2>/dev/null || echo " (Not installed)" + +echo "9. Directory permissions:" +ls -ld "$(dirname "$SCRIPT")" + +echo "10. Test execution:" +bash -n "$SCRIPT" && echo " ✅ Syntax OK" || echo " ❌ Syntax error" + +echo "" +echo "=== End Diagnostic ===" +EOF + +chmod +x /tmp/check-backup-script.sh +/tmp/check-backup-script.sh +``` + +--- + +## Step 13: Alternative Solutions + +If the issue persists, try these workarounds: + +### Solution A: Use bash Explicitly in Cron +```bash +# Instead of: +0 2 * * * /opt/app/backend/lottery-be/scripts/backup-database.sh + +# Use: +0 2 * * * /bin/bash /opt/app/backend/lottery-be/scripts/backup-database.sh +``` + +### Solution B: Create Wrapper Script +```bash +cat > /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh << 'EOF' +#!/bin/bash +cd /opt/app/backend/lottery-be +exec /opt/app/backend/lottery-be/scripts/backup-database.sh "$@" +EOF + +chmod +x /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh + +# Update cron to use wrapper +0 2 * * * /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh >> /opt/app/logs/backup.log 2>&1 +``` + +### Solution C: Use systemd Timer Instead of Cron +```bash +# Create systemd service +cat > /etc/systemd/system/lottery-backup.service << 'EOF' +[Unit] +Description=Lottery Database Backup +After=network.target + +[Service] +Type=oneshot +ExecStart=/opt/app/backend/lottery-be/scripts/backup-database.sh +User=root +StandardOutput=append:/opt/app/logs/backup.log +StandardError=append:/opt/app/logs/backup.log +EOF + +# Create systemd timer +cat > /etc/systemd/system/lottery-backup.timer << 'EOF' +[Unit] +Description=Run Lottery Database Backup Daily +Requires=lottery-backup.service + +[Timer] +OnCalendar=02:00 +Persistent=true + +[Install] +WantedBy=timers.target +EOF + +# Enable and start +systemctl daemon-reload +systemctl enable lottery-backup.timer +systemctl start lottery-backup.timer +``` + +--- + +## Most Common Causes (Quick Reference) + +1. **Line endings (CRLF)** - Most common if file was edited on Windows +2. **File system mounted with `noexec`** - Check mount options +3. **Cron running as wrong user** - Must run as root (use `sudo crontab -e`) +4. **SELinux/AppArmor blocking** - Check security contexts +5. **Missing execute permission** - Run `chmod +x` again +6. **Directory not executable** - Check parent directory permissions + +--- + +## Quick Fix Checklist + +Run these commands in order: + +```bash +# 1. Fix line endings +dos2unix /opt/app/backend/lottery-be/scripts/backup-database.sh +# OR if dos2unix not available: +sed -i 's/\r$//' /opt/app/backend/lottery-be/scripts/backup-database.sh + +# 2. Ensure execute permission +chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh + +# 3. Ensure directory is executable +chmod +x /opt/app/backend/lottery-be/scripts/ + +# 4. Test execution +sudo /opt/app/backend/lottery-be/scripts/backup-database.sh --keep-local + +# 5. Verify cron job uses bash explicitly +sudo crontab -e +# Change to: 0 2 * * * /bin/bash /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1 +``` + +--- + +## Still Not Working? + +If none of the above fixes work, provide the output of: + +```bash +# Run diagnostic +/tmp/check-backup-script.sh + +# Check cron logs +sudo journalctl -u cron | tail -50 + +# Check system logs +sudo dmesg | tail -20 +``` + +This will help identify the exact issue. + diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..9634328 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,765 @@ +# VPS Deployment Guide for Lottery Application + +This guide will help you deploy the Lottery application to a VPS (Ubuntu) using Docker, Docker Compose, and Nginx. + +## Prerequisites + +- Ubuntu VPS (tested on Ubuntu 20.04+) +- Root or sudo access +- Domain name pointing to your VPS IP (for HTTPS) +- Basic knowledge of Linux commands + +## Architecture Overview + +``` +Internet + ↓ +Nginx (HTTPS, Port 443) + ↓ + ├─→ Frontend (Static files from /opt/app/frontend/dist) + ├─→ Backend API (/api/* → Docker container on port 8080) + ├─→ WebSocket (/ws → Docker container) + └─→ Avatars (/avatars/* → /opt/app/data/avatars) +``` + +## Step 1: Initial VPS Setup + +### 1.1 Update System + +```bash +sudo apt update +sudo apt upgrade -y +``` + +### 1.2 Install Required Software + +```bash +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER + +# Docker Compose v2+ is included with Docker (as a plugin) +# Verify it's installed: +docker compose version + +# If not installed, install Docker Compose plugin: +# For Ubuntu/Debian: +sudo apt-get update +sudo apt-get install docker-compose-plugin + +# Or if you need the standalone version (older method): +# sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +# sudo chmod +x /usr/local/bin/docker-compose + +# Install Nginx +sudo apt install nginx -y + +# Install Certbot for SSL certificates +sudo apt install certbot python3-certbot-nginx -y + +# Log out and log back in for Docker group to take effect +exit +``` + +## Step 2: Create Directory Structure + +```bash +# Create main application directory +sudo mkdir -p /opt/app +sudo chown $USER:$USER /opt/app + +# Create subdirectories +mkdir -p /opt/app/backend +mkdir -p /opt/app/frontend +mkdir -p /opt/app/nginx +mkdir -p /opt/app/data/avatars +mkdir -p /opt/app/mysql/data + +# Set proper permissions +sudo chmod -R 755 /opt/app +sudo chown -R $USER:$USER /opt/app/data +``` + +## Step 3: Deploy Backend + +### 3.1 Copy Backend Files + +From your local machine, copy the backend repository to the VPS: + +```bash +# On your local machine, use scp or rsync +scp -r lottery-be/* user@your-vps-ip:/opt/app/backend/ + +# Or use git (recommended) +# On VPS: +cd /opt/app/backend +git clone . +``` + +### 3.2 Plan Database Configuration + +**Important:** MySQL runs as a Docker container (no separate MySQL installation needed). Before creating the secret file, you need to decide on your database credentials: + +1. **Database Name**: `lottery_db` (default, can be changed) +2. **Database Username**: `root` (default, can be changed) +3. **Database Password**: Choose a strong, secure password +4. **Database URL**: `jdbc:mysql://db:3306/lottery_db` + +**Understanding the Database URL (`SPRING_DATASOURCE_URL`):** + +The URL format is: `jdbc:mysql://:/` + +**For this deployment, use: `jdbc:mysql://db:3306/lottery_db`** + +Breaking it down: +- `jdbc:mysql://` - JDBC protocol for MySQL +- `db` - This is the **service name** in `docker-compose.prod.yml` (acts as hostname in Docker network) +- `3306` - Default MySQL port (internal to Docker network) +- `lottery_db` - Database name (must match `MYSQL_DATABASE` in docker-compose) + +**Why `db` as hostname?** +- In Docker Compose, services communicate using their **service names** as hostnames +- The MySQL service is named `db` in `docker-compose.prod.yml` (line 4: `services: db:`) +- Both containers are on the same Docker network (`lottery-network`) +- The backend container connects to MySQL using `db:3306` (not `localhost` or the VPS IP) +- This is an **internal Docker network connection** - MySQL is not exposed to the host + +**Quick Reference:** +- ✅ Correct: `jdbc:mysql://db:3306/lottery_db` (uses service name) +- ❌ Wrong: `jdbc:mysql://localhost:3306/lottery_db` (won't work - localhost refers to the container itself) +- ❌ Wrong: `jdbc:mysql://127.0.0.1:3306/lottery_db` (won't work - same reason) + +**Example credentials (use your own secure password!):** +- Database URL: `jdbc:mysql://db:3306/lottery_db` +- Database Name: `lottery_db` +- Username: `root` +- Password: `MySecurePassword123!` + +**Note:** These credentials will be used in: +- The secret file (`SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD`) +- MySQL container environment variables (`DB_PASSWORD`, `DB_ROOT_PASSWORD`) + +The MySQL container will be created automatically when you run `docker-compose`, and the database will be initialized with these credentials. + +### 3.3 Create Secret Configuration File + +The application uses a mounted secret file instead of environment variables for security. Create the secret file: + +**Option 1: Copy from template (if template file exists)** + +```bash +# Create the secrets directory (if it doesn't exist) +sudo mkdir -p /run/secrets + +# Navigate to backend directory +cd /opt/app/backend + +# Check if template file exists +ls -la lottery-config.properties.template + +# If it exists, copy it +sudo cp lottery-config.properties.template /run/secrets/lottery-config.properties +``` + +**Option 2: Create the file directly (if template wasn't copied)** + +If the template file doesn't exist in `/opt/app/backend/`, create the secret file directly: + +```bash +# Create the secrets directory (if it doesn't exist) +sudo mkdir -p /run/secrets + +# Create the secret file +sudo nano /run/secrets/lottery-config.properties +``` + +Then paste the following content (replace placeholder values): + +```properties +# Lottery Application Configuration +# Replace all placeholder values with your actual configuration + +# ============================================ +# Database Configuration +# ============================================ +# SPRING_DATASOURCE_URL format: jdbc:mysql://:/ +# +# How to determine the URL: +# - Hostname: 'db' (this is the MySQL service name in docker-compose.prod.yml) +# * In Docker Compose, services communicate using their service names +# * The MySQL service is named 'db', so use 'db' as the hostname +# * Both containers are on the same Docker network, so 'db' resolves to the MySQL container +# - Port: '3306' (default MySQL port, internal to Docker network) +# - Database name: 'lottery_db' (must match MYSQL_DATABASE in docker-compose.prod.yml) +# +# Example: jdbc:mysql://db:3306/lottery_db +# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘ +# │ │ │ └─ Database name +# │ │ └─ Port (3306 is MySQL default) +# │ └─ Service name in docker-compose (acts as hostname) +# └─ JDBC protocol for MySQL +SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db +SPRING_DATASOURCE_USERNAME=root +SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here + +# ============================================ +# Telegram Bot Configuration +# ============================================ +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here +TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here +TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name + +# ============================================ +# Frontend Configuration +# ============================================ +FRONTEND_URL=https://yourdomain.com + +# ============================================ +# Avatar Storage Configuration +# ============================================ +APP_AVATAR_STORAGE_PATH=/app/data/avatars +APP_AVATAR_PUBLIC_BASE_URL= +APP_AVATAR_MAX_SIZE_BYTES=2097152 +APP_AVATAR_MAX_DIMENSION=512 + +# ============================================ +# Session Configuration (Optional - defaults shown) +# ============================================ +APP_SESSION_MAX_ACTIVE_PER_USER=5 +APP_SESSION_CLEANUP_BATCH_SIZE=5000 +APP_SESSION_CLEANUP_MAX_BATCHES=20 + +# ============================================ +# GeoIP Configuration (Optional) +# ============================================ +GEOIP_DB_PATH= +``` + +**Edit the secret file with your actual values:** + +```bash +sudo nano /run/secrets/lottery-config.properties +``` + +**Important:** Replace all placeholder values with your actual configuration: + +```properties +# Database Configuration +SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db +SPRING_DATASOURCE_USERNAME=root +SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here + +# Telegram Bot Configuration +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here +TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here +TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name + +# Frontend Configuration +FRONTEND_URL=https://yourdomain.com + +# Avatar Storage Configuration +APP_AVATAR_STORAGE_PATH=/app/data/avatars +APP_AVATAR_PUBLIC_BASE_URL= + +# Optional: Session Configuration (defaults shown) +APP_SESSION_MAX_ACTIVE_PER_USER=5 +APP_SESSION_CLEANUP_BATCH_SIZE=5000 +APP_SESSION_CLEANUP_MAX_BATCHES=20 + +# Optional: GeoIP Configuration +GEOIP_DB_PATH= +``` + +**Set secure permissions:** + +```bash +# Make the file readable only by root and the docker group +sudo chmod 640 /run/secrets/lottery-config.properties +sudo chown root:docker /run/secrets/lottery-config.properties +``` + +**Important Notes:** +- The database credentials you set here (`SPRING_DATASOURCE_*`) must match the MySQL container environment variables (see Step 3.4) +- The MySQL container will be created automatically when you run `docker-compose` +- The database `lottery_db` will be created automatically on first startup +- Data will persist in a Docker volume (`mysql_data`) + +### 3.4 Set MySQL Container Environment Variables + +The MySQL container needs the database password as environment variables. These must match the credentials in your secret file. + +**Option 1: Read from secret file automatically (recommended - more secure and consistent)** + +Use the provided script that reads the password from the secret file: + +```bash +cd /opt/app/backend + +# Make sure the script is executable +chmod +x scripts/load-db-password.sh + +# Source the script to load the password (this exports DB_PASSWORD and DB_ROOT_PASSWORD) +source scripts/load-db-password.sh +``` + +**What this does:** +- Reads `SPRING_DATASOURCE_PASSWORD` from `/run/secrets/lottery-config.properties` +- Exports `DB_PASSWORD` and `DB_ROOT_PASSWORD` with the same value +- Ensures MySQL container credentials match the backend credentials automatically + +**Verify it worked:** +```bash +# Check that the variables are set +echo "DB_PASSWORD is set: $([ -n "$DB_PASSWORD" ] && echo "yes" || echo "no")" +``` + +**Option 2: Set environment variables manually (simpler but less secure)** + +If you prefer to set them manually (not recommended): + +```bash +# Export the password (must match SPRING_DATASOURCE_PASSWORD from your secret file) +# Replace with the actual password you set in the secret file +export DB_PASSWORD=your_secure_database_password_here +export DB_ROOT_PASSWORD=your_secure_database_password_here + +# Verify it's set +echo $DB_PASSWORD +``` + +**Important Notes:** +- The `DB_PASSWORD` and `DB_ROOT_PASSWORD` must match `SPRING_DATASOURCE_PASSWORD` from your secret file +- These environment variables are only used by the MySQL container +- The backend application reads credentials from the secret file, not from environment variables +- **Option 1 is recommended** because it ensures consistency and reduces the chance of mismatched passwords + +### 3.5 Build and Start Backend + +**Before starting:** Make sure you have: +- ✅ Secret file created at `/run/secrets/lottery-config.properties` with database credentials +- ✅ Environment variables `DB_PASSWORD` and `DB_ROOT_PASSWORD` set (use `source scripts/load-db-password.sh` from Step 3.4) + +```bash +cd /opt/app/backend + +# Make sure DB_PASSWORD and DB_ROOT_PASSWORD are set (if not already done in Step 3.4) +# If you haven't sourced the script yet, do it now: +source scripts/load-db-password.sh + +# Build and start services +docker compose -f docker-compose.prod.yml up -d --build + +# Check logs (press Ctrl+C to exit) +docker compose -f docker-compose.prod.yml logs -f +``` + +**What happens when you start:** + +1. **MySQL container starts first** (`lottery-mysql`) + - Creates the database `lottery_db` automatically (if it doesn't exist) + - Sets up the root user with your password from `DB_PASSWORD` + - Waits until healthy before backend starts + +2. **Backend container starts** (`lottery-backend`) + - Loads configuration from `/run/secrets/lottery-config.properties` + - Connects to MySQL using credentials from secret file + - Runs Flyway migrations to create all database tables + - Starts the Spring Boot application + +**Wait for the database to be ready and migrations to complete.** You should see: +- `lottery-mysql` container running +- `lottery-backend` container running +- Log message: "📁 Loading configuration from mounted secret file: /run/secrets/lottery-config.properties" +- Database migration messages (Flyway creating tables) +- No errors in logs + +**Verify everything is working:** + +```bash +# Check that both containers are running +docker ps | grep lottery + +# Check backend logs for secret file loading +docker compose -f docker-compose.prod.yml logs backend | grep "Loading configuration" + +# Check backend logs for database connection +docker compose -f docker-compose.prod.yml logs backend | grep -i "database\|mysql\|flyway" + +# Check for any errors +docker compose -f docker-compose.prod.yml logs backend | grep -i error +``` + +You should see: +- `📁 Loading configuration from mounted secret file: /run/secrets/lottery-config.properties` +- `Flyway migration` messages showing tables being created +- No connection errors + +**If you see connection errors:** +- Verify `SPRING_DATASOURCE_PASSWORD` in secret file matches `DB_PASSWORD` environment variable +- Re-run the password loading script: `source scripts/load-db-password.sh` +- Check that MySQL container is healthy: `docker ps | grep mysql` +- Check MySQL logs: `docker compose -f docker-compose.prod.yml logs db` + +## Step 4: Build and Deploy Frontend + +### 4.1 Build Frontend Locally (Recommended) + +On your local machine: + +```bash +cd lottery-fe + +# Build for production (uses relative API URLs by default) +npm install +npm run build + +# The dist/ folder will be created +``` + +### 4.2 Copy Frontend Build to VPS + +```bash +# On your local machine +scp -r lottery-fe/dist/* user@your-vps-ip:/opt/app/frontend/dist/ + +# Or use rsync +rsync -avz lottery-fe/dist/ user@your-vps-ip:/opt/app/frontend/dist/ +``` + +### 4.3 Alternative: Build on VPS + +If you prefer to build on the VPS: + +```bash +# Install Node.js on VPS +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +# Copy frontend source +scp -r lottery-fe/* user@your-vps-ip:/opt/app/frontend-source/ + +# Build +cd /opt/app/frontend-source +npm install +npm run build + +# Copy dist to frontend directory +cp -r dist/* /opt/app/frontend/dist/ +``` + +## Step 5: Configure Nginx + +### 5.1 Copy Nginx Configuration + +```bash +# Copy the template +cp /opt/app/backend/nginx.conf.template /opt/app/nginx/nginx.conf + +# Edit the configuration +nano /opt/app/nginx/nginx.conf +``` + +**Update the following:** +1. Replace `server_name _;` with your domain name (e.g., `server_name yourdomain.com;`) +2. Update SSL certificate paths if using Let's Encrypt (see Step 6) +3. Verify paths match your directory structure + +### 5.2 Link Nginx Configuration + +```bash +# Remove default Nginx config +sudo rm /etc/nginx/sites-enabled/default + +# Create symlink to your config +sudo ln -s /opt/app/nginx/nginx.conf /etc/nginx/sites-available/lottery +sudo ln -s /etc/nginx/sites-available/lottery /etc/nginx/sites-enabled/ + +# Test Nginx configuration +sudo nginx -t + +# If test passes, reload Nginx +sudo systemctl reload nginx +``` + +## Step 6: Setup SSL Certificate (HTTPS) + +### 6.1 Obtain SSL Certificate + +```bash +# Stop Nginx temporarily +sudo systemctl stop nginx + +# Obtain certificate (replace with your domain and email) +sudo certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com --email your-email@example.com --agree-tos + +# Start Nginx +sudo systemctl start nginx +``` + +### 6.2 Update Nginx Config with Certificate Paths + +Edit `/opt/app/nginx/nginx.conf` and update: + +```nginx +ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; +ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; +``` + +### 6.3 Setup Auto-Renewal + +```bash +# Test renewal +sudo certbot renew --dry-run + +# Certbot will automatically renew certificates +``` + +## Step 7: Configure Telegram Webhook + +Update your Telegram bot webhook to point to your VPS: + +```bash +# Replace with your bot token, domain, and the same webhook token you set in APP_TELEGRAM_WEBHOOK_TOKEN +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -d "url=https://yourdomain.com/api/telegram/webhook/" +``` + +Verify webhook: + +```bash +curl "https://api.telegram.org/bot/getWebhookInfo" +``` + +## Step 8: Final Verification + +### 8.1 Check Services + +```bash +# Check Docker containers +docker ps + +# Should show: +# - lottery-mysql +# - lottery-backend + +# Check Nginx +sudo systemctl status nginx +``` + +### 8.2 Test Endpoints + +```bash +# Test backend health +curl http://localhost:8080/actuator/health + +# Test frontend (should return HTML) +curl https://yourdomain.com/ + +# Test API (should return JSON or error with auth) +curl https://yourdomain.com/api/health +``` + +### 8.3 Check Logs + +```bash +# Backend logs +cd /opt/app/backend +docker-compose -f docker-compose.prod.yml logs -f + +# Nginx logs +sudo tail -f /var/log/nginx/access.log +sudo tail -f /var/log/nginx/error.log +``` + +### 8.4 Browser Testing + +1. Open `https://yourdomain.com` in a browser +2. Test the Telegram Mini App +3. Verify API calls work (check browser console) +4. Test WebSocket connection (game updates) + +## Step 9: Maintenance Commands + +### 9.1 Restart Services + +```bash +# Restart backend +cd /opt/app/backend +docker compose -f docker-compose.prod.yml restart + +# Restart Nginx +sudo systemctl restart nginx +``` + +### 9.2 Update Application + +```bash +# Backend update +cd /opt/app/backend +git pull # or copy new files +docker compose -f docker-compose.prod.yml up -d --build + +# Frontend update +# Rebuild and copy dist/ folder +``` + +### 9.3 Backup Database + +```bash +# Create backup +docker exec lottery-mysql mysqldump -u root -p${DB_PASSWORD} lottery_db > backup_$(date +%Y%m%d).sql + +# Restore backup +docker exec -i lottery-mysql mysql -u root -p${DB_PASSWORD} lottery_db < backup_20240101.sql +``` + +### 9.4 View Logs + +```bash +# Backend logs +cd /opt/app/backend +docker-compose -f docker-compose.prod.yml logs -f backend + +# Database logs +docker-compose -f docker-compose.prod.yml logs -f db + +# Nginx logs +sudo tail -f /var/log/nginx/error.log +``` + +## Troubleshooting + +### Backend Not Starting + +```bash +# Check logs +docker compose -f docker-compose.prod.yml logs backend + +# Common issues: +# - Database not ready: wait for health check +# - Missing configuration: check secret file at /run/secrets/lottery-config.properties +# - Secret file not found: ensure file exists and is mounted correctly +# - Port conflict: ensure port 8080 is not exposed to host +``` + +### Frontend Not Loading + +```bash +# Check Nginx error log +sudo tail -f /var/log/nginx/error.log + +# Verify files exist +ls -la /opt/app/frontend/dist/ + +# Check Nginx config +sudo nginx -t +``` + +### Database Connection Issues + +```bash +# Check database container +docker ps | grep mysql + +# Check database logs +docker compose -f docker-compose.prod.yml logs db + +# Test connection +docker exec -it lottery-mysql mysql -u root -p +``` + +### SSL Certificate Issues + +```bash +# Check certificate +sudo certbot certificates + +# Renew certificate +sudo certbot renew + +# Check Nginx SSL config +sudo nginx -t +``` + +### WebSocket Not Working + +```bash +# Check backend logs for WebSocket errors +docker compose -f docker-compose.prod.yml logs backend | grep -i websocket + +# Verify Nginx WebSocket configuration +grep -A 10 "/ws" /opt/app/nginx/nginx.conf +``` + +## Security Checklist + +- [ ] Strong database passwords set +- [ ] Secret file has restricted permissions (`chmod 640`, owned by `root:docker`) +- [ ] Secret file contains all required configuration values +- [ ] SSL certificate installed and auto-renewal configured +- [ ] Firewall configured (UFW recommended) +- [ ] Backend port 8080 not exposed to host +- [ ] MySQL port 3306 not exposed to host +- [ ] Regular backups scheduled +- [ ] Logs monitored for suspicious activity + +## Firewall Setup (Optional but Recommended) + +```bash +# Install UFW +sudo apt install ufw -y + +# Allow SSH (IMPORTANT - do this first!) +sudo ufw allow 22/tcp + +# Allow HTTP and HTTPS +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Enable firewall +sudo ufw enable + +# Check status +sudo ufw status +``` + +## Directory Structure Summary + +``` +/opt/app/ +├── backend/ +│ ├── Dockerfile +│ ├── docker-compose.prod.yml +│ ├── lottery-config.properties.template +│ ├── pom.xml +│ └── src/ +├── frontend/ +│ └── dist/ (Vite production build) +├── nginx/ +│ └── nginx.conf +├── data/ +│ └── avatars/ (persistent uploads) +└── mysql/ + └── data/ (persistent DB storage) + +/run/secrets/ +└── lottery-config.properties (mounted secret configuration file) +``` + +## Support + +If you encounter issues: +1. Check logs first (backend, Nginx, Docker) +2. Verify secret file exists at `/run/secrets/lottery-config.properties` and has correct values +3. Verify secret file permissions (`chmod 640`, owned by `root:docker`) +4. Check backend logs for "Loading configuration from mounted secret file" message +5. Ensure all directories exist and have proper permissions +6. Verify network connectivity between containers +7. Check SSL certificate validity + +--- + +**Last Updated:** 2026-01-24 +**Version:** 1.0 + diff --git a/DOCKER_LOGGING_SETUP.md b/DOCKER_LOGGING_SETUP.md new file mode 100644 index 0000000..0dd5630 --- /dev/null +++ b/DOCKER_LOGGING_SETUP.md @@ -0,0 +1,202 @@ +# Docker Logging Setup - Automatic Configuration + +## Overview + +The Docker setup is **automatically configured** to use external `logback-spring.xml` for runtime log level changes. No manual configuration needed! + +## How It Works + +### 1. Dockerfile Configuration + +Both `Dockerfile` and `Dockerfile.inferno` automatically: +- Copy `logback-spring.xml` to `/app/config/logback-spring.xml` in the container +- Create `/app/logs` directory for log files +- Set default environment variables: + - `LOGGING_CONFIG=/app/config/logback-spring.xml` + - `LOG_DIR=/app/logs` +- Configure Java to use external config via `-Dlogging.config` and `-DLOG_DIR` + +### 2. Docker Compose Configuration + +Both `docker-compose.inferno.yml` and `docker-compose.prod.yml` automatically: +- **Mount external config**: `/opt/app/backend/config/logback-spring.xml` → `/app/config/logback-spring.xml` (read-write, editable on VPS) +- **Mount logs directory**: `/opt/app/logs` → `/app/logs` (persistent storage) +- **Set environment variables**: `LOGGING_CONFIG` and `LOG_DIR` + +## Initial Setup (One-Time) + +### Option 1: Use Setup Script (Recommended) + +```bash +cd /opt/app/backend +# Make script executable (if not already) +chmod +x scripts/setup-logging.sh +# Run the script +./scripts/setup-logging.sh +``` + +This script will: +1. Create `/opt/app/backend/config` directory +2. Create `/opt/app/logs` directory +3. Extract `logback-spring.xml` from JAR (if available) +4. Set proper permissions + +### Option 2: Manual Setup + +```bash +# Create directories +mkdir -p /opt/app/backend/config +mkdir -p /opt/app/logs + +# Extract logback-spring.xml from JAR +cd /opt/app/backend +unzip -p target/lottery-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml + +# Or copy from source (if building from source on VPS) +cp src/main/resources/logback-spring.xml /opt/app/backend/config/logback-spring.xml + +# Set permissions +chmod 644 /opt/app/backend/config/logback-spring.xml +chmod 755 /opt/app/logs +``` + +## Usage + +### Start Application + +Just start Docker Compose as usual: + +```bash +cd /opt/app/backend +docker compose -f docker-compose.inferno.yml up -d +``` + +The external logging configuration is **automatically active** - no additional steps needed! + +### Change Log Level at Runtime + +1. **Edit the mounted config file**: + ```bash + nano /opt/app/backend/config/logback-spring.xml + ``` + +2. **Change log level** (example: enable DEBUG): + ```xml + + ``` + +3. **Save the file**. Logback will automatically reload within 30 seconds. + +4. **Verify**: + ```bash + # View logs from VPS + tail -f /opt/app/logs/lottery-be.log + + # Or from inside container + docker exec lottery-backend tail -f /app/logs/lottery-be.log + ``` + +### View Logs + +```bash +# Real-time monitoring +tail -f /opt/app/logs/lottery-be.log + +# Search for errors +grep -i "error" /opt/app/logs/lottery-be.log + +# View last 100 lines +tail -n 100 /opt/app/logs/lottery-be.log + +# From inside container +docker exec lottery-backend tail -f /app/logs/lottery-be.log +``` + +## File Locations + +### On VPS (Host) +- **Config file**: `/opt/app/backend/config/logback-spring.xml` (editable) +- **Log files**: `/opt/app/logs/lottery-be.log` and rolled files + +### Inside Container +- **Config file**: `/app/config/logback-spring.xml` (mounted from host) +- **Log files**: `/app/logs/lottery-be.log` (mounted to host) + +## Verification + +### Check Configuration is Active + +```bash +# Check container logs for logback initialization +docker logs lottery-backend | grep -i "logback\|logging" + +# Check mounted file exists +ls -la /opt/app/backend/config/logback-spring.xml + +# Check log directory +ls -la /opt/app/logs/ + +# Check environment variables in container +docker exec lottery-backend env | grep LOG +``` + +### Expected Output + +You should see: +- `LOGGING_CONFIG=/app/config/logback-spring.xml` +- `LOG_DIR=/app/logs` +- Log files appearing in `/opt/app/logs/` + +## Benefits + +✅ **No manual configuration needed** - Works automatically with Docker +✅ **Runtime log level changes** - Edit file, changes take effect in 30 seconds +✅ **No container restart required** - Changes apply without restarting +✅ **Persistent logs** - Logs survive container restarts +✅ **Editable config** - Edit logback-spring.xml directly on VPS + +## Troubleshooting + +### Config file not found + +```bash +# Check if file exists +ls -la /opt/app/backend/config/logback-spring.xml + +# If missing, extract from JAR or copy from source +./scripts/setup-logging.sh +``` + +### Logs not appearing + +```bash +# Check log directory permissions +ls -ld /opt/app/logs + +# Check container can write +docker exec lottery-backend ls -la /app/logs + +# Check disk space +df -h /opt/app/logs +``` + +### Log level changes not working + +1. Verify `scan="true" scanPeriod="30 seconds"` in logback-spring.xml +2. Check for XML syntax errors +3. Wait 30 seconds after saving +4. Check container logs for Logback errors: + ```bash + docker logs lottery-backend | grep -i "logback\|error" + ``` + +## Summary + +**You don't need to do anything manually!** The Docker setup automatically: +- Uses external logback-spring.xml +- Mounts it as a volume (editable on VPS) +- Sets all required environment variables +- Configures log directory + +Just run `docker compose up` and you're ready to go! 🚀 + diff --git a/Dockerfile b/Dockerfile index a2bc257..61741f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,15 +18,28 @@ WORKDIR /app # Copy fat jar from build stage COPY --from=build /app/target/*.jar app.jar -# Copy startup script +# Copy startup script (optional - only used if secret file doesn't exist) COPY scripts/create-secret-file.sh /app/create-secret-file.sh RUN chmod +x /app/create-secret-file.sh +# Copy logback-spring.xml to config directory (can be overridden by volume mount) +COPY src/main/resources/logback-spring.xml /app/config/logback-spring.xml + +# Create log directory +RUN mkdir -p /app/logs && chmod 755 /app/logs + # Expose port (for local/docker-compose/documentation) EXPOSE 8080 +# Default environment variables (can be overridden in docker-compose) ENV JAVA_OPTS="" +ENV LOGGING_CONFIG="/app/config/logback-spring.xml" +ENV LOG_DIR="/app/logs" -# Create secret file from env vars (for testing ConfigLoader) then start app -ENTRYPOINT ["sh", "-c", "/app/create-secret-file.sh && java $JAVA_OPTS -jar app.jar"] +# Start app +# If /run/secrets/lottery-config.properties exists (mounted), ConfigLoader will use it +# Otherwise, create-secret-file.sh will create it from env vars (for development/testing) +# Uses external logback-spring.xml for runtime log level changes +# Ensure logback-spring.xml exists and is a file (not a directory) +ENTRYPOINT ["sh", "-c", "if [ ! -f /run/secrets/lottery-config.properties ]; then /app/create-secret-file.sh; fi && if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"] diff --git a/Dockerfile.inferno b/Dockerfile.inferno index 92d9e19..7bfc0a7 100644 --- a/Dockerfile.inferno +++ b/Dockerfile.inferno @@ -18,10 +18,22 @@ WORKDIR /app # Copy fat jar from build stage COPY --from=build /app/target/*.jar app.jar +# Copy logback-spring.xml to config directory (can be overridden by volume mount) +COPY --from=build /app/src/main/resources/logback-spring.xml /app/config/logback-spring.xml + +# Create log directory +RUN mkdir -p /app/logs && chmod 755 /app/logs + # Expose port (for internal communication with nginx) EXPOSE 8080 +# Default environment variables (can be overridden in docker-compose) ENV JAVA_OPTS="" +ENV LOGGING_CONFIG="/app/config/logback-spring.xml" +ENV LOG_DIR="/app/logs" + +# Start app with external logback config +# Ensure logback-spring.xml exists and is a file (not a directory) +ENTRYPOINT ["sh", "-c", "if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"] -ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/EXTERNAL_API.md b/EXTERNAL_API.md new file mode 100644 index 0000000..b788fa1 --- /dev/null +++ b/EXTERNAL_API.md @@ -0,0 +1,88 @@ +# Внешние API (токен в пути, без сессионной авторизации) + +Описание трёх эндпоинтов для внешних систем. Токены задаются через переменные окружения на VPS. + +--- + +## 1. GET /api/remotebet/{token} + +Регистрация пользователя в текущий раунд комнаты с указанной ставкой (удалённая ставка). + +**Параметры пути** + +| Параметр | Тип | Описание | +|----------|--------|----------| +| token | string | Секретный токен (должен совпадать с `APP_REMOTE_BET_TOKEN`) | + +**Query-параметры** + +| Параметр | Тип | Обязательный | Описание | +|----------|--------|--------------|----------| +| user_id | integer| да | Внутренний ID пользователя (db_users_a.id) | +| room | integer| да | Номер комнаты: 1, 2 или 3 | +| amount | integer| да | Ставка в билетах (например, 5 = 5 билетов) | + +**Ответ 200** + +| Поле | Тип | Описание | +|--------------|--------|----------| +| success | boolean| Успешность операции | +| roundId | integer| ID раунда (или null) | +| room | integer| Номер комнаты | +| betTickets | integer| Размер ставки в билетах | +| error | string | Сообщение об ошибке (при success = false) | + +**Коды ответа:** 200, 400, 403, 503 + +--- + +## 2. GET /api/check_user/{token}/{telegramId} + +Получение информации о пользователе по Telegram ID. + +**Параметры пути** + +| Параметр | Тип | Описание | +|------------|--------|----------| +| token | string | Секретный токен (должен совпадать с `APP_CHECK_USER_TOKEN`) | +| telegramId | long | Telegram ID пользователя | + +**Тело запроса:** отсутствует + +**Ответ 200** + +При успешном вызове всегда возвращается 200. По полю `found` можно определить, найден ли пользователь. + +| Поле | Тип | Описание | +|-------------|--------|----------| +| found | boolean| true — пользователь найден, остальные поля заполнены; false — пользователь не найден, остальные поля null | +| dateReg | integer| Дата регистрации (при found=true) | +| tickets | number | Баланс в билетах (balance_a / 1_000_000) (при found=true) | +| depositTotal| integer| Сумма stars_amount по завершённым платежам (Stars) (при found=true) | +| refererId | integer| referer_id_1 из db_users_d (0 если нет) (при found=true) | +| roundsPlayed| integer| Количество сыгранных раундов (при found=true) | + +**Коды ответа:** 200, 403, 500 + +--- + +## 3. POST /api/deposit_webhook/{token} + +Уведомление об успешном пополнении пользователя (криптоплатёж). Создаётся платёж в статусе COMPLETED, начисляются билеты, обновляются баланс и статистика депозитов, создаётся транзакция типа DEPOSIT. + +**Параметры пути** + +| Параметр | Тип | Описание | +|----------|--------|----------| +| token | string | Секретный токен (должен совпадать с `APP_DEPOSIT_WEBHOOK_TOKEN`) | + +**Тело запроса (application/json)** + +| Поле | Тип | Обязательный | Описание | +|-----------|--------|--------------|----------| +| user_id | integer| да | Внутренний ID пользователя (db_users_a.id) | +| usd_amount| number | да | Сумма в USD в виде числа (например, 1.45 или 50) | + +**Тело ответа:** пустое при успехе + +**Коды ответа:** 200, 400, 403, 500 diff --git a/LOGGING_GUIDE.md b/LOGGING_GUIDE.md new file mode 100644 index 0000000..18311e2 --- /dev/null +++ b/LOGGING_GUIDE.md @@ -0,0 +1,341 @@ +# Logging Configuration Guide + +## Overview + +The application uses Logback for logging with the following features: +- **Runtime log level changes** (scan every 30 seconds) +- **Asynchronous file logging** (non-blocking I/O for high concurrency) +- **Automatic log rotation** (50MB per file, 14 days retention, 10GB total cap) +- **External configuration file** (editable on VPS without rebuilding) + +## Log File Location + +### Default Location +Logs are stored in: `./logs/` directory (relative to where the JAR is executed) + +### Custom Location +Set the `LOG_DIR` environment variable or system property: +```bash +export LOG_DIR=/var/log/lottery-be +java -jar lottery-be.jar +``` + +Or: +```bash +java -DLOG_DIR=/var/log/lottery-be -jar lottery-be.jar +``` + +## Log File Naming + +- **Current log**: `logs/lottery-be.log` +- **Rolled logs**: `logs/lottery-be-2024-01-15.0.log`, `logs/lottery-be-2024-01-15.1.log`, etc. +- **Max file size**: 50MB per file +- **Retention**: 14 days +- **Total size cap**: 10GB + +## Using External logback-spring.xml on VPS + +By default, `logback-spring.xml` is packaged inside the JAR. To use an external file on your VPS: + +### Step 1: Copy logback-spring.xml to VPS + +```bash +# Copy from JAR (if needed) or from your source code +# Place it in a location like: /opt/lottery-be/config/logback-spring.xml +# Or next to your JAR: /opt/lottery-be/logback-spring.xml +``` + +### Step 2: Start application with external config + +```bash +# Option 1: System property +java -Dlogging.config=/opt/lottery-be/logback-spring.xml -jar lottery-be.jar + +# Option 2: Environment variable +export LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml +java -jar lottery-be.jar + +# Option 3: In systemd service file +[Service] +Environment="LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml" +ExecStart=/usr/bin/java -jar /opt/lottery-be/lottery-be.jar +``` + +### Step 3: Edit logback-spring.xml on VPS + +```bash +# Edit the file +nano /opt/lottery-be/logback-spring.xml + +# Change log level (example: change com.lottery from INFO to DEBUG) +# Find: +# Change to: + +# Save and exit +# Logback will automatically reload within 30 seconds (scanPeriod="30 seconds") +``` + +## Linux Commands for Log Management + +### Find Log Files + +```bash +# Find all log files +find /opt/lottery-be -name "*.log" -type f + +# Find logs in default location +ls -lh ./logs/ + +# Find logs with custom LOG_DIR +ls -lh /var/log/lottery-be/ +``` + +### View Log Files + +```bash +# View current log file (real-time) +tail -f logs/lottery-be.log + +# View last 100 lines +tail -n 100 logs/lottery-be.log + +# View with line numbers +cat -n logs/lottery-be.log | less + +# View specific date's log +cat logs/lottery-be-2024-01-15.0.log +``` + +### Search Logs + +```bash +# Search for errors +grep -i "error" logs/lottery-be.log + +# Search for specific user ID +grep "userId=123" logs/lottery-be.log + +# Search across all log files +grep -r "ERROR" logs/ + +# Search with context (5 lines before/after) +grep -C 5 "ERROR" logs/lottery-be.log + +# Search and highlight +grep --color=always "ERROR\|WARN" logs/lottery-be.log | less -R +``` + +### Monitor Logs in Real-Time + +```bash +# Follow current log +tail -f logs/lottery-be.log + +# Follow and filter for errors only +tail -f logs/lottery-be.log | grep -i error + +# Follow multiple log files +tail -f logs/lottery-be*.log + +# Follow with timestamps +tail -f logs/lottery-be.log | while read line; do echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"; done +``` + +### Check Log File Sizes + +```bash +# Check size of all log files +du -sh logs/* + +# Check total size of logs directory +du -sh logs/ + +# List files sorted by size +ls -lhS logs/ + +# Check disk space +df -h +``` + +### Clean Old Logs + +```bash +# Logback automatically deletes logs older than 14 days +# But you can manually clean if needed: + +# Remove logs older than 7 days +find logs/ -name "*.log" -mtime +7 -delete + +# Remove logs older than 14 days (matching logback retention) +find logs/ -name "*.log" -mtime +14 -delete +``` + +## Changing Log Level at Runtime + +### Method 1: Edit logback-spring.xml (Recommended) + +1. **Edit the external logback-spring.xml file**: + ```bash + nano /opt/lottery-be/logback-spring.xml + ``` + +2. **Change the logger level** (example: enable DEBUG for entire app): + ```xml + + + + + + ``` + +3. **Save the file**. Logback will automatically reload within 30 seconds. + +4. **Verify the change**: + ```bash + tail -f logs/lottery-be.log + # You should see DEBUG logs appearing after ~30 seconds + ``` + +### Method 2: Change Specific Logger + +To change only a specific service (e.g., GameRoomService): + +```xml + + + + + +``` + +### Method 3: Change Root Level + +To change the root level for all loggers: + +```xml + + + + + +``` + +**Note**: This will generate A LOT of logs. Use with caution in production. + +## Log Levels Explained + +- **ERROR**: Critical errors that need immediate attention +- **WARN**: Warnings that might indicate problems +- **INFO**: Important application events (round completion, payments, etc.) +- **DEBUG**: Detailed debugging information (very verbose, use only for troubleshooting) + +## Default Configuration + +- **Root level**: INFO +- **Application (com.lottery)**: INFO +- **High-traffic services**: WARN (GameRoomService, GameWebSocketController) +- **Infrastructure packages**: WARN (Spring, Hibernate, WebSocket, etc.) + +## Performance Considerations + +- **Asynchronous logging**: Logs are written asynchronously to prevent blocking main threads +- **Queue size**: 256 log entries (good for 1000+ concurrent users) +- **Never block**: If queue is full, lower-level logs (DEBUG/INFO) may be dropped, but WARN/ERROR are always kept +- **File I/O**: All file writes are non-blocking + +## Troubleshooting + +### Logs not appearing + +1. Check log file location: + ```bash + ls -la logs/ + ``` + +2. Check file permissions: + ```bash + ls -l logs/lottery-be.log + # Ensure the application user has write permissions + ``` + +3. Check disk space: + ```bash + df -h + ``` + +### Log level changes not taking effect + +1. Verify scan is enabled in logback-spring.xml: + ```xml + + ``` + +2. Check for syntax errors in logback-spring.xml: + ```bash + # Logback will log errors to console if config is invalid + ``` + +3. Restart application if needed (shouldn't be necessary with scan enabled) + +### Too many logs / Out of memory + +1. Increase log level to WARN: + ```xml + + ``` + +2. Check log file sizes: + ```bash + du -sh logs/* + ``` + +3. Clean old logs manually if needed + +## Example: Enabling DEBUG for Troubleshooting + +1. **Edit logback-spring.xml**: + ```bash + nano /opt/lottery-be/logback-spring.xml + ``` + +2. **Change specific logger to DEBUG**: + ```xml + + ``` + +3. **Save and wait 30 seconds** + +4. **Monitor logs**: + ```bash + tail -f logs/lottery-be.log | grep "GameRoomService" + ``` + +5. **After troubleshooting, change back to WARN**: + ```xml + + ``` + +## Systemd Service Example + +If using systemd, here's an example service file: + +```ini +[Unit] +Description=Lottery Backend Application +After=network.target + +[Service] +Type=simple +User=lottery +WorkingDirectory=/opt/lottery-be +Environment="LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml" +Environment="LOG_DIR=/var/log/lottery-be" +ExecStart=/usr/bin/java -jar /opt/lottery-be/lottery-be.jar +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + + diff --git a/PHPMYADMIN_QUICK_START.md b/PHPMYADMIN_QUICK_START.md new file mode 100644 index 0000000..1081e74 --- /dev/null +++ b/PHPMYADMIN_QUICK_START.md @@ -0,0 +1,94 @@ +# phpMyAdmin Quick Start Guide + +## Quick Setup (Copy & Paste) + +```bash +# 1. Navigate to project directory +cd /opt/app/backend/lottery-be + +# 2. Load database password +source scripts/load-db-password.sh + +# 3. Start phpMyAdmin +docker-compose -f docker-compose.prod.yml up -d phpmyadmin + +# 4. Verify it's running +docker ps | grep phpmyadmin + +# 5. Open firewall port +sudo ufw allow 8081/tcp +sudo ufw reload + +# 6. Get your VPS IP (if you don't know it) +hostname -I | awk '{print $1}' +``` + +## Access phpMyAdmin + +**URL**: `http://YOUR_VPS_IP:8081` + +**Login Credentials**: +- **Server**: `db` (or leave default) +- **Username**: `root` +- **Password**: Get it with: `grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties` + +## Security: Restrict Access to Your IP Only + +```bash +# Get your current IP +curl ifconfig.me + +# Remove open access +sudo ufw delete allow 8081/tcp + +# Allow only your IP (replace YOUR_IP with your actual IP) +sudo ufw allow from YOUR_IP to any port 8081 + +# Reload firewall +sudo ufw reload +``` + +## Verify Everything Works + +```bash +# Check container is running +docker ps | grep phpmyadmin + +# Check logs +docker logs lottery-phpmyadmin + +# Test connection from browser +# Open: http://YOUR_VPS_IP:8081 +``` + +## Common Issues + +**Container won't start?** +```bash +# Make sure password is loaded +source scripts/load-db-password.sh +echo $DB_ROOT_PASSWORD + +# Restart +docker-compose -f docker-compose.prod.yml restart phpmyadmin +``` + +**Can't access from browser?** +```bash +# Check firewall +sudo ufw status | grep 8081 + +# Check if port is listening +sudo netstat -tlnp | grep 8081 +``` + +**Wrong password?** +```bash +# Get the correct password +grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties +``` + +## Full Documentation + +See `PHPMYADMIN_SETUP.md` for detailed instructions and troubleshooting. + diff --git a/PHPMYADMIN_SETUP.md b/PHPMYADMIN_SETUP.md new file mode 100644 index 0000000..85cee6c --- /dev/null +++ b/PHPMYADMIN_SETUP.md @@ -0,0 +1,355 @@ +# phpMyAdmin Setup Guide + +This guide explains how to set up phpMyAdmin for managing your MySQL database on your VPS. + +## Overview + +- **phpMyAdmin Port**: 8081 (mapped to container port 80) +- **MySQL Service Name**: `db` (internal Docker network) +- **Database Name**: `lottery_db` +- **Network**: `lottery-network` (shared with MySQL and backend) + +## Security Features + +✅ **MySQL port 3306 is NOT exposed** - Only accessible within Docker network +✅ **phpMyAdmin accessible on port 8081** - Can be restricted via firewall +✅ **Upload limit set to 64M** - Prevents large file uploads +✅ **Uses same root password** - From your existing secret file + +## Prerequisites + +- Docker and Docker Compose installed on VPS +- Existing MySQL database running in Docker +- `DB_ROOT_PASSWORD` environment variable set (from secret file) + +## Step-by-Step Deployment + +### Step 1: Verify Current Setup + +First, check that your MySQL container is running and the database password is accessible: + +```bash +cd /opt/app/backend/lottery-be + +# Check if MySQL container is running +docker ps | grep lottery-mysql + +# Load database password (if not already set) +source scripts/load-db-password.sh + +# Verify password is set +echo $DB_ROOT_PASSWORD +``` + +### Step 2: Update Docker Compose + +The `docker-compose.prod.yml` file has already been updated with the phpMyAdmin service. Verify the changes: + +```bash +# View the phpMyAdmin service configuration +grep -A 20 "phpmyadmin:" docker-compose.prod.yml +``` + +You should see: +- Service name: `phpmyadmin` +- Port mapping: `8081:80` +- PMA_HOST: `db` +- UPLOAD_LIMIT: `64M` + +### Step 3: Start phpMyAdmin Service + +```bash +cd /opt/app/backend/lottery-be + +# Make sure DB_ROOT_PASSWORD is set +source scripts/load-db-password.sh + +# Start only the phpMyAdmin service (MySQL should already be running) +docker-compose -f docker-compose.prod.yml up -d phpmyadmin +``` + +Or if you want to restart all services: + +```bash +# Stop all services +docker-compose -f docker-compose.prod.yml down + +# Start all services (including phpMyAdmin) +source scripts/load-db-password.sh +docker-compose -f docker-compose.prod.yml up -d +``` + +### Step 4: Verify phpMyAdmin is Running + +```bash +# Check container status +docker ps | grep phpmyadmin + +# Check logs for any errors +docker logs lottery-phpmyadmin + +# Test if port 8081 is listening +netstat -tlnp | grep 8081 +# or +ss -tlnp | grep 8081 +``` + +### Step 5: Configure Firewall (UFW) + +On Inferno Solutions VPS (Ubuntu), you need to allow port 8081: + +```bash +# Check current UFW status +sudo ufw status + +# Allow port 8081 (replace with your VPS IP if you want to restrict access) +sudo ufw allow 8081/tcp + +# If you want to restrict to specific IP only (recommended for production): +# sudo ufw allow from YOUR_IP_ADDRESS to any port 8081 + +# Reload UFW +sudo ufw reload + +# Verify the rule was added +sudo ufw status numbered +``` + +**Security Recommendation**: If you have a static IP, restrict access to that IP only: + +```bash +# Replace YOUR_IP_ADDRESS with your actual IP +sudo ufw allow from YOUR_IP_ADDRESS to any port 8081 +``` + +### Step 6: Access phpMyAdmin + +Open your web browser and navigate to: + +``` +http://YOUR_VPS_IP:8081 +``` + +**Example**: If your VPS IP is `37.1.206.220`, use: +``` +http://37.1.206.220:8081 +``` + +### Step 7: Login to phpMyAdmin + +Use these credentials: + +- **Server**: `db` (or leave as default - phpMyAdmin will auto-detect) +- **Username**: `root` +- **Password**: The value from `SPRING_DATASOURCE_PASSWORD` in your secret file + +To get the password: + +```bash +# On your VPS +grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties +``` + +## Verification Checklist + +After setup, verify: + +- [ ] phpMyAdmin container is running: `docker ps | grep phpmyadmin` +- [ ] Port 8081 is accessible: `curl http://localhost:8081` (should return HTML) +- [ ] Firewall allows port 8081: `sudo ufw status | grep 8081` +- [ ] Can login to phpMyAdmin with root credentials +- [ ] Can see `lottery_db` database in phpMyAdmin +- [ ] MySQL port 3306 is NOT exposed: `netstat -tlnp | grep 3306` (should show nothing or only 127.0.0.1) + +## Security Best Practices + +### 1. Restrict Access by IP (Recommended) + +Only allow your IP address to access phpMyAdmin: + +```bash +# Find your current IP +curl ifconfig.me + +# Allow only your IP +sudo ufw delete allow 8081/tcp +sudo ufw allow from YOUR_IP_ADDRESS to any port 8081 +``` + +### 2. Use HTTPS (Optional but Recommended) + +If you have a domain and SSL certificate, you can set up Nginx as a reverse proxy: + +```nginx +# /etc/nginx/sites-available/phpmyadmin +server { + listen 443 ssl; + server_name phpmyadmin.yourdomain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 3. Change Default phpMyAdmin Behavior + +You can add additional security settings to the phpMyAdmin service in `docker-compose.prod.yml`: + +```yaml +environment: + # ... existing settings ... + # Disable certain features for security + PMA_CONTROLUSER: '' + PMA_CONTROLPASS: '' + # Enable HTTPS only (if using reverse proxy) + # PMA_ABSOLUTE_URI: https://phpmyadmin.yourdomain.com +``` + +### 4. Regular Updates + +Keep phpMyAdmin updated: + +```bash +# Pull latest image +docker-compose -f docker-compose.prod.yml pull phpmyadmin + +# Restart service +docker-compose -f docker-compose.prod.yml up -d phpmyadmin +``` + +## Troubleshooting + +### phpMyAdmin Container Won't Start + +```bash +# Check logs +docker logs lottery-phpmyadmin + +# Common issues: +# 1. DB_ROOT_PASSWORD not set +source scripts/load-db-password.sh +docker-compose -f docker-compose.prod.yml up -d phpmyadmin + +# 2. MySQL container not running +docker-compose -f docker-compose.prod.yml up -d db +``` + +### Cannot Connect to Database + +```bash +# Verify MySQL is accessible from phpMyAdmin container +docker exec lottery-phpmyadmin ping -c 3 db + +# Check if MySQL is healthy +docker ps | grep lottery-mysql +docker logs lottery-mysql | tail -20 +``` + +### Port 8081 Not Accessible + +```bash +# Check if port is listening +sudo netstat -tlnp | grep 8081 + +# Check firewall +sudo ufw status + +# Check if container is running +docker ps | grep phpmyadmin + +# Restart phpMyAdmin +docker-compose -f docker-compose.prod.yml restart phpmyadmin +``` + +### "Access Denied" When Logging In + +1. Verify password is correct: + ```bash + grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties + ``` + +2. Verify `DB_ROOT_PASSWORD` matches: + ```bash + source scripts/load-db-password.sh + echo $DB_ROOT_PASSWORD + ``` + +3. Test MySQL connection directly: + ```bash + docker exec -it lottery-mysql mysql -u root -p + # Enter the password when prompted + ``` + +## Spring Boot Configuration Verification + +Your Spring Boot application should be using the Docker service name for the database connection. Verify: + +1. **Secret file** (`/run/secrets/lottery-config.properties`) should contain: + ``` + SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db + ``` + +2. **NOT using localhost**: + - ❌ Wrong: `jdbc:mysql://localhost:3306/lottery_db` + - ✅ Correct: `jdbc:mysql://db:3306/lottery_db` + +To verify: + +```bash +grep SPRING_DATASOURCE_URL /run/secrets/lottery-config.properties +``` + +## Maintenance Commands + +```bash +# View phpMyAdmin logs +docker logs lottery-phpmyadmin + +# Restart phpMyAdmin +docker-compose -f docker-compose.prod.yml restart phpmyadmin + +# Stop phpMyAdmin +docker-compose -f docker-compose.prod.yml stop phpmyadmin + +# Start phpMyAdmin +docker-compose -f docker-compose.prod.yml start phpmyadmin + +# Remove phpMyAdmin (keeps data) +docker-compose -f docker-compose.prod.yml rm -f phpmyadmin + +# Update phpMyAdmin to latest version +docker-compose -f docker-compose.prod.yml pull phpmyadmin +docker-compose -f docker-compose.prod.yml up -d phpmyadmin +``` + +## Quick Reference + +| Item | Value | +|------|-------| +| **URL** | `http://YOUR_VPS_IP:8081` | +| **Username** | `root` | +| **Password** | From `SPRING_DATASOURCE_PASSWORD` in secret file | +| **Server** | `db` (auto-detected) | +| **Database** | `lottery_db` | +| **Container** | `lottery-phpmyadmin` | +| **Port** | `8081` (host) → `80` (container) | +| **Network** | `lottery-network` | + +## Next Steps + +After phpMyAdmin is set up: + +1. ✅ Test login and database access +2. ✅ Verify you can see all tables in `lottery_db` +3. ✅ Set up IP restrictions for better security +4. ✅ Consider setting up HTTPS via Nginx reverse proxy +5. ✅ Document your access credentials securely + diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..757bc15 --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,217 @@ +# Quick Reference - VPS Deployment + +## Common Commands + +### Docker Compose (Backend) + +```bash +cd /opt/app/backend + +# Start services +docker compose -f docker-compose.prod.yml up -d + +# Stop services +docker compose -f docker-compose.prod.yml down + +# Restart services +docker compose -f docker-compose.prod.yml restart + +# View logs +docker compose -f docker-compose.prod.yml logs -f + +# Rebuild and restart +docker compose -f docker-compose.prod.yml up -d --build + +# Check status +docker compose -f docker-compose.prod.yml ps +``` + +### Nginx + +```bash +# Test configuration +sudo nginx -t + +# Reload configuration +sudo systemctl reload nginx + +# Restart Nginx +sudo systemctl restart nginx + +# Check status +sudo systemctl status nginx + +# View logs +sudo tail -f /var/log/nginx/error.log +sudo tail -f /var/log/nginx/access.log +``` + +### SSL Certificate + +```bash +# Renew certificate +sudo certbot renew + +# Test renewal +sudo certbot renew --dry-run + +# Check certificates +sudo certbot certificates +``` + +### Database + +```bash +# Load database password from secret file (if not already loaded) +cd /opt/app/backend +source scripts/load-db-password.sh + +# Backup +docker exec lottery-mysql mysqldump -u root -p${DB_PASSWORD} lottery_db > backup_$(date +%Y%m%d).sql + +# Restore +docker exec -i lottery-mysql mysql -u root -p${DB_PASSWORD} lottery_db < backup.sql + +# Access MySQL shell +docker exec -it lottery-mysql mysql -u root -p +``` + +### Health Checks + +```bash +# Backend health +curl http://localhost:8080/actuator/health + +# Frontend +curl https://yourdomain.com/ + +# API endpoint +curl https://yourdomain.com/api/health +``` + +### Logs + +```bash +# Backend logs +cd /opt/app/backend +docker compose -f docker-compose.prod.yml logs -f backend + +# Database logs +docker compose -f docker-compose.prod.yml logs -f db + +# All logs +docker compose -f docker-compose.prod.yml logs -f + +# Nginx error log +sudo tail -f /var/log/nginx/error.log +``` + +### File Permissions + +```bash +# Fix avatar directory permissions +sudo chown -R $USER:$USER /opt/app/data/avatars +sudo chmod -R 755 /opt/app/data/avatars + +# Secure secret file +sudo chmod 640 /run/secrets/lottery-config.properties +sudo chown root:docker /run/secrets/lottery-config.properties +``` + +### Update Application + +```bash +# Backend update +cd /opt/app/backend +git pull # or copy new files +docker compose -f docker-compose.prod.yml up -d --build + +# Frontend update +# 1. Build locally: npm run build +# 2. Copy dist/ to /opt/app/frontend/dist/ +scp -r dist/* user@vps:/opt/app/frontend/dist/ +``` + +## Troubleshooting + +### Backend won't start +```bash +# Check logs +docker compose -f docker-compose.prod.yml logs backend + +# Check secret file exists and is readable +sudo ls -la /run/secrets/lottery-config.properties + +# Verify secret file is loaded (check logs for "Loading configuration from mounted secret file") +docker compose -f docker-compose.prod.yml logs backend | grep "Loading configuration" + +# Verify database is ready +docker compose -f docker-compose.prod.yml ps db +``` + +### Frontend not loading +```bash +# Check Nginx config +sudo nginx -t + +# Verify files exist +ls -la /opt/app/frontend/dist/ + +# Check Nginx error log +sudo tail -f /var/log/nginx/error.log +``` + +### WebSocket issues +```bash +# Check backend logs +docker compose -f docker-compose.prod.yml logs backend | grep -i websocket + +# Verify Nginx WebSocket config +grep -A 10 "/ws" /opt/app/nginx/nginx.conf +``` + +### Database connection failed +```bash +# Check database container +docker ps | grep mysql + +# Check database logs +docker compose -f docker-compose.prod.yml logs db + +# Test connection +docker exec -it lottery-mysql mysql -u root -p +``` + +## File Locations + +``` +Backend source: /opt/app/backend/ +Frontend build: /opt/app/frontend/dist/ +Nginx config: /opt/app/nginx/nginx.conf +Avatar storage: /opt/app/data/avatars/ +Database data: /opt/app/mysql/data/ (via Docker volume) +Secret file: /run/secrets/lottery-config.properties +``` + +## Configuration Variables + +Required in `/run/secrets/lottery-config.properties`: + +- `SPRING_DATASOURCE_URL` +- `SPRING_DATASOURCE_USERNAME` +- `SPRING_DATASOURCE_PASSWORD` +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN` +- `TELEGRAM_FOLLOW_TASK_CHANNEL_ID` +- `FRONTEND_URL` + +Optional: +- `APP_AVATAR_STORAGE_PATH` +- `APP_AVATAR_PUBLIC_BASE_URL` +- `APP_SESSION_MAX_ACTIVE_PER_USER` +- `APP_SESSION_CLEANUP_BATCH_SIZE` +- `APP_SESSION_CLEANUP_MAX_BATCHES` +- `GEOIP_DB_PATH` + +**Note:** The MySQL container also needs `DB_PASSWORD` and `DB_ROOT_PASSWORD` as environment variables (should match `SPRING_DATASOURCE_PASSWORD`). + diff --git a/README.md b/README.md index 53b3f2d..b351885 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Honey Backend +# Lottery Backend -Spring Boot backend application for Honey project. +Spring Boot backend application for Lottery project. ## Technology Stack @@ -30,7 +30,7 @@ Spring Boot backend application for Honey project. 3. **Create `.env` file** (for local development): ```env - DB_NAME=honey_db + DB_NAME=lottery_db DB_USERNAME=root DB_PASSWORD=password DB_ROOT_PASSWORD=password @@ -75,7 +75,7 @@ Railway is the primary deployment platform for staging. It provides built-in log 1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**) 2. If using GitHub: - Connect your GitHub account - - Select the `honey-be` repository + - Select the `lottery-be` repository - Railway will automatically detect it's a Java/Maven project 3. If using Empty Service: - Click **"Empty Service"** @@ -122,12 +122,12 @@ PORT=8080 1. In your backend service, go to **"Settings"** → **"Networking"** 2. Click **"Generate Domain"** to get a public URL 3. Or use the default Railway domain -4. Copy the URL (e.g., `https://honey-be-production.up.railway.app`) +4. Copy the URL (e.g., `https://lottery-be-production.up.railway.app`) #### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway) 1. In your Railway project, click **"+ New"** → **"GitHub Repo"** -2. Select your `honey-fe` repository +2. Select your `lottery-fe` repository 3. Railway will detect it's a Node.js project 4. Add environment variable: ```env @@ -140,7 +140,7 @@ PORT=8080 If you need persistent storage: 1. In your Railway project, click **"+ New"** → **"Volume"** -2. Name it (e.g., `honey-data`) +2. Name it (e.g., `lottery-data`) 3. Mount it to your service if needed ### Inferno Deployment (Production Environment) @@ -184,16 +184,16 @@ Inferno Solution provides the production environment. It requires manual server 5. **Create project directory**: ```bash - mkdir -p /opt/honey - cd /opt/honey + mkdir -p /opt/lottery + cd /opt/lottery ``` #### Step 2: Clone Repository ```bash -cd /opt/honey -git clone https://github.com/your-username/honey-be.git -cd honey-be +cd /opt/lottery +git clone https://github.com/your-username/lottery-be.git +cd lottery-be ``` #### Step 3: Create Secret Configuration File @@ -206,14 +206,14 @@ sudo mkdir -p /run/secrets sudo chmod 700 /run/secrets # Create secret file -sudo nano /run/secrets/honey-config.properties +sudo nano /run/secrets/lottery-config.properties ``` Add the following content (replace with your actual values): ```properties -SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db -SPRING_DATASOURCE_USERNAME=honey_user +SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db +SPRING_DATASOURCE_USERNAME=lottery_user SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password TELEGRAM_BOT_TOKEN=your_telegram_bot_token FRONTEND_URL=https://your-frontend-domain.com @@ -233,7 +233,7 @@ The `docker-compose.inferno.yml` file is already configured. Make sure it's pres #### Step 5: Build and Start Services ```bash -cd /opt/honey/honey-be +cd /opt/lottery/lottery-be # Build and start all services docker-compose -f docker-compose.inferno.yml up -d --build @@ -249,7 +249,7 @@ This will: 1. **Edit nginx configuration**: ```bash - nano nginx/conf.d/honey.conf + nano nginx/conf.d/lottery.conf ``` 2. **Update server_name** (if using HTTPS): @@ -278,7 +278,7 @@ This will: sudo certbot --nginx -d your-domain.com ``` -3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/honey.conf`) +3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`) 4. **Reload nginx**: ```bash @@ -304,21 +304,21 @@ sudo ufw enable Create a systemd service to ensure services start on boot: ```bash -sudo nano /etc/systemd/system/honey.service +sudo nano /etc/systemd/system/lottery.service ``` Add: ```ini [Unit] -Description=Honey Application +Description=Lottery Application Requires=docker.service After=docker.service [Service] Type=oneshot RemainAfterExit=yes -WorkingDirectory=/opt/honey/honey-be +WorkingDirectory=/opt/lottery/lottery-be ExecStart=/usr/local/bin/docker-compose -f docker-compose.inferno.yml up -d ExecStop=/usr/local/bin/docker-compose -f docker-compose.inferno.yml down TimeoutStartSec=0 @@ -331,8 +331,8 @@ Enable the service: ```bash sudo systemctl daemon-reload -sudo systemctl enable honey.service -sudo systemctl start honey.service +sudo systemctl enable lottery.service +sudo systemctl start lottery.service ``` #### Step 10: Set Up Grafana Integration (Production Logging) @@ -357,13 +357,13 @@ sudo systemctl start honey.service - url: http://loki:3100/loki/api/v1/push scrape_configs: - - job_name: honey-backend + - job_name: lottery-backend docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s relabel_configs: - source_labels: [__meta_docker_container_name] - regex: honey-backend + regex: lottery-backend action: keep ``` @@ -398,14 +398,14 @@ docker-compose -f docker-compose.inferno.yml logs -f app **Update application**: ```bash -cd /opt/honey/honey-be +cd /opt/lottery/lottery-be git pull docker-compose -f docker-compose.inferno.yml up -d --build ``` **Backup database**: ```bash -docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p honey_db > backup_$(date +%Y%m%d).sql +docker-compose -f docker-compose.inferno.yml exec db mysqldump -u lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql ``` ## Configuration @@ -415,7 +415,7 @@ docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p The application supports two configuration strategies: 1. **Environment Variables** (Railway): Set variables in Railway dashboard -2. **Secret File** (Inferno): Mount file at `/run/secrets/honey-config.properties` +2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties` Priority: Secret file → Environment variables @@ -511,10 +511,10 @@ docker-compose up --build ## Project Structure ``` -honey-be/ +lottery-be/ ├── src/ │ ├── main/ -│ │ ├── java/com/honey/honey/ +│ │ ├── java/com/lottery/lottery/ │ │ │ ├── config/ # Configuration classes │ │ │ ├── controller/ # REST controllers │ │ │ ├── dto/ # Data transfer objects @@ -540,3 +540,4 @@ honey-be/ [Your License Here] + diff --git a/ROLLING_UPDATE_GUIDE.md b/ROLLING_UPDATE_GUIDE.md new file mode 100644 index 0000000..0bf80aa --- /dev/null +++ b/ROLLING_UPDATE_GUIDE.md @@ -0,0 +1,356 @@ +# Rolling Update Deployment Guide + +This guide explains how to perform zero-downtime deployments using the rolling update strategy. + +## Overview + +The rolling update approach allows you to deploy new backend code without any downtime for users. Here's how it works: + +1. **Build** new backend image while old container is still running +2. **Start** new container on port 8082 (old one stays on 8080) +3. **Health check** new container to ensure it's ready +4. **Switch** Nginx to point to new container (zero downtime) +5. **Stop** old container after grace period + +## Architecture + +``` +┌─────────────┐ +│ Nginx │ (Port 80/443) +│ (Host) │ +└──────┬──────┘ + │ + ├───> Backend (Port 8080) - Primary + └───> Backend-New (Port 8082) - Standby (during deployment) +``` + +## Prerequisites + +1. **Nginx running on host** (not in Docker) +2. **Backend containers** managed by Docker Compose +3. **Health check endpoint** available at `/actuator/health/readiness` +4. **Sufficient memory** for two backend containers during deployment (~24GB) + +## Quick Start + +### 1. Make Script Executable + +```bash +cd /opt/app/backend/lottery-be +chmod +x scripts/rolling-update.sh +``` + +### 2. Run Deployment + +```bash +# Load database password (if not already set) +source scripts/load-db-password.sh + +# Run rolling update +sudo ./scripts/rolling-update.sh +``` + +That's it! The script handles everything automatically. + +## What the Script Does + +1. **Checks prerequisites**: + - Verifies Docker and Nginx are available + - Ensures primary backend is running + - Loads database password + +2. **Builds new image**: + - Builds backend-new service + - Uses Docker Compose build cache for speed + +3. **Starts new container**: + - Starts `lottery-backend-new` on port 8082 + - Waits for container initialization + +4. **Health checks**: + - Checks `/actuator/health/readiness` endpoint + - Retries up to 30 times (60 seconds total) + - Fails deployment if health check doesn't pass + +5. **Updates Nginx**: + - Backs up current Nginx config + - Updates upstream to point to port 8082 + - Sets old backend (8080) as backup + - Tests Nginx configuration + +6. **Reloads Nginx**: + - Uses `systemctl reload nginx` (zero downtime) + - Traffic immediately switches to new backend + +7. **Stops old container**: + - Waits 10 seconds grace period + - Stops old backend container + - Old container can be removed or kept for rollback + +## Manual Steps (If Needed) + +If you prefer to do it manually or need to troubleshoot: + +### Step 1: Build New Image + +```bash +cd /opt/app/backend/lottery-be +source scripts/load-db-password.sh +docker-compose -f docker-compose.prod.yml --profile rolling-update build backend-new +``` + +### Step 2: Start New Container + +```bash +docker-compose -f docker-compose.prod.yml --profile rolling-update up -d backend-new +``` + +### Step 3: Health Check + +```bash +# Wait for container to be ready +sleep 10 + +# Check health +curl http://127.0.0.1:8082/actuator/health/readiness + +# Check logs +docker logs lottery-backend-new +``` + +### Step 4: Update Nginx + +```bash +# Backup config +sudo cp /etc/nginx/conf.d/lottery.conf /etc/nginx/conf.d/lottery.conf.backup + +# Edit config +sudo nano /etc/nginx/conf.d/lottery.conf +``` + +Change upstream from: +```nginx +upstream lottery_backend { + server 127.0.0.1:8080 max_fails=3 fail_timeout=30s; +} +``` + +To: +```nginx +upstream lottery_backend { + server 127.0.0.1:8082 max_fails=3 fail_timeout=30s; + server 127.0.0.1:8080 backup; +} +``` + +### Step 5: Reload Nginx + +```bash +# Test config +sudo nginx -t + +# Reload (zero downtime) +sudo systemctl reload nginx +``` + +### Step 6: Stop Old Container + +```bash +# Wait for active connections to finish +sleep 10 + +# Stop old container +docker-compose -f docker-compose.prod.yml stop backend +``` + +## Rollback Procedure + +If something goes wrong, you can quickly rollback: + +### Automatic Rollback + +The script automatically rolls back if: +- Health check fails +- Nginx config test fails +- Nginx reload fails + +### Manual Rollback + +```bash +# 1. Restore Nginx config +sudo cp /etc/nginx/conf.d/lottery.conf.backup /etc/nginx/conf.d/lottery.conf +sudo systemctl reload nginx + +# 2. Start old backend (if stopped) +cd /opt/app/backend/lottery-be +docker-compose -f docker-compose.prod.yml start backend + +# 3. Stop new backend +docker-compose -f docker-compose.prod.yml --profile rolling-update stop backend-new +docker-compose -f docker-compose.prod.yml --profile rolling-update rm -f backend-new +``` + +## Configuration + +### Health Check Settings + +Edit `scripts/rolling-update.sh` to adjust: + +```bash +HEALTH_CHECK_RETRIES=30 # Number of retries +HEALTH_CHECK_INTERVAL=2 # Seconds between retries +GRACE_PERIOD=10 # Seconds to wait before stopping old container +``` + +### Nginx Upstream Settings + +Edit `/etc/nginx/conf.d/lottery.conf`: + +```nginx +upstream lottery_backend { + server 127.0.0.1:8082 max_fails=3 fail_timeout=30s; + server 127.0.0.1:8080 backup; # Old backend as backup + keepalive 32; +} +``` + +## Monitoring + +### During Deployment + +```bash +# Watch container status +watch -n 1 'docker ps | grep lottery-backend' + +# Monitor new backend logs +docker logs -f lottery-backend-new + +# Check Nginx access logs +sudo tail -f /var/log/nginx/access.log + +# Monitor memory usage +free -h +docker stats --no-stream +``` + +### After Deployment + +```bash +# Verify new backend is serving traffic +curl http://localhost/api/health + +# Check container status +docker ps | grep lottery-backend + +# Verify Nginx upstream +curl http://localhost/actuator/health +``` + +## Troubleshooting + +### Health Check Fails + +```bash +# Check new container logs +docker logs lottery-backend-new + +# Check if container is running +docker ps | grep lottery-backend-new + +# Test health endpoint directly +curl -v http://127.0.0.1:8082/actuator/health/readiness + +# Check database connection +docker exec lottery-backend-new wget -q -O- http://localhost:8080/actuator/health +``` + +### Nginx Reload Fails + +```bash +# Test Nginx config +sudo nginx -t + +# Check Nginx error logs +sudo tail -f /var/log/nginx/error.log + +# Verify upstream syntax +sudo nginx -T | grep -A 5 upstream +``` + +### Memory Issues + +If you run out of memory during deployment: + +```bash +# Check memory usage +free -h +docker stats --no-stream + +# Option 1: Reduce heap size temporarily +# Edit docker-compose.prod.yml, change JAVA_OPTS to use 8GB heap + +# Option 2: Stop other services temporarily +docker stop lottery-phpmyadmin # If not needed +``` + +### Old Container Won't Stop + +```bash +# Force stop +docker stop lottery-backend + +# If still running, kill it +docker kill lottery-backend + +# Remove container +docker rm lottery-backend +``` + +## Best Practices + +1. **Test in staging first** - Always test the deployment process in a staging environment + +2. **Monitor during deployment** - Watch logs and metrics during the first few deployments + +3. **Keep backups** - The script automatically backs up Nginx config, but keep your own backups too + +4. **Database migrations** - Ensure migrations are backward compatible or run them separately + +5. **Gradual rollout** - For major changes, consider deploying during low-traffic periods + +6. **Health checks** - Ensure your health check endpoint properly validates all dependencies + +7. **Graceful shutdown** - Spring Boot graceful shutdown (30s) allows active requests to finish + +## Performance Considerations + +- **Build time**: First build takes longer, subsequent builds use cache +- **Memory**: Two containers use ~24GB during deployment (brief period) +- **Network**: No network interruption, Nginx handles the switch seamlessly +- **Database**: No impact, both containers share the same database + +## Security Notes + +- New container uses same secrets and configuration as old one +- No exposure of new port to internet (only localhost) +- Nginx handles all external traffic +- Health checks are internal only + +## Next Steps + +After successful deployment: + +1. ✅ Monitor new backend for errors +2. ✅ Verify all endpoints are working +3. ✅ Check application logs +4. ✅ Remove old container image (optional): `docker image prune` + +## Support + +If you encounter issues: + +1. Check logs: `docker logs lottery-backend-new` +2. Check Nginx: `sudo nginx -t && sudo tail -f /var/log/nginx/error.log` +3. Rollback if needed (see Rollback Procedure above) +4. Review this guide's Troubleshooting section + diff --git a/VPS_DEPLOYMENT_NOTES.md b/VPS_DEPLOYMENT_NOTES.md new file mode 100644 index 0000000..9060f65 --- /dev/null +++ b/VPS_DEPLOYMENT_NOTES.md @@ -0,0 +1,208 @@ +# VPS Deployment Notes - Logging Configuration + +## Automatic Setup (Docker - Recommended) + +The Docker setup is **automatically configured** to use external logback-spring.xml. No manual setup needed! + +### How It Works + +1. **Dockerfile** automatically: + - Copies logback-spring.xml to `/app/config/logback-spring.xml` in the container + - Sets `LOGGING_CONFIG` and `LOG_DIR` environment variables + - Configures Java to use external config + +2. **docker-compose.inferno.yml** automatically: + - Mounts `/opt/app/backend/config/logback-spring.xml` → `/app/config/logback-spring.xml` (editable on VPS) + - Mounts `/opt/app/logs` → `/app/logs` (persistent log storage) + - Sets environment variables + +### Initial Setup (One-Time) + +Run the setup script to extract logback-spring.xml: + +```bash +cd /opt/app/backend +# Make script executable (if not already) +chmod +x scripts/setup-logging.sh +# Run the script +./scripts/setup-logging.sh +``` + +Or run directly with bash: +```bash +bash scripts/setup-logging.sh +``` + +Or manually: + +```bash +# Create directories +mkdir -p /opt/app/backend/config +mkdir -p /opt/app/logs + +# Extract logback-spring.xml from JAR (if building on VPS) +unzip -p target/lottery-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml + +# Or copy from source +cp src/main/resources/logback-spring.xml /opt/app/backend/config/logback-spring.xml + +# Set permissions +chmod 644 /opt/app/backend/config/logback-spring.xml +``` + +### Verify Configuration + +After starting the container, check that external config is being used: + +```bash +# Check container logs +docker logs lottery-backend | grep -i "logback\|logging" + +# Check mounted file exists +ls -la /opt/app/backend/config/logback-spring.xml + +# Check log directory +ls -la /opt/app/logs/ +``` + +## Manual Setup (Non-Docker) + +If you're not using Docker, follow these steps: + +### 1. Extract logback-spring.xml from JAR + +```bash +# Option 1: Extract from JAR +unzip -p lottery-be.jar BOOT-INF/classes/logback-spring.xml > /opt/lottery-be/logback-spring.xml + +# Option 2: Copy from source code +scp logback-spring.xml user@vps:/opt/lottery-be/ +``` + +### 2. Set Up Log Directory + +```bash +# Create log directory +mkdir -p /var/log/lottery-be +chown lottery:lottery /var/log/lottery-be +chmod 755 /var/log/lottery-be +``` + +### 3. Update Your Startup Script/Service + +Add these environment variables or system properties: + +```bash +# In your startup script or systemd service: +export LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml +export LOG_DIR=/var/log/lottery-be + +java -jar lottery-be.jar +``` + +Or with system properties: + +```bash +java -Dlogging.config=/opt/lottery-be/logback-spring.xml \ + -DLOG_DIR=/var/log/lottery-be \ + -jar lottery-be.jar +``` + +### 4. Verify External Config is Being Used + +Check application startup logs for: +``` +Loading configuration from: /opt/lottery-be/logback-spring.xml +``` + +If you see this, the external config is active. + +## Changing Log Level at Runtime + +### Quick Method (30 seconds to take effect) + +**For Docker deployment:** +1. Edit the mounted logback-spring.xml: + ```bash + nano /opt/app/backend/config/logback-spring.xml + ``` + +2. Change the level (example: enable DEBUG): + ```xml + + ``` + +3. Save the file. Logback will reload within 30 seconds automatically. + +4. Verify: + ```bash + tail -f /opt/app/logs/lottery-be.log + # Or from inside container: + docker exec lottery-backend tail -f /app/logs/lottery-be.log + ``` + +**For non-Docker deployment:** +1. Edit the external logback-spring.xml: + ```bash + nano /opt/lottery-be/logback-spring.xml + ``` + +2. Change the level (example: enable DEBUG): + ```xml + + ``` + +3. Save the file. Logback will reload within 30 seconds automatically. + +4. Verify: + ```bash + tail -f /var/log/lottery-be/lottery-be.log + ``` + +### Common Log Level Changes + +**Enable DEBUG for entire app:** +```xml + +``` + +**Enable DEBUG for specific service:** +```xml + +``` + +**Enable DEBUG for WebSocket:** +```xml + +``` + +**Change root level (affects everything):** +```xml + +``` + +## Important Notes + +- **Default log level**: INFO (good for production) +- **High-traffic services**: WARN (GameRoomService, WebSocketController) +- **Auto-reload**: Changes take effect within 30 seconds +- **No restart needed**: Runtime log level changes work without restarting the app +- **Log location (Docker)**: `/opt/app/logs/` on VPS (mounted to `/app/logs` in container) +- **Log location (Non-Docker)**: `/var/log/lottery-be/` (or `./logs/` if LOG_DIR not set) +- **Config location (Docker)**: `/opt/app/backend/config/logback-spring.xml` on VPS +- **Config location (Non-Docker)**: `/opt/lottery-be/logback-spring.xml` (or your custom path) + +## Troubleshooting + +**If external config is not being used:** +1. Check the path is correct +2. Verify file permissions (readable by application user) +3. Check startup logs for errors +4. Ensure `-Dlogging.config=` or `LOGGING_CONFIG` is set correctly + +**If log level changes don't work:** +1. Verify `scan="true" scanPeriod="30 seconds"` is in logback-spring.xml +2. Check for XML syntax errors +3. Wait 30 seconds after saving +4. Check application logs for Logback errors + diff --git a/VPS_DEPLOYMENT_SUMMARY.md b/VPS_DEPLOYMENT_SUMMARY.md new file mode 100644 index 0000000..0c9cd30 --- /dev/null +++ b/VPS_DEPLOYMENT_SUMMARY.md @@ -0,0 +1,188 @@ +# VPS Deployment Summary + +## ✅ Compatibility Check + +### Backend (lottery-be) + +✅ **Dockerfile**: Production-ready +- Multi-stage build (Maven → JRE) +- Exposes port 8080 (internal only) +- HTTP only (no HTTPS configuration) +- Binds to 0.0.0.0 by default (Spring Boot default) +- Graceful shutdown supported + +✅ **Configuration**: Externalized +- Database connection via environment variables +- Avatar storage path configurable (`APP_AVATAR_STORAGE_PATH`) +- All sensitive data via `.env` file +- CORS configured via `FRONTEND_URL` env var + +✅ **File Uploads**: Persistent storage ready +- Avatar path configurable and mountable as Docker volume +- Uses filesystem (not ephemeral storage) +- Path: `/app/data/avatars` (configurable) + +✅ **Networking**: Internal Docker network +- No ports exposed to host in production compose +- Accessible only via Nginx reverse proxy +- Uses Docker bridge network + +✅ **Production Readiness**: +- Logging to stdout/stderr (Docker logs) +- Health checks configured +- Graceful shutdown +- No dev-only features enabled + +### Frontend (lottery-fe) + +✅ **Build Mode**: Production-ready +- `npm run build` creates static files in `dist/` +- Vite production build configured + +✅ **API Base URL**: Configurable +- Uses relative URLs in production (empty string) +- Falls back to `localhost:8080` in development +- Can be overridden via `VITE_API_BASE_URL` env var + +✅ **Docker Usage**: Optional +- Dockerfile exists but not required for VPS +- Static files can be served directly by Nginx + +✅ **Telegram Mini App**: Ready +- Works under HTTPS +- No localhost assumptions +- Uses relative API URLs + +## 📋 Required Changes Made + +### Frontend Changes + +1. **API Base URL Configuration** (`src/api.js`, `src/auth/authService.js`, `src/services/gameWebSocket.js`, `src/utils/remoteLogger.js`) + - Changed to use relative URLs in production + - Falls back to `localhost:8080` only in development + - Pattern: `import.meta.env.VITE_API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8080")` + +### Backend Changes + +✅ **No changes required** - Already production-ready! + +## 📁 New Files Created + +1. **`docker-compose.prod.yml`** - Production Docker Compose configuration + - No port exposure to host + - Persistent volumes for database and avatars + - Health checks configured + - Internal Docker network + +2. **`nginx.conf.template`** - Nginx reverse proxy configuration + - HTTPS termination + - Frontend static file serving + - Backend API proxying (`/api/*`) + - WebSocket support (`/ws`) + - Avatar file serving (`/avatars/*`) + - Security headers + - Gzip compression + +3. **`DEPLOYMENT_GUIDE.md`** - Comprehensive deployment guide + - Step-by-step instructions + - Troubleshooting section + - Maintenance commands + - Security checklist + +## 🚀 Deployment Steps Overview + +1. **VPS Setup**: Install Docker, Docker Compose, Nginx, Certbot +2. **Directory Structure**: Create `/opt/app` with subdirectories +3. **Backend Deployment**: Copy files, create secret file at `/run/secrets/lottery-config.properties`, build and start +4. **Frontend Deployment**: Build locally, copy `dist/` to VPS +5. **Nginx Configuration**: Copy template, update domain, link config +6. **SSL Setup**: Obtain Let's Encrypt certificate +7. **Telegram Webhook**: Update webhook URL +8. **Verification**: Test all endpoints and functionality + +## 🔧 Configuration Required + +### Backend Secret File (`/run/secrets/lottery-config.properties`) + +All configuration is stored in a mounted secret file. See `lottery-config.properties.template` for the complete template. + +**Required variables:** +- `SPRING_DATASOURCE_URL` +- `SPRING_DATASOURCE_USERNAME` +- `SPRING_DATASOURCE_PASSWORD` +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN` +- `TELEGRAM_FOLLOW_TASK_CHANNEL_ID` +- `FRONTEND_URL` + +**Optional variables:** +- `APP_AVATAR_STORAGE_PATH` +- `APP_AVATAR_PUBLIC_BASE_URL` +- `APP_SESSION_MAX_ACTIVE_PER_USER` +- `APP_SESSION_CLEANUP_BATCH_SIZE` +- `APP_SESSION_CLEANUP_MAX_BATCHES` +- `GEOIP_DB_PATH` + +**Note:** The MySQL container also needs `DB_PASSWORD` and `DB_ROOT_PASSWORD` as environment variables (should match `SPRING_DATASOURCE_PASSWORD`). + +## 📂 Final Directory Structure on VPS + +``` +/opt/app/ +├── backend/ +│ ├── Dockerfile +│ ├── docker-compose.prod.yml +│ ├── lottery-config.properties.template +│ └── [source files] +├── frontend/ +│ └── dist/ (Vite production build) +├── nginx/ +│ └── nginx.conf +├── data/ +│ └── avatars/ (persistent uploads) +└── mysql/ + └── data/ (persistent DB storage) + +/run/secrets/ +└── lottery-config.properties (mounted secret file) +``` + +## ✅ Verification Checklist + +Before going live: + +- [ ] All environment variables set in `.env` +- [ ] Backend containers running (`docker ps`) +- [ ] Frontend `dist/` folder populated +- [ ] Nginx configuration tested (`nginx -t`) +- [ ] SSL certificate installed and valid +- [ ] Telegram webhook updated +- [ ] Health checks passing (`/actuator/health`) +- [ ] Frontend loads in browser +- [ ] API calls work (check browser console) +- [ ] WebSocket connects (game updates work) +- [ ] Avatar uploads work +- [ ] Database persists data (restart test) + +## 🔒 Security Notes + +- Backend port 8080 not exposed to host +- MySQL port 3306 not exposed to host +- HTTPS enforced (HTTP → HTTPS redirect) +- Strong passwords required +- `.env` file permissions restricted +- Firewall recommended (UFW) + +## 📝 Next Steps + +1. Review `DEPLOYMENT_GUIDE.md` for detailed instructions +2. Prepare your VPS (Ubuntu recommended) +3. Follow the step-by-step guide +4. Test thoroughly before going live +5. Set up monitoring and backups + +--- + +**Status**: ✅ Ready for VPS Deployment +**Last Updated**: 2026-01-24 + diff --git a/docker-compose.inferno.yml b/docker-compose.inferno.yml index 8df3090..b9ea1df 100644 --- a/docker-compose.inferno.yml +++ b/docker-compose.inferno.yml @@ -3,48 +3,56 @@ version: "3.9" services: db: image: mysql:8.0 - container_name: honey-mysql + container_name: lottery-mysql restart: always environment: - MYSQL_DATABASE: honey_db - MYSQL_USER: honey_user + MYSQL_DATABASE: lottery_db + MYSQL_USER: lottery_user MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - - honey_mysql_data:/var/lib/mysql + - lottery_mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 networks: - - honey-network + - lottery-network app: build: context: . dockerfile: Dockerfile.inferno - container_name: honey-backend + container_name: lottery-backend restart: always depends_on: db: condition: service_healthy environment: - - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db - - SPRING_DATASOURCE_USERNAME=honey_user + - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db + - SPRING_DATASOURCE_USERNAME=lottery_user - SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - FRONTEND_URL=${FRONTEND_URL} + # Logging configuration (external logback-spring.xml) + - LOGGING_CONFIG=/app/config/logback-spring.xml + - LOG_DIR=/app/logs volumes: # Mount secret file from tmpfs - /run/secrets:/run/secrets:ro + # Mount logback config directory (editable on VPS without rebuilding) + # Note: File must exist on host before mounting. Run setup-logging.sh first. + - /opt/app/backend/config:/app/config:rw + # Mount logs directory (persistent storage) + - /opt/app/logs:/app/logs networks: - - honey-network + - lottery-network # Don't expose port directly - nginx will handle it nginx: image: nginx:alpine - container_name: honey-nginx + container_name: lottery-nginx restart: always ports: - "80:80" @@ -57,12 +65,13 @@ services: depends_on: - app networks: - - honey-network + - lottery-network volumes: - honey_mysql_data: + lottery_mysql_data: networks: - honey-network: + lottery-network: driver: bridge + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d5c9f35 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,193 @@ +version: "3.9" + +services: + db: + image: mysql:8.0 + container_name: lottery-mysql + restart: always + # Database credentials are read from the secret file via backend container + # The backend will construct the connection URL from SPRING_DATASOURCE_* properties + # For MySQL container, we need to set these via environment or use a separate secret + # Option 1: Use environment variables (for MySQL container only) + # Note: MYSQL_USER cannot be "root" - root user is configured via MYSQL_ROOT_PASSWORD only + environment: + MYSQL_DATABASE: lottery_db + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + # Option 2: Mount secret file and read values (more secure) + # This requires parsing the secret file or using a script + # For simplicity, we'll use environment variables for MySQL container + # The secret file is primarily for the backend application + # Do NOT expose MySQL port to host - only accessible within Docker network + # ports: + # - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + # Mount MySQL performance configuration (created on VPS at /opt/app/mysql/conf/my.cnf) + - /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro + # Resource limits for MySQL (16GB buffer pool + 2GB overhead) + deploy: + resources: + limits: + cpus: '2.0' + memory: 18G + healthcheck: + # Use shell to access environment variable (Docker Compose doesn't interpolate in healthcheck arrays) + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - lottery-network + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: lottery-backend + depends_on: + db: + condition: service_healthy + # Expose backend port to localhost only (for Nginx on host to access) + # This is safe - only accessible from the host, not from internet + # Port 8080 is the primary/active backend + ports: + - "127.0.0.1:8080:8080" + # Labels for rolling update management + labels: + - "deployment.role=primary" + - "deployment.version=current" + volumes: + # Mount persistent avatar storage (absolute path for consistency) + - /opt/app/data/avatars:/app/data/avatars + # Mount secret configuration file (read-only) + - /run/secrets/lottery-config.properties:/run/secrets/lottery-config.properties:ro + # Mount logback config directory (editable on VPS without rebuilding) + # Note: File must exist on host before mounting. Run setup-logging.sh first. + - /opt/app/backend/config:/app/config:rw + # Mount logs directory (persistent storage) + - /opt/app/logs:/app/logs + environment: + # Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency + # -Xms: Start with 10GB (prevents resizing overhead) + # -Xmx: Max limit 10GB + # -XX:+UseG1GC: Use G1 garbage collector (best for large heaps) + # -XX:MaxGCPauseMillis=200: Target max GC pause time + JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 + # Logging configuration (external logback-spring.xml) + LOGGING_CONFIG: /app/config/logback-spring.xml + LOG_DIR: /app/logs + # Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace) + deploy: + resources: + limits: + cpus: '4.0' + memory: 12G + networks: + - lottery-network + restart: always + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + backend-new: + # This service is used during rolling updates + # It will be started manually via deployment script + build: + context: . + dockerfile: Dockerfile + container_name: lottery-backend-new + depends_on: + db: + condition: service_healthy + # Port 8082 is the new/standby backend during deployment (8081 is used by phpMyAdmin) + ports: + - "127.0.0.1:8082:8080" + profiles: + - rolling-update + # Labels for rolling update management + labels: + - "deployment.role=standby" + - "deployment.version=new" + volumes: + # Mount persistent avatar storage (absolute path for consistency) + - /opt/app/data/avatars:/app/data/avatars + # Mount secret configuration file (read-only) + - /run/secrets/lottery-config.properties:/run/secrets/lottery-config.properties:ro + # Mount logback config directory (editable on VPS without rebuilding) + # Note: File must exist on host before mounting. Run setup-logging.sh first. + - /opt/app/backend/config:/app/config:rw + # Mount logs directory (persistent storage) + - /opt/app/logs:/app/logs + environment: + # Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency + # -Xms: Start with 10GB (prevents resizing overhead) + # -Xmx: Max limit 10GB + # -XX:+UseG1GC: Use G1 garbage collector (best for large heaps) + # -XX:MaxGCPauseMillis=200: Target max GC pause time + JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 + # Logging configuration (external logback-spring.xml) + LOGGING_CONFIG: /app/config/logback-spring.xml + LOG_DIR: /app/logs + # Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace) + deploy: + resources: + limits: + cpus: '4.0' + memory: 12G + networks: + - lottery-network + restart: always + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + + phpmyadmin: + image: phpmyadmin:latest + container_name: lottery-phpmyadmin + restart: always + depends_on: + db: + condition: service_healthy + # Expose phpMyAdmin to localhost only (Nginx will proxy it with path protection) + ports: + - "127.0.0.1:8081:80" + environment: + # Connect to MySQL service using Docker service name + PMA_HOST: db + PMA_PORT: 3306 + # Use the same root password as MySQL container + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + # Security: Set upload limit + UPLOAD_LIMIT: 64M + # Configure absolute URI so phpMyAdmin generates correct URLs for assets + # This variable must be set from a secret file on the VPS (not in git) + # Example: export PMA_ABSOLUTE_URI="https://win-spin.live/your-secret-path" + PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-} + # Tell phpMyAdmin it's behind a proxy using HTTPS + PMA_SSL: "true" + # Trust proxy headers (X-Forwarded-Proto, etc.) + PMA_TRUSTED_PROXIES: "127.0.0.1" + networks: + - lottery-network + # Resource limits for phpMyAdmin + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + +volumes: + mysql_data: + driver: local + +networks: + lottery-network: + driver: bridge + diff --git a/docker-compose.yml b/docker-compose.yml index cf46bb4..dbd2a1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,17 +3,17 @@ version: "3.9" services: db: image: mysql:8.0 - container_name: honey-mysql + container_name: lottery-mysql restart: always environment: - MYSQL_DATABASE: ${DB_NAME:honey_db} + MYSQL_DATABASE: ${DB_NAME:lottery_db} MYSQL_USER: ${DB_USERNAME:root} MYSQL_PASSWORD: ${DB_PASSWORD:password} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password} ports: - "3306:3306" volumes: - - honey_mysql_data:/var/lib/mysql + - lottery_mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"] interval: 10s @@ -24,17 +24,18 @@ services: env_file: - .env build: . - container_name: honey-backend + container_name: lottery-backend depends_on: db: condition: service_healthy ports: - "8080:8080" environment: - - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db} + - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:lottery_db} - SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root} - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password} volumes: - honey_mysql_data: + lottery_mysql_data: + diff --git a/lottery-config.properties.template b/lottery-config.properties.template new file mode 100644 index 0000000..3451b89 --- /dev/null +++ b/lottery-config.properties.template @@ -0,0 +1,62 @@ +# Lottery Application Configuration +# Copy this file to /run/secrets/lottery-config.properties on your VPS +# Replace all placeholder values with your actual configuration + +# ============================================ +# Database Configuration +# ============================================ +# SPRING_DATASOURCE_URL format: jdbc:mysql://:/ +# +# How to determine the URL: +# - Hostname: 'db' (this is the MySQL service name in docker-compose.prod.yml) +# * In Docker Compose, services communicate using their service names +# * The MySQL service is named 'db', so use 'db' as the hostname +# * Both containers are on the same Docker network, so 'db' resolves to the MySQL container +# - Port: '3306' (default MySQL port, internal to Docker network) +# - Database name: 'lottery_db' (must match MYSQL_DATABASE in docker-compose.prod.yml) +# +# Example: jdbc:mysql://db:3306/lottery_db +# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘ +# │ │ │ └─ Database name +# │ │ └─ Port (3306 is MySQL default) +# │ └─ Service name in docker-compose (acts as hostname) +# └─ JDBC protocol for MySQL +# +# IMPORTANT: Use 'db' as hostname, NOT 'localhost' or '127.0.0.1' +# This is an internal Docker network connection +SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db +SPRING_DATASOURCE_USERNAME=root +SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here + +# ============================================ +# Telegram Bot Configuration +# ============================================ +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here +TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here +TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name + +# ============================================ +# Frontend Configuration +# ============================================ +FRONTEND_URL=https://yourdomain.com + +# ============================================ +# Avatar Storage Configuration +# ============================================ +APP_AVATAR_STORAGE_PATH=/app/data/avatars +APP_AVATAR_PUBLIC_BASE_URL= +APP_AVATAR_MAX_SIZE_BYTES=2097152 +APP_AVATAR_MAX_DIMENSION=512 + +# ============================================ +# Session Configuration (Optional - defaults shown) +# ============================================ +APP_SESSION_MAX_ACTIVE_PER_USER=5 +APP_SESSION_CLEANUP_BATCH_SIZE=5000 +APP_SESSION_CLEANUP_MAX_BATCHES=20 + +# ============================================ +# GeoIP Configuration (Optional) +# ============================================ +GEOIP_DB_PATH= + diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 0000000..2176aac --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,128 @@ +# Nginx configuration for Lottery Application +# Place this file at: /opt/app/nginx/nginx.conf +# +# This configuration assumes: +# - Frontend static files are at: /opt/app/frontend/dist +# - Avatar files are at: /opt/app/data/avatars +# - Backend is accessible at: http://127.0.0.1:8080 (exposed to localhost only) +# - HTTPS is handled by Nginx (SSL certificates should be configured separately) + +# Upstream backend (using localhost since Nginx runs on host, not in Docker) +upstream backend { + server 127.0.0.1:8080; +} + +# Redirect HTTP to HTTPS +server { + listen 80; + server_name _; # Replace with your domain name + + # For Let's Encrypt ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect all other HTTP traffic to HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS server +server { + listen 443 ssl http2; + server_name _; # Replace with your domain name + + # SSL certificate configuration + # Update these paths to your actual certificate files + ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; + + # SSL configuration (recommended settings) + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Root directory for frontend static files + root /opt/app/frontend/dist; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + # Serve avatar files with aggressive caching + # Avatars are served by backend, but we can also serve them directly from filesystem + location /avatars/ { + alias /opt/app/data/avatars/; + expires 1h; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Backend API endpoints + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Timeouts for long-running requests + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # WebSocket endpoint (for game updates) + location /ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeouts + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # Frontend static files (SPA routing) + location / { + try_files $uri $uri/ /index.html; + expires 1h; + add_header Cache-Control "public"; + } + + # Cache static assets (JS, CSS, images) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Health check endpoint (optional, for monitoring) + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} + diff --git a/nginx/conf.d/honey.conf b/nginx/conf.d/lottery.conf similarity index 79% rename from nginx/conf.d/honey.conf rename to nginx/conf.d/lottery.conf index 8a7d92e..db343a7 100644 --- a/nginx/conf.d/honey.conf +++ b/nginx/conf.d/lottery.conf @@ -1,5 +1,12 @@ -upstream honey_backend { - server app:8080; +upstream lottery_backend { + # Primary backend (port 8080) + server 127.0.0.1:8080 max_fails=3 fail_timeout=30s; + # Standby backend (port 8082) - used during rolling updates + # Uncomment the line below to switch traffic to new backend + # server 127.0.0.1:8082 max_fails=3 fail_timeout=30s backup; + + # Health check configuration + keepalive 32; } server { @@ -16,7 +23,7 @@ server { # API endpoints location /api/ { - proxy_pass http://honey_backend; + proxy_pass http://lottery_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -34,7 +41,7 @@ server { # Actuator endpoints (for health checks) location /actuator/ { - proxy_pass http://honey_backend; + proxy_pass http://lottery_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -44,7 +51,7 @@ server { # Ping endpoint location /ping { - proxy_pass http://honey_backend; + proxy_pass http://lottery_backend; proxy_http_version 1.1; proxy_set_header Host $host; } @@ -74,3 +81,4 @@ server { # # ... # } + diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 63d9fda..b380f0d 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -32,3 +32,4 @@ http { include /etc/nginx/conf.d/*.conf; } + diff --git a/pom.xml b/pom.xml index e5093ae..88a43ce 100644 --- a/pom.xml +++ b/pom.xml @@ -5,8 +5,8 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.honey - honey-be + com.lottery + lottery-be 1.0.0 jar @@ -79,6 +79,51 @@ 4.2.0 + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.telegram + telegrambots + 6.9.0 + + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + + + + + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + diff --git a/scripts/backup-database.sh b/scripts/backup-database.sh new file mode 100644 index 0000000..d0b2bb5 --- /dev/null +++ b/scripts/backup-database.sh @@ -0,0 +1,216 @@ +#!/bin/bash +# Database Backup Script for Lottery Application +# This script creates a MySQL dump and transfers it to the backup VPS +# +# Usage: +# ./scripts/backup-database.sh [--keep-local] [--compress] +# +# Options: +# --keep-local Keep a local copy of the backup (default: delete after transfer) +# --compress Compress the backup before transfer (default: gzip) +# +# Prerequisites: +# 1. SSH key-based authentication to backup VPS (5.45.77.77) +# 2. Database password accessible via /run/secrets/lottery-config.properties +# 3. Docker container 'lottery-mysql' running +# +# Backup location on backup VPS: /raid/backup/acc_260182/ + +set -euo pipefail + +# Configuration +BACKUP_VPS_HOST="5.45.77.77" +BACKUP_VPS_USER="acc_260182" # User account on backup VPS +BACKUP_VPS_PATH="/raid/backup/acc_260182" +MYSQL_CONTAINER="lottery-mysql" +MYSQL_DATABASE="lottery_db" +SECRET_FILE="/run/secrets/lottery-config.properties" +BACKUP_DIR="/opt/app/backups" +KEEP_LOCAL=false +COMPRESS=true + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --keep-local) + KEEP_LOCAL=true + shift + ;; + --no-compress) + COMPRESS=false + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--keep-local] [--no-compress]" + exit 1 + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Logging function +log() { + echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +# Check if running as root or with sudo +if [ "$EUID" -ne 0 ]; then + error "This script must be run as root (or with sudo)" + exit 1 +fi + +# Load database password +if [ ! -f "$SECRET_FILE" ]; then + error "Secret file not found at $SECRET_FILE" + exit 1 +fi + +DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +if [ -z "$DB_PASSWORD" ]; then + error "SPRING_DATASOURCE_PASSWORD not found in secret file" + exit 1 +fi + +# Check if MySQL container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then + error "MySQL container '${MYSQL_CONTAINER}' is not running" + exit 1 +fi + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Generate backup filename with timestamp +TIMESTAMP=$(date +'%Y%m%d_%H%M%S') +BACKUP_FILENAME="lottery_db_backup_${TIMESTAMP}.sql" +BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}" + +# If compression is enabled, add .gz extension +if [ "$COMPRESS" = true ]; then + BACKUP_FILENAME="${BACKUP_FILENAME}.gz" + BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}" +fi + +log "Starting database backup..." +log "Database: ${MYSQL_DATABASE}" +log "Container: ${MYSQL_CONTAINER}" +log "Backup file: ${BACKUP_FILENAME}" + +# Create MySQL dump +log "Creating MySQL dump..." + +if [ "$COMPRESS" = true ]; then + # Dump and compress in one step (saves disk space) + if docker exec "${MYSQL_CONTAINER}" mysqldump \ + -u root \ + -p"${DB_PASSWORD}" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --quick \ + --lock-tables=false \ + "${MYSQL_DATABASE}" | gzip > "${BACKUP_PATH}"; then + log "✅ Database dump created and compressed: ${BACKUP_PATH}" + else + error "Failed to create database dump" + exit 1 + fi +else + # Dump without compression + if docker exec "${MYSQL_CONTAINER}" mysqldump \ + -u root \ + -p"${DB_PASSWORD}" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --quick \ + --lock-tables=false \ + "${MYSQL_DATABASE}" > "${BACKUP_PATH}"; then + log "✅ Database dump created: ${BACKUP_PATH}" + else + error "Failed to create database dump" + exit 1 + fi +fi + +# Get backup file size +BACKUP_SIZE=$(du -h "${BACKUP_PATH}" | cut -f1) +log "Backup size: ${BACKUP_SIZE}" + +# Transfer to backup VPS +log "Transferring backup to backup VPS (${BACKUP_VPS_HOST})..." + +# Test SSH connection first +if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "echo 'SSH connection successful'" > /dev/null 2>&1; then + error "Cannot connect to backup VPS via SSH" + error "Please ensure:" + error " 1. SSH key-based authentication is set up" + error " 2. Backup VPS is accessible from this server" + error " 3. User '${BACKUP_VPS_USER}' has access to ${BACKUP_VPS_PATH}" + + if [ "$KEEP_LOCAL" = true ]; then + warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}" + else + rm -f "${BACKUP_PATH}" + fi + exit 1 +fi + +# Create backup directory on remote VPS if it doesn't exist +ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "mkdir -p ${BACKUP_VPS_PATH}" + +# Transfer the backup file +if scp "${BACKUP_PATH}" "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/"; then + log "✅ Backup transferred successfully to ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}" + + # Verify remote file exists + REMOTE_SIZE=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "du -h ${BACKUP_VPS_PATH}/${BACKUP_FILENAME} 2>/dev/null | cut -f1" || echo "0") + if [ "$REMOTE_SIZE" != "0" ]; then + log "✅ Remote backup verified (size: ${REMOTE_SIZE})" + else + warn "Could not verify remote backup file" + fi +else + error "Failed to transfer backup to backup VPS" + if [ "$KEEP_LOCAL" = true ]; then + warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}" + else + rm -f "${BACKUP_PATH}" + fi + exit 1 +fi + +# Clean up local backup if not keeping it +if [ "$KEEP_LOCAL" = false ]; then + rm -f "${BACKUP_PATH}" + log "Local backup file removed (transferred successfully)" +fi + +# Clean up old backups on remote VPS (keep last 10 days) +log "Cleaning up old backups on remote VPS (keeping last 10 days)..." +ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +10 -delete" || warn "Failed to clean up old backups" + +# Count remaining backups +BACKUP_COUNT=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "ls -1 ${BACKUP_VPS_PATH}/lottery_db_backup_*.sql* 2>/dev/null | wc -l" || echo "0") +log "Total backups on remote VPS: ${BACKUP_COUNT}" + +log "✅ Backup completed successfully!" +log " Remote location: ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}" + diff --git a/scripts/create-secret-file-from-template.sh b/scripts/create-secret-file-from-template.sh new file mode 100644 index 0000000..e1dd53a --- /dev/null +++ b/scripts/create-secret-file-from-template.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Script to create secret file from template +# Usage: ./create-secret-file-from-template.sh /path/to/template /path/to/output + +TEMPLATE_FILE="${1:-lottery-config.properties.template}" +OUTPUT_FILE="${2:-/run/secrets/lottery-config.properties}" +OUTPUT_DIR=$(dirname "$OUTPUT_FILE") + +# Check if template exists +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "❌ Template file not found: $TEMPLATE_FILE" + exit 1 +fi + +# Create output directory if it doesn't exist +mkdir -p "$OUTPUT_DIR" + +# Copy template to output +cp "$TEMPLATE_FILE" "$OUTPUT_FILE" + +# Set secure permissions (read-only for owner, no access for others) +chmod 600 "$OUTPUT_FILE" + +echo "✅ Secret file created at $OUTPUT_FILE" +echo "⚠️ IMPORTANT: Edit this file and replace all placeholder values with your actual configuration!" +echo "⚠️ After editing, ensure permissions are secure: chmod 600 $OUTPUT_FILE" + + + diff --git a/scripts/create-secret-file.sh b/scripts/create-secret-file.sh index 2247b89..3d2f23f 100644 --- a/scripts/create-secret-file.sh +++ b/scripts/create-secret-file.sh @@ -3,7 +3,7 @@ # Create secret file from environment variables for testing ConfigLoader # This simulates the mounted secret file approach used in Inferno -SECRET_FILE="/run/secrets/honey-config.properties" +SECRET_FILE="/run/secrets/lottery-config.properties" SECRET_DIR="/run/secrets" # Create directory if it doesn't exist @@ -25,3 +25,4 @@ chmod 644 "$SECRET_FILE" echo "✅ Secret file created at $SECRET_FILE from environment variables" + diff --git a/scripts/diagnose-backup-permissions.sh b/scripts/diagnose-backup-permissions.sh new file mode 100644 index 0000000..5fddf7b --- /dev/null +++ b/scripts/diagnose-backup-permissions.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# Diagnostic script for backup-database.sh permission issues +# Run this on your VPS to identify the root cause + +SCRIPT="/opt/app/backend/lottery-be/scripts/backup-database.sh" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "==========================================" +echo "Backup Script Permission Diagnostic" +echo "==========================================" +echo "" + +# 1. File exists +echo "1. Checking if file exists..." +if [ -f "$SCRIPT" ]; then + echo -e " ${GREEN}✅ File exists${NC}" +else + echo -e " ${RED}❌ File NOT found at: $SCRIPT${NC}" + echo " Please verify the path." + exit 1 +fi +echo "" + +# 2. File permissions +echo "2. File permissions:" +ls -la "$SCRIPT" +echo "" + +# 3. Is executable +echo "3. Is file executable?" +if [ -x "$SCRIPT" ]; then + echo -e " ${GREEN}✅ File is executable${NC}" +else + echo -e " ${RED}❌ File is NOT executable${NC}" + echo " Fix: chmod +x $SCRIPT" +fi +echo "" + +# 4. Shebang line +echo "4. Shebang line (first line):" +SHEBANG=$(head -1 "$SCRIPT") +echo " $SHEBANG" +if [[ "$SHEBANG" == "#!/bin/bash" ]] || [[ "$SHEBANG" == "#!/usr/bin/bash" ]]; then + echo -e " ${GREEN}✅ Shebang looks correct${NC}" +else + echo -e " ${YELLOW}⚠️ Unexpected shebang${NC}" +fi +echo "" + +# 5. Bash exists +echo "5. Checking if bash interpreter exists:" +if [ -f /bin/bash ]; then + echo -e " ${GREEN}✅ /bin/bash exists${NC}" + /bin/bash --version | head -1 +elif [ -f /usr/bin/bash ]; then + echo -e " ${GREEN}✅ /usr/bin/bash exists${NC}" + /usr/bin/bash --version | head -1 +else + echo -e " ${RED}❌ bash not found in /bin/bash or /usr/bin/bash${NC}" + echo " Found at: $(which bash 2>/dev/null || echo 'NOT FOUND')" +fi +echo "" + +# 6. Line endings +echo "6. Checking line endings:" +FILE_TYPE=$(file "$SCRIPT") +echo " $FILE_TYPE" +if echo "$FILE_TYPE" | grep -q "CRLF"; then + echo -e " ${RED}❌ File has Windows line endings (CRLF)${NC}" + echo " Fix: dos2unix $SCRIPT" + echo " Or: sed -i 's/\r$//' $SCRIPT" +elif echo "$FILE_TYPE" | grep -q "ASCII text"; then + echo -e " ${GREEN}✅ Line endings look correct (LF)${NC}" +else + echo -e " ${YELLOW}⚠️ Could not determine line endings${NC}" +fi +echo "" + +# 7. Mount options +echo "7. Checking filesystem mount options:" +MOUNT_INFO=$(mount | grep -E "(/opt|/app)" || echo "Not a separate mount") +echo " $MOUNT_INFO" +if echo "$MOUNT_INFO" | grep -q "noexec"; then + echo -e " ${RED}❌ Filesystem mounted with 'noexec' flag${NC}" + echo " This prevents script execution!" + echo " Fix: Remove 'noexec' from /etc/fstab and remount" +else + echo -e " ${GREEN}✅ No 'noexec' flag detected${NC}" +fi +echo "" + +# 8. SELinux +echo "8. Checking SELinux:" +if command -v getenforce &> /dev/null; then + SELINUX_STATUS=$(getenforce 2>/dev/null) + echo " Status: $SELINUX_STATUS" + if [ "$SELINUX_STATUS" = "Enforcing" ]; then + echo -e " ${YELLOW}⚠️ SELinux is enforcing - may block execution${NC}" + echo " Check context: ls -Z $SCRIPT" + else + echo -e " ${GREEN}✅ SELinux not blocking (or disabled)${NC}" + fi +else + echo -e " ${GREEN}✅ SELinux not installed${NC}" +fi +echo "" + +# 9. Directory permissions +echo "9. Parent directory permissions:" +DIR=$(dirname "$SCRIPT") +ls -ld "$DIR" +if [ -x "$DIR" ]; then + echo -e " ${GREEN}✅ Directory is executable${NC}" +else + echo -e " ${RED}❌ Directory is NOT executable${NC}" + echo " Fix: chmod +x $DIR" +fi +echo "" + +# 10. Syntax check +echo "10. Checking script syntax:" +if bash -n "$SCRIPT" 2>&1; then + echo -e " ${GREEN}✅ Syntax is valid${NC}" +else + echo -e " ${RED}❌ Syntax errors found${NC}" + bash -n "$SCRIPT" +fi +echo "" + +# 11. Test execution +echo "11. Testing script execution (dry run):" +echo " Attempting to read first 10 lines..." +if head -10 "$SCRIPT" > /dev/null 2>&1; then + echo -e " ${GREEN}✅ Can read script${NC}" +else + echo -e " ${RED}❌ Cannot read script${NC}" +fi +echo "" + +# 12. Cron job check +echo "12. Checking cron configuration:" +if [ "$EUID" -eq 0 ]; then + echo " Root's crontab:" + crontab -l 2>/dev/null | grep -i backup || echo " (No backup cron job found in root's crontab)" + echo "" + echo " To check cron job, run: sudo crontab -l" +else + echo " (Run as root to check crontab: sudo crontab -l)" +fi +echo "" + +# 13. Environment check +echo "13. Checking required commands:" +REQUIRED_COMMANDS=("docker" "ssh" "gzip" "bash") +for cmd in "${REQUIRED_COMMANDS[@]}"; do + if command -v "$cmd" &> /dev/null; then + CMD_PATH=$(which "$cmd") + echo -e " ${GREEN}✅ $cmd${NC} found at: $CMD_PATH" + else + echo -e " ${RED}❌ $cmd${NC} NOT found in PATH" + fi +done +echo "" + +# 14. Secret file check +echo "14. Checking secret file:" +SECRET_FILE="/run/secrets/lottery-config.properties" +if [ -f "$SECRET_FILE" ]; then + echo -e " ${GREEN}✅ Secret file exists${NC}" + if [ -r "$SECRET_FILE" ]; then + echo -e " ${GREEN}✅ Secret file is readable${NC}" + else + echo -e " ${RED}❌ Secret file is NOT readable${NC}" + fi +else + echo -e " ${YELLOW}⚠️ Secret file not found (script will fail at runtime)${NC}" +fi +echo "" + +# Summary +echo "==========================================" +echo "Summary & Recommendations" +echo "==========================================" + +ISSUES=0 + +if [ ! -x "$SCRIPT" ]; then + echo -e "${RED}❌ Issue: File is not executable${NC}" + echo " Fix: chmod +x $SCRIPT" + ISSUES=$((ISSUES + 1)) +fi + +if file "$SCRIPT" | grep -q "CRLF"; then + echo -e "${RED}❌ Issue: Windows line endings detected${NC}" + echo " Fix: dos2unix $SCRIPT (or: sed -i 's/\r$//' $SCRIPT)" + ISSUES=$((ISSUES + 1)) +fi + +if mount | grep -E "(/opt|/app)" | grep -q "noexec"; then + echo -e "${RED}❌ Issue: Filesystem mounted with noexec${NC}" + echo " Fix: Remove noexec from /etc/fstab and remount" + ISSUES=$((ISSUES + 1)) +fi + +if [ "$ISSUES" -eq 0 ]; then + echo -e "${GREEN}✅ No obvious issues found${NC}" + echo "" + echo "If cron still fails, try:" + echo " 1. Update cron to use bash explicitly:" + echo " 0 2 * * * /bin/bash $SCRIPT >> /opt/app/logs/backup.log 2>&1" + echo "" + echo " 2. Check cron logs:" + echo " sudo journalctl -u cron | tail -50" + echo "" + echo " 3. Test manual execution:" + echo " sudo $SCRIPT --keep-local" +else + echo "" + echo -e "${YELLOW}Found $ISSUES issue(s) that need to be fixed.${NC}" +fi + +echo "" +echo "==========================================" + diff --git a/scripts/load-db-password.sh b/scripts/load-db-password.sh new file mode 100644 index 0000000..7b1f6e4 --- /dev/null +++ b/scripts/load-db-password.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Script to load database password from secret file +# This ensures DB_PASSWORD and DB_ROOT_PASSWORD match SPRING_DATASOURCE_PASSWORD +# Usage: source ./load-db-password.sh + +SECRET_FILE="/run/secrets/lottery-config.properties" + +if [ ! -f "$SECRET_FILE" ]; then + echo "❌ Error: Secret file not found at $SECRET_FILE" + echo " Please create the secret file first (see deployment guide Step 3.3)" + return 1 2>/dev/null || exit 1 +fi + +# Read SPRING_DATASOURCE_PASSWORD from secret file +DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +if [ -z "$DB_PASSWORD" ]; then + echo "❌ Error: SPRING_DATASOURCE_PASSWORD not found in secret file" + echo " Please ensure the secret file contains: SPRING_DATASOURCE_PASSWORD=your_password" + return 1 2>/dev/null || exit 1 +fi + +# Export both variables (MySQL uses both) +export DB_PASSWORD="$DB_PASSWORD" +export DB_ROOT_PASSWORD="$DB_PASSWORD" + +# Optionally load PMA_ABSOLUTE_URI from secret file (for phpMyAdmin path protection) +PMA_ABSOLUTE_URI=$(grep "^PMA_ABSOLUTE_URI=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/^"//;s/"$//' | sed "s/^'//;s/'$//") +if [ -n "$PMA_ABSOLUTE_URI" ]; then + export PMA_ABSOLUTE_URI="$PMA_ABSOLUTE_URI" + echo "✅ PMA_ABSOLUTE_URI loaded from secret file" +fi + +echo "✅ Database password loaded from secret file" +echo " DB_PASSWORD and DB_ROOT_PASSWORD are now set (matching SPRING_DATASOURCE_PASSWORD)" + + + diff --git a/scripts/restore-database.sh b/scripts/restore-database.sh new file mode 100644 index 0000000..767ca23 --- /dev/null +++ b/scripts/restore-database.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Database Restore Script for Lottery Application +# This script restores a MySQL database from a backup file +# +# Usage: +# ./scripts/restore-database.sh +# +# Examples: +# # Restore from local file +# ./scripts/restore-database.sh /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz +# +# # Restore from backup VPS +# ./scripts/restore-database.sh 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz +# +# Prerequisites: +# 1. Database password accessible via /run/secrets/lottery-config.properties +# 2. Docker container 'lottery-mysql' running +# 3. Database will be DROPPED and RECREATED (all data will be lost!) + +set -euo pipefail + +# Configuration +MYSQL_CONTAINER="lottery-mysql" +MYSQL_DATABASE="lottery_db" +SECRET_FILE="/run/secrets/lottery-config.properties" +BACKUP_VPS_USER="acc_260182" # User account on backup VPS + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Logging function +log() { + echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +# Check arguments +if [ $# -eq 0 ]; then + error "No backup file specified" + echo "Usage: $0 " + echo "" + echo "Examples:" + echo " $0 /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz" + echo " $0 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz" + exit 1 +fi + +BACKUP_SOURCE="$1" + +# Check if running as root or with sudo +if [ "$EUID" -ne 0 ]; then + error "This script must be run as root (or with sudo)" + exit 1 +fi + +# Load database password +if [ ! -f "$SECRET_FILE" ]; then + error "Secret file not found at $SECRET_FILE" + exit 1 +fi + +DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + +if [ -z "$DB_PASSWORD" ]; then + error "SPRING_DATASOURCE_PASSWORD not found in secret file" + exit 1 +fi + +# Check if MySQL container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then + error "MySQL container '${MYSQL_CONTAINER}' is not running" + exit 1 +fi + +# Determine if backup is remote or local +TEMP_BACKUP="/tmp/restore_backup_$$" +BACKUP_IS_COMPRESSED=false + +if [[ "$BACKUP_SOURCE" == *":"* ]]; then + # Remote backup (format: host:/path/to/file) + log "Detected remote backup: ${BACKUP_SOURCE}" + HOST_PATH=(${BACKUP_SOURCE//:/ }) + REMOTE_HOST="${HOST_PATH[0]}" + REMOTE_PATH="${HOST_PATH[1]}" + + log "Downloading backup from ${REMOTE_HOST}..." + if scp "${REMOTE_HOST}:${REMOTE_PATH}" "${TEMP_BACKUP}"; then + log "✅ Backup downloaded successfully" + else + error "Failed to download backup from remote VPS" + exit 1 + fi +else + # Local backup + if [ ! -f "$BACKUP_SOURCE" ]; then + error "Backup file not found: ${BACKUP_SOURCE}" + exit 1 + fi + log "Using local backup: ${BACKUP_SOURCE}" + cp "$BACKUP_SOURCE" "${TEMP_BACKUP}" +fi + +# Check if backup is compressed +if [[ "$TEMP_BACKUP" == *.gz ]] || file "$TEMP_BACKUP" | grep -q "gzip compressed"; then + BACKUP_IS_COMPRESSED=true + log "Backup is compressed (gzip)" +fi + +# Get backup file size +BACKUP_SIZE=$(du -h "${TEMP_BACKUP}" | cut -f1) +log "Backup size: ${BACKUP_SIZE}" + +# WARNING: This will destroy all existing data! +warn "⚠️ WARNING: This will DROP and RECREATE the database '${MYSQL_DATABASE}'" +warn "⚠️ ALL EXISTING DATA WILL BE LOST!" +echo "" +read -p "Are you sure you want to continue? Type 'YES' to confirm: " CONFIRM + +if [ "$CONFIRM" != "YES" ]; then + log "Restore cancelled by user" + rm -f "${TEMP_BACKUP}" + exit 0 +fi + +log "Starting database restore..." + +# Drop and recreate database +log "Dropping existing database (if exists)..." +docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "DROP DATABASE IF EXISTS ${MYSQL_DATABASE};" || true + +log "Creating fresh database..." +docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "CREATE DATABASE ${MYSQL_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + +# Restore database +log "Restoring database from backup..." + +if [ "$BACKUP_IS_COMPRESSED" = true ]; then + # Restore from compressed backup + if gunzip -c "${TEMP_BACKUP}" | docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}"; then + log "✅ Database restored successfully from compressed backup" + else + error "Failed to restore database" + rm -f "${TEMP_BACKUP}" + exit 1 + fi +else + # Restore from uncompressed backup + if docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}" < "${TEMP_BACKUP}"; then + log "✅ Database restored successfully" + else + error "Failed to restore database" + rm -f "${TEMP_BACKUP}" + exit 1 + fi +fi + +# Clean up temporary file +rm -f "${TEMP_BACKUP}" + +# Verify restore +log "Verifying restore..." +TABLE_COUNT=$(docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${MYSQL_DATABASE}';" 2>/dev/null || echo "0") + +if [ "$TABLE_COUNT" -gt 0 ]; then + log "✅ Restore verified: ${TABLE_COUNT} tables found in database" +else + warn "⚠️ Warning: No tables found in database after restore" +fi + +log "✅ Database restore completed!" +warn "⚠️ Remember to restart the backend container if it's running:" +warn " docker restart lottery-backend" + diff --git a/scripts/rolling-update.sh b/scripts/rolling-update.sh new file mode 100644 index 0000000..8ef547b --- /dev/null +++ b/scripts/rolling-update.sh @@ -0,0 +1,628 @@ +#!/bin/bash +# Rolling Update Deployment Script +# This script performs zero-downtime deployment by: +# 1. Building new backend image +# 2. Starting new backend container on port 8082 +# 3. Health checking the new container +# 4. Updating Nginx to point to new container +# 5. Reloading Nginx (zero downtime) +# 6. Stopping old container after grace period + +set -euo pipefail + +# Colors (define early for use in config detection) +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Logging functions (define early) +log() { + echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${PROJECT_DIR}/docker-compose.prod.yml" + +# Detect Nginx config file (try common locations) +# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths +NGINX_CONF="${NGINX_CONF:-}" +if [ -z "$NGINX_CONF" ]; then + if [ -f "/etc/nginx/sites-enabled/win-spin.live" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/win-spin.live" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/sites-enabled/win-spin.live.conf" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/win-spin.live.conf" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/conf.d/lottery.conf" ]; then + NGINX_CONF="/etc/nginx/conf.d/lottery.conf" + log "Using Nginx config: $NGINX_CONF (conf.d)" + elif [ -f "/opt/app/nginx/win-spin.live.conf" ]; then + warn "Found config at /opt/app/nginx/win-spin.live.conf" + warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..." + if [ -L "/etc/nginx/sites-enabled/win-spin.live" ] || [ -L "/etc/nginx/sites-enabled/win-spin.live.conf" ]; then + # Find the actual target + local target=$(readlink -f /etc/nginx/sites-enabled/win-spin.live 2>/dev/null || readlink -f /etc/nginx/sites-enabled/win-spin.live.conf 2>/dev/null) + if [ -n "$target" ]; then + NGINX_CONF="$target" + log "Using Nginx config: $NGINX_CONF (symlink target)" + else + NGINX_CONF="/opt/app/nginx/win-spin.live.conf" + warn "Using custom path - will update this file, but you may need to copy to sites-enabled" + fi + else + NGINX_CONF="/opt/app/nginx/win-spin.live.conf" + warn "Using custom path - will update this file, but you may need to copy to sites-enabled" + fi + else + error "Cannot find Nginx config file." + error "Searched:" + error " - /etc/nginx/sites-enabled/win-spin.live" + error " - /etc/nginx/sites-enabled/win-spin.live.conf" + error " - /etc/nginx/conf.d/lottery.conf" + error " - /opt/app/nginx/win-spin.live.conf" + error "" + error "Please set NGINX_CONF environment variable with the correct path." + exit 1 + fi +else + log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)" +fi + +# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files) +NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)" + +# Ports for backends (will be swapped dynamically) +PRIMARY_PORT=8080 +STANDBY_PORT=8082 + +# Detect which backend is currently active +detect_active_backend() { + # Check which port Nginx is currently using in upstream block + # Look for server line that is NOT marked as backup + local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1) + + if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then + # Port 8082 is active (not backup) + ACTIVE_PORT=8082 + STANDBY_PORT=8080 + ACTIVE_CONTAINER="lottery-backend-new" + STANDBY_CONTAINER="lottery-backend" + log "Detected: Port 8082 is currently active" + else + # Port 8080 is active (default or only one present) + ACTIVE_PORT=8080 + STANDBY_PORT=8082 + ACTIVE_CONTAINER="lottery-backend" + STANDBY_CONTAINER="lottery-backend-new" + log "Detected: Port 8080 is currently active" + fi + + PRIMARY_PORT=$ACTIVE_PORT + HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness" +} + +HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max) +HEALTH_CHECK_INTERVAL=2 +GRACE_PERIOD=10 + +# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback) +# This allows keeping failed containers for debugging even when using sudo +if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then + SCRIPT_KEEP_FAILED_CONTAINER="true" + export SCRIPT_KEEP_FAILED_CONTAINER + log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging" +fi + +# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose') +DOCKER_COMPOSE_CMD="" +if docker compose version &> /dev/null; then + DOCKER_COMPOSE_CMD="docker compose" +elif command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" +else + error "Neither 'docker compose' nor 'docker-compose' is available" + exit 1 +fi + +# Check prerequisites +check_prerequisites() { + log "Checking prerequisites..." + + # Check if running as root + if [ "$EUID" -ne 0 ]; then + error "This script must be run as root (or with sudo)" + exit 1 + fi + + # Check if docker compose is available (already detected above) + log "Using Docker Compose command: $DOCKER_COMPOSE_CMD" + + # Check if Nginx config exists + if [ ! -f "$NGINX_CONF" ]; then + error "Nginx config not found at $NGINX_CONF" + exit 1 + fi + + # Check if DB_ROOT_PASSWORD is set + if [ -z "${DB_ROOT_PASSWORD:-}" ]; then + warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..." + if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then + source "${SCRIPT_DIR}/load-db-password.sh" + else + error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh" + exit 1 + fi + fi + + # Detect which backend is currently active + detect_active_backend + + # Check if active backend is running + if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then + error "Active backend container (${ACTIVE_CONTAINER}) is not running" + error "Please start it first: docker-compose -f ${COMPOSE_FILE} up -d backend" + exit 1 + fi + + log "✅ Prerequisites check passed" + log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}" + log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}" +} + +# Build new backend image +build_new_image() { + log "Building new backend image..." + + cd "$PROJECT_DIR" + + # Determine which service to build based on which container will be used + # Both services use the same Dockerfile, but we need to build the correct one + # to ensure the image cache is updated for the service that will be started + if [ "$STANDBY_PORT" = "8082" ]; then + SERVICE_TO_BUILD="backend-new" + else + SERVICE_TO_BUILD="backend" + fi + + log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..." + + # Build the image for the service that will be used + # This ensures the correct service's image cache is updated with latest migrations + if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then + log "✅ New backend image built successfully" + else + error "Failed to build new backend image" + exit 1 + fi + else + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then + log "✅ New backend image built successfully" + else + error "Failed to build new backend image" + exit 1 + fi + fi +} + +# Start new backend container +start_new_container() { + log "Starting new backend container on port ${STANDBY_PORT}..." + + cd "$PROJECT_DIR" + + # Determine which service to start based on standby port + if [ "$STANDBY_PORT" = "8082" ]; then + SERVICE_NAME="backend-new" + CONTAINER_NAME="lottery-backend-new" + else + SERVICE_NAME="backend" + CONTAINER_NAME="lottery-backend" + fi + + # Check if standby container exists (running or stopped) + # We need to remove it to ensure a fresh start with migrations + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + warn "${CONTAINER_NAME} container is already running, stopping it first..." + else + warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..." + fi + if [ "$SERVICE_NAME" = "backend-new" ]; then + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true + else + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true + fi + fi + + # Start the new container + if [ "$SERVICE_NAME" = "backend-new" ]; then + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then + log "✅ New backend container started" + else + error "Failed to start new backend container" + exit 1 + fi + else + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then + log "✅ New backend container started" + else + error "Failed to start new backend container" + exit 1 + fi + fi + + # Wait for container to initialize (Spring Boot needs time to start) + log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..." + sleep 10 + + # Check if container is still running (might have crashed) + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:" + error " docker logs ${CONTAINER_NAME}" + exit 1 + fi +} + +# Health check new container +health_check_new_container() { + log "Performing health check on new backend container (port ${STANDBY_PORT})..." + + # First, check if container is still running + if [ "$STANDBY_PORT" = "8082" ]; then + local container_name="lottery-backend-new" + else + local container_name="lottery-backend" + fi + + if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then + error "Container ${container_name} is not running!" + error "Check logs: docker logs ${container_name}" + return 1 + fi + + # Check container health status + local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none") + if [ "$health_status" != "none" ]; then + info "Container health status: $health_status" + fi + + local retries=0 + while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do + # Check if container is still running + if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then + error "Container ${container_name} stopped during health check!" + error "Check logs: docker logs ${container_name}" + return 1 + fi + + # Try health check + if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + log "✅ New backend container is healthy" + return 0 + fi + + retries=$((retries + 1)) + if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then + # Show container status every 5 attempts + if [ $((retries % 5)) -eq 0 ]; then + info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)" + info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')" + info "Last 5 log lines:" + docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /' + else + info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..." + fi + sleep $HEALTH_CHECK_INTERVAL + fi + done + + error "Health check failed after $HEALTH_CHECK_RETRIES attempts" + error "New backend container is not responding at $HEALTH_CHECK_URL" + error "" + error "Container status:" + docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true + error "" + error "Last 200 log lines:" + docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /' + error "" + error "To debug, keep container running and check:" + error " docker logs -f ${container_name}" + error " docker logs --tail 500 ${container_name} # For even more logs" + error " curl -v $HEALTH_CHECK_URL" + return 1 +} + +# Update Nginx configuration +update_nginx_config() { + log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..." + + # Backup current config + cp "$NGINX_CONF" "$NGINX_CONF_BACKUP" + log "Backed up Nginx config to: $NGINX_CONF_BACKUP" + + # Use Python for reliable config manipulation + # Pass variables directly to Python (not via sys.argv) + python3 << PYTHON_SCRIPT +import re +import sys + +config_file = "$NGINX_CONF" +standby_port = "$STANDBY_PORT" +active_port = "$ACTIVE_PORT" + +try: + # Read the entire file + with open(config_file, 'r') as f: + lines = f.readlines() + + # Find and update upstream block + new_lines = [] + in_upstream = False + upstream_start_idx = -1 + upstream_end_idx = -1 + keepalive_line = None + keepalive_idx = -1 + + # First pass: find upstream block boundaries + for i, line in enumerate(lines): + if re.match(r'^\s*upstream\s+backend\s*\{', line): + upstream_start_idx = i + in_upstream = True + elif in_upstream and re.match(r'^\s*\}', line): + upstream_end_idx = i + break + elif in_upstream and re.search(r'keepalive', line): + keepalive_line = line + keepalive_idx = i + + if upstream_start_idx == -1 or upstream_end_idx == -1: + raise Exception("Could not find upstream backend block") + + # Build new lines + for i, line in enumerate(lines): + if i < upstream_start_idx: + # Before upstream block - keep as is + new_lines.append(line) + elif i == upstream_start_idx: + # Start of upstream block + new_lines.append(line) + elif i > upstream_start_idx and i < upstream_end_idx: + # Inside upstream block + # Skip old server lines + if re.search(r'server\s+127\.0\.0\.1:808[02]', line): + continue + # Skip keepalive (we'll add it at the end) + if re.search(r'keepalive', line): + continue + # Keep comments and other lines + new_lines.append(line) + elif i == upstream_end_idx: + # Before closing brace - add server lines and keepalive + new_lines.append(f" server 127.0.0.1:{standby_port};\n") + new_lines.append(f" server 127.0.0.1:{active_port} backup;\n") + if keepalive_line: + new_lines.append(keepalive_line) + else: + new_lines.append(" keepalive 200;\n") + new_lines.append(line) + else: + # After upstream block - keep as is + new_lines.append(line) + + # Write updated config + with open(config_file, 'w') as f: + f.writelines(new_lines) + + print("Nginx config updated successfully") + +except Exception as e: + print(f"Error updating Nginx config: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) +PYTHON_SCRIPT + + if [ $? -ne 0 ]; then + error "Failed to update Nginx config" + cp "$NGINX_CONF_BACKUP" "$NGINX_CONF" + exit 1 + fi + + # Test Nginx configuration + if nginx -t; then + log "✅ Nginx configuration is valid" + else + error "Nginx configuration test failed, restoring backup..." + error "Error details:" + nginx -t 2>&1 | sed 's/^/ /' + error "" + error "Current config (first 50 lines):" + head -50 "$NGINX_CONF" | sed 's/^/ /' + cp "$NGINX_CONF_BACKUP" "$NGINX_CONF" + exit 1 + fi +} + +# Reload Nginx (zero downtime) +reload_nginx() { + log "Reloading Nginx (zero downtime)..." + + if systemctl reload nginx; then + log "✅ Nginx reloaded successfully" + log "✅ Traffic is now being served by new backend (port 8082)" + else + error "Failed to reload Nginx, restoring backup config..." + cp "$NGINX_CONF_BACKUP" "$NGINX_CONF" + systemctl reload nginx + exit 1 + fi +} + +# Stop old container after grace period +stop_old_container() { + log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..." + sleep $GRACE_PERIOD + + log "Stopping old backend container (${ACTIVE_CONTAINER})..." + + cd "$PROJECT_DIR" + + if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then + log "✅ Old backend container stopped" + else + warn "Failed to stop old backend container gracefully" + fi + else + if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then + log "✅ Old backend container stopped" + else + warn "Failed to stop old backend container gracefully" + fi + fi + + # Optionally remove the old container (comment out if you want to keep it for rollback) + # if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then + # docker-compose -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new + # else + # docker-compose -f "$COMPOSE_FILE" rm -f backend + # fi +} + +# Rollback function +rollback() { + error "Rolling back to previous version..." + + # Check KEEP_FAILED_CONTAINER (check both current env and script-level variable) + local keep_container="${KEEP_FAILED_CONTAINER:-false}" + if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then + keep_container="true" + fi + + # Restore Nginx config + if [ -f "$NGINX_CONF_BACKUP" ]; then + cp "$NGINX_CONF_BACKUP" "$NGINX_CONF" + systemctl reload nginx + log "✅ Nginx config restored" + fi + + # Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set) + cd "$PROJECT_DIR" + if [ "$keep_container" = "true" ]; then + warn "" + warn "═══════════════════════════════════════════════════════════════" + warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging" + warn "═══════════════════════════════════════════════════════════════" + if [ "$STANDBY_PORT" = "8082" ]; then + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true + warn "" + warn "Container 'lottery-backend-new' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs lottery-backend-new" + warn " docker logs --tail 100 lottery-backend-new" + warn "" + warn "To remove manually:" + warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new" + else + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true + warn "" + warn "Container 'lottery-backend' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs lottery-backend" + warn " docker logs --tail 100 lottery-backend" + warn "" + warn "To remove manually:" + warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend" + fi + warn "═══════════════════════════════════════════════════════════════" + else + if [ "$STANDBY_PORT" = "8082" ]; then + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true + else + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true + fi + fi + + # Start old container if it was stopped + if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then + if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \ + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new + else + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \ + $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend + fi + fi + + error "Rollback completed" + exit 1 +} + +# Main deployment flow +main() { + log "Starting rolling update deployment..." + + # Trap errors for rollback + trap rollback ERR + + check_prerequisites + build_new_image + start_new_container + + if ! health_check_new_container; then + rollback + fi + + update_nginx_config + reload_nginx + + # Clear error trap after successful switch + trap - ERR + + stop_old_container + + log "✅ Rolling update completed successfully!" + log "" + log "Summary:" + log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})" + log " - Nginx is serving traffic from new backend" + log " - Old backend (${ACTIVE_CONTAINER}) has been stopped" + log "" + log "To rollback (if needed):" + log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF" + log " 2. Reload Nginx: systemctl reload nginx" + if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then + log " 3. Start old backend: docker-compose -f $COMPOSE_FILE --profile rolling-update start backend-new" + log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE stop backend" + else + log " 3. Start old backend: docker-compose -f $COMPOSE_FILE start backend" + log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE --profile rolling-update stop backend-new" + fi +} + +# Run main function +main "$@" + diff --git a/scripts/setup-logging.sh b/scripts/setup-logging.sh new file mode 100644 index 0000000..0e5b021 --- /dev/null +++ b/scripts/setup-logging.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Setup script for external logback-spring.xml on VPS +# This script extracts logback-spring.xml from the JAR and places it in the config directory +# MUST be run before starting Docker containers to create the required files + +set -e + +# Determine config directory based on current location +if [ -d "/opt/app/backend" ]; then + CONFIG_DIR="/opt/app/backend/config" + LOG_DIR="/opt/app/logs" +elif [ -d "/opt/app/backend/lottery-be" ]; then + CONFIG_DIR="/opt/app/backend/lottery-be/config" + LOG_DIR="/opt/app/logs" +else + # Try to find from current directory + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + CONFIG_DIR="$BACKEND_DIR/config" + LOG_DIR="/opt/app/logs" +fi + +echo "Setting up external logging configuration..." +echo "Config directory: $CONFIG_DIR" +echo "Log directory: $LOG_DIR" + +# Create config directory if it doesn't exist +mkdir -p "$CONFIG_DIR" +chmod 755 "$CONFIG_DIR" + +# Create log directory if it doesn't exist +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" + +# Extract logback-spring.xml from JAR if it doesn't exist +if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then + echo "Extracting logback-spring.xml from JAR..." + + # Try multiple locations for JAR file + JAR_PATH="" + for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")" "$(dirname "$(dirname "$CONFIG_DIR")")"; do + if [ -d "$search_path" ]; then + found_jar=$(find "$search_path" -name "lottery-be-*.jar" -type f 2>/dev/null | head -n 1) + if [ -n "$found_jar" ]; then + JAR_PATH="$found_jar" + break + fi + fi + done + + # Try to find in target directory + if [ -z "$JAR_PATH" ]; then + for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do + if [ -d "$search_path/target" ]; then + found_jar=$(find "$search_path/target" -name "*.jar" -type f | head -n 1) + if [ -n "$found_jar" ]; then + JAR_PATH="$found_jar" + break + fi + fi + done + fi + + if [ -z "$JAR_PATH" ]; then + echo "Warning: JAR file not found. Trying to copy from source..." + # If JAR not found, copy from source (if available) + for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do + if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then + cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml" + echo "Copied from source: $search_path/src/main/resources/logback-spring.xml" + break + fi + done + + if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then + echo "Error: Cannot find logback-spring.xml in JAR or source." + echo "Please ensure the file exists or copy it manually to: $CONFIG_DIR/logback-spring.xml" + exit 1 + fi + else + echo "Found JAR: $JAR_PATH" + # Extract from JAR + unzip -p "$JAR_PATH" BOOT-INF/classes/logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || \ + unzip -p "$JAR_PATH" logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || { + echo "Warning: Could not extract from JAR. Trying to copy from source..." + # Try copying from source + for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do + if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then + cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml" + break + fi + done + + if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then + echo "Error: Cannot extract or find logback-spring.xml." + echo "Please copy it manually to: $CONFIG_DIR/logback-spring.xml" + exit 1 + fi + } + echo "Extracted from JAR: $JAR_PATH" + fi + + echo "logback-spring.xml created at $CONFIG_DIR/logback-spring.xml" +else + echo "logback-spring.xml already exists at $CONFIG_DIR/logback-spring.xml" +fi + +# Set proper permissions +chmod 644 "$CONFIG_DIR/logback-spring.xml" +chown $USER:$USER "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || true + +echo "Logging configuration setup complete!" +echo "" +echo "Configuration file: $CONFIG_DIR/logback-spring.xml" +echo "Log directory: $LOG_DIR" +echo "" +echo "You can now edit $CONFIG_DIR/logback-spring.xml to change log levels at runtime." +echo "Changes will take effect within 30 seconds (no restart needed)." + diff --git a/src/main/java/com/honey/honey/config/TelegramProperties.java b/src/main/java/com/honey/honey/config/TelegramProperties.java deleted file mode 100644 index b206aab..0000000 --- a/src/main/java/com/honey/honey/config/TelegramProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.honey.honey.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@ConfigurationProperties(prefix = "telegram") -@Data -public class TelegramProperties { - private String botToken; -} - diff --git a/src/main/java/com/honey/honey/config/WebConfig.java b/src/main/java/com/honey/honey/config/WebConfig.java deleted file mode 100644 index 4cb9441..0000000 --- a/src/main/java/com/honey/honey/config/WebConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.honey.honey.config; - -import com.honey.honey.security.AuthInterceptor; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - private final AuthInterceptor authInterceptor; - - @Override - public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) { - registry.addInterceptor(authInterceptor) - .excludePathPatterns( - "/ping", - "/actuator/**", - "/api/auth/tma/session" // Session creation endpoint doesn't require auth - ); - } -} - diff --git a/src/main/java/com/honey/honey/controller/UserController.java b/src/main/java/com/honey/honey/controller/UserController.java deleted file mode 100644 index 5ff1150..0000000 --- a/src/main/java/com/honey/honey/controller/UserController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.honey.honey.controller; - -import com.honey.honey.dto.UserDto; -import com.honey.honey.model.UserA; -import com.honey.honey.security.UserContext; -import com.honey.honey.service.UserService; -import com.honey.honey.util.IpUtils; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/users") -@RequiredArgsConstructor -public class UserController { - - private final UserService userService; - - @GetMapping("/current") - public UserDto getCurrentUser() { - UserA user = UserContext.get(); - - // Convert IP from byte[] to string for display - String ipAddress = IpUtils.bytesToIp(user.getIp()); - - return UserDto.builder() - .telegram_id(user.getTelegramId()) - .username(user.getTelegramName()) - .ip(ipAddress) - .build(); - } - - /** - * Updates user's language code. - * Called when user changes language in app header. - */ - @PutMapping("/language") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void updateLanguage(@RequestBody UpdateLanguageRequest request) { - UserA user = UserContext.get(); - userService.updateLanguageCode(user.getId(), request.getLanguageCode()); - } - - @Data - public static class UpdateLanguageRequest { - private String languageCode; - } -} diff --git a/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java b/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java deleted file mode 100644 index 4ca1f24..0000000 --- a/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.honey.honey.exception; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity handleUnauthorized(UnauthorizedException ex) { - log.warn("Unauthorized: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new ErrorResponse("UNAUTHORIZED", ex.getMessage())); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneric(Exception ex) { - log.error("Unexpected error", ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred")); - } -} - diff --git a/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java b/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java deleted file mode 100644 index f4def52..0000000 --- a/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.honey.honey.health; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.actuate.health.Health; -import org.springframework.boot.actuate.health.HealthIndicator; -import org.springframework.stereotype.Component; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.SQLException; - -@Slf4j -@Component -@RequiredArgsConstructor -public class DatabaseHealthIndicator implements HealthIndicator { - - private final DataSource dataSource; - - @Override - public Health health() { - try (Connection connection = dataSource.getConnection()) { - if (connection.isValid(1)) { - return Health.up() - .withDetail("database", "MySQL") - .withDetail("status", "Connected") - .build(); - } - } catch (SQLException e) { - log.error("Database health check failed", e); - return Health.down() - .withDetail("database", "MySQL") - .withDetail("error", e.getMessage()) - .build(); - } - return Health.down().withDetail("database", "MySQL").build(); - } -} - diff --git a/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java b/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java deleted file mode 100644 index 85b21c0..0000000 --- a/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.honey.honey.logging; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Configuration; - -import jakarta.annotation.PostConstruct; - -/** - * Configuration for Grafana integration. - * This class prepares the logging infrastructure for Grafana. - * - * In production (Inferno), logs will be sent to Grafana via: - * - Loki (log aggregation) - * - Prometheus (metrics) - * - * For now, this is a placeholder that ensures structured logging - * is ready for Grafana integration. - */ -@Slf4j -@Configuration -public class GrafanaLoggingConfig { - - @PostConstruct - public void init() { - log.info("📊 Grafana logging configuration initialized"); - log.info("📊 Logs are structured and ready for Grafana/Loki integration"); - log.info("📊 Metrics will be available for Prometheus when configured"); - } - - /** - * Log structured data for Grafana. - * This method can be used to send custom logs to Grafana/Loki. - * - * @param level Log level (INFO, WARN, ERROR, etc.) - * @param message Log message - * @param metadata Additional metadata as key-value pairs - */ - public static void logToGrafana(String level, String message, java.util.Map metadata) { - // For now, just use standard logging - // In production, this will send logs to Grafana/Loki - switch (level.toUpperCase()) { - case "ERROR": - log.error("{} | Metadata: {}", message, metadata); - break; - case "WARN": - log.warn("{} | Metadata: {}", message, metadata); - break; - case "INFO": - default: - log.info("{} | Metadata: {}", message, metadata); - break; - } - } -} - diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java deleted file mode 100644 index 0e72c55..0000000 --- a/src/main/java/com/honey/honey/repository/UserARepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.honey.honey.repository; - -import com.honey.honey.model.UserA; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface UserARepository extends JpaRepository { - Optional findByTelegramId(Long telegramId); -} - diff --git a/src/main/java/com/honey/honey/repository/UserBRepository.java b/src/main/java/com/honey/honey/repository/UserBRepository.java deleted file mode 100644 index 85482be..0000000 --- a/src/main/java/com/honey/honey/repository/UserBRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.honey.honey.repository; - -import com.honey.honey.model.UserB; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface UserBRepository extends JpaRepository { -} - diff --git a/src/main/java/com/honey/honey/repository/UserDRepository.java b/src/main/java/com/honey/honey/repository/UserDRepository.java deleted file mode 100644 index d726f60..0000000 --- a/src/main/java/com/honey/honey/repository/UserDRepository.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.honey.honey.repository; - -import com.honey.honey.model.UserD; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -@Repository -public interface UserDRepository extends JpaRepository { - - /** - * Increments referals_1 for a user. - */ - @Modifying - @Query("UPDATE UserD u SET u.referals1 = u.referals1 + 1 WHERE u.id = :userId") - void incrementReferals1(@Param("userId") Integer userId); - - /** - * Increments referals_2 for a user. - */ - @Modifying - @Query("UPDATE UserD u SET u.referals2 = u.referals2 + 1 WHERE u.id = :userId") - void incrementReferals2(@Param("userId") Integer userId); - - /** - * Increments referals_3 for a user. - */ - @Modifying - @Query("UPDATE UserD u SET u.referals3 = u.referals3 + 1 WHERE u.id = :userId") - void incrementReferals3(@Param("userId") Integer userId); - - /** - * Increments referals_4 for a user. - */ - @Modifying - @Query("UPDATE UserD u SET u.referals4 = u.referals4 + 1 WHERE u.id = :userId") - void incrementReferals4(@Param("userId") Integer userId); - - /** - * Increments referals_5 for a user. - */ - @Modifying - @Query("UPDATE UserD u SET u.referals5 = u.referals5 + 1 WHERE u.id = :userId") - void incrementReferals5(@Param("userId") Integer userId); -} - diff --git a/src/main/java/com/honey/honey/service/UserService.java b/src/main/java/com/honey/honey/service/UserService.java deleted file mode 100644 index ee5969c..0000000 --- a/src/main/java/com/honey/honey/service/UserService.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.honey.honey.service; - -import com.honey.honey.model.UserA; -import com.honey.honey.model.UserB; -import com.honey.honey.model.UserD; -import com.honey.honey.repository.UserARepository; -import com.honey.honey.repository.UserBRepository; -import com.honey.honey.repository.UserDRepository; -import com.honey.honey.util.IpUtils; -import com.honey.honey.util.TimeProvider; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Map; -import java.util.Optional; - -/** - * Service for user management with sharded tables. - * Handles registration, login, and referral system. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class UserService { - - private final UserARepository userARepository; - private final UserBRepository userBRepository; - private final UserDRepository userDRepository; - private final CountryCodeService countryCodeService; - - /** - * Gets or creates a user based on Telegram initData. - * Updates user data on each login. - * Handles referral system if start parameter is present. - * - * @param tgUserData Parsed Telegram data from initData (contains "user" map and "start" string) - * @param request HTTP request for IP extraction - * @return UserA entity - */ - @Transactional - public UserA getOrCreateUser(Map tgUserData, HttpServletRequest request) { - // Extract user data and start parameter (from URL: /honey?start=774876) - @SuppressWarnings("unchecked") - Map tgUser = (Map) tgUserData.get("user"); - String start = (String) tgUserData.get("start"); - Long telegramId = ((Number) tgUser.get("id")).longValue(); - String firstName = (String) tgUser.get("first_name"); - String lastName = (String) tgUser.get("last_name"); - String username = (String) tgUser.get("username"); - Boolean isPremium = (Boolean) tgUser.get("is_premium"); - String languageCode = (String) tgUser.get("language_code"); - - // Build screen_name from first_name and last_name - String screenName = buildScreenName(firstName, lastName); - - // device_code should be language_code from initData (uppercase) - String deviceCode = languageCode != null ? languageCode.toUpperCase() : "XX"; - - // Get client IP and convert to bytes - String clientIp = IpUtils.getClientIp(request); - byte[] ipBytes = IpUtils.ipToBytes(clientIp); - - // Get country code from IP - String countryCode = countryCodeService.getCountryCode(clientIp); - - // Get current timestamp - long nowSeconds = TimeProvider.nowSeconds(); - - // Check if user exists - Optional existingUserOpt = userARepository.findByTelegramId(telegramId); - - if (existingUserOpt.isPresent()) { - // User exists - update login data - UserA userA = existingUserOpt.get(); - updateUserOnLogin(userA, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds); - return userA; - } else { - // New user - create in all 3 tables - return createNewUser(telegramId, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds, start); - } - } - - /** - * Updates user data on login (when session is created). - * Note: language_code is NOT updated here - it should be updated via separate endpoint when user changes language in app. - */ - private void updateUserOnLogin(UserA userA, String screenName, String username, Boolean isPremium, - String languageCode, String countryCode, String deviceCode, - byte[] ipBytes, long nowSeconds) { - userA.setScreenName(screenName); - userA.setTelegramName(username != null ? username : "-"); - userA.setIsPremium(isPremium != null && isPremium ? 1 : 0); - userA.setCountryCode(countryCode); - userA.setDeviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX"); - userA.setIp(ipBytes); - userA.setDateLogin((int) nowSeconds); - // language_code is NOT updated here - it's updated via separate endpoint when user changes language - - userARepository.save(userA); - log.debug("Updated user data on login: userId={}", userA.getId()); - } - - /** - * Updates user's language code (called when user changes language in app header). - */ - @Transactional - public void updateLanguageCode(Integer userId, String languageCode) { - Optional userOpt = userARepository.findById(userId); - if (userOpt.isPresent()) { - UserA user = userOpt.get(); - user.setLanguageCode(languageCode != null && languageCode.length() == 2 ? languageCode.toUpperCase() : "XX"); - userARepository.save(user); - log.debug("Updated language_code for userId={}: {}", userId, user.getLanguageCode()); - } - } - - /** - * Creates a new user in all 3 tables with referral handling. - */ - private UserA createNewUser(Long telegramId, String screenName, String username, Boolean isPremium, - String languageCode, String countryCode, String deviceCode, - byte[] ipBytes, long nowSeconds, String start) { - - // Create UserA - UserA userA = UserA.builder() - .screenName(screenName) - .telegramId(telegramId) - .telegramName(username != null ? username : "-") - .isPremium(isPremium != null && isPremium ? 1 : 0) - .languageCode(languageCode != null ? languageCode.toUpperCase() : "XX") - .countryCode(countryCode) - .deviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX") - .ip(ipBytes) - .dateReg((int) nowSeconds) - .dateLogin((int) nowSeconds) - .banned(0) - .build(); - - userA = userARepository.save(userA); - Integer userId = userA.getId(); - - log.info("Created new user: userId={}, telegramId={}", userId, telegramId); - - // Create UserB with same ID - UserB userB = UserB.builder() - .id(userId) - .balanceA(0L) - .balanceB(0L) - .depositTotal(0L) - .depositCount(0) - .withdrawTotal(0L) - .withdrawCount(0) - .build(); - userBRepository.save(userB); - - // Create UserD with referral handling - UserD userD = createUserDWithReferral(userId, start); - userDRepository.save(userD); - - return userA; - } - - /** - * Creates UserD entity with referral chain setup. - * @param userId New user's ID - * @param start Referral parameter from URL (e.g., "774876" from /honey?start=774876) - */ - private UserD createUserDWithReferral(Integer userId, String start) { - UserD.UserDBuilder builder = UserD.builder() - .id(userId) - .refererId1(0) - .refererId2(0) - .refererId3(0) - .refererId4(0) - .refererId5(0) - .masterId(1) // Default master_id = 1 - .referals1(0) - .referals2(0) - .referals3(0) - .referals4(0) - .referals5(0) - .fromReferals1(0L) - .fromReferals2(0L) - .fromReferals3(0L) - .fromReferals4(0L) - .fromReferals5(0L) - .toReferer1(0L) - .toReferer2(0L) - .toReferer3(0L) - .toReferer4(0L) - .toReferer5(0L); - - if (start != null && !start.isEmpty()) { - try { - Integer refererId = Integer.parseInt(start); - Optional refererUserDOpt = userDRepository.findById(refererId); - - if (refererUserDOpt.isPresent()) { - UserD refererUserD = refererUserDOpt.get(); - - // Set referral chain: shift referer's chain down by 1 level - builder.refererId1(refererId) - .masterId(refererUserD.getMasterId()) - .refererId2(refererUserD.getRefererId1()) - .refererId3(refererUserD.getRefererId2()) - .refererId4(refererUserD.getRefererId3()) - .refererId5(refererUserD.getRefererId4()); - - // Increment referal counts for all 5 levels up the chain - setupReferralChain(userId, refererId); - } else { - // Referer doesn't exist, just set referer_id_1 - log.warn("Referer with id {} not found, setting only referer_id_1", refererId); - builder.refererId1(refererId); - } - } catch (NumberFormatException e) { - log.warn("Invalid start parameter format: {}", start); - } - } - - return builder.build(); - } - - /** - * Sets up referral chain and increments referal counts for all 5 levels. - * Example: If user F registers with referer E, increments: - * - referals_1 for E - * - referals_2 for D (E's referer_id_1) - * - referals_3 for C (D's referer_id_1) - * - referals_4 for B (C's referer_id_1) - * - referals_5 for A (B's referer_id_1) - */ - private void setupReferralChain(Integer newUserId, Integer refererId) { - // Level 1: Direct referer - userDRepository.incrementReferals1(refererId); - - Optional level1Opt = userDRepository.findById(refererId); - if (level1Opt.isEmpty()) { - return; - } - - UserD level1 = level1Opt.get(); - - // Level 2 - if (level1.getRefererId1() > 0) { - userDRepository.incrementReferals2(level1.getRefererId1()); - - Optional level2Opt = userDRepository.findById(level1.getRefererId1()); - if (level2Opt.isPresent()) { - UserD level2 = level2Opt.get(); - - // Level 3 - if (level2.getRefererId1() > 0) { - userDRepository.incrementReferals3(level2.getRefererId1()); - - Optional level3Opt = userDRepository.findById(level2.getRefererId1()); - if (level3Opt.isPresent()) { - UserD level3 = level3Opt.get(); - - // Level 4 - if (level3.getRefererId1() > 0) { - userDRepository.incrementReferals4(level3.getRefererId1()); - - Optional level4Opt = userDRepository.findById(level3.getRefererId1()); - if (level4Opt.isPresent()) { - UserD level4 = level4Opt.get(); - - // Level 5 - if (level4.getRefererId1() > 0) { - userDRepository.incrementReferals5(level4.getRefererId1()); - } - } - } - } - } - } - } - - log.info("Referral chain setup completed: newUserId={}, refererId={}", newUserId, refererId); - } - - /** - * Builds screen_name from first_name and last_name. - */ - private String buildScreenName(String firstName, String lastName) { - StringBuilder sb = new StringBuilder(); - if (firstName != null && !firstName.isEmpty()) { - sb.append(firstName); - } - if (lastName != null && !lastName.isEmpty()) { - if (sb.length() > 0) { - sb.append(" "); - } - sb.append(lastName); - } - String result = sb.toString().trim(); - return result.isEmpty() ? "-" : (result.length() > 75 ? result.substring(0, 75) : result); - } - - /** - * Gets user by ID. - */ - public Optional getUserById(Integer userId) { - return userARepository.findById(userId); - } - - /** - * Gets user by Telegram ID. - */ - public Optional getUserByTelegramId(Long telegramId) { - return userARepository.findByTelegramId(telegramId); - } -} - diff --git a/src/main/java/com/honey/honey/HoneyBackendApplication.java b/src/main/java/com/lottery/lottery/LotteryBackendApplication.java similarity index 59% rename from src/main/java/com/honey/honey/HoneyBackendApplication.java rename to src/main/java/com/lottery/lottery/LotteryBackendApplication.java index 5cb5365..e8c2e2d 100644 --- a/src/main/java/com/honey/honey/HoneyBackendApplication.java +++ b/src/main/java/com/lottery/lottery/LotteryBackendApplication.java @@ -1,18 +1,20 @@ -package com.honey.honey; +package com.lottery.lottery; -import com.honey.honey.config.ConfigLoader; -import com.honey.honey.config.TelegramProperties; +import com.lottery.lottery.config.ConfigLoader; +import com.lottery.lottery.config.TelegramProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableAsync @EnableConfigurationProperties({TelegramProperties.class}) -public class HoneyBackendApplication { +public class LotteryBackendApplication { public static void main(String[] args) { - SpringApplication app = new SpringApplication(HoneyBackendApplication.class); + SpringApplication app = new SpringApplication(LotteryBackendApplication.class); app.addListeners(new ConfigLoader()); app.run(args); } diff --git a/src/main/java/com/lottery/lottery/config/AdminSecurityConfig.java b/src/main/java/com/lottery/lottery/config/AdminSecurityConfig.java new file mode 100644 index 0000000..13d7799 --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/AdminSecurityConfig.java @@ -0,0 +1,95 @@ +package com.lottery.lottery.config; + +import com.lottery.lottery.security.admin.AdminDetailsService; +import com.lottery.lottery.security.admin.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class AdminSecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AdminDetailsService adminDetailsService; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider adminAuthenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(adminDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager adminAuthenticationManager() { + return new ProviderManager(adminAuthenticationProvider()); + } + + @Bean + public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/admin/**") + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/admin/login").permitAll() + .requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN") + .requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN") + .requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN") + .requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN") + .requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN") + .requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN") + .requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN") + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().denyAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:5173", + "http://localhost:3000", + "https://win-spin.live" // Main domain (admin panel is on same domain with secret path) + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/admin/**", configuration); + return source; + } +} + diff --git a/src/main/java/com/honey/honey/config/ConfigLoader.java b/src/main/java/com/lottery/lottery/config/ConfigLoader.java similarity index 96% rename from src/main/java/com/honey/honey/config/ConfigLoader.java rename to src/main/java/com/lottery/lottery/config/ConfigLoader.java index 76b6bbb..e523f21 100644 --- a/src/main/java/com/honey/honey/config/ConfigLoader.java +++ b/src/main/java/com/lottery/lottery/config/ConfigLoader.java @@ -1,4 +1,4 @@ -package com.honey.honey.config; +package com.lottery.lottery.config; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; @@ -19,13 +19,13 @@ import java.util.Properties; * This allows switching between Railway (env vars) and Inferno (mounted file) deployments. * * Priority: - * 1. Mounted file at /run/secrets/honey-config.properties (Inferno) + * 1. Mounted file at /run/secrets/lottery-config.properties (Inferno) * 2. Environment variables (Railway) */ @Slf4j public class ConfigLoader implements ApplicationListener { - private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties"; + private static final String SECRET_FILE_PATH = "/run/secrets/lottery-config.properties"; @Override public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { diff --git a/src/main/java/com/honey/honey/config/CorsConfig.java b/src/main/java/com/lottery/lottery/config/CorsConfig.java similarity index 97% rename from src/main/java/com/honey/honey/config/CorsConfig.java rename to src/main/java/com/lottery/lottery/config/CorsConfig.java index 1d0990e..b44e588 100644 --- a/src/main/java/com/honey/honey/config/CorsConfig.java +++ b/src/main/java/com/lottery/lottery/config/CorsConfig.java @@ -1,4 +1,4 @@ -package com.honey.honey.config; +package com.lottery.lottery.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/lottery/lottery/config/LocaleConfig.java b/src/main/java/com/lottery/lottery/config/LocaleConfig.java new file mode 100644 index 0000000..747dcdf --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/LocaleConfig.java @@ -0,0 +1,61 @@ +package com.lottery.lottery.config; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +@Configuration +public class LocaleConfig { + + // Supported languages + public static final List SUPPORTED_LANGUAGES = Arrays.asList( + "EN", "RU", "DE", "IT", "NL", "PL", "FR", "ES", "ID", "TR" + ); + + @Bean + public MessageSource messageSource() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + messageSource.setBasename("messages"); + messageSource.setDefaultEncoding("UTF-8"); + messageSource.setFallbackToSystemLocale(false); + return messageSource; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); + resolver.setDefaultLocale(Locale.ENGLISH); + return resolver; + } + + /** + * Converts language code (EN, RU, etc.) to Locale. + */ + public static Locale languageCodeToLocale(String languageCode) { + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + return Locale.ENGLISH; + } + + String upperCode = languageCode.toUpperCase(); + // Handle special cases + switch (upperCode) { + case "ID": + return new Locale("id"); // Indonesian + default: + try { + return new Locale(upperCode.toLowerCase()); + } catch (Exception e) { + return Locale.ENGLISH; // Default fallback + } + } + } +} + + diff --git a/src/main/java/com/lottery/lottery/config/TelegramProperties.java b/src/main/java/com/lottery/lottery/config/TelegramProperties.java new file mode 100644 index 0000000..0609f3f --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/TelegramProperties.java @@ -0,0 +1,35 @@ +package com.lottery.lottery.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "telegram") +@Data +public class TelegramProperties { + private String botToken; + + /** + * Bot token for checking channel membership. + * Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN + * or in mounted file at /run/secrets/lottery-config.properties as telegram.channel-checker-bot-token + */ + private String channelCheckerBotToken; + + /** + * Channel ID for follow tasks (e.g., "@win_spin_news" or numeric ID). + * Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID + * or in mounted file at /run/secrets/lottery-config.properties as telegram.follow-task-channel-id + */ + private String followTaskChannelId; + + /** + * Channel ID for follow withdrawals channel task (e.g., "@win_spin_withdrawals" or numeric ID). + * Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2 + * or in mounted file at /run/secrets/lottery-config.properties as telegram.follow-task-channel-id-2 + */ + private String followTaskChannelId2; +} + + diff --git a/src/main/java/com/lottery/lottery/config/WebConfig.java b/src/main/java/com/lottery/lottery/config/WebConfig.java new file mode 100644 index 0000000..3305311 --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/WebConfig.java @@ -0,0 +1,44 @@ +package com.lottery.lottery.config; + +import com.lottery.lottery.security.AuthInterceptor; +import com.lottery.lottery.security.RateLimitInterceptor; +import com.lottery.lottery.security.UserRateLimitInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + private final RateLimitInterceptor rateLimitInterceptor; + private final UserRateLimitInterceptor userRateLimitInterceptor; + + @Override + public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) { + // NOTE: Rate limiting is NOT applied to Telegram webhook endpoint + // Telegram sends webhooks from multiple IPs and we need to process all updates, especially payments + // Rate limiting interceptor is only for bot registration endpoint (if needed elsewhere) + + // User session interceptor for all other authenticated endpoints + registry.addInterceptor(authInterceptor) + .excludePathPatterns( + "/ping", + "/actuator/**", + "/api/auth/tma/session", // Session creation endpoint doesn't require auth + "/api/telegram/webhook/**", // Telegram webhook (token in path, validated in controller) + "/avatars/**", // Avatar static files don't require auth (served by Nginx in production) + "/api/check_user/**", // User check endpoint for external applications (open endpoint) + "/api/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth) + "/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth) + "/api/remotebet/**", // Remote bet: token + feature switch protected, no user auth + "/api/admin/**" // Admin endpoints are handled by Spring Security + ); + + // User-based rate limiting for payment creation and payout creation (applied after auth interceptor) + registry.addInterceptor(userRateLimitInterceptor) + .addPathPatterns("/api/payments/create", "/api/payouts"); + } +} + diff --git a/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java b/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java new file mode 100644 index 0000000..bb5a3c8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/WebSocketAuthInterceptor.java @@ -0,0 +1,106 @@ +package com.lottery.lottery.config; + +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.SessionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.security.Principal; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketAuthInterceptor implements ChannelInterceptor { + + private final SessionService sessionService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + // Extract Bearer token from headers + List authHeaders = accessor.getNativeHeader("Authorization"); + String token = null; + + if (authHeaders != null && !authHeaders.isEmpty()) { + String authHeader = authHeaders.get(0); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + } + } + + // Also check query parameter (for SockJS fallback) + if (token == null) { + String query = accessor.getFirstNativeHeader("query"); + if (query != null && query.contains("token=")) { + int tokenStart = query.indexOf("token=") + 6; + int tokenEnd = query.indexOf("&", tokenStart); + if (tokenEnd == -1) { + tokenEnd = query.length(); + } + token = query.substring(tokenStart, tokenEnd); + } + } + + if (token == null || token.isBlank()) { + log.warn("WebSocket connection rejected: No token provided"); + throw new SecurityException("Authentication required"); + } + + // Validate token and get user + var userOpt = sessionService.getUserBySession(token); + if (userOpt.isEmpty()) { + log.warn("WebSocket connection rejected: Invalid token"); + throw new SecurityException("Invalid authentication token"); + } + + UserA user = userOpt.get(); + accessor.setUser(new StompPrincipal(user.getId(), user)); + UserContext.set(user); + + log.debug("WebSocket connection authenticated for user {}", user.getId()); + } + + return message; + } + + @Override + public void postSend(Message message, MessageChannel channel, boolean sent) { + UserContext.clear(); + } + + // Simple principal class to store user info + public static class StompPrincipal implements Principal { + private final Integer userId; + private final UserA user; + + public StompPrincipal(Integer userId, UserA user) { + this.userId = userId; + this.user = user; + } + + public Integer getUserId() { + return userId; + } + + public UserA getUser() { + return user; + } + + @Override + public String getName() { + return String.valueOf(userId); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/config/WebSocketConfig.java b/src/main/java/com/lottery/lottery/config/WebSocketConfig.java new file mode 100644 index 0000000..e53ad4f --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/WebSocketConfig.java @@ -0,0 +1,62 @@ +package com.lottery.lottery.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketAuthInterceptor authInterceptor; + + @Value("${app.websocket.allowed-origins:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org}") + private String allowedOrigins; + + public WebSocketConfig(WebSocketAuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // Enable simple broker for sending messages to clients + config.enableSimpleBroker("/topic", "/queue"); + // Prefix for messages from client to server + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // Parse allowed origins from configuration + // Spring's setAllowedOriginPatterns uses Ant-style patterns, not regex + // For exact matches, use the URL as-is + // For subdomain matching, use https://*.example.com + List origins = Arrays.asList(allowedOrigins.split(",")); + String[] originPatterns = origins.stream() + .map(String::trim) + .filter(origin -> !origin.isEmpty()) + .toArray(String[]::new); + + log.info("[WEBSOCKET] Configuring WebSocket endpoint /ws with allowed origins: {}", Arrays.toString(originPatterns)); + + // WebSocket endpoint - clients connect here + registry.addEndpoint("/ws") + .setAllowedOriginPatterns(originPatterns) // Restricted to configured domains + .withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(authInterceptor); + } +} + diff --git a/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java b/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java new file mode 100644 index 0000000..1a4ddef --- /dev/null +++ b/src/main/java/com/lottery/lottery/config/WebSocketSubscriptionListener.java @@ -0,0 +1,178 @@ +package com.lottery.lottery.config; + +import com.lottery.lottery.dto.GameRoomStateDto; +import com.lottery.lottery.service.GameRoomService; +import com.lottery.lottery.service.RoomConnectionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketSubscriptionListener { + + private final GameRoomService gameRoomService; + private final SimpMessagingTemplate messagingTemplate; + private final RoomConnectionService roomConnectionService; + + // Pattern to match room subscription: /topic/room/{roomNumber} + private static final Pattern ROOM_SUBSCRIPTION_PATTERN = Pattern.compile("/topic/room/(\\d+)"); + + /** + * Listens for WebSocket subscription events. + * When a client subscribes to a room topic, sends the current room state immediately. + */ + @EventListener + public void handleSubscription(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + + if (destination == null) { + return; + } + + // Check if this is a room subscription + Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination); + if (matcher.matches()) { + try { + Integer roomNumber = Integer.parseInt(matcher.group(1)); + + // Get the user ID from the principal + Object principal = accessor.getUser(); + Integer userId = null; + if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { + userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); + } + + // Get session ID + String sessionId = accessor.getSessionId(); + + log.info("Client subscribed to room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId); + + // Register session for disconnect tracking + if (sessionId != null && userId != null) { + roomConnectionService.registerSession(sessionId, userId); + } + + // Track room-level connection (not just round participation) + if (userId != null && sessionId != null) { + roomConnectionService.addUserToRoom(userId, roomNumber, sessionId); + } else { + log.warn("Cannot track room connection: userId={}, sessionId={}", userId, sessionId); + } + + // Get current room state and send it to the subscribing client + // This ensures client gets authoritative state immediately on subscribe + GameRoomStateDto state = gameRoomService.getRoomState(roomNumber); + + // Send state directly to the destination (room topic) + // This will be received by the subscribing client + messagingTemplate.convertAndSend(destination, state); + + log.debug("Sent initial room state to subscriber: room={}, phase={}, participants={}, connectedUsers={}", + roomNumber, state.getPhase(), + state.getParticipants() != null ? state.getParticipants().size() : 0, + state.getConnectedUsers()); + + } catch (NumberFormatException e) { + log.warn("Invalid room number in subscription destination: {}", destination); + } catch (Exception e) { + log.error("Error sending initial state for room subscription: {}", destination, e); + } + } + } + + /** + * Listens for WebSocket unsubscribe events. + * When a client unsubscribes from a room topic, removes them from room connections. + */ + @EventListener + public void handleUnsubscribe(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + + // Skip if destination is null (Spring WebSocket sometimes sends unsubscribe events without destination during cleanup) + if (destination == null) { + return; + } + + log.debug("Unsubscribe event received for destination: {}", destination); + + // Check if this is a room unsubscription + Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination); + if (matcher.matches()) { + try { + Integer roomNumber = Integer.parseInt(matcher.group(1)); + + // Get the user ID from the principal + Object principal = accessor.getUser(); + Integer userId = null; + if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { + userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); + } else { + log.warn("Unsubscribe event: principal is not StompPrincipal, type: {}", + principal != null ? principal.getClass().getName() : "null"); + } + + // Get session ID + String sessionId = accessor.getSessionId(); + + if (userId != null && sessionId != null) { + log.info("Client unsubscribed from room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId); + roomConnectionService.removeUserFromRoom(userId, roomNumber, sessionId); + } else { + log.warn("Unsubscribe event: userId or sessionId is null for destination: {} (userId: {}, sessionId: {})", + destination, userId, sessionId); + } + } catch (NumberFormatException e) { + log.warn("Invalid room number in unsubscription destination: {}", destination); + } catch (Exception e) { + log.error("Error handling room unsubscription: {}", destination, e); + } + } else { + log.debug("Unsubscribe event destination does not match room pattern: {}", destination); + } + } + + /** + * Listens for WebSocket disconnect events. + * When a client disconnects completely, removes them from all rooms. + */ + @EventListener + public void handleDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + + // Try to get user ID from principal first + Object principal = accessor.getUser(); + Integer userId = null; + if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) { + userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId(); + } + + if (userId != null && sessionId != null) { + log.info("Client disconnected (userId: {}, sessionId: {}), removing session from all rooms", userId, sessionId); + // Remove only this specific session from all rooms + roomConnectionService.removeUserFromAllRooms(userId, sessionId); + // Also remove session mapping + roomConnectionService.removeSession(sessionId); + } else if (sessionId != null) { + // Principal might be lost, try to get userId from session mapping + log.info("Client disconnected (sessionId: {}), principal lost, using session mapping", sessionId); + roomConnectionService.removeUserFromAllRoomsBySession(sessionId); + } else { + log.warn("Disconnect event: both userId and sessionId are null, cannot remove from rooms"); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java b/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java new file mode 100644 index 0000000..e2b222b --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminAnalyticsController.java @@ -0,0 +1,206 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.model.Payment; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.repository.GameRoundRepository; +import com.lottery.lottery.repository.PaymentRepository; +import com.lottery.lottery.repository.PayoutRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/analytics") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminAnalyticsController { + + private final UserARepository userARepository; + private final PaymentRepository paymentRepository; + private final PayoutRepository payoutRepository; + private final GameRoundRepository gameRoundRepository; + + /** + * Get revenue and payout time series data for charts. + * @param range Time range: 7d, 30d, 90d, 1y, all + * @return Time series data with daily/weekly/monthly aggregation + */ + @GetMapping("/revenue") + public ResponseEntity> getRevenueAnalytics( + @RequestParam(defaultValue = "30d") String range) { + + Instant now = Instant.now(); + Instant startDate; + String granularity; + + // Determine start date and granularity based on range + switch (range.toLowerCase()) { + case "7d": + startDate = now.minus(7, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "30d": + startDate = now.minus(30, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "90d": + startDate = now.minus(90, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "1y": + startDate = now.minus(365, ChronoUnit.DAYS); + granularity = "weekly"; + break; + case "all": + startDate = Instant.ofEpochSecond(0); // All time + granularity = "monthly"; + break; + default: + startDate = now.minus(30, ChronoUnit.DAYS); + granularity = "daily"; + } + + List> dataPoints = new ArrayList<>(); + Instant current = startDate; + + while (current.isBefore(now)) { + Instant periodEnd; + if (granularity.equals("daily")) { + periodEnd = current.plus(1, ChronoUnit.DAYS); + } else if (granularity.equals("weekly")) { + periodEnd = current.plus(7, ChronoUnit.DAYS); + } else { + periodEnd = current.plus(30, ChronoUnit.DAYS); + } + + if (periodEnd.isAfter(now)) { + periodEnd = now; + } + + // CRYPTO only: revenue and payouts in USD for this period + java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween( + Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO); + java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween( + Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO); + java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd); + + Map point = new HashMap<>(); + point.put("date", current.getEpochSecond()); + point.put("revenue", revenueUsd); + point.put("payouts", payoutsUsd); + point.put("netRevenue", netRevenueUsd); + + dataPoints.add(point); + + current = periodEnd; + } + + Map response = new HashMap<>(); + response.put("range", range); + response.put("granularity", granularity); + response.put("data", dataPoints); + + return ResponseEntity.ok(response); + } + + /** + * Get user activity time series data (registrations, active players, rounds). + * @param range Time range: 7d, 30d, 90d, 1y, all + * @return Time series data + */ + @GetMapping("/activity") + public ResponseEntity> getActivityAnalytics( + @RequestParam(defaultValue = "30d") String range) { + + Instant now = Instant.now(); + Instant startDate; + String granularity; + + switch (range.toLowerCase()) { + case "7d": + startDate = now.minus(7, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "30d": + startDate = now.minus(30, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "90d": + startDate = now.minus(90, ChronoUnit.DAYS); + granularity = "daily"; + break; + case "1y": + startDate = now.minus(365, ChronoUnit.DAYS); + granularity = "weekly"; + break; + case "all": + startDate = Instant.ofEpochSecond(0); + granularity = "monthly"; + break; + default: + startDate = now.minus(30, ChronoUnit.DAYS); + granularity = "daily"; + } + + List> dataPoints = new ArrayList<>(); + Instant current = startDate; + + while (current.isBefore(now)) { + Instant periodEnd; + if (granularity.equals("daily")) { + periodEnd = current.plus(1, ChronoUnit.DAYS); + } else if (granularity.equals("weekly")) { + periodEnd = current.plus(7, ChronoUnit.DAYS); + } else { + periodEnd = current.plus(30, ChronoUnit.DAYS); + } + + if (periodEnd.isAfter(now)) { + periodEnd = now; + } + + // Convert to Unix timestamps for UserA queries + int periodStartTs = (int) current.getEpochSecond(); + int periodEndTs = (int) periodEnd.getEpochSecond(); + + // Count new registrations in this period (between current and periodEnd) + long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs); + + // Count active players (logged in) in this period + long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs); + + // Count rounds resolved in this period + long rounds = gameRoundRepository.countByResolvedAtBetween(current, periodEnd); + + Map point = new HashMap<>(); + point.put("date", current.getEpochSecond()); + point.put("newUsers", newUsers); + point.put("activePlayers", activePlayers); + point.put("rounds", rounds); + + dataPoints.add(point); + + current = periodEnd; + } + + Map response = new HashMap<>(); + response.put("range", range); + response.put("granularity", granularity); + response.put("data", dataPoints); + + return ResponseEntity.ok(response); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java b/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java new file mode 100644 index 0000000..3bf508d --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminBotConfigController.java @@ -0,0 +1,96 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminBotConfigDto; +import com.lottery.lottery.dto.AdminBotConfigRequest; +import com.lottery.lottery.service.AdminBotConfigService; +import com.lottery.lottery.service.ConfigurationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/admin/bots") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBotConfigController { + + private final AdminBotConfigService adminBotConfigService; + private final ConfigurationService configurationService; + + @GetMapping + public ResponseEntity> list() { + return ResponseEntity.ok(adminBotConfigService.listAll()); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Integer id) { + Optional dto = adminBotConfigService.getById(id); + return dto.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody AdminBotConfigRequest request) { + try { + AdminBotConfigDto created = adminBotConfigService.create(request); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Integer id, @Valid @RequestBody AdminBotConfigRequest request) { + try { + Optional updated = adminBotConfigService.update(id, request); + return updated.map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Integer id) { + boolean deleted = adminBotConfigService.delete(id); + return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + /** + * Shuffle time windows for bots that have the given room enabled. + * Redistributes the same set of time windows randomly across those bots. + */ + @PostMapping("/shuffle") + public ResponseEntity shuffleTimeWindows(@RequestParam int roomNumber) { + if (roomNumber != 2 && roomNumber != 3) { + return ResponseEntity.badRequest().body(Map.of("error", "roomNumber must be 2 or 3")); + } + try { + adminBotConfigService.shuffleTimeWindowsForRoom(roomNumber); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + @GetMapping("/settings") + public ResponseEntity> getBotSettings() { + return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", configurationService.getMaxParticipantsBeforeBotJoin())); + } + + @PatchMapping("/settings") + public ResponseEntity updateBotSettings(@RequestBody Map body) { + Integer v = body != null ? body.get("maxParticipantsBeforeBotJoin") : null; + if (v == null) { + return ResponseEntity.badRequest().body(Map.of("error", "maxParticipantsBeforeBotJoin is required")); + } + int updated = configurationService.setMaxParticipantsBeforeBotJoin(v); + return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", updated)); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java b/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java new file mode 100644 index 0000000..7eee83f --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminConfigurationsController.java @@ -0,0 +1,49 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminConfigurationsRequest; +import com.lottery.lottery.service.BotConfigService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Admin API for safe bots and flexible bots (winner-override config used e.g. with /remotebet). + * Configurations tab in admin panel uses GET/PUT /api/admin/configurations. + */ +@RestController +@RequestMapping("/api/admin/configurations") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") +public class AdminConfigurationsController { + + private final BotConfigService botConfigService; + + @GetMapping + public ResponseEntity getConfig() { + return ResponseEntity.ok(botConfigService.getConfig()); + } + + @PutMapping + public ResponseEntity updateConfig( + @RequestBody AdminConfigurationsRequest request + ) { + List safeIds = request.getSafeBotUserIds() != null + ? request.getSafeBotUserIds() + : Collections.emptyList(); + List flexibleBots = Collections.emptyList(); + if (request.getFlexibleBots() != null) { + flexibleBots = request.getFlexibleBots().stream() + .filter(e -> e != null && e.getUserId() != null && e.getWinRate() != null) + .map(e -> new BotConfigService.FlexibleBotEntryDto(e.getUserId(), e.getWinRate())) + .collect(Collectors.toList()); + } + botConfigService.setSafeBotUserIds(safeIds); + botConfigService.setFlexibleBots(flexibleBots); + return ResponseEntity.ok(botConfigService.getConfig()); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java b/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java new file mode 100644 index 0000000..fd57d7f --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminDashboardController.java @@ -0,0 +1,194 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.model.Payment; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.model.SupportTicket; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.GameRoundRepository; +import com.lottery.lottery.repository.PaymentRepository; +import com.lottery.lottery.repository.PayoutRepository; +import com.lottery.lottery.repository.SupportTicketRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/dashboard") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminDashboardController { + + private final UserARepository userARepository; + private final PaymentRepository paymentRepository; + private final PayoutRepository payoutRepository; + private final GameRoundRepository gameRoundRepository; + private final SupportTicketRepository supportTicketRepository; + + @GetMapping("/stats") + public ResponseEntity> getDashboardStats() { + Instant now = Instant.now(); + Instant todayStart = now.truncatedTo(ChronoUnit.DAYS); + Instant weekStart = now.minus(7, ChronoUnit.DAYS); + Instant monthStart = now.minus(30, ChronoUnit.DAYS); + Instant dayAgo = now.minus(24, ChronoUnit.HOURS); + Instant weekAgo = now.minus(7, ChronoUnit.DAYS); + Instant monthAgo = now.minus(30, ChronoUnit.DAYS); + + // Convert to Unix timestamps (seconds) for UserA date fields + int todayStartTs = (int) todayStart.getEpochSecond(); + int weekStartTs = (int) weekStart.getEpochSecond(); + int monthStartTs = (int) monthStart.getEpochSecond(); + int dayAgoTs = (int) dayAgo.getEpochSecond(); + int weekAgoTs = (int) weekAgo.getEpochSecond(); + int monthAgoTs = (int) monthAgo.getEpochSecond(); + + Map stats = new HashMap<>(); + + // Total Users + long totalUsers = userARepository.count(); + long newUsersToday = userARepository.countByDateRegAfter(todayStartTs); + long newUsersWeek = userARepository.countByDateRegAfter(weekStartTs); + long newUsersMonth = userARepository.countByDateRegAfter(monthStartTs); + + // Active Players (users who logged in recently) + long activePlayers24h = userARepository.countByDateLoginAfter(dayAgoTs); + long activePlayers7d = userARepository.countByDateLoginAfter(weekAgoTs); + long activePlayers30d = userARepository.countByDateLoginAfter(monthAgoTs); + + // Revenue (from completed payments) - in Stars + int totalRevenue = paymentRepository.sumStarsAmountByStatus(Payment.PaymentStatus.COMPLETED) + .orElse(0); + int revenueToday = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payment.PaymentStatus.COMPLETED, todayStart).orElse(0); + int revenueWeek = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payment.PaymentStatus.COMPLETED, weekStart).orElse(0); + int revenueMonth = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payment.PaymentStatus.COMPLETED, monthStart).orElse(0); + + // Payouts (from completed payouts) - in Stars + int totalPayouts = payoutRepository.sumStarsAmountByStatus(Payout.PayoutStatus.COMPLETED) + .orElse(0); + int payoutsToday = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payout.PayoutStatus.COMPLETED, todayStart).orElse(0); + int payoutsWeek = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payout.PayoutStatus.COMPLETED, weekStart).orElse(0); + int payoutsMonth = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter( + Payout.PayoutStatus.COMPLETED, monthStart).orElse(0); + + // Net Revenue (in Stars) + int netRevenue = totalRevenue - totalPayouts; + int netRevenueToday = revenueToday - payoutsToday; + int netRevenueWeek = revenueWeek - payoutsWeek; + int netRevenueMonth = revenueMonth - payoutsMonth; + + // CRYPTO only: revenue and payouts in USD (for dashboard / financial analytics) + BigDecimal cryptoRevenueTotal = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNull(Payment.PaymentStatus.COMPLETED).orElse(BigDecimal.ZERO); + BigDecimal cryptoRevenueToday = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoRevenueWeek = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoRevenueMonth = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoPayoutsTotal = payoutRepository.sumUsdAmountByTypeCryptoAndStatus(Payout.PayoutStatus.COMPLETED).orElse(BigDecimal.ZERO); + BigDecimal cryptoPayoutsToday = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoPayoutsWeek = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoPayoutsMonth = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO); + BigDecimal cryptoNetRevenueTotal = cryptoRevenueTotal.subtract(cryptoPayoutsTotal); + BigDecimal cryptoNetRevenueToday = cryptoRevenueToday.subtract(cryptoPayoutsToday); + BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek); + BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth); + + // Game Rounds + long totalRounds = gameRoundRepository.count(); + long roundsToday = gameRoundRepository.countByResolvedAtAfter(todayStart); + long roundsWeek = gameRoundRepository.countByResolvedAtAfter(weekStart); + long roundsMonth = gameRoundRepository.countByResolvedAtAfter(monthStart); + + // Average Round Pool (from resolved rounds) - round to int + Double avgPoolDouble = gameRoundRepository.avgTotalBetByResolvedAtAfter(monthStart) + .orElse(0.0); + int avgPool = (int) Math.round(avgPoolDouble); + + // Support Tickets + long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED); + // Count tickets closed today + long ticketsResolvedToday = supportTicketRepository.findAll().stream() + .filter(t -> t.getStatus() == SupportTicket.TicketStatus.CLOSED && + t.getUpdatedAt() != null && + t.getUpdatedAt().isAfter(todayStart)) + .count(); + + // Build response + stats.put("users", Map.of( + "total", totalUsers, + "newToday", newUsersToday, + "newWeek", newUsersWeek, + "newMonth", newUsersMonth + )); + + stats.put("activePlayers", Map.of( + "last24h", activePlayers24h, + "last7d", activePlayers7d, + "last30d", activePlayers30d + )); + + stats.put("revenue", Map.of( + "total", totalRevenue, + "today", revenueToday, + "week", revenueWeek, + "month", revenueMonth + )); + + stats.put("payouts", Map.of( + "total", totalPayouts, + "today", payoutsToday, + "week", payoutsWeek, + "month", payoutsMonth + )); + + stats.put("netRevenue", Map.of( + "total", netRevenue, + "today", netRevenueToday, + "week", netRevenueWeek, + "month", netRevenueMonth + )); + + Map crypto = new HashMap<>(); + crypto.put("revenueUsd", cryptoRevenueTotal); + crypto.put("revenueUsdToday", cryptoRevenueToday); + crypto.put("revenueUsdWeek", cryptoRevenueWeek); + crypto.put("revenueUsdMonth", cryptoRevenueMonth); + crypto.put("payoutsUsd", cryptoPayoutsTotal); + crypto.put("payoutsUsdToday", cryptoPayoutsToday); + crypto.put("payoutsUsdWeek", cryptoPayoutsWeek); + crypto.put("payoutsUsdMonth", cryptoPayoutsMonth); + crypto.put("profitUsd", cryptoNetRevenueTotal); + crypto.put("profitUsdToday", cryptoNetRevenueToday); + crypto.put("profitUsdWeek", cryptoNetRevenueWeek); + crypto.put("profitUsdMonth", cryptoNetRevenueMonth); + stats.put("crypto", crypto); + + stats.put("rounds", Map.of( + "total", totalRounds, + "today", roundsToday, + "week", roundsWeek, + "month", roundsMonth, + "avgPool", avgPool + )); + + stats.put("supportTickets", Map.of( + "open", openTickets, + "resolvedToday", ticketsResolvedToday + )); + + return ResponseEntity.ok(stats); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminFeatureSwitchController.java b/src/main/java/com/lottery/lottery/controller/AdminFeatureSwitchController.java new file mode 100644 index 0000000..6e1743b --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminFeatureSwitchController.java @@ -0,0 +1,36 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.service.FeatureSwitchService; +import com.lottery.lottery.service.FeatureSwitchService.FeatureSwitchDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/feature-switches") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminFeatureSwitchController { + + private final FeatureSwitchService featureSwitchService; + + @GetMapping + public ResponseEntity> getAll() { + return ResponseEntity.ok(featureSwitchService.getAll()); + } + + @PatchMapping("/{key}") + public ResponseEntity update( + @PathVariable String key, + @RequestBody Map body) { + Boolean enabled = body != null ? body.get("enabled") : null; + if (enabled == null) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(featureSwitchService.setEnabled(key, enabled)); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminLoginController.java b/src/main/java/com/lottery/lottery/controller/AdminLoginController.java new file mode 100644 index 0000000..879a403 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminLoginController.java @@ -0,0 +1,51 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminLoginRequest; +import com.lottery.lottery.dto.AdminLoginResponse; +import com.lottery.lottery.service.AdminService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminLoginController { + + private final AdminService adminService; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody AdminLoginRequest request) { + if (request.getUsername() == null || request.getPassword() == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body("Username and password are required"); + } + + Optional tokenOpt = adminService.authenticate( + request.getUsername(), + request.getPassword() + ); + + if (tokenOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Invalid credentials"); + } + + // Get admin to retrieve role + var adminOpt = adminService.getAdminByUsername(request.getUsername()); + String role = adminOpt.map(admin -> admin.getRole()).orElse("ROLE_ADMIN"); + + return ResponseEntity.ok(new AdminLoginResponse( + tokenOpt.get(), + request.getUsername(), + role + )); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminMasterController.java b/src/main/java/com/lottery/lottery/controller/AdminMasterController.java new file mode 100644 index 0000000..a61b80c --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminMasterController.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminMasterDto; +import com.lottery.lottery.service.AdminMasterService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/masters") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminMasterController { + + private final AdminMasterService adminMasterService; + + @GetMapping + public ResponseEntity> getMasters() { + return ResponseEntity.ok(adminMasterService.getMasters()); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminNotificationController.java b/src/main/java/com/lottery/lottery/controller/AdminNotificationController.java new file mode 100644 index 0000000..fb85743 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminNotificationController.java @@ -0,0 +1,42 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.NotifyBroadcastRequest; +import com.lottery.lottery.service.NotificationBroadcastService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * Admin API to trigger or stop notification broadcast (ADMIN only). + */ +@Slf4j +@RestController +@RequestMapping("/api/admin/notifications") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminNotificationController { + + private final NotificationBroadcastService notificationBroadcastService; + + @PostMapping("/send") + public ResponseEntity send(@RequestBody(required = false) NotifyBroadcastRequest body) { + NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest(); + notificationBroadcastService.runBroadcast( + req.getMessage(), + req.getImageUrl(), + req.getVideoUrl(), + req.getUserIdFrom(), + req.getUserIdTo(), + req.getButtonText(), + req.getIgnoreBlocked()); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/stop") + public ResponseEntity stop() { + notificationBroadcastService.requestStop(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminPaymentController.java b/src/main/java/com/lottery/lottery/controller/AdminPaymentController.java new file mode 100644 index 0000000..cb358ff --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminPaymentController.java @@ -0,0 +1,147 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminPaymentDto; +import com.lottery.lottery.model.Payment; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.PaymentRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin/payments") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") +public class AdminPaymentController { + + private final PaymentRepository paymentRepository; + private final UserARepository userARepository; + private final UserDRepository userDRepository; + + private boolean isGameAdmin() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getAuthorities() == null) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority())); + } + + @GetMapping + public ResponseEntity> getPayments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir, + // Filters + @RequestParam(required = false) String status, + @RequestParam(required = false) Integer userId, + @RequestParam(required = false) String dateFrom, + @RequestParam(required = false) String dateTo, + @RequestParam(required = false) Long amountMin, + @RequestParam(required = false) Long amountMax) { + + // Build sort + Sort sort = Sort.by("createdAt").descending(); // Default sort + if (sortBy != null && !sortBy.trim().isEmpty()) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) + ? Sort.Direction.ASC + : Sort.Direction.DESC; + sort = Sort.by(direction, sortBy); + } + + Pageable pageable = PageRequest.of(page, size, sort); + + List masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of(); + + // Build specification + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (!masterIds.isEmpty()) { + predicates.add(cb.not(root.get("userId").in(masterIds))); + } + if (status != null && !status.trim().isEmpty()) { + try { + Payment.PaymentStatus paymentStatus = Payment.PaymentStatus.valueOf(status.toUpperCase()); + predicates.add(cb.equal(root.get("status"), paymentStatus)); + } catch (IllegalArgumentException e) { + // Invalid status, ignore + } + } + + if (userId != null) { + predicates.add(cb.equal(root.get("userId"), userId)); + } + + // Date range filters would need Instant conversion + // For now, we'll skip them or implement if needed + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Page paymentPage = paymentRepository.findAll(spec, pageable); + + // Fetch user names + List userIds = paymentPage.getContent().stream() + .map(Payment::getUserId) + .distinct() + .collect(Collectors.toList()); + + Map userNameMap = userARepository.findAllById(userIds).stream() + .collect(Collectors.toMap( + UserA::getId, + u -> u.getTelegramName() != null && !u.getTelegramName().equals("-") + ? u.getTelegramName() + : u.getScreenName() + )); + + // Convert to DTOs + Page dtoPage = paymentPage.map(payment -> { + String userName = userNameMap.getOrDefault(payment.getUserId(), "Unknown"); + return AdminPaymentDto.builder() + .id(payment.getId()) + .userId(payment.getUserId()) + .userName(userName) + .orderId(payment.getOrderId()) + .starsAmount(payment.getStarsAmount()) + .ticketsAmount(payment.getTicketsAmount()) + .status(payment.getStatus().name()) + .telegramPaymentChargeId(payment.getTelegramPaymentChargeId()) + .telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId()) + .createdAt(payment.getCreatedAt()) + .completedAt(payment.getCompletedAt()) + .build(); + }); + + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + + return ResponseEntity.ok(response); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminPayoutController.java b/src/main/java/com/lottery/lottery/controller/AdminPayoutController.java new file mode 100644 index 0000000..1adf3ea --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminPayoutController.java @@ -0,0 +1,204 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminPayoutDto; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.repository.PayoutRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.repository.UserDRepository; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.PayoutService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import jakarta.persistence.criteria.Predicate; +import org.springframework.http.HttpStatus; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin/payouts") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')") +public class AdminPayoutController { + + private final PayoutRepository payoutRepository; + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final UserDRepository userDRepository; + private final PayoutService payoutService; + private final LocalizationService localizationService; + + private boolean isGameAdmin() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getAuthorities() == null) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority())); + } + + @GetMapping + public ResponseEntity> getPayouts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir, + // Filters + @RequestParam(required = false) String status, + @RequestParam(required = false) Integer userId, + @RequestParam(required = false) String type) { + + // Build sort + Sort sort = Sort.by("createdAt").descending(); // Default sort + if (sortBy != null && !sortBy.trim().isEmpty()) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) + ? Sort.Direction.ASC + : Sort.Direction.DESC; + sort = Sort.by(direction, sortBy); + } + + Pageable pageable = PageRequest.of(page, size, sort); + + List masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of(); + + // Build specification + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (!masterIds.isEmpty()) { + predicates.add(cb.not(root.get("userId").in(masterIds))); + } + if (status != null && !status.trim().isEmpty()) { + try { + Payout.PayoutStatus payoutStatus = Payout.PayoutStatus.valueOf(status.toUpperCase()); + predicates.add(cb.equal(root.get("status"), payoutStatus)); + } catch (IllegalArgumentException e) { + // Invalid status, ignore + } + } + + if (userId != null) { + predicates.add(cb.equal(root.get("userId"), userId)); + } + + if (type != null && !type.trim().isEmpty()) { + try { + Payout.PayoutType payoutType = Payout.PayoutType.valueOf(type.toUpperCase()); + predicates.add(cb.equal(root.get("type"), payoutType)); + } catch (IllegalArgumentException e) { + // Invalid type, ignore + } + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Page payoutPage = payoutRepository.findAll(spec, pageable); + + // Fetch user names + List userIds = payoutPage.getContent().stream() + .map(Payout::getUserId) + .distinct() + .collect(Collectors.toList()); + + Map userNameMap = userARepository.findAllById(userIds).stream() + .collect(Collectors.toMap( + UserA::getId, + u -> u.getTelegramName() != null && !u.getTelegramName().equals("-") + ? u.getTelegramName() + : u.getScreenName() + )); + + // Convert to DTOs + Page dtoPage = payoutPage.map(payout -> { + String userName = userNameMap.getOrDefault(payout.getUserId(), "Unknown"); + return AdminPayoutDto.builder() + .id(payout.getId()) + .userId(payout.getUserId()) + .userName(userName) + .username(payout.getUsername()) + .type(payout.getType().name()) + .giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null) + .total(payout.getTotal()) + .starsAmount(payout.getStarsAmount()) + .quantity(payout.getQuantity()) + .status(payout.getStatus().name()) + .createdAt(payout.getCreatedAt()) + .resolvedAt(payout.getResolvedAt()) + .build(); + }); + + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + + return ResponseEntity.ok(response); + } + + @PostMapping("/{id}/complete") + @PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')") + public ResponseEntity completePayout(@PathVariable Long id) { + try { + Optional payoutOpt = payoutRepository.findById(id); + if (payoutOpt.isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id)))); + } + Payout payout = payoutOpt.get(); + if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) { + return ResponseEntity.badRequest() + .body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanComplete", payout.getStatus().name()))); + } + payoutService.markPayoutCompleted(id); + return ResponseEntity.ok(Map.of("message", "Payout marked as completed")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage())); + } + } + + @PostMapping("/{id}/cancel") + @PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')") + public ResponseEntity cancelPayout(@PathVariable Long id) { + try { + Optional payoutOpt = payoutRepository.findById(id); + if (payoutOpt.isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id)))); + } + Payout payout = payoutOpt.get(); + if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) { + return ResponseEntity.badRequest() + .body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanCancel", payout.getStatus().name()))); + } + payoutService.markPayoutCancelled(id); + return ResponseEntity.ok(Map.of("message", "Payout cancelled")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage())); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminPromotionController.java b/src/main/java/com/lottery/lottery/controller/AdminPromotionController.java new file mode 100644 index 0000000..e5af27b --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminPromotionController.java @@ -0,0 +1,139 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.service.AdminPromotionService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/admin/promotions") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminPromotionController { + + private final AdminPromotionService adminPromotionService; + + @GetMapping + public ResponseEntity> listPromotions() { + return ResponseEntity.ok(adminPromotionService.listPromotions()); + } + + @GetMapping("/{id}") + public ResponseEntity getPromotion(@PathVariable int id) { + return adminPromotionService.getPromotion(id) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createPromotion(@Valid @RequestBody AdminPromotionRequest request) { + try { + AdminPromotionDto dto = adminPromotionService.createPromotion(request); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PutMapping("/{id}") + public ResponseEntity updatePromotion( + @PathVariable int id, + @Valid @RequestBody AdminPromotionRequest request) { + try { + return adminPromotionService.updatePromotion(id, request) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deletePromotion(@PathVariable int id) { + return adminPromotionService.deletePromotion(id) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + // --- Rewards --- + + @GetMapping("/{promoId}/rewards") + public ResponseEntity> listRewards(@PathVariable int promoId) { + return ResponseEntity.ok(adminPromotionService.listRewards(promoId)); + } + + @PostMapping("/{promoId}/rewards") + public ResponseEntity createReward( + @PathVariable int promoId, + @Valid @RequestBody AdminPromotionRewardRequest request) { + try { + AdminPromotionRewardDto dto = adminPromotionService.createReward(promoId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PutMapping("/rewards/{rewardId}") + public ResponseEntity updateReward( + @PathVariable int rewardId, + @Valid @RequestBody AdminPromotionRewardRequest request) { + try { + return adminPromotionService.updateReward(rewardId, request) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping("/rewards/{rewardId}") + public ResponseEntity deleteReward(@PathVariable int rewardId) { + return adminPromotionService.deleteReward(rewardId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + // --- Promotion users (leaderboard / results) --- + + @GetMapping("/{promoId}/users") + public ResponseEntity> getPromotionUsers( + @PathVariable int promoId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir, + @RequestParam(required = false) Integer userId) { + Page dtoPage = adminPromotionService.getPromotionUsers( + promoId, page, size, sortBy, sortDir, userId); + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{promoId}/users/{userId}/points") + public ResponseEntity updatePromotionUserPoints( + @PathVariable int promoId, + @PathVariable int userId, + @Valid @RequestBody AdminPromotionUserPointsRequest request) { + Optional updated = adminPromotionService.updatePromotionUserPoints( + promoId, userId, request.getPoints()); + return updated.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminRoomController.java b/src/main/java/com/lottery/lottery/controller/AdminRoomController.java new file mode 100644 index 0000000..58ba3d7 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminRoomController.java @@ -0,0 +1,57 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.AdminRoomDetailDto; +import com.lottery.lottery.dto.AdminRoomOnlineUserDto; +import com.lottery.lottery.dto.AdminRoomSummaryDto; +import com.lottery.lottery.service.GameRoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/rooms") +@RequiredArgsConstructor +public class AdminRoomController { + + private final GameRoomService gameRoomService; + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> listRooms() { + return ResponseEntity.ok(gameRoomService.getAdminRoomSummaries()); + } + + @GetMapping("/online-users") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getOnlineUsers() { + return ResponseEntity.ok(gameRoomService.getAdminOnlineUsersAcrossRooms()); + } + + @GetMapping("/{roomNumber}") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity getRoomDetail(@PathVariable Integer roomNumber) { + if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(gameRoomService.getAdminRoomDetail(roomNumber)); + } + + @PostMapping("/{roomNumber}/repair") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> repairRoom(@PathVariable Integer roomNumber) { + if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { + return ResponseEntity.badRequest().build(); + } + try { + gameRoomService.repairRoom(roomNumber); + return ResponseEntity.ok(Map.of("success", true, "message", "Repair completed for room " + roomNumber)); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Repair failed")); + } + } +} diff --git a/src/main/java/com/lottery/lottery/controller/AdminSupportTicketController.java b/src/main/java/com/lottery/lottery/controller/AdminSupportTicketController.java new file mode 100644 index 0000000..02b51a8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminSupportTicketController.java @@ -0,0 +1,316 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.model.Admin; +import com.lottery.lottery.model.SupportMessage; +import com.lottery.lottery.model.SupportTicket; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.AdminRepository; +import com.lottery.lottery.repository.SupportMessageRepository; +import com.lottery.lottery.repository.SupportTicketRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import jakarta.persistence.criteria.Predicate; +import jakarta.validation.Valid; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin/tickets") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')") +public class AdminSupportTicketController { + + private final SupportTicketRepository supportTicketRepository; + private final SupportMessageRepository supportMessageRepository; + private final UserARepository userARepository; + private final AdminRepository adminRepository; + + @GetMapping + public ResponseEntity> getTickets( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) String status, + @RequestParam(required = false) Integer userId, + @RequestParam(required = false) String search) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + + // Build specification + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (status != null && !status.trim().isEmpty()) { + try { + SupportTicket.TicketStatus ticketStatus = SupportTicket.TicketStatus.valueOf(status.toUpperCase()); + predicates.add(cb.equal(root.get("status"), ticketStatus)); + } catch (IllegalArgumentException e) { + // Invalid status, ignore + } + } + + if (userId != null) { + predicates.add(cb.equal(root.get("user").get("id"), userId)); + } + + if (search != null && !search.trim().isEmpty()) { + String searchPattern = "%" + search.trim() + "%"; + predicates.add(cb.or( + cb.like(cb.lower(root.get("subject")), searchPattern.toLowerCase()) + )); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Page ticketPage = supportTicketRepository.findAll(spec, pageable); + + // Fetch user names and message counts + List userIds = ticketPage.getContent().stream() + .map(t -> t.getUser().getId()) + .distinct() + .collect(Collectors.toList()); + + Map userNameMap = userARepository.findAllById(userIds).stream() + .collect(Collectors.toMap( + UserA::getId, + u -> u.getTelegramName() != null && !u.getTelegramName().equals("-") + ? u.getTelegramName() + : u.getScreenName() + )); + + // Convert to DTOs + Page dtoPage = ticketPage.map(ticket -> { + String userName = userNameMap.getOrDefault(ticket.getUser().getId(), "Unknown"); + long messageCount = supportMessageRepository.countByTicketId(ticket.getId()); + + // Get last message preview + List lastMessages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(ticket.getId()); + String lastMessagePreview = ""; + Instant lastMessageAt = null; + if (!lastMessages.isEmpty()) { + SupportMessage lastMsg = lastMessages.get(lastMessages.size() - 1); + lastMessagePreview = lastMsg.getMessage().length() > 100 + ? lastMsg.getMessage().substring(0, 100) + "..." + : lastMsg.getMessage(); + lastMessageAt = lastMsg.getCreatedAt(); + } + + return AdminSupportTicketDto.builder() + .id(ticket.getId()) + .userId(ticket.getUser().getId()) + .userName(userName) + .subject(ticket.getSubject()) + .status(ticket.getStatus().name()) + .createdAt(ticket.getCreatedAt()) + .updatedAt(ticket.getUpdatedAt()) + .messageCount(messageCount) + .lastMessagePreview(lastMessagePreview) + .lastMessageAt(lastMessageAt) + .build(); + }); + + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity getTicketDetail(@PathVariable Long id) { + return supportTicketRepository.findById(id) + .map(ticket -> { + String userName = ticket.getUser().getTelegramName() != null && !ticket.getUser().getTelegramName().equals("-") + ? ticket.getUser().getTelegramName() + : ticket.getUser().getScreenName(); + + List messages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(id); + + // Get all admin user IDs from admins table + List adminUserIds = adminRepository.findAll().stream() + .filter(admin -> admin.getUserId() != null) + .map(Admin::getUserId) + .collect(Collectors.toList()); + + List messageDtos = messages.stream() + .map(msg -> { + // Check if message is from admin by checking if user_id is in admins table + boolean isAdmin = adminUserIds.contains(msg.getUser().getId()); + String msgUserName = msg.getUser().getTelegramName() != null && !msg.getUser().getTelegramName().equals("-") + ? msg.getUser().getTelegramName() + : msg.getUser().getScreenName(); + + return AdminSupportMessageDto.builder() + .id(msg.getId()) + .userId(msg.getUser().getId()) + .userName(msgUserName) + .message(msg.getMessage()) + .createdAt(msg.getCreatedAt()) + .isAdmin(isAdmin) + .build(); + }) + .collect(Collectors.toList()); + + return AdminSupportTicketDetailDto.builder() + .id(ticket.getId()) + .userId(ticket.getUser().getId()) + .userName(userName) + .subject(ticket.getSubject()) + .status(ticket.getStatus().name()) + .createdAt(ticket.getCreatedAt()) + .updatedAt(ticket.getUpdatedAt()) + .messages(messageDtos) + .build(); + }) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/reply") + public ResponseEntity replyToTicket( + @PathVariable Long id, + @Valid @RequestBody SupportTicketReplyRequest request) { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String adminUsername; + + if (authentication.getPrincipal() instanceof UserDetails) { + adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername(); + } else { + // Fallback if principal is a String + adminUsername = authentication.getName(); + } + + // Get admin entity to find user_id + Admin admin = adminRepository.findByUsername(adminUsername) + .orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername)); + + if (admin.getUserId() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account")); + } + + // Get the admin's UserA entity + UserA adminUser = userARepository.findById(admin.getUserId()) + .orElseThrow(() -> new RuntimeException("Admin user account not found: " + admin.getUserId())); + + return supportTicketRepository.findById(id) + .map(ticket -> { + // Create message without prefix, using admin's user_id + SupportMessage message = SupportMessage.builder() + .ticket(ticket) + .user(adminUser) // Use admin's UserA entity + .message(request.getMessage()) // Save message as-is, no prefix + .build(); + + supportMessageRepository.save(message); + + // Update ticket updated_at + ticket.setUpdatedAt(java.time.Instant.now()); + if (ticket.getStatus() == SupportTicket.TicketStatus.CLOSED) { + ticket.setStatus(SupportTicket.TicketStatus.OPENED); // Reopen if admin replies + } + supportTicketRepository.save(ticket); + + return ResponseEntity.ok(Map.of("message", "Reply sent successfully")); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/close") + public ResponseEntity closeTicket(@PathVariable Long id) { + return supportTicketRepository.findById(id) + .map(ticket -> { + ticket.setStatus(SupportTicket.TicketStatus.CLOSED); + ticket.setUpdatedAt(java.time.Instant.now()); + supportTicketRepository.save(ticket); + return ResponseEntity.ok(Map.of("message", "Ticket closed")); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/reopen") + public ResponseEntity reopenTicket(@PathVariable Long id) { + return supportTicketRepository.findById(id) + .map(ticket -> { + ticket.setStatus(SupportTicket.TicketStatus.OPENED); + ticket.setUpdatedAt(java.time.Instant.now()); + supportTicketRepository.save(ticket); + return ResponseEntity.ok(Map.of("message", "Ticket reopened")); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PutMapping("/messages/{messageId}") + public ResponseEntity editMessage( + @PathVariable Long messageId, + @Valid @RequestBody SupportTicketReplyRequest request) { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String adminUsername; + + if (authentication.getPrincipal() instanceof UserDetails) { + adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername(); + } else { + adminUsername = authentication.getName(); + } + + // Get admin entity to find user_id + Admin admin = adminRepository.findByUsername(adminUsername) + .orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername)); + + if (admin.getUserId() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account")); + } + + return supportMessageRepository.findById(messageId) + .map(message -> { + // Check if message is from this admin + if (!message.getUser().getId().equals(admin.getUserId())) { + return ResponseEntity.badRequest().body(Map.of("error", "You can only edit your own messages")); + } + + // Check if user is an admin (verify in admins table) + boolean isAdmin = adminRepository.findAll().stream() + .anyMatch(a -> a.getUserId() != null && a.getUserId().equals(message.getUser().getId())); + + if (!isAdmin) { + return ResponseEntity.badRequest().body(Map.of("error", "Only admin messages can be edited")); + } + + // Update message + message.setMessage(request.getMessage()); + supportMessageRepository.save(message); + + // Update ticket updated_at + message.getTicket().setUpdatedAt(java.time.Instant.now()); + supportTicketRepository.save(message.getTicket()); + + return ResponseEntity.ok(Map.of("message", "Message updated successfully")); + }) + .orElse(ResponseEntity.notFound().build()); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/AdminUserController.java b/src/main/java/com/lottery/lottery/controller/AdminUserController.java new file mode 100644 index 0000000..ea3ed5a --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/AdminUserController.java @@ -0,0 +1,277 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.service.AdminUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + + /** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */ + private static final Set SORTABLE_FIELDS = Set.of( + "id", "screenName", "telegramId", "telegramName", "isPremium", + "languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned", + "balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit" + ); + private static final Set DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt"); + private static final Set WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt"); + + private final AdminUserService adminUserService; + + private boolean isGameAdmin() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getAuthorities() == null) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority())); + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir, + // Filters + @RequestParam(required = false) String search, + @RequestParam(required = false) Integer banned, + @RequestParam(required = false) String countryCode, + @RequestParam(required = false) String languageCode, + @RequestParam(required = false) Integer dateRegFrom, + @RequestParam(required = false) Integer dateRegTo, + @RequestParam(required = false) Long balanceMin, + @RequestParam(required = false) Long balanceMax, + @RequestParam(required = false) Integer roundsPlayedMin, + @RequestParam(required = false) Integer roundsPlayedMax, + @RequestParam(required = false) Integer referralCountMin, + @RequestParam(required = false) Integer referralCountMax, + @RequestParam(required = false) Integer referrerId, + @RequestParam(required = false) Integer referralLevel, + @RequestParam(required = false) String ip) { + + // Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, roundsPlayed, referralCount) + // are handled in service via custom query; others are applied to UserA. + Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"); + String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null); + if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) { + // Pass through; service will use custom ordered query + } else if (effectiveSortBy != null && !SORTABLE_FIELDS.contains(effectiveSortBy)) { + effectiveSortBy = null; + } + Sort sort = Sort.by("id").descending(); + if (effectiveSortBy != null && !sortRequiresJoin.contains(effectiveSortBy)) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC; + sort = Sort.by(direction, effectiveSortBy); + } + Pageable pageable = PageRequest.of(page, size, sort); + + // Convert balance filters from tickets (divide by 1000000) to bigint format + Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null; + Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null; + + boolean excludeMasters = isGameAdmin(); + Page dtoPage = adminUserService.getUsers( + pageable, + search, + banned, + countryCode, + languageCode, + dateRegFrom, + dateRegTo, + balanceMinBigint, + balanceMaxBigint, + roundsPlayedMin, + roundsPlayedMax, + referralCountMin, + referralCountMax, + referrerId, + referralLevel, + ip, + effectiveSortBy, + sortDir, + excludeMasters + ); + + Map response = new HashMap<>(); + response.put("content", dtoPage.getContent()); + response.put("totalElements", dtoPage.getTotalElements()); + response.put("totalPages", dtoPage.getTotalPages()); + response.put("currentPage", dtoPage.getNumber()); + response.put("size", dtoPage.getSize()); + response.put("hasNext", dtoPage.hasNext()); + response.put("hasPrevious", dtoPage.hasPrevious()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity getUserDetail(@PathVariable Integer id) { + AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin()); + if (userDetail == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(userDetail); + } + + @GetMapping("/{id}/transactions") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUserTransactions( + @PathVariable Integer id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page transactions = adminUserService.getUserTransactions(id, pageable); + + Map response = new HashMap<>(); + response.put("content", transactions.getContent()); + response.put("totalElements", transactions.getTotalElements()); + response.put("totalPages", transactions.getTotalPages()); + response.put("currentPage", transactions.getNumber()); + response.put("size", transactions.getSize()); + response.put("hasNext", transactions.hasNext()); + response.put("hasPrevious", transactions.hasPrevious()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/game-rounds") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUserGameRounds( + @PathVariable Integer id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page rounds = adminUserService.getUserGameRounds(id, pageable); + + Map response = new HashMap<>(); + response.put("content", rounds.getContent()); + response.put("totalElements", rounds.getTotalElements()); + response.put("totalPages", rounds.getTotalPages()); + response.put("currentPage", rounds.getNumber()); + response.put("size", rounds.getSize()); + response.put("hasNext", rounds.hasNext()); + response.put("hasPrevious", rounds.hasPrevious()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/payments") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUserPayments( + @PathVariable Integer id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir) { + String field = sortBy != null && DEPOSIT_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt"; + Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field)); + Page deposits = adminUserService.getUserPayments(id, pageable); + Map response = new HashMap<>(); + response.put("content", deposits.getContent()); + response.put("totalElements", deposits.getTotalElements()); + response.put("totalPages", deposits.getTotalPages()); + response.put("currentPage", deposits.getNumber()); + response.put("size", deposits.getSize()); + response.put("hasNext", deposits.hasNext()); + response.put("hasPrevious", deposits.hasPrevious()); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/payouts") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUserPayouts( + @PathVariable Integer id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) String sortDir) { + String field = sortBy != null && WITHDRAWAL_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt"; + Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field)); + Page payouts = adminUserService.getUserPayouts(id, pageable); + Map response = new HashMap<>(); + response.put("content", payouts.getContent()); + response.put("totalElements", payouts.getTotalElements()); + response.put("totalPages", payouts.getTotalPages()); + response.put("currentPage", payouts.getNumber()); + response.put("size", payouts.getSize()); + response.put("hasNext", payouts.hasNext()); + response.put("hasPrevious", payouts.hasPrevious()); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/tasks") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity> getUserTasks(@PathVariable Integer id) { + Map tasks = adminUserService.getUserTasks(id); + return ResponseEntity.ok(tasks); + } + + @PatchMapping("/{id}/ban") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity setUserBanned( + @PathVariable Integer id, + @RequestBody Map body) { + Boolean banned = body != null ? body.get("banned") : null; + if (banned == null) { + return ResponseEntity.badRequest().body(Map.of("error", "banned is required (true/false)")); + } + try { + adminUserService.setBanned(id, banned); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PatchMapping("/{id}/withdrawals-enabled") + @PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')") + public ResponseEntity setWithdrawalsEnabled( + @PathVariable Integer id, + @RequestBody Map body) { + Boolean enabled = body != null ? body.get("enabled") : null; + if (enabled == null) { + return ResponseEntity.badRequest().body(Map.of("error", "enabled is required (true/false)")); + } + try { + adminUserService.setWithdrawalsEnabled(id, enabled); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/{id}/balance/adjust") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity adjustBalance( + @PathVariable Integer id, + @Valid @RequestBody com.lottery.lottery.dto.BalanceAdjustmentRequest request) { + try { + com.lottery.lottery.dto.BalanceAdjustmentResponse response = adminUserService.adjustBalance(id, request); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } +} + diff --git a/src/main/java/com/honey/honey/controller/AuthController.java b/src/main/java/com/lottery/lottery/controller/AuthController.java similarity index 68% rename from src/main/java/com/honey/honey/controller/AuthController.java rename to src/main/java/com/lottery/lottery/controller/AuthController.java index 3cedc89..a72cb77 100644 --- a/src/main/java/com/honey/honey/controller/AuthController.java +++ b/src/main/java/com/lottery/lottery/controller/AuthController.java @@ -1,17 +1,21 @@ -package com.honey.honey.controller; +package com.lottery.lottery.controller; -import com.honey.honey.dto.CreateSessionRequest; -import com.honey.honey.dto.CreateSessionResponse; -import com.honey.honey.model.UserA; -import com.honey.honey.service.SessionService; -import com.honey.honey.service.TelegramAuthService; -import com.honey.honey.service.UserService; +import com.lottery.lottery.dto.CreateSessionRequest; +import com.lottery.lottery.dto.CreateSessionResponse; +import com.lottery.lottery.exception.BannedUserException; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.SessionService; +import com.lottery.lottery.service.TelegramAuthService; +import com.lottery.lottery.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.servlet.http.HttpServletRequest; +import java.util.HashMap; import java.util.Map; @Slf4j @@ -23,6 +27,7 @@ public class AuthController { private final TelegramAuthService telegramAuthService; private final SessionService sessionService; private final UserService userService; + private final LocalizationService localizationService; /** * Creates a session by validating Telegram initData. @@ -36,19 +41,25 @@ public class AuthController { String initData = request.getInitData(); if (initData == null || initData.isBlank()) { - throw new IllegalArgumentException("initData is required"); + throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired")); } // Validate Telegram initData signature and parse data Map tgUserData = telegramAuthService.validateAndParseInitData(initData); // Get or create user (handles registration, login update, and referral system) + // Note: Referral handling is done via bot registration endpoint, not through WebApp initData UserA user = userService.getOrCreateUser(tgUserData, httpRequest); + if (user.getBanned() != null && user.getBanned() == 1) { + String message = localizationService.getMessageForUser(user.getId(), "auth.error.accessRestricted"); + throw new BannedUserException(message); + } + // Create session String sessionId = sessionService.createSession(user); - log.info("Session created for userId={}, telegramId={}", user.getId(), user.getTelegramId()); + log.debug("Session created: userId={}", user.getId()); return CreateSessionResponse.builder() .access_token(sessionId) @@ -71,7 +82,7 @@ public class AuthController { String sessionId = extractBearerToken(authHeader); if (sessionId != null) { sessionService.invalidateSession(sessionId); - log.info("Session invalidated via logout"); + log.debug("Session invalidated via logout"); } } diff --git a/src/main/java/com/lottery/lottery/controller/DepositWebhookController.java b/src/main/java/com/lottery/lottery/controller/DepositWebhookController.java new file mode 100644 index 0000000..22b8349 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/DepositWebhookController.java @@ -0,0 +1,56 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.ExternalDepositWebhookRequest; +import com.lottery.lottery.service.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Controller for 3rd party deposit completion webhook. + * Path: POST /api/deposit_webhook/{token}. Token must match app.deposit-webhook.token (APP_DEPOSIT_WEBHOOK_TOKEN). + * No session auth; token in path only. Set the token on VPS via environment variable. + */ +@Slf4j +@RestController +@RequestMapping("/api/deposit_webhook") +@RequiredArgsConstructor +public class DepositWebhookController { + + @Value("${app.deposit-webhook.token:}") + private String expectedToken; + + private final PaymentService paymentService; + + /** + * Called by 3rd party when a user's crypto deposit was successful. + * Body: user_id (internal id from db_users_a), usd_amount (decimal, e.g. 1.45). + */ + @PostMapping("/{token}") + public ResponseEntity onDepositCompleted( + @PathVariable String token, + @RequestBody ExternalDepositWebhookRequest request) { + if (expectedToken.isEmpty() || !expectedToken.equals(token)) { + log.warn("Deposit webhook rejected: invalid token"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + if (request == null || request.getUserId() == null || request.getUsdAmount() == null) { + return ResponseEntity.badRequest().build(); + } + try { + paymentService.processExternalDepositCompletion( + request.getUserId(), + request.getUsdAmount()); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + log.warn("Deposit webhook rejected: {}", e.getMessage()); + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.error("Deposit webhook error: userId={}, usdAmount={}", request.getUserId(), request.getUsdAmount(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/src/main/java/com/lottery/lottery/controller/GameController.java b/src/main/java/com/lottery/lottery/controller/GameController.java new file mode 100644 index 0000000..a95ca38 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/GameController.java @@ -0,0 +1,99 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.CompletedRoundDto; +import com.lottery.lottery.dto.GameHistoryEntryDto; +import com.lottery.lottery.model.GameRound; +import com.lottery.lottery.repository.GameRoundRepository; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.AvatarService; +import com.lottery.lottery.service.GameHistoryService; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequestMapping("/api/game") +@RequiredArgsConstructor +public class GameController { + + private final GameRoundRepository gameRoundRepository; + private final UserARepository userARepository; + private final AvatarService avatarService; + private final GameHistoryService gameHistoryService; + + /** + * Gets the last 10 completed rounds for a specific room. + * Fetches data from game_rounds table only. + */ + @GetMapping("/room/{roomNumber}/completed-rounds") + public ResponseEntity> getCompletedRounds( + @PathVariable Integer roomNumber + ) { + List rounds = gameRoundRepository.findLastCompletedRoundsByRoomNumber( + roomNumber, + PageRequest.of(0, 10) + ); + + List completedRounds = rounds.stream() + .map(round -> { + // Calculate winner's chance from game_rounds table data + Double winChance = null; + if (round.getWinnerBet() != null && round.getTotalBet() != null && round.getTotalBet() > 0) { + winChance = ((double) round.getWinnerBet() / round.getTotalBet()) * 100.0; + } + + // Get winner's screen name and avatar + String screenName = null; + String avatarUrl = null; + if (round.getWinnerUserId() != null) { + screenName = userARepository.findById(round.getWinnerUserId()) + .map(userA -> userA.getScreenName()) + .orElse(null); + avatarUrl = avatarService.getAvatarUrl(round.getWinnerUserId()); + } + + return CompletedRoundDto.builder() + .roundId(round.getId()) + .winnerUserId(round.getWinnerUserId()) + .winnerScreenName(screenName) + .winnerAvatarUrl(avatarUrl) + .winnerBet(round.getWinnerBet()) + .payout(round.getPayout()) + .totalBet(round.getTotalBet()) + .winChance(winChance) + .resolvedAt(round.getResolvedAt() != null ? round.getResolvedAt().toEpochMilli() : null) + .build(); + }) + .collect(Collectors.toList()); + + return ResponseEntity.ok(completedRounds); + } + + /** + * Gets WIN transactions for the current user from the last 30 days with pagination. + * + * @param page Page number (0-indexed, default 0) + * @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC. + */ + @GetMapping("/history") + public ResponseEntity> getUserGameHistory( + @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) String timezone) { + Integer userId = UserContext.get().getId(); + com.lottery.lottery.model.UserA user = UserContext.get(); + String languageCode = user.getLanguageCode(); + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + org.springframework.data.domain.Page history = gameHistoryService.getUserGameHistory(userId, page, timezone, languageCode); + return ResponseEntity.ok(history); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java b/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java new file mode 100644 index 0000000..cbea96f --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/GameWebSocketController.java @@ -0,0 +1,184 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.config.WebSocketAuthInterceptor; +import com.lottery.lottery.dto.BalanceUpdateDto; +import com.lottery.lottery.dto.GameRoomStateDto; +import com.lottery.lottery.dto.JoinRoundRequest; +import com.lottery.lottery.exception.GameException; +import com.lottery.lottery.service.GameRoomService; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.UserService; +import jakarta.annotation.PostConstruct; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SubscribeMapping; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Controller; +import org.springframework.validation.annotation.Validated; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class GameWebSocketController { + + private final GameRoomService gameRoomService; + private final SimpMessagingTemplate messagingTemplate; + private final UserService userService; + private final LocalizationService localizationService; + + // Track which users are subscribed to which rooms + private final Map userRoomSubscriptions = new ConcurrentHashMap<>(); + + // Track winners who have already received balance updates (to avoid duplicates) + private final Map notifiedWinners = new ConcurrentHashMap<>(); // roomNumber -> winnerUserId + + /** + * Initializes the controller and sets up balance update callback. + */ + @PostConstruct + public void init() { + // Set callback for balance update notifications + gameRoomService.setBalanceUpdateCallback(this::notifyBalanceUpdate); + + // Set callback for state broadcast notifications (event-driven) + gameRoomService.setStateBroadcastCallback(this::broadcastRoomState); + } + + /** + * Notifies a user about balance update. + * Called by GameRoomService for single participant refunds (no spin, so immediate update is fine). + */ + private void notifyBalanceUpdate(Integer userId) { + String username = String.valueOf(userId); + sendBalanceUpdate(username, userId); + } + + /** + * Handles join round request from client. + */ + @MessageMapping("/game/join") + public void joinRound(@Valid @Payload JoinRoundRequest request, WebSocketAuthInterceptor.StompPrincipal principal) { + Integer userId = principal.getUserId(); + + // Additional validation beyond @Valid annotations + // @Valid handles null checks and basic constraints, but we add explicit checks for clarity + if (request == null) { + throw new GameException(localizationService.getMessage("game.error.invalidRequest")); + } + + // Validate room number range (1-3) + // This is also covered by @Min/@Max, but explicit check provides better error message + if (request.getRoomNumber() == null || request.getRoomNumber() < 1 || request.getRoomNumber() > 3) { + throw new GameException(localizationService.getMessage("game.error.roomNumberInvalid")); + } + + // Validate bet amount is positive (also covered by @Positive, but explicit for clarity) + if (request.getBetAmount() == null || request.getBetAmount() <= 0) { + throw new GameException(localizationService.getMessage("game.error.betMustBePositive")); + } + + try { + // Join the round + GameRoomStateDto state = gameRoomService.joinRound(userId, request.getRoomNumber(), request.getBetAmount()); + + // Track subscription + userRoomSubscriptions.put(userId, request.getRoomNumber()); + + // Send balance update to the user who joined + sendBalanceUpdate(principal.getName(), userId); + + // State is already broadcast by GameRoomService.joinRound() via callback (event-driven) + // No need to broadcast again here + + } catch (GameException e) { + // User-friendly error message + sendErrorToUser(principal.getName(), e.getUserMessage()); + } catch (Exception e) { + // Generic error - don't expose technical details + log.error("Unexpected error joining round for user {}", userId, e); + sendErrorToUser(principal.getName(), localizationService.getMessage("common.error.unknown")); + } + } + + /** + * Sends error message to user. + */ + private void sendErrorToUser(String username, String errorMessage) { + messagingTemplate.convertAndSendToUser( + username, + "/queue/errors", + Map.of("error", errorMessage) + ); + } + + /** + * Global exception handler for WebSocket messages. + */ + @MessageExceptionHandler + public void handleException(Exception ex, WebSocketAuthInterceptor.StompPrincipal principal) { + String userMessage; + + if (ex instanceof GameException) { + userMessage = ((GameException) ex).getUserMessage(); + } else if (ex instanceof ConstraintViolationException) { + // Handle validation errors from @Valid annotation + ConstraintViolationException cve = (ConstraintViolationException) ex; + userMessage = cve.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .findFirst() + .orElse("Validation failed. Please check your input."); + log.warn("Validation error for user {}: {}", principal.getUserId(), userMessage); + } else { + log.error("Unexpected WebSocket error", ex); + userMessage = localizationService.getMessage("common.error.unknown"); + } + + sendErrorToUser(principal.getName(), userMessage); + } + + /** + * Sends current room state when client subscribes. + * Note: SubscribeMapping doesn't support path variables well, so we'll handle subscription in joinRound + */ + + /** + * Broadcasts room state to all subscribers. + * Called by GameRoomService via callback (event-driven). + */ + public void broadcastRoomState(Integer roomNumber, GameRoomStateDto state) { + messagingTemplate.convertAndSend("/topic/room/" + roomNumber, state); + } + + /** + * Sends balance update to a specific user. + */ + private void sendBalanceUpdate(String username, Integer userId) { + try { + // Get current balance from database + Long balance = userService.getUserBalance(userId); + if (balance != null) { + BalanceUpdateDto balanceUpdate = BalanceUpdateDto.builder() + .balanceA(balance) + .build(); + messagingTemplate.convertAndSendToUser( + username, + "/queue/balance", + balanceUpdate + ); + } + } catch (Exception e) { + log.error("Failed to send balance update to user {}", userId, e); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/NotifyBroadcastController.java b/src/main/java/com/lottery/lottery/controller/NotifyBroadcastController.java new file mode 100644 index 0000000..931839a --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/NotifyBroadcastController.java @@ -0,0 +1,56 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.NotifyBroadcastRequest; +import com.lottery.lottery.service.NotificationBroadcastService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Public API to trigger or stop notification broadcast. Token in path; no secrets in codebase. + * Set APP_NOTIFY_BROADCAST_TOKEN on VPS. + */ +@Slf4j +@RestController +@RequestMapping("/api/notify_broadcast") +@RequiredArgsConstructor +public class NotifyBroadcastController { + + @Value("${app.notify-broadcast.token:}") + private String expectedToken; + + private final NotificationBroadcastService notificationBroadcastService; + + @PostMapping("/{token}") + public ResponseEntity start( + @PathVariable String token, + @RequestBody(required = false) NotifyBroadcastRequest body) { + if (expectedToken.isEmpty() || !expectedToken.equals(token)) { + log.warn("Notify broadcast rejected: invalid token"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest(); + notificationBroadcastService.runBroadcast( + req.getMessage(), + req.getImageUrl(), + req.getVideoUrl(), + req.getUserIdFrom(), + req.getUserIdTo(), + req.getButtonText(), + req.getIgnoreBlocked()); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/{token}/stop") + public ResponseEntity stop(@PathVariable String token) { + if (expectedToken.isEmpty() || !expectedToken.equals(token)) { + log.warn("Notify broadcast stop rejected: invalid token"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + notificationBroadcastService.requestStop(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/PaymentController.java b/src/main/java/com/lottery/lottery/controller/PaymentController.java new file mode 100644 index 0000000..ca5e496 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/PaymentController.java @@ -0,0 +1,237 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.CryptoWithdrawalResponse; +import com.lottery.lottery.dto.CreateCryptoWithdrawalRequest; +import com.lottery.lottery.dto.CreatePaymentRequest; +import com.lottery.lottery.dto.DepositAddressRequest; +import com.lottery.lottery.dto.DepositAddressResultDto; +import com.lottery.lottery.dto.DepositMethodsDto; +import com.lottery.lottery.dto.ErrorResponse; +import com.lottery.lottery.dto.PaymentInvoiceResponse; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.dto.WithdrawalMethodDetailsDto; +import com.lottery.lottery.dto.WithdrawalMethodsDto; +import com.lottery.lottery.service.CryptoDepositService; +import com.lottery.lottery.service.CryptoWithdrawalService; +import com.lottery.lottery.service.FeatureSwitchService; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.PaymentService; +import com.lottery.lottery.service.PayoutService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + private final CryptoDepositService cryptoDepositService; + private final CryptoWithdrawalService cryptoWithdrawalService; + private final PayoutService payoutService; + private final FeatureSwitchService featureSwitchService; + private final LocalizationService localizationService; + + /** + * Returns minimum deposit from DB only (no sync). Used by Store screen for validation. + */ + @GetMapping("/minimum-deposit") + public ResponseEntity getMinimumDeposit() { + if (!featureSwitchService.isPaymentEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable"))); + } + return ResponseEntity.ok(java.util.Map.of("minimumDeposit", cryptoDepositService.getMinimumDeposit())); + } + + /** + * Returns crypto deposit methods and minimum_deposit from DB only (sync is done every 10 min). + * Called when user opens Payment Options screen. + */ + @GetMapping("/deposit-methods") + public ResponseEntity getDepositMethods() { + if (!featureSwitchService.isPaymentEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable"))); + } + DepositMethodsDto dto = cryptoDepositService.getDepositMethodsFromDb(); + return ResponseEntity.ok(dto); + } + + /** + * Returns crypto withdrawal methods from DB only (sync is done every 30 min). + * Called when user opens Payout screen. + */ + @GetMapping("/withdrawal-methods") + public ResponseEntity getWithdrawalMethods() { + if (!featureSwitchService.isPayoutEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable"))); + } + WithdrawalMethodsDto dto = cryptoWithdrawalService.getWithdrawalMethodsFromDb(); + return ResponseEntity.ok(dto); + } + + /** + * Returns withdrawal method details (rate_usd, misha_fee_usd) from external API for the given pid. + * Called when user opens Payout Confirmation screen to show network fee and compute "You will receive". + */ + @GetMapping("/withdrawal-method-details") + public ResponseEntity getWithdrawalMethodDetails(@RequestParam("pid") int pid) { + if (!featureSwitchService.isPayoutEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable"))); + } + return cryptoWithdrawalService.getWithdrawalMethodDetails(pid) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** + * Creates a crypto withdrawal: calls external API, then on success creates payout and deducts balance. + * Uses in-memory lock to prevent double-submit. Validates deposit total and maxWinAfterDeposit. + */ + @PostMapping("/crypto-withdrawal") + public ResponseEntity createCryptoWithdrawal(@RequestBody CreateCryptoWithdrawalRequest request) { + if (!featureSwitchService.isPayoutEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable"))); + } + try { + UserA user = UserContext.get(); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required")); + } + Payout payout = payoutService.createCryptoPayout(user.getId(), request); + return ResponseEntity.ok(CryptoWithdrawalResponse.builder() + .id(payout.getId()) + .status(payout.getStatus().name()) + .build()); + } catch (IllegalArgumentException e) { + log.warn("Crypto withdrawal validation failed: {}", e.getMessage()); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } catch (IllegalStateException e) { + log.warn("Crypto withdrawal failed: {}", e.getMessage()); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + log.error("Crypto withdrawal error: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Withdrawal failed. Please try again later.")); + } + } + + /** + * Creates a payment invoice for the current user. + * Returns invoice data that frontend will use to open Telegram payment UI. + */ + @PostMapping("/create") + public ResponseEntity createPaymentInvoice(@RequestBody CreatePaymentRequest request) { + if (!featureSwitchService.isPaymentEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable"))); + } + try { + UserA user = UserContext.get(); + PaymentInvoiceResponse response = paymentService.createPaymentInvoice(user.getId(), request); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + log.warn("Payment invoice creation failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + log.error("Payment invoice creation error: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Failed to create payment invoice: " + e.getMessage())); + } + } + + + /** + * Gets a crypto deposit address from the external API (no payment record is created). + * Call when user selects a payment method on Payment Options screen. + * Returns address, amount_coins, name, network for the Payment Confirmation screen. + */ + @PostMapping("/deposit-address") + public ResponseEntity getDepositAddress(@RequestBody DepositAddressRequest request) { + if (!featureSwitchService.isPaymentEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable"))); + } + try { + UserA user = UserContext.get(); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required")); + } + DepositAddressResultDto result = paymentService.requestCryptoDepositAddress( + user.getId(), request.getPid(), request.getUsdAmount()); + return ResponseEntity.ok(result); + } catch (IllegalArgumentException e) { + log.warn("Deposit address request failed: {}", e.getMessage()); + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + log.error("Deposit address error: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(e.getMessage() != null ? e.getMessage() : "Failed to get deposit address")); + } + } + + /** + * Cancels a payment (e.g., when user cancels in Telegram UI). + */ + @PostMapping("/cancel") + public ResponseEntity cancelPayment(@RequestBody CancelPaymentRequest request) { + try { + String orderId = request.getOrderId(); + UserA caller = UserContext.get(); + log.info("Payment cancel requested: orderId={}, callerUserId={}", orderId, caller != null ? caller.getId() : null); + paymentService.cancelPayment(orderId); + return ResponseEntity.ok().body(new PaymentWebhookResponse(true, "Payment cancelled")); + } catch (IllegalArgumentException e) { + log.warn("Payment cancellation failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + log.error("Payment cancellation error: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Failed to cancel payment: " + e.getMessage())); + } + } + + // Response DTOs + private static class PaymentWebhookResponse { + private final boolean success; + private final String message; + + public PaymentWebhookResponse(boolean success, String message) { + this.success = success; + this.message = message; + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + } + + private static class CancelPaymentRequest { + private String orderId; + + public String getOrderId() { + return orderId; + } + + public void setOrderId(String orderId) { + this.orderId = orderId; + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/PayoutController.java b/src/main/java/com/lottery/lottery/controller/PayoutController.java new file mode 100644 index 0000000..c57c89e --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/PayoutController.java @@ -0,0 +1,66 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.CreatePayoutRequest; +import com.lottery.lottery.dto.ErrorResponse; +import com.lottery.lottery.dto.PayoutHistoryEntryDto; +import com.lottery.lottery.dto.PayoutResponse; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.PayoutService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/payouts") +@RequiredArgsConstructor +public class PayoutController { + + private final PayoutService payoutService; + + /** + * Creates a payout request for the current user. + * Validates input and deducts balance if validation passes. + */ + @PostMapping + public ResponseEntity createPayout(@RequestBody CreatePayoutRequest request) { + try { + UserA user = UserContext.get(); + Payout payout = payoutService.createPayout(user.getId(), request); + PayoutResponse response = payoutService.toResponse(payout); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + log.warn("Payout validation failed: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(new ErrorResponse(e.getMessage())); + } catch (IllegalStateException e) { + log.error("Payout creation failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(e.getMessage())); + } + } + + + /** + * Gets the last 20 payout history entries for the current user. + * + * @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC. + */ + @GetMapping("/history") + public List getUserPayoutHistory( + @RequestParam(required = false) String timezone) { + UserA user = UserContext.get(); + String languageCode = user.getLanguageCode(); + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + return payoutService.getUserPayoutHistory(user.getId(), timezone, languageCode); + } +} + diff --git a/src/main/java/com/honey/honey/controller/PingController.java b/src/main/java/com/lottery/lottery/controller/PingController.java similarity index 90% rename from src/main/java/com/honey/honey/controller/PingController.java rename to src/main/java/com/lottery/lottery/controller/PingController.java index 7094247..dbb9cf1 100644 --- a/src/main/java/com/honey/honey/controller/PingController.java +++ b/src/main/java/com/lottery/lottery/controller/PingController.java @@ -1,4 +1,4 @@ -package com.honey.honey.controller; +package com.lottery.lottery.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,3 +17,4 @@ public class PingController { } } + diff --git a/src/main/java/com/lottery/lottery/controller/PromotionController.java b/src/main/java/com/lottery/lottery/controller/PromotionController.java new file mode 100644 index 0000000..f92f75c --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/PromotionController.java @@ -0,0 +1,43 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.PromotionDetailDto; +import com.lottery.lottery.dto.PromotionListItemDto; +import com.lottery.lottery.service.FeatureSwitchService; +import com.lottery.lottery.service.PublicPromotionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Public API for the lottery app: list and view promotion details (leaderboard, user progress). + * Excludes INACTIVE promotions. Requires Bearer auth (app user). + * When promotions feature switch is false, all endpoints return 404. + */ +@RestController +@RequestMapping("/api/promotions") +@RequiredArgsConstructor +public class PromotionController { + + private final PublicPromotionService publicPromotionService; + private final FeatureSwitchService featureSwitchService; + + @GetMapping + public ResponseEntity> list() { + if (!featureSwitchService.isPromotionsEnabled()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(publicPromotionService.listForApp()); + } + + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable int id) { + if (!featureSwitchService.isPromotionsEnabled()) { + return ResponseEntity.notFound().build(); + } + return publicPromotionService.getDetailForApp(id) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/com/lottery/lottery/controller/QuickAnswerController.java b/src/main/java/com/lottery/lottery/controller/QuickAnswerController.java new file mode 100644 index 0000000..b1ba87a --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/QuickAnswerController.java @@ -0,0 +1,134 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.QuickAnswerCreateRequest; +import com.lottery.lottery.dto.QuickAnswerDto; +import com.lottery.lottery.model.Admin; +import com.lottery.lottery.model.QuickAnswer; +import com.lottery.lottery.repository.AdminRepository; +import com.lottery.lottery.repository.QuickAnswerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin/quick-answers") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')") +public class QuickAnswerController { + + private final QuickAnswerRepository quickAnswerRepository; + private final AdminRepository adminRepository; + + /** + * Get current admin from authentication context + */ + private Admin getCurrentAdmin() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + return adminRepository.findByUsername(username) + .orElseThrow(() -> new RuntimeException("Admin not found: " + username)); + } + + /** + * Get all quick answers for the current admin + */ + @GetMapping + public ResponseEntity> getQuickAnswers() { + Admin admin = getCurrentAdmin(); + List quickAnswers = quickAnswerRepository.findByAdminIdOrderByCreatedAtDesc(admin.getId()); + List dtos = quickAnswers.stream() + .map(qa -> new QuickAnswerDto( + qa.getId(), + qa.getText(), + qa.getCreatedAt(), + qa.getUpdatedAt() + )) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + /** + * Create a new quick answer for the current admin + */ + @PostMapping + public ResponseEntity createQuickAnswer(@Valid @RequestBody QuickAnswerCreateRequest request) { + Admin admin = getCurrentAdmin(); + + if (request.getText() == null || request.getText().trim().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + QuickAnswer quickAnswer = QuickAnswer.builder() + .admin(admin) + .text(request.getText().trim()) + .build(); + + QuickAnswer saved = quickAnswerRepository.save(quickAnswer); + QuickAnswerDto dto = new QuickAnswerDto( + saved.getId(), + saved.getText(), + saved.getCreatedAt(), + saved.getUpdatedAt() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + /** + * Update a quick answer (only if it belongs to the current admin) + */ + @PutMapping("/{id}") + public ResponseEntity updateQuickAnswer( + @PathVariable Integer id, + @Valid @RequestBody QuickAnswerCreateRequest request) { + Admin admin = getCurrentAdmin(); + QuickAnswer quickAnswer = quickAnswerRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Quick answer not found")); + + // Verify that the quick answer belongs to the current admin + if (!quickAnswer.getAdmin().getId().equals(admin.getId())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + if (request.getText() == null || request.getText().trim().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + quickAnswer.setText(request.getText().trim()); + QuickAnswer saved = quickAnswerRepository.save(quickAnswer); + + QuickAnswerDto dto = new QuickAnswerDto( + saved.getId(), + saved.getText(), + saved.getCreatedAt(), + saved.getUpdatedAt() + ); + return ResponseEntity.ok(dto); + } + + /** + * Delete a quick answer (only if it belongs to the current admin) + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteQuickAnswer(@PathVariable Integer id) { + Admin admin = getCurrentAdmin(); + QuickAnswer quickAnswer = quickAnswerRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Quick answer not found")); + + // Verify that the quick answer belongs to the current admin + if (!quickAnswer.getAdmin().getId().equals(admin.getId())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + quickAnswerRepository.delete(quickAnswer); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/RemoteBetController.java b/src/main/java/com/lottery/lottery/controller/RemoteBetController.java new file mode 100644 index 0000000..78a9272 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/RemoteBetController.java @@ -0,0 +1,191 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.exception.GameException; +import com.lottery.lottery.service.FeatureSwitchService; +import com.lottery.lottery.service.GameRoomService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Unauthenticated API for 3rd party to register a user into a round (remote bet). + * Protected by a shared token in the path and a runtime feature switch. + * Same business logic as in-app join (balance, commissions, transactions, visibility in app). + */ +@Slf4j +@RestController +@RequestMapping("/api/remotebet") +@RequiredArgsConstructor +public class RemoteBetController { + + private static final long TICKETS_TO_BIGINT = 1_000_000L; + + @Value("${app.remote-bet.token:}") + private String configuredToken; + + private final FeatureSwitchService featureSwitchService; + private final GameRoomService gameRoomService; + + /** + * Registers the user to the current round in the given room with the given bet. + * GET /api/remotebet/{token}?user_id=228&room=2&amount=5&unique=false + * Or with random range: at least one of rand_min or rand_max (amount ignored). + * - user_id: db_users_a.id + * - room: room number (1, 2, or 3) + * - amount: bet in tickets. Ignored when rand_min and/or rand_max are provided. + * - unique: optional. If true, user can only have one bet per room per round (repeated calls no-op). + * - rand_min: optional. If only rand_min: random between rand_min and room max. If both: random between rand_min and rand_max. + * - rand_max: optional. If only rand_max: random between room min and rand_max. If both: random between rand_min and rand_max. + * Params are validated against room min/max (rand_min >= room min, rand_max <= room max; when both, rand_min <= rand_max). + */ + @GetMapping("/{token}") + public ResponseEntity remoteBet( + @PathVariable String token, + @RequestParam(name = "user_id") Integer userId, + @RequestParam(name = "room") Integer room, + @RequestParam(name = "amount") Integer amountTickets, + @RequestParam(name = "unique", required = false) Boolean unique, + @RequestParam(name = "rand_min", required = false) Integer randMin, + @RequestParam(name = "rand_max", required = false) Integer randMax) { + + if (configuredToken == null || configuredToken.isEmpty() || !configuredToken.equals(token)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + if (!featureSwitchService.isRemoteBetEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + + boolean useRandomRange = randMin != null || randMax != null; + long betAmount; + int amountTicketsForLog; + + if (useRandomRange) { + GameRoomService.BetLimits limits = GameRoomService.getBetLimitsForRoom(room); + long roomMinTickets = limits.minBet() / TICKETS_TO_BIGINT; + long roomMaxTickets = limits.maxBet() / TICKETS_TO_BIGINT; + + long effectiveMinTickets; + long effectiveMaxTickets; + + if (randMin != null && randMax != null) { + if (randMin < roomMinTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)")); + } + if (randMax > roomMaxTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)")); + } + if (randMin > randMax) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_min must be less than or equal to rand_max")); + } + effectiveMinTickets = randMin; + effectiveMaxTickets = randMax; + } else if (randMin != null) { + if (randMin < roomMinTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)")); + } + if (randMin > roomMaxTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_min must not be higher than room max bet (" + roomMaxTickets + " tickets)")); + } + effectiveMinTickets = randMin; + effectiveMaxTickets = roomMaxTickets; + } else { + if (randMax < roomMinTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_max must not be lower than room min bet (" + roomMinTickets + " tickets)")); + } + if (randMax > roomMaxTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)")); + } + effectiveMinTickets = roomMinTickets; + effectiveMaxTickets = randMax; + } + + long currentUserBetBigint = gameRoomService.getCurrentUserBetInRoom(userId, room); + long maxAdditionalBigint = Math.max(0L, limits.maxBet() - currentUserBetBigint); + long maxAdditionalTickets = maxAdditionalBigint / TICKETS_TO_BIGINT; + if (maxAdditionalTickets < limits.minBet() / TICKETS_TO_BIGINT) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "Max bet for this room already reached")); + } + + effectiveMaxTickets = Math.min(effectiveMaxTickets, maxAdditionalTickets); + if (effectiveMinTickets > effectiveMaxTickets) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "Random range exceeds remaining bet capacity for this room")); + } + + // Room 1: any integer; Room 2: divisible by 10; Room 3: divisible by 100 + long step = room == 2 ? 10L : (room == 3 ? 100L : 1L); + long minAligned = roundUpToMultiple(effectiveMinTickets, step); + long maxAligned = roundDownToMultiple(effectiveMaxTickets, step); + if (minAligned > maxAligned) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0, + "No valid random value in range for room " + room + " (room 2 must be multiple of 10, room 3 multiple of 100)")); + } + long randomTickets = minAligned >= maxAligned + ? minAligned + : minAligned + step * ThreadLocalRandom.current().nextLong(0, (maxAligned - minAligned) / step + 1); + betAmount = randomTickets * TICKETS_TO_BIGINT; + amountTicketsForLog = (int) randomTickets; + } else { + betAmount = (long) amountTickets * TICKETS_TO_BIGINT; + amountTicketsForLog = amountTickets; + } + + boolean uniqueBet = Boolean.TRUE.equals(unique); + try { + var result = gameRoomService.joinRoundWithResult(userId, room, betAmount, uniqueBet); + var state = result.getState(); + Long roundId = state.getRoundId(); + int betTicketsForResponse = result.getBetTicketsForResponse(); + String randRangeLog = useRandomRange ? (randMin != null ? randMin : "roomMin") + "-" + (randMax != null ? randMax : "roomMax") : "no"; + log.info("Remote bet: user connected to round remotely, userId={}, roundId={}, roomId={}, betTickets={}, unique={}, randRange={}", + userId, roundId, room, betTicketsForResponse, uniqueBet, randRangeLog); + return ResponseEntity.ok(new RemoteBetResponse(true, roundId != null ? roundId.intValue() : null, room, betTicketsForResponse)); + } catch (GameException e) { + return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, amountTicketsForLog, e.getUserMessage())); + } catch (Exception e) { + log.warn("Remote bet failed for userId={}, room={}, amount={}", userId, room, amountTicketsForLog, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new RemoteBetResponse(false, null, room, amountTicketsForLog, "Internal error")); + } + } + + @lombok.Data + @lombok.AllArgsConstructor + @lombok.NoArgsConstructor + public static class RemoteBetResponse { + private boolean success; + private Integer roundId; + private Integer room; + private Integer betTickets; + private String error; + + public RemoteBetResponse(boolean success, Integer roundId, Integer room, Integer betTickets) { + this(success, roundId, room, betTickets, null); + } + } + + /** Round value up to next multiple of step (e.g. 23, 10 -> 30). */ + private static long roundUpToMultiple(long value, long step) { + if (step <= 0) return value; + return ((value + step - 1) / step) * step; + } + + /** Round value down to previous multiple of step (e.g. 197, 10 -> 190). */ + private static long roundDownToMultiple(long value, long step) { + if (step <= 0) return value; + return (value / step) * step; + } +} diff --git a/src/main/java/com/lottery/lottery/controller/SupportController.java b/src/main/java/com/lottery/lottery/controller/SupportController.java new file mode 100644 index 0000000..636225b --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/SupportController.java @@ -0,0 +1,104 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.SupportTicketService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/support") +@RequiredArgsConstructor +public class SupportController { + + private final SupportTicketService supportTicketService; + + /** + * Creates a new support ticket with the first message. + */ + @PostMapping("/tickets") + public ResponseEntity createTicket( + @Valid @RequestBody CreateTicketRequest request) { + + UserA user = UserContext.get(); + TicketDto ticket = supportTicketService.createTicket( + user.getId(), + request + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(ticket); + } + + /** + * Gets ticket history for the authenticated user (last 20 tickets). + */ + @GetMapping("/tickets") + public ResponseEntity> getTicketHistory() { + + UserA user = UserContext.get(); + List tickets = supportTicketService.getTicketHistory( + user.getId() + ); + + return ResponseEntity.ok(tickets); + } + + /** + * Gets ticket details with all messages. + */ + @GetMapping("/tickets/{ticketId}") + public ResponseEntity getTicketDetail( + @PathVariable Long ticketId) { + + UserA user = UserContext.get(); + TicketDetailDto ticket = supportTicketService.getTicketDetail( + user.getId(), + ticketId + ); + + return ResponseEntity.ok(ticket); + } + + /** + * Adds a message to an existing ticket. + */ + @PostMapping("/tickets/{ticketId}/messages") + public ResponseEntity addMessage( + @PathVariable Long ticketId, + @Valid @RequestBody CreateMessageRequest request) { + + UserA user = UserContext.get(); + MessageDto message = supportTicketService.addMessage( + user.getId(), + ticketId, + request + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(message); + } + + /** + * Closes a ticket. + */ + @PostMapping("/tickets/{ticketId}/close") + public ResponseEntity closeTicket( + @PathVariable Long ticketId) { + + UserA user = UserContext.get(); + supportTicketService.closeTicket( + user.getId(), + ticketId + ); + + return ResponseEntity.ok().build(); + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/TaskController.java b/src/main/java/com/lottery/lottery/controller/TaskController.java new file mode 100644 index 0000000..651928c --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/TaskController.java @@ -0,0 +1,100 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.ClaimTaskResponse; +import com.lottery.lottery.dto.DailyBonusStatusDto; +import com.lottery.lottery.dto.RecentBonusClaimDto; +import com.lottery.lottery.dto.TaskDto; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.TaskService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/tasks") +@RequiredArgsConstructor +public class TaskController { + + private final TaskService taskService; + private final LocalizationService localizationService; + + /** + * Gets all tasks for a specific type (referral, follow, other). + * Includes user progress and claim status. + */ + @GetMapping + public List getTasks(@RequestParam String type) { + UserA user = UserContext.get(); + String languageCode = user.getLanguageCode(); + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + return taskService.getTasksByType(user.getId(), type, languageCode); + } + + /** + * Gets daily bonus status for the current user. + * Returns availability status and cooldown time if on cooldown. + */ + @GetMapping("/daily-bonus") + public ResponseEntity getDailyBonusStatus() { + UserA user = UserContext.get(); + DailyBonusStatusDto status = taskService.getDailyBonusStatus(user.getId()); + return ResponseEntity.ok(status); + } + + /** + * Gets the 50 most recent daily bonus claims. + * Returns claims ordered by claimed_at DESC (most recent first). + * Includes user avatar URL, screen name, and formatted claim timestamp with timezone and localized "at" word. + * + * @param timezone Optional timezone (e.g., "Europe/Kiev"). If not provided, uses UTC. + */ + @GetMapping("/daily-bonus/recent-claims") + public ResponseEntity> getRecentDailyBonusClaims( + @RequestParam(required = false) String timezone) { + UserA user = UserContext.get(); + String languageCode = user.getLanguageCode(); + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + List claims = taskService.getRecentDailyBonusClaims(timezone, languageCode); + return ResponseEntity.ok(claims); + } + + /** + * Claims a task for the current user. + * Checks if task is completed and gives reward if applicable. + * Returns 200 with success status and message. + */ + @PostMapping("/claim") + public ResponseEntity claimTask(@RequestBody ClaimTaskRequest request) { + UserA user = UserContext.get(); + boolean claimed = taskService.claimTask(user.getId(), request.getTaskId()); + + if (claimed) { + return ResponseEntity.ok(ClaimTaskResponse.builder() + .success(true) + .message(localizationService.getMessage("task.message.claimed")) + .build()); + } else { + return ResponseEntity.ok(ClaimTaskResponse.builder() + .success(false) + .message(localizationService.getMessage("task.message.notCompleted")) + .build()); + } + } + + @Data + public static class ClaimTaskRequest { + private Integer taskId; + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/TelegramWebhookController.java b/src/main/java/com/lottery/lottery/controller/TelegramWebhookController.java new file mode 100644 index 0000000..f0e32b6 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/TelegramWebhookController.java @@ -0,0 +1,868 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.dto.TelegramApiResponse; +import com.lottery.lottery.dto.PaymentWebhookRequest; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.service.PaymentService; +import com.lottery.lottery.service.TelegramBotApiService; +import com.lottery.lottery.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.HttpClientErrorException; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery; +import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow; +import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.config.LocaleConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import java.util.List; +import java.util.ArrayList; +import java.util.Locale; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.StreamUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.core.io.ByteArrayResource; + +/** + * Webhook controller for receiving Telegram updates directly. + * Path: POST /api/telegram/webhook/{token}. Token must match APP_TELEGRAM_WEBHOOK_TOKEN. + */ +@Slf4j +@RestController +@RequestMapping("/api/telegram/webhook") +@RequiredArgsConstructor +public class TelegramWebhookController { + + @Value("${app.telegram-webhook.token:}") + private String expectedWebhookToken; + + private final UserService userService; + private final PaymentService paymentService; + private final TelegramProperties telegramProperties; + private final LocalizationService localizationService; + private final TelegramBotApiService telegramBotApiService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Webhook endpoint for receiving updates from Telegram. + * Path token must match app.telegram-webhook.token (APP_TELEGRAM_WEBHOOK_TOKEN). + */ + @PostMapping("/{token}") + public ResponseEntity handleWebhook(@PathVariable String token, @RequestBody Update update, HttpServletRequest httpRequest) { + if (expectedWebhookToken.isEmpty() || !expectedWebhookToken.equals(token)) { + log.warn("Webhook rejected: invalid or missing token (possible misconfiguration or wrong URL); update dropped"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + // Handle callback queries (button clicks) + if (update.hasCallbackQuery()) { + handleCallbackQuery(update.getCallbackQuery()); + } + + // Handle message updates (e.g., /start command or Reply Keyboard button clicks) + if (update.hasMessage() && update.getMessage().hasText()) { + handleMessage(update.getMessage(), httpRequest); + } + + // Handle pre-checkout query (before payment confirmation) + if (update.hasPreCheckoutQuery()) { + handlePreCheckoutQuery(update.getPreCheckoutQuery()); + } + + // Handle successful payment + if (update.hasMessage() && update.getMessage().hasSuccessfulPayment()) { + handleSuccessfulPayment(update.getMessage().getSuccessfulPayment(), update.getMessage().getFrom().getId()); + } + + return ResponseEntity.ok().build(); + } catch (Exception e) { + log.error("Error processing Telegram webhook: {}", e.getMessage(), e); + if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().startsWith("/start")) { + Long telegramId = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getId() : null; + log.warn("Registration attempt failed (webhook error), update dropped: telegramId={}", telegramId); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * Handles /start command with optional referral parameter, and Reply Keyboard button clicks. + * Format: /start or /start 123 (where 123 is the referral user ID) + */ + private void handleMessage(Message message, HttpServletRequest httpRequest) { + String messageText = message.getText(); + if (messageText == null) { + return; + } + + User telegramUser = message.getFrom(); + Long telegramId = telegramUser.getId(); + Long chatId = message.getChatId(); + + // Handle /start command + if (messageText.startsWith("/start")) { + handleStartCommand(message, httpRequest, telegramUser, telegramId); + return; + } + + // Handle Reply Keyboard button clicks + // Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN + String languageCode = "EN"; // Default + try { + var userOpt = userService.getUserByTelegramId(telegramId); + if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) { + languageCode = userOpt.get().getLanguageCode(); + } else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } catch (Exception e) { + log.warn("Could not get user language, using default: {}", e.getMessage()); + // Fallback to Telegram user's language_code if available + if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + + // Check if message matches Reply Keyboard button text + String startSpinningText = "🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning"); + String usersPayoutsText = "💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts"); + String infoChannelText = "ℹ️ " + localizationService.getMessage(locale, "bot.button.infoChannel"); + + if (messageText.equals(startSpinningText)) { + sendStartSpinningMessage(chatId, locale); + } else if (messageText.equals(usersPayoutsText)) { + sendUsersPayoutsMessage(chatId, locale); + } else if (messageText.equals(infoChannelText)) { + sendInfoChannelMessage(chatId, locale); + } else { + // Unknown message (e.g. old "Start Spinning" button or free text): reply and refresh keyboard + sendUnrecognizedMessageAndUpdateKeyboard(chatId, locale); + } + } + + /** + * Handles /start command with optional referral parameter. + * Format: /start or /start 123 (where 123 is the referral user ID) + */ + private void handleStartCommand(Message message, HttpServletRequest httpRequest, User telegramUser, Long telegramId) { + String messageText = message.getText(); + + log.debug("Received /start command: telegramId={}", telegramId); + + Integer referralUserId = null; + + // Parse referral parameter from /start command + // Format: /start or /start 123 + String[] parts = messageText.split("\\s+", 2); + if (parts.length > 1 && !parts[1].trim().isEmpty()) { + try { + referralUserId = Integer.parseInt(parts[1].trim()); + log.debug("Parsed referral ID: {}", referralUserId); + } catch (NumberFormatException e) { + log.warn("Invalid referral parameter format: '{}'", parts[1]); + return; + } + } + + // Check if user already exists + boolean isNewUser = userService.getUserByTelegramId(telegramId).isEmpty(); + if (isNewUser) { + log.info("New user registration via bot - telegramId={}, referralUserId={}", + telegramId, referralUserId); + } + + // Build tgUserData map similar to what TelegramAuthService.parseInitData returns + Map tgUser = new HashMap<>(); + tgUser.put("id", telegramId); + tgUser.put("first_name", telegramUser.getFirstName()); + tgUser.put("last_name", telegramUser.getLastName()); + tgUser.put("username", telegramUser.getUserName()); + tgUser.put("is_premium", telegramUser.getIsPremium() != null && telegramUser.getIsPremium()); + tgUser.put("language_code", telegramUser.getLanguageCode()); + // Note: Telegram Bot API User object doesn't have photo_url (only available in WebApp initData) + // AvatarService will fetch avatar from Bot API first, photo_url is only used as fallback + tgUser.put("photo_url", null); + + Map tgUserData = new HashMap<>(); + tgUserData.put("user", tgUser); + + // Convert referralUserId to start parameter string (as expected by UserService) + String start = referralUserId != null ? String.valueOf(referralUserId) : null; + tgUserData.put("start", start); + + try { + // Get or create user (handles registration, login update, and referral system) + UserA user = userService.getOrCreateUser(tgUserData, httpRequest); + log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}", + user.getId(), user.getTelegramId(), isNewUser); + + // Send welcome message with buttons + // Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN + String languageCode = "EN"; // Default + if (user.getLanguageCode() != null && !user.getLanguageCode().isEmpty()) { + languageCode = user.getLanguageCode(); + } else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + sendWelcomeMessage(telegramId, languageCode); + } catch (Exception e) { + log.warn("Registration failed for telegramId={}, user may not receive welcome message: {}", telegramId, e.getMessage()); + log.error("Error registering user via bot: telegramId={}", telegramId, e); + } + } + + /** + * Handles callback queries from inline keyboard buttons. + */ + private void handleCallbackQuery(CallbackQuery callbackQuery) { + String data = callbackQuery.getData(); + Long telegramUserId = callbackQuery.getFrom().getId(); + Long chatId = callbackQuery.getMessage().getChatId(); + + log.debug("Received callback query: data={}, telegramUserId={}", data, telegramUserId); + + // Get user's language for localization + // Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN + User telegramUser = callbackQuery.getFrom(); + String languageCode = "EN"; // Default + try { + var userOpt = userService.getUserByTelegramId(telegramUserId); + if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) { + languageCode = userOpt.get().getLanguageCode(); + } else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } catch (Exception e) { + log.warn("Could not get user language, using default: {}", e.getMessage()); + // Fallback to Telegram user's language_code if available + if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } + + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + + try { + switch (data) { + case "start_spinning": + sendStartSpinningMessage(chatId, locale); + answerCallbackQuery(callbackQuery.getId(), null); + break; + case "users_payouts": + sendUsersPayoutsMessage(chatId, locale); + answerCallbackQuery(callbackQuery.getId(), null); + break; + case "info_channel": + sendInfoChannelMessage(chatId, locale); + answerCallbackQuery(callbackQuery.getId(), null); + break; + default: + log.warn("Unknown callback data: {}", data); + answerCallbackQuery(callbackQuery.getId(), "Unknown action"); + } + } catch (Exception e) { + log.error("Error handling callback query: data={}", data, e); + answerCallbackQuery(callbackQuery.getId(), "Error processing request"); + } + } + + /** + * Builds the current Reply Keyboard (Start Game, Users payouts, Info channel) for the given locale. + */ + private ReplyKeyboardMarkup buildReplyKeyboard(Locale locale) { + ReplyKeyboardMarkup replyKeyboard = new ReplyKeyboardMarkup(); + replyKeyboard.setResizeKeyboard(true); + replyKeyboard.setOneTimeKeyboard(false); + replyKeyboard.setSelective(false); + List keyboardRows = new ArrayList<>(); + KeyboardRow row1 = new KeyboardRow(); + KeyboardButton startButton = new KeyboardButton(); + startButton.setText("🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning")); + row1.add(startButton); + keyboardRows.add(row1); + KeyboardRow row2 = new KeyboardRow(); + KeyboardButton payoutsButton = new KeyboardButton(); + payoutsButton.setText("💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts")); + row2.add(payoutsButton); + KeyboardButton infoButton = new KeyboardButton(); + infoButton.setText("ℹ️ " + localizationService.getMessage(locale, "bot.button.infoChannel")); + row2.add(infoButton); + keyboardRows.add(row2); + replyKeyboard.setKeyboard(keyboardRows); + return replyKeyboard; + } + + /** + * Sends welcome messages: first message with reply keyboard, second message with inline button. + */ + private void sendWelcomeMessage(Long chatId, String languageCode) { + if (telegramProperties.getBotToken() == null || telegramProperties.getBotToken().isEmpty()) { + log.warn("Bot token not configured; welcome message not sent for chatId={} (registration flow affected)", chatId); + return; + } + Locale locale = LocaleConfig.languageCodeToLocale(languageCode != null ? languageCode : "EN"); + ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale); + // Create inline keyboard with only START SPINNING button + InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup(); + List> inlineRows = new ArrayList<>(); + List inlineRow = new ArrayList<>(); + + InlineKeyboardButton startInlineButton = new InlineKeyboardButton(); + // Add arrows on both sides like in the reference app (right arrow on left, left arrow on right) + String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline"); + startInlineButton.setText(startSpinningButtonText); + // Use WebAppInfo to open mini app instead of regular URL + WebAppInfo webAppInfo = new WebAppInfo(); + webAppInfo.setUrl("https://win-spin.live/auth"); + startInlineButton.setWebApp(webAppInfo); + inlineRow.add(startInlineButton); + inlineRows.add(inlineRow); + + inlineKeyboard.setKeyboard(inlineRows); + + // Send first message with GIF animation and reply keyboard + // Note: Telegram doesn't allow both inline and reply keyboards in the same message + String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage"); + sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard); + + // Send second message with inline button (START SPINNING) + String welcomeText = localizationService.getMessage(locale, "bot.welcome.message"); + sendMessage(chatId, welcomeText, inlineKeyboard); + } + + /** + * Sends message with Start Spinning button. + */ + private void sendStartSpinningMessage(Long chatId, Locale locale) { + String message = localizationService.getMessage(locale, "bot.message.startSpinning"); + + InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup(); + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + + InlineKeyboardButton button = new InlineKeyboardButton(); + // Add arrows on both sides like in the reference app (right arrow on left, left arrow on right) + String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline"); + button.setText(startSpinningButtonText); + // Use WebAppInfo to open mini app instead of regular URL + WebAppInfo webAppInfo = new WebAppInfo(); + webAppInfo.setUrl("https://win-spin.live/auth"); + button.setWebApp(webAppInfo); + row.add(button); + rows.add(row); + + keyboard.setKeyboard(rows); + + sendMessage(chatId, message, keyboard); + } + + /** + * Sends a friendly "unrecognized message" reply and updates the user's reply keyboard to the current one. + * Used when the user sends unknown text (e.g. old "Start Spinning" button) so they get the new keyboard. + */ + private void sendUnrecognizedMessageAndUpdateKeyboard(Long chatId, Locale locale) { + String message = localizationService.getMessage(locale, "bot.message.unrecognized"); + ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale); + sendMessageWithReplyKeyboard(chatId, message, replyKeyboard); + } + + /** + * Sends message with Users payouts button. + */ + private void sendUsersPayoutsMessage(Long chatId, Locale locale) { + String message = localizationService.getMessage(locale, "bot.message.usersPayouts"); + + InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup(); + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + + InlineKeyboardButton button = new InlineKeyboardButton(); + button.setText(localizationService.getMessage(locale, "bot.button.openChannel")); + button.setUrl("https://t.me/win_spin_withdrawals"); + row.add(button); + rows.add(row); + + keyboard.setKeyboard(rows); + + sendMessage(chatId, message, keyboard); + } + + /** + * Handles /paysupport command. + */ + private void handlePaySupportCommand(Long chatId, User telegramUser, Long telegramId) { + // Get user's language for localization + // Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN + String languageCode = "EN"; // Default + try { + var userOpt = userService.getUserByTelegramId(telegramId); + if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) { + languageCode = userOpt.get().getLanguageCode(); + } else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } catch (Exception e) { + log.warn("Could not get user language for /paysupport, using default: {}", e.getMessage()); + // Fallback to Telegram user's language_code if available + if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) { + languageCode = telegramUser.getLanguageCode(); + } + } + + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + String message = localizationService.getMessage(locale, "bot.message.paySupport"); + sendMessage(chatId, message, null); + } + + /** + * Sends message with Info channel button. + */ + private void sendInfoChannelMessage(Long chatId, Locale locale) { + String message = localizationService.getMessage(locale, "bot.message.infoChannel"); + + InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup(); + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + + InlineKeyboardButton button = new InlineKeyboardButton(); + button.setText(localizationService.getMessage(locale, "bot.button.goToChannel")); + button.setUrl("https://t.me/win_spin_news"); + row.add(button); + rows.add(row); + + keyboard.setKeyboard(rows); + + sendMessage(chatId, message, keyboard); + } + + /** + * Sends a message to a chat with inline keyboard. + */ + private void sendMessage(Long chatId, String text, InlineKeyboardMarkup keyboard) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/sendMessage"; + + Map requestBody = new HashMap<>(); + requestBody.put("chat_id", chatId); + requestBody.put("text", text); + if (keyboard != null) { + // Convert InlineKeyboardMarkup to Map for JSON serialization + try { + String keyboardJson = objectMapper.writeValueAsString(keyboard); + Map keyboardMap = objectMapper.readValue(keyboardJson, Map.class); + requestBody.put("reply_markup", keyboardMap); + } catch (Exception e) { + log.error("Error serializing keyboard: {}", e.getMessage(), e); + return; + } + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null) { + if (!Boolean.TRUE.equals(response.getBody().getOk())) { + log.warn("Failed to send message: chatId={}, error={}", + chatId, response.getBody().getDescription()); + } + } else if (response != null && !response.getStatusCode().is2xxSuccessful()) { + log.error("Failed to send message: chatId={}, status={}", chatId, response.getStatusCode()); + } else if (response == null) { + log.warn("Message not sent (Telegram 429, retry scheduled): chatId={} – may affect registration welcome", chatId); + } + } catch (Exception e) { + if (isTelegramUserUnavailable(e)) { + log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Error sending message: chatId={}", chatId, e); + } + } + } + + /** + * Returns true if the failure is due to user blocking the bot or chat being unavailable. + * These are expected and should be logged at WARN without stack trace. + */ + private boolean isTelegramUserUnavailable(Throwable t) { + if (t instanceof HttpClientErrorException e) { + if (e.getStatusCode().value() == 403) { + return true; + } + String body = e.getResponseBodyAsString(); + return body != null && ( + body.contains("blocked by the user") || + body.contains("user is deactivated") || + body.contains("chat not found") + ); + } + return false; + } + + private boolean isTelegramUserUnavailableDescription(String description) { + return description != null && ( + description.contains("blocked by the user") || + description.contains("user is deactivated") || + description.contains("chat not found") + ); + } + + /** + * Sends a message with text and reply keyboard. + */ + private void sendMessageWithReplyKeyboard(Long chatId, String text, ReplyKeyboardMarkup replyKeyboard) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/sendMessage"; + + Map requestBody = new HashMap<>(); + requestBody.put("chat_id", chatId); + requestBody.put("text", text); + + try { + String keyboardJson = objectMapper.writeValueAsString(replyKeyboard); + Map keyboardMap = objectMapper.readValue(keyboardJson, Map.class); + requestBody.put("reply_markup", keyboardMap); + } catch (Exception e) { + log.error("Error serializing reply keyboard: {}", e.getMessage(), e); + return; + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null) { + if (!Boolean.TRUE.equals(response.getBody().getOk())) { + String desc = response.getBody().getDescription(); + if (isTelegramUserUnavailableDescription(desc)) { + log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Failed to send message with reply keyboard: chatId={}, error={}", chatId, desc); + } + } else { + log.info("Message with reply keyboard sent successfully: chatId={}", chatId); + } + } else if (response != null && !response.getStatusCode().is2xxSuccessful()) { + log.error("Failed to send message with reply keyboard: chatId={}, status={}", + chatId, response.getStatusCode()); + } + } catch (Exception e) { + if (isTelegramUserUnavailable(e)) { + log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Error sending message with reply keyboard: chatId={}", chatId, e); + } + } + } + + /** + * Sends an animation (MP4 video) with caption text and reply keyboard. + * Uses MP4 format as Telegram handles silent MP4s better than GIF files. + */ + private void sendAnimationWithReplyKeyboard(Long chatId, String caption, ReplyKeyboardMarkup replyKeyboard) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/sendAnimation"; + + try { + // Load MP4 from resources (Telegram "GIFs" are actually silent MP4 videos) + Resource resource = new ClassPathResource("assets/winspin_5.mp4"); + if (!resource.exists()) { + log.error("MP4 file not found: assets/winspin_5.mp4"); + // Fallback to text message if MP4 not found + sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard); + return; + } + + byte[] videoBytes = StreamUtils.copyToByteArray(resource.getInputStream()); + ByteArrayResource videoResource = new ByteArrayResource(videoBytes) { + @Override + public String getFilename() { + return "winspin_5.mp4"; + } + }; + + // Create multipart form data + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("chat_id", chatId.toString()); + body.add("caption", caption); + + // EXPLICITLY SET MIME TYPE FOR THE ANIMATION PART + // This is crucial - Telegram needs to know it's a video/mp4 + HttpHeaders fileHeaders = new HttpHeaders(); + fileHeaders.setContentType(MediaType.parseMediaType("video/mp4")); + HttpEntity filePart = new HttpEntity<>(videoResource, fileHeaders); + body.add("animation", filePart); + + // Add reply keyboard if provided + if (replyKeyboard != null) { + try { + String keyboardJson = objectMapper.writeValueAsString(replyKeyboard); + body.add("reply_markup", keyboardJson); + } catch (Exception e) { + log.error("Error serializing reply keyboard: {}", e.getMessage(), e); + } + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null) { + if (!Boolean.TRUE.equals(response.getBody().getOk())) { + String desc = response.getBody().getDescription(); + if (isTelegramUserUnavailableDescription(desc)) { + log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Failed to send animation with reply keyboard: chatId={}, error={}", chatId, desc); + sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard); + } + } else { + log.info("Animation with reply keyboard sent successfully: chatId={}", chatId); + } + } else if (response != null && !response.getStatusCode().is2xxSuccessful()) { + log.error("Failed to send animation with reply keyboard: chatId={}, status={}", + chatId, response.getStatusCode()); + sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard); + } else if (response == null) { + log.warn("Welcome animation delayed (Telegram 429, retry scheduled): chatId={} – registration flow may appear incomplete", chatId); + } + } catch (Exception e) { + if (isTelegramUserUnavailable(e)) { + log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Error sending animation with reply keyboard: chatId={}", chatId, e); + sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard); + } + } + } + + /** + * Sends a message with only reply keyboard (for setting up persistent keyboard). + */ + private void sendReplyKeyboardOnly(Long chatId, ReplyKeyboardMarkup replyKeyboard) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/sendMessage"; + + Map requestBody = new HashMap<>(); + requestBody.put("chat_id", chatId); + // Telegram requires non-empty text for messages with reply keyboard + // Sending with a minimal message - this message won't be visible to users + // but is required to set up the persistent keyboard + requestBody.put("text", "."); + + try { + String keyboardJson = objectMapper.writeValueAsString(replyKeyboard); + Map keyboardMap = objectMapper.readValue(keyboardJson, Map.class); + requestBody.put("reply_markup", keyboardMap); + } catch (Exception e) { + log.error("Error serializing reply keyboard: {}", e.getMessage(), e); + return; + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null) { + if (!Boolean.TRUE.equals(response.getBody().getOk())) { + String desc = response.getBody().getDescription(); + if (isTelegramUserUnavailableDescription(desc)) { + log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Failed to send reply keyboard: chatId={}, error={}", chatId, desc); + } + } else { + log.info("Reply keyboard sent successfully: chatId={}", chatId); + } + } else if (response != null && !response.getStatusCode().is2xxSuccessful()) { + log.error("Failed to send reply keyboard: chatId={}, status={}", + chatId, response.getStatusCode()); + } + } catch (Exception e) { + if (isTelegramUserUnavailable(e)) { + log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId); + } else { + log.error("Error sending reply keyboard: chatId={}", chatId, e); + } + } + } + + /** + * Answers a callback query. + */ + private void answerCallbackQuery(String queryId, String text) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/answerCallbackQuery"; + + Map requestBody = new HashMap<>(); + requestBody.put("callback_query_id", queryId); + if (text != null) { + requestBody.put("text", text); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) { + log.warn("Failed to answer callback query: queryId={}, error={}", + queryId, response.getBody().getDescription()); + } + } catch (Exception e) { + log.error("Error answering callback query: queryId={}", queryId, e); + } + } + + /** + * Handles pre-checkout query (before payment confirmation). + * Telegram sends this to verify the payment before the user confirms. + * We must answer it to approve the payment, otherwise it will expire. + */ + private void handlePreCheckoutQuery(PreCheckoutQuery preCheckoutQuery) { + String queryId = preCheckoutQuery.getId(); + String invoicePayload = preCheckoutQuery.getInvoicePayload(); // This is our orderId + + log.debug("Pre-checkout query: queryId={}, orderId={}", queryId, invoicePayload); + + // Answer the pre-checkout query to approve the payment + // We always approve since we validate on successful payment + answerPreCheckoutQuery(queryId, true, null); + } + + /** + * Answers a pre-checkout query to approve or reject the payment. + * + * @param queryId The pre-checkout query ID + * @param ok True to approve, false to reject + * @param errorMessage Error message if rejecting (null if approving) + */ + private void answerPreCheckoutQuery(String queryId, boolean ok, String errorMessage) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + return; + } + + String url = "https://api.telegram.org/bot" + botToken + "/answerPreCheckoutQuery"; + + Map requestBody = new HashMap<>(); + requestBody.put("pre_checkout_query_id", queryId); + requestBody.put("ok", ok); + if (!ok && errorMessage != null) { + requestBody.put("error_message", errorMessage); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + log.debug("Answering pre-checkout query: queryId={}, ok={}", queryId, ok); + ResponseEntity response = telegramBotApiService.post(url, entity); + if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) { + log.warn("Failed to answer pre-checkout query: queryId={}, error={}", + queryId, response.getBody().getDescription()); + } else if (response != null && !response.getStatusCode().is2xxSuccessful()) { + log.error("Failed to answer pre-checkout query: queryId={}, status={}", queryId, response.getStatusCode()); + } + } catch (Exception e) { + log.error("Error answering pre-checkout query: queryId={}", queryId, e); + } + } + + /** + * Handles successful payment from Telegram. + * Processes the payment and credits the user's balance. + */ + private void handleSuccessfulPayment(SuccessfulPayment successfulPayment, Long telegramUserId) { + String invoicePayload = successfulPayment.getInvoicePayload(); // This is our orderId + + // Extract stars amount from total amount + // Telegram sends amount in the smallest currency unit (for Stars, it's 1:1) + Integer starsAmount = successfulPayment.getTotalAmount().intValue(); + + log.info("Payment webhook received: orderId={}, telegramUserId={}, starsAmount={}", invoicePayload, telegramUserId, starsAmount); + + try { + // Create webhook request and process payment + PaymentWebhookRequest request = new PaymentWebhookRequest(); + request.setOrderId(invoicePayload); + request.setTelegramUserId(telegramUserId); + request.setTelegramPaymentChargeId(successfulPayment.getTelegramPaymentChargeId()); + request.setTelegramProviderPaymentChargeId(successfulPayment.getProviderPaymentChargeId()); + request.setStarsAmount(starsAmount); + + boolean processed = paymentService.processPaymentWebhook(request); + if (!processed) { + log.warn("Payment already processed: orderId={}", invoicePayload); + } + } catch (Exception e) { + log.error("Error processing payment webhook: orderId={}", invoicePayload, e); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/TransactionController.java b/src/main/java/com/lottery/lottery/controller/TransactionController.java new file mode 100644 index 0000000..848b575 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/TransactionController.java @@ -0,0 +1,50 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.TransactionDto; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.TransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Controller for transaction history operations. + */ +@Slf4j +@RestController +@RequestMapping("/api/transactions") +@RequiredArgsConstructor +public class TransactionController { + + private final TransactionService transactionService; + + /** + * Gets transaction history for the current user. + * Returns 50 transactions per page, ordered by creation time descending (newest first). + * + * @param page Page number (0-indexed, defaults to 0) + * @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC. + * @return Page of transactions + */ + @GetMapping + public ResponseEntity> getTransactions( + @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) String timezone) { + UserA user = UserContext.get(); + Integer userId = user.getId(); + String languageCode = user.getLanguageCode(); + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + // Note: languageCode is still used for date formatting (localized "at" word) + // Transaction type localization is now handled in the frontend + Page transactions = transactionService.getUserTransactions(userId, page, timezone, languageCode); + return ResponseEntity.ok(transactions); + } +} + + + diff --git a/src/main/java/com/lottery/lottery/controller/UserCheckController.java b/src/main/java/com/lottery/lottery/controller/UserCheckController.java new file mode 100644 index 0000000..e9cf991 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/UserCheckController.java @@ -0,0 +1,105 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.UserCheckDto; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.model.UserD; +import com.lottery.lottery.repository.PaymentRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +/** + * Controller for user check endpoint (open endpoint for external applications). + * Path token is validated against app.check-user.token (APP_CHECK_USER_TOKEN). No user auth. + */ +@Slf4j +@RestController +@RequestMapping("/api/check_user") +@RequiredArgsConstructor +public class UserCheckController { + + @Value("${app.check-user.token:}") + private String expectedToken; + + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final UserDRepository userDRepository; + private final PaymentRepository paymentRepository; + + /** + * Gets user information by Telegram ID. + * Path: /api/check_user/{token}/{telegramId}. Token must match APP_CHECK_USER_TOKEN. + * + * @param token Secret token from path (must match config) + * @param telegramId The Telegram ID of the user + * @return 200 with user info (found=true) or 200 with found=false when user not found; 403 if token invalid + */ + @GetMapping("/{token}/{telegramId}") + public ResponseEntity checkUser(@PathVariable String token, @PathVariable Long telegramId) { + if (expectedToken.isEmpty() || !expectedToken.equals(token)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + // Find user by telegram_id + Optional userAOpt = userARepository.findByTelegramId(telegramId); + + if (userAOpt.isEmpty()) { + log.debug("User not found for telegramId={}", telegramId); + UserCheckDto notFoundResponse = UserCheckDto.builder().found(false).build(); + return ResponseEntity.ok(notFoundResponse); + } + + UserA userA = userAOpt.get(); + Integer userId = userA.getId(); + + // Get balance_a from db_users_b + Optional userBOpt = userBRepository.findById(userId); + Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L); + // Convert to tickets (balance_a / 1,000,000) + Double tickets = balanceA / 1_000_000.0; + + // Get rounds_played from db_users_b + Integer roundsPlayed = userBOpt.map(UserB::getRoundsPlayed).orElse(0); + + // Get referer_id_1 from db_users_d + Optional userDOpt = userDRepository.findById(userId); + Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0); + // Return 0 if refererId is 0 or negative (not set) + if (refererId <= 0) { + refererId = 0; + } + + // Sum completed payments stars_amount + Integer depositTotal = paymentRepository.sumCompletedStarsAmountByUserId(userId); + + // Build response + UserCheckDto response = UserCheckDto.builder() + .found(true) + .dateReg(userA.getDateReg()) + .tickets(tickets) + .depositTotal(depositTotal) + .refererId(refererId) + .roundsPlayed(roundsPlayed) + .build(); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Error checking user for telegramId={}", telegramId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/controller/UserController.java b/src/main/java/com/lottery/lottery/controller/UserController.java new file mode 100644 index 0000000..cf721d5 --- /dev/null +++ b/src/main/java/com/lottery/lottery/controller/UserController.java @@ -0,0 +1,146 @@ +package com.lottery.lottery.controller; + +import com.lottery.lottery.dto.ReferralDto; +import com.lottery.lottery.dto.UserDto; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.security.UserContext; +import com.lottery.lottery.service.AvatarService; +import com.lottery.lottery.service.FeatureSwitchService; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.UserService; +import com.lottery.lottery.util.IpUtils; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final UserBRepository userBRepository; + private final AvatarService avatarService; + private final LocalizationService localizationService; + private final FeatureSwitchService featureSwitchService; + + @GetMapping("/current") + public UserDto getCurrentUser() { + UserA user = UserContext.get(); + + // Convert IP from byte[] to string for display + String ipAddress = IpUtils.bytesToIp(user.getIp()); + + // Get balance + Long balanceA = userBRepository.findById(user.getId()) + .map(UserB::getBalanceA) + .orElse(0L); + + // Generate avatar URL on-the-fly (deterministic from userId) + String avatarUrl = avatarService.getAvatarUrl(user.getId()); + + return UserDto.builder() + .id(user.getId()) + .telegram_id(user.getTelegramId()) + .username(user.getTelegramName()) + .screenName(user.getScreenName()) + .dateReg(user.getDateReg()) + .ip(ipAddress) + .balanceA(balanceA) + .avatarUrl(avatarUrl) + .languageCode(user.getLanguageCode()) + .paymentEnabled(featureSwitchService.isPaymentEnabled()) + .payoutEnabled(featureSwitchService.isPayoutEnabled()) + .promotionsEnabled(featureSwitchService.isPromotionsEnabled()) + .build(); + } + + /** + * Updates user's language code. + * Called when user changes language in app header. + */ + @PutMapping("/language") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updateLanguage(@RequestBody UpdateLanguageRequest request) { + UserA user = UserContext.get(); + userService.updateLanguageCode(user.getId(), request.getLanguageCode()); + } + + /** + * Adds deposit amount to user's balance_a. + * For now, this is a mock implementation that directly adds to balance. + * Will be replaced with payment integration later. + */ + @PostMapping("/deposit") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deposit(@RequestBody DepositRequest request) { + UserA user = UserContext.get(); + + // Frontend sends amount already in bigint format (no conversion needed) + Long depositAmount = request.getAmount(); + if (depositAmount == null || depositAmount <= 0) { + throw new IllegalArgumentException(localizationService.getMessage("user.error.depositAmountInvalid")); + } + + UserB userB = userBRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + + // Add to balance + userB.setBalanceA(userB.getBalanceA() + depositAmount); + + // Update deposit statistics + userB.setDepositTotal(userB.getDepositTotal() + depositAmount); + userB.setDepositCount(userB.getDepositCount() + 1); + + userBRepository.save(userB); + } + + @Data + public static class UpdateLanguageRequest { + private String languageCode; + } + + /** + * Gets referrals for a specific level with pagination. + * Always returns 50 results per page. + * + * @param level The referral level (1, 2, or 3) + * @param page Page number (0-indexed, defaults to 0) + * @return Page of referrals with name and commission + */ + @GetMapping("/referrals") + public ReferralsResponse getReferrals( + @RequestParam Integer level, + @RequestParam(defaultValue = "0") Integer page) { + UserA user = UserContext.get(); + + Page referralsPage = userService.getReferrals(user.getId(), level, page); + + return new ReferralsResponse( + referralsPage.getContent(), + referralsPage.getNumber(), + referralsPage.getTotalPages(), + referralsPage.getTotalElements() + ); + } + + @Data + public static class DepositRequest { + private Long amount; // Amount in bigint format (frontend converts before sending) + } + + @Data + @RequiredArgsConstructor + public static class ReferralsResponse { + private final java.util.List referrals; + private final Integer currentPage; + private final Integer totalPages; + private final Long totalElements; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java b/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java new file mode 100644 index 0000000..2558288 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java @@ -0,0 +1,34 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminBotConfigDto { + private Integer id; + private Integer userId; + /** User screen name from db_users_a (for display). */ + private String screenName; + private Boolean room1; + private Boolean room2; + private Boolean room3; + /** Time window start UTC, format HH:mm (e.g. "14:00"). */ + private String timeUtcStart; + /** Time window end UTC, format HH:mm (e.g. "17:00"). */ + private String timeUtcEnd; + /** Min bet in bigint (1 ticket = 1_000_000). */ + private Long betMin; + /** Max bet in bigint. */ + private Long betMax; + private String persona; + private Boolean active; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java b/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java new file mode 100644 index 0000000..f0cf85d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminBotConfigRequest.java @@ -0,0 +1,37 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminBotConfigRequest { + @NotNull(message = "userId is required") + private Integer userId; + @NotNull + private Boolean room1; + @NotNull + private Boolean room2; + @NotNull + private Boolean room3; + /** Time window start UTC, format HH:mm (e.g. "14:00"). */ + @NotNull + private String timeUtcStart; + /** Time window end UTC, format HH:mm (e.g. "17:00"). */ + @NotNull + private String timeUtcEnd; + /** Min bet in bigint (1 ticket = 1_000_000). */ + @NotNull + private Long betMin; + /** Max bet in bigint. */ + @NotNull + private Long betMax; + private String persona; // conservative, aggressive, balanced; default balanced + @NotNull + private Boolean active; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java b/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java new file mode 100644 index 0000000..cf7032d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminConfigurationsRequest.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class AdminConfigurationsRequest { + + private List safeBotUserIds = new ArrayList<>(); + private List flexibleBots = new ArrayList<>(); + + @Data + public static class FlexibleBotEntry { + private Integer userId; + private Double winRate; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java b/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java new file mode 100644 index 0000000..76008d1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java @@ -0,0 +1,28 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminGameRoundDto { + private Long roundId; + private Integer roomNumber; + private String phase; + private Long totalBet; + private Long userBet; + private Integer winnerUserId; + private Long winnerBet; + private Long payout; + private Long commission; + private Instant startedAt; + private Instant resolvedAt; + private Boolean isWinner; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminLoginRequest.java b/src/main/java/com/lottery/lottery/dto/AdminLoginRequest.java new file mode 100644 index 0000000..958c37a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminLoginRequest.java @@ -0,0 +1,10 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +@Data +public class AdminLoginRequest { + private String username; + private String password; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminLoginResponse.java b/src/main/java/com/lottery/lottery/dto/AdminLoginResponse.java new file mode 100644 index 0000000..5b77cd5 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminLoginResponse.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AdminLoginResponse { + private String token; + private String username; + private String role; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminMasterDto.java b/src/main/java/com/lottery/lottery/dto/AdminMasterDto.java new file mode 100644 index 0000000..b45fbcf --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminMasterDto.java @@ -0,0 +1,31 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminMasterDto { + private Integer id; + private String screenName; + /** Level 1 referrals count (from master's UserD row). */ + private Integer referals1; + /** Level 2 referrals count. */ + private Integer referals2; + /** Level 3 referrals count. */ + private Integer referals3; + /** Total users with master_id = this master's id. */ + private Long totalReferrals; + /** Sum of deposit_total of all referrals, in USD (divided by 1e9). */ + private BigDecimal depositTotalUsd; + /** Sum of withdraw_total of all referrals, in USD (divided by 1e9). */ + private BigDecimal withdrawTotalUsd; + /** depositTotalUsd - withdrawTotalUsd. */ + private BigDecimal profitUsd; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPaymentDto.java b/src/main/java/com/lottery/lottery/dto/AdminPaymentDto.java new file mode 100644 index 0000000..08b8168 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPaymentDto.java @@ -0,0 +1,27 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPaymentDto { + private Long id; + private Integer userId; + private String userName; + private String orderId; + private Integer starsAmount; + private Long ticketsAmount; + private String status; + private String telegramPaymentChargeId; + private String telegramProviderPaymentChargeId; + private Instant createdAt; + private Instant completedAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminPayoutDto.java b/src/main/java/com/lottery/lottery/dto/AdminPayoutDto.java new file mode 100644 index 0000000..6937649 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPayoutDto.java @@ -0,0 +1,28 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPayoutDto { + private Long id; + private Integer userId; + private String userName; + private String username; + private String type; + private String giftName; + private Long total; + private Integer starsAmount; + private Integer quantity; + private String status; + private Instant createdAt; + private Instant resolvedAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionDto.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionDto.java new file mode 100644 index 0000000..a960395 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionDto.java @@ -0,0 +1,23 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionDto { + private Integer id; + private String type; + private Instant startTime; + private Instant endTime; + private String status; + private Long totalReward; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionRequest.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionRequest.java new file mode 100644 index 0000000..566982c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionRequest.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionRequest { + @NotNull + private String type; + @NotNull + private Instant startTime; + @NotNull + private Instant endTime; + @NotNull + private String status; + private Long totalReward; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardDto.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardDto.java new file mode 100644 index 0000000..0b88bd3 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardDto.java @@ -0,0 +1,22 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionRewardDto { + private Integer id; + private Integer promoId; + private Integer place; + /** Reward in bigint (1 ticket = 1_000_000). */ + private Long reward; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardRequest.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardRequest.java new file mode 100644 index 0000000..fb0d6ec --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionRewardRequest.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionRewardRequest { + @NotNull + private Integer place; + @NotNull + private Long reward; // bigint, 1 ticket = 1_000_000 +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionUserDto.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionUserDto.java new file mode 100644 index 0000000..fbbf4b0 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionUserDto.java @@ -0,0 +1,21 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionUserDto { + private Integer promoId; + private Integer userId; + /** Points as ticket count, 2 decimal places (e.g. 100.25). */ + private BigDecimal points; + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminPromotionUserPointsRequest.java b/src/main/java/com/lottery/lottery/dto/AdminPromotionUserPointsRequest.java new file mode 100644 index 0000000..313bf8b --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminPromotionUserPointsRequest.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPromotionUserPointsRequest { + @NotNull + private BigDecimal points; // ticket count, 2 decimal places +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java new file mode 100644 index 0000000..cb78f0a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomDetailDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomDetailDto { + private Integer roomNumber; + private String phase; + private Long roundId; + private Long totalBetTickets; + private Double totalBetUsd; + private Integer registeredPlayers; + private Integer connectedUsers; + private List participants; + /** Viewers: same as participants section format but without tickets/chances (screen name + id). */ + private List connectedViewers; + private AdminRoomWinnerDto winner; // when phase is SPINNING or RESOLUTION +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java new file mode 100644 index 0000000..0ab24c5 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomOnlineUserDto.java @@ -0,0 +1,29 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One row for the "all online users across rooms" admin table. + * currentBetTickets null = viewer (not registered in current round). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomOnlineUserDto { + private Integer userId; + private String screenName; + private Integer roomNumber; + /** Current round bet in tickets; null if viewer (not registered). */ + private Long currentBetTickets; + /** balance_a in bigint (divide by 1_000_000 for display). */ + private Long balanceA; + private Long depositTotal; + private Integer depositCount; + private Long withdrawTotal; + private Integer withdrawCount; + private Integer roundsPlayed; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java new file mode 100644 index 0000000..6f294e1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomParticipantDto.java @@ -0,0 +1,17 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomParticipantDto { + private Integer userId; + private String screenName; + private Long betTickets; + private Double chancePct; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java new file mode 100644 index 0000000..4dc9b97 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomSummaryDto.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomSummaryDto { + private Integer roomNumber; + private String phase; // WAITING, COUNTDOWN, SPINNING, RESOLUTION + private Integer connectedUsers; + private Integer registeredPlayers; + private Long totalBetTickets; + private Double totalBetUsd; // tickets / 1000 for display + private Long roundId; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java new file mode 100644 index 0000000..561c421 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomViewerDto.java @@ -0,0 +1,15 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomViewerDto { + private Integer userId; + private String screenName; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java b/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java new file mode 100644 index 0000000..6d40935 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminRoomWinnerDto.java @@ -0,0 +1,17 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminRoomWinnerDto { + private Integer userId; + private String screenName; + private Long betTickets; + private Double winChancePct; +} diff --git a/src/main/java/com/lottery/lottery/dto/AdminSupportMessageDto.java b/src/main/java/com/lottery/lottery/dto/AdminSupportMessageDto.java new file mode 100644 index 0000000..f56338e --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminSupportMessageDto.java @@ -0,0 +1,22 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminSupportMessageDto { + private Long id; + private Integer userId; + private String userName; + private String message; + private Instant createdAt; + private Boolean isAdmin; // true if sent by admin +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDetailDto.java b/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDetailDto.java new file mode 100644 index 0000000..a5b747d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDetailDto.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminSupportTicketDetailDto { + private Long id; + private Integer userId; + private String userName; + private String subject; + private String status; + private Instant createdAt; + private Instant updatedAt; + private List messages; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDto.java b/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDto.java new file mode 100644 index 0000000..38cbbec --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminSupportTicketDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminSupportTicketDto { + private Long id; + private Integer userId; + private String userName; + private String subject; + private String status; + private Instant createdAt; + private Instant updatedAt; + private Long messageCount; + private String lastMessagePreview; + private Instant lastMessageAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminTaskClaimDto.java b/src/main/java/com/lottery/lottery/dto/AdminTaskClaimDto.java new file mode 100644 index 0000000..1c0e232 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminTaskClaimDto.java @@ -0,0 +1,21 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminTaskClaimDto { + private Long id; + private Integer taskId; + private String taskTitle; + private String taskType; + private Instant claimedAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminTransactionDto.java b/src/main/java/com/lottery/lottery/dto/AdminTransactionDto.java new file mode 100644 index 0000000..70b0c8c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminTransactionDto.java @@ -0,0 +1,22 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminTransactionDto { + private Long id; + private Long amount; // In bigint format + private String type; + private Integer taskId; + private Long roundId; + private Instant createdAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java b/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java new file mode 100644 index 0000000..3ebe2af --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminUserDetailDto.java @@ -0,0 +1,55 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminUserDetailDto { + // Basic Info + private Integer id; + private String screenName; + private Long telegramId; + private String telegramName; + private Integer isPremium; + private String languageCode; + private String countryCode; + private String deviceCode; + private String avatarUrl; + private Integer dateReg; + private Integer dateLogin; + private Integer banned; + /** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */ + private String ipAddress; + + // Balance Info + private Long balanceA; + private Long depositTotal; + private Integer depositCount; + private Long withdrawTotal; + private Integer withdrawCount; + /** Total deposits in USD (CRYPTO only). */ + private java.math.BigDecimal depositTotalUsd; + /** Total withdrawals in USD (CRYPTO only). */ + private java.math.BigDecimal withdrawTotalUsd; + /** When true, the user cannot create any payout request. */ + private Boolean withdrawalsDisabled; + + // Game Stats + private Integer roundsPlayed; + + // Referral Info + private Integer referralCount; + private Long totalCommissionsEarned; + /** Total commissions earned in USD (converted from tickets). */ + private java.math.BigDecimal totalCommissionsEarnedUsd; + private Integer masterId; + private List referralLevels; +} + diff --git a/src/main/java/com/lottery/lottery/dto/AdminUserDto.java b/src/main/java/com/lottery/lottery/dto/AdminUserDto.java new file mode 100644 index 0000000..8167057 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/AdminUserDto.java @@ -0,0 +1,42 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminUserDto { + private Integer id; + private String screenName; + private Long telegramId; + private String telegramName; + private Long balanceA; + private Long balanceB; + private Long depositTotal; + private Integer depositCount; + private Long withdrawTotal; + private Integer withdrawCount; + private Integer roundsPlayed; + private Integer dateReg; + private Integer dateLogin; + private Integer banned; + private String countryCode; + private String languageCode; + private Integer referralCount; // Total referrals across all levels + private Long totalCommissionsEarned; // Total commissions earned from referrals + /** Profit in tickets (bigint): depositTotal - withdrawTotal */ + private Long profit; + /** USD from db_users_b: depositTotal (tickets/1000) */ + private BigDecimal depositTotalUsd; + /** USD from db_users_b: withdrawTotal (tickets/1000) */ + private BigDecimal withdrawTotalUsd; + /** USD from db_users_b: profit (tickets/1000) */ + private BigDecimal profitUsd; +} + diff --git a/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentRequest.java b/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentRequest.java new file mode 100644 index 0000000..3753ad1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentRequest.java @@ -0,0 +1,36 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BalanceAdjustmentRequest { + @NotNull(message = "Balance type is required") + private BalanceType balanceType; // A or B + + @NotNull(message = "Amount is required") + private Long amount; // In bigint format (tickets * 1,000,000) + + @NotNull(message = "Operation is required") + private OperationType operation; // ADD or SUBTRACT + + @NotBlank(message = "Reason is required") + private String reason; // Reason for adjustment (for audit log) + + public enum BalanceType { + A, B + } + + public enum OperationType { + ADD, SUBTRACT + } +} + diff --git a/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentResponse.java b/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentResponse.java new file mode 100644 index 0000000..9dd544a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/BalanceAdjustmentResponse.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BalanceAdjustmentResponse { + private Long newBalanceA; + private Long newBalanceB; + private Long previousBalanceA; + private Long previousBalanceB; + private Long adjustmentAmount; + private String message; +} + diff --git a/src/main/java/com/lottery/lottery/dto/BalanceUpdateDto.java b/src/main/java/com/lottery/lottery/dto/BalanceUpdateDto.java new file mode 100644 index 0000000..65c9f19 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/BalanceUpdateDto.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BalanceUpdateDto { + private Long balanceA; // Balance in bigint format (database format) +} + + + + + diff --git a/src/main/java/com/lottery/lottery/dto/BotRegisterRequest.java b/src/main/java/com/lottery/lottery/dto/BotRegisterRequest.java new file mode 100644 index 0000000..92a61d2 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/BotRegisterRequest.java @@ -0,0 +1,37 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BotRegisterRequest { + @JsonProperty("telegram_id") + private Long telegramId; + + @JsonProperty("first_name") + private String firstName; + + @JsonProperty("last_name") + private String lastName; + + private String username; + + @JsonProperty("is_premium") + private Boolean isPremium; + + @JsonProperty("language_code") + private String languageCode; + + @JsonProperty("photo_url") + private String photoUrl; + + @JsonProperty("referral_user_id") + private Integer referralUserId; +} + diff --git a/src/main/java/com/lottery/lottery/dto/BotRegisterResponse.java b/src/main/java/com/lottery/lottery/dto/BotRegisterResponse.java new file mode 100644 index 0000000..32fe77e --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/BotRegisterResponse.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BotRegisterResponse { + @JsonProperty("user_id") + private Integer userId; + + @JsonProperty("is_new_user") + private Boolean isNewUser; + + private String message; +} + + + + diff --git a/src/main/java/com/honey/honey/dto/UserDto.java b/src/main/java/com/lottery/lottery/dto/ClaimTaskResponse.java similarity index 55% rename from src/main/java/com/honey/honey/dto/UserDto.java rename to src/main/java/com/lottery/lottery/dto/ClaimTaskResponse.java index 5152267..2248cb0 100644 --- a/src/main/java/com/honey/honey/dto/UserDto.java +++ b/src/main/java/com/lottery/lottery/dto/ClaimTaskResponse.java @@ -1,4 +1,4 @@ -package com.honey.honey.dto; +package com.lottery.lottery.dto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -9,9 +9,11 @@ import lombok.NoArgsConstructor; @Builder @NoArgsConstructor @AllArgsConstructor -public class UserDto { - private Long telegram_id; - private String username; - private String ip; +public class ClaimTaskResponse { + private boolean success; + private String message; } + + + diff --git a/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java b/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java new file mode 100644 index 0000000..14ec720 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CompletedRoundDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompletedRoundDto { + private Long roundId; + private Integer winnerUserId; + private String winnerScreenName; + private String winnerAvatarUrl; + private Long winnerBet; + private Long payout; + private Long totalBet; + private Double winChance; // winner's chance percentage + private Long resolvedAt; // timestamp +} + + + + diff --git a/src/main/java/com/lottery/lottery/dto/CreateCryptoWithdrawalRequest.java b/src/main/java/com/lottery/lottery/dto/CreateCryptoWithdrawalRequest.java new file mode 100644 index 0000000..0fed935 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CreateCryptoWithdrawalRequest.java @@ -0,0 +1,23 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Request body for POST /api/payments/crypto-withdrawal. + */ +@Data +public class CreateCryptoWithdrawalRequest { + + @NotNull(message = "pid is required") + private Integer pid; + + @NotBlank(message = "wallet is required") + private String wallet; + + /** Tickets amount in bigint format (tickets * 1_000_000). */ + @NotNull(message = "total is required") + private Long total; +} diff --git a/src/main/java/com/lottery/lottery/dto/CreateMessageRequest.java b/src/main/java/com/lottery/lottery/dto/CreateMessageRequest.java new file mode 100644 index 0000000..8b9f12a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CreateMessageRequest.java @@ -0,0 +1,22 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateMessageRequest { + + @NotBlank(message = "Message is required") + @Size(min = 3, max = 2000, message = "Message must be between 3 and 2000 characters") + private String message; +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/CreatePaymentRequest.java b/src/main/java/com/lottery/lottery/dto/CreatePaymentRequest.java new file mode 100644 index 0000000..ec2346c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CreatePaymentRequest.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +@Data +public class CreatePaymentRequest { + private Integer starsAmount; // Amount in Stars (legacy) + private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto) +} + + + + diff --git a/src/main/java/com/lottery/lottery/dto/CreatePayoutRequest.java b/src/main/java/com/lottery/lottery/dto/CreatePayoutRequest.java new file mode 100644 index 0000000..3500d51 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CreatePayoutRequest.java @@ -0,0 +1,14 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +@Data +public class CreatePayoutRequest { + private String username; + private Long total; // Tickets amount in bigint format + private Integer starsAmount; // Stars amount (for STARS type) + private String type; // "STARS" or "GIFT" + private String giftName; // Gift name (for GIFT type): "HEART", "BEAR", etc. + private Integer quantity; // Quantity of gifts/stars (1-100, default 1) +} + diff --git a/src/main/java/com/honey/honey/dto/CreateSessionRequest.java b/src/main/java/com/lottery/lottery/dto/CreateSessionRequest.java similarity index 74% rename from src/main/java/com/honey/honey/dto/CreateSessionRequest.java rename to src/main/java/com/lottery/lottery/dto/CreateSessionRequest.java index a1d0bbb..69d4812 100644 --- a/src/main/java/com/honey/honey/dto/CreateSessionRequest.java +++ b/src/main/java/com/lottery/lottery/dto/CreateSessionRequest.java @@ -1,4 +1,4 @@ -package com.honey.honey.dto; +package com.lottery.lottery.dto; import lombok.Data; diff --git a/src/main/java/com/honey/honey/dto/CreateSessionResponse.java b/src/main/java/com/lottery/lottery/dto/CreateSessionResponse.java similarity index 89% rename from src/main/java/com/honey/honey/dto/CreateSessionResponse.java rename to src/main/java/com/lottery/lottery/dto/CreateSessionResponse.java index 7f08372..970d954 100644 --- a/src/main/java/com/honey/honey/dto/CreateSessionResponse.java +++ b/src/main/java/com/lottery/lottery/dto/CreateSessionResponse.java @@ -1,4 +1,4 @@ -package com.honey.honey.dto; +package com.lottery.lottery.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/lottery/lottery/dto/CreateTicketRequest.java b/src/main/java/com/lottery/lottery/dto/CreateTicketRequest.java new file mode 100644 index 0000000..a433aa1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CreateTicketRequest.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateTicketRequest { + + @NotBlank(message = "Subject is required") + @Size(min = 5, max = 100, message = "Subject must be between 5 and 100 characters") + private String subject; + + @NotBlank(message = "Message is required") + @Size(min = 3, max = 2000, message = "Message must be between 3 and 2000 characters") + private String message; +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/CryptoDepositMethodsResponse.java b/src/main/java/com/lottery/lottery/dto/CryptoDepositMethodsResponse.java new file mode 100644 index 0000000..0871816 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CryptoDepositMethodsResponse.java @@ -0,0 +1,52 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** Response from external GET /api/v1/deposit-methods */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CryptoDepositMethodsResponse { + + @JsonProperty("request_info") + private RequestInfo requestInfo; + + @JsonProperty("result") + private Result result; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequestInfo { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_message") + private String errorMessage; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Result { + @JsonProperty("active_methods") + private List activeMethods; + @JsonProperty("hash") + private String hash; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ActiveMethod { + @JsonProperty("pid") + private Integer pid; + @JsonProperty("name") + private String name; + @JsonProperty("network") + private String network; + @JsonProperty("example") + private String example; + @JsonProperty("min_deposit_sum") + private Double minDepositSum; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/CryptoWithdrawalResponse.java b/src/main/java/com/lottery/lottery/dto/CryptoWithdrawalResponse.java new file mode 100644 index 0000000..fb4bbc8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/CryptoWithdrawalResponse.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Minimal response for POST /api/payments/crypto-withdrawal. + * Exposes only id and status; no internal or PII fields. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CryptoWithdrawalResponse { + private Long id; + private String status; +} diff --git a/src/main/java/com/lottery/lottery/dto/DailyBonusStatusDto.java b/src/main/java/com/lottery/lottery/dto/DailyBonusStatusDto.java new file mode 100644 index 0000000..9f5c9fa --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DailyBonusStatusDto.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DailyBonusStatusDto { + private Integer taskId; + private Boolean available; // true if bonus can be claimed, false if on cooldown + private Long cooldownSeconds; // Remaining cooldown time in seconds (null if available) + private Long rewardAmount; // Reward amount in bigint format (1 ticket = 1000000) +} + + diff --git a/src/main/java/com/lottery/lottery/dto/DepositAddressApiRequest.java b/src/main/java/com/lottery/lottery/dto/DepositAddressApiRequest.java new file mode 100644 index 0000000..e056058 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DepositAddressApiRequest.java @@ -0,0 +1,43 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +/** + * Request body for external POST api/v1/deposit-address. + */ +@Data +@Builder +public class DepositAddressApiRequest { + + @JsonProperty("pid") + private Integer pid; + + @JsonProperty("amount_usd") + private Double amountUsd; + + @JsonProperty("user_data") + private UserData userData; + + @Data + @Builder + public static class UserData { + @JsonProperty("internal_id") + private Integer internalId; + @JsonProperty("screen_name") + private String screenName; + @JsonProperty("tg_username") + private String tgUsername; + @JsonProperty("tg_id") + private String tgId; + @JsonProperty("country_code") + private String countryCode; + @JsonProperty("device_code") + private String deviceCode; + @JsonProperty("language_code") + private String languageCode; + @JsonProperty("user_ip") + private String userIp; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/DepositAddressRequest.java b/src/main/java/com/lottery/lottery/dto/DepositAddressRequest.java new file mode 100644 index 0000000..ea86a6d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DepositAddressRequest.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +/** + * Request from frontend to get a crypto deposit address. + * usdAmount: decimal, e.g. 3.25 USD. + */ +@Data +public class DepositAddressRequest { + private Integer pid; // PID from deposit-methods + private Double usdAmount; // USD as decimal, e.g. 3.25 +} diff --git a/src/main/java/com/lottery/lottery/dto/DepositAddressResponse.java b/src/main/java/com/lottery/lottery/dto/DepositAddressResponse.java new file mode 100644 index 0000000..fca28cf --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DepositAddressResponse.java @@ -0,0 +1,43 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Response from external POST api/v1/deposit-address (and returned to frontend). + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DepositAddressResponse { + + @JsonProperty("request_info") + private RequestInfo requestInfo; + + @JsonProperty("result") + private Result result; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequestInfo { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_message") + private String errorMessage; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Result { + @JsonProperty("ps_id") + private Integer psId; + @JsonProperty("name") + private String name; + @JsonProperty("network") + private String network; + @JsonProperty("address") + private String address; + @JsonProperty("amount_coins") + private String amountCoins; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/DepositAddressResultDto.java b/src/main/java/com/lottery/lottery/dto/DepositAddressResultDto.java new file mode 100644 index 0000000..e9bc807 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DepositAddressResultDto.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * Result returned to frontend after getting deposit address from crypto API. + * No payment record is created at this step. + */ +@Data +@Builder +public class DepositAddressResultDto { + private String address; + private String amountCoins; + private String name; + private String network; + private Integer psId; + /** Minimum deposit for this method (from crypto_deposit_methods.min_deposit_sum), for display on Payment Confirmation. Value as in DB, e.g. 2.50, 40.00. */ + private Double minAmount; +} diff --git a/src/main/java/com/lottery/lottery/dto/DepositMethodsDto.java b/src/main/java/com/lottery/lottery/dto/DepositMethodsDto.java new file mode 100644 index 0000000..b64b532 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/DepositMethodsDto.java @@ -0,0 +1,32 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** Response for GET /api/payments/deposit-methods */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DepositMethodsDto { + + private BigDecimal minimumDeposit; + private List activeMethods; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DepositMethodItemDto { + private Integer pid; + private String name; + private String network; + private String example; + private BigDecimal minDepositSum; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/ErrorResponse.java b/src/main/java/com/lottery/lottery/dto/ErrorResponse.java new file mode 100644 index 0000000..bddcf8d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + private String message; +} + + + + diff --git a/src/main/java/com/lottery/lottery/dto/ExternalDepositWebhookRequest.java b/src/main/java/com/lottery/lottery/dto/ExternalDepositWebhookRequest.java new file mode 100644 index 0000000..049dd19 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/ExternalDepositWebhookRequest.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Request body for 3rd party deposit webhook: POST /api/deposit_webhook/{token}. + * usd_amount: decimal, e.g. 1.45 (3rd party sends as number). + */ +@Data +public class ExternalDepositWebhookRequest { + + @JsonProperty("user_id") + private Integer userId; + + @JsonProperty("usd_amount") + private Double usdAmount; +} diff --git a/src/main/java/com/lottery/lottery/dto/GameHistoryEntryDto.java b/src/main/java/com/lottery/lottery/dto/GameHistoryEntryDto.java new file mode 100644 index 0000000..02e26df --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/GameHistoryEntryDto.java @@ -0,0 +1,29 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for a single game history entry. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameHistoryEntryDto { + /** + * Amount in bigint format (positive for wins, negative for losses). + * Example: +900000000 means +900.0000 (win of 900 tickets) + * Example: -100000000 means -100.0000 (loss of 100 tickets) + */ + private Long amount; + + /** + * Date formatted as dd.MM at HH:mm (e.g., "13.01 at 22:29") + */ + private String date; +} + + diff --git a/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java b/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java new file mode 100644 index 0000000..69be243 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/GameRoomStateDto.java @@ -0,0 +1,63 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameRoomStateDto { + @JsonProperty("rN") + private Integer roomNumber; + + @JsonProperty("rI") + private Long roundId; // Current round id (null when no active round) + + @JsonProperty("p") + private Integer phase; // 1=WAITING, 2=COUNTDOWN, 3=SPINNING, 4=RESOLUTION + + @JsonProperty("tB") + private Long totalBet; // In tickets (not bigint) + + @JsonProperty("rP") + private Integer registeredPlayers; // Users registered in current round + + @JsonProperty("cU") + private Integer connectedUsers; // Total users connected to room (regardless of round participation) + + @JsonProperty("aR") + private Map allRoomsConnectedUsers; // Connected users count for all rooms (roomNumber -> count) + + @JsonProperty("mB") + private Long minBet; // Minimum bet for this room (in tickets, not bigint) + + @JsonProperty("mX") + private Long maxBet; // Maximum bet for this room (in tickets, not bigint) + + @JsonProperty("cE") + private Instant countdownEndAt; + + @JsonProperty("cR") + private Long countdownRemainingSeconds; + + @JsonProperty("ps") + private List participants; + + @JsonProperty("w") + private WinnerDto winner; + + @JsonProperty("sD") + private Long spinDuration; // milliseconds + + @JsonProperty("sI") + private Long stopIndex; // for spin animation +} + diff --git a/src/main/java/com/lottery/lottery/dto/JoinRoundRequest.java b/src/main/java/com/lottery/lottery/dto/JoinRoundRequest.java new file mode 100644 index 0000000..2577506 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/JoinRoundRequest.java @@ -0,0 +1,24 @@ +package com.lottery.lottery.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class JoinRoundRequest { + @NotNull(message = "Room number is required") + @Min(value = 1, message = "Room number must be between 1 and 3") + @Max(value = 3, message = "Room number must be between 1 and 3") + private Integer roomNumber; + + @NotNull(message = "Bet amount is required") + @Positive(message = "Bet amount must be a positive integer") + private Long betAmount; +} + diff --git a/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java b/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java new file mode 100644 index 0000000..0765765 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/JoinRoundResult.java @@ -0,0 +1,17 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Result of joining a round (used by remote bet API to return state and bet amount for response). + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class JoinRoundResult { + private GameRoomStateDto state; + /** Bet tickets to report in API response (amount added this call, or current bet when unique=true no-op). */ + private int betTicketsForResponse; +} diff --git a/src/main/java/com/lottery/lottery/dto/MessageDto.java b/src/main/java/com/lottery/lottery/dto/MessageDto.java new file mode 100644 index 0000000..cc07b21 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/MessageDto.java @@ -0,0 +1,24 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MessageDto { + private Long id; + private Long ticketId; + private Integer userId; + private String message; + private Instant createdAt; + private Boolean isFromSupport; // true if message is from support agent (different user_id than ticket owner) +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/NotifyBroadcastRequest.java b/src/main/java/com/lottery/lottery/dto/NotifyBroadcastRequest.java new file mode 100644 index 0000000..b9fc0f4 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/NotifyBroadcastRequest.java @@ -0,0 +1,21 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +@Data +public class NotifyBroadcastRequest { + /** HTML/text message. */ + private String message; + /** Optional image URL (ignored if videoUrl is set). */ + private String imageUrl; + /** Optional video URL (takes priority over imageUrl). */ + private String videoUrl; + /** Internal user id range start (default 1). */ + private Integer userIdFrom; + /** Internal user id range end (default max id). */ + private Integer userIdTo; + /** Optional button text; if set, adds an inline button that opens the mini app. */ + private String buttonText; + /** When true, skip users whose latest notification_audit record has status FAILED (e.g. blocked the bot). When false or null, send to all in range. */ + private Boolean ignoreBlocked; +} diff --git a/src/main/java/com/lottery/lottery/dto/ParticipantDto.java b/src/main/java/com/lottery/lottery/dto/ParticipantDto.java new file mode 100644 index 0000000..8991d36 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/ParticipantDto.java @@ -0,0 +1,23 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantDto { + @JsonProperty("uI") + private Integer userId; + + @JsonProperty("b") + private Long bet; // In tickets (not bigint) + + @JsonProperty("aU") + private String avatarUrl; +} + diff --git a/src/main/java/com/lottery/lottery/dto/PaymentInvoiceResponse.java b/src/main/java/com/lottery/lottery/dto/PaymentInvoiceResponse.java new file mode 100644 index 0000000..c6f7a9f --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PaymentInvoiceResponse.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentInvoiceResponse { + private String invoiceId; // Order ID to be used in Telegram invoice + private String invoiceUrl; // Invoice URL to open in Telegram (null for crypto) + private Integer starsAmount; // Amount in Stars (legacy) + private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto) + private Long ticketsAmount; // Tickets amount in bigint format +} + diff --git a/src/main/java/com/lottery/lottery/dto/PaymentWebhookRequest.java b/src/main/java/com/lottery/lottery/dto/PaymentWebhookRequest.java new file mode 100644 index 0000000..5fc2716 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PaymentWebhookRequest.java @@ -0,0 +1,16 @@ +package com.lottery.lottery.dto; + +import lombok.Data; + +@Data +public class PaymentWebhookRequest { + private String orderId; // Order ID from Telegram invoice + private Long telegramUserId; // Telegram user ID + private String telegramPaymentChargeId; // Telegram payment charge ID + private String telegramProviderPaymentChargeId; // Telegram provider payment charge ID + private Integer starsAmount; // Amount in Stars +} + + + + diff --git a/src/main/java/com/lottery/lottery/dto/PayoutHistoryEntryDto.java b/src/main/java/com/lottery/lottery/dto/PayoutHistoryEntryDto.java new file mode 100644 index 0000000..6178408 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PayoutHistoryEntryDto.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for payout history table entries. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutHistoryEntryDto { + private Long amount; // Total in bigint format (will be converted to tickets on frontend) + private String date; // Formatted as dd.MM at HH:mm (e.g., "13.01 at 22:29") + private String status; // PROCESSING, COMPLETED, CANCELLED +} + diff --git a/src/main/java/com/lottery/lottery/dto/PayoutResponse.java b/src/main/java/com/lottery/lottery/dto/PayoutResponse.java new file mode 100644 index 0000000..45dc120 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PayoutResponse.java @@ -0,0 +1,24 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PayoutResponse { + private Long id; + private String username; + private String type; + private String giftName; + private Long total; + private Integer starsAmount; + private Integer quantity; + private String status; + private Long createdAt; // Unix timestamp in milliseconds + private Long resolvedAt; // Unix timestamp in milliseconds, null if not resolved +} + diff --git a/src/main/java/com/lottery/lottery/dto/PromotionDetailDto.java b/src/main/java/com/lottery/lottery/dto/PromotionDetailDto.java new file mode 100644 index 0000000..740fc6c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PromotionDetailDto.java @@ -0,0 +1,29 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotionDetailDto { + private Integer id; + private String type; + private String status; + private Instant startTime; + private Instant endTime; + private Long totalReward; + private List leaderboard; + /** 1-based position of current user (0 if not in leaderboard). */ + private int userPosition; + /** Total number of participants. */ + private int userTotal; + private BigDecimal userPoints; +} diff --git a/src/main/java/com/lottery/lottery/dto/PromotionLeaderboardEntryDto.java b/src/main/java/com/lottery/lottery/dto/PromotionLeaderboardEntryDto.java new file mode 100644 index 0000000..4f48d13 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PromotionLeaderboardEntryDto.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotionLeaderboardEntryDto { + private int place; + private String screenName; + private BigDecimal points; + /** Reward for this place in tickets (null if no reward for this place). */ + private Long rewardTickets; +} diff --git a/src/main/java/com/lottery/lottery/dto/PromotionListItemDto.java b/src/main/java/com/lottery/lottery/dto/PromotionListItemDto.java new file mode 100644 index 0000000..0785505 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/PromotionListItemDto.java @@ -0,0 +1,22 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotionListItemDto { + private Integer id; + private String type; + private String status; + private Instant startTime; + private Instant endTime; + /** Total prize fund in bigint (1 ticket = 1_000_000). */ + private Long totalReward; +} diff --git a/src/main/java/com/lottery/lottery/dto/QuickAnswerCreateRequest.java b/src/main/java/com/lottery/lottery/dto/QuickAnswerCreateRequest.java new file mode 100644 index 0000000..72872fb --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/QuickAnswerCreateRequest.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuickAnswerCreateRequest { + private String text; +} + diff --git a/src/main/java/com/lottery/lottery/dto/QuickAnswerDto.java b/src/main/java/com/lottery/lottery/dto/QuickAnswerDto.java new file mode 100644 index 0000000..a694a2b --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/QuickAnswerDto.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class QuickAnswerDto { + private Integer id; + private String text; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/lottery/lottery/dto/RecentBonusClaimDto.java b/src/main/java/com/lottery/lottery/dto/RecentBonusClaimDto.java new file mode 100644 index 0000000..367af8a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/RecentBonusClaimDto.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * DTO for recent daily bonus claims. + * Contains user information and claim timestamp. + * The claimedAt field contains the raw timestamp, but the date field contains the formatted string. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecentBonusClaimDto { + private String avatarUrl; + private String screenName; + private LocalDateTime claimedAt; + private String date; // Formatted date string (dd.MM 'at' HH:mm) with timezone +} + diff --git a/src/main/java/com/lottery/lottery/dto/ReferralDto.java b/src/main/java/com/lottery/lottery/dto/ReferralDto.java new file mode 100644 index 0000000..3acc6bc --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/ReferralDto.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReferralDto { + private String name; // screen_name from db_users_a + private Long commission; // to_referer_1/2/3 from db_users_d (bigint, needs to be divided by 1,000,000 on frontend) +} + + + + diff --git a/src/main/java/com/lottery/lottery/dto/ReferralLevelDto.java b/src/main/java/com/lottery/lottery/dto/ReferralLevelDto.java new file mode 100644 index 0000000..2f21c4f --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/ReferralLevelDto.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReferralLevelDto { + private Integer level; // 1-5 + private Integer refererId; + private Integer referralCount; + private Long commissionsEarned; + private Long commissionsPaid; + /** Commissions earned in USD (converted from tickets: 1000 tickets = 1 USD). */ + private BigDecimal commissionsEarnedUsd; + /** Commissions paid in USD (converted from tickets). */ + private BigDecimal commissionsPaidUsd; +} + diff --git a/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java b/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java new file mode 100644 index 0000000..9a08b80 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/RoomUpdateDto.java @@ -0,0 +1,49 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Incremental update DTO for room state changes. + * Used to send only what changed instead of full state. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoomUpdateDto { + + /** + * Update type: USER_JOINED, USER_LEFT, FULL_STATE, PHASE_CHANGED + */ + private String type; + + /** + * Participant data (for USER_JOINED) + */ + private ParticipantDto participant; + + /** + * User ID (for USER_LEFT) + */ + private Integer userId; + + /** + * Full state (for FULL_STATE - fallback when incremental not possible) + */ + private GameRoomStateDto fullState; + + /** + * Phase change data (for PHASE_CHANGED) + */ + private String phase; + private Long countdownRemaining; + private WinnerDto winner; + private Long stopIndex; + private Long spinDuration; +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/SupportTicketReplyRequest.java b/src/main/java/com/lottery/lottery/dto/SupportTicketReplyRequest.java new file mode 100644 index 0000000..7a5b515 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/SupportTicketReplyRequest.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SupportTicketReplyRequest { + @NotBlank(message = "Message is required") + private String message; +} + diff --git a/src/main/java/com/lottery/lottery/dto/TaskDto.java b/src/main/java/com/lottery/lottery/dto/TaskDto.java new file mode 100644 index 0000000..62f7883 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TaskDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskDto { + private Integer id; + private String type; // referral, follow, other + private Long requirement; // For referral tasks: number of friends. For other tasks: deposit_total threshold in bigint (frontend converts) + private Long rewardAmount; // bigint format + private String rewardType; // Tickets (all tasks use Tickets as reward type) + private String title; + private String description; + private Integer displayOrder; + private Boolean claimed; // Whether user has claimed this task + private String progress; // Progress string like "1008 / 30" or "CLAIMED" (for referral) or null (for follow/other - frontend handles) + private Long currentValue; // Current value in bigint format (referals1 for referral, depositTotal for other). Frontend converts for display. + private String localizedRewardText; // Localized reward text like "+2 Билеты" or "+5 Билетов" (for proper grammar) +} + diff --git a/src/main/java/com/lottery/lottery/dto/TelegramApiResponse.java b/src/main/java/com/lottery/lottery/dto/TelegramApiResponse.java new file mode 100644 index 0000000..4927de1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TelegramApiResponse.java @@ -0,0 +1,19 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Telegram Bot API response wrapper. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class TelegramApiResponse { + + @JsonProperty("ok") + private Boolean ok; + + @JsonProperty("description") + private String description; +} diff --git a/src/main/java/com/lottery/lottery/dto/TelegramSendResult.java b/src/main/java/com/lottery/lottery/dto/TelegramSendResult.java new file mode 100644 index 0000000..cbbdbe4 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TelegramSendResult.java @@ -0,0 +1,17 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Result of a single Telegram send call for broadcast audit. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TelegramSendResult { + private boolean success; + /** HTTP status code from Telegram API (e.g. 200, 403, 429). */ + private int statusCode; +} diff --git a/src/main/java/com/lottery/lottery/dto/TicketDetailDto.java b/src/main/java/com/lottery/lottery/dto/TicketDetailDto.java new file mode 100644 index 0000000..38826cc --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TicketDetailDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import com.lottery.lottery.model.SupportTicket.TicketStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TicketDetailDto { + private Long id; + private String subject; + private TicketStatus status; + private Instant createdAt; + private Instant updatedAt; + private List messages; +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/TicketDto.java b/src/main/java/com/lottery/lottery/dto/TicketDto.java new file mode 100644 index 0000000..b908e2c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TicketDto.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.dto; + +import com.lottery.lottery.model.SupportTicket.TicketStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TicketDto { + private Long id; + private String subject; + private TicketStatus status; + private Instant createdAt; + private Instant updatedAt; + private Integer messageCount; +} + + + diff --git a/src/main/java/com/lottery/lottery/dto/TransactionDto.java b/src/main/java/com/lottery/lottery/dto/TransactionDto.java new file mode 100644 index 0000000..6c732e8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/TransactionDto.java @@ -0,0 +1,44 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for a transaction entry in transaction history. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TransactionDto { + /** + * Amount in bigint format (positive for credits, negative for debits). + * Example: +900000000 means +900.0000 (credit) + * Example: -100000000 means -100.0000 (debit) + */ + private Long amount; + + /** + * Date formatted as dd.MM at HH:mm (e.g., "13.01 at 22:29") + */ + private String date; + + /** + * Transaction type: DEPOSIT, WITHDRAWAL, WIN, BET, TASK_BONUS, DAILY_BONUS + */ + private String type; + + /** + * Task ID for TASK_BONUS type (null for DAILY_BONUS and other types) + */ + private Integer taskId; + + /** + * Round ID for WIN/BET type (null for other types) + */ + private Long roundId; +} + + diff --git a/src/main/java/com/lottery/lottery/dto/UserCheckDto.java b/src/main/java/com/lottery/lottery/dto/UserCheckDto.java new file mode 100644 index 0000000..3e04a49 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/UserCheckDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for user check endpoint response. + * Contains user information for external applications. + * Always returned with HTTP 200; use {@code found} to distinguish user-not-found from success. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserCheckDto { + /** When false, user was not found by telegramId; other fields are null. */ + private Boolean found; + private Integer dateReg; + private Double tickets; // balance_a / 1,000,000 + private Integer depositTotal; // Sum of completed payments stars_amount + private Integer refererId; // referer_id_1 from db_users_d + private Integer roundsPlayed; // rounds_played from db_users_b +} + diff --git a/src/main/java/com/lottery/lottery/dto/UserDepositDto.java b/src/main/java/com/lottery/lottery/dto/UserDepositDto.java new file mode 100644 index 0000000..d4710fd --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/UserDepositDto.java @@ -0,0 +1,23 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; + +/** Deposit (payment) row for admin user detail Deposits tab. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDepositDto { + private Long id; + private BigDecimal usdAmount; + private String status; + private String orderId; + private Instant createdAt; + private Instant completedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/UserDto.java b/src/main/java/com/lottery/lottery/dto/UserDto.java new file mode 100644 index 0000000..190ffae --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/UserDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + private Integer id; // User ID + private Long telegram_id; + private String username; + private String screenName; // User's screen name + private Integer dateReg; // Registration date (Unix timestamp in seconds) + private String ip; + private Long balanceA; // Balance (stored as bigint, represents number with 6 decimal places) + private String avatarUrl; // Public URL of user's avatar + private String languageCode; // User's language preference (EN, RU, DE, IT, NL, PL, FR, ES, ID, TR) + private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed + private Boolean payoutEnabled; // Runtime toggle: withdrawals (Payout, crypto withdrawal) allowed + private Boolean promotionsEnabled; // Runtime toggle: Promotions button and /api/promotions endpoints +} + diff --git a/src/main/java/com/lottery/lottery/dto/UserProgressDto.java b/src/main/java/com/lottery/lottery/dto/UserProgressDto.java new file mode 100644 index 0000000..7598599 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/UserProgressDto.java @@ -0,0 +1,15 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserProgressDto { + private Integer referals1; // Level 1 referrals count (used for referral tasks) +} + diff --git a/src/main/java/com/lottery/lottery/dto/UserWithdrawalDto.java b/src/main/java/com/lottery/lottery/dto/UserWithdrawalDto.java new file mode 100644 index 0000000..fbaad15 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/UserWithdrawalDto.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; + +/** Withdrawal (CRYPTO payout) row for admin user detail Withdrawals tab. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserWithdrawalDto { + private Long id; + private BigDecimal usdAmount; + private String cryptoName; // ticker (e.g. USDT) + private String amountToSend; // coins amount + private String txhash; // transaction id + private String status; + private Integer paymentId; // external payment id + private Instant createdAt; + private Instant resolvedAt; +} diff --git a/src/main/java/com/lottery/lottery/dto/WinnerDto.java b/src/main/java/com/lottery/lottery/dto/WinnerDto.java new file mode 100644 index 0000000..40bd773 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WinnerDto.java @@ -0,0 +1,35 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDto { + @JsonProperty("uI") + private Integer userId; + + @JsonProperty("sN") + private String screenName; + + @JsonProperty("aU") + private String avatarUrl; + + @JsonProperty("b") + private Long bet; // In tickets (not bigint) + + @JsonProperty("pO") + private Long payout; // In bigint format (with 6 decimal places) + + @JsonProperty("cO") + private Long commission; // In bigint format (with 6 decimal places) + + @JsonProperty("wC") + private Double winChance; // Winner's chance percentage (calculated from round's actual participant bets) +} + diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalApiRequest.java b/src/main/java/com/lottery/lottery/dto/WithdrawalApiRequest.java new file mode 100644 index 0000000..8d319e1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalApiRequest.java @@ -0,0 +1,55 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +/** + * Request body for external POST api/v1/withdrawal. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WithdrawalApiRequest { + + @JsonProperty("pid") + private Integer pid; + + @JsonProperty("user_id") + private Integer userId; + + @JsonProperty("wallet") + private String wallet; + + @JsonProperty("amount_usd") + private Double amountUsd; + + /** When 1, crypto system treats payout as manual. Omitted when null. Set only if user completed referral task 50 or 100. */ + @JsonProperty("manual_pay") + private Integer manualPay; + + @JsonProperty("user_data") + private UserData userData; + + @Data + @Builder + public static class UserData { + @JsonProperty("internal_id") + private Integer internalId; + @JsonProperty("screen_name") + private String screenName; + @JsonProperty("tg_username") + private String tgUsername; + @JsonProperty("tg_id") + private String tgId; + @JsonProperty("country_code") + private String countryCode; + @JsonProperty("device_code") + private String deviceCode; + @JsonProperty("language_code") + private String languageCode; + @JsonProperty("user_ip") + private String userIp; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalApiResponse.java b/src/main/java/com/lottery/lottery/dto/WithdrawalApiResponse.java new file mode 100644 index 0000000..4999750 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalApiResponse.java @@ -0,0 +1,62 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Response from external POST api/v1/withdrawal. + * Success: result.payment present. Business error: result.error with error_code 1 (wallet invalid) or other. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WithdrawalApiResponse { + + @JsonProperty("request_info") + private RequestInfo requestInfo; + + @JsonProperty("result") + private Result result; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequestInfo { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_message") + private String errorMessage; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Result { + @JsonProperty("payment") + private Payment payment; + @JsonProperty("error") + private ResultError error; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Payment { + @JsonProperty("payment_id") + private Integer paymentId; + @JsonProperty("ticker") + private String ticker; + @JsonProperty("amount_coins") + private String amountCoins; + @JsonProperty("comission_coins") + private String comissionCoins; + @JsonProperty("amount_to_send") + private String amountToSend; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultError { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_text") + private String errorText; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalInfoApiResponse.java b/src/main/java/com/lottery/lottery/dto/WithdrawalInfoApiResponse.java new file mode 100644 index 0000000..f27917d --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalInfoApiResponse.java @@ -0,0 +1,71 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * Response from external GET api/v1/withdrawals-info/{payment_id}. + * status in payment_list: "-1" PROCESSING, "0" WAITING, "1" COMPLETED, "2" CANCELLED. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WithdrawalInfoApiResponse { + + @JsonProperty("request_info") + private RequestInfo requestInfo; + + @JsonProperty("result") + private Result result; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequestInfo { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_message") + private String errorMessage; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Result { + @JsonProperty("payment_list") + private List paymentList; + @JsonProperty("hash") + private String hash; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PaymentItem { + @JsonProperty("payment_id") + private Integer paymentId; + @JsonProperty("user_id") + private Integer userId; + @JsonProperty("name") + private String name; + @JsonProperty("ticker") + private String ticker; + @JsonProperty("wallet") + private String wallet; + @JsonProperty("amount_usd") + private Double amountUsd; + @JsonProperty("amount_coins") + private String amountCoins; + @JsonProperty("amount_coins_fee") + private String amountCoinsFee; + @JsonProperty("txhash") + private String txhash; + @JsonProperty("status") + private String status; // "-1" PROCESSING, "0" WAITING, "1" COMPLETED, "2" CANCELLED + @JsonProperty("status_text") + private String statusText; + @JsonProperty("date_added") + private String dateAdded; + @JsonProperty("last_update") + private String lastUpdate; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalMethodDetailsDto.java b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodDetailsDto.java new file mode 100644 index 0000000..a21962a --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodDetailsDto.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * Single withdrawal method details from external API (rate and fee for Payout Confirmation screen). + */ +@Data +@Builder +public class WithdrawalMethodDetailsDto { + + @JsonProperty("pid") + private Integer pid; + + @JsonProperty("name") + private String name; + + @JsonProperty("ticker") + private String ticker; + + @JsonProperty("rateUsd") + private BigDecimal rateUsd; + + @JsonProperty("totalFeeUsd") + private BigDecimal mishaFeeUsd; +} diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsApiResponse.java b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsApiResponse.java new file mode 100644 index 0000000..e9d4b95 --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsApiResponse.java @@ -0,0 +1,62 @@ +package com.lottery.lottery.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** Response from external GET /api/v1/withdrawal-methods */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WithdrawalMethodsApiResponse { + + @JsonProperty("request_info") + private RequestInfo requestInfo; + + @JsonProperty("result") + private Result result; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequestInfo { + @JsonProperty("error_code") + private Integer errorCode; + @JsonProperty("error_message") + private String errorMessage; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Result { + @JsonProperty("active_methods") + private List activeMethods; + @JsonProperty("hash") + private String hash; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ActiveMethod { + @JsonProperty("pid") + private Integer pid; + @JsonProperty("name") + private String name; + @JsonProperty("ticker") + private String ticker; + @JsonProperty("icon_id") + private String iconId; + @JsonProperty("fee_network") + private String feeNetwork; + @JsonProperty("fee_network_usd") + private String feeNetworkUsd; + @JsonProperty("min_amount_pay") + private String minAmountPay; + @JsonProperty("min_amount_pay_usd") + private String minAmountPayUsd; + @JsonProperty("rate_usd") + private String rateUsd; + @JsonProperty("misha_fee_usd") + private Double mishaFeeUsd; + } +} diff --git a/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsDto.java b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsDto.java new file mode 100644 index 0000000..842250c --- /dev/null +++ b/src/main/java/com/lottery/lottery/dto/WithdrawalMethodsDto.java @@ -0,0 +1,31 @@ +package com.lottery.lottery.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** Response for GET /api/payments/withdrawal-methods */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WithdrawalMethodsDto { + + private List methods; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class WithdrawalMethodItemDto { + private Integer pid; + private String name; + private String network; + private String iconId; + private BigDecimal minWithdrawal; + } +} diff --git a/src/main/java/com/lottery/lottery/exception/BannedUserException.java b/src/main/java/com/lottery/lottery/exception/BannedUserException.java new file mode 100644 index 0000000..2391f95 --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/BannedUserException.java @@ -0,0 +1,12 @@ +package com.lottery.lottery.exception; + +/** + * Thrown when a banned user attempts to create a session or access the app. + * Handled by GlobalExceptionHandler to return 403 with code BANNED. + */ +public class BannedUserException extends RuntimeException { + + public BannedUserException(String message) { + super(message); + } +} diff --git a/src/main/java/com/lottery/lottery/exception/BetDecisionException.java b/src/main/java/com/lottery/lottery/exception/BetDecisionException.java new file mode 100644 index 0000000..ddce2ae --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/BetDecisionException.java @@ -0,0 +1,16 @@ +package com.lottery.lottery.exception; + +/** + * Thrown when bet amount cannot be decided (e.g. invalid bot range). + * Scheduler must not register the bot when this is thrown. + */ +public class BetDecisionException extends RuntimeException { + + public BetDecisionException(String message) { + super(message); + } + + public BetDecisionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/honey/honey/exception/ErrorResponse.java b/src/main/java/com/lottery/lottery/exception/ErrorResponse.java similarity index 84% rename from src/main/java/com/honey/honey/exception/ErrorResponse.java rename to src/main/java/com/lottery/lottery/exception/ErrorResponse.java index b0441ee..87cf256 100644 --- a/src/main/java/com/honey/honey/exception/ErrorResponse.java +++ b/src/main/java/com/lottery/lottery/exception/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.honey.honey.exception; +package com.lottery.lottery.exception; import lombok.AllArgsConstructor; import lombok.Data; @@ -12,3 +12,4 @@ public class ErrorResponse { private String message; } + diff --git a/src/main/java/com/lottery/lottery/exception/GameException.java b/src/main/java/com/lottery/lottery/exception/GameException.java new file mode 100644 index 0000000..6529273 --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/GameException.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.exception; + +import lombok.Getter; + +/** + * Base exception for game-related errors with user-friendly messages. + */ +@Getter +public class GameException extends RuntimeException { + private final String userMessage; + + public GameException(String userMessage) { + super(userMessage); + this.userMessage = userMessage; + } + + public GameException(String userMessage, Throwable cause) { + super(userMessage, cause); + this.userMessage = userMessage; + } +} + + + + + diff --git a/src/main/java/com/lottery/lottery/exception/GlobalExceptionHandler.java b/src/main/java/com/lottery/lottery/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e713d50 --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/GlobalExceptionHandler.java @@ -0,0 +1,115 @@ +package com.lottery.lottery.exception; + +import com.lottery.lottery.service.LocalizationService; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final LocalizationService localizationService; + + @ExceptionHandler(BannedUserException.class) + public ResponseEntity handleBannedUser(BannedUserException ex) { + log.warn("Banned user access attempt: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse("BANNED", ex.getMessage())); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException ex) { + log.warn("Unauthorized: {}", ex.getMessage()); + String localizedMessage = ex.getMessage(); + // Try to localize if message looks like a message code + if (ex.getMessage() != null && ex.getMessage().contains(".")) { + try { + localizedMessage = localizationService.getMessage(ex.getMessage()); + } catch (Exception e) { + // Use original message if localization fails + } + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("UNAUTHORIZED", localizedMessage)); + } + + @ExceptionHandler(GameException.class) + public ResponseEntity handleGameException(GameException ex) { + log.warn("GameException: {}", ex.getUserMessage()); + String localizedMessage = ex.getUserMessage(); + // Try to localize if message looks like a message code (contains dots) + if (ex.getUserMessage() != null && ex.getUserMessage().contains(".") && !ex.getUserMessage().contains(" ")) { + try { + localizedMessage = localizationService.getMessage(ex.getUserMessage()); + } catch (Exception e) { + // Use original message if localization fails + } + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("GAME_ERROR", localizedMessage)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getDefaultMessage()) + .findFirst() + .orElse(localizationService.getMessage("validation.error.required", "Field")); + log.warn("Validation error: {}", errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("VALIDATION_ERROR", errorMessage)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + String errorMessage = ex.getConstraintViolations().stream() + .map(violation -> violation.getMessage()) + .findFirst() + .orElse(localizationService.getMessage("validation.error.required", "Field")); + log.warn("Constraint violation: {}", errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("VALIDATION_ERROR", errorMessage)); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex) { + String paramName = ex.getName(); + String message = "telegramId".equals(paramName) + ? "Path parameter telegramId must be a numeric Telegram user ID (e.g. 757747558), not a placeholder." + : "Invalid value for parameter '" + paramName + "': expected " + (ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "valid type"); + log.warn("Invalid request parameter: {} = '{}'", paramName, ex.getValue()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("INVALID_PARAMETER", message)); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound(NoResourceFoundException ex) { + // Only log avatar-related missing resources without stacktrace + String resourcePath = ex.getResourcePath(); + if (resourcePath != null && resourcePath.startsWith("/avatars/")) { + log.debug("Avatar not found: {}", resourcePath); + } else { + log.debug("Resource not found: {}", resourcePath); + } + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex) { + log.error("Unexpected error", ex); + String localizedMessage = localizationService.getMessage("common.error.unknown"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("INTERNAL_ERROR", localizedMessage)); + } +} + + diff --git a/src/main/java/com/lottery/lottery/exception/InsufficientBalanceException.java b/src/main/java/com/lottery/lottery/exception/InsufficientBalanceException.java new file mode 100644 index 0000000..57900db --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/InsufficientBalanceException.java @@ -0,0 +1,8 @@ +package com.lottery.lottery.exception; + +public class InsufficientBalanceException extends GameException { + public InsufficientBalanceException(String localizedMessage) { + super(localizedMessage); + } +} + diff --git a/src/main/java/com/lottery/lottery/exception/InvalidBetAmountException.java b/src/main/java/com/lottery/lottery/exception/InvalidBetAmountException.java new file mode 100644 index 0000000..6dd2760 --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/InvalidBetAmountException.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.exception; + +import com.lottery.lottery.service.LocalizationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Exception for invalid bet amounts. + * Uses localization service to get user's language preference. + */ +public class InvalidBetAmountException extends GameException { + public InvalidBetAmountException(String localizedMessage) { + super(localizedMessage); + } + + /** + * Factory method to create exception with localized message. + * Converts bigint values to display format (divide by 1,000,000). + */ + public static InvalidBetAmountException create(LocalizationService localizationService, long minBet, long maxBet) { + // Convert bigint to display format for user-friendly message + double minBetDisplay = minBet / 1_000_000.0; + double maxBetDisplay = maxBet / 1_000_000.0; + String message = localizationService.getMessage("game.error.invalidBetAmount", + String.format("%.2f", minBetDisplay), + String.format("%.2f", maxBetDisplay)); + return new InvalidBetAmountException(message); + } +} + diff --git a/src/main/java/com/lottery/lottery/exception/RoomNotJoinableException.java b/src/main/java/com/lottery/lottery/exception/RoomNotJoinableException.java new file mode 100644 index 0000000..e8543c9 --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/RoomNotJoinableException.java @@ -0,0 +1,14 @@ +package com.lottery.lottery.exception; + +import com.lottery.lottery.model.GamePhase; +import com.lottery.lottery.service.LocalizationService; + +public class RoomNotJoinableException extends GameException { + public RoomNotJoinableException(GamePhase currentPhase, LocalizationService localizationService) { + super(localizationService.getMessage("game.error.roomNotJoinable")); + } +} + + + + diff --git a/src/main/java/com/honey/honey/exception/UnauthorizedException.java b/src/main/java/com/lottery/lottery/exception/UnauthorizedException.java similarity index 78% rename from src/main/java/com/honey/honey/exception/UnauthorizedException.java rename to src/main/java/com/lottery/lottery/exception/UnauthorizedException.java index 1b2346c..b6dcc3c 100644 --- a/src/main/java/com/honey/honey/exception/UnauthorizedException.java +++ b/src/main/java/com/lottery/lottery/exception/UnauthorizedException.java @@ -1,4 +1,4 @@ -package com.honey.honey.exception; +package com.lottery.lottery.exception; public class UnauthorizedException extends RuntimeException { public UnauthorizedException(String message) { @@ -6,3 +6,4 @@ public class UnauthorizedException extends RuntimeException { } } + diff --git a/src/main/java/com/lottery/lottery/exception/UserAlreadyJoinedException.java b/src/main/java/com/lottery/lottery/exception/UserAlreadyJoinedException.java new file mode 100644 index 0000000..16363ab --- /dev/null +++ b/src/main/java/com/lottery/lottery/exception/UserAlreadyJoinedException.java @@ -0,0 +1,12 @@ +package com.lottery.lottery.exception; + +public class UserAlreadyJoinedException extends GameException { + public UserAlreadyJoinedException() { + super("You have already joined this round. Please wait for the next round."); + } +} + + + + + diff --git a/src/main/java/com/lottery/lottery/health/DatabaseHealthIndicator.java b/src/main/java/com/lottery/lottery/health/DatabaseHealthIndicator.java new file mode 100644 index 0000000..9df9af6 --- /dev/null +++ b/src/main/java/com/lottery/lottery/health/DatabaseHealthIndicator.java @@ -0,0 +1,130 @@ +package com.lottery.lottery.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + @Override + public Health health() { + try (Connection connection = dataSource.getConnection()) { + if (!connection.isValid(1)) { + return Health.down() + .withDetail("database", "MySQL") + .withDetail("status", "Connection invalid") + .build(); + } + + // Check if Flyway schema history table exists and migrations are complete + // This ensures migrations have run before health check passes + boolean migrationsComplete = checkFlywayMigrations(connection); + + if (migrationsComplete) { + return Health.up() + .withDetail("database", "MySQL") + .withDetail("status", "Connected") + .withDetail("migrations", "Complete") + .build(); + } else { + // Migrations not complete yet - return DOWN to prevent health check from passing + return Health.down() + .withDetail("database", "MySQL") + .withDetail("status", "Connected") + .withDetail("migrations", "Pending") + .build(); + } + } catch (SQLException e) { + log.error("Database health check failed", e); + return Health.down() + .withDetail("database", "MySQL") + .withDetail("error", e.getMessage()) + .build(); + } + } + + /** + * Checks if Flyway migrations are complete by verifying: + * 1. The flyway_schema_history table exists + * 2. There are no pending migrations (all migrations have been applied) + * + * This prevents the health check from passing before migrations complete, + * which is important during rolling updates when both old and new containers + * might be running simultaneously. + */ + private boolean checkFlywayMigrations(Connection connection) { + try { + // Check if flyway_schema_history table exists + // Flyway creates this table in the same database as the application + String checkTableQuery = "SELECT COUNT(*) as count FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = 'flyway_schema_history'"; + + try (PreparedStatement stmt = connection.prepareStatement(checkTableQuery); + ResultSet rs = stmt.executeQuery()) { + + if (!rs.next() || rs.getInt("count") == 0) { + // Table doesn't exist yet - migrations haven't started + log.debug("Flyway schema history table not found - migrations not started"); + return false; + } + } + + // Check if there are any pending migrations + // Pending migrations have success = 0 or don't exist in the history + // We check by comparing the installed_rank with the expected count + // If all migrations are applied, the highest installed_rank should match the total + String checkPendingQuery = "SELECT " + + "(SELECT COUNT(*) FROM flyway_schema_history WHERE success = 1) as applied, " + + "(SELECT MAX(installed_rank) FROM flyway_schema_history WHERE success = 1) as max_rank, " + + "(SELECT COUNT(*) FROM flyway_schema_history) as total"; + + try (PreparedStatement stmt = connection.prepareStatement(checkPendingQuery); + ResultSet rs = stmt.executeQuery()) { + + if (rs.next()) { + int applied = rs.getInt("applied"); + int maxRank = rs.getInt("max_rank"); + int total = rs.getInt("total"); + + // If there are failed migrations (total > applied), migrations are not complete + if (total > applied) { + log.warn("Flyway migrations incomplete: {} applied, {} total (some failed)", applied, total); + return false; + } + + // If max_rank is 0 and total is 0, no migrations have run yet + if (maxRank == 0 && total == 0) { + log.debug("No Flyway migrations found in history"); + return false; + } + + // All migrations applied successfully + log.debug("Flyway migrations complete: {} migrations applied", applied); + return true; + } + } + + return false; + } catch (SQLException e) { + // If we can't check migrations, assume they're not complete + // This is safer than assuming they are complete + log.warn("Could not check Flyway migration status: {}", e.getMessage()); + return false; + } + } +} + + diff --git a/src/main/java/com/lottery/lottery/logging/GrafanaLoggingConfig.java b/src/main/java/com/lottery/lottery/logging/GrafanaLoggingConfig.java new file mode 100644 index 0000000..f3acad1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/logging/GrafanaLoggingConfig.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.logging; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +/** + * Configuration for Grafana integration. + * This class prepares the logging infrastructure for Grafana. + * + * In production (Inferno), logs will be sent to Grafana via: + * - Loki (log aggregation) + * - Prometheus (metrics) + * + * For now, this is a placeholder that ensures structured logging + * is ready for Grafana integration. + */ +/** + * Grafana logging configuration - DISABLED + * Remote logging is disabled for security and to prevent external connections. + * All logging is handled locally via standard SLF4J. + */ +// @Slf4j +// @Configuration +public class GrafanaLoggingConfig { + // Disabled - no remote logging configured +} + + diff --git a/src/main/java/com/lottery/lottery/model/Admin.java b/src/main/java/com/lottery/lottery/model/Admin.java new file mode 100644 index 0000000..db93112 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Admin.java @@ -0,0 +1,48 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "admins") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Admin { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "user_id", nullable = true) + private Integer userId; + + @Column(name = "username", nullable = false, unique = true, length = 50) + private String username; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "role", nullable = false, length = 20) + @Builder.Default + private String role = "ROLE_ADMIN"; + + @Column(name = "created_at", nullable = false, updatable = false) + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at", nullable = false) + @Builder.Default + private LocalDateTime updatedAt = LocalDateTime.now(); + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/Configuration.java b/src/main/java/com/lottery/lottery/model/Configuration.java new file mode 100644 index 0000000..1c37175 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Configuration.java @@ -0,0 +1,21 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "configurations") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Configuration { + + @Id + @Column(name = "`key`", length = 128, nullable = false) + private String key; + + @Column(name = "value", length = 512, nullable = false) + private String value; +} diff --git a/src/main/java/com/lottery/lottery/model/CryptoDepositConfig.java b/src/main/java/com/lottery/lottery/model/CryptoDepositConfig.java new file mode 100644 index 0000000..6a320be --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/CryptoDepositConfig.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "crypto_deposit_config") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CryptoDepositConfig { + + @Id + @Column(name = "id") + private Integer id = 1; + + @Column(name = "methods_hash", length = 255) + private String methodsHash; + + @Column(name = "minimum_deposit", nullable = false, precision = 10, scale = 2) + private BigDecimal minimumDeposit; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/model/CryptoDepositMethod.java b/src/main/java/com/lottery/lottery/model/CryptoDepositMethod.java new file mode 100644 index 0000000..faa956c --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/CryptoDepositMethod.java @@ -0,0 +1,39 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "crypto_deposit_methods") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CryptoDepositMethod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "pid", nullable = false, unique = true) + private Integer pid; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "network", nullable = false, length = 50) + private String network; + + @Column(name = "example", length = 255) + private String example; + + @Column(name = "min_deposit_sum", nullable = false, precision = 10, scale = 2) + private BigDecimal minDepositSum; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/model/CryptoWithdrawalMethod.java b/src/main/java/com/lottery/lottery/model/CryptoWithdrawalMethod.java new file mode 100644 index 0000000..0232578 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/CryptoWithdrawalMethod.java @@ -0,0 +1,39 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "crypto_withdrawal_methods") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CryptoWithdrawalMethod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "pid", nullable = false, unique = true) + private Integer pid; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "network", nullable = false, length = 100) + private String network; + + @Column(name = "icon_id", nullable = false, length = 20) + private String iconId; + + @Column(name = "min_withdrawal", nullable = false, precision = 10, scale = 2) + private BigDecimal minWithdrawal; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/lottery/lottery/model/FeatureSwitch.java b/src/main/java/com/lottery/lottery/model/FeatureSwitch.java new file mode 100644 index 0000000..ffc7b0e --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/FeatureSwitch.java @@ -0,0 +1,32 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "feature_switches") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FeatureSwitch { + + @Id + @Column(name = "`key`", length = 64, nullable = false) + private String key; + + @Column(name = "enabled", nullable = false) + private boolean enabled; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PreUpdate + @PrePersist + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java b/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java new file mode 100644 index 0000000..cecab2c --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/FlexibleBotConfig.java @@ -0,0 +1,34 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "flexible_bot_configs") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FlexibleBotConfig { + + @Id + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** Win rate 0.0 to 1.0 (e.g. 0.25 = 25%). */ + @Column(name = "win_rate", nullable = false, precision = 5, scale = 4) + private BigDecimal winRate; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PreUpdate + @PrePersist + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/lottery/lottery/model/GamePhase.java b/src/main/java/com/lottery/lottery/model/GamePhase.java new file mode 100644 index 0000000..5f81c6d --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/GamePhase.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.model; + +public enum GamePhase { + WAITING, // Zero or one player joined + COUNTDOWN, // 30 seconds countdown started + SPINNING, // Spin animation in progress + RESOLUTION // Winner resolved, payout applied +} + + + + + diff --git a/src/main/java/com/lottery/lottery/model/GameRoom.java b/src/main/java/com/lottery/lottery/model/GameRoom.java new file mode 100644 index 0000000..d9baa05 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/GameRoom.java @@ -0,0 +1,58 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "game_rooms") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameRoom { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "room_number", unique = true, nullable = false) + private Integer roomNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "current_phase", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + @Builder.Default + private GamePhase currentPhase = GamePhase.WAITING; + + @Column(name = "countdown_end_at") + private Instant countdownEndAt; + + @Column(name = "total_bet", nullable = false) + @Builder.Default + private Long totalBet = 0L; + + @Column(name = "registered_players", nullable = false) + @Builder.Default + private Integer registeredPlayers = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/GameRound.java b/src/main/java/com/lottery/lottery/model/GameRound.java new file mode 100644 index 0000000..020a671 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/GameRound.java @@ -0,0 +1,68 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "game_rounds") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameRound { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private GameRoom room; + + @Enumerated(EnumType.STRING) + @Column(name = "phase", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + private GamePhase phase; + + @Column(name = "total_bet", nullable = false) + private Long totalBet; + + @Column(name = "winner_user_id") + private Integer winnerUserId; + + @Column(name = "winner_bet", nullable = false) + @Builder.Default + private Long winnerBet = 0L; + + @Column(name = "commission", nullable = false) + @Builder.Default + private Long commission = 0L; + + @Column(name = "payout", nullable = false) + @Builder.Default + private Long payout = 0L; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "countdown_started_at") + private Instant countdownStartedAt; + + @Column(name = "countdown_ended_at") + private Instant countdownEndedAt; + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java b/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java new file mode 100644 index 0000000..f701ec2 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/GameRoundParticipant.java @@ -0,0 +1,40 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "game_round_participants") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GameRoundParticipant { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "round_id", nullable = false) + private GameRound round; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "bet", nullable = false) + private Long bet; + + @Column(name = "joined_at", nullable = false, updatable = false) + private Instant joinedAt; + + @PrePersist + protected void onCreate() { + joinedAt = Instant.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java b/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java new file mode 100644 index 0000000..164946c --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/LotteryBotConfig.java @@ -0,0 +1,75 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalTime; +import java.time.Instant; + +@Entity +@Table(name = "lottery_bot_configs") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LotteryBotConfig { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "user_id", nullable = false, unique = true) + private Integer userId; + + @Column(name = "room_1", nullable = false) + @Builder.Default + private Boolean room1 = false; + + @Column(name = "room_2", nullable = false) + @Builder.Default + private Boolean room2 = false; + + @Column(name = "room_3", nullable = false) + @Builder.Default + private Boolean room3 = false; + + @Column(name = "time_utc_start", nullable = false) + private LocalTime timeUtcStart; + + @Column(name = "time_utc_end", nullable = false) + private LocalTime timeUtcEnd; + + @Column(name = "bet_min", nullable = false) + private Long betMin; + + @Column(name = "bet_max", nullable = false) + private Long betMax; + + @Column(name = "persona", nullable = false, length = 20) + @Builder.Default + private String persona = "balanced"; + + @Column(name = "active", nullable = false) + @Builder.Default + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/lottery/lottery/model/NotificationAudit.java b/src/main/java/com/lottery/lottery/model/NotificationAudit.java new file mode 100644 index 0000000..469715b --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/NotificationAudit.java @@ -0,0 +1,35 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "notifications_audit") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationAudit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "status", nullable = false, length = 20) + private String status; // SUCCESS or FAILED + + @Column(name = "telegram_status_code") + private Integer telegramStatusCode; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAILED = "FAILED"; +} diff --git a/src/main/java/com/lottery/lottery/model/Payment.java b/src/main/java/com/lottery/lottery/model/Payment.java new file mode 100644 index 0000000..ae47eea --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Payment.java @@ -0,0 +1,67 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "payments") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "order_id", nullable = false, unique = true, length = 255) + private String orderId; + + @Column(name = "stars_amount", nullable = false) + private Integer starsAmount; + + @Column(name = "usd_amount", precision = 20, scale = 2) + private BigDecimal usdAmount; // stored as decimal, e.g. 1.25 USD = 1.25 + + @Column(name = "tickets_amount", nullable = false) + private Long ticketsAmount; // Tickets amount in bigint format + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + @Builder.Default + private PaymentStatus status = PaymentStatus.PENDING; + + @Column(name = "telegram_payment_charge_id", length = 255) + private String telegramPaymentChargeId; + + @Column(name = "telegram_provider_payment_charge_id", length = 255) + private String telegramProviderPaymentChargeId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + } + + public enum PaymentStatus { + PENDING, COMPLETED, FAILED, CANCELLED + } +} + + + + diff --git a/src/main/java/com/lottery/lottery/model/Payout.java b/src/main/java/com/lottery/lottery/model/Payout.java new file mode 100644 index 0000000..2089036 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Payout.java @@ -0,0 +1,109 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Entity +@Table(name = "payouts") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Payout { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "username", nullable = false, length = 255) + private String username; + + @Column(name = "wallet", length = 120) + private String wallet; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + private PayoutType type; + + @Enumerated(EnumType.STRING) + @Column(name = "gift_name", length = 50, columnDefinition = "VARCHAR(50)") + private GiftType giftName; + + @Column(name = "crypto_name", length = 20) + private String cryptoName; + + @Column(name = "total", nullable = false) + private Long total; // Tickets amount in bigint format + + @Column(name = "stars_amount", nullable = false) + private Integer starsAmount; + + @Column(name = "usd_amount", precision = 20, scale = 2) + private BigDecimal usdAmount; // stored as decimal, e.g. 1.25 USD = 1.25 + + @Column(name = "amount_coins", length = 50) + private String amountCoins; + + @Column(name = "commission_coins", length = 50) + private String commissionCoins; + + @Column(name = "amount_to_send", length = 50) + private String amountToSend; + + @Column(name = "payment_id") + private Integer paymentId; // Crypto API payment id from withdrawal response + + @Column(name = "txhash", length = 255) + private String txhash; // Transaction hash from crypto API (WithdrawalInfoApiResponse.PaymentItem) + + @Column(name = "quantity", nullable = false) + @Builder.Default + private Integer quantity = 1; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + @Builder.Default + private PayoutStatus status = PayoutStatus.PROCESSING; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + public enum PayoutType { + STARS, GIFT, CRYPTO + } + + public enum PayoutStatus { + PROCESSING, COMPLETED, CANCELLED, WAITING + } + + public enum GiftType { + HEART, BEAR, GIFTBOX, FLOWER, CAKE, BOUQUET, ROCKET, CUP, RING, DIAMOND, CHAMPAGNE + } +} + diff --git a/src/main/java/com/lottery/lottery/model/Promotion.java b/src/main/java/com/lottery/lottery/model/Promotion.java new file mode 100644 index 0000000..50a6021 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Promotion.java @@ -0,0 +1,72 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "promotions") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Promotion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 32, columnDefinition = "VARCHAR(32)") + private PromotionType type; + + @Column(name = "start_time", nullable = false) + private Instant startTime; + + @Column(name = "end_time", nullable = false) + private Instant endTime; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)") + @Builder.Default + private PromotionStatus status = PromotionStatus.PLANNED; + + /** Total prize fund in bigint (1 ticket = 1_000_000). */ + @Column(name = "total_reward") + private Long totalReward; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + public enum PromotionType { + NET_WIN, + /** Same as NET_WIN but points only when winner made max bet in the room. */ + NET_WIN_MAX_BET, + /** 1 point per referral (level 1) who played at least one round (awarded when referral completes first round). */ + REF_COUNT + } + + public enum PromotionStatus { + ACTIVE, + INACTIVE, + FINISHED, + PLANNED + } +} diff --git a/src/main/java/com/lottery/lottery/model/PromotionReward.java b/src/main/java/com/lottery/lottery/model/PromotionReward.java new file mode 100644 index 0000000..3ce99b4 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/PromotionReward.java @@ -0,0 +1,48 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "promotions_rewards") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotionReward { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "promo_id", nullable = false) + private Promotion promotion; + + @Column(name = "place", nullable = false) + private Integer place; + + @Column(name = "reward", nullable = false) + private Long reward; // tickets in bigint (1 ticket = 1_000_000) + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/lottery/lottery/model/PromotionUser.java b/src/main/java/com/lottery/lottery/model/PromotionUser.java new file mode 100644 index 0000000..e0a20f3 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/PromotionUser.java @@ -0,0 +1,67 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; + +@Entity +@Table(name = "promotions_users") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@IdClass(PromotionUser.PromotionUserId.class) +public class PromotionUser { + + @Id + @Column(name = "promo_id", nullable = false) + private Integer promoId; + + @Id + @Column(name = "user_id", nullable = false) + private Integer userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "promo_id", nullable = false, insertable = false, updatable = false) + private Promotion promotion; + + @Column(name = "points", nullable = false, precision = 20, scale = 2) + @Builder.Default + private BigDecimal points = BigDecimal.ZERO; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class PromotionUserId implements Serializable { + private Integer promoId; + private Integer userId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PromotionUserId that = (PromotionUserId) o; + return Objects.equals(promoId, that.promoId) && Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(promoId, userId); + } + } +} diff --git a/src/main/java/com/lottery/lottery/model/QuickAnswer.java b/src/main/java/com/lottery/lottery/model/QuickAnswer.java new file mode 100644 index 0000000..9a8f495 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/QuickAnswer.java @@ -0,0 +1,42 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "quick_answers") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class QuickAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private Admin admin; + + @Column(name = "text", nullable = false, columnDefinition = "TEXT") + private String text; + + @Column(name = "created_at", nullable = false, updatable = false) + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at", nullable = false) + @Builder.Default + private LocalDateTime updatedAt = LocalDateTime.now(); + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/SafeBotUser.java b/src/main/java/com/lottery/lottery/model/SafeBotUser.java new file mode 100644 index 0000000..9e6f637 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/SafeBotUser.java @@ -0,0 +1,18 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "safe_bot_users") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SafeBotUser { + + @Id + @Column(name = "user_id", nullable = false) + private Integer userId; +} diff --git a/src/main/java/com/honey/honey/model/Session.java b/src/main/java/com/lottery/lottery/model/Session.java similarity index 95% rename from src/main/java/com/honey/honey/model/Session.java rename to src/main/java/com/lottery/lottery/model/Session.java index bc58daf..b0d1b6f 100644 --- a/src/main/java/com/honey/honey/model/Session.java +++ b/src/main/java/com/lottery/lottery/model/Session.java @@ -1,4 +1,4 @@ -package com.honey.honey.model; +package com.lottery.lottery.model; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/lottery/lottery/model/SupportMessage.java b/src/main/java/com/lottery/lottery/model/SupportMessage.java new file mode 100644 index 0000000..d80e6e7 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/SupportMessage.java @@ -0,0 +1,40 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "support_messages") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SupportMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private SupportTicket ticket; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserA user; + + @Column(name = "message", nullable = false, length = 2000) + private String message; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + } +} + diff --git a/src/main/java/com/lottery/lottery/model/SupportTicket.java b/src/main/java/com/lottery/lottery/model/SupportTicket.java new file mode 100644 index 0000000..f7bf6e7 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/SupportTicket.java @@ -0,0 +1,57 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "support_tickets") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SupportTicket { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserA user; + + @Column(name = "subject", nullable = false, length = 100) + private String subject; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 10) + @Builder.Default + private TicketStatus status = TicketStatus.OPENED; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + public enum TicketStatus { + OPENED, + CLOSED + } +} + + + diff --git a/src/main/java/com/lottery/lottery/model/Task.java b/src/main/java/com/lottery/lottery/model/Task.java new file mode 100644 index 0000000..a9226ca --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Task.java @@ -0,0 +1,43 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "tasks") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Task { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "type", nullable = false, length = 20) + private String type; // referral, follow, other + + @Column(name = "requirement", nullable = false) + private Long requirement; + + @Column(name = "reward_amount", nullable = false) + private Long rewardAmount; + + @Column(name = "reward_type", nullable = false, length = 20) + @Builder.Default + private String rewardType = "Tickets"; + + @Column(name = "display_order", nullable = false) + @Builder.Default + private Integer displayOrder = 0; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; +} + diff --git a/src/main/java/com/lottery/lottery/model/Transaction.java b/src/main/java/com/lottery/lottery/model/Transaction.java new file mode 100644 index 0000000..c405cc5 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/Transaction.java @@ -0,0 +1,61 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "transactions") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "amount", nullable = false) + private Long amount; // Amount in bigint format (positive for credits, negative for debits) + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50, columnDefinition = "VARCHAR(50)") + private TransactionType type; + + @Column(name = "task_id") + private Integer taskId; // Task ID for TASK_BONUS type + + @Column(name = "round_id") + private Long roundId; // Round ID for WIN/BET type + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + // Only set createdAt if it's not already set (allows custom timestamps) + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public enum TransactionType { + DEPOSIT, // Payment/deposit + WITHDRAWAL, // Payout/withdrawal + WIN, // Game round win (total payout) + BET, // Game round bet (for all participants, winners and losers) + @Deprecated + LOSS, // Legacy: Old bet type, replaced by BET (kept for backward compatibility with old database records) + TASK_BONUS, // Task reward + DAILY_BONUS, // Daily bonus reward (no taskId) + CANCELLATION_OF_WITHDRAWAL // Cancellation of withdrawal (payout cancelled by admin) + } +} + diff --git a/src/main/java/com/honey/honey/model/UserA.java b/src/main/java/com/lottery/lottery/model/UserA.java similarity index 87% rename from src/main/java/com/honey/honey/model/UserA.java rename to src/main/java/com/lottery/lottery/model/UserA.java index 24de1ba..7dba0bd 100644 --- a/src/main/java/com/honey/honey/model/UserA.java +++ b/src/main/java/com/lottery/lottery/model/UserA.java @@ -1,4 +1,4 @@ -package com.honey.honey.model; +package com.lottery.lottery.model; import jakarta.persistence.*; import lombok.*; @@ -58,5 +58,12 @@ public class UserA { @Column(name = "banned", nullable = false) @Builder.Default private Integer banned = 0; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Column(name = "last_telegram_file_id", length = 255) + private String lastTelegramFileId; } + diff --git a/src/main/java/com/honey/honey/model/UserB.java b/src/main/java/com/lottery/lottery/model/UserB.java similarity index 57% rename from src/main/java/com/honey/honey/model/UserB.java rename to src/main/java/com/lottery/lottery/model/UserB.java index cfea42f..fed6662 100644 --- a/src/main/java/com/honey/honey/model/UserB.java +++ b/src/main/java/com/lottery/lottery/model/UserB.java @@ -1,4 +1,4 @@ -package com.honey.honey.model; +package com.lottery.lottery.model; import jakarta.persistence.*; import lombok.*; @@ -39,5 +39,20 @@ public class UserB { @Column(name = "withdraw_count", nullable = false) @Builder.Default private Integer withdrawCount = 0; + + @Column(name = "rounds_played", nullable = false) + @Builder.Default + private Integer roundsPlayed = 0; + + /** Total winnings since last deposit (bigint: 1 ticket = 1_000_000). Reset to 0 on deposit; incremented on round win; reduced when payout is created. */ + @Column(name = "total_win_after_deposit", nullable = false) + @Builder.Default + private Long totalWinAfterDeposit = 0L; + + /** When true, the user cannot create any payout request (blocked on backend). */ + @Column(name = "withdrawals_disabled", nullable = false) + @Builder.Default + private Boolean withdrawalsDisabled = false; } + diff --git a/src/main/java/com/honey/honey/model/UserD.java b/src/main/java/com/lottery/lottery/model/UserD.java similarity index 94% rename from src/main/java/com/honey/honey/model/UserD.java rename to src/main/java/com/lottery/lottery/model/UserD.java index ace3413..97b82bd 100644 --- a/src/main/java/com/honey/honey/model/UserD.java +++ b/src/main/java/com/lottery/lottery/model/UserD.java @@ -1,4 +1,4 @@ -package com.honey.honey.model; +package com.lottery.lottery.model; import jakarta.persistence.*; import lombok.*; @@ -16,6 +16,10 @@ public class UserD { @Column(name = "id") private Integer id; + @Column(name = "screen_name", nullable = false, length = 75) + @Builder.Default + private String screenName = "-"; + @Column(name = "referer_id_1", nullable = false) @Builder.Default private Integer refererId1 = 0; @@ -101,3 +105,4 @@ public class UserD { private Long toReferer5 = 0L; } + diff --git a/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java b/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java new file mode 100644 index 0000000..3f1863f --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/UserDailyBonusClaim.java @@ -0,0 +1,42 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_daily_bonus_claims") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDailyBonusClaim { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "avatar_url", length = 255) + private String avatarUrl; + + @Column(name = "screen_name", nullable = false, length = 75) + @Builder.Default + private String screenName = "-"; + + @Column(name = "claimed_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + private LocalDateTime claimedAt; + + @PrePersist + protected void onCreate() { + if (claimedAt == null) { + claimedAt = LocalDateTime.now(); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/model/UserTaskClaim.java b/src/main/java/com/lottery/lottery/model/UserTaskClaim.java new file mode 100644 index 0000000..21c90b2 --- /dev/null +++ b/src/main/java/com/lottery/lottery/model/UserTaskClaim.java @@ -0,0 +1,42 @@ +package com.lottery.lottery.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_task_claims") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserTaskClaim { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "task_id", nullable = false) + private Integer taskId; + + @Column(name = "claimed_at", nullable = false, updatable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + private LocalDateTime claimedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "task_id", insertable = false, updatable = false) + private Task task; + + @PrePersist + protected void onCreate() { + if (claimedAt == null) { + claimedAt = LocalDateTime.now(); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/repository/AdminRepository.java b/src/main/java/com/lottery/lottery/repository/AdminRepository.java new file mode 100644 index 0000000..65f18a7 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/AdminRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Admin; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AdminRepository extends JpaRepository { + Optional findByUsername(String username); +} + diff --git a/src/main/java/com/lottery/lottery/repository/ConfigurationRepository.java b/src/main/java/com/lottery/lottery/repository/ConfigurationRepository.java new file mode 100644 index 0000000..2d85d5d --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/ConfigurationRepository.java @@ -0,0 +1,9 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Configuration; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ConfigurationRepository extends JpaRepository { +} diff --git a/src/main/java/com/lottery/lottery/repository/CryptoDepositConfigRepository.java b/src/main/java/com/lottery/lottery/repository/CryptoDepositConfigRepository.java new file mode 100644 index 0000000..0f70098 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/CryptoDepositConfigRepository.java @@ -0,0 +1,9 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.CryptoDepositConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CryptoDepositConfigRepository extends JpaRepository { +} diff --git a/src/main/java/com/lottery/lottery/repository/CryptoDepositMethodRepository.java b/src/main/java/com/lottery/lottery/repository/CryptoDepositMethodRepository.java new file mode 100644 index 0000000..125bf28 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/CryptoDepositMethodRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.CryptoDepositMethod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CryptoDepositMethodRepository extends JpaRepository { + + Optional findByPid(Integer pid); +} diff --git a/src/main/java/com/lottery/lottery/repository/CryptoWithdrawalMethodRepository.java b/src/main/java/com/lottery/lottery/repository/CryptoWithdrawalMethodRepository.java new file mode 100644 index 0000000..78bcc2a --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/CryptoWithdrawalMethodRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.CryptoWithdrawalMethod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CryptoWithdrawalMethodRepository extends JpaRepository { + + List findAllByOrderByPidAsc(); +} diff --git a/src/main/java/com/lottery/lottery/repository/FeatureSwitchRepository.java b/src/main/java/com/lottery/lottery/repository/FeatureSwitchRepository.java new file mode 100644 index 0000000..b428fed --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/FeatureSwitchRepository.java @@ -0,0 +1,9 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.FeatureSwitch; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FeatureSwitchRepository extends JpaRepository { +} diff --git a/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java b/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java new file mode 100644 index 0000000..1c67603 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/FlexibleBotConfigRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.FlexibleBotConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface FlexibleBotConfigRepository extends JpaRepository { + + List findAllByOrderByUserIdAsc(); +} diff --git a/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java new file mode 100644 index 0000000..e7ca749 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/GameRoomRepository.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.GamePhase; +import com.lottery.lottery.model.GameRoom; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface GameRoomRepository extends JpaRepository { + Optional findByRoomNumber(Integer roomNumber); + + // Efficient query for rooms in specific phase (uses index on current_phase) + List findByCurrentPhase(GamePhase phase); + + /** + * Finds room by room number with pessimistic write lock to prevent race conditions. + * This ensures only one transaction can update the room at a time. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM GameRoom r WHERE r.roomNumber = :roomNumber") + Optional findByRoomNumberWithLock(@Param("roomNumber") Integer roomNumber); +} + diff --git a/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java new file mode 100644 index 0000000..8a5efbf --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/GameRoundParticipantRepository.java @@ -0,0 +1,53 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.GameRoundParticipant; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface GameRoundParticipantRepository extends JpaRepository { + List findByRoundId(Long roundId); + + @Query("SELECT p FROM GameRoundParticipant p WHERE p.round.id = :roundId AND p.userId = :userId") + List findByRoundIdAndUserId(@Param("roundId") Long roundId, @Param("userId") Integer userId); + + /** + * Finds participant by ID with pessimistic write lock to prevent race conditions. + * This ensures only one transaction can update the participant at a time. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM GameRoundParticipant p WHERE p.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + /** + * Finds all rounds where the user participated, ordered by resolution time (newest first). + * Only returns completed rounds (phase = RESOLUTION, resolvedAt IS NOT NULL). + */ + @Query("SELECT p FROM GameRoundParticipant p " + + "WHERE p.userId = :userId " + + "AND p.round.phase = 'RESOLUTION' " + + "AND p.round.resolvedAt IS NOT NULL " + + "ORDER BY p.round.resolvedAt DESC") + List findUserCompletedRounds(@Param("userId") Integer userId, + org.springframework.data.domain.Pageable pageable); + + /** + * Batch deletes participants older than the specified date (up to batchSize). + * Returns the number of deleted rows. + * Note: MySQL requires LIMIT to be used directly in DELETE statements. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "DELETE FROM game_round_participants WHERE joined_at < :cutoffDate LIMIT :batchSize", nativeQuery = true) + int deleteOldParticipantsBatch(@Param("cutoffDate") Instant cutoffDate, @Param("batchSize") int batchSize); +} + + diff --git a/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java b/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java new file mode 100644 index 0000000..b147a0e --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/GameRoundRepository.java @@ -0,0 +1,61 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.GameRound; +import com.lottery.lottery.model.GameRoom; +import com.lottery.lottery.model.GamePhase; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface GameRoundRepository extends JpaRepository { + + /** Fetch rounds by ids with room loaded (for admin game history). */ + @Query("SELECT r FROM GameRound r LEFT JOIN FETCH r.room WHERE r.id IN :ids") + List findAllByIdWithRoom(@Param("ids") Set ids); + + /** + * Finds the most recent active round(s) for a room, ordered by startedAt DESC. + * Use Pageable with size 1 to get only the single most recent round (resilient to corrupted data with multiple rounds in same phase). + */ + @Query("SELECT r FROM GameRound r WHERE r.room.id = :roomId AND r.phase IN :phases ORDER BY r.startedAt DESC") + List findMostRecentActiveRoundsByRoomId( + @Param("roomId") Integer roomId, + @Param("phases") List phases, + Pageable pageable + ); + + /** + * Finds the last N completed rounds for a room, ordered by resolution time (newest first). + * Only returns rounds that have a winner (winner_user_id IS NOT NULL). + */ + @Query("SELECT r FROM GameRound r WHERE r.room.roomNumber = :roomNumber AND r.phase = 'RESOLUTION' AND r.resolvedAt IS NOT NULL AND r.winnerUserId IS NOT NULL ORDER BY r.resolvedAt DESC") + List findLastCompletedRoundsByRoomNumber( + @Param("roomNumber") Integer roomNumber, + org.springframework.data.domain.Pageable pageable + ); + + /** + * Counts rounds resolved after the specified date. + */ + long countByResolvedAtAfter(Instant date); + + /** + * Calculates average total_bet for rounds resolved after the specified date. + */ + @Query("SELECT AVG(r.totalBet) FROM GameRound r WHERE r.resolvedAt >= :after AND r.resolvedAt IS NOT NULL") + Optional avgTotalBetByResolvedAtAfter(@Param("after") Instant after); + + /** + * Counts rounds resolved between two dates. + */ + long countByResolvedAtBetween(Instant start, Instant end); +} + diff --git a/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java b/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java new file mode 100644 index 0000000..a943f97 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/LotteryBotConfigRepository.java @@ -0,0 +1,24 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.LotteryBotConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface LotteryBotConfigRepository extends JpaRepository { + + List findAllByOrderByIdAsc(); + + List findAllByActiveTrue(); + + List findAllByRoom2True(); + + List findAllByRoom3True(); + + Optional findByUserId(Integer userId); + + boolean existsByUserId(Integer userId); +} diff --git a/src/main/java/com/lottery/lottery/repository/NotificationAuditRepository.java b/src/main/java/com/lottery/lottery/repository/NotificationAuditRepository.java new file mode 100644 index 0000000..f9db65b --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/NotificationAuditRepository.java @@ -0,0 +1,14 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.NotificationAudit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NotificationAuditRepository extends JpaRepository { + + /** Latest audit row for the user (by created_at). Uses index (user_id, created_at DESC). */ + Optional findTopByUserIdOrderByCreatedAtDesc(Integer userId); +} diff --git a/src/main/java/com/lottery/lottery/repository/PaymentRepository.java b/src/main/java/com/lottery/lottery/repository/PaymentRepository.java new file mode 100644 index 0000000..2fb3e0f --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/PaymentRepository.java @@ -0,0 +1,91 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Payment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +@Repository +public interface PaymentRepository extends JpaRepository, JpaSpecificationExecutor { + org.springframework.data.domain.Page findByUserId(Integer userId, org.springframework.data.domain.Pageable pageable); + Optional findByOrderId(String orderId); + Optional findByOrderIdAndStatus(String orderId, Payment.PaymentStatus status); + + /** + * Sums all completed payment stars_amount for a user. + * @param userId The user ID + * @return Sum of stars_amount for all COMPLETED payments, or 0 if none + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payment p WHERE p.userId = :userId AND p.status = 'COMPLETED'") + Integer sumCompletedStarsAmountByUserId(@Param("userId") Integer userId); + + /** + * Sums tickets_amount for all payments with given status. + */ + @Query("SELECT COALESCE(SUM(p.ticketsAmount), 0) FROM Payment p WHERE p.status = :status") + Optional sumTicketsAmountByStatus(@Param("status") Payment.PaymentStatus status); + + /** + * Sums tickets_amount for payments with given status created after the specified date. + */ + @Query("SELECT COALESCE(SUM(p.ticketsAmount), 0) FROM Payment p WHERE p.status = :status AND p.createdAt >= :after") + Optional sumTicketsAmountByStatusAndCreatedAtAfter( + @Param("status") Payment.PaymentStatus status, + @Param("after") Instant after + ); + + /** + * Sums stars_amount for all payments with given status. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payment p WHERE p.status = :status") + Optional sumStarsAmountByStatus(@Param("status") Payment.PaymentStatus status); + + /** + * Sums stars_amount for payments with given status created after the specified date. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payment p WHERE p.status = :status AND p.createdAt >= :after") + Optional sumStarsAmountByStatusAndCreatedAtAfter( + @Param("status") Payment.PaymentStatus status, + @Param("after") Instant after + ); + + /** + * Sums stars_amount for payments with given status created between two dates. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payment p WHERE p.status = :status AND p.createdAt >= :start AND p.createdAt < :end") + Optional sumStarsAmountByStatusAndCreatedAtBetween( + @Param("status") Payment.PaymentStatus status, + @Param("start") Instant start, + @Param("end") Instant end + ); + + /** Sum usd_amount for completed payments where usd_amount is not null (CRYPTO deposits). */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status AND p.usdAmount IS NOT NULL") + Optional sumUsdAmountByStatusAndUsdAmountNotNull(@Param("status") Payment.PaymentStatus status); + + /** Sum usd_amount for completed payments (CRYPTO) created after the specified date. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status AND p.usdAmount IS NOT NULL AND p.createdAt >= :after") + Optional sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter( + @Param("status") Payment.PaymentStatus status, + @Param("after") Instant after + ); + + /** Sum usd_amount for a user's completed CRYPTO deposits. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.userId = :userId AND p.status = 'COMPLETED' AND p.usdAmount IS NOT NULL") + Optional sumUsdAmountByUserIdAndCompletedAndUsdAmountNotNull(@Param("userId") Integer userId); + + /** Sum usd_amount for completed CRYPTO payments created between two dates. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payment p WHERE p.status = :status AND p.usdAmount IS NOT NULL AND p.createdAt >= :start AND p.createdAt < :end") + Optional sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween( + @Param("status") Payment.PaymentStatus status, + @Param("start") Instant start, + @Param("end") Instant end + ); +} + diff --git a/src/main/java/com/lottery/lottery/repository/PayoutRepository.java b/src/main/java/com/lottery/lottery/repository/PayoutRepository.java new file mode 100644 index 0000000..0aa257c --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/PayoutRepository.java @@ -0,0 +1,111 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Payout; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface PayoutRepository extends JpaRepository, JpaSpecificationExecutor { + List findByUserIdOrderByCreatedAtDesc(Integer userId); + org.springframework.data.domain.Page findByUserIdAndType(Integer userId, Payout.PayoutType type, org.springframework.data.domain.Pageable pageable); + + /** + * Finds CRYPTO payouts by status in (PROCESSING, WAITING) with non-null payment_id, ordered by updated_at ASC (oldest first). + * Used by withdrawal status sync cron to process up to page size (e.g. 20) per run. STARS and GIFT are ignored. + */ + List findByTypeAndStatusInAndPaymentIdIsNotNullOrderByUpdatedAtAsc( + Payout.PayoutType type, + Set statuses, + Pageable pageable); + + /** + * Returns whether the user has at least one payout with the given status. + * Used to enforce at most one PROCESSING payout per user. + */ + boolean existsByUserIdAndStatus(Integer userId, Payout.PayoutStatus status); + + List findByStatusOrderByCreatedAtDesc(Payout.PayoutStatus status); + + Optional findByIdAndStatus(Long id, Payout.PayoutStatus status); + + /** + * Finds the last N payouts for a specific user, ordered by creation date descending. + * Uses the index idx_payouts_user_created for optimal performance. + */ + @Query("SELECT p FROM Payout p WHERE p.userId = :userId ORDER BY p.createdAt DESC") + List findLastPayoutsByUserId(@Param("userId") Integer userId, org.springframework.data.domain.Pageable pageable); + + /** + * Sums total for all payouts with given status. + */ + @Query("SELECT COALESCE(SUM(p.total), 0) FROM Payout p WHERE p.status = :status") + Optional sumTotalByStatus(@Param("status") Payout.PayoutStatus status); + + /** + * Sums total for payouts with given status created after the specified date. + */ + @Query("SELECT COALESCE(SUM(p.total), 0) FROM Payout p WHERE p.status = :status AND p.createdAt >= :after") + Optional sumTotalByStatusAndCreatedAtAfter( + @Param("status") Payout.PayoutStatus status, + @Param("after") Instant after + ); + + /** + * Sums stars_amount for all payouts with given status. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payout p WHERE p.status = :status") + Optional sumStarsAmountByStatus(@Param("status") Payout.PayoutStatus status); + + /** + * Sums stars_amount for payouts with given status created after the specified date. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payout p WHERE p.status = :status AND p.createdAt >= :after") + Optional sumStarsAmountByStatusAndCreatedAtAfter( + @Param("status") Payout.PayoutStatus status, + @Param("after") Instant after + ); + + /** + * Sums stars_amount for payouts with given status created between two dates. + */ + @Query("SELECT COALESCE(SUM(p.starsAmount), 0) FROM Payout p WHERE p.status = :status AND p.createdAt >= :start AND p.createdAt < :end") + Optional sumStarsAmountByStatusAndCreatedAtBetween( + @Param("status") Payout.PayoutStatus status, + @Param("start") Instant start, + @Param("end") Instant end + ); + + /** Sum usd_amount for CRYPTO payouts with given status. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.type = 'CRYPTO' AND p.status = :status") + Optional sumUsdAmountByTypeCryptoAndStatus(@Param("status") Payout.PayoutStatus status); + + /** Sum usd_amount for CRYPTO payouts with given status created after the specified date. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.type = 'CRYPTO' AND p.status = :status AND p.createdAt >= :after") + Optional sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter( + @Param("status") Payout.PayoutStatus status, + @Param("after") Instant after + ); + + /** Sum usd_amount for a user's completed CRYPTO payouts. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.userId = :userId AND p.type = 'CRYPTO' AND p.status = 'COMPLETED'") + Optional sumUsdAmountByUserIdAndTypeCryptoAndCompleted(@Param("userId") Integer userId); + + /** Sum usd_amount for CRYPTO payouts with given status created between two dates. */ + @Query("SELECT COALESCE(SUM(p.usdAmount), 0) FROM Payout p WHERE p.type = 'CRYPTO' AND p.status = :status AND p.createdAt >= :start AND p.createdAt < :end") + Optional sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween( + @Param("status") Payout.PayoutStatus status, + @Param("start") Instant start, + @Param("end") Instant end + ); +} + diff --git a/src/main/java/com/lottery/lottery/repository/PromotionRepository.java b/src/main/java/com/lottery/lottery/repository/PromotionRepository.java new file mode 100644 index 0000000..3257c34 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/PromotionRepository.java @@ -0,0 +1,30 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Promotion; +import com.lottery.lottery.model.Promotion.PromotionStatus; +import com.lottery.lottery.model.Promotion.PromotionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface PromotionRepository extends JpaRepository { + + /** + * Find all promotions of given type that are ACTIVE and current time is within [start_time, end_time]. + */ + @Query("SELECT p FROM Promotion p WHERE p.type = :type AND p.status = 'ACTIVE' " + + "AND p.startTime < :now AND p.endTime > :now") + List findActiveByTypeAndTimeRange( + @Param("type") PromotionType type, + @Param("now") Instant now); + + List findAllByOrderByStartTimeDesc(); + + /** For app: list promotions with status in ACTIVE, PLANNED, FINISHED (exclude INACTIVE). */ + List findByStatusInOrderByStartTimeDesc(List statuses); +} diff --git a/src/main/java/com/lottery/lottery/repository/PromotionRewardRepository.java b/src/main/java/com/lottery/lottery/repository/PromotionRewardRepository.java new file mode 100644 index 0000000..2911916 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/PromotionRewardRepository.java @@ -0,0 +1,15 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.PromotionReward; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PromotionRewardRepository extends JpaRepository { + + List findByPromotionIdOrderByPlaceAsc(Integer promotionId); + + void deleteByPromotionId(Integer promotionId); +} diff --git a/src/main/java/com/lottery/lottery/repository/PromotionUserRepository.java b/src/main/java/com/lottery/lottery/repository/PromotionUserRepository.java new file mode 100644 index 0000000..8f7cd32 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/PromotionUserRepository.java @@ -0,0 +1,32 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.PromotionUser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.util.Optional; + +@Repository +public interface PromotionUserRepository extends JpaRepository { + + Optional findByPromoIdAndUserId(Integer promoId, Integer userId); + + Page findByPromoId(Integer promoId, Pageable pageable); + + Page findByPromoIdAndUserId(Integer promoId, Integer userId, Pageable pageable); + + long countByPromoId(Integer promoId); + + @Query("SELECT COUNT(pu) FROM PromotionUser pu WHERE pu.promoId = :promoId AND pu.points > :points") + long countByPromoIdAndPointsGreaterThan(@Param("promoId") int promoId, @Param("points") java.math.BigDecimal points); + + @Modifying + @Query("UPDATE PromotionUser pu SET pu.points = :points WHERE pu.promoId = :promoId AND pu.userId = :userId") + int updatePoints(@Param("promoId") int promoId, @Param("userId") int userId, @Param("points") BigDecimal points); +} diff --git a/src/main/java/com/lottery/lottery/repository/QuickAnswerRepository.java b/src/main/java/com/lottery/lottery/repository/QuickAnswerRepository.java new file mode 100644 index 0000000..8b47acf --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/QuickAnswerRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.QuickAnswer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface QuickAnswerRepository extends JpaRepository { + List findByAdminIdOrderByCreatedAtDesc(Integer adminId); +} + diff --git a/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java b/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java new file mode 100644 index 0000000..f21edbf --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/SafeBotUserRepository.java @@ -0,0 +1,13 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.SafeBotUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SafeBotUserRepository extends JpaRepository { + + List findAllByOrderByUserIdAsc(); +} diff --git a/src/main/java/com/honey/honey/repository/SessionRepository.java b/src/main/java/com/lottery/lottery/repository/SessionRepository.java similarity index 77% rename from src/main/java/com/honey/honey/repository/SessionRepository.java rename to src/main/java/com/lottery/lottery/repository/SessionRepository.java index b77807c..08b132e 100644 --- a/src/main/java/com/honey/honey/repository/SessionRepository.java +++ b/src/main/java/com/lottery/lottery/repository/SessionRepository.java @@ -1,6 +1,6 @@ -package com.honey.honey.repository; +package com.lottery.lottery.repository; -import com.honey.honey.model.Session; +import com.lottery.lottery.model.Session; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -29,6 +29,18 @@ public interface SessionRepository extends JpaRepository { @Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.expiresAt > :now ORDER BY s.createdAt ASC") List findOldestActiveSessionsByUserId(@Param("userId") Integer userId, @Param("now") LocalDateTime now, Pageable pageable); + /** + * Counts all sessions (active + expired) for a user. + */ + long countByUserId(Integer userId); + + /** + * Finds oldest sessions (active or expired) for a user, ordered by created_at ASC. + * Used to delete oldest sessions when max limit is exceeded. + */ + @Query("SELECT s FROM Session s WHERE s.userId = :userId ORDER BY s.createdAt ASC") + List findOldestSessionsByUserId(@Param("userId") Integer userId, Pageable pageable); + /** * Batch deletes expired sessions (up to batchSize). * Returns the number of deleted rows. diff --git a/src/main/java/com/lottery/lottery/repository/SupportMessageRepository.java b/src/main/java/com/lottery/lottery/repository/SupportMessageRepository.java new file mode 100644 index 0000000..d5dce91 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/SupportMessageRepository.java @@ -0,0 +1,32 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.SupportMessage; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SupportMessageRepository extends JpaRepository { + + /** + * Find all messages for a ticket, ordered by created_at ASC. + */ + List findByTicketIdOrderByCreatedAtAsc(Long ticketId); + + /** + * Count messages for a ticket. + */ + long countByTicketId(Long ticketId); + + /** + * Find the last message for a user in a specific ticket (for rate limiting per ticket). + * Returns only the most recent message (limit 1). + */ + @Query("SELECT m FROM SupportMessage m WHERE m.ticket.id = :ticketId AND m.user.id = :userId ORDER BY m.createdAt DESC") + List findLastMessageByTicketIdAndUserId(@Param("ticketId") Long ticketId, @Param("userId") Integer userId, Pageable pageable); +} + diff --git a/src/main/java/com/lottery/lottery/repository/SupportTicketRepository.java b/src/main/java/com/lottery/lottery/repository/SupportTicketRepository.java new file mode 100644 index 0000000..eb758da --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/SupportTicketRepository.java @@ -0,0 +1,51 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.SupportTicket; +import com.lottery.lottery.model.SupportTicket.TicketStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SupportTicketRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * Count OPENED tickets for a user. + */ + long countByUserIdAndStatus(Integer userId, TicketStatus status); + + /** + * Count tickets by status (for admin dashboard). + */ + long countByStatus(TicketStatus status); + + /** + * Find tickets by user ID, ordered by created_at DESC. + */ + Page findByUserIdOrderByCreatedAtDesc(Integer userId, Pageable pageable); + + /** + * Find a ticket by ID and user ID (for security - users can only access their own tickets). + */ + Optional findByIdAndUserId(Long id, Integer userId); + + /** + * Find all tickets ordered by created_at DESC (for admin panel). + */ + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + /** + * Find tickets by status, ordered by created_at DESC (for admin panel). + */ + Page findByStatusOrderByCreatedAtDesc(TicketStatus status, Pageable pageable); +} + + + diff --git a/src/main/java/com/lottery/lottery/repository/TaskRepository.java b/src/main/java/com/lottery/lottery/repository/TaskRepository.java new file mode 100644 index 0000000..46ae6da --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/TaskRepository.java @@ -0,0 +1,14 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Task; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface TaskRepository extends JpaRepository { + List findByTypeOrderByDisplayOrderAsc(String type); + List findByTypeAndRequirementIn(String type, List requirements); +} + diff --git a/src/main/java/com/lottery/lottery/repository/TransactionRepository.java b/src/main/java/com/lottery/lottery/repository/TransactionRepository.java new file mode 100644 index 0000000..0c1e101 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/TransactionRepository.java @@ -0,0 +1,62 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.Transaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Set; +import java.time.Instant; + +@Repository +public interface TransactionRepository extends JpaRepository { + + /** + * Finds all transactions for a user, ordered by creation time descending (newest first). + * Uses index idx_user_id_created_at for optimal performance. + */ + Page findByUserIdOrderByCreatedAtDesc(Integer userId, Pageable pageable); + + /** + * Finds WIN transactions for a user created after the specified date, ordered by creation time descending. + * Used for game history (win history). + */ + Page findByUserIdAndTypeAndCreatedAtAfterOrderByCreatedAtDesc( + Integer userId, Transaction.TransactionType type, Instant createdAfter, Pageable pageable); + + /** + * Batch deletes all transactions older than the specified date (up to batchSize). + * Returns the number of deleted rows. + * Note: MySQL requires LIMIT to be used directly in DELETE statements. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "DELETE FROM transactions WHERE created_at < :cutoffDate LIMIT :batchSize", nativeQuery = true) + int deleteOldTransactionsBatch(@Param("cutoffDate") Instant cutoffDate, @Param("batchSize") int batchSize); + + /** + * Counts transactions of a specific type for a user. + * Used to check if this is the user's 3rd bet for referral bonus. + */ + long countByUserIdAndType(Integer userId, Transaction.TransactionType type); + + /** + * Returns sum of transaction amounts per user for the given user IDs (batch, for admin list). + */ + @Query("SELECT t.userId, COALESCE(SUM(t.amount), 0) FROM Transaction t WHERE t.userId IN :userIds GROUP BY t.userId") + List sumAmountByUserIdIn(@Param("userIds") List userIds); + + /** BET transactions for a user, ordered by createdAt desc (for game history). */ + Page findByUserIdAndTypeOrderByCreatedAtDesc(Integer userId, Transaction.TransactionType type, Pageable pageable); + + /** WIN transactions for a user and given round IDs (batch). */ + @Query("SELECT t FROM Transaction t WHERE t.userId = :userId AND t.type = 'WIN' AND t.roundId IN :roundIds") + List findByUserIdAndTypeWinAndRoundIdIn(@Param("userId") Integer userId, @Param("roundIds") Set roundIds); +} + diff --git a/src/main/java/com/lottery/lottery/repository/UserARepository.java b/src/main/java/com/lottery/lottery/repository/UserARepository.java new file mode 100644 index 0000000..48e1b12 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/UserARepository.java @@ -0,0 +1,54 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.UserA; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserARepository extends JpaRepository, JpaSpecificationExecutor { + /** + * Users with id in [fromId, toId] for notification broadcast (paged). + */ + @Query("SELECT u FROM UserA u WHERE u.id >= :fromId AND u.id <= :toId ORDER BY u.id") + Page findByIdBetween(@Param("fromId") int fromId, @Param("toId") int toId, Pageable pageable); + + /** + * Max user id for default broadcast range (1 to latest). + */ + @Query("SELECT COALESCE(MAX(u.id), 0) FROM UserA u") + int getMaxId(); + Optional findByTelegramId(Long telegramId); + + /** + * Counts users registered after the specified Unix timestamp (seconds). + */ + @Query("SELECT COUNT(u) FROM UserA u WHERE u.dateReg >= :timestamp") + long countByDateRegAfter(@Param("timestamp") Integer timestamp); + + /** + * Counts users who logged in after the specified Unix timestamp (seconds). + */ + @Query("SELECT COUNT(u) FROM UserA u WHERE u.dateLogin >= :timestamp") + long countByDateLoginAfter(@Param("timestamp") Integer timestamp); + + /** + * Counts users registered between two Unix timestamps (seconds). + */ + @Query("SELECT COUNT(u) FROM UserA u WHERE u.dateReg >= :start AND u.dateReg < :end") + long countByDateRegBetween(@Param("start") Integer start, @Param("end") Integer end); + + /** + * Counts users who logged in between two Unix timestamps (seconds). + */ + @Query("SELECT COUNT(u) FROM UserA u WHERE u.dateLogin >= :start AND u.dateLogin < :end") + long countByDateLoginBetween(@Param("start") Integer start, @Param("end") Integer end); +} + + diff --git a/src/main/java/com/lottery/lottery/repository/UserBRepository.java b/src/main/java/com/lottery/lottery/repository/UserBRepository.java new file mode 100644 index 0000000..780a2da --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/UserBRepository.java @@ -0,0 +1,25 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.UserB; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserBRepository extends JpaRepository { + + /** + * Loads UserB with pessimistic write lock (SELECT ... FOR UPDATE) so that concurrent + * withdrawals for the same user are serialized and cannot double-spend. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT b FROM UserB b WHERE b.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); +} + + diff --git a/src/main/java/com/lottery/lottery/repository/UserDRepository.java b/src/main/java/com/lottery/lottery/repository/UserDRepository.java new file mode 100644 index 0000000..8f3e5d8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/UserDRepository.java @@ -0,0 +1,114 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.dto.ReferralDto; +import com.lottery.lottery.model.UserD; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserDRepository extends JpaRepository { + + /** + * Increments referals_1 for a user. + */ + @Modifying + @Query("UPDATE UserD u SET u.referals1 = u.referals1 + 1 WHERE u.id = :userId") + void incrementReferals1(@Param("userId") Integer userId); + + /** + * Increments referals_2 for a user. + */ + @Modifying + @Query("UPDATE UserD u SET u.referals2 = u.referals2 + 1 WHERE u.id = :userId") + void incrementReferals2(@Param("userId") Integer userId); + + /** + * Increments referals_3 for a user. + */ + @Modifying + @Query("UPDATE UserD u SET u.referals3 = u.referals3 + 1 WHERE u.id = :userId") + void incrementReferals3(@Param("userId") Integer userId); + + /** + * Increments referals_4 for a user. + */ + @Modifying + @Query("UPDATE UserD u SET u.referals4 = u.referals4 + 1 WHERE u.id = :userId") + void incrementReferals4(@Param("userId") Integer userId); + + /** + * Increments referals_5 for a user. + */ + @Modifying + @Query("UPDATE UserD u SET u.referals5 = u.referals5 + 1 WHERE u.id = :userId") + void incrementReferals5(@Param("userId") Integer userId); + + /** + * Finds referrals for level 1 (where referer_id_1 = userId). + * Returns referrals with their screen_name and to_referer_1 commission. + */ + @Query("SELECT new com.lottery.lottery.dto.ReferralDto(" + + "ud.screenName, ud.toReferer1) " + + "FROM UserD ud " + + "WHERE ud.refererId1 = :userId AND ud.refererId1 > 0 " + + "ORDER BY ud.toReferer1 DESC") + Page findReferralsLevel1(@Param("userId") Integer userId, Pageable pageable); + + /** + * Finds referrals for level 2 (where referer_id_2 = userId). + * Returns referrals with their screen_name and to_referer_2 commission. + */ + @Query("SELECT new com.lottery.lottery.dto.ReferralDto(" + + "ud.screenName, ud.toReferer2) " + + "FROM UserD ud " + + "WHERE ud.refererId2 = :userId AND ud.refererId2 > 0 " + + "ORDER BY ud.toReferer2 DESC") + Page findReferralsLevel2(@Param("userId") Integer userId, Pageable pageable); + + /** + * Finds referrals for level 3 (where referer_id_3 = userId). + * Returns referrals with their screen_name and to_referer_3 commission. + */ + @Query("SELECT new com.lottery.lottery.dto.ReferralDto(" + + "ud.screenName, ud.toReferer3) " + + "FROM UserD ud " + + "WHERE ud.refererId3 = :userId AND ud.refererId3 > 0 " + + "ORDER BY ud.toReferer3 DESC") + Page findReferralsLevel3(@Param("userId") Integer userId, Pageable pageable); + + /** + * Masters: users whose id equals their master_id (and master_id > 0). + * Ordered by id DESC (primary key) by default. + */ + @Query("SELECT d FROM UserD d WHERE d.id = d.masterId AND d.masterId > 0 ORDER BY d.id DESC") + List findAllMasters(); + + /** + * IDs of users who are Masters (id = master_id and master_id > 0). Used to exclude them from GAME_ADMIN views. + */ + @Query("SELECT d.id FROM UserD d WHERE d.id = d.masterId AND d.masterId > 0") + List findMasterUserIds(); + + /** + * For each master_id in the list, returns total referral count (users with that master_id). + * Returns Object[] { masterId (Integer), count (Long) }. + */ + @Query(value = "SELECT d.master_id, COUNT(*) FROM db_users_d d WHERE d.master_id IN :masterIds GROUP BY d.master_id", nativeQuery = true) + List countReferralsByMasterIds(@Param("masterIds") List masterIds); + + /** + * Sum of deposit_total and withdraw_total per master_id (over all users with that master_id). + * Returns Object[] { masterId (Integer), sumDeposit (Long), sumWithdraw (Long) }. + */ + @Query(value = "SELECT d.master_id, COALESCE(SUM(b.deposit_total),0), COALESCE(SUM(b.withdraw_total),0) FROM db_users_d d LEFT JOIN db_users_b b ON b.id = d.id WHERE d.master_id IN :masterIds GROUP BY d.master_id", nativeQuery = true) + List sumDepositWithdrawByMasterIds(@Param("masterIds") List masterIds); +} + + diff --git a/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java b/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java new file mode 100644 index 0000000..383cd25 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/UserDailyBonusClaimRepository.java @@ -0,0 +1,31 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.UserDailyBonusClaim; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserDailyBonusClaimRepository extends JpaRepository { + + /** + * Finds the most recent daily bonus claim for a user. + * Used to check if user can claim (24h cooldown). + */ + Optional findFirstByUserIdOrderByClaimedAtDesc(Integer userId); + + /** + * Finds the 50 most recent daily bonus claims ordered by claimed_at DESC. + * Simple query without JOINs - all data is in the same table. + */ + List findTop50ByOrderByClaimedAtDesc(); + + /** + * Finds all daily bonus claims for a user, ordered by claimed_at DESC. + */ + List findByUserIdOrderByClaimedAtDesc(Integer userId); +} + diff --git a/src/main/java/com/lottery/lottery/repository/UserTaskClaimRepository.java b/src/main/java/com/lottery/lottery/repository/UserTaskClaimRepository.java new file mode 100644 index 0000000..a124394 --- /dev/null +++ b/src/main/java/com/lottery/lottery/repository/UserTaskClaimRepository.java @@ -0,0 +1,20 @@ +package com.lottery.lottery.repository; + +import com.lottery.lottery.model.UserTaskClaim; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserTaskClaimRepository extends JpaRepository { + Optional findByUserIdAndTaskId(Integer userId, Integer taskId); + List findByUserId(Integer userId); + boolean existsByUserIdAndTaskId(Integer userId, Integer taskId); + boolean existsByUserIdAndTaskIdIn(Integer userId, List taskIds); +} + + + + diff --git a/src/main/java/com/honey/honey/security/AuthInterceptor.java b/src/main/java/com/lottery/lottery/security/AuthInterceptor.java similarity index 63% rename from src/main/java/com/honey/honey/security/AuthInterceptor.java rename to src/main/java/com/lottery/lottery/security/AuthInterceptor.java index c43ae02..6353f9c 100644 --- a/src/main/java/com/honey/honey/security/AuthInterceptor.java +++ b/src/main/java/com/lottery/lottery/security/AuthInterceptor.java @@ -1,7 +1,9 @@ -package com.honey.honey.security; +package com.lottery.lottery.security; -import com.honey.honey.model.UserA; -import com.honey.honey.service.SessionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.service.LocalizationService; +import com.lottery.lottery.service.SessionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -9,6 +11,7 @@ import org.springframework.web.servlet.HandlerInterceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; import java.util.Optional; @Slf4j @@ -17,6 +20,8 @@ import java.util.Optional; public class AuthInterceptor implements HandlerInterceptor { private final SessionService sessionService; + private final LocalizationService localizationService; + private final ObjectMapper objectMapper = new ObjectMapper(); @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { @@ -32,7 +37,7 @@ public class AuthInterceptor implements HandlerInterceptor { // If no Bearer token, fail if (sessionId == null || sessionId.isBlank()) { - log.warn("❌ Missing Bearer token in Authorization header"); + log.debug("Missing Bearer token"); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } @@ -41,15 +46,29 @@ public class AuthInterceptor implements HandlerInterceptor { Optional userOpt = sessionService.getUserBySession(sessionId); if (userOpt.isEmpty()) { - log.warn("❌ Invalid or expired session: {}", maskSessionId(sessionId)); + log.debug("Invalid or expired session: {}", maskSessionId(sessionId)); res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } - // Put user in context UserA user = userOpt.get(); + if (user.getBanned() != null && user.getBanned() == 1) { + log.debug("Banned user attempted access: userId={}", user.getId()); + res.setStatus(HttpServletResponse.SC_FORBIDDEN); + res.setContentType("application/json"); + res.setCharacterEncoding("UTF-8"); + try { + String message = localizationService.getMessageForUser(user.getId(), "auth.error.accessRestricted"); + String body = objectMapper.writeValueAsString(Map.of("code", "BANNED", "message", message)); + res.getWriter().write(body); + } catch (Exception e) { + log.warn("Failed to write banned response body", e); + } + return false; + } + + // Put user in context UserContext.set(user); - log.debug("🔑 Authenticated userId={} via session", user.getId()); return true; } diff --git a/src/main/java/com/lottery/lottery/security/RateLimitInterceptor.java b/src/main/java/com/lottery/lottery/security/RateLimitInterceptor.java new file mode 100644 index 0000000..f75c680 --- /dev/null +++ b/src/main/java/com/lottery/lottery/security/RateLimitInterceptor.java @@ -0,0 +1,98 @@ +package com.lottery.lottery.security; + +import com.lottery.lottery.util.IpUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Rate limiting interceptor (IP-based). Limits requests per IP to prevent abuse. + * Note: Not currently registered for any path in WebConfig; available if needed. + */ +@Slf4j +@Component +public class RateLimitInterceptor implements HandlerInterceptor { + + // Rate limit configuration + private static final int MAX_REQUESTS_PER_IP = 10; // per time window + private static final long TIME_WINDOW_MS = 60_000; // 1 minute + + // IP-based rate limiting + private final Map ipRateLimit = new ConcurrentHashMap<>(); + + @Override + public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { + // Allow CORS preflight (OPTIONS) without rate limiting + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + return true; + } + + String clientIp = IpUtils.getClientIp(req); + + if (clientIp == null) { + // If we can't determine IP, allow the request (shouldn't happen, but be safe) + return true; + } + + // Check IP-based rate limit + if (isRateLimited(clientIp, ipRateLimit, MAX_REQUESTS_PER_IP)) { + log.warn("Rate limit exceeded for bot registration - IP={}, maxRequests={}, window={}ms", + clientIp, MAX_REQUESTS_PER_IP, TIME_WINDOW_MS); + res.setStatus(429); // 429 Too Many Requests + res.setHeader("Retry-After", "60"); // Retry after 60 seconds + return false; + } + + return true; + } + + /** + * Checks if a key is rate limited. + */ + private boolean isRateLimited(String key, Map rateLimitMap, int maxRequests) { + long now = System.currentTimeMillis(); + + RateLimitEntry entry = rateLimitMap.computeIfAbsent(key, k -> new RateLimitEntry()); + + // Reset if time window has passed + if (now - entry.windowStart.get() > TIME_WINDOW_MS) { + entry.count.set(0); + entry.windowStart.set(now); + } + + // Increment and check + int currentCount = entry.count.incrementAndGet(); + + // Clean up old entries periodically + if (currentCount == 1 && rateLimitMap.size() > 1000) { + cleanupOldEntries(rateLimitMap, now); + } + + return currentCount > maxRequests; + } + + /** + * Cleans up old rate limit entries. + */ + private void cleanupOldEntries(Map rateLimitMap, long now) { + rateLimitMap.entrySet().removeIf(entry -> + now - entry.getValue().windowStart.get() > TIME_WINDOW_MS * 2 + ); + } + + /** + * Rate limit entry for tracking requests. + */ + private static class RateLimitEntry { + final AtomicInteger count = new AtomicInteger(0); + final AtomicLong windowStart = new AtomicLong(System.currentTimeMillis()); + } +} + diff --git a/src/main/java/com/honey/honey/security/UserContext.java b/src/main/java/com/lottery/lottery/security/UserContext.java similarity index 80% rename from src/main/java/com/honey/honey/security/UserContext.java rename to src/main/java/com/lottery/lottery/security/UserContext.java index 90b9f4f..ee6d97e 100644 --- a/src/main/java/com/honey/honey/security/UserContext.java +++ b/src/main/java/com/lottery/lottery/security/UserContext.java @@ -1,6 +1,6 @@ -package com.honey.honey.security; +package com.lottery.lottery.security; -import com.honey.honey.model.UserA; +import com.lottery.lottery.model.UserA; public class UserContext { diff --git a/src/main/java/com/lottery/lottery/security/UserRateLimitInterceptor.java b/src/main/java/com/lottery/lottery/security/UserRateLimitInterceptor.java new file mode 100644 index 0000000..5c1bd00 --- /dev/null +++ b/src/main/java/com/lottery/lottery/security/UserRateLimitInterceptor.java @@ -0,0 +1,113 @@ +package com.lottery.lottery.security; + +import com.lottery.lottery.model.UserA; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Rate limiting interceptor for authenticated user endpoints. + * Limits requests per user ID to prevent abuse. + * Requires UserContext to be set (must be applied after AuthInterceptor). + */ +@Slf4j +@Component +public class UserRateLimitInterceptor implements HandlerInterceptor { + + // Rate limit configuration for payment creation + private static final int MAX_REQUESTS_PER_USER = 5; // per time window + private static final long TIME_WINDOW_MS = 60_000; // 1 minute + + // User-based rate limiting + private final Map userRateLimit = new ConcurrentHashMap<>(); + + @Override + public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { + // Allow CORS preflight (OPTIONS) without rate limiting + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + return true; + } + + // Only rate limit POST requests (GET requests like /api/payouts/history should not be rate limited) + if (!"POST".equalsIgnoreCase(req.getMethod())) { + return true; + } + + // Get user from context (set by AuthInterceptor) + UserA user = UserContext.get(); + if (user == null) { + // If no user context, allow (shouldn't happen for authenticated endpoints, but be safe) + return true; + } + + Integer userId = user.getId(); + + // Check user-based rate limit + if (isRateLimited(userId, userRateLimit, MAX_REQUESTS_PER_USER)) { + log.warn("Rate limit exceeded: userId={}, endpoint={}", userId, req.getRequestURI()); + res.setStatus(429); // 429 Too Many Requests + res.setHeader("Retry-After", "60"); // Retry after 60 seconds + res.setContentType("application/json;charset=UTF-8"); + try { + java.io.PrintWriter writer = res.getWriter(); + writer.write("{\"message\":\"Too many requests. Please wait a moment before trying again.\"}"); + writer.flush(); + } catch (Exception e) { + log.error("Error writing rate limit response", e); + } + return false; + } + + return true; + } + + /** + * Checks if a user is rate limited. + */ + private boolean isRateLimited(Integer userId, Map rateLimitMap, int maxRequests) { + long now = System.currentTimeMillis(); + + RateLimitEntry entry = rateLimitMap.computeIfAbsent(userId, k -> new RateLimitEntry()); + + // Reset if time window has passed + if (now - entry.windowStart.get() > TIME_WINDOW_MS) { + entry.count.set(0); + entry.windowStart.set(now); + } + + // Increment and check + int currentCount = entry.count.incrementAndGet(); + + // Clean up old entries periodically + if (currentCount == 1 && rateLimitMap.size() > 1000) { + cleanupOldEntries(rateLimitMap, now); + } + + return currentCount > maxRequests; + } + + /** + * Cleans up old rate limit entries. + */ + private void cleanupOldEntries(Map rateLimitMap, long now) { + rateLimitMap.entrySet().removeIf(entry -> + now - entry.getValue().windowStart.get() > TIME_WINDOW_MS * 2 + ); + } + + /** + * Rate limit entry for tracking requests. + */ + private static class RateLimitEntry { + final AtomicInteger count = new AtomicInteger(0); + final AtomicLong windowStart = new AtomicLong(System.currentTimeMillis()); + } +} + diff --git a/src/main/java/com/lottery/lottery/security/admin/AdminDetailsService.java b/src/main/java/com/lottery/lottery/security/admin/AdminDetailsService.java new file mode 100644 index 0000000..37dfd16 --- /dev/null +++ b/src/main/java/com/lottery/lottery/security/admin/AdminDetailsService.java @@ -0,0 +1,39 @@ +package com.lottery.lottery.security.admin; + +import com.lottery.lottery.model.Admin; +import com.lottery.lottery.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminDetailsService implements UserDetailsService { + + private final AdminRepository adminRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Admin admin = adminRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Admin not found: " + username)); + + List authorities = Collections.singletonList( + new SimpleGrantedAuthority(admin.getRole()) + ); + + return User.builder() + .username(admin.getUsername()) + .password(admin.getPasswordHash()) + .authorities(authorities) + .build(); + } +} + diff --git a/src/main/java/com/lottery/lottery/security/admin/JwtAuthenticationFilter.java b/src/main/java/com/lottery/lottery/security/admin/JwtAuthenticationFilter.java new file mode 100644 index 0000000..4132f95 --- /dev/null +++ b/src/main/java/com/lottery/lottery/security/admin/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package com.lottery.lottery.security.admin; + +import com.lottery.lottery.model.Admin; +import com.lottery.lottery.repository.AdminRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final AdminRepository adminRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + String username = null; + String jwt = null; + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + jwt = authHeader.substring(7); + try { + username = jwtUtil.getUsernameFromToken(jwt); + } catch (Exception e) { + // Invalid token, continue without authentication + } + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + if (jwtUtil.validateToken(jwt, username)) { + // Get admin from database to retrieve actual role + String role = adminRepository.findByUsername(username) + .map(Admin::getRole) + .orElse("ROLE_ADMIN"); // Fallback to ROLE_ADMIN if not found + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + username, + null, + Collections.singletonList(new SimpleGrantedAuthority(role)) + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/com/lottery/lottery/security/admin/JwtUtil.java b/src/main/java/com/lottery/lottery/security/admin/JwtUtil.java new file mode 100644 index 0000000..20abb15 --- /dev/null +++ b/src/main/java/com/lottery/lottery/security/admin/JwtUtil.java @@ -0,0 +1,81 @@ +package com.lottery.lottery.security.admin; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtil { + + @Value("${app.admin.jwt.secret:your-secret-key-change-this-in-production-min-256-bits}") + private String secret; + + @Value("${app.admin.jwt.expiration:86400000}") // 24 hours default + private Long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(Integer userId, String username) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("username", username); + return createToken(claims, username); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .claims(claims) + .subject(subject) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + public Integer getUserIdFromToken(String token) { + return getClaimFromToken(token, claims -> claims.get("userId", Integer.class)); + } + + public String getUsernameFromToken(String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + public Date getExpirationDateFromToken(String token) { + return getClaimFromToken(token, Claims::getExpiration); + } + + public T getClaimFromToken(String token, Function claimsResolver) { + final Claims claims = getAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + private Claims getAllClaimsFromToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Boolean isTokenExpired(String token) { + final Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + public Boolean validateToken(String token, String username) { + final String tokenUsername = getUsernameFromToken(token); + return (tokenUsername.equals(username) && !isTokenExpired(token)); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java b/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java new file mode 100644 index 0000000..fc4b6b6 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AdminBotConfigService.java @@ -0,0 +1,210 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.AdminBotConfigDto; +import com.lottery.lottery.dto.AdminBotConfigRequest; +import com.lottery.lottery.model.LotteryBotConfig; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.LotteryBotConfigRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminBotConfigService { + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + + private final LotteryBotConfigRepository lotteryBotConfigRepository; + private final UserARepository userARepository; + + public List listAll() { + List configs = lotteryBotConfigRepository.findAllByOrderByIdAsc(); + if (configs.isEmpty()) { + return List.of(); + } + List userIds = configs.stream().map(LotteryBotConfig::getUserId).distinct().toList(); + Map screenNameByUserId = userARepository.findAllById(userIds).stream() + .collect(Collectors.toMap(UserA::getId, u -> u.getScreenName() != null ? u.getScreenName() : "-")); + return configs.stream() + .map(c -> toDto(c, screenNameByUserId.getOrDefault(c.getUserId(), "-"))) + .toList(); + } + + public Optional getById(Integer id) { + return lotteryBotConfigRepository.findById(id) + .map(c -> { + String screenName = userARepository.findById(c.getUserId()) + .map(UserA::getScreenName) + .orElse("-"); + return toDto(c, screenName); + }); + } + + public Optional getByUserId(Integer userId) { + return lotteryBotConfigRepository.findByUserId(userId) + .map(c -> { + String screenName = userARepository.findById(c.getUserId()) + .map(UserA::getScreenName) + .orElse("-"); + return toDto(c, screenName); + }); + } + + @Transactional + public AdminBotConfigDto create(AdminBotConfigRequest request) { + if (!userARepository.existsById(request.getUserId())) { + throw new IllegalArgumentException("User with id " + request.getUserId() + " does not exist"); + } + if (lotteryBotConfigRepository.existsByUserId(request.getUserId())) { + throw new IllegalArgumentException("Bot config already exists for user id " + request.getUserId()); + } + LotteryBotConfig config = toEntity(request); + config.setId(null); + config.setCreatedAt(Instant.now()); + config.setUpdatedAt(Instant.now()); + config = lotteryBotConfigRepository.save(config); + String screenName = userARepository.findById(config.getUserId()).map(UserA::getScreenName).orElse("-"); + return toDto(config, screenName); + } + + @Transactional + public Optional update(Integer id, AdminBotConfigRequest request) { + Optional opt = lotteryBotConfigRepository.findById(id); + if (opt.isEmpty()) return Optional.empty(); + if (!userARepository.existsById(request.getUserId())) { + throw new IllegalArgumentException("User with id " + request.getUserId() + " does not exist"); + } + LotteryBotConfig existing = opt.get(); + if (!existing.getUserId().equals(request.getUserId()) && lotteryBotConfigRepository.existsByUserId(request.getUserId())) { + throw new IllegalArgumentException("Bot config already exists for user id " + request.getUserId()); + } + updateEntity(existing, request); + existing.setUpdatedAt(Instant.now()); + LotteryBotConfig saved = lotteryBotConfigRepository.save(existing); + String screenName = userARepository.findById(saved.getUserId()).map(UserA::getScreenName).orElse("-"); + return Optional.of(toDto(saved, screenName)); + } + + @Transactional + public boolean delete(Integer id) { + if (!lotteryBotConfigRepository.existsById(id)) return false; + lotteryBotConfigRepository.deleteById(id); + return true; + } + + /** + * Shuffles time windows for bots that have the given room enabled. + * Groups configs by their current time window, then randomly redistributes those same windows across all configs. + */ + @Transactional + public void shuffleTimeWindowsForRoom(int roomNumber) { + List configs = roomNumber == 2 + ? lotteryBotConfigRepository.findAllByRoom2True() + : lotteryBotConfigRepository.findAllByRoom3True(); + if (configs.isEmpty()) { + throw new IllegalArgumentException("No bot configs with room " + roomNumber + " enabled"); + } + shuffleWindows(configs); + Instant now = Instant.now(); + for (LotteryBotConfig c : configs) { + c.setUpdatedAt(now); + } + lotteryBotConfigRepository.saveAll(configs); + } + + /** Groups configs by (start, end) window, collects one slot per config, shuffles slots, assigns back. */ + private static void shuffleWindows(List configs) { + Map> byWindow = new LinkedHashMap<>(); + for (LotteryBotConfig c : configs) { + if (c.getTimeUtcStart() == null || c.getTimeUtcEnd() == null) continue; + TimeWindow w = new TimeWindow(c.getTimeUtcStart(), c.getTimeUtcEnd()); + byWindow.computeIfAbsent(w, k -> new ArrayList<>()).add(c); + } + List windowSlots = new ArrayList<>(); + for (Map.Entry> e : byWindow.entrySet()) { + for (int i = 0; i < e.getValue().size(); i++) { + windowSlots.add(e.getKey()); + } + } + Collections.shuffle(windowSlots); + int idx = 0; + for (LotteryBotConfig c : configs) { + if (c.getTimeUtcStart() != null && c.getTimeUtcEnd() != null && idx < windowSlots.size()) { + TimeWindow w = windowSlots.get(idx++); + c.setTimeUtcStart(w.start); + c.setTimeUtcEnd(w.end); + } + } + } + + private record TimeWindow(LocalTime start, LocalTime end) {} + + private static AdminBotConfigDto toDto(LotteryBotConfig c, String screenName) { + return AdminBotConfigDto.builder() + .id(c.getId()) + .userId(c.getUserId()) + .screenName(screenName) + .room1(c.getRoom1()) + .room2(c.getRoom2()) + .room3(c.getRoom3()) + .timeUtcStart(c.getTimeUtcStart() != null ? c.getTimeUtcStart().format(TIME_FORMAT) : null) + .timeUtcEnd(c.getTimeUtcEnd() != null ? c.getTimeUtcEnd().format(TIME_FORMAT) : null) + .betMin(c.getBetMin()) + .betMax(c.getBetMax()) + .persona(c.getPersona() != null ? c.getPersona() : "balanced") + .active(c.getActive()) + .createdAt(c.getCreatedAt()) + .updatedAt(c.getUpdatedAt()) + .build(); + } + + private static LotteryBotConfig toEntity(AdminBotConfigRequest r) { + return LotteryBotConfig.builder() + .userId(r.getUserId()) + .room1(r.getRoom1()) + .room2(r.getRoom2()) + .room3(r.getRoom3()) + .timeUtcStart(parseTime(r.getTimeUtcStart())) + .timeUtcEnd(parseTime(r.getTimeUtcEnd())) + .betMin(r.getBetMin()) + .betMax(r.getBetMax()) + .persona(r.getPersona() != null && !r.getPersona().isBlank() ? r.getPersona() : "balanced") + .active(r.getActive()) + .build(); + } + + private static void updateEntity(LotteryBotConfig existing, AdminBotConfigRequest r) { + existing.setUserId(r.getUserId()); + existing.setRoom1(r.getRoom1()); + existing.setRoom2(r.getRoom2()); + existing.setRoom3(r.getRoom3()); + existing.setTimeUtcStart(parseTime(r.getTimeUtcStart())); + existing.setTimeUtcEnd(parseTime(r.getTimeUtcEnd())); + existing.setBetMin(r.getBetMin()); + existing.setBetMax(r.getBetMax()); + existing.setPersona(r.getPersona() != null && !r.getPersona().isBlank() ? r.getPersona() : "balanced"); + existing.setActive(r.getActive()); + } + + private static LocalTime parseTime(String s) { + if (s == null || s.isBlank()) throw new IllegalArgumentException("Time is required (HH:mm)"); + try { + return LocalTime.parse(s.trim(), TIME_FORMAT); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid time format, use HH:mm (e.g. 14:00)"); + } + } +} diff --git a/src/main/java/com/lottery/lottery/service/AdminMasterService.java b/src/main/java/com/lottery/lottery/service/AdminMasterService.java new file mode 100644 index 0000000..2840a9d --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AdminMasterService.java @@ -0,0 +1,79 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.AdminMasterDto; +import com.lottery.lottery.model.UserD; +import com.lottery.lottery.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminMasterService { + + private static final BigDecimal USD_DIVISOR = new BigDecimal("1000000000"); + + private final UserDRepository userDRepository; + + @Transactional(readOnly = true) + public List getMasters() { + List masters = userDRepository.findAllMasters(); + if (masters.isEmpty()) { + return List.of(); + } + List masterIds = masters.stream().map(UserD::getId).collect(Collectors.toList()); + + Map referralCountByMaster = mapFromCountQuery(userDRepository.countReferralsByMasterIds(masterIds)); + Map depositWithdrawByMaster = mapFromSumQuery(userDRepository.sumDepositWithdrawByMasterIds(masterIds)); + + return masters.stream() + .map(m -> { + int id = m.getId(); + long totalReferrals = Math.max(0L, referralCountByMaster.getOrDefault(id, 0L) - 1L); // exclude master themselves from count + long[] sums = depositWithdrawByMaster.getOrDefault(id, new long[]{0L, 0L}); + long sumDeposit = sums[0]; + long sumWithdraw = sums[1]; + BigDecimal depositUsd = BigDecimal.valueOf(sumDeposit).divide(USD_DIVISOR, 4, RoundingMode.HALF_UP); + BigDecimal withdrawUsd = BigDecimal.valueOf(sumWithdraw).divide(USD_DIVISOR, 4, RoundingMode.HALF_UP); + BigDecimal profitUsd = depositUsd.subtract(withdrawUsd); + + return AdminMasterDto.builder() + .id(id) + .screenName(m.getScreenName() != null ? m.getScreenName() : "-") + .referals1(m.getReferals1() != null ? m.getReferals1() : 0) + .referals2(m.getReferals2() != null ? m.getReferals2() : 0) + .referals3(m.getReferals3() != null ? m.getReferals3() : 0) + .totalReferrals(totalReferrals) + .depositTotalUsd(depositUsd) + .withdrawTotalUsd(withdrawUsd) + .profitUsd(profitUsd) + .build(); + }) + .collect(Collectors.toList()); + } + + private static Map mapFromCountQuery(List rows) { + return rows.stream() + .collect(Collectors.toMap( + row -> (Integer) row[0], + row -> row[1] instanceof Long ? (Long) row[1] : ((Number) row[1]).longValue() + )); + } + + private static Map mapFromSumQuery(List rows) { + return rows.stream() + .collect(Collectors.toMap( + row -> (Integer) row[0], + row -> new long[]{ + row[1] instanceof Long ? (Long) row[1] : ((Number) row[1]).longValue(), + row[2] instanceof Long ? (Long) row[2] : ((Number) row[2]).longValue() + } + )); + } +} diff --git a/src/main/java/com/lottery/lottery/service/AdminPromotionService.java b/src/main/java/com/lottery/lottery/service/AdminPromotionService.java new file mode 100644 index 0000000..26c3b6d --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AdminPromotionService.java @@ -0,0 +1,180 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.model.Promotion; +import com.lottery.lottery.model.Promotion.PromotionStatus; +import com.lottery.lottery.model.Promotion.PromotionType; +import com.lottery.lottery.model.PromotionReward; +import com.lottery.lottery.model.PromotionUser; +import com.lottery.lottery.repository.PromotionRepository; +import com.lottery.lottery.repository.PromotionRewardRepository; +import com.lottery.lottery.repository.PromotionUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminPromotionService { + + private final PromotionRepository promotionRepository; + private final PromotionRewardRepository promotionRewardRepository; + private final PromotionUserRepository promotionUserRepository; + + public List listPromotions() { + return promotionRepository.findAllByOrderByStartTimeDesc().stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + public Optional getPromotion(int id) { + return promotionRepository.findById(id).map(this::toDto); + } + + @Transactional + public AdminPromotionDto createPromotion(AdminPromotionRequest req) { + Promotion p = Promotion.builder() + .type(PromotionType.valueOf(req.getType())) + .startTime(req.getStartTime()) + .endTime(req.getEndTime()) + .status(PromotionStatus.valueOf(req.getStatus())) + .totalReward(req.getTotalReward()) + .createdAt(java.time.Instant.now()) + .updatedAt(java.time.Instant.now()) + .build(); + p = promotionRepository.save(p); + return toDto(p); + } + + @Transactional + public Optional updatePromotion(int id, AdminPromotionRequest req) { + return promotionRepository.findById(id).map(p -> { + p.setType(PromotionType.valueOf(req.getType())); + p.setStartTime(req.getStartTime()); + p.setEndTime(req.getEndTime()); + p.setStatus(PromotionStatus.valueOf(req.getStatus())); + p.setTotalReward(req.getTotalReward()); + return toDto(promotionRepository.save(p)); + }); + } + + @Transactional + public boolean deletePromotion(int id) { + if (!promotionRepository.existsById(id)) return false; + promotionRepository.deleteById(id); + return true; + } + + public List listRewards(int promoId) { + return promotionRewardRepository.findByPromotionIdOrderByPlaceAsc(promoId).stream() + .map(this::toRewardDto) + .collect(Collectors.toList()); + } + + @Transactional + public AdminPromotionRewardDto createReward(int promoId, AdminPromotionRewardRequest req) { + Promotion promo = promotionRepository.findById(promoId) + .orElseThrow(() -> new IllegalArgumentException("Promotion not found: " + promoId)); + PromotionReward r = PromotionReward.builder() + .promotion(promo) + .place(req.getPlace()) + .reward(req.getReward()) + .build(); + r = promotionRewardRepository.save(r); + return toRewardDto(r); + } + + @Transactional + public Optional updateReward(int rewardId, AdminPromotionRewardRequest req) { + return promotionRewardRepository.findById(rewardId).map(r -> { + r.setPlace(req.getPlace()); + r.setReward(req.getReward()); + return toRewardDto(promotionRewardRepository.save(r)); + }); + } + + @Transactional + public boolean deleteReward(int rewardId) { + if (!promotionRewardRepository.existsById(rewardId)) return false; + promotionRewardRepository.deleteById(rewardId); + return true; + } + + public Page getPromotionUsers(int promoId, int page, int size, + String sortBy, String sortDir, + Integer searchUserId) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC; + String property = "points".equalsIgnoreCase(sortBy) ? "points" : "userId"; + Sort sort = Sort.by(direction, property); + Pageable pageable = PageRequest.of(page, size, sort); + + Page pageResult; + if (searchUserId != null) { + pageResult = promotionUserRepository.findByPromoIdAndUserId(promoId, searchUserId, pageable); + } else { + pageResult = promotionUserRepository.findByPromoId(promoId, pageable); + } + + return pageResult.map(this::toUserDto); + } + + @Transactional + public Optional updatePromotionUserPoints(int promoId, int userId, BigDecimal points) { + Optional opt = promotionUserRepository.findByPromoIdAndUserId(promoId, userId); + if (opt.isEmpty()) { + PromotionUser pu = PromotionUser.builder() + .promoId(promoId) + .userId(userId) + .points(points != null ? points : BigDecimal.ZERO) + .updatedAt(java.time.Instant.now()) + .build(); + pu = promotionUserRepository.save(pu); + return Optional.of(toUserDto(pu)); + } + PromotionUser pu = opt.get(); + pu.setPoints(points != null ? points : BigDecimal.ZERO); + return Optional.of(toUserDto(promotionUserRepository.save(pu))); + } + + private AdminPromotionDto toDto(Promotion p) { + return AdminPromotionDto.builder() + .id(p.getId()) + .type(p.getType().name()) + .startTime(p.getStartTime()) + .endTime(p.getEndTime()) + .status(p.getStatus().name()) + .totalReward(p.getTotalReward()) + .createdAt(p.getCreatedAt()) + .updatedAt(p.getUpdatedAt()) + .build(); + } + + private AdminPromotionRewardDto toRewardDto(PromotionReward r) { + return AdminPromotionRewardDto.builder() + .id(r.getId()) + .promoId(r.getPromotion().getId()) + .place(r.getPlace()) + .reward(r.getReward()) + .createdAt(r.getCreatedAt()) + .updatedAt(r.getUpdatedAt()) + .build(); + } + + private AdminPromotionUserDto toUserDto(PromotionUser pu) { + return AdminPromotionUserDto.builder() + .promoId(pu.getPromoId()) + .userId(pu.getUserId()) + .points(pu.getPoints()) + .updatedAt(pu.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/lottery/lottery/service/AdminService.java b/src/main/java/com/lottery/lottery/service/AdminService.java new file mode 100644 index 0000000..6618301 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AdminService.java @@ -0,0 +1,43 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.Admin; +import com.lottery.lottery.repository.AdminRepository; +import com.lottery.lottery.security.admin.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final AdminRepository adminRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public Optional authenticate(String username, String password) { + Optional adminOpt = adminRepository.findByUsername(username); + + if (adminOpt.isEmpty()) { + return Optional.empty(); + } + + Admin admin = adminOpt.get(); + + // Check password + if (!passwordEncoder.matches(password, admin.getPasswordHash())) { + return Optional.empty(); + } + + // Generate JWT token + String token = jwtUtil.generateToken(admin.getId(), username); + return Optional.of(token); + } + + public Optional getAdminByUsername(String username) { + return adminRepository.findByUsername(username); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/AdminUserService.java b/src/main/java/com/lottery/lottery/service/AdminUserService.java new file mode 100644 index 0000000..58001c1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AdminUserService.java @@ -0,0 +1,864 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.model.*; +import com.lottery.lottery.repository.*; +import com.lottery.lottery.util.IpUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private static final long TICKETS_MULTIPLIER = 1_000_000L; + + private static final BigDecimal TICKETS_TO_USD = new BigDecimal("0.001"); // 1000 tickets = 1 USD + + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final UserDRepository userDRepository; + private final TransactionRepository transactionRepository; + private final GameRoundParticipantRepository gameRoundParticipantRepository; + private final PaymentRepository paymentRepository; + private final PayoutRepository payoutRepository; + private final UserTaskClaimRepository userTaskClaimRepository; + private final TaskRepository taskRepository; + private final UserDailyBonusClaimRepository userDailyBonusClaimRepository; + private final EntityManager entityManager; + private final GameRoundRepository gameRoundRepository; + + public Page getUsers( + Pageable pageable, + String search, + Integer banned, + String countryCode, + String languageCode, + Integer dateRegFrom, + Integer dateRegTo, + Long balanceMin, + Long balanceMax, + Integer roundsPlayedMin, + Integer roundsPlayedMax, + Integer referralCountMin, + Integer referralCountMax, + Integer referrerId, + Integer referralLevel, + String ipFilter, + String sortBy, + String sortDir, + boolean excludeMasters) { + + List masterIds = excludeMasters ? userDRepository.findMasterUserIds() : List.of(); + + // Build specification for filtering UserA fields + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (!masterIds.isEmpty()) { + predicates.add(cb.not(root.get("id").in(masterIds))); + } + + // IP filter (exact match; stored as varbinary(16)) + if (ipFilter != null && !ipFilter.trim().isEmpty()) { + byte[] ipBytes = IpUtils.ipToBytes(ipFilter.trim()); + if (ipBytes != null) { + predicates.add(cb.equal(root.get("ip"), ipBytes)); + } + } + + // Search filter (ID, Telegram ID, or name) + if (search != null && !search.trim().isEmpty()) { + String searchPattern = "%" + search.trim() + "%"; + List searchPredicates = new ArrayList<>(); + + // Try to parse as integer (for ID search) + Integer searchId = parseInteger(search.trim()); + if (searchId != null) { + searchPredicates.add(cb.equal(root.get("id"), searchId)); + } + + // Try to parse as long (for Telegram ID search) + Long searchTelegramId = parseLong(search.trim()); + if (searchTelegramId != null) { + searchPredicates.add(cb.equal(root.get("telegramId"), searchTelegramId)); + } + + // Name search (telegramName or screenName) + searchPredicates.add(cb.or( + cb.like(cb.lower(root.get("telegramName")), searchPattern.toLowerCase()), + cb.like(cb.lower(root.get("screenName")), searchPattern.toLowerCase()) + )); + + predicates.add(cb.or(searchPredicates.toArray(new Predicate[0]))); + } + + // Ban status filter + if (banned != null) { + predicates.add(cb.equal(root.get("banned"), banned)); + } + + // Country code filter + if (countryCode != null && !countryCode.trim().isEmpty()) { + predicates.add(cb.equal(root.get("countryCode"), countryCode.trim())); + } + + // Language code filter + if (languageCode != null && !languageCode.trim().isEmpty()) { + predicates.add(cb.equal(root.get("languageCode"), languageCode.trim())); + } + + // Registration date range filter + if (dateRegFrom != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("dateReg"), dateRegFrom)); + } + if (dateRegTo != null) { + // Include the end date (<= instead of <) + // Convert to end of day timestamp (23:59:59) + int endOfDayTimestamp = dateRegTo + 86399; // Add 86399 seconds (23:59:59) + predicates.add(cb.lessThanOrEqualTo(root.get("dateReg"), endOfDayTimestamp)); + } + + // Balance / rounds / referral filters via subqueries so DB handles pagination + if (balanceMin != null || balanceMax != null || roundsPlayedMin != null || roundsPlayedMax != null) { + Subquery subB = query.subquery(Integer.class); + Root br = subB.from(UserB.class); + subB.select(br.get("id")); + List subPreds = new ArrayList<>(); + if (balanceMin != null) { + subPreds.add(cb.greaterThanOrEqualTo( + cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMin)); + } + if (balanceMax != null) { + subPreds.add(cb.lessThanOrEqualTo( + cb.sum(br.get("balanceA"), br.get("balanceB")), balanceMax)); + } + if (roundsPlayedMin != null) subPreds.add(cb.greaterThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMin)); + if (roundsPlayedMax != null) subPreds.add(cb.lessThanOrEqualTo(br.get("roundsPlayed"), roundsPlayedMax)); + subB.where(cb.and(subPreds.toArray(new Predicate[0]))); + predicates.add(cb.in(root.get("id")).value(subB)); + } + if (referralCountMin != null || referralCountMax != null) { + Subquery subD = query.subquery(Integer.class); + Root dr = subD.from(UserD.class); + subD.select(dr.get("id")); + var refSum = cb.sum(cb.sum(cb.sum(cb.sum(dr.get("referals1"), dr.get("referals2")), + dr.get("referals3")), dr.get("referals4")), dr.get("referals5")); + List subPreds = new ArrayList<>(); + if (referralCountMin != null) subPreds.add(cb.greaterThanOrEqualTo(refSum.as(Long.class), cb.literal(referralCountMin.longValue()))); + if (referralCountMax != null) subPreds.add(cb.lessThanOrEqualTo(refSum.as(Long.class), cb.literal(referralCountMax.longValue()))); + subD.where(cb.and(subPreds.toArray(new Predicate[0]))); + predicates.add(cb.in(root.get("id")).value(subD)); + } + + if (referrerId != null && referrerId > 0 && referralLevel != null && referralLevel >= 1 && referralLevel <= 5) { + Subquery subRef = query.subquery(Integer.class); + Root refDr = subRef.from(UserD.class); + subRef.select(refDr.get("id")); + Predicate refererPred = switch (referralLevel) { + case 1 -> cb.equal(refDr.get("refererId1"), referrerId); + case 2 -> cb.equal(refDr.get("refererId2"), referrerId); + case 3 -> cb.equal(refDr.get("refererId3"), referrerId); + case 4 -> cb.equal(refDr.get("refererId4"), referrerId); + case 5 -> cb.equal(refDr.get("refererId5"), referrerId); + default -> cb.disjunction(); + }; + subRef.where(refererPred); + predicates.add(cb.in(root.get("id")).value(subRef)); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"); + boolean useJoinSort = sortBy != null && sortRequiresJoin.contains(sortBy); + List userList; + long totalElements; + + if (useJoinSort) { + List orderedIds = getOrderedUserIdsForAdminList( + search, banned, countryCode, languageCode, + dateRegFrom, dateRegTo, balanceMin, balanceMax, + roundsPlayedMin, roundsPlayedMax, referralCountMin, referralCountMax, + referrerId, referralLevel, ipFilter, + sortBy, sortDir != null ? sortDir : "desc", + pageable.getPageSize(), (int) pageable.getOffset(), + masterIds); + totalElements = userARepository.count(spec); + if (orderedIds.isEmpty()) { + userList = List.of(); + } else { + List unsorted = userARepository.findAllById(orderedIds); + userList = unsorted.stream() + .sorted(Comparator.comparingInt(u -> orderedIds.indexOf(u.getId()))) + .collect(Collectors.toList()); + } + } else { + Page userPage = userARepository.findAll(spec, pageable); + userList = userPage.getContent(); + totalElements = userPage.getTotalElements(); + } + + List userIds = userList.stream().map(UserA::getId).collect(Collectors.toList()); + + Map userBMap = userBRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(UserB::getId, ub -> ub)); + + Map userDMap = userDRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(UserD::getId, ud -> ud)); + + // Map to DTOs (filtering is done in DB via specification subqueries) + List pageContent = userList.stream() + .map(userA -> { + UserB userB = userBMap.getOrDefault(userA.getId(), + UserB.builder() + .id(userA.getId()) + .balanceA(0L) + .balanceB(0L) + .depositTotal(0L) + .depositCount(0) + .withdrawTotal(0L) + .withdrawCount(0) + .roundsPlayed(0) + .build()); + + UserD userD = userDMap.getOrDefault(userA.getId(), + UserD.builder() + .id(userA.getId()) + .referals1(0) + .referals2(0) + .referals3(0) + .referals4(0) + .referals5(0) + .fromReferals1(0L) + .fromReferals2(0L) + .fromReferals3(0L) + .fromReferals4(0L) + .fromReferals5(0L) + .build()); + + int totalReferrals = userD.getReferals1() + userD.getReferals2() + + userD.getReferals3() + userD.getReferals4() + userD.getReferals5(); + long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() + + userD.getFromReferals3() + userD.getFromReferals4() + userD.getFromReferals5(); + long profit = userB.getDepositTotal() - userB.getWithdrawTotal(); + BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal()); + BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal()); + BigDecimal profitUsd = ticketsToUsd(profit); + + return AdminUserDto.builder() + .id(userA.getId()) + .screenName(userA.getScreenName()) + .telegramId(userA.getTelegramId()) + .telegramName(userA.getTelegramName()) + .balanceA(userB.getBalanceA()) + .balanceB(userB.getBalanceB()) + .depositTotal(userB.getDepositTotal()) + .depositCount(userB.getDepositCount()) + .withdrawTotal(userB.getWithdrawTotal()) + .withdrawCount(userB.getWithdrawCount()) + .roundsPlayed(userB.getRoundsPlayed()) + .dateReg(userA.getDateReg()) + .dateLogin(userA.getDateLogin()) + .banned(userA.getBanned()) + .countryCode(userA.getCountryCode()) + .languageCode(userA.getLanguageCode()) + .referralCount(totalReferrals) + .totalCommissionsEarned(totalCommissions) + .profit(profit) + .depositTotalUsd(depositTotalUsd) + .withdrawTotalUsd(withdrawTotalUsd) + .profitUsd(profitUsd) + .build(); + }) + .collect(Collectors.toList()); + + return new PageImpl<>(pageContent, pageable, totalElements); + } + + /** + * Returns ordered user ids for admin list when sorting by UserB/UserD columns (balanceA, depositTotal, etc.). + */ + @SuppressWarnings("unchecked") + private List getOrderedUserIdsForAdminList( + String search, + Integer banned, + String countryCode, + String languageCode, + Integer dateRegFrom, + Integer dateRegTo, + Long balanceMin, + Long balanceMax, + Integer roundsPlayedMin, + Integer roundsPlayedMax, + Integer referralCountMin, + Integer referralCountMax, + Integer referrerId, + Integer referralLevel, + String ipFilter, + String sortBy, + String sortDir, + int limit, + int offset, + List excludeUserIds) { + StringBuilder sql = new StringBuilder( + "SELECT a.id FROM db_users_a a " + + "INNER JOIN db_users_b b ON a.id = b.id " + + "INNER JOIN db_users_d d ON a.id = d.id WHERE 1=1"); + List params = new ArrayList<>(); + int paramIndex = 1; + + if (excludeUserIds != null && !excludeUserIds.isEmpty()) { + sql.append(" AND a.id NOT IN ("); + for (int i = 0; i < excludeUserIds.size(); i++) { + sql.append(i == 0 ? "?" : ",?"); + } + sql.append(")"); + params.addAll(excludeUserIds); + paramIndex += excludeUserIds.size(); + } + + if (search != null && !search.trim().isEmpty()) { + String searchPattern = "%" + search.trim() + "%"; + Integer searchId = parseInteger(search.trim()); + Long searchTgId = parseLong(search.trim()); + if (searchId != null) { + sql.append(" AND a.id = ?"); + params.add(searchId); + paramIndex++; + } else if (searchTgId != null) { + sql.append(" AND a.telegram_id = ?"); + params.add(searchTgId); + paramIndex++; + } else { + sql.append(" AND (a.screen_name LIKE ? OR a.telegram_name LIKE ?)"); + params.add(searchPattern); + params.add(searchPattern); + paramIndex += 2; + } + } + if (banned != null) { + sql.append(" AND a.banned = ?"); + params.add(banned); + paramIndex++; + } + if (countryCode != null && !countryCode.trim().isEmpty()) { + sql.append(" AND a.country_code = ?"); + params.add(countryCode.trim()); + paramIndex++; + } + if (languageCode != null && !languageCode.trim().isEmpty()) { + sql.append(" AND a.language_code = ?"); + params.add(languageCode.trim()); + paramIndex++; + } + if (dateRegFrom != null) { + sql.append(" AND a.date_reg >= ?"); + params.add(dateRegFrom); + paramIndex++; + } + if (dateRegTo != null) { + sql.append(" AND a.date_reg <= ?"); + params.add(dateRegTo); + paramIndex++; + } + if (balanceMin != null) { + sql.append(" AND (b.balance_a + b.balance_b) >= ?"); + params.add(balanceMin); + paramIndex++; + } + if (balanceMax != null) { + sql.append(" AND (b.balance_a + b.balance_b) <= ?"); + params.add(balanceMax); + paramIndex++; + } + if (roundsPlayedMin != null) { + sql.append(" AND b.rounds_played >= ?"); + params.add(roundsPlayedMin); + paramIndex++; + } + if (roundsPlayedMax != null) { + sql.append(" AND b.rounds_played <= ?"); + params.add(roundsPlayedMax); + paramIndex++; + } + if (referralCountMin != null || referralCountMax != null) { + sql.append(" AND (d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"); + if (referralCountMin != null && referralCountMax != null) { + sql.append(" BETWEEN ? AND ?"); + params.add(referralCountMin.longValue()); + params.add(referralCountMax.longValue()); + paramIndex += 2; + } else if (referralCountMin != null) { + sql.append(" >= ?"); + params.add(referralCountMin.longValue()); + paramIndex++; + } else { + sql.append(" <= ?"); + params.add(referralCountMax.longValue()); + paramIndex++; + } + } + if (referrerId != null && referrerId > 0 && referralLevel != null && referralLevel >= 1 && referralLevel <= 5) { + sql.append(" AND d.referer_id_").append(referralLevel).append(" = ?"); + params.add(referrerId); + paramIndex++; + } + if (ipFilter != null && !ipFilter.trim().isEmpty()) { + byte[] ipBytes = IpUtils.ipToBytes(ipFilter.trim()); + if (ipBytes != null) { + sql.append(" AND a.ip = ?"); + params.add(ipBytes); + paramIndex++; + } + } + + String orderColumn = switch (sortBy) { + case "balanceA" -> "b.balance_a"; + case "depositTotal" -> "b.deposit_total"; + case "withdrawTotal" -> "b.withdraw_total"; + case "roundsPlayed" -> "b.rounds_played"; + case "referralCount" -> "(d.referals_1 + d.referals_2 + d.referals_3 + d.referals_4 + d.referals_5)"; + case "profit" -> "(b.deposit_total - b.withdraw_total)"; + default -> "a.id"; + }; + String direction = "asc".equalsIgnoreCase(sortDir) ? " ASC" : " DESC"; + sql.append(" ORDER BY ").append(orderColumn).append(direction); + sql.append(" LIMIT ? OFFSET ?"); + params.add(limit); + params.add(offset); + + Query query = entityManager.createNativeQuery(sql.toString()); + for (int i = 0; i < params.size(); i++) { + query.setParameter(i + 1, params.get(i)); + } + List rows = query.getResultList(); + List ids = new ArrayList<>(); + for (Object row : rows) { + if (row instanceof Number n) { + ids.add(n.intValue()); + } + } + return ids; + } + + private Integer parseInteger(String str) { + try { + return Integer.parseInt(str); + } catch (NumberFormatException e) { + return null; + } + } + + private Long parseLong(String str) { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return null; + } + } + + public AdminUserDetailDto getUserDetail(Integer userId, boolean excludeMasters) { + if (excludeMasters) { + Optional userDOpt = userDRepository.findById(userId); + if (userDOpt.isPresent()) { + UserD d = userDOpt.get(); + if (d.getMasterId() > 0 && d.getId().equals(d.getMasterId())) { + return null; + } + } + } + Optional userAOpt = userARepository.findById(userId); + if (userAOpt.isEmpty()) { + return null; + } + + UserA userA = userAOpt.get(); + UserB userB = userBRepository.findById(userId).orElse( + UserB.builder() + .id(userId) + .balanceA(0L) + .balanceB(0L) + .depositTotal(0L) + .depositCount(0) + .withdrawTotal(0L) + .withdrawCount(0) + .roundsPlayed(0) + .totalWinAfterDeposit(0L) + .withdrawalsDisabled(false) + .build()); + + UserD userD = userDRepository.findById(userId).orElse( + UserD.builder() + .id(userId) + .referals1(0) + .referals2(0) + .referals3(0) + .referals4(0) + .referals5(0) + .fromReferals1(0L) + .fromReferals2(0L) + .fromReferals3(0L) + .fromReferals4(0L) + .fromReferals5(0L) + .toReferer1(0L) + .toReferer2(0L) + .toReferer3(0L) + .toReferer4(0L) + .toReferer5(0L) + .masterId(0) + .build()); + + int totalReferrals = userD.getReferals1() + userD.getReferals2() + + userD.getReferals3() + userD.getReferals4() + userD.getReferals5(); + long totalCommissions = userD.getFromReferals1() + userD.getFromReferals2() + + userD.getFromReferals3() + userD.getFromReferals4() + userD.getFromReferals5(); + + // Build referral levels + List referralLevels = new ArrayList<>(); + for (int level = 1; level <= 5; level++) { + int refererId = switch (level) { + case 1 -> userD.getRefererId1(); + case 2 -> userD.getRefererId2(); + case 3 -> userD.getRefererId3(); + case 4 -> userD.getRefererId4(); + case 5 -> userD.getRefererId5(); + default -> 0; + }; + int referralCount = switch (level) { + case 1 -> userD.getReferals1(); + case 2 -> userD.getReferals2(); + case 3 -> userD.getReferals3(); + case 4 -> userD.getReferals4(); + case 5 -> userD.getReferals5(); + default -> 0; + }; + long commissionsEarned = switch (level) { + case 1 -> userD.getFromReferals1(); + case 2 -> userD.getFromReferals2(); + case 3 -> userD.getFromReferals3(); + case 4 -> userD.getFromReferals4(); + case 5 -> userD.getFromReferals5(); + default -> 0L; + }; + long commissionsPaid = switch (level) { + case 1 -> userD.getToReferer1(); + case 2 -> userD.getToReferer2(); + case 3 -> userD.getToReferer3(); + case 4 -> userD.getToReferer4(); + case 5 -> userD.getToReferer5(); + default -> 0L; + }; + + referralLevels.add(ReferralLevelDto.builder() + .level(level) + .refererId(refererId > 0 ? refererId : null) + .referralCount(referralCount) + .commissionsEarned(commissionsEarned) + .commissionsPaid(commissionsPaid) + .commissionsEarnedUsd(ticketsToUsd(commissionsEarned)) + .commissionsPaidUsd(ticketsToUsd(commissionsPaid)) + .build()); + } + + BigDecimal depositTotalUsd = ticketsToUsd(userB.getDepositTotal()); + BigDecimal withdrawTotalUsd = ticketsToUsd(userB.getWithdrawTotal()); + BigDecimal totalCommissionsEarnedUsd = ticketsToUsd(totalCommissions); + + return AdminUserDetailDto.builder() + .id(userA.getId()) + .screenName(userA.getScreenName()) + .telegramId(userA.getTelegramId()) + .telegramName(userA.getTelegramName()) + .isPremium(userA.getIsPremium()) + .languageCode(userA.getLanguageCode()) + .countryCode(userA.getCountryCode()) + .deviceCode(userA.getDeviceCode()) + .avatarUrl(userA.getAvatarUrl()) + .dateReg(userA.getDateReg()) + .dateLogin(userA.getDateLogin()) + .banned(userA.getBanned()) + .ipAddress(IpUtils.bytesToIp(userA.getIp())) + .balanceA(userB.getBalanceA()) + .depositTotal(userB.getDepositTotal()) + .depositCount(userB.getDepositCount()) + .withdrawTotal(userB.getWithdrawTotal()) + .withdrawCount(userB.getWithdrawCount()) + .depositTotalUsd(depositTotalUsd) + .withdrawTotalUsd(withdrawTotalUsd) + .withdrawalsDisabled(Boolean.TRUE.equals(userB.getWithdrawalsDisabled())) + .roundsPlayed(userB.getRoundsPlayed()) + .referralCount(totalReferrals) + .totalCommissionsEarned(totalCommissions) + .totalCommissionsEarnedUsd(totalCommissionsEarnedUsd) + .masterId(userD.getMasterId() > 0 ? userD.getMasterId() : null) + .referralLevels(referralLevels) + .build(); + } + + private static BigDecimal ticketsToUsd(long ticketsBigint) { + if (ticketsBigint == 0) return BigDecimal.ZERO; + return BigDecimal.valueOf(ticketsBigint).divide(BigDecimal.valueOf(1_000_000L), 6, RoundingMode.HALF_UP).multiply(TICKETS_TO_USD).setScale(2, RoundingMode.HALF_UP); + } + + public Page getUserTransactions(Integer userId, Pageable pageable) { + Page transactions = transactionRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + return transactions.map(t -> AdminTransactionDto.builder() + .id(t.getId()) + .amount(t.getAmount()) + .type(t.getType().name()) + .taskId(t.getTaskId()) + .roundId(t.getRoundId()) + .createdAt(t.getCreatedAt()) + .build()); + } + + /** Deposits (payments) for user detail. */ + public Page getUserPayments(Integer userId, Pageable pageable) { + Page page = paymentRepository.findByUserId(userId, pageable); + return page.map(p -> UserDepositDto.builder() + .id(p.getId()) + .usdAmount(p.getUsdAmount()) + .status(p.getStatus().name()) + .orderId(p.getOrderId()) + .createdAt(p.getCreatedAt()) + .completedAt(p.getCompletedAt()) + .build()); + } + + /** Withdrawals (CRYPTO payouts only) for user detail. */ + public Page getUserPayouts(Integer userId, Pageable pageable) { + Page page = payoutRepository.findByUserIdAndType(userId, Payout.PayoutType.CRYPTO, pageable); + return page.map(p -> UserWithdrawalDto.builder() + .id(p.getId()) + .usdAmount(p.getUsdAmount()) + .cryptoName(p.getCryptoName()) + .amountToSend(p.getAmountToSend()) + .txhash(p.getTxhash()) + .status(p.getStatus().name()) + .paymentId(p.getPaymentId()) + .createdAt(p.getCreatedAt()) + .resolvedAt(p.getResolvedAt()) + .build()); + } + + /** + * Game history from transactions (BET/WIN). Participants table is cleaned after each round. + */ + public Page getUserGameRounds(Integer userId, Pageable pageable) { + Page betPage = transactionRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, Transaction.TransactionType.BET, pageable); + List bets = betPage.getContent(); + if (bets.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + Set roundIds = bets.stream().map(Transaction::getRoundId).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + List wins = roundIds.isEmpty() ? List.of() : transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIds); + Map payoutByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getAmount() != null ? t.getAmount() : 0L, (a, b) -> a)); + Map resolvedAtByRound = wins.stream().collect(Collectors.toMap(Transaction::getRoundId, t -> t.getCreatedAt() != null ? t.getCreatedAt() : Instant.EPOCH, (a, b) -> a)); + + Map roundById = roundIds.isEmpty() ? Map.of() : gameRoundRepository.findAllByIdWithRoom(roundIds).stream() + .collect(Collectors.toMap(GameRound::getId, r -> r, (a, b) -> a)); + + List rounds = bets.stream() + .map(bet -> { + Long roundId = bet.getRoundId(); + GameRound gr = roundById.get(roundId); + Integer roomNumber = gr != null && gr.getRoom() != null ? gr.getRoom().getRoomNumber() : null; + Long totalBet = gr != null ? gr.getTotalBet() : null; + long userBet = bet.getAmount() != null ? Math.abs(bet.getAmount()) : 0L; + Long payout = payoutByRound.getOrDefault(roundId, 0L); + boolean isWinner = payout > 0; + Instant resolvedAt = resolvedAtByRound.getOrDefault(roundId, bet.getCreatedAt()); + return AdminGameRoundDto.builder() + .roundId(roundId) + .roomNumber(roomNumber) + .phase(gr != null && gr.getPhase() != null ? gr.getPhase().name() : null) + .totalBet(totalBet) + .userBet(userBet) + .winnerUserId(isWinner ? userId : null) + .winnerBet(isWinner ? userBet : null) + .payout(isWinner ? payout : 0L) + .commission(null) + .startedAt(null) + .resolvedAt(resolvedAt) + .isWinner(isWinner) + .build(); + }) + .collect(Collectors.toList()); + + return new PageImpl<>(rounds, pageable, betPage.getTotalElements()); + } + + public Map getUserTasks(Integer userId) { + List claims = userTaskClaimRepository.findByUserId(userId); + List allTasks = taskRepository.findAll(); + + Map taskMap = allTasks.stream() + .collect(Collectors.toMap(Task::getId, t -> t)); + + List completedTasks = claims.stream() + .map(claim -> { + Task task = taskMap.get(claim.getTaskId()); + // Convert LocalDateTime to Instant (assuming database stores in UTC) + Instant claimedAtInstant = claim.getClaimedAt() != null + ? claim.getClaimedAt().atZone(ZoneId.of("UTC")).toInstant() + : null; + return AdminTaskClaimDto.builder() + .id(claim.getId()) + .taskId(claim.getTaskId()) + .taskTitle(task != null ? task.getTitle() : "Unknown Task") + .taskType(task != null ? task.getType() : "unknown") + .claimedAt(claimedAtInstant) + .build(); + }) + .collect(Collectors.toList()); + + List> availableTasks = allTasks.stream() + .filter(task -> !claims.stream().anyMatch(c -> c.getTaskId().equals(task.getId()))) + .map(task -> Map.of( + "id", task.getId(), + "title", task.getTitle(), + "type", task.getType(), + "requirement", task.getRequirement(), + "rewardAmount", task.getRewardAmount(), + "rewardType", task.getRewardType(), + "description", task.getDescription() != null ? task.getDescription() : "" + )) + .collect(Collectors.toList()); + + // Get daily bonus claims + List dailyBonusClaims = userDailyBonusClaimRepository.findByUserIdOrderByClaimedAtDesc(userId); + List> dailyBonuses = dailyBonusClaims.stream() + .map(claim -> { + Instant claimedAtInstant = claim.getClaimedAt() != null + ? claim.getClaimedAt().atZone(ZoneId.of("UTC")).toInstant() + : null; + return Map.of( + "id", claim.getId(), + "claimedAt", claimedAtInstant != null ? claimedAtInstant.toEpochMilli() : null, + "screenName", claim.getScreenName() != null ? claim.getScreenName() : "-" + ); + }) + .collect(Collectors.toList()); + + return Map.of( + "completed", completedTasks, + "available", availableTasks, + "dailyBonuses", dailyBonuses + ); + } + + @Transactional + public void setBanned(Integer userId, boolean banned) { + UserA user = userARepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found with ID: " + userId)); + user.setBanned(banned ? 1 : 0); + userARepository.save(user); + } + + @Transactional + public void setWithdrawalsEnabled(Integer userId, boolean enabled) { + UserB userB = userBRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found with ID: " + userId)); + userB.setWithdrawalsDisabled(!enabled); + userBRepository.save(userB); + } + + @Transactional + public BalanceAdjustmentResponse adjustBalance(Integer userId, BalanceAdjustmentRequest request) { + // Verify user exists + Optional userAOpt = userARepository.findById(userId); + if (userAOpt.isEmpty()) { + throw new IllegalArgumentException("User not found with ID: " + userId); + } + + // Get or create UserB + UserB userB = userBRepository.findById(userId).orElse( + UserB.builder() + .id(userId) + .balanceA(0L) + .balanceB(0L) + .depositTotal(0L) + .depositCount(0) + .withdrawTotal(0L) + .withdrawCount(0) + .roundsPlayed(0) + .build()); + + // Store previous balances + Long previousBalanceA = userB.getBalanceA(); + Long previousBalanceB = userB.getBalanceB(); + + // Calculate new balance + Long adjustmentAmount = request.getAmount(); + if (request.getOperation() == BalanceAdjustmentRequest.OperationType.SUBTRACT) { + adjustmentAmount = -adjustmentAmount; + } + + Long newBalanceA = userB.getBalanceA(); + Long newBalanceB = userB.getBalanceB(); + + if (request.getBalanceType() == BalanceAdjustmentRequest.BalanceType.A) { + newBalanceA = userB.getBalanceA() + adjustmentAmount; + if (newBalanceA < 0) { + throw new IllegalArgumentException("Balance A cannot be negative. Current: " + + (userB.getBalanceA() / 1_000_000.0) + ", Attempted adjustment: " + + (adjustmentAmount / 1_000_000.0)); + } + userB.setBalanceA(newBalanceA); + } else { + newBalanceB = userB.getBalanceB() + adjustmentAmount; + if (newBalanceB < 0) { + throw new IllegalArgumentException("Balance B cannot be negative. Current: " + + (userB.getBalanceB() / 1_000_000.0) + ", Attempted adjustment: " + + (adjustmentAmount / 1_000_000.0)); + } + userB.setBalanceB(newBalanceB); + } + + // Save updated balance + userBRepository.save(userB); + + // Create transaction record for audit + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(adjustmentAmount) + .type(adjustmentAmount > 0 ? Transaction.TransactionType.DEPOSIT : Transaction.TransactionType.WITHDRAWAL) + .createdAt(java.time.Instant.now()) + .build(); + transactionRepository.save(transaction); + + return BalanceAdjustmentResponse.builder() + .newBalanceA(userB.getBalanceA()) + .newBalanceB(userB.getBalanceB()) + .previousBalanceA(previousBalanceA) + .previousBalanceB(previousBalanceB) + .adjustmentAmount(adjustmentAmount) + .message("Balance adjusted successfully") + .build(); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/AvatarService.java b/src/main/java/com/lottery/lottery/service/AvatarService.java new file mode 100644 index 0000000..d02cbbd --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/AvatarService.java @@ -0,0 +1,693 @@ +package com.lottery.lottery.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import jakarta.annotation.PostConstruct; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.Adler32; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.UserARepository; + +/** + * Service for handling user avatar download, processing, and storage. + * Stores avatar URL and last_telegram_file_id in database to avoid unnecessary downloads. + * URLs include ?v=timestamp parameter for cache busting (forces refresh within 1h session lifetime). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AvatarService { + + private final TelegramProperties telegramProperties; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UserARepository userARepository; + + @Value("${app.avatar.storage-path:./data/avatars}") + private String storagePath; + + // Normalized absolute storage path (computed once on initialization) + private Path normalizedStoragePath; + + @Value("${app.avatar.public-base-url:}") + private String publicBaseUrl; + + @Value("${app.avatar.max-size-bytes:2097152}") // 2MB default + private long maxSizeBytes; + + @Value("${app.avatar.max-dimension:110}") // 110x110 default + private int maxDimension; + + @Value("${app.avatar.cache-ttl-minutes:5}") // 5 minutes default + private int cacheTtlMinutes; + + private final HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + // In-memory cache for avatar URLs (userId -> CacheEntry with URL and timestamp) + private final Map avatarUrlCache = new ConcurrentHashMap<>(); + + // Store last downloaded content type for avatar type detection + private volatile String lastDownloadedContentType = null; + + /** + * Cache entry with timestamp for TTL-based expiration. + */ + private static class CacheEntry { + final String avatarUrl; + final long timestamp; + + CacheEntry(String avatarUrl) { + this.avatarUrl = avatarUrl; + this.timestamp = System.currentTimeMillis(); + } + + boolean isExpired(int ttlMinutes) { + return (System.currentTimeMillis() - timestamp) > (ttlMinutes * 60 * 1000L); + } + } + + /** + * Initializes the normalized storage path after dependency injection. + */ + @PostConstruct + public void init() { + normalizedStoragePath = Paths.get(storagePath).toAbsolutePath().normalize(); + log.debug("Avatar storage path initialized: {}", normalizedStoragePath); + } + + /** + * Updates user avatar if the Telegram file_id has changed. + * Only downloads and processes avatar if last_telegram_file_id differs from current file_id. + * Stores avatar URL with ?v=timestamp parameter in database for cache busting. + * + * @param user UserA entity (will be updated with new avatar_url and last_telegram_file_id) + * @param telegramUserId Telegram user ID (from initData.user.id) + * @param tgUser Telegram user data map (from initData) - used for fallback to photo_url + * @return true if avatar was updated, false if no update needed or failed + */ + public boolean updateAvatarIfNeeded(UserA user, Long telegramUserId, Map tgUser) { + try { + String currentFileId = null; + byte[] imageData = null; + boolean isSvg = false; + + // Step 1: Try to get user profile photos from Telegram Bot API + String fileId = getProfilePhotoFileId(telegramUserId); + if (fileId != null && !fileId.isEmpty()) { + currentFileId = fileId; + + // Check if file_id changed (skip download if unchanged) + if (fileId.equals(user.getLastTelegramFileId())) { + return false; + } + + // Step 2: Get file path from Telegram + String telegramFilePath = getTelegramFilePath(fileId); + if (telegramFilePath != null && !telegramFilePath.isEmpty()) { + // Step 3: Download the actual image from Telegram + imageData = downloadTelegramFile(telegramFilePath); + } + } + + // Fallback: If Bot API failed (user restricted access or no photos), try photo_url from initData + if (imageData == null || imageData.length == 0) { + String photoUrl = extractAvatarUrl(tgUser); + if (photoUrl != null && !photoUrl.isEmpty()) { + imageData = downloadAvatar(photoUrl); + String contentTypeFromDownload = getLastDownloadedContentType(); + if (imageData != null && imageData.length > 0) { + // Check if it's an SVG - use Content-Type header first, then URL extension, then content check + String lowerUrl = photoUrl.toLowerCase(); + boolean urlSuggestsSvg = lowerUrl.endsWith(".svg"); + boolean contentTypeSuggestsSvg = contentTypeFromDownload != null && + (contentTypeFromDownload.toLowerCase().contains("svg") || contentTypeFromDownload.toLowerCase().contains("xml")); + boolean contentSuggestsSvg = imageData.length > 4 && + new String(imageData, 0, Math.min(100, imageData.length), java.nio.charset.StandardCharsets.UTF_8).trim().startsWith(" maxSizeBytes) { + log.warn("Avatar too large for userId={}: {} bytes (max: {})", user.getId(), imageData.length, maxSizeBytes); + return false; + } + + // Determine file extension: .svg for fallback avatars, .png for Bot API avatars + String fileExtension = isSvg ? "svg" : "png"; + + // Calculate path components once (includes hash calculation) - reused for both file path and URL + PathComponents pathComponents = calculatePathComponents(user.getId(), fileExtension); + + // Calculate file path using sharding with appropriate extension + Path filePath = calculateFilePath(pathComponents); + + // Create directories if they don't exist + Path parentDir = filePath.getParent(); + try { + Files.createDirectories(parentDir); + } catch (Exception e) { + log.error("Failed to create parent directory for userId={}, parentDir={}: {}", + user.getId(), parentDir, e.getMessage(), e); + return false; + } + + // Save file: SVG directly, PNG after processing + if (isSvg) { + // Save SVG directly + try { + Files.write(filePath, imageData); + } catch (Exception e) { + log.error("Exception while writing SVG file for userId={}, filePath={}: {}", + user.getId(), filePath, e.getMessage(), e); + return false; + } + } else { + // Process and save PNG (resize if needed) + BufferedImage image = processImage(imageData); + if (image == null) { + log.warn("Failed to process image for userId={}, imageData size={} bytes", user.getId(), imageData.length); + return false; + } + + File outputFile = filePath.toFile(); + boolean written = ImageIO.write(image, "png", outputFile); + if (!written) { + log.warn("ImageIO.write() returned false for userId={}, filePath={}", user.getId(), filePath); + } + } + + // Build public URL with ?v=timestamp parameter for cache busting (reuses pathComponents) + String avatarUrl = buildPublicUrlWithTimestamp(pathComponents); + + // Update user entity with new avatar URL and file_id + user.setAvatarUrl(avatarUrl); + user.setLastTelegramFileId(currentFileId); + userARepository.save(user); + + // Invalidate cache when avatar is updated + invalidateCache(user.getId()); + + return true; + + } catch (Exception e) { + log.error("Error updating avatar for userId={}: {}", user.getId(), e.getMessage(), e); + return false; + } + } + + /** + * Gets the public URL for a user's avatar from database. + * Returns null if user has no avatar URL stored. + * + * @param userId User ID + * @return Avatar URL from database, or null if not found + */ + public String getAvatarUrl(Integer userId) { + if (userId == null) { + return null; + } + + return userARepository.findById(userId) + .map(UserA::getAvatarUrl) + .orElse(null); + } + + /** + * Gets avatar URLs for multiple users in a single database query (optimized for bulk operations). + * Returns a map of userId -> avatarUrl. Users without avatars will have null values in the map. + * Uses cache to avoid database queries for recently fetched avatars. + * + * @param userIds List of user IDs + * @return Map of userId to avatarUrl + */ + public Map getAvatarUrls(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyMap(); + } + + Map avatarUrlMap = new HashMap<>(); + List uncachedUserIds = new ArrayList<>(); + + // Check cache first + for (Integer userId : userIds) { + CacheEntry cached = avatarUrlCache.get(userId); + if (cached != null && !cached.isExpired(cacheTtlMinutes)) { + avatarUrlMap.put(userId, cached.avatarUrl); + // Cache hit + } else { + uncachedUserIds.add(userId); + } + } + + // Fetch uncached users from database in one query + if (!uncachedUserIds.isEmpty()) { + List users = userARepository.findAllById(uncachedUserIds); + + for (UserA user : users) { + String avatarUrl = user.getAvatarUrl(); + avatarUrlMap.put(user.getId(), avatarUrl); + + // Update cache + if (avatarUrl != null) { + avatarUrlCache.put(user.getId(), new CacheEntry(avatarUrl)); + } else { + // Cache null for a shorter time (1 minute) + avatarUrlCache.put(user.getId(), new CacheEntry(null)); + } + } + + // Ensure all requested userIds are in the map (even if null) + for (Integer userId : uncachedUserIds) { + avatarUrlMap.putIfAbsent(userId, null); + } + + // Bulk fetch completed + } + + return avatarUrlMap; + } + + /** + * Invalidates cache entry for a user (called when avatar is updated). + * + * @param userId User ID + */ + public void invalidateCache(Integer userId) { + if (userId != null) { + avatarUrlCache.remove(userId); + // Cache invalidated + } + } + + /** + * Gets user profile photos from Telegram Bot API and returns the file_id of the largest photo. + * + * @param telegramUserId Telegram user ID + * @return file_id of the largest profile photo, or null if no photos found + */ + private String getProfilePhotoFileId(Long telegramUserId) { + try { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured for avatar fetching"); + return null; + } + + String apiUrl = String.format("https://api.telegram.org/bot%s/getUserProfilePhotos?user_id=%d&limit=1", + botToken, telegramUserId); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .GET() + .timeout(java.time.Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.warn("Failed to get profile photos from Telegram: HTTP {}", response.statusCode()); + return null; + } + + // Parse JSON response + JsonNode jsonResponse = objectMapper.readTree(response.body()); + + if (!jsonResponse.get("ok").asBoolean()) { + String description = jsonResponse.has("description") ? jsonResponse.get("description").asText() : "Unknown error"; + log.warn("Telegram API returned error when getting profile photos: {}, telegramUserId={}", + description, telegramUserId); + return null; + } + + JsonNode result = jsonResponse.get("result"); + int totalCount = result.get("total_count").asInt(); + + if (totalCount == 0) { + return null; + } + + // Get the first photo (photos[0]) + JsonNode photos = result.get("photos"); + if (!photos.isArray() || photos.size() == 0) { + log.warn("Photos array is empty or invalid for telegramUserId={}", telegramUserId); + return null; + } + + JsonNode firstPhoto = photos.get(0); + if (!firstPhoto.isArray() || firstPhoto.size() == 0) { + log.warn("First photo array is empty or invalid for telegramUserId={}", telegramUserId); + return null; + } + + // Get the last element (largest resolution) + JsonNode largestPhoto = firstPhoto.get(firstPhoto.size() - 1); + String fileId = largestPhoto.get("file_id").asText(); + + return fileId; + + } catch (Exception e) { + log.error("Error fetching profile photos for telegramUserId={}: {}", telegramUserId, e.getMessage(), e); + return null; + } + } + + /** + * Gets file path from Telegram using file_id. + * + * @param fileId Telegram file_id + * @return file_path from Telegram, or null if failed + */ + private String getTelegramFilePath(String fileId) { + try { + String botToken = telegramProperties.getBotToken(); + String apiUrl = String.format("https://api.telegram.org/bot%s/getFile?file_id=%s", + botToken, fileId); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .GET() + .timeout(java.time.Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.warn("Failed to get file path from Telegram: HTTP {}", response.statusCode()); + return null; + } + + // Parse JSON response + JsonNode jsonResponse = objectMapper.readTree(response.body()); + + if (!jsonResponse.get("ok").asBoolean()) { + String description = jsonResponse.has("description") ? jsonResponse.get("description").asText() : "Unknown error"; + log.warn("Telegram API returned error when getting file path: {}", description); + return null; + } + + String filePath = jsonResponse.get("result").get("file_path").asText(); + + return filePath; + + } catch (Exception e) { + log.error("Error getting file path for fileId={}: {}", fileId, e.getMessage(), e); + return null; + } + } + + /** + * Downloads file from Telegram file server. + * + * @param filePath Telegram file path (e.g., "photos/file_123.jpg") + * @return File content as bytes, or null if failed + */ + private byte[] downloadTelegramFile(String filePath) { + try { + String botToken = telegramProperties.getBotToken(); + String downloadUrl = String.format("https://api.telegram.org/file/bot%s/%s", + botToken, filePath); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(downloadUrl)) + .GET() + .timeout(java.time.Duration.ofSeconds(30)) // Longer timeout for file downloads + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() == 200) { + return response.body(); + } else { + log.warn("Failed to download file from Telegram: HTTP {}", response.statusCode()); + return null; + } + } catch (Exception e) { + log.error("Error downloading file from Telegram: {}", e.getMessage(), e); + return null; + } + } + + /** + * Extracts avatar URL from Telegram user data (fallback method). + * Telegram provides photo_url in the user object (usually SVG with initials). + */ + private String extractAvatarUrl(Map tgUser) { + if (tgUser == null) { + return null; + } + Object photoUrl = tgUser.get("photo_url"); + if (photoUrl != null) { + return photoUrl.toString(); + } + return null; + } + + /** + * Downloads avatar image from URL (fallback method for photo_url). + */ + private byte[] downloadAvatar(String url) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .timeout(java.time.Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + // Store Content-Type for later use + String contentType = response.headers().firstValue("Content-Type").orElse(""); + lastDownloadedContentType = contentType; + + if (response.statusCode() == 200) { + return response.body(); + } else { + log.warn("Failed to download avatar from URL: HTTP {}, url={}", response.statusCode(), url); + lastDownloadedContentType = null; + return null; + } + } catch (Exception e) { + log.error("Error downloading avatar from {}: {}", url, e.getMessage(), e); + lastDownloadedContentType = null; + return null; + } + } + + /** + * Gets the Content-Type of the last downloaded avatar (used for type detection). + * @return Content-Type header value, or null if not available + */ + private String getLastDownloadedContentType() { + return lastDownloadedContentType; + } + + /** + * Processes image: validates format and resizes if needed. + * Only used for PNG images from Telegram Bot API. + */ + private BufferedImage processImage(byte[] imageData) { + try { + // Read image from bytes + BufferedImage image = ImageIO.read(new java.io.ByteArrayInputStream(imageData)); + if (image == null) { + log.warn("Failed to read image data - unsupported format (might be SVG or corrupted file)"); + return null; + } + + // Resize if image is too large + int width = image.getWidth(); + int height = image.getHeight(); + + if (width > maxDimension || height > maxDimension) { + // Calculate new dimensions maintaining aspect ratio + double scale = Math.min((double) maxDimension / width, (double) maxDimension / height); + int newWidth = (int) (width * scale); + int newHeight = (int) (height * scale); + + log.debug("Resizing image from {}x{} to {}x{}", width, height, newWidth, newHeight); + + // Use high-quality scaling + BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = resized.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.drawImage(image, 0, 0, newWidth, newHeight, null); + g.dispose(); + + return resized; + } + + // Convert to ARGB if needed (for PNG support) + if (image.getType() != BufferedImage.TYPE_INT_ARGB) { + BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = converted.createGraphics(); + g.drawImage(image, 0, 0, null); + g.dispose(); + return converted; + } + + return image; + + } catch (IOException e) { + log.error("Error processing image: {}", e.getMessage(), e); + return null; + } + } + + /** + * Path components for avatar file storage. + * Contains all calculated values needed for both file path and URL generation. + */ + private static class PathComponents { + final String digit2; + final String digit1; + final String digit0; + final String hash; + final String filename; + + PathComponents(String digit2, String digit1, String digit0, String hash, String filename) { + this.digit2 = digit2; + this.digit1 = digit1; + this.digit0 = digit0; + this.hash = hash; + this.filename = filename; + } + } + + /** + * Calculates path components (folder digits, hash, filename) for a user ID. + * This is done once and reused for both file path and URL generation. + * + * @param userId User ID + * @param extension File extension ("png" or "svg") + * @return PathComponents with all calculated values + */ + private PathComponents calculatePathComponents(Integer userId, String extension) { + // Zero-pad user ID to 8 digits + String paddedId = String.format("%08d", userId); + + // Extract last 3 digits for folder hierarchy + String digit2 = String.valueOf(paddedId.charAt(5)); // 6th digit (0-indexed) + String digit1 = String.valueOf(paddedId.charAt(6)); // 7th digit + String digit0 = String.valueOf(paddedId.charAt(7)); // 8th digit (last) + + // Hash the entire 8-digit padded ID for filename + String hash = hashString(paddedId); + String filename = hash + "." + extension; + + return new PathComponents(digit2, digit1, digit0, hash, filename); + } + + /** + * Calculates file path using sharding logic from pre-calculated path components. + * + * @param components Pre-calculated path components + * @return File path for storing the avatar + */ + private Path calculateFilePath(PathComponents components) { + // Build path using normalized storage path + return normalizedStoragePath + .resolve(components.digit2) + .resolve(components.digit1) + .resolve(components.digit0) + .resolve(components.filename); + } + + /** + * Hashes a string to an 8-character hex string using Adler32. + * Adler32 is a fast checksum algorithm that produces a 32-bit hash value. + * + * @param input Input string to hash + * @return 8-character hexadecimal hash string + */ + private String hashString(String input) { + if (input == null || input.isEmpty()) { + return "00000000"; + } + + Adler32 adler32 = new Adler32(); + byte[] bytes = input.getBytes(java.nio.charset.StandardCharsets.UTF_8); + adler32.update(bytes); + + long hashValue = adler32.getValue(); + return String.format("%08x", hashValue); + } + + /** + * Builds public URL for the avatar with ?v=timestamp parameter for cache busting. + * Format: {publicBaseUrl}/avatars/{digit2}/{digit1}/{digit0}/{hash}.{ext}?v={timestamp} + * If publicBaseUrl is empty, returns relative URL starting with /avatars + * + * Uses pre-calculated path components to avoid duplicate hash calculation. + * + * @param components Pre-calculated path components + * @return Public URL with timestamp parameter + */ + private String buildPublicUrlWithTimestamp(PathComponents components) { + // Build relative path + String relativePath = String.format("/avatars/%s/%s/%s/%s", + components.digit2, components.digit1, components.digit0, components.filename); + + // Add timestamp parameter for cache busting (forces refresh within 1h session lifetime) + long timestamp = System.currentTimeMillis() / 1000; // Unix timestamp in seconds + String pathWithTimestamp = relativePath + "?v=" + timestamp; + + // If publicBaseUrl is set, prepend it; otherwise return relative URL + if (publicBaseUrl != null && !publicBaseUrl.isEmpty()) { + String baseUrl = publicBaseUrl.endsWith("/") + ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) + : publicBaseUrl; + return baseUrl + pathWithTimestamp; + } + + // Return relative URL (browser will resolve it relative to current origin) + return pathWithTimestamp; + } +} diff --git a/src/main/java/com/lottery/lottery/service/BetDecisionService.java b/src/main/java/com/lottery/lottery/service/BetDecisionService.java new file mode 100644 index 0000000..29797d8 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/BetDecisionService.java @@ -0,0 +1,17 @@ +package com.lottery.lottery.service; + +/** + * Decides bet amount (in tickets) for a lottery bot. + * Implemented in-process by persona + loss-streak and zone logic (no external API). + */ +public interface BetDecisionService { + + /** + * Returns bet amount in tickets (1 ticket = 1_000_000 in DB bigint). + * Must be within room min/max; caller will clamp if needed. + * + * @param context room, round, config, etc. (for future AI) + * @return bet amount in tickets (e.g. 1000) + */ + long decideBetAmountTickets(BotBetContext context); +} diff --git a/src/main/java/com/lottery/lottery/service/BotBetContext.java b/src/main/java/com/lottery/lottery/service/BotBetContext.java new file mode 100644 index 0000000..1728ede --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/BotBetContext.java @@ -0,0 +1,33 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.LotteryBotConfig; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * Context passed to bet decision (ChatGPT). Bot range and history are required for the prompt. + */ +@Data +@Builder +public class BotBetContext { + + private int roomNumber; + private Long roundId; + private int participantCount; + private LotteryBotConfig config; + + /** Room min/max bet in tickets. */ + private long roomMinTickets; + private long roomMaxTickets; + /** Bot config min/max bet in tickets (hard bounds for output). */ + private long botMinTickets; + private long botMaxTickets; + /** Current round total bet in tickets (pot so far). */ + private long currentRoundTotalBetTickets; + /** Last 10 bet amounts in tickets (oldest → newest). Padded with 0 if fewer than 10. */ + private List lastBets10; + /** Last 10 results: W=win, L=loss, N=no data (oldest → newest). Padded with N if fewer than 10. */ + private List lastResults10; +} diff --git a/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java b/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java new file mode 100644 index 0000000..f734a75 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/BotBetHistoryService.java @@ -0,0 +1,83 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.Transaction; +import com.lottery.lottery.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Loads last N bet amounts and win/loss results for a user from transactions (BET + WIN). + * Used to build ChatGPT prompt context. + */ +@Service +@RequiredArgsConstructor +public class BotBetHistoryService { + + private static final long TICKETS_TO_BIGINT = 1_000_000L; + private static final String RESULT_WIN = "W"; + private static final String RESULT_LOSS = "L"; + private static final String RESULT_NONE = "N"; + + private final TransactionRepository transactionRepository; + + /** + * Returns last {@code count} bet amounts (in tickets) and results (W/L/N), oldest first. + * If fewer than count bets exist, pads with 0 and "N". + */ + @Transactional(readOnly = true) + public BetHistoryResult getLastBetsAndResults(int userId, int count) { + if (count <= 0) { + return new BetHistoryResult(Collections.nCopies(count, 0), Collections.nCopies(count, RESULT_NONE)); + } + List betTxs = transactionRepository + .findByUserIdAndTypeOrderByCreatedAtDesc(userId, Transaction.TransactionType.BET, PageRequest.of(0, count)) + .getContent(); + if (betTxs.isEmpty()) { + return new BetHistoryResult( + Collections.nCopies(count, 0), + Collections.nCopies(count, RESULT_NONE)); + } + // Newest first → reverse to oldest first + List oldestFirst = new ArrayList<>(betTxs); + Collections.reverse(oldestFirst); + + List bets = new ArrayList<>(); + List roundIds = new ArrayList<>(); + for (Transaction t : oldestFirst) { + // BET amounts are stored as negative (debit); use abs for ticket count in prompt + long amount = t.getAmount() != null ? Math.abs(t.getAmount()) : 0L; + long tickets = amount / TICKETS_TO_BIGINT; + bets.add((int) Math.max(0, Math.min(Integer.MAX_VALUE, tickets))); + roundIds.add(t.getRoundId()); + } + + Set roundIdsToCheck = roundIds.stream().filter(id -> id != null).collect(Collectors.toSet()); + Set roundIdsWithWin = Set.of(); + if (!roundIdsToCheck.isEmpty()) { + List winTxs = transactionRepository.findByUserIdAndTypeWinAndRoundIdIn(userId, roundIdsToCheck); + roundIdsWithWin = winTxs.stream().map(Transaction::getRoundId).filter(id -> id != null).collect(Collectors.toSet()); + } + + List results = new ArrayList<>(); + for (Long roundId : roundIds) { + results.add(roundId != null && roundIdsWithWin.contains(roundId) ? RESULT_WIN : RESULT_LOSS); + } + + // Left-pad to count so format is [oldest …, newest] + while (bets.size() < count) { + bets.add(0, 0); + results.add(0, RESULT_NONE); + } + return new BetHistoryResult(bets, results); + } + + public record BetHistoryResult(List lastBets, List lastResults) {} +} diff --git a/src/main/java/com/lottery/lottery/service/BotConfigService.java b/src/main/java/com/lottery/lottery/service/BotConfigService.java new file mode 100644 index 0000000..194054c --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/BotConfigService.java @@ -0,0 +1,145 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.FlexibleBotConfig; +import com.lottery.lottery.model.GameRoundParticipant; +import com.lottery.lottery.model.SafeBotUser; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.repository.FlexibleBotConfigRepository; +import com.lottery.lottery.repository.SafeBotUserRepository; +import com.lottery.lottery.repository.UserBRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Bot configuration and winner-override logic for safe/flexible bots. + * Does not affect displayed chances or tape; only who is selected as winner at resolution. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BotConfigService { + + /** Balance below this (bigint: 1 ticket = 1_000_000) → safe bot gets 100% win. 20_000 tickets = 20$ */ + private static final long SAFE_BOT_BALANCE_THRESHOLD = 20_000L * 1_000_000L; // 20_000_000_000 + + private final SafeBotUserRepository safeBotUserRepository; + private final FlexibleBotConfigRepository flexibleBotConfigRepository; + private final UserBRepository userBRepository; + + /** + * If a bot override applies, returns the participant to use as winner; otherwise empty (use normal weighted random). + * Order: 1) Safe bot with balance < threshold wins. 2) Flexible bot with configured win rate. 3) Normal. + */ + @Transactional(readOnly = true) + public Optional resolveWinnerOverride( + List participants, + long totalBet + ) { + if (participants == null || participants.isEmpty()) return Optional.empty(); + + Set safeBotUserIds = getSafeBotUserIds(); + Map flexibleWinRates = getFlexibleBotWinRates(); + + // 1) Safe bot: any safe bot in round with balance < threshold wins (pick one randomly if multiple) + List safeBotsInRound = participants.stream() + .filter(p -> safeBotUserIds.contains(p.getUserId())) + .toList(); + if (!safeBotsInRound.isEmpty()) { + List lowBalanceSafeBots = new ArrayList<>(); + for (GameRoundParticipant p : safeBotsInRound) { + UserB userB = userBRepository.findById(p.getUserId()).orElse(null); + if (userB != null && userB.getBalanceA() != null && userB.getBalanceA() < SAFE_BOT_BALANCE_THRESHOLD) { + lowBalanceSafeBots.add(p); + } + } + if (!lowBalanceSafeBots.isEmpty()) { + GameRoundParticipant chosen = lowBalanceSafeBots.get(new Random().nextInt(lowBalanceSafeBots.size())); + log.debug("Safe bot winner override: userId={}, balance below threshold", chosen.getUserId()); + return Optional.of(chosen); + } + } + + // 2) Flexible bot: with probability win_rate that bot wins; remaining probability = normal weighted random + List flexBotsInRound = participants.stream() + .filter(p -> flexibleWinRates.containsKey(p.getUserId())) + .toList(); + if (flexBotsInRound.isEmpty()) return Optional.empty(); + + double roll = new Random().nextDouble(); + double cumulative = 0; + for (GameRoundParticipant p : flexBotsInRound) { + double rate = flexibleWinRates.get(p.getUserId()); + cumulative += rate; + if (roll < cumulative) { + log.debug("Flexible bot winner override: userId={}, winRate={}", p.getUserId(), rate); + return Optional.of(p); + } + } + // roll >= cumulative: fall through to normal (don't return empty here - we already have normal logic in caller) + return Optional.empty(); + } + + public Set getSafeBotUserIds() { + return safeBotUserRepository.findAllByOrderByUserIdAsc().stream() + .map(SafeBotUser::getUserId) + .collect(Collectors.toSet()); + } + + public Map getFlexibleBotWinRates() { + Map map = new HashMap<>(); + for (FlexibleBotConfig c : flexibleBotConfigRepository.findAllByOrderByUserIdAsc()) { + if (c.getWinRate() != null) { + map.put(c.getUserId(), c.getWinRate().doubleValue()); + } + } + return map; + } + + @Transactional(readOnly = true) + public BotConfigDto getConfig() { + List safeBotUserIds = safeBotUserRepository.findAllByOrderByUserIdAsc().stream() + .map(SafeBotUser::getUserId) + .toList(); + List flexibleBots = flexibleBotConfigRepository.findAllByOrderByUserIdAsc().stream() + .map(c -> new FlexibleBotEntryDto(c.getUserId(), c.getWinRate() != null ? c.getWinRate().doubleValue() : 0)) + .toList(); + return new BotConfigDto(safeBotUserIds, flexibleBots); + } + + @Transactional + public void setSafeBotUserIds(List userIds) { + safeBotUserRepository.deleteAll(); + if (userIds != null) { + for (Integer id : userIds) { + if (id != null) safeBotUserRepository.save(SafeBotUser.builder().userId(id).build()); + } + } + } + + @Transactional + public void setFlexibleBots(List entries) { + flexibleBotConfigRepository.deleteAll(); + if (entries != null) { + for (FlexibleBotEntryDto e : entries) { + if (e != null && e.userId() != null && e.winRate() != null) { + double r = Math.max(0, Math.min(1, e.winRate())); + flexibleBotConfigRepository.save(FlexibleBotConfig.builder() + .userId(e.userId()) + .winRate(BigDecimal.valueOf(r)) + .updatedAt(Instant.now()) + .build()); + } + } + } + } + + public record BotConfigDto(List safeBotUserIds, List flexibleBots) {} + public record FlexibleBotEntryDto(Integer userId, Double winRate) {} +} diff --git a/src/main/java/com/lottery/lottery/service/ConfigurationService.java b/src/main/java/com/lottery/lottery/service/ConfigurationService.java new file mode 100644 index 0000000..005ef8a --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/ConfigurationService.java @@ -0,0 +1,53 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.Configuration; +import com.lottery.lottery.repository.ConfigurationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Configurations: key-value store for app-wide settings (e.g. lottery_bot_max_participants_before_join). + */ +@Service +@RequiredArgsConstructor +public class ConfigurationService { + + public static final String KEY_BOT_MAX_PARTICIPANTS_BEFORE_JOIN = "lottery_bot_max_participants_before_join"; + private static final int DEFAULT_MAX_PARTICIPANTS_BEFORE_JOIN = 1; + private static final int MIN_MAX_PARTICIPANTS = 0; + private static final int MAX_MAX_PARTICIPANTS = 10; + + private final ConfigurationRepository configurationRepository; + + @Transactional(readOnly = true) + public int getMaxParticipantsBeforeBotJoin() { + return configurationRepository.findById(KEY_BOT_MAX_PARTICIPANTS_BEFORE_JOIN) + .map(c -> parsePositiveInt(c.getValue(), DEFAULT_MAX_PARTICIPANTS_BEFORE_JOIN)) + .orElse(DEFAULT_MAX_PARTICIPANTS_BEFORE_JOIN); + } + + @Transactional + public int setMaxParticipantsBeforeBotJoin(int value) { + int clamped = Math.max(MIN_MAX_PARTICIPANTS, Math.min(MAX_MAX_PARTICIPANTS, value)); + Configuration c = configurationRepository.findById(KEY_BOT_MAX_PARTICIPANTS_BEFORE_JOIN) + .orElseGet(() -> { + Configuration newConfig = new Configuration(); + newConfig.setKey(KEY_BOT_MAX_PARTICIPANTS_BEFORE_JOIN); + return newConfig; + }); + c.setValue(String.valueOf(clamped)); + configurationRepository.save(c); + return clamped; + } + + private static int parsePositiveInt(String value, int defaultValue) { + if (value == null || value.isBlank()) return defaultValue; + try { + int v = Integer.parseInt(value.trim()); + return Math.max(MIN_MAX_PARTICIPANTS, Math.min(MAX_MAX_PARTICIPANTS, v)); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/src/main/java/com/honey/honey/service/CountryCodeService.java b/src/main/java/com/lottery/lottery/service/CountryCodeService.java similarity index 99% rename from src/main/java/com/honey/honey/service/CountryCodeService.java rename to src/main/java/com/lottery/lottery/service/CountryCodeService.java index f694aa9..2bfa62c 100644 --- a/src/main/java/com/honey/honey/service/CountryCodeService.java +++ b/src/main/java/com/lottery/lottery/service/CountryCodeService.java @@ -1,4 +1,4 @@ -package com.honey.honey.service; +package com.lottery.lottery.service; import com.maxmind.db.CHMCache; import com.maxmind.geoip2.DatabaseReader; diff --git a/src/main/java/com/lottery/lottery/service/CryptoDepositService.java b/src/main/java/com/lottery/lottery/service/CryptoDepositService.java new file mode 100644 index 0000000..fd98580 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/CryptoDepositService.java @@ -0,0 +1,216 @@ +package com.lottery.lottery.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lottery.lottery.dto.CryptoDepositMethodsResponse; +import com.lottery.lottery.dto.DepositAddressApiRequest; +import com.lottery.lottery.dto.DepositAddressResponse; +import com.lottery.lottery.dto.DepositMethodsDto; +import com.lottery.lottery.model.CryptoDepositConfig; +import com.lottery.lottery.model.CryptoDepositMethod; +import com.lottery.lottery.repository.CryptoDepositConfigRepository; +import com.lottery.lottery.repository.CryptoDepositMethodRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CryptoDepositService { + + private static final String DEPOSIT_METHODS_PATH = "api/v1/deposit-methods"; + private static final String DEPOSIT_ADDRESS_PATH = "api/v1/deposit-address"; + + @Value("${app.crypto-api.base-url:https://spin-passim.tech/}") + private String baseUrl; + + @Value("${app.crypto-api.api-key:}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + private final CryptoDepositConfigRepository configRepository; + private final CryptoDepositMethodRepository methodRepository; + + /** + * Returns minimum deposit from DB only (no external API call). + * Used by Store screen for validation. + */ + @Transactional(readOnly = true) + public BigDecimal getMinimumDeposit() { + return configRepository.findById(1) + .map(CryptoDepositConfig::getMinimumDeposit) + .orElse(BigDecimal.valueOf(2.5)); + } + + /** + * Returns deposit methods and minimum deposit from DB only (no external API call). + * Used by Payment Options screen. + */ + @Transactional(readOnly = true) + public DepositMethodsDto getDepositMethodsFromDb() { + Optional configOpt = configRepository.findById(1); + BigDecimal minDeposit = configOpt.map(CryptoDepositConfig::getMinimumDeposit).orElse(BigDecimal.valueOf(2.5)); + List methods = methodRepository.findAll(); + return toDto(minDeposit, methods); + } + + /** + * Runs every 10 minutes. Fetches from external API; if response hash differs from stored hash, updates methods and config. + */ + @Scheduled(cron = "0 */10 * * * ?") + @Transactional + public void syncFromExternalIfHashChanged() { + String url = baseUrl.endsWith("/") ? baseUrl + DEPOSIT_METHODS_PATH : baseUrl + "/" + DEPOSIT_METHODS_PATH; + HttpHeaders headers = new HttpHeaders(); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response; + try { + if (log.isDebugEnabled()) { + log.debug("Crypto API request: GET {} (sync deposit methods)", url); + } + response = restTemplate.exchange(url, HttpMethod.GET, entity, CryptoDepositMethodsResponse.class); + if (log.isDebugEnabled()) { + log.debug("Crypto API response: GET {} status={} body={}", url, response.getStatusCode(), toJson(response.getBody())); + } + } catch (HttpStatusCodeException e) { + if (log.isDebugEnabled()) { + log.debug("Crypto API error response: GET {} status={} body={}", url, e.getStatusCode(), e.getResponseBodyAsString()); + } + log.warn("Crypto deposit sync (10 min): failed to fetch from external API: {}", e.getMessage()); + return; + } catch (Exception e) { + log.warn("Crypto deposit sync (10 min): failed to fetch from external API: {}", e.getMessage()); + return; + } + + CryptoDepositMethodsResponse body = response.getBody(); + if (body == null || body.getResult() == null || body.getResult().getActiveMethods() == null) { + return; + } + if (body.getRequestInfo() != null && body.getRequestInfo().getErrorCode() != null && body.getRequestInfo().getErrorCode() != 0) { + log.warn("Crypto deposit sync (10 min): API error: {}", body.getRequestInfo().getErrorMessage()); + return; + } + + String responseHash = body.getResult().getHash(); + String storedHash = configRepository.findById(1).map(CryptoDepositConfig::getMethodsHash).orElse(null); + if (responseHash != null && !responseHash.isEmpty() && responseHash.equals(storedHash)) { + log.debug("Crypto deposit sync (10 min): hash unchanged, skip update"); + return; + } + + List active = body.getResult().getActiveMethods(); + double minSum = active.stream() + .mapToDouble(m -> m.getMinDepositSum() != null ? m.getMinDepositSum() : 0) + .filter(v -> v > 0) + .min() + .orElse(2.5); + + methodRepository.deleteAllInBatch(); + Instant now = Instant.now(); + for (CryptoDepositMethodsResponse.ActiveMethod m : active) { + if (m.getPid() == null) continue; + CryptoDepositMethod method = CryptoDepositMethod.builder() + .pid(m.getPid()) + .name(m.getName() != null ? m.getName() : "") + .network(m.getNetwork() != null ? m.getNetwork() : "") + .example(m.getExample()) + .minDepositSum(BigDecimal.valueOf(m.getMinDepositSum() != null ? m.getMinDepositSum() : 2.5).setScale(2, RoundingMode.HALF_UP)) + .updatedAt(now) + .build(); + methodRepository.save(method); + } + + CryptoDepositConfig config = configRepository.findById(1).orElseGet(() -> { + CryptoDepositConfig c = new CryptoDepositConfig(); + c.setId(1); + return c; + }); + config.setMinimumDeposit(BigDecimal.valueOf(minSum).setScale(2, RoundingMode.HALF_UP)); + config.setMethodsHash(responseHash); + config.setUpdatedAt(now); + configRepository.save(config); + + log.info("Crypto deposit sync (10 min): updated {} methods, minimum_deposit={}, hash={}", active.size(), minSum, responseHash); + } + + /** + * Calls external API POST deposit-address to get wallet address and amount in coins. + * Does not create any payment record; caller is responsible for that. + */ + public DepositAddressResponse postDepositAddress(DepositAddressApiRequest request) { + Integer userId = request.getUserData() != null ? request.getUserData().getInternalId() : null; + String url = baseUrl.endsWith("/") ? baseUrl + DEPOSIT_ADDRESS_PATH : baseUrl + "/" + DEPOSIT_ADDRESS_PATH; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + HttpEntity entity = new HttpEntity<>(request, headers); + if (log.isDebugEnabled()) { + log.debug("Crypto API request: userId={} POST {} body={}", userId, url, toJson(request)); + } + try { + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.POST, entity, DepositAddressResponse.class); + DepositAddressResponse responseBody = response.getBody(); + if (log.isDebugEnabled()) { + log.debug("Crypto API response: userId={} POST {} status={} body={}", userId, url, response.getStatusCode(), toJson(responseBody)); + } + return responseBody; + } catch (HttpStatusCodeException e) { + if (log.isDebugEnabled()) { + log.debug("Crypto API error response: userId={} POST {} status={} body={}", userId, url, e.getStatusCode(), e.getResponseBodyAsString()); + } + throw e; + } + } + + private String toJson(Object o) { + if (o == null) return "null"; + try { + return objectMapper.writeValueAsString(o); + } catch (JsonProcessingException e) { + return "<" + e.getMessage() + ">"; + } + } + + private static DepositMethodsDto toDto(BigDecimal minimumDeposit, List methods) { + List list = methods.stream() + .map(m -> DepositMethodsDto.DepositMethodItemDto.builder() + .pid(m.getPid()) + .name(m.getName()) + .network(m.getNetwork()) + .example(m.getExample()) + .minDepositSum(m.getMinDepositSum()) + .build()) + .collect(Collectors.toList()); + return DepositMethodsDto.builder() + .minimumDeposit(minimumDeposit) + .activeMethods(list) + .build(); + } +} diff --git a/src/main/java/com/lottery/lottery/service/CryptoWithdrawalService.java b/src/main/java/com/lottery/lottery/service/CryptoWithdrawalService.java new file mode 100644 index 0000000..b52bc36 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/CryptoWithdrawalService.java @@ -0,0 +1,332 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.WithdrawalApiRequest; +import com.lottery.lottery.dto.WithdrawalApiResponse; +import com.lottery.lottery.dto.WithdrawalInfoApiResponse; +import com.lottery.lottery.dto.WithdrawalMethodDetailsDto; +import com.lottery.lottery.dto.WithdrawalMethodsApiResponse; +import com.lottery.lottery.dto.WithdrawalMethodsDto; +import com.lottery.lottery.model.CryptoWithdrawalMethod; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.CryptoWithdrawalMethodRepository; +import com.lottery.lottery.repository.UserARepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lottery.lottery.util.IpUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CryptoWithdrawalService { + + private static final String WITHDRAWAL_METHODS_PATH = "api/v1/withdrawal-methods"; + private static final String WITHDRAWAL_PATH = "api/v1/withdrawal"; + private static final String WITHDRAWALS_INFO_PATH = "api/v1/withdrawals-info"; + private static final BigDecimal DEFAULT_MIN_WITHDRAWAL = new BigDecimal("0.10"); + + @Value("${app.crypto-api.base-url:https://spin-passim.tech/}") + private String baseUrl; + + @Value("${app.crypto-api.api-key:}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + private final CryptoWithdrawalMethodRepository methodRepository; + private final UserARepository userARepository; + + /** In-memory lock: one withdrawal per user at a time to prevent double-submit / spam. */ + private final ConcurrentHashMap withdrawalInProgress = new ConcurrentHashMap<>(); + + /** + * Tries to acquire the withdrawal lock for the user. Returns false if user already has a withdrawal in progress. + */ + public boolean tryAcquireWithdrawal(Integer userId) { + return withdrawalInProgress.putIfAbsent(userId, Boolean.TRUE) == null; + } + + /** + * Releases the withdrawal lock for the user. Must be called in finally after API call. + */ + public void releaseWithdrawal(Integer userId) { + withdrawalInProgress.remove(userId); + } + + /** + * Calls external API POST api/v1/withdrawal. Does not create payout or deduct balance. + * Caller must hold the withdrawal lock and release it in finally. + * + * @param manualPay when true, adds manual_pay=1 to the request (for users who completed referral 50 or 100) + * @return response body on HTTP 200 + * @throws org.springframework.web.client.HttpStatusCodeException on non-2xx + */ + public WithdrawalApiResponse postWithdrawal(Integer userId, Integer pid, String wallet, Double amountUsd, boolean manualPay) { + UserA user = userARepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + String userIp = formatIpForCryptoApi(user.getIp()); + WithdrawalApiRequest.UserData userData = WithdrawalApiRequest.UserData.builder() + .internalId(user.getId()) + .screenName(user.getScreenName() != null ? user.getScreenName() : "") + .tgUsername(user.getTelegramName() != null ? user.getTelegramName() : "") + .tgId(user.getTelegramId() != null ? String.valueOf(user.getTelegramId()) : "") + .countryCode(user.getCountryCode() != null ? user.getCountryCode() : "XX") + .deviceCode(user.getDeviceCode() != null ? user.getDeviceCode() : "XX") + .languageCode(user.getLanguageCode() != null ? user.getLanguageCode() : "XX") + .userIp(userIp != null ? userIp : "0.0.0.0") + .build(); + + WithdrawalApiRequest.WithdrawalApiRequestBuilder requestBuilder = WithdrawalApiRequest.builder() + .pid(pid) + .userId(userId) + .wallet(wallet) + .amountUsd(amountUsd) + .userData(userData); + if (manualPay) { + requestBuilder.manualPay(1); + } + WithdrawalApiRequest request = requestBuilder.build(); + + String url = baseUrl.endsWith("/") ? baseUrl + WITHDRAWAL_PATH : baseUrl + "/" + WITHDRAWAL_PATH; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + HttpEntity entity = new HttpEntity<>(request, headers); + if (log.isDebugEnabled()) { + log.debug("Crypto API request: POST {} body={}", url, toJson(request)); + } + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, WithdrawalApiResponse.class); + WithdrawalApiResponse body = response.getBody(); + if (log.isDebugEnabled()) { + log.debug("Crypto API response: POST {} status={} body={}", url, response.getStatusCode(), toJson(body)); + } + return body; + } catch (HttpStatusCodeException e) { + if (log.isDebugEnabled()) { + log.debug("Crypto API error response: POST {} status={} body={}", url, e.getStatusCode(), e.getResponseBodyAsString()); + } + throw e; + } + } + + /** + * Calls external API GET api/v1/withdrawals-info/{payment_id}. + * Returns empty if request fails or result has no payment_list / empty list. + */ + public Optional getWithdrawalInfo(int paymentId) { + String path = WITHDRAWALS_INFO_PATH + "/" + paymentId; + String url = baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path; + HttpHeaders headers = new HttpHeaders(); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + try { + log.debug("Crypto API request: GET {}", url); + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), WithdrawalInfoApiResponse.class); + WithdrawalInfoApiResponse responseBody = response.getBody(); + log.debug("Crypto API response: GET {} status={} body={}", url, response.getStatusCode(), toJson(responseBody)); + return Optional.ofNullable(responseBody); + } catch (HttpStatusCodeException e) { + log.debug("Crypto API error response: GET {} status={} body={}", url, e.getStatusCode(), e.getResponseBodyAsString()); + log.warn("Withdrawal info API error: paymentId={}, status={}", paymentId, e.getStatusCode()); + return Optional.empty(); + } + } + + private String toJson(Object o) { + if (o == null) return "null"; + try { + return objectMapper.writeValueAsString(o); + } catch (JsonProcessingException e) { + return "<" + e.getMessage() + ">"; + } + } + + private static String formatIpForCryptoApi(byte[] ipBytes) { + if (ipBytes == null || ipBytes.length == 0) { + return "0.0.0.0"; + } + if (ipBytes.length == 16) { + return "0.0.0.0"; + } + String s = IpUtils.bytesToIp(ipBytes); + return s != null ? s : "0.0.0.0"; + } + + /** + * Fetches from external API and returns details for one method by pid (rate_usd, misha_fee_usd). + * Called when user opens Payout Confirmation screen. + */ + public Optional getWithdrawalMethodDetails(int pid) { + String url = baseUrl.endsWith("/") ? baseUrl + WITHDRAWAL_METHODS_PATH : baseUrl + "/" + WITHDRAWAL_METHODS_PATH; + HttpHeaders headers = new HttpHeaders(); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + HttpEntity entity = new HttpEntity<>(headers); + + try { + if (log.isDebugEnabled()) { + log.debug("Crypto API request: GET {} (pid={})", url, pid); + } + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, WithdrawalMethodsApiResponse.class); + WithdrawalMethodsApiResponse body = response.getBody(); + if (log.isDebugEnabled()) { + log.debug("Crypto API response: GET {} status={} body={}", url, response.getStatusCode(), toJson(body)); + } + if (body == null || body.getResult() == null || body.getResult().getActiveMethods() == null) { + return Optional.empty(); + } + if (body.getRequestInfo() != null && body.getRequestInfo().getErrorCode() != null && body.getRequestInfo().getErrorCode() != 0) { + log.warn("Withdrawal method details: API error pid={}: {}", pid, body.getRequestInfo().getErrorMessage()); + return Optional.empty(); + } + return body.getResult().getActiveMethods().stream() + .filter(m -> pid == (m.getPid() != null ? m.getPid() : 0)) + .findFirst() + .map(m -> { + BigDecimal rateUsd = parseBigDecimal(m.getRateUsd()); + BigDecimal mishaFeeUsd = m.getMishaFeeUsd() != null ? BigDecimal.valueOf(m.getMishaFeeUsd()) : BigDecimal.ZERO; + return WithdrawalMethodDetailsDto.builder() + .pid(m.getPid()) + .name(m.getName()) + .ticker(m.getTicker()) + .rateUsd(rateUsd != null ? rateUsd : BigDecimal.ZERO) + .mishaFeeUsd(mishaFeeUsd) + .build(); + }); + } catch (HttpStatusCodeException e) { + if (log.isDebugEnabled()) { + log.debug("Crypto API error response: GET {} status={} body={}", url, e.getStatusCode(), e.getResponseBodyAsString()); + } + log.warn("Withdrawal method details pid={}: failed to fetch: {}", pid, e.getMessage()); + return Optional.empty(); + } catch (Exception e) { + log.warn("Withdrawal method details pid={}: failed to fetch: {}", pid, e.getMessage()); + return Optional.empty(); + } + } + + private static BigDecimal parseBigDecimal(String s) { + if (s == null || s.isEmpty()) return null; + try { + return new BigDecimal(s.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Returns withdrawal methods from DB only (no external API call). + * Called when user opens Payout screen. + */ + @Transactional(readOnly = true) + public WithdrawalMethodsDto getWithdrawalMethodsFromDb() { + List methods = methodRepository.findAllByOrderByPidAsc(); + List list = methods.stream() + .map(m -> WithdrawalMethodsDto.WithdrawalMethodItemDto.builder() + .pid(m.getPid()) + .name(m.getName()) + .network(m.getNetwork()) + .iconId(m.getIconId()) + .minWithdrawal(m.getMinWithdrawal()) + .build()) + .collect(Collectors.toList()); + return WithdrawalMethodsDto.builder() + .methods(list) + .build(); + } + + /** + * Runs every 30 minutes. Fetches from external API and overwrites crypto_withdrawal_methods (no hash check). + */ + @Scheduled(cron = "0 */30 * * * ?") // Every 30 minutes at second 0 + @Transactional + public void syncFromExternal() { + String url = baseUrl.endsWith("/") ? baseUrl + WITHDRAWAL_METHODS_PATH : baseUrl + "/" + WITHDRAWAL_METHODS_PATH; + HttpHeaders headers = new HttpHeaders(); + if (apiKey != null && !apiKey.isEmpty()) { + headers.set("API-KEY", apiKey); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response; + try { + if (log.isDebugEnabled()) { + log.debug("Crypto API request: GET {} (sync withdrawal methods)", url); + } + response = restTemplate.exchange(url, HttpMethod.GET, entity, WithdrawalMethodsApiResponse.class); + if (log.isDebugEnabled()) { + log.debug("Crypto API response: GET {} status={} body={}", url, response.getStatusCode(), toJson(response.getBody())); + } + } catch (HttpStatusCodeException e) { + if (log.isDebugEnabled()) { + log.debug("Crypto API error response: GET {} status={} body={}", url, e.getStatusCode(), e.getResponseBodyAsString()); + } + log.warn("Crypto withdrawal sync (30 min): failed to fetch from external API: {}", e.getMessage()); + return; + } catch (Exception e) { + log.warn("Crypto withdrawal sync (30 min): failed to fetch from external API: {}", e.getMessage()); + return; + } + + WithdrawalMethodsApiResponse body = response.getBody(); + if (body == null || body.getResult() == null || body.getResult().getActiveMethods() == null) { + return; + } + if (body.getRequestInfo() != null && body.getRequestInfo().getErrorCode() != null && body.getRequestInfo().getErrorCode() != 0) { + log.warn("Crypto withdrawal sync (30 min): API error: {}", body.getRequestInfo().getErrorMessage()); + return; + } + + List active = body.getResult().getActiveMethods(); + + methodRepository.deleteAllInBatch(); + Instant now = Instant.now(); + Set pidsSeen = new HashSet<>(); + for (WithdrawalMethodsApiResponse.ActiveMethod m : active) { + if (m.getPid() == null) continue; + if (!pidsSeen.add(m.getPid())) continue; + String name = m.getTicker() != null ? m.getTicker() : ""; + String network = m.getName() != null ? m.getName() : ""; + String iconId = m.getIconId() != null ? m.getIconId().trim() : ""; + CryptoWithdrawalMethod method = CryptoWithdrawalMethod.builder() + .pid(m.getPid()) + .name(name) + .network(network) + .iconId(iconId) + .minWithdrawal(DEFAULT_MIN_WITHDRAWAL) + .updatedAt(now) + .build(); + methodRepository.save(method); + } + + log.debug("Crypto withdrawal sync (30 min): updated {} methods", active.size()); + } +} diff --git a/src/main/java/com/lottery/lottery/service/DataCleanupService.java b/src/main/java/com/lottery/lottery/service/DataCleanupService.java new file mode 100644 index 0000000..7b365ec --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/DataCleanupService.java @@ -0,0 +1,88 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.repository.GameRoundParticipantRepository; +import com.lottery.lottery.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * Scheduled service for batch cleanup of old transactions. + * Runs once per day at 3 AM (not at the same time as session cleanup which runs hourly). + * Deletes transactions older than 30 days in batches to avoid long transactions. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DataCleanupService { + + private final TransactionRepository transactionRepository; + + @Value("${app.data-cleanup.batch-size:5000}") + private int batchSize; + + @Value("${app.data-cleanup.max-batches-per-run:20}") + private int maxBatchesPerRun; + + @Value("${app.data-cleanup.batch-sleep-ms:500}") + private long batchSleepMs; + + /** + * Batch deletes old transactions. + * Runs once per day at 3:00 AM (not at the same time as session cleanup at minute 0). + * Processes up to MAX_BATCHES_PER_RUN batches per run. + * Sleeps between batches to let the database "breathe". + */ + @Scheduled(cron = "0 0 3 * * ?") // Every day at 3:00 AM + @Transactional + public void cleanupOldData() { + Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS); + + int totalTransactionsDeleted = 0; + int batchesProcessed = 0; + + log.info("Starting transaction cleanup (cutoffDate={}, batchSize={}, maxBatches={}, sleepMs={})", + thirtyDaysAgo, batchSize, maxBatchesPerRun, batchSleepMs); + + // Cleanup all transactions older than 30 days + while (batchesProcessed < maxBatchesPerRun) { + int deleted = transactionRepository.deleteOldTransactionsBatch(thirtyDaysAgo, batchSize); + totalTransactionsDeleted += deleted; + batchesProcessed++; + + if (deleted > 0) { + log.debug("Deleted {} old transactions in batch {}", deleted, batchesProcessed); + } + + // Sleep between batches to let database process WebSocket inserts + if (deleted > 0 && batchesProcessed < maxBatchesPerRun) { + try { + Thread.sleep(batchSleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Cleanup sleep interrupted", e); + break; + } + } + + // If we deleted less than batch size, we've caught up + if (deleted < batchSize) { + break; + } + } + + if (totalTransactionsDeleted > 0) { + log.info("Transaction cleanup completed: deleted {} transaction(s) in {} batch(es)", + totalTransactionsDeleted, batchesProcessed); + } else { + log.debug("Transaction cleanup completed: no old data to delete"); + } + } +} + diff --git a/src/main/java/com/lottery/lottery/service/FeatureSwitchService.java b/src/main/java/com/lottery/lottery/service/FeatureSwitchService.java new file mode 100644 index 0000000..bf0fe87 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/FeatureSwitchService.java @@ -0,0 +1,139 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.FeatureSwitch; +import com.lottery.lottery.repository.FeatureSwitchRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FeatureSwitchService { + + public static final String REMOTE_BET_ENABLED = "remote_bet_enabled"; + public static final String PAYMENT_ENABLED = "payment_enabled"; + public static final String PAYOUT_ENABLED = "payout_enabled"; + public static final String TASK_REFERRAL_50_ENABLED = "task_referral_50_enabled"; + public static final String TASK_REFERRAL_100_ENABLED = "task_referral_100_enabled"; + /** When enabled, the scheduler registers lottery bots into joinable rounds (Part 2). */ + public static final String LOTTERY_BOT_SCHEDULER_ENABLED = "lottery_bot_scheduler_enabled"; + /** When enabled, app shows Promotions button and /api/promotions endpoints are available. Default false. */ + public static final String PROMOTIONS_ENABLED = "promotions_enabled"; + /** When enabled, send manual_pay=1 for all crypto payouts. When disabled, only for users who completed 50 or 100 referrals (first withdrawal). Default true. */ + public static final String MANUAL_PAY_FOR_ALL_PAYOUTS = "manual_pay_for_all_payouts"; + + private final FeatureSwitchRepository featureSwitchRepository; + + /** + * Returns whether the remote bet API endpoint is enabled (runtime toggle from admin). + */ + @Transactional(readOnly = true) + public boolean isRemoteBetEnabled() { + return featureSwitchRepository.findById(REMOTE_BET_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(false); + } + + /** + * Returns whether payment (deposits) is enabled. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isPaymentEnabled() { + return featureSwitchRepository.findById(PAYMENT_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns whether payout (withdrawals) is enabled. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isPayoutEnabled() { + return featureSwitchRepository.findById(PAYOUT_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns whether the "Invite 50 friends" referral task is enabled. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isTaskReferral50Enabled() { + return featureSwitchRepository.findById(TASK_REFERRAL_50_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns whether the "Invite 100 friends" referral task is enabled. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isTaskReferral100Enabled() { + return featureSwitchRepository.findById(TASK_REFERRAL_100_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns whether the lottery bot scheduler (auto-join bots) is enabled. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isLotteryBotSchedulerEnabled() { + return featureSwitchRepository.findById(LOTTERY_BOT_SCHEDULER_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns whether promotions are enabled (app button and /api/promotions). Default false when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isPromotionsEnabled() { + return featureSwitchRepository.findById(PROMOTIONS_ENABLED) + .map(FeatureSwitch::isEnabled) + .orElse(false); + } + + /** + * Returns whether to send manual_pay=1 for all crypto payouts. When true, all payouts use manual_pay=1; when false, only users who completed 50 or 100 referrals (and have no withdrawals yet) do. Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isManualPayForAllPayoutsEnabled() { + return featureSwitchRepository.findById(MANUAL_PAY_FOR_ALL_PAYOUTS) + .map(FeatureSwitch::isEnabled) + .orElse(true); + } + + /** + * Returns all feature switches for admin (key and enabled). + */ + @Transactional(readOnly = true) + public List getAll() { + return featureSwitchRepository.findAll().stream() + .map(f -> new FeatureSwitchDto(f.getKey(), f.isEnabled())) + .collect(Collectors.toList()); + } + + /** + * Updates a feature switch by key. Creates the row if it does not exist. + */ + @Transactional + public FeatureSwitchDto setEnabled(String key, boolean enabled) { + FeatureSwitch f = featureSwitchRepository.findById(key) + .orElseGet(() -> { + FeatureSwitch newSwitch = new FeatureSwitch(); + newSwitch.setKey(key); + newSwitch.setUpdatedAt(Instant.now()); + return newSwitch; + }); + f.setEnabled(enabled); + featureSwitchRepository.save(f); + return new FeatureSwitchDto(f.getKey(), f.isEnabled()); + } + + public record FeatureSwitchDto(String key, boolean enabled) {} +} diff --git a/src/main/java/com/lottery/lottery/service/GameHistoryService.java b/src/main/java/com/lottery/lottery/service/GameHistoryService.java new file mode 100644 index 0000000..65f1105 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/GameHistoryService.java @@ -0,0 +1,86 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.GameHistoryEntryDto; +import com.lottery.lottery.model.Transaction; +import com.lottery.lottery.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for retrieving game history for users. + * Fetches WIN transactions from the last 30 days. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GameHistoryService { + + private final TransactionRepository transactionRepository; + private final LocalizationService localizationService; + private static final int PAGE_SIZE = 50; + private static final int DAYS_TO_FETCH = 30; + + /** + * Gets WIN transactions for a user from the last 30 days with pagination. + * + * @param userId User ID + * @param page Page number (0-indexed) + * @param timezone Optional timezone (e.g., "Europe/London"). If null, uses UTC. + * @param languageCode Optional language code for date formatting (e.g., "EN", "RU"). If null, uses "EN". + * @return Page of game history entries with amount and date + */ + public Page getUserGameHistory(Integer userId, int page, String timezone, String languageCode) { + Instant thirtyDaysAgo = Instant.now().minus(DAYS_TO_FETCH, ChronoUnit.DAYS); + Pageable pageable = PageRequest.of(page, PAGE_SIZE); + + // Fetch WIN transactions from the last 30 days + Page transactions = transactionRepository.findByUserIdAndTypeAndCreatedAtAfterOrderByCreatedAtDesc( + userId, Transaction.TransactionType.WIN, thirtyDaysAgo, pageable); + + // Determine timezone to use + ZoneId zoneId; + try { + zoneId = (timezone != null && !timezone.trim().isEmpty()) + ? ZoneId.of(timezone) + : ZoneId.of("UTC"); + } catch (Exception e) { + // Invalid timezone, fallback to UTC + zoneId = ZoneId.of("UTC"); + } + + // Get localized "at" word + String atWord = localizationService.getMessage("dateTime.at", languageCode); + if (atWord == null || atWord.isEmpty()) { + atWord = "at"; // Fallback to English + } + + // Create formatter with localized "at" word + final ZoneId finalZoneId = zoneId; + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") + .withZone(finalZoneId); + + return transactions.map(transaction -> { + // Format date as dd.MM at HH:mm (with localized "at" word) + String date = formatter.format(transaction.getCreatedAt()); + + // Amount is the total payout (already positive in WIN transactions) + return GameHistoryEntryDto.builder() + .amount(transaction.getAmount()) + .date(date) + .build(); + }); + } +} + + diff --git a/src/main/java/com/lottery/lottery/service/GameRoomService.java b/src/main/java/com/lottery/lottery/service/GameRoomService.java new file mode 100644 index 0000000..cb97182 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/GameRoomService.java @@ -0,0 +1,1424 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.AdminRoomDetailDto; +import com.lottery.lottery.dto.AdminRoomOnlineUserDto; +import com.lottery.lottery.dto.AdminRoomParticipantDto; +import com.lottery.lottery.dto.AdminRoomSummaryDto; +import com.lottery.lottery.dto.AdminRoomViewerDto; +import com.lottery.lottery.dto.AdminRoomWinnerDto; +import com.lottery.lottery.dto.GameRoomStateDto; +import com.lottery.lottery.dto.JoinRoundResult; +import com.lottery.lottery.dto.ParticipantDto; +import com.lottery.lottery.dto.WinnerDto; +import com.lottery.lottery.exception.*; +import com.lottery.lottery.model.*; +import com.lottery.lottery.repository.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GameRoomService { + + private static final long COUNTDOWN_DURATION_SECONDS = 20; + private static final long SPIN_DURATION_MS = 5000; // 5 seconds + + /** + * Gets room-specific bet limits in bigint format (database format with 6 decimal places). + * Room1: 1-200, Room2: 10-5000, Room3: 1000-50000 tickets + */ + private static long getMinBet(Integer roomNumber) { + return switch (roomNumber) { + case 1 -> 1_000_000L; // 1 + case 2 -> 10_000_000L; // 10 + case 3 -> 1_000_000_000L; // 1000 + default -> 1_000_000L; // Default to Room1 limits + }; + } + + private static long getMaxBet(Integer roomNumber) { + return switch (roomNumber) { + case 1 -> 200_000_000L; // 200 + case 2 -> 5_000_000_000L; // 5000 + case 3 -> 50_000_000_000L; // 50000 + default -> 200_000_000L; // Default to Room1 limits + }; + } + + /** + * Returns bet limits for a room (min and max in bigint format). + * Used by remote bet API when rand=true to pick a random amount. + */ + public static BetLimits getBetLimitsForRoom(int roomNumber) { + return new BetLimits(getMinBet(roomNumber), getMaxBet(roomNumber)); + } + + /** Min and max bet in bigint format (1 ticket = 1_000_000). */ + public record BetLimits(long minBet, long maxBet) {} + + /** + * Returns the user's current total bet in the given room's current round (bigint). + * Returns 0 if room not found, no active round, or user is not in the round. + * Used by remote bet API when rand=true to cap the random additional bet. + */ + @Transactional(readOnly = true) + public long getCurrentUserBetInRoom(int userId, int roomNumber) { + Optional roomOpt = gameRoomRepository.findByRoomNumber(roomNumber); + if (roomOpt.isEmpty()) { + return 0L; + } + GameRoom room = roomOpt.get(); + GameRound round = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN)).orElse(null); + if (round == null) { + return 0L; + } + List list = participantRepository.findByRoundIdAndUserId(round.getId(), userId); + return list.isEmpty() ? 0L : list.get(0).getBet(); + } + + private final GameRoomRepository gameRoomRepository; + private final GameRoundRepository gameRoundRepository; + private final GameRoundParticipantRepository participantRepository; + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final UserDRepository userDRepository; + private final AvatarService avatarService; + private final RoomConnectionService roomConnectionService; + private final ReferralCommissionService referralCommissionService; + private final TransactionService transactionService; + private final LocalizationService localizationService; + private final BotConfigService botConfigService; + private final PromotionService promotionService; + + // Track last bet time per user per round to prevent fast clicks (rate limiting) + // Key: "userId:roundId", Value: last bet timestamp + private final Map lastBetTimes = new ConcurrentHashMap<>(); + private static final long MIN_BET_INTERVAL_MS = 1000; // 1 second + + // Callback for balance update notifications (set by controller) + private BalanceUpdateCallback balanceUpdateCallback; + + // Callback for state broadcast notifications (set by controller) + private StateBroadcastCallback stateBroadcastCallback; + + /** + * Returns the single most recent active round for a room in the given phases. + * Uses limit 1 in DB so corrupted data (multiple rounds in same phase) never breaks the game. + */ + private Optional getMostRecentActiveRound(Integer roomId, List phases) { + List list = gameRoundRepository.findMostRecentActiveRoundsByRoomId( + roomId, phases, PageRequest.of(0, 1)); + return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0)); + } + + /** + * Sets the callback for balance update notifications. + * Called by GameWebSocketController during initialization. + */ + public void setBalanceUpdateCallback(BalanceUpdateCallback callback) { + this.balanceUpdateCallback = callback; + } + + /** + * Sets the callback for state broadcast notifications. + * Called by GameWebSocketController during initialization. + * Also sets up room connection change callback to broadcast state updates. + */ + public void setStateBroadcastCallback(StateBroadcastCallback callback) { + this.stateBroadcastCallback = callback; + + // Set callback to broadcast state when room connections change + // This is set here (not in @PostConstruct) to ensure stateBroadcastCallback is not null + roomConnectionService.setConnectionChangeCallback((roomNumber, connectedCount) -> { + // Broadcast updated state to ALL rooms when connections change + // This ensures all clients receive updated allRoomsConnectedUsers map, + // regardless of which room they're subscribed to + if (stateBroadcastCallback != null) { + try { + // Broadcast state for all rooms (1, 2, 3) so all clients get updated counts + for (int roomNum = 1; roomNum <= 3; roomNum++) { + try { + GameRoomStateDto state = getRoomState(roomNum); + stateBroadcastCallback.broadcastState(roomNum, state); + } catch (Exception e) { + log.error("Error broadcasting state update for room {} after connection change in room {}", + roomNum, roomNumber, e); + } + } + } catch (Exception e) { + log.error("Error broadcasting state updates after connection change in room {}", roomNumber, e); + } + } + }); + } + + /** + * Broadcasts current state for all rooms (1, 2, 3). + * Used when a client subscribes so all clients (including the new one) get updated allRoomsConnectedUsers. + */ + public void broadcastStateToAllRooms() { + if (stateBroadcastCallback == null) return; + for (int roomNum = 1; roomNum <= 3; roomNum++) { + try { + GameRoomStateDto state = getRoomState(roomNum); + stateBroadcastCallback.broadcastState(roomNum, state); + } catch (Exception e) { + log.error("Error broadcasting state for room {}", roomNum, e); + } + } + } + + /** + * Callback interface for balance update notifications. + */ + @FunctionalInterface + public interface BalanceUpdateCallback { + void notifyBalanceUpdate(Integer userId); + } + + /** + * Callback interface for state broadcast notifications. + */ + @FunctionalInterface + public interface StateBroadcastCallback { + void broadcastState(Integer roomNumber, GameRoomStateDto state); + } + + /** + * Joins a user to a game round (for in-app WebSocket). + * Validates bet, deducts balance, and starts countdown if needed. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public GameRoomStateDto joinRound(Integer userId, Integer roomNumber, Long betAmount) { + return joinRoundWithResult(userId, roomNumber, betAmount, false).getState(); + } + + /** + * Joins a user to a game round with optional unique-bet behaviour. + * When {@code uniqueBet} is true, if the user has already placed a bet in this room's current round, + * no additional bet is placed and the current state is returned with their existing bet amount. + * + * @param uniqueBet when true, at most one bet per user per room per round (no accumulation) + * @return result with state and bet amount to report in API response + */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public JoinRoundResult joinRoundWithResult(Integer userId, Integer roomNumber, Long betAmount, boolean uniqueBet) { + + // Validate room number range (1-3) + if (roomNumber == null || roomNumber < 1 || roomNumber > 3) { + throw new GameException(localizationService.getMessage("game.error.roomNumberInvalid")); + } + + // Validate betAmount is not null + if (betAmount == null) { + throw new GameException(localizationService.getMessage("validation.error.required", "Bet amount")); + } + + // Validate bet amount is a positive integer (defense against float values) + // Jackson may truncate floats when deserializing to Long (e.g., 50.99 -> 50) + // This explicit check ensures we reject any non-integer values + if (betAmount <= 0) { + throw new GameException(localizationService.getMessage("game.error.betMustBePositive")); + } + + // Additional validation: Check if betAmount represents a whole number + // Since betAmount is already Long (integer type), this is mainly for documentation + // and defense-in-depth. If a float was sent and Jackson truncated it, the value + // would still be a valid Long, but the original intent was a decimal. + // Note: We can't detect truncation at this point, but we ensure the value is valid. + + // Validate bet amount against room-specific limits + long minBet = getMinBet(roomNumber); + long maxBet = getMaxBet(roomNumber); + if (betAmount < minBet || betAmount > maxBet) { + throw InvalidBetAmountException.create(localizationService, minBet, maxBet); + } + + // Get room with pessimistic lock to prevent race conditions + // This ensures only one transaction can update the room at a time + GameRoom room = gameRoomRepository.findByRoomNumberWithLock(roomNumber) + .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roomNotFound"))); + + // Check if room is joinable + if (room.getCurrentPhase() == GamePhase.SPINNING || room.getCurrentPhase() == GamePhase.RESOLUTION) { + throw new RoomNotJoinableException(room.getCurrentPhase(), localizationService); + } + + // Get or create current round (must be done before checking participants) + GameRound currentRound = getOrCreateCurrentRound(room); + + // Ensure round is persisted and flushed before checking participants + if (currentRound.getId() == null) { + currentRound = gameRoundRepository.saveAndFlush(currentRound); + } else { + // Refresh from database to ensure it's properly loaded + currentRound = gameRoundRepository.findById(currentRound.getId()) + .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roundNotFound"))); + } + + // Check if user already joined this round + List existingParticipants = participantRepository + .findByRoundIdAndUserId(currentRound.getId(), userId); + + // When unique=true, do not add another bet if user already has a bet in this room + if (uniqueBet && !existingParticipants.isEmpty()) { + GameRoomStateDto state = buildRoomState(room, currentRound); + int currentBetTickets = (int) (existingParticipants.get(0).getBet() / 1_000_000L); + return new JoinRoundResult(state, currentBetTickets); + } + + // Rate limiting: prevent fast clicks (backend validation) + String rateLimitKey = userId + ":" + currentRound.getId(); + Instant lastBetTime = lastBetTimes.get(rateLimitKey); + if (lastBetTime != null) { + long timeSinceLastBet = Instant.now().toEpochMilli() - lastBetTime.toEpochMilli(); + if (timeSinceLastBet < MIN_BET_INTERVAL_MS) { + throw new GameException(localizationService.getMessage("game.error.rateLimitWait")); + } + } + + // Update last bet time + lastBetTimes.put(rateLimitKey, Instant.now()); + + boolean isNewParticipant = existingParticipants.isEmpty(); + GameRoundParticipant participant; + Long previousBet = 0L; + Long userTotalBet = 0L; + + if (isNewParticipant) { + // New participant - validate total bet doesn't exceed max + userTotalBet = betAmount; + if (userTotalBet > maxBet) { + long maxBetTickets = maxBet / 1_000_000L; + long currentTotalBetTickets = userTotalBet / 1_000_000L; + String message = localizationService.getMessage("game.error.maxBetExceeded", + String.valueOf(maxBetTickets), + String.valueOf(currentTotalBetTickets), + "0"); + throw new GameException(message); + } + + // New participant - create new entry + // Reload round from database to ensure it's in the persistence context + GameRound managedRound = gameRoundRepository.findById(currentRound.getId()) + .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.roundNotFound"))); + + participant = GameRoundParticipant.builder() + .round(managedRound) + .userId(userId) + .bet(betAmount) + .build(); + } else { + // Existing participant - extend their bet + // Use pessimistic lock to prevent race conditions on bet updates + participant = existingParticipants.get(0); + previousBet = participant.getBet(); + // Reload participant with pessimistic lock to prevent concurrent updates + participant = participantRepository.findByIdWithLock(participant.getId()) + .orElseThrow(() -> new GameException(localizationService.getMessage("game.error.participantNotFound"))); + + // Validate total bet doesn't exceed max + userTotalBet = participant.getBet() + betAmount; + if (userTotalBet > maxBet) { + long currentTotalBetTickets = participant.getBet() / 1_000_000L; + long maxBetTickets = maxBet / 1_000_000L; + long remainingCapacity = maxBetTickets - currentTotalBetTickets; + String message = localizationService.getMessage("game.error.maxBetExceeded", + String.valueOf(maxBetTickets), + String.valueOf(currentTotalBetTickets), + String.valueOf(remainingCapacity)); + throw new GameException(message); + } + + participant.setBet(userTotalBet); + } + + // Check user balance + // betAmount is already in bigint format (database format) + UserB userB = userBRepository.findById(userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("user.error.notFound"))); + + if (userB.getBalanceA() < betAmount) { + throw new InsufficientBalanceException(localizationService.getMessage("game.error.insufficientBalance")); + } + + // Deduct balance (both in bigint format) + userB.setBalanceA(userB.getBalanceA() - betAmount); + userBRepository.save(userB); + + // Save participant (new or updated) + participantRepository.saveAndFlush(participant); + + // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) + // This prevents race conditions - don't use room.getTotalBet() which can be corrupted + List allParticipants = participantRepository.findByRoundId(currentRound.getId()); + Long actualTotalBet = allParticipants.stream() + .mapToLong(GameRoundParticipant::getBet) + .sum(); + + // Update room totals with calculated value (not increment) + room.setTotalBet(actualTotalBet); + int actualParticipantCount = allParticipants.size(); + room.setRegisteredPlayers(actualParticipantCount); + + // Start countdown when we have at least 2 players and room is still WAITING. + // Using >= 2 (not == 2) so that if countdown wasn't started when the 2nd joined (e.g. race/multi-instance), + // the 3rd or any later join will unblock the room. + if (actualParticipantCount >= 2 && room.getCurrentPhase() == GamePhase.WAITING) { + startCountdown(room, currentRound); + } + + gameRoomRepository.save(room); + + GameRoomStateDto state = buildRoomState(room, currentRound); + + // Immediately broadcast state update (event-driven) + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(roomNumber, state); + } + + int betTicketsForResponse = (int) (betAmount / 1_000_000L); + return new JoinRoundResult(state, betTicketsForResponse); + } + + /** + * Starts the countdown for a round. + */ + private void startCountdown(GameRoom room, GameRound round) { + Instant countdownEnd = Instant.now().plusSeconds(COUNTDOWN_DURATION_SECONDS); + room.setCountdownEndAt(countdownEnd); + room.setCurrentPhase(GamePhase.COUNTDOWN); + + round.setCountdownStartedAt(Instant.now()); + round.setPhase(GamePhase.COUNTDOWN); + + gameRoomRepository.save(room); + gameRoundRepository.save(round); + + // Immediately broadcast state update (event-driven) + GameRoomStateDto state = buildRoomState(room, round); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + } + + /** + * Gets or creates the current active round for a room. + * Uses DB as single source of truth (no in-memory round cache). + */ + private GameRound getOrCreateCurrentRound(GameRoom room) { + Optional foundRound = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN)); + return foundRound.orElseGet(() -> createNewRound(room)); + } + + /** + * Creates a new round for a room. + */ + private GameRound createNewRound(GameRoom room) { + GameRound newRound = GameRound.builder() + .room(room) + .phase(GamePhase.WAITING) + .totalBet(0L) + .startedAt(Instant.now()) + .build(); + newRound = gameRoundRepository.saveAndFlush(newRound); + return newRound; + } + + /** + * Checks for countdowns that have ended and starts spins. + * Lightweight check (only queries COUNTDOWN phase rooms, uses index). + * Most countdowns are handled event-driven, but this catches edge cases. + */ + @Scheduled(fixedRate = 500) // Check every 500ms for countdowns (lightweight query) + @Transactional + public void checkCountdowns() { + // Only query rooms in COUNTDOWN phase (indexed query, very fast) + List roomsInCountdown = gameRoomRepository.findByCurrentPhase(GamePhase.COUNTDOWN); + + for (GameRoom room : roomsInCountdown) { + if (room.getCountdownEndAt() != null && Instant.now().isAfter(room.getCountdownEndAt())) { + try { + startSpin(room); + } catch (Exception e) { + log.error("Error starting spin for room {}", room.getRoomNumber(), e); + } + } + } + } + + /** + * Starts the spin phase and selects winner. + */ + @Transactional + public void startSpin(GameRoom room) { + + // CRITICAL FIX: Prevent concurrent execution - if room is already in SPINNING, skip + // This can happen if checkCountdowns is called multiple times concurrently + if (room.getCurrentPhase() == GamePhase.SPINNING) { + log.debug("Room {} is already in SPINNING phase, skipping", room.getRoomNumber()); + return; + } + + // Also check if room is not in COUNTDOWN phase (shouldn't happen, but safety check) + if (room.getCurrentPhase() != GamePhase.COUNTDOWN) { + log.warn("Room {} is not in COUNTDOWN phase (current: {}), skipping", + room.getRoomNumber(), room.getCurrentPhase()); + return; + } + + GameRound round = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.COUNTDOWN, GamePhase.WAITING)).orElse(null); + if (round == null) { + log.warn("No active round found for room {}", room.getRoomNumber()); + return; + } + + // Check participant count before starting spin + // If only 1 participant, refund immediately without starting spin animation + List participants = participantRepository.findByRoundId(round.getId()); + if (participants.size() < 2) { + if (participants.size() == 1) { + GameRoundParticipant participant = participants.get(0); + UserB userB = userBRepository.findById(participant.getUserId()) + .orElseThrow(() -> new IllegalStateException("User balance not found")); + userB.setBalanceA(userB.getBalanceA() + participant.getBet()); + userBRepository.save(userB); + + round.setPhase(GamePhase.RESOLUTION); + round.setResolvedAt(Instant.now()); + gameRoundRepository.save(round); + + // Set room to RESOLUTION (not WAITING) to allow frontend to process + room.setCurrentPhase(GamePhase.RESOLUTION); + gameRoomRepository.save(room); + + // Notify balance update + if (balanceUpdateCallback != null) { + balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); + } + + // Don't reset room immediately - let scheduled task handle it after delay + } else { + // No participants, just reset + resetRoom(room); + } + return; + } + + // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) + // Don't use room.getTotalBet() which can be corrupted by race conditions + Long actualRoundTotalBet = participants.stream() + .mapToLong(GameRoundParticipant::getBet) + .sum(); + + // Update round with actual totals from participants + round.setTotalBet(actualRoundTotalBet); + round.setCountdownEndedAt(Instant.now()); + round.setPhase(GamePhase.SPINNING); + + // CRITICAL: Determine winner NOW (before SPINNING phase) so frontend can generate tape correctly + // This ensures stopIndex and winner are available during SPINNING phase + // The actual payout will be applied later in resolveWinner() during RESOLUTION phase + // Use actualRoundTotalBet (from participants) not room.getTotalBet() (which might be corrupted) + long totalBet = actualRoundTotalBet; + if (totalBet <= 0) { + log.error("Invalid totalBet for round - roundId={}, totalBet={}, participants={}", + round.getId(), totalBet, participants.size()); + throw new IllegalStateException("Cannot start spin with zero total bet"); + } + + // Bot override: safe bot (balance < threshold) or flexible bot (fixed win rate) may force winner + GameRoundParticipant winner = botConfigService.resolveWinnerOverride(participants, totalBet) + .orElse(null); + + if (winner == null) { + // Normal weighted random by bet + long randomValue = new Random().nextLong(totalBet); + if (randomValue < 0) { + randomValue = -randomValue; + } + long cumulative = 0; + for (GameRoundParticipant p : participants) { + cumulative += p.getBet(); + if (randomValue < cumulative) { + winner = p; + break; + } + } + if (winner == null) { + winner = participants.get(participants.size() - 1); + } + } + + // Set winner in round (but don't apply payout yet - that happens in resolveWinner) + round.setWinnerUserId(winner.getUserId()); + round.setWinnerBet(winner.getBet()); + gameRoundRepository.save(round); + + // Update room phase + room.setCurrentPhase(GamePhase.SPINNING); + gameRoomRepository.save(room); + + // Immediately broadcast SPINNING state (event-driven) + // This state will now include winner and stopIndex for tape generation + GameRoomStateDto state = buildRoomState(room, round); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + + // Winner resolution is handled by resolveSpins() scheduled task + // It checks countdownEndedAt + SPIN_DURATION_MS to determine when to resolve + // Note: Winner is already determined above, resolveWinner() will only apply payout + } + + /** + * Resolves the winner and applies payouts. + * Called after spin duration. + * Checks only rooms in SPINNING phase (lightweight query). + */ + @Scheduled(fixedRate = 500) // Check every 500ms for spins (lightweight query) + @Transactional + public void resolveSpins() { + // Only query rooms in SPINNING phase (indexed query, very fast) + List roomsSpinning = gameRoomRepository.findByCurrentPhase(GamePhase.SPINNING); + + for (GameRoom room : roomsSpinning) { + GameRound round = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.SPINNING)).orElse(null); + if (round == null) { + log.warn("Room {} is in SPINNING phase but no round found in database", room.getRoomNumber()); + resetRoom(room); + continue; + } + + if (round.getCountdownEndedAt() == null) { + log.warn("Round {} is in SPINNING phase but countdownEndedAt is null", round.getId()); + // This shouldn't happen, but if it does, set it to now to allow resolution + round.setCountdownEndedAt(Instant.now()); + gameRoundRepository.save(round); + } + + // Check if spin duration has passed + Instant spinEndTime = round.getCountdownEndedAt().plusMillis(SPIN_DURATION_MS); + if (Instant.now().isBefore(spinEndTime)) { + continue; // Still spinning + } + + // Re-check participant count before resolving + // If participants dropped below 2, handle refund instead + List participants = participantRepository.findByRoundId(round.getId()); + if (participants.size() < 2) { + log.warn("Participant count dropped during spin: room={}, roundId={}, count={}", + room.getRoomNumber(), round.getId(), participants.size()); + if (participants.size() == 1) { + GameRoundParticipant participant = participants.get(0); + UserB userB = userBRepository.findById(participant.getUserId()) + .orElseThrow(() -> new IllegalStateException("User balance not found")); + userB.setBalanceA(userB.getBalanceA() + participant.getBet()); + userBRepository.save(userB); + + log.info("Round refunded (participant dropped during spin) - room={}, roundId={}, userId={}, refundAmount={}", + room.getRoomNumber(), round.getId(), participant.getUserId(), participant.getBet()); + + round.setPhase(GamePhase.RESOLUTION); + round.setResolvedAt(Instant.now()); + gameRoundRepository.save(round); + + room.setCurrentPhase(GamePhase.RESOLUTION); + gameRoomRepository.save(room); + + // Immediately broadcast RESOLUTION state (event-driven) + GameRoomStateDto state = buildRoomState(room, round); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + + // Notify balance update (event-driven) + if (balanceUpdateCallback != null) { + balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); + } + + continue; + } else { + // No participants, reset room + resetRoom(room); + continue; + } + } + + try { + Integer winnerUserId = resolveWinner(room, round); + + // Balance update is already sent by resolveWinner via callback (event-driven) + // State broadcast is already done by resolveWinner via callback (event-driven) + } catch (Exception e) { + log.error("Error resolving winner for room {}", room.getRoomNumber(), e); + } + } + + // Second, reset rooms that have been in RESOLUTION for at least 4 seconds + // This allows frontend to process RESOLUTION phase and clear tape via animation callback + // Only query rooms in RESOLUTION phase (indexed query, very fast) + // DB as authority: if round is not in cache (e.g. after restart), load from DB so we still reset + List roomsInResolution = gameRoomRepository.findByCurrentPhase(GamePhase.RESOLUTION); + + for (GameRoom room : roomsInResolution) { + GameRound round = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.RESOLUTION)).orElse(null); + if (round != null && round.getResolvedAt() != null) { + // Check if RESOLUTION phase has been visible for at least 4 seconds + long secondsSinceResolution = Instant.now().getEpochSecond() - round.getResolvedAt().getEpochSecond(); + if (secondsSinceResolution >= 4) { + resetRoom(room); + + // Immediately broadcast WAITING state (event-driven) + GameRoomStateDto state = buildRoomState(room, null); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + } + } + } + } + + /** + * Selects winner and applies payouts. + * @return Winner user ID for balance update notification + */ + @Transactional + public Integer resolveWinner(GameRoom room, GameRound round) { + // Re-read room so we see latest phase (avoid double payout if called twice) + GameRoom currentRoom = gameRoomRepository.findByRoomNumber(room.getRoomNumber()).orElse(null); + if (currentRoom == null || currentRoom.getCurrentPhase() != GamePhase.SPINNING) { + log.debug("Room {} no longer SPINNING (phase={}), skipping resolveWinner", + room.getRoomNumber(), currentRoom != null ? currentRoom.getCurrentPhase() : null); + return null; + } + room = currentRoom; + + // Get all participants + List participants = participantRepository.findByRoundId(round.getId()); + + if (participants.isEmpty()) { + log.warn("No participants found for round {}", round.getId()); + resetRoom(room); + return null; + } + + if (participants.size() == 1) { + // Only one participant, refund + // All values are in bigint format (database format) + GameRoundParticipant participant = participants.get(0); + UserB userB = userBRepository.findById(participant.getUserId()) + .orElseThrow(() -> new IllegalStateException("User balance not found")); + userB.setBalanceA(userB.getBalanceA() + participant.getBet()); + // Increment rounds_played even for refunded rounds (user still participated) + userB.setRoundsPlayed(userB.getRoundsPlayed() + 1); + userBRepository.save(userB); + + log.info("Round refunded (single participant) - room={}, roundId={}, userId={}, refundAmount={}", + room.getRoomNumber(), round.getId(), participant.getUserId(), participant.getBet()); + + round.setPhase(GamePhase.RESOLUTION); + round.setResolvedAt(Instant.now()); + gameRoundRepository.save(round); + + // Set room to RESOLUTION (not WAITING) to allow frontend to process + room.setCurrentPhase(GamePhase.RESOLUTION); + gameRoomRepository.save(room); + + + // Immediately broadcast RESOLUTION state (event-driven) + GameRoomStateDto state = buildRoomState(room, round); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + + // Notify user's balance update via callback (event-driven) + if (balanceUpdateCallback != null) { + balanceUpdateCallback.notifyBalanceUpdate(participant.getUserId()); + } + + return participant.getUserId(); + } + + // Winner was already determined in startSpin() to enable tape generation + // Here we only need to apply the payout + Integer winnerUserId = round.getWinnerUserId(); + if (winnerUserId == null) { + // Defensive recovery: round in SPINNING without winner (e.g. replication lag, restart). + // Pick winner using same weighted-random logic as startSpin() so we can resolve and unblock the room. + log.warn("Defensive recovery: winner was null for round {} (room {}), selecting winner from participants", + round.getId(), room.getRoomNumber()); + long actualTotalBetRecovery = participants.stream().mapToLong(GameRoundParticipant::getBet).sum(); + if (actualTotalBetRecovery <= 0) { + log.error("Cannot recover round {} - totalBet is zero", round.getId()); + resetRoom(room); + return null; + } + long randomValue = new Random().nextLong(actualTotalBetRecovery); + if (randomValue < 0) { + randomValue = -randomValue; + } + long cumulative = 0; + GameRoundParticipant selectedWinner = null; + for (GameRoundParticipant p : participants) { + cumulative += p.getBet(); + if (randomValue < cumulative) { + selectedWinner = p; + break; + } + } + if (selectedWinner == null) { + selectedWinner = participants.get(participants.size() - 1); + } + round.setWinnerUserId(selectedWinner.getUserId()); + round.setWinnerBet(selectedWinner.getBet()); + round.setTotalBet(actualTotalBetRecovery); + gameRoundRepository.save(round); + winnerUserId = round.getWinnerUserId(); + } + + final Integer winnerIdForLookup = winnerUserId; + GameRoundParticipant winner = participants.stream() + .filter(p -> p.getUserId().equals(winnerIdForLookup)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Winner participant not found")); + + // CRITICAL FIX: Calculate totalBet from actual participants (source of truth) + // Don't use round.getTotalBet() which can be corrupted by race conditions + long actualTotalBet = participants.stream() + .mapToLong(GameRoundParticipant::getBet) + .sum(); + + // Update round with actual totalBet to ensure consistency + if (actualTotalBet != round.getTotalBet()) { + log.warn("Round totalBet mismatch - roundId={}, roundTotalBet={}, actualTotalBet={}, updating round", + round.getId(), round.getTotalBet(), actualTotalBet); + round.setTotalBet(actualTotalBet); + } + + // Calculate commission and payout + // All values are in bigint format (database format) + long totalBet = actualTotalBet; + long winnerBet = winner.getBet(); + long commission = (long) ((totalBet - winnerBet) * 0.2); + long payout = totalBet - commission; + + // Update winner balance (all values in bigint format) + UserB winnerBalance = userBRepository.findById(winner.getUserId()) + .orElseThrow(() -> new IllegalStateException("Winner balance not found")); + winnerBalance.setBalanceA(winnerBalance.getBalanceA() + payout); + // Increment total winnings since last deposit (for withdrawal limit) + long currentWinAfterDeposit = winnerBalance.getTotalWinAfterDeposit() != null ? winnerBalance.getTotalWinAfterDeposit() : 0L; + winnerBalance.setTotalWinAfterDeposit(currentWinAfterDeposit + payout); + userBRepository.save(winnerBalance); + + log.info("Round completed: room={}, roundId={}, winner={}, totalBet={}, payout={}", + room.getRoomNumber(), round.getId(), winner.getUserId(), totalBet, payout); + + // Create win transaction (total payout, not net profit) + try { + transactionService.createWinTransaction(winner.getUserId(), payout, round.getId()); + } catch (Exception e) { + log.error("Error creating win transaction: userId={}, roundId={}", winner.getUserId(), round.getId(), e); + // Continue even if transaction record creation fails + } + + // Add net win to active NET_WIN promotions (net win = payout - winner's bet) + long netWinBigint = payout - winnerBet; + if (netWinBigint > 0) { + try { + promotionService.addNetWinPoints(winner.getUserId(), netWinBigint); + } catch (Exception e) { + log.error("Error adding promotion points: userId={}, roundId={}", winner.getUserId(), round.getId(), e); + // Continue even if promotion update fails + } + // NET_WIN_MAX_BET: same points but only when winner made max bet in this room + long roomMaxBet = getMaxBet(room.getRoomNumber()); + if (winnerBet == roomMaxBet) { + try { + promotionService.addNetWinMaxBetPoints(winner.getUserId(), netWinBigint); + } catch (Exception e) { + log.error("Error adding NET_WIN_MAX_BET promotion points: userId={}, roundId={}", winner.getUserId(), round.getId(), e); + } + } + } + + Set refererIdsToNotify = new HashSet<>(); + + // REF_COUNT: for each participant whose first round this is (rounds_played == 0), add 1 point to their referer (level 1) + // only if the referral was registered during the promotion's timeframe (not before promo start) + for (GameRoundParticipant participant : participants) { + try { + UserB userB = userBRepository.findById(participant.getUserId()) + .orElseThrow(() -> new IllegalStateException("User balance not found for userId=" + participant.getUserId())); + if (userB.getRoundsPlayed() != null && userB.getRoundsPlayed() == 0) { + userDRepository.findById(participant.getUserId()).ifPresent(userD -> { + Integer refererId1 = userD.getRefererId1(); + if (refererId1 != null && refererId1 > 0) { + Instant referralRegTime = userARepository.findById(participant.getUserId()) + .filter(ua -> ua.getDateReg() != null && ua.getDateReg() > 0) + .map(ua -> Instant.ofEpochSecond(ua.getDateReg().longValue())) + .orElse(null); + try { + promotionService.addRefCountPoints(refererId1, referralRegTime); + } catch (Exception e) { + log.error("Error adding REF_COUNT promotion point for refererId={}, roundId={}", refererId1, round.getId(), e); + } + } + }); + } + } catch (Exception e) { + log.error("Error checking REF_COUNT for userId={}, roundId={}", participant.getUserId(), round.getId(), e); + } + } + + // Increment rounds_played for all participants + for (GameRoundParticipant participant : participants) { + try { + UserB userB = userBRepository.findById(participant.getUserId()) + .orElseThrow(() -> new IllegalStateException("User balance not found for userId=" + participant.getUserId())); + userB.setRoundsPlayed(userB.getRoundsPlayed() + 1); + userBRepository.save(userB); + } catch (Exception e) { + log.error("Error incrementing rounds_played for userId={}, roundId={}", participant.getUserId(), round.getId(), e); + // Continue processing other participants even if one fails + } + } + + // Create bet transactions for all participants (winners and losers) + for (GameRoundParticipant participant : participants) { + try { + // Check if this will be the user's 3rd bet BEFORE creating the transaction + boolean isThirdBet = referralCommissionService.willBeThirdBet(participant.getUserId()); + + transactionService.createBetTransaction(participant.getUserId(), participant.getBet(), round.getId()); + + // If this was the 3rd bet, give bonus to referrer 1 + if (isThirdBet) { + try { + Integer referer1Id = referralCommissionService.giveThirdBetBonus(participant.getUserId()); + if (referer1Id != null) { + refererIdsToNotify.add(referer1Id); + } + } catch (Exception e) { + log.error("Error giving 3rd bet bonus for userId={}", participant.getUserId(), e); + // Continue even if bonus fails + } + } + } catch (Exception e) { + log.error("Error creating bet transaction: userId={}, roundId={}", participant.getUserId(), round.getId(), e); + // Continue processing other participants even if one fails + } + } + + // Delete all participants for this round immediately after round finishes + try { + participantRepository.deleteAll(participants); + // Participants deleted for round (no log needed - happens on every round completion) + } catch (Exception e) { + log.error("Error deleting participants for roundId={}", round.getId(), e); + // Continue even if participant deletion fails + } + + // Process referral commissions for the winner and collect referer IDs + try { + Set winnerReferers = referralCommissionService.processWinnerCommissions( + winner.getUserId(), winnerBet, totalBet, commission); + refererIdsToNotify.addAll(winnerReferers); + } catch (Exception e) { + log.error("Error processing winner referral commissions for userId={}", winner.getUserId(), e); + // Continue even if referral commission processing fails + } + + // Process referral commissions for all losers and collect referer IDs + for (GameRoundParticipant participant : participants) { + if (!participant.getUserId().equals(winnerUserId)) { + try { + Set loserReferers = referralCommissionService.processLoserCommissions( + participant.getUserId(), participant.getBet()); + refererIdsToNotify.addAll(loserReferers); + } catch (Exception e) { + log.error("Error processing loser referral commissions for userId={}", participant.getUserId(), e); + // Continue processing other participants even if one fails + } + } + } + + // Notify referers of balance updates + if (balanceUpdateCallback != null) { + for (Integer refererId : refererIdsToNotify) { + try { + balanceUpdateCallback.notifyBalanceUpdate(refererId); + } catch (Exception e) { + log.error("Error notifying balance update for refererId={}", refererId, e); + } + } + } + + // Update round (winner info already set in startSpin, just update phase and payout info) + round.setWinnerBet(winnerBet); + round.setCommission(commission); + round.setPayout(payout); + round.setPhase(GamePhase.RESOLUTION); + round.setResolvedAt(Instant.now()); + gameRoundRepository.save(round); + + // Set room phase to RESOLUTION + room.setCurrentPhase(GamePhase.RESOLUTION); + gameRoomRepository.save(room); + + // Immediately broadcast RESOLUTION state (event-driven) + GameRoomStateDto state = buildRoomState(room, round); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + + // Send balance update to winner (event-driven) + if (balanceUpdateCallback != null) { + balanceUpdateCallback.notifyBalanceUpdate(winner.getUserId()); + } + + // Return winner ID + return winner.getUserId(); + } + + /** + * Resets room to WAITING state for next round. + */ + private void resetRoom(GameRoom room) { + room.setCurrentPhase(GamePhase.WAITING); + room.setTotalBet(0L); + room.setRegisteredPlayers(0); + room.setCountdownEndAt(null); + gameRoomRepository.save(room); + + // Clean up rate limit entries for the round we're leaving (prevent memory leaks) + getMostRecentActiveRound(room.getId(), + List.of(GamePhase.RESOLUTION, GamePhase.SPINNING, GamePhase.COUNTDOWN)) + .ifPresent(r -> { + if (r.getId() != null) { + Long roundId = r.getId(); + lastBetTimes.entrySet().removeIf(entry -> entry.getKey().endsWith(":" + roundId)); + } + }); + + log.info("Room {} reset to WAITING state", room.getRoomNumber()); + } + + /** + * Resets room after RESOLUTION phase has been broadcast. + * Called after balance update is sent to winner. + */ + public void resetRoomAfterResolution(Integer roomNumber) { + GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) + .orElse(null); + if (room != null && room.getCurrentPhase() == GamePhase.RESOLUTION) { + resetRoom(room); + } + } + + /** + * Gets current room state. + */ + @Transactional(readOnly = true) + public GameRoomStateDto getRoomState(Integer roomNumber) { + GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) + .orElseThrow(() -> new IllegalArgumentException("Room not found: " + roomNumber)); + List phases = room.getCurrentPhase() == GamePhase.RESOLUTION + ? List.of(GamePhase.RESOLUTION) + : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); + GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); + return buildRoomState(room, round); + } + + /** + * Builds room state DTO. + */ + private GameRoomStateDto buildRoomState(GameRoom room, GameRound round) { + List participants = new ArrayList<>(); + WinnerDto winner = null; + Long countdownRemaining = null; + Long spinDuration = null; + Long stopIndex = null; + + if (round != null) { + // Get participants + List participantList = participantRepository.findByRoundId(round.getId()); + + // Optimize: Fetch all avatar URLs in one query (avoids N+1 query problem) + List userIds = participantList.stream() + .map(GameRoundParticipant::getUserId) + .collect(Collectors.toList()); + Map avatarUrlMap = avatarService.getAvatarUrls(userIds); + + participants = participantList.stream() + .map(p -> { + String avatarUrl = avatarUrlMap.get(p.getUserId()); + return ParticipantDto.builder() + .userId(p.getUserId()) + .bet(p.getBet() / 1_000_000L) // Convert bigint to tickets + .avatarUrl(avatarUrl) + .build(); + }) + .collect(Collectors.toList()); + + // Calculate countdown remaining + if (room.getCurrentPhase() == GamePhase.COUNTDOWN && room.getCountdownEndAt() != null) { + long remaining = room.getCountdownEndAt().getEpochSecond() - Instant.now().getEpochSecond(); + countdownRemaining = Math.max(0, remaining); + } + + // Winner info - include during SPINNING phase (for tape generation) and RESOLUTION phase (for display) + if ((round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) && round.getWinnerUserId() != null) { + // Fetch winner's screen name from UserA + String winnerScreenName = "-"; + Optional winnerUser = userARepository.findById(round.getWinnerUserId()); + if (winnerUser.isPresent()) { + winnerScreenName = winnerUser.get().getScreenName(); + } + + // Generate avatar URL for winner + String winnerAvatarUrl = avatarService.getAvatarUrl(round.getWinnerUserId()); + + // Calculate winner's chance percentage from actual participant bets (same as CompletedRoundDto) + // This ensures consistency - calculate totalBet from participants, not from round.getTotalBet() + // which might be out of sync with actual participant bets + Double winChance = null; + Long winnerBetBigint = round.getWinnerBet(); + Long winnerBetTickets = winnerBetBigint / 1_000_000L; + if (winnerBetBigint > 0) { + // Calculate actual totalBet from participants (source of truth) + Long actualTotalBetFromParticipants = participantList.stream() + .mapToLong(GameRoundParticipant::getBet) + .sum(); + + if (actualTotalBetFromParticipants > 0) { + winChance = ((double) winnerBetBigint / actualTotalBetFromParticipants) * 100.0; + } + } + + winner = WinnerDto.builder() + .userId(round.getWinnerUserId()) + .screenName(winnerScreenName) + .avatarUrl(winnerAvatarUrl) + .bet(winnerBetTickets) // Convert bigint to tickets + .payout(round.getPayout()) // Keep in bigint format + .commission(round.getCommission()) // Keep in bigint format + .winChance(winChance) // Calculated from actual participant bets + .build(); + } + + // Spin animation parameters + if (round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) { + spinDuration = SPIN_DURATION_MS; + // Calculate stop index based on winner's position in cumulative bet + // stopIndex is in tickets (not bigint) for consistency + if (round.getWinnerUserId() != null) { + long cumulative = 0; + for (GameRoundParticipant p : participantList) { + if (p.getUserId().equals(round.getWinnerUserId())) { + // Calculate in bigint, then convert to tickets + long betInTickets = p.getBet() / 1_000_000L; + stopIndex = cumulative + (betInTickets / 2); // Middle of winner's range + break; + } + cumulative += p.getBet() / 1_000_000L; // Convert to tickets for cumulative + } + } + } + } + + // Get connected users count from room connection service + int connectedUsers = roomConnectionService.getConnectedUsersCount(room.getRoomNumber()); + + // Get connected users count for all rooms (1, 2, 3) so frontend can update all room counters + Map allRoomsConnectedUsers = new HashMap<>(); + for (int roomNum = 1; roomNum <= 3; roomNum++) { + allRoomsConnectedUsers.put(roomNum, roomConnectionService.getConnectedUsersCount(roomNum)); + } + + // Get room-specific bet limits (convert from bigint to tickets) + long minBetBigint = getMinBet(room.getRoomNumber()); + long maxBetBigint = getMaxBet(room.getRoomNumber()); + long minBet = minBetBigint / 1_000_000L; + long maxBet = maxBetBigint / 1_000_000L; + + // Convert phase enum to integer: 1=WAITING, 2=COUNTDOWN, 3=SPINNING, 4=RESOLUTION + int phaseInt = switch (room.getCurrentPhase()) { + case WAITING -> 1; + case COUNTDOWN -> 2; + case SPINNING -> 3; + case RESOLUTION -> 4; + }; + + // Convert totalBet from bigint to tickets + long totalBetTickets = room.getTotalBet() / 1_000_000L; + + return GameRoomStateDto.builder() + .roomNumber(room.getRoomNumber()) + .roundId(round != null ? round.getId() : null) + .phase(phaseInt) + .totalBet(totalBetTickets) + .registeredPlayers(room.getRegisteredPlayers()) + .connectedUsers(connectedUsers) + .allRoomsConnectedUsers(allRoomsConnectedUsers) + .minBet(minBet) + .maxBet(maxBet) + .countdownEndAt(room.getCountdownEndAt()) + .countdownRemainingSeconds(countdownRemaining) + .participants(participants) + .winner(winner) + .spinDuration(spinDuration) + .stopIndex(stopIndex) + .build(); + } + + // --- Admin room management --- + + private static final long REPAIR_COUNTDOWN_DEAD_GRACE_SECONDS = 30; + + /** + * Returns summary for all rooms (for admin list). Uses DB and connection service. + */ + @Transactional(readOnly = true) + public List getAdminRoomSummaries() { + List list = new ArrayList<>(); + for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { + GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber).orElse(null); + if (room == null) continue; + List phases = room.getCurrentPhase() == GamePhase.RESOLUTION + ? List.of(GamePhase.RESOLUTION) + : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); + GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); + long totalBetTickets = room.getTotalBet() != null ? room.getTotalBet() / 1_000_000L : 0L; + double totalBetUsd = totalBetTickets / 1000.0; + int connected = roomConnectionService.getConnectedUsersCount(roomNumber); + list.add(AdminRoomSummaryDto.builder() + .roomNumber(room.getRoomNumber()) + .phase(room.getCurrentPhase().name()) + .connectedUsers(connected) + .registeredPlayers(room.getRegisteredPlayers() != null ? room.getRegisteredPlayers() : 0) + .totalBetTickets(totalBetTickets) + .totalBetUsd(totalBetUsd) + .roundId(round != null ? round.getId() : null) + .build()); + } + return list; + } + + /** + * Returns full detail for one room (for admin room detail screen). + */ + @Transactional(readOnly = true) + public AdminRoomDetailDto getAdminRoomDetail(Integer roomNumber) { + GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber) + .orElseThrow(() -> new IllegalArgumentException("Room not found: " + roomNumber)); + List phases = room.getCurrentPhase() == GamePhase.RESOLUTION + ? List.of(GamePhase.RESOLUTION) + : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); + GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); + long totalBetTickets = room.getTotalBet() != null ? room.getTotalBet() / 1_000_000L : 0L; + double totalBetUsd = totalBetTickets / 1000.0; + int connected = roomConnectionService.getConnectedUsersCount(roomNumber); + List connectedUserIds = roomConnectionService.getConnectedUserIds(roomNumber); + List connectedViewers = new ArrayList<>(); + for (Integer viewerId : connectedUserIds) { + String screenName = "-"; + Optional u = userARepository.findById(viewerId); + if (u.isPresent() && u.get().getScreenName() != null) screenName = u.get().getScreenName(); + connectedViewers.add(AdminRoomViewerDto.builder().userId(viewerId).screenName(screenName).build()); + } + + List participants = new ArrayList<>(); + AdminRoomWinnerDto winnerDto = null; + if (round != null) { + List participantList = participantRepository.findByRoundId(round.getId()); + long totalBetBigint = participantList.stream().mapToLong(GameRoundParticipant::getBet).sum(); + for (GameRoundParticipant p : participantList) { + double chancePct = totalBetBigint > 0 ? (p.getBet() * 100.0 / totalBetBigint) : 0; + String screenName = "-"; + Optional u = userARepository.findById(p.getUserId()); + if (u.isPresent() && u.get().getScreenName() != null) screenName = u.get().getScreenName(); + participants.add(AdminRoomParticipantDto.builder() + .userId(p.getUserId()) + .screenName(screenName) + .betTickets(p.getBet() / 1_000_000L) + .chancePct(Math.round(chancePct * 100.0) / 100.0) + .build()); + } + if ((round.getPhase() == GamePhase.SPINNING || round.getPhase() == GamePhase.RESOLUTION) && round.getWinnerUserId() != null) { + long winnerBetBigint = round.getWinnerBet() != null ? round.getWinnerBet() : 0L; + double winChancePct = totalBetBigint > 0 ? (winnerBetBigint * 100.0 / totalBetBigint) : 0; + String winnerScreenName = "-"; + Optional u = userARepository.findById(round.getWinnerUserId()); + if (u.isPresent() && u.get().getScreenName() != null) winnerScreenName = u.get().getScreenName(); + winnerDto = AdminRoomWinnerDto.builder() + .userId(round.getWinnerUserId()) + .screenName(winnerScreenName) + .betTickets(winnerBetBigint / 1_000_000L) + .winChancePct(Math.round(winChancePct * 100.0) / 100.0) + .build(); + } + } + + return AdminRoomDetailDto.builder() + .roomNumber(room.getRoomNumber()) + .phase(room.getCurrentPhase().name()) + .roundId(round != null ? round.getId() : null) + .totalBetTickets(totalBetTickets) + .totalBetUsd(totalBetUsd) + .registeredPlayers(room.getRegisteredPlayers() != null ? room.getRegisteredPlayers() : 0) + .connectedUsers(connected) + .participants(participants) + .connectedViewers(connectedViewers) + .winner(winnerDto) + .build(); + } + + /** + * Returns all users currently connected to any room (viewers + participants), with room, current bet, balance, deposits, withdrawals, rounds played. + * For admin "online users across all rooms" table. + */ + @Transactional(readOnly = true) + public List getAdminOnlineUsersAcrossRooms() { + List result = new ArrayList<>(); + for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { + GameRoom room = gameRoomRepository.findByRoomNumber(roomNumber).orElse(null); + if (room == null) continue; + List phases = room.getCurrentPhase() == GamePhase.RESOLUTION + ? List.of(GamePhase.RESOLUTION) + : List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING); + GameRound round = getMostRecentActiveRound(room.getId(), phases).orElse(null); + List connectedUserIds = roomConnectionService.getConnectedUserIds(roomNumber); + for (Integer userId : connectedUserIds) { + Long currentBetTickets = null; + if (round != null) { + List participantList = participantRepository.findByRoundIdAndUserId(round.getId(), userId); + if (!participantList.isEmpty()) { + currentBetTickets = participantList.get(0).getBet() / 1_000_000L; + } + } + String screenName = "-"; + Optional ua = userARepository.findById(userId); + if (ua.isPresent() && ua.get().getScreenName() != null) screenName = ua.get().getScreenName(); + UserB userB = userBRepository.findById(userId).orElse(UserB.builder() + .id(userId) + .balanceA(0L) + .depositTotal(0L) + .depositCount(0) + .withdrawTotal(0L) + .withdrawCount(0) + .roundsPlayed(0) + .build()); + result.add(AdminRoomOnlineUserDto.builder() + .userId(userId) + .screenName(screenName) + .roomNumber(roomNumber) + .currentBetTickets(currentBetTickets) + .balanceA(userB.getBalanceA()) + .depositTotal(userB.getDepositTotal()) + .depositCount(userB.getDepositCount()) + .withdrawTotal(userB.getWithdrawTotal()) + .withdrawCount(userB.getWithdrawCount()) + .roundsPlayed(userB.getRoundsPlayed()) + .build()); + } + } + return result; + } + + /** + * Admin-only: run repair logic for one room (fix dead state). Uses room lock. + */ + @Transactional + public void repairRoom(int roomNumber) { + if (roomNumber < 1 || roomNumber > 3) { + throw new IllegalArgumentException("Invalid room number: " + roomNumber); + } + GameRoom room = gameRoomRepository.findByRoomNumberWithLock(roomNumber).orElse(null); + if (room == null) return; + GamePhase phase = room.getCurrentPhase(); + switch (phase) { + case WAITING -> recoverWaitingIfDead(room); + case COUNTDOWN -> recoverCountdownIfDead(room); + case SPINNING -> recoverSpinningIfDead(room); + case RESOLUTION -> recoverResolutionIfDead(room); + } + } + + private void recoverWaitingIfDead(GameRoom room) { + Optional roundOpt = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.WAITING)); + if (roundOpt.isEmpty()) return; + GameRound round = roundOpt.get(); + int count = participantRepository.findByRoundId(round.getId()).size(); + if (count < 2) return; + log.warn("Admin repair: room {} was WAITING with {} participants, starting countdown", room.getRoomNumber(), count); + startCountdown(room, round); + } + + private void recoverCountdownIfDead(GameRoom room) { + Instant now = Instant.now(); + if (room.getCountdownEndAt() == null) { + log.warn("Admin repair: room {} was COUNTDOWN with null countdownEndAt, setting to past", room.getRoomNumber()); + room.setCountdownEndAt(now.minusSeconds(1)); + gameRoomRepository.save(room); + return; + } + if (now.minusSeconds(REPAIR_COUNTDOWN_DEAD_GRACE_SECONDS).isBefore(room.getCountdownEndAt())) return; + log.warn("Admin repair: room {} was COUNTDOWN past end time, starting spin", room.getRoomNumber()); + startSpin(room); + } + + private void recoverSpinningIfDead(GameRoom room) { + Optional roundOpt = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.SPINNING)); + if (roundOpt.isEmpty()) return; + GameRound round = roundOpt.get(); + if (round.getCountdownEndedAt() == null) { + round.setCountdownEndedAt(Instant.now()); + gameRoundRepository.save(round); + } + Instant spinEnd = round.getCountdownEndedAt().plusMillis(SPIN_DURATION_MS); + if (Instant.now().isBefore(spinEnd)) return; + log.warn("Admin repair: room {} was SPINNING past spin end, resolving winner", room.getRoomNumber()); + try { + resolveWinner(room, round); + } catch (Exception e) { + log.error("Admin repair: failed to resolve winner for room {}", room.getRoomNumber(), e); + } + } + + private void recoverResolutionIfDead(GameRoom room) { + Optional roundOpt = getMostRecentActiveRound( + room.getId(), List.of(GamePhase.RESOLUTION)); + if (roundOpt.isEmpty()) return; + GameRound round = roundOpt.get(); + if (round.getResolvedAt() == null) return; + long secondsSince = Instant.now().getEpochSecond() - round.getResolvedAt().getEpochSecond(); + if (secondsSince < 4) return; + log.warn("Admin repair: room {} was RESOLUTION for {}s, resetting to WAITING", room.getRoomNumber(), secondsSince); + resetRoom(room); + GameRoomStateDto state = buildRoomState(room, null); + if (stateBroadcastCallback != null) { + stateBroadcastCallback.broadcastState(room.getRoomNumber(), state); + } + } +} diff --git a/src/main/java/com/lottery/lottery/service/LocalizationService.java b/src/main/java/com/lottery/lottery/service/LocalizationService.java new file mode 100644 index 0000000..fe223b5 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/LocalizationService.java @@ -0,0 +1,84 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.config.LocaleConfig; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.security.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Optional; + +/** + * Service for localization and message retrieval. + * Gets user's language from UserContext and provides localized messages. + */ +@Service +@RequiredArgsConstructor +public class LocalizationService { + + private final MessageSource messageSource; + private final UserARepository userARepository; + + /** + * Gets the current user's locale based on their languageCode. + * Falls back to English if user not found or languageCode is invalid. + */ + public Locale getCurrentUserLocale() { + try { + UserA user = UserContext.get(); + if (user != null && user.getLanguageCode() != null) { + return LocaleConfig.languageCodeToLocale(user.getLanguageCode()); + } + } catch (Exception e) { + // UserContext might not be available in all contexts (e.g., scheduled tasks) + // Fall back to English + } + return Locale.ENGLISH; + } + + /** + * Gets a localized message for the current user. + * + * @param code Message code (e.g., "game.error.roomNotFound") + * @param args Optional arguments for message formatting + * @return Localized message string + */ + public String getMessage(String code, Object... args) { + Locale locale = getCurrentUserLocale(); + return messageSource.getMessage(code, args, code, locale); + } + + /** + * Gets a localized message for a specific user by userId. + * + * @param userId User ID + * @param code Message code + * @param args Optional arguments for message formatting + * @return Localized message string + */ + public String getMessageForUser(Integer userId, String code, Object... args) { + Locale locale = Locale.ENGLISH; // Default + Optional userOpt = userARepository.findById(userId); + if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) { + locale = LocaleConfig.languageCodeToLocale(userOpt.get().getLanguageCode()); + } + return messageSource.getMessage(code, args, code, locale); + } + + /** + * Gets a localized message for a specific locale. + * + * @param locale Locale to use + * @param code Message code + * @param args Optional arguments for message formatting + * @return Localized message string + */ + public String getMessage(Locale locale, String code, Object... args) { + return messageSource.getMessage(code, args, code, locale); + } +} + + diff --git a/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java b/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java new file mode 100644 index 0000000..8adac50 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/LotteryBotSchedulerService.java @@ -0,0 +1,223 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.exception.BetDecisionException; +import com.lottery.lottery.exception.GameException; +import com.lottery.lottery.model.GamePhase; +import com.lottery.lottery.model.GameRound; +import com.lottery.lottery.model.GameRoundParticipant; +import com.lottery.lottery.model.GameRoom; +import com.lottery.lottery.model.LotteryBotConfig; +import com.lottery.lottery.repository.GameRoomRepository; +import com.lottery.lottery.repository.GameRoundParticipantRepository; +import com.lottery.lottery.repository.GameRoundRepository; +import com.lottery.lottery.repository.LotteryBotConfigRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Scheduler that registers lottery bots into joinable rounds based on + * lottery_bot_configs (time window, room flags, active). Joins only when: + * - round has no participants for at least 1 minute, or + * - round has exactly one participant who has been waiting longer than 10 seconds. + * Does not join when 2+ participants. Uses BetDecisionService (persona + streak) for bet amount. + * Does not use /remotebet. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LotteryBotSchedulerService { + + private static final long TICKETS_TO_BIGINT = 1_000_000L; + /** Round empty (0 participants) for at least this long before a bot may join. */ + private static final long EMPTY_ROOM_THRESHOLD_SECONDS = 1L; + /** Single participant must be waiting at least this long before a bot may join. */ + private static final long ONE_PARTICIPANT_WAIT_THRESHOLD_SECONDS = 3L; + + private static final int BOT_HISTORY_SIZE = 10; + + private final GameRoomRepository gameRoomRepository; + private final GameRoundRepository gameRoundRepository; + private final GameRoundParticipantRepository participantRepository; + private final LotteryBotConfigRepository lotteryBotConfigRepository; + private final GameRoomService gameRoomService; + private final BetDecisionService betDecisionService; + private final BotBetHistoryService botBetHistoryService; + private final FeatureSwitchService featureSwitchService; + private final ConfigurationService configurationService; + + /** Per room: first time we observed no active round (for EMPTY_ROOM_THRESHOLD). Cleared when room has an active round again. */ + private final Map roomFirstSeenNoRound = new ConcurrentHashMap<>(); + + /** + * Every 15 seconds: for each room, if joinable and round state allows (0 participants >= 1 min, or 1 participant >= 10 sec), register eligible bots. + * Controlled by feature switch {@code lottery_bot_scheduler_enabled} (default on). + */ + @Scheduled(fixedDelayString = "${app.lottery-bot.schedule-fixed-delay-ms:5000}") + public void registerBotsForJoinableRooms() { + boolean featureOn = featureSwitchService.isLotteryBotSchedulerEnabled(); + List activeConfigs = lotteryBotConfigRepository.findAllByActiveTrue(); + + if (!featureOn) { + return; + } + if (activeConfigs.isEmpty()) { + return; + } + + LocalTime nowUtc = LocalTime.now(ZoneOffset.UTC); + + for (int roomNumber = 1; roomNumber <= 3; roomNumber++) { + Optional roomOpt = gameRoomRepository.findByRoomNumber(roomNumber); + if (roomOpt.isEmpty()) { + continue; + } + GameRoom room = roomOpt.get(); + if (room.getCurrentPhase() != GamePhase.WAITING && room.getCurrentPhase() != GamePhase.COUNTDOWN) { + continue; + } + + // 0 participants: no round exists yet (round is created on first join). Check no active round in WAITING/COUNTDOWN/SPINNING. + // 1 participant: we need an active round in WAITING phase only (not COUNTDOWN). + List roundsActive = gameRoundRepository.findMostRecentActiveRoundsByRoomId( + room.getId(), List.of(GamePhase.WAITING, GamePhase.COUNTDOWN, GamePhase.SPINNING), PageRequest.of(0, 1)); + + GameRound round = null; + List participants = List.of(); + int participantCount = 0; + boolean mayJoin = false; + + if (roundsActive.isEmpty()) { + // No active round → 0 participants. Enforce EMPTY_ROOM_THRESHOLD: only join after room has been empty for that long. + participantCount = 0; + Instant now = Instant.now(); + Instant firstSeenNoRound = roomFirstSeenNoRound.computeIfAbsent(roomNumber, k -> now); + mayJoin = !now.isBefore(firstSeenNoRound.plusSeconds(EMPTY_ROOM_THRESHOLD_SECONDS)); + if (!mayJoin) { + continue; + } + } else { + roomFirstSeenNoRound.remove(roomNumber); // room has an active round again, clear so next "no round" starts 30s from scratch + round = roundsActive.get(0); + participants = participantRepository.findByRoundId(round.getId()); + participantCount = participants.size(); + + int maxParticipantsBeforeBotJoin = configurationService.getMaxParticipantsBeforeBotJoin(); + if (participantCount > maxParticipantsBeforeBotJoin) { + continue; + } + + Instant now = Instant.now(); + if (participantCount == 0) { + mayJoin = round.getStartedAt() != null + && round.getStartedAt().plusSeconds(EMPTY_ROOM_THRESHOLD_SECONDS).isBefore(now); + } else { + // 1..N participants: only join if round is in WAITING (not COUNTDOWN) and oldest participant waited long enough + if (round.getPhase() != GamePhase.WAITING) { + continue; + } + Instant oldestJoined = participants.stream() + .map(GameRoundParticipant::getJoinedAt) + .min(Instant::compareTo) + .orElse(Instant.EPOCH); + mayJoin = oldestJoined.plusSeconds(ONE_PARTICIPANT_WAIT_THRESHOLD_SECONDS).isBefore(now); + } + if (!mayJoin) { + continue; + } + } + + // Shuffle so we don't always try the same bot first (e.g. by config id) + List configsToTry = new ArrayList<>(activeConfigs); + Collections.shuffle(configsToTry); + + for (LotteryBotConfig config : configsToTry) { + if (!isRoomEnabledForConfig(config, roomNumber)) { + continue; + } + if (!isCurrentTimeInWindow(nowUtc, config.getTimeUtcStart(), config.getTimeUtcEnd())) { + continue; + } + int userId = config.getUserId(); + if (gameRoomService.getCurrentUserBetInRoom(userId, roomNumber) > 0L) { + continue; + } + + GameRoomService.BetLimits limits = GameRoomService.getBetLimitsForRoom(roomNumber); + long roomMinTickets = limits.minBet() / TICKETS_TO_BIGINT; + long roomMaxTickets = limits.maxBet() / TICKETS_TO_BIGINT; + long botMinTickets = config.getBetMin() != null ? config.getBetMin() / TICKETS_TO_BIGINT : roomMinTickets; + long botMaxTickets = config.getBetMax() != null ? config.getBetMax() / TICKETS_TO_BIGINT : roomMaxTickets; + long currentRoundTotalBetTickets = participants.stream() + .mapToLong(p -> p.getBet() != null ? p.getBet() / TICKETS_TO_BIGINT : 0L) + .sum(); + var history = botBetHistoryService.getLastBetsAndResults(userId, BOT_HISTORY_SIZE); + + try { + long tickets = betDecisionService.decideBetAmountTickets( + BotBetContext.builder() + .roomNumber(roomNumber) + .roundId(round != null ? round.getId() : null) + .participantCount(participantCount) + .config(config) + .roomMinTickets(roomMinTickets) + .roomMaxTickets(roomMaxTickets) + .botMinTickets(botMinTickets) + .botMaxTickets(botMaxTickets) + .currentRoundTotalBetTickets(currentRoundTotalBetTickets) + .lastBets10(history.lastBets()) + .lastResults10(history.lastResults()) + .build()); + long betBigint = Math.max(1L, tickets) * TICKETS_TO_BIGINT; + betBigint = Math.max(limits.minBet(), Math.min(limits.maxBet(), betBigint)); + + gameRoomService.joinRoundWithResult(userId, roomNumber, betBigint, true); + log.info("Lottery bot registered: userId={}, room={}, betTickets={}", userId, roomNumber, tickets); + // Only one bot per room per run; next run will see updated participant count and enforce 7s wait for second bot + break; + } catch (BetDecisionException e) { + log.warn("Bot not registered (bet decision failed): userId={}, room={}, reason={}", userId, roomNumber, e.getMessage()); + } catch (GameException e) { + log.warn("Bot join skipped: userId={}, room={}, reason={}", userId, roomNumber, e.getUserMessage()); + } catch (Exception e) { + log.warn("Bot join failed: userId={}, room={}", userId, roomNumber, e); + } + } + } + } + + private static boolean isRoomEnabledForConfig(LotteryBotConfig config, int roomNumber) { + return switch (roomNumber) { + case 1 -> Boolean.TRUE.equals(config.getRoom1()); + case 2 -> Boolean.TRUE.equals(config.getRoom2()); + case 3 -> Boolean.TRUE.equals(config.getRoom3()); + default -> false; + }; + } + + /** + * Returns true if current UTC time is within [start, end]. + * Handles overnight window: e.g. start 22:00, end 06:00 → true if now >= 22:00 or now <= 06:00. + */ + private static boolean isCurrentTimeInWindow(LocalTime now, LocalTime start, LocalTime end) { + if (start == null || end == null) { + return false; + } + if (!start.isAfter(end)) { + return !now.isBefore(start) && !now.isAfter(end); + } + return !now.isBefore(start) || !now.isAfter(end); + } +} diff --git a/src/main/java/com/lottery/lottery/service/NotificationBroadcastService.java b/src/main/java/com/lottery/lottery/service/NotificationBroadcastService.java new file mode 100644 index 0000000..c4f5d84 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/NotificationBroadcastService.java @@ -0,0 +1,183 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.dto.TelegramSendResult; +import com.lottery.lottery.model.NotificationAudit; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.NotificationAuditRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Broadcasts a notification (text + optional image or video) to users via Telegram. + * Runs asynchronously; respects Telegram rate limit (~30 msg/s) and a stop flag. + * No @Transactional on the send loop; users are read in pages (500) to avoid memory issues. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationBroadcastService { + + /** Delay between sends to stay under 30 messages per second (Telegram limit). */ + private static final long DELAY_MS_BETWEEN_SENDS = 40; + private static final int PAGE_SIZE = 500; + + private final TelegramProperties telegramProperties; + private final TelegramBotApiService telegramBotApiService; + private final UserARepository userARepository; + private final NotificationAuditRepository notificationAuditRepository; + + private final AtomicBoolean stopRequested = new AtomicBoolean(false); + + /** + * Request running broadcast to stop. Checked between each user send. + */ + public void requestStop() { + stopRequested.set(true); + } + + /** Mini app URL for the optional inline button (same as in TelegramWebhookController). */ + private static final String MINI_APP_URL = "https://win-spin.live/auth"; + + /** + * Run broadcast asynchronously. Uses userIdFrom/userIdTo (internal user ids); if null, uses 1 and max id. + * Only one of imageUrl or videoUrl is used; video takes priority if both are set. + * If buttonText is non-empty, each message gets an inline button with that text opening the mini app. + * When ignoreBlocked is true, skips users whose latest notification_audit record has status FAILED (e.g. blocked the bot). + */ + @Async + public void runBroadcast(String message, String imageUrl, String videoUrl, + Integer userIdFrom, Integer userIdTo, String buttonText, Boolean ignoreBlocked) { + stopRequested.set(false); + String botToken = telegramProperties.getBotToken(); + if (!StringUtils.hasText(botToken)) { + log.error("Notification broadcast skipped: bot token not configured"); + return; + } + + int fromId = userIdFrom != null && userIdFrom > 0 ? userIdFrom : 1; + int toId = userIdTo != null && userIdTo > 0 ? userIdTo : userARepository.getMaxId(); + if (fromId > toId) { + log.warn("Notification broadcast: empty range fromId={} toId={}", fromId, toId); + return; + } + + boolean skipBlocked = Boolean.TRUE.equals(ignoreBlocked); + log.info("Notification broadcast started: user id range [{}, {}], ignoreBlocked={}", fromId, toId, skipBlocked); + int pageNumber = 0; + boolean hasNext = true; + long sent = 0; + long failed = 0; + long skippedBlocked = 0; + + while (hasNext && !stopRequested.get()) { + Pageable pageable = PageRequest.of(pageNumber, PAGE_SIZE); + Page page = userARepository.findByIdBetween(fromId, toId, pageable); + for (UserA user : page.getContent()) { + if (stopRequested.get()) break; + if (user.getTelegramId() == null) continue; + + if (skipBlocked) { + var latest = notificationAuditRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()); + if (latest.isPresent() && NotificationAudit.STATUS_FAILED.equals(latest.get().getStatus())) { + skippedBlocked++; + continue; + } + } + + TelegramSendResult result = sendOne(botToken, user.getTelegramId(), message, imageUrl, videoUrl, buttonText); + int statusCode = result.getStatusCode(); + boolean success = result.isSuccess(); + if (success) sent++; else failed++; + + NotificationAudit audit = NotificationAudit.builder() + .userId(user.getId()) + .status(success ? NotificationAudit.STATUS_SUCCESS : NotificationAudit.STATUS_FAILED) + .telegramStatusCode(statusCode != 0 ? statusCode : null) + .createdAt(Instant.now()) + .build(); + notificationAuditRepository.save(audit); + + try { + Thread.sleep(DELAY_MS_BETWEEN_SENDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Broadcast interrupted"); + return; + } + } + hasNext = page.hasNext(); + pageNumber++; + } + + if (stopRequested.get()) { + log.info("Notification broadcast stopped by request. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked); + } else { + log.info("Notification broadcast finished. Sent={}, Failed={}, Skipped(blocked)={}", sent, failed, skippedBlocked); + } + } + + /** + * Normalize message so Telegram renders it correctly: literal \n and \" (e.g. from admin panel paste) + * become real newline and quote. parse_mode=HTML is sent separately. + */ + private static String normalizeMessage(String message) { + if (message == null || message.isEmpty()) return message; + return message.replace("\\n", "\n").replace("\\\"", "\""); + } + + /** + * Send one notification to a chat: text only, or photo+caption, or video+caption (video over image if both set). + * If buttonText is non-empty, adds an inline keyboard with one button (Web App -> mini app). + */ + private TelegramSendResult sendOne(String botToken, Long chatId, String message, + String imageUrl, String videoUrl, String buttonText) { + String normalizedMessage = normalizeMessage(message); + String baseUrl = "https://api.telegram.org/bot" + botToken; + Map body = new HashMap<>(); + body.put("chat_id", chatId); + body.put("parse_mode", "HTML"); + String url; + if (StringUtils.hasText(videoUrl)) { + url = baseUrl + "/sendVideo"; + body.put("video", videoUrl); + if (StringUtils.hasText(normalizedMessage)) body.put("caption", normalizedMessage); + } else if (StringUtils.hasText(imageUrl)) { + url = baseUrl + "/sendPhoto"; + body.put("photo", imageUrl); + if (StringUtils.hasText(normalizedMessage)) body.put("caption", normalizedMessage); + } else { + url = baseUrl + "/sendMessage"; + body.put("text", StringUtils.hasText(normalizedMessage) ? normalizedMessage : "(no text)"); + } + + if (StringUtils.hasText(buttonText)) { + Map webApp = new HashMap<>(); + webApp.put("url", MINI_APP_URL); + Map button = new HashMap<>(); + button.put("text", buttonText.trim()); + button.put("web_app", webApp); + body.put("reply_markup", Map.of("inline_keyboard", java.util.List.of(java.util.List.of(button)))); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(body, headers); + return telegramBotApiService.postForBroadcast(url, entity); + } +} diff --git a/src/main/java/com/lottery/lottery/service/PaymentService.java b/src/main/java/com/lottery/lottery/service/PaymentService.java new file mode 100644 index 0000000..cdfae4f --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/PaymentService.java @@ -0,0 +1,449 @@ +package com.lottery.lottery.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.dto.CreatePaymentRequest; +import com.lottery.lottery.dto.DepositAddressApiRequest; +import com.lottery.lottery.dto.DepositAddressResponse; +import com.lottery.lottery.dto.DepositAddressResultDto; +import com.lottery.lottery.dto.PaymentInvoiceResponse; +import com.lottery.lottery.dto.PaymentWebhookRequest; +import com.lottery.lottery.model.CryptoDepositMethod; +import com.lottery.lottery.model.Payment; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.repository.CryptoDepositMethodRepository; +import com.lottery.lottery.repository.PaymentRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.util.IpUtils; +import com.lottery.lottery.util.TelegramTokenRedactor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +/** + * Service for handling Telegram Stars payment operations. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final CryptoDepositMethodRepository cryptoDepositMethodRepository; + private final TelegramProperties telegramProperties; + private final TransactionService transactionService; + private final LocalizationService localizationService; + private final CryptoDepositService cryptoDepositService; + private final RestTemplate restTemplate = new RestTemplate(); + + // Conversion rate: 1 Star = 9 Tickets, so in bigint: 1 Star = 9,000,000 (legacy) + private static final long STARS_TO_TICKETS_MULTIPLIER = 9_000_000L; + + // USD stored as decimal (e.g. 1.25). Tickets in DB: 1 ticket = 1_000_000; 1 USD = 1000 tickets -> ticketsAmount = round(usdAmount * 1_000_000_000) + private static final long TICKETS_DB_UNITS_PER_USD = 1_000_000_000L; + + private static final double MIN_USD = 0.01; + private static final double MAX_USD = 10_000.0; + + // Minimum and maximum stars amounts (legacy) + private static final int MIN_STARS = 50; + private static final int MAX_STARS = 100000; + + /** + * Creates a payment invoice for the user. + * Validates the stars amount and creates a pending payment record. + * + * @param userId The user ID + * @param request The payment request with stars amount + * @return Payment invoice response with order ID and amounts + */ + @Transactional + public PaymentInvoiceResponse createPaymentInvoice(Integer userId, CreatePaymentRequest request) { + Double usdAmountDouble = request.getUsdAmount(); + Integer starsAmount = request.getStarsAmount(); + + if (usdAmountDouble != null) { + validateUsd(usdAmountDouble); + BigDecimal usdAmount = BigDecimal.valueOf(usdAmountDouble).setScale(2, RoundingMode.UNNECESSARY); + long ticketsAmount = usdToTicketsAmount(usdAmountDouble); + + String orderId = generateOrderId(userId); + Payment payment = Payment.builder() + .userId(userId) + .orderId(orderId) + .starsAmount(0) + .usdAmount(usdAmount) + .ticketsAmount(ticketsAmount) + .status(Payment.PaymentStatus.PENDING) + .build(); + paymentRepository.save(payment); + log.info("Payment invoice created (crypto): orderId={}, userId={}, usdAmount={}, ticketsAmount={}", orderId, userId, usdAmount, ticketsAmount); + + return PaymentInvoiceResponse.builder() + .invoiceId(orderId) + .invoiceUrl(null) + .starsAmount(null) + .usdAmount(usdAmountDouble) + .ticketsAmount(ticketsAmount) + .build(); + } + + // Legacy payment flow no longer supported (crypto-only) + throw new IllegalArgumentException(localizationService.getMessage("payment.error.legacyNotSupported")); + } + + /** + * Creates an invoice link via Telegram Bot API for Stars payment. + * The invoice link can be used with tg.openInvoice() in the Mini App. + */ + private String createInvoiceLink(String orderId, Integer starsAmount) { + String botToken = telegramProperties.getBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Bot token is not configured"); + throw new IllegalStateException(localizationService.getMessage("payment.error.botTokenNotConfigured")); + } + + String url = "https://api.telegram.org/bot" + botToken + "/createInvoiceLink"; + // Do not log url or any string that may contain the bot token (use TelegramTokenRedactor if needed). + + // Build request body + var requestBody = new java.util.HashMap(); + requestBody.put("title", "Purchase Tickets"); + requestBody.put("description", String.format("Purchase %d Stars to receive tickets", starsAmount)); + requestBody.put("payload", orderId); // Order ID as payload + requestBody.put("provider_token", ""); // Empty for Stars + requestBody.put("currency", "XTR"); // XTR is the currency code for Stars + requestBody.put("prices", java.util.List.of( + java.util.Map.of("label", starsAmount + " Stars", "amount", starsAmount) + )); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + try { + log.debug("Creating invoice link: orderId={}, starsAmount={}", orderId, starsAmount); + ResponseEntity response = restTemplate.postForEntity( + url, + entity, + TelegramCreateInvoiceLinkResponse.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + String invoiceUrl = response.getBody().getResult(); + log.debug("Invoice link created: orderId={}", orderId); + return invoiceUrl; + } else { + log.error("Failed to create invoice link: orderId={}, status={}", orderId, response.getStatusCode()); + throw new IllegalStateException(localizationService.getMessage("payment.error.failedToCreateInvoice")); + } + } catch (Exception e) { + log.error("Error creating invoice link: orderId={}, error={}", orderId, TelegramTokenRedactor.redact(e.getMessage()), e); + throw new IllegalStateException(localizationService.getMessage("payment.error.failedToCreateInvoice") + ": " + e.getMessage(), e); + } + } + + /** + * Processes a payment webhook from Telegram bot. + * Validates the payment and credits the user's balance. + * Ensures idempotency by checking if payment was already processed. + * + * @param request The webhook request with payment details + * @return true if payment was successfully processed, false if already processed + */ + @Transactional + public boolean processPaymentWebhook(PaymentWebhookRequest request) { + String orderId = request.getOrderId(); + Long telegramUserId = request.getTelegramUserId(); + Integer starsAmount = request.getStarsAmount(); + + // Find payment by order ID + Payment payment = paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> { + log.info("Payment webhook rejected: orderId={}, reason=paymentNotFound", orderId); + return new IllegalArgumentException(localizationService.getMessage("payment.error.paymentNotFound", orderId)); + }); + + log.info("Payment webhook processing: orderId={}, paymentUserId={}, currentStatus={}, starsAmount={}", + orderId, payment.getUserId(), payment.getStatus(), starsAmount); + + // Check if payment was already processed (idempotency) + if (payment.getStatus() == Payment.PaymentStatus.COMPLETED) { + log.warn("Payment already processed: orderId={}", orderId); + return false; // Already processed + } + + // Validate payment details: Look up user by Telegram ID and compare internal user IDs + UserA user = userARepository.findByTelegramId(telegramUserId) + .orElseThrow(() -> { + log.info("Payment webhook rejected: orderId={}, reason=userNotFound, telegramUserId={}", orderId, telegramUserId); + return new IllegalArgumentException(localizationService.getMessage("payment.error.userNotFound", String.valueOf(telegramUserId))); + }); + + if (!payment.getUserId().equals(user.getId())) { + log.info("Payment webhook rejected: orderId={}, reason=userIdMismatch, paymentUserId={}, telegramUserId={} -> userId={}", + orderId, payment.getUserId(), telegramUserId, user.getId()); + throw new IllegalArgumentException(localizationService.getMessage("payment.error.userIdMismatch", + String.valueOf(payment.getUserId()), String.valueOf(user.getId()))); + } + + if (!payment.getStarsAmount().equals(starsAmount)) { + log.info("Payment webhook rejected: orderId={}, reason=starsAmountMismatch, expected={}, received={}", + orderId, payment.getStarsAmount(), starsAmount); + throw new IllegalArgumentException(localizationService.getMessage("payment.error.starsAmountMismatch", + String.valueOf(payment.getStarsAmount()), String.valueOf(starsAmount))); + } + + // Update payment status + payment.setStatus(Payment.PaymentStatus.COMPLETED); + payment.setTelegramPaymentChargeId(request.getTelegramPaymentChargeId()); + payment.setTelegramProviderPaymentChargeId(request.getTelegramProviderPaymentChargeId()); + payment.setCompletedAt(Instant.now()); + paymentRepository.save(payment); + + // Credit user balance + UserB userB = userBRepository.findById(payment.getUserId()) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + + Long ticketsAmount = payment.getTicketsAmount(); + userB.setBalanceA(userB.getBalanceA() + ticketsAmount); + + // Update deposit statistics + userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); + userB.setDepositCount(userB.getDepositCount() + 1); + // Reset total winnings since last deposit (withdrawal limit is based on this) + userB.setTotalWinAfterDeposit(0L); + + userBRepository.save(userB); + + // Create transaction record + try { + transactionService.createDepositTransaction(payment.getUserId(), ticketsAmount); + } catch (Exception e) { + log.error("Error creating deposit transaction: userId={}, amount={}", payment.getUserId(), ticketsAmount, e); + // Continue even if transaction record creation fails + } + + log.info("Payment completed: orderId={}, userId={}, ticketsAmount={}", + orderId, payment.getUserId(), ticketsAmount); + + return true; // Successfully processed + } + + /** + * Requests a crypto deposit address from the external API. Does not create any payment record. + * Used when user selects a payment method on Payment Options screen. + * + * @param userId current user id (from db_users_a) + * @param pid deposit method PID from deposit-methods + * @param usdAmount USD as decimal, e.g. 3.25 + * @return result with address, amountCoins, name, network, psId for Payment Confirmation screen + */ + public DepositAddressResultDto requestCryptoDepositAddress(Integer userId, Integer pid, Double usdAmount) { + if (pid == null) { + throw new IllegalArgumentException(localizationService.getMessage("payment.error.invalidPid")); + } + if (usdAmount == null) { + throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000")); + } + validateUsd(usdAmount); + + UserA user = userARepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("user.error.notFound"))); + + String userIp = formatIpForCryptoApi(user.getIp()); + DepositAddressApiRequest.UserData userData = DepositAddressApiRequest.UserData.builder() + .internalId(user.getId()) + .screenName(user.getScreenName() != null ? user.getScreenName() : "") + .tgUsername(user.getTelegramName() != null ? user.getTelegramName() : "") + .tgId(user.getTelegramId() != null ? String.valueOf(user.getTelegramId()) : "") + .countryCode(user.getCountryCode() != null ? user.getCountryCode() : "XX") + .deviceCode(user.getDeviceCode() != null ? user.getDeviceCode() : "XX") + .languageCode(user.getLanguageCode() != null ? user.getLanguageCode() : "XX") + .userIp(userIp != null ? userIp : "0.0.0.0") + .build(); + + DepositAddressApiRequest apiRequest = DepositAddressApiRequest.builder() + .pid(pid) + .amountUsd(usdAmount) + .userData(userData) + .build(); + + DepositAddressResponse apiResponse = cryptoDepositService.postDepositAddress(apiRequest); + if (apiResponse == null || apiResponse.getResult() == null) { + throw new IllegalStateException(localizationService.getMessage("payment.error.depositAddressFailed")); + } + if (apiResponse.getRequestInfo() != null && apiResponse.getRequestInfo().getErrorCode() != null + && apiResponse.getRequestInfo().getErrorCode() != 0) { + String msg = apiResponse.getRequestInfo().getErrorMessage() != null + ? apiResponse.getRequestInfo().getErrorMessage() + : localizationService.getMessage("payment.error.depositAddressFailed"); + throw new IllegalStateException(msg); + } + + DepositAddressResponse.Result result = apiResponse.getResult(); + + log.debug("Crypto deposit address requested: userId={}, psId={}, usdAmount={}", userId, result.getPsId(), usdAmount); + + int pidForLookup = result.getPsId() != null ? result.getPsId() : pid; + Double minAmount = cryptoDepositMethodRepository.findByPid(pidForLookup) + .map(CryptoDepositMethod::getMinDepositSum) + .map(BigDecimal::doubleValue) + .orElse(null); + + return DepositAddressResultDto.builder() + .address(result.getAddress()) + .amountCoins(result.getAmountCoins()) + .name(result.getName()) + .network(result.getNetwork()) + .psId(result.getPsId()) + .minAmount(minAmount) + .build(); + } + + /** + * Format stored IP (varbinary 16) for crypto API. IPv6 -> "0.0.0.0" per spec. + */ + private static String formatIpForCryptoApi(byte[] ipBytes) { + if (ipBytes == null || ipBytes.length == 0) { + return "0.0.0.0"; + } + if (ipBytes.length == 16) { + return "0.0.0.0"; + } + String s = IpUtils.bytesToIp(ipBytes); + return s != null ? s : "0.0.0.0"; + } + + /** + * Processes a deposit completion from 3rd party webhook (e.g. crypto payment provider). + * Creates a COMPLETED payment record, credits user balance, updates deposit stats, creates DEPOSIT transaction. + * Same effect as Telegram Stars webhook completion but for USD amount. + * + * @param userId internal user id (db_users_a.id) + * @param usdAmount USD as decimal, e.g. 1.45 (3rd party sends as number) + */ + @Transactional + public void processExternalDepositCompletion(Integer userId, Double usdAmount) { + if (userId == null) { + throw new IllegalArgumentException("user_id is required"); + } + if (usdAmount == null) { + throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000")); + } + validateUsd(usdAmount); + + String orderId = "ext-deposit-" + userId + "-" + UUID.randomUUID().toString(); + + if (!userARepository.existsById(userId)) { + throw new IllegalArgumentException(localizationService.getMessage("user.error.notFound")); + } + UserB userB = userBRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("user.error.balanceNotFound"))); + + long ticketsAmount = usdToTicketsAmount(usdAmount); + BigDecimal usdAmountBd = BigDecimal.valueOf(usdAmount).setScale(2, RoundingMode.HALF_UP); + Instant now = Instant.now(); + + Payment payment = Payment.builder() + .userId(userId) + .orderId(orderId) + .starsAmount(0) + .usdAmount(usdAmountBd) + .ticketsAmount(ticketsAmount) + .status(Payment.PaymentStatus.COMPLETED) + .completedAt(now) + .build(); + paymentRepository.save(payment); + + userB.setBalanceA(userB.getBalanceA() + ticketsAmount); + userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount); + userB.setDepositCount(userB.getDepositCount() + 1); + userB.setTotalWinAfterDeposit(0L); + userBRepository.save(userB); + + try { + transactionService.createDepositTransaction(userId, ticketsAmount); + } catch (Exception e) { + log.error("Error creating deposit transaction: userId={}, amount={}", userId, ticketsAmount, e); + } + + log.info("External deposit completed: orderId={}, userId={}, usdAmount={}, ticketsAmount={}", orderId, userId, usdAmountBd, ticketsAmount); + } + + /** USD range 2–10000 and at most 2 decimal places. */ + private void validateUsd(double usdAmount) { + if (usdAmount < MIN_USD || usdAmount > MAX_USD) { + throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000")); + } + double rounded = Math.round(usdAmount * 100) / 100.0; + if (Math.abs(usdAmount - rounded) > 1e-9) { + throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdMaxTwoDecimals")); + } + } + + /** Converts USD (e.g. 1.45) to tickets amount in DB. 1 USD = 1000 tickets; 1 ticket = 1_000_000 in DB. */ + private long usdToTicketsAmount(double usdAmount) { + return Math.round(usdAmount * TICKETS_DB_UNITS_PER_USD); + } + + /** + * Marks a payment as cancelled (e.g., user cancelled in Telegram UI). + * + * @param orderId The order ID + */ + @Transactional + public void cancelPayment(String orderId) { + Payment payment = paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("payment.error.paymentNotFound", orderId))); + + if (payment.getStatus() == Payment.PaymentStatus.COMPLETED) { + log.warn("Cannot cancel completed payment: orderId={}", orderId); + return; + } + + payment.setStatus(Payment.PaymentStatus.CANCELLED); + paymentRepository.save(payment); + + log.info("Payment cancelled: orderId={}, userId={}", orderId, payment.getUserId()); + } + + /** + * Generates a unique order ID for the payment. + * Format: payment-{userId}-{uuid} + */ + private String generateOrderId(Integer userId) { + return "payment-" + userId + "-" + UUID.randomUUID().toString(); + } + + // Response DTOs for Telegram API + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + private static class TelegramCreateInvoiceLinkResponse { + @JsonProperty("ok") + private Boolean ok; + + @JsonProperty("result") + private String result; + } +} + diff --git a/src/main/java/com/lottery/lottery/service/PayoutService.java b/src/main/java/com/lottery/lottery/service/PayoutService.java new file mode 100644 index 0000000..239873a --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/PayoutService.java @@ -0,0 +1,607 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.CreateCryptoWithdrawalRequest; +import com.lottery.lottery.dto.CreatePayoutRequest; +import com.lottery.lottery.dto.PayoutHistoryEntryDto; +import com.lottery.lottery.dto.PayoutResponse; +import com.lottery.lottery.dto.WithdrawalApiResponse; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.repository.PayoutRepository; +import com.lottery.lottery.repository.UserBRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.HttpStatusCodeException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; + +/** + * Service for handling payout operations. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PayoutService { + + private final PayoutRepository payoutRepository; + private final UserBRepository userBRepository; + private final TransactionService transactionService; + private final LocalizationService localizationService; + private final CryptoWithdrawalService cryptoWithdrawalService; + private final TaskService taskService; + private final FeatureSwitchService featureSwitchService; + + // Gift type to stars amount mapping + private static final Map GIFT_TO_STARS = new HashMap<>(); + + // Gift type to total (tickets in bigint format) mapping + private static final Map GIFT_TO_TOTAL = new HashMap<>(); + + // Conversion rate for STARS payout: 1 Star = 12 Tickets, so in bigint: 1 Star = 12,000,000 + private static final long STARS_TO_TOTAL_MULTIPLIER = 12_000_000L; + + // Allowed stars amounts for STARS payout (user must pick one of these) + private static final Set ALLOWED_STARS_AMOUNTS = Set.of( + 50, 75, 100, 150, 250, 350, 500, 750, 2500, 10000, 25000, 35000 + ); + + static { + GIFT_TO_STARS.put(Payout.GiftType.HEART, 15); + GIFT_TO_STARS.put(Payout.GiftType.BEAR, 15); + GIFT_TO_STARS.put(Payout.GiftType.GIFTBOX, 25); + GIFT_TO_STARS.put(Payout.GiftType.FLOWER, 25); + GIFT_TO_STARS.put(Payout.GiftType.CAKE, 50); + GIFT_TO_STARS.put(Payout.GiftType.BOUQUET, 50); + GIFT_TO_STARS.put(Payout.GiftType.ROCKET, 50); + GIFT_TO_STARS.put(Payout.GiftType.CHAMPAGNE, 50); + GIFT_TO_STARS.put(Payout.GiftType.CUP, 100); + GIFT_TO_STARS.put(Payout.GiftType.RING, 100); + GIFT_TO_STARS.put(Payout.GiftType.DIAMOND, 100); + + // Gift type to total mapping (tickets in bigint format: tickets * 1,000,000) + // All values multiplied by 10 (e.g., 18 → 180) + GIFT_TO_TOTAL.put(Payout.GiftType.HEART, 180_000_000L); // 180 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.BEAR, 180_000_000L); // 180 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.GIFTBOX, 280_000_000L); // 280 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.FLOWER, 280_000_000L); // 280 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.CAKE, 550_000_000L); // 550 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.BOUQUET, 550_000_000L); // 550 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.ROCKET, 550_000_000L); // 550 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.CHAMPAGNE, 550_000_000L); // 550 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.CUP, 1_100_000_000L); // 1100 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.RING, 1_100_000_000L); // 1100 tickets + GIFT_TO_TOTAL.put(Payout.GiftType.DIAMOND, 1_100_000_000L); // 1100 tickets + } + + /** + * Validates and creates a payout request. + * + * @param userId User ID + * @param request Payout request data + * @return Created payout + * @throws IllegalArgumentException if validation fails + */ + @Transactional + public Payout createPayout(Integer userId, CreatePayoutRequest request) { + // Require at least one deposit before allowing withdrawal + UserB userB = userBRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + if (Boolean.TRUE.equals(userB.getWithdrawalsDisabled())) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalsRestrictedForAccount")); + } + if (userB.getDepositTotal() == null || userB.getDepositTotal() == 0) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.depositRequiredToWithdraw")); + } + + // Validate username pattern + validateUsername(request.getUsername()); + + // Validate and normalize quantity + Integer quantity = request.getQuantity(); + if (quantity == null) { + quantity = 1; // Default to 1 if not provided + } + validateQuantity(quantity, request.getType()); + + // For STARS type, quantity must always be 1 + if ("STARS".equalsIgnoreCase(request.getType()) && quantity != 1) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.quantityMustBeOne")); + } + + // Validate and process based on type + Payout payout; + if ("STARS".equalsIgnoreCase(request.getType())) { + // Validate that total matches stars amount * conversion rate BEFORE creating payout + validateStarsTotal(request.getStarsAmount(), request.getTotal(), quantity); + payout = createStarsPayout(userId, request, quantity); + } else if ("GIFT".equalsIgnoreCase(request.getType())) { + // Validate that total matches the gift type's expected value * quantity BEFORE creating payout + validateGiftTotal(request.getGiftName(), request.getTotal(), quantity); + payout = createGiftPayout(userId, request, quantity); + } else { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidPayoutType")); + } + + // Withdrawal cannot exceed total winnings since last deposit + long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; + if (payout.getTotal() > maxWinAfterDeposit) { + long maxTickets = maxWinAfterDeposit / 1_000_000L; + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); + } + + // Validate tickets amount and user balance + validateTicketsAmount(userId, payout.getTotal()); + + // Deduct balance and reduce total_win_after_deposit + deductBalance(userId, payout.getTotal()); + + // Save payout + payout = payoutRepository.save(payout); + + log.info("Payout created: id={}, userId={}, type={}, total={}", + payout.getId(), userId, payout.getType(), payout.getTotal()); + + return payout; + } + + /** + * Creates a CRYPTO withdrawal: calls external crypto API, then on success creates payout and deducts balance. + * Uses in-memory lock so only one withdrawal per user at a time. Keeps deposit total and maxWinAfterDeposit validation. + */ + @Transactional + public Payout createCryptoPayout(Integer userId, CreateCryptoWithdrawalRequest request) { + UserB userB = userBRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + if (Boolean.TRUE.equals(userB.getWithdrawalsDisabled())) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalsRestrictedForAccount")); + } + if (userB.getDepositTotal() == null || userB.getDepositTotal() == 0) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.depositRequiredToWithdraw")); + } + + Long total = request.getTotal(); + validateTicketsAmount(userId, total); + validateCryptoWithdrawalMaxTwoDecimals(total); + + long maxWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; + if (total > maxWinAfterDeposit) { + long maxTickets = maxWinAfterDeposit / 1_000_000L; + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); + } + + if (payoutRepository.existsByUserIdAndStatus(userId, Payout.PayoutStatus.PROCESSING)) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress")); + } + + if (!cryptoWithdrawalService.tryAcquireWithdrawal(userId)) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalInProgress")); + } + + try { + // Lock UserB row before calling external API so concurrent requests (e.g. from another instance) block + // and cannot double-spend. We hold the lock until the transaction commits. + userB = userBRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + if (userB.getBalanceA() < total) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed", + String.valueOf(userB.getBalanceA() / 1_000_000.0), String.valueOf(total / 1_000_000.0))); + } + long maxWin = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; + if (total > maxWin) { + long maxTickets = maxWin / 1_000_000L; + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawExceedsWinAfterDeposit", String.valueOf(maxTickets))); + } + + double amountUsd = total / 1_000_000_000.0; + boolean noWithdrawalsYet = (userB.getWithdrawCount() != null ? userB.getWithdrawCount() : 0) == 0; + boolean manualPay = featureSwitchService.isManualPayForAllPayoutsEnabled() + || (noWithdrawalsYet && taskService.hasCompletedReferral50Or100(userId)); + WithdrawalApiResponse response; + try { + response = cryptoWithdrawalService.postWithdrawal(userId, request.getPid(), request.getWallet().trim(), amountUsd, manualPay); + } catch (HttpStatusCodeException e) { + log.warn("Crypto withdrawal API HTTP error: userId={}, status={}, body={}", userId, e.getStatusCode(), e.getResponseBodyAsString()); + throw new IllegalStateException(localizationService.getMessage("withdraw.error.tryLater")); + } + + if (response == null) { + log.warn("Crypto withdrawal API returned null body: userId={}", userId); + throw new IllegalStateException(localizationService.getMessage("withdraw.error.tryLater")); + } + if (response.getResult() != null && response.getResult().getError() != null) { + WithdrawalApiResponse.ResultError err = response.getResult().getError(); + int code = err.getErrorCode() != null ? err.getErrorCode() : -1; + if (code == 1) { + throw new IllegalArgumentException(localizationService.getMessage("withdraw.error.walletInvalidFormat")); + } + log.warn("Crypto withdrawal API business error: userId={}, error_code={}, error_text={}", userId, code, err.getErrorText()); + throw new IllegalStateException(localizationService.getMessage("withdraw.error.tryLater")); + } + if (response.getResult() == null || response.getResult().getPayment() == null) { + log.warn("Crypto withdrawal API success but no payment in result: userId={}", userId); + throw new IllegalStateException(localizationService.getMessage("withdraw.error.tryLater")); + } + + WithdrawalApiResponse.Payment payment = response.getResult().getPayment(); + if (payment.getPaymentId() == null) { + log.warn("Crypto withdrawal API returned payment with null payment_id: userId={}", userId); + throw new IllegalStateException(localizationService.getMessage("withdraw.error.tryLater")); + } + BigDecimal usdAmount = BigDecimal.valueOf(total / 1_000_000_000.0).setScale(2, RoundingMode.DOWN); + + Payout payout = Payout.builder() + .userId(userId) + .username("") + .wallet(request.getWallet().trim()) + .type(Payout.PayoutType.CRYPTO) + .giftName(null) + .cryptoName(payment.getTicker() != null ? payment.getTicker() : "") + .total(total) + .starsAmount(0) + .usdAmount(usdAmount) + .amountCoins(payment.getAmountCoins()) + .commissionCoins(payment.getComissionCoins()) + .amountToSend(payment.getAmountToSend()) + .paymentId(payment.getPaymentId()) + .quantity(1) + .status(Payout.PayoutStatus.PROCESSING) + .build(); + + applyDeductToUserB(userB, userId, total); + payout = payoutRepository.save(payout); + log.debug("Crypto payout created: id={}, userId={}, pid={}, total={}", payout.getId(), userId, request.getPid(), total); + return payout; + } finally { + cryptoWithdrawalService.releaseWithdrawal(userId); + } + } + + /** + * Marks a payout as COMPLETED: sets status, resolvedAt, updates user withdrawTotal and withdrawCount. + * Allowed only when payout status is PROCESSING or WAITING (e.g. from admin or cron sync). + */ + @Transactional + public void markPayoutCompleted(Long payoutId) { + Payout payout = payoutRepository.findById(payoutId) + .orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("payout.error.notFound", String.valueOf(payoutId)))); + if (payout.getStatus() != Payout.PayoutStatus.PROCESSING && payout.getStatus() != Payout.PayoutStatus.WAITING) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.onlyProcessingCanComplete", payout.getStatus().name())); + } + Instant now = Instant.now(); + payout.setStatus(Payout.PayoutStatus.COMPLETED); + payout.setResolvedAt(now); + payout.setUpdatedAt(now); + payoutRepository.save(payout); + + UserB userB = userBRepository.findById(payout.getUserId()) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + userB.setWithdrawTotal(userB.getWithdrawTotal() + payout.getTotal()); + userB.setWithdrawCount(userB.getWithdrawCount() + 1); + userBRepository.save(userB); + log.info("Payout completed: id={}, userId={}", payoutId, payout.getUserId()); + } + + /** + * Marks a payout as CANCELLED: sets status, resolvedAt, refunds balance, creates cancellation transaction. + * Allowed only when payout status is PROCESSING or WAITING (e.g. from admin or cron sync). + */ + @Transactional + public void markPayoutCancelled(Long payoutId) { + Payout payout = payoutRepository.findById(payoutId) + .orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("payout.error.notFound", String.valueOf(payoutId)))); + if (payout.getStatus() != Payout.PayoutStatus.PROCESSING && payout.getStatus() != Payout.PayoutStatus.WAITING) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.onlyProcessingCanCancel", payout.getStatus().name())); + } + Instant now = Instant.now(); + payout.setStatus(Payout.PayoutStatus.CANCELLED); + payout.setResolvedAt(now); + payout.setUpdatedAt(now); + payoutRepository.save(payout); + + UserB userB = userBRepository.findById(payout.getUserId()) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + userB.setBalanceA(userB.getBalanceA() + payout.getTotal()); + userBRepository.save(userB); + + transactionService.createCancellationOfWithdrawalTransaction(payout.getUserId(), payout.getTotal(), now); + log.info("Payout cancelled: id={}, userId={}", payoutId, payout.getUserId()); + } + + /** + * Creates a STARS type payout. + * Stars amount must be one of the allowed values; total is calculated as stars * 12 (in bigint). + */ + private Payout createStarsPayout(Integer userId, CreatePayoutRequest request, Integer quantity) { + Integer starsAmount = request.getStarsAmount(); + if (starsAmount == null || starsAmount < 0) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.starsAmountNonNegative")); + } + if (!ALLOWED_STARS_AMOUNTS.contains(starsAmount)) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.starsAmountNotAllowed")); + } + + // Calculate expected total from stars amount (quantity is always 1 for STARS) + long expectedTotal = (long) starsAmount * STARS_TO_TOTAL_MULTIPLIER; + + return Payout.builder() + .userId(userId) + .username(request.getUsername()) + .type(Payout.PayoutType.STARS) + .giftName(null) + .total(expectedTotal) // Use calculated total, not from request + .starsAmount(request.getStarsAmount()) + .quantity(1) // Always 1 for STARS + .status(Payout.PayoutStatus.PROCESSING) + .build(); + } + + /** + * Validates that stars amount is allowed and total matches stars * 12 (in bigint). + * Quantity is always 1 for STARS type. + */ + private void validateStarsTotal(Integer starsAmount, Long total, Integer quantity) { + if (starsAmount == null || total == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.starsAmountAndTotalRequired")); + } + if (!ALLOWED_STARS_AMOUNTS.contains(starsAmount)) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.starsAmountNotAllowed")); + } + if (quantity == null || quantity != 1) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.quantityMustBeOne")); + } + + long expectedTotal = (long) starsAmount * STARS_TO_TOTAL_MULTIPLIER; + if (!total.equals(expectedTotal)) { + throw new IllegalArgumentException( + localizationService.getMessage("payout.error.totalMustEqualStars", + String.valueOf(expectedTotal), String.valueOf(total))); + } + } + + /** + * Creates a GIFT type payout. + */ + private Payout createGiftPayout(Integer userId, CreatePayoutRequest request, Integer quantity) { + // Validate gift name + if (request.getGiftName() == null || request.getGiftName().isEmpty()) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.giftNameRequired")); + } + + Payout.GiftType giftType; + try { + giftType = Payout.GiftType.valueOf(request.getGiftName().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidGiftName", request.getGiftName())); + } + + // Calculate stars amount from gift type + Integer starsAmount = GIFT_TO_STARS.get(giftType); + if (starsAmount == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.starsAmountNotDefined", giftType.name())); + } + + // Get expected total for this gift type (single gift) + Long singleGiftTotal = GIFT_TO_TOTAL.get(giftType); + if (singleGiftTotal == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.totalNotDefined", giftType.name())); + } + + // Calculate total with quantity + long expectedTotal = singleGiftTotal * quantity; + + return Payout.builder() + .userId(userId) + .username(request.getUsername()) + .type(Payout.PayoutType.GIFT) + .giftName(giftType) + .total(expectedTotal) // Use calculated total (single gift * quantity) + .starsAmount(starsAmount * quantity) // Stars amount also multiplied by quantity + .quantity(quantity) + .status(Payout.PayoutStatus.PROCESSING) + .build(); + } + + /** + * Validates that the total matches the expected value for the gift type * quantity. + */ + private void validateGiftTotal(String giftName, Long total, Integer quantity) { + if (giftName == null || giftName.isEmpty() || total == null || quantity == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.giftNameTotalQuantityRequired")); + } + + Payout.GiftType giftType; + try { + giftType = Payout.GiftType.valueOf(giftName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.invalidGiftName", giftName)); + } + + Long singleGiftTotal = GIFT_TO_TOTAL.get(giftType); + if (singleGiftTotal == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.totalNotDefined", giftType.name())); + } + + long expectedTotal = singleGiftTotal * quantity; + + if (!total.equals(expectedTotal)) { + throw new IllegalArgumentException( + localizationService.getMessage("payout.error.totalForGiftMismatch", + giftType.name(), String.valueOf(quantity), String.valueOf(expectedTotal), String.valueOf(total))); + } + } + + /** + * Validates quantity is between 1 and 100. + */ + private void validateQuantity(Integer quantity, String type) { + if (quantity == null) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.quantityRequired")); + } + + if (quantity < 1 || quantity > 100) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.quantityRange")); + } + } + + /** + * Validates username pattern. + */ + private void validateUsername(String username) { + if (username == null || username.isEmpty()) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.usernameRequired")); + } + + // Username should start with @ followed by at least 1 English letter + if (!username.matches("^@[a-zA-Z].*")) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.usernamePattern")); + } + } + + /** Units per 0.01 tickets when 1 ticket = 1_000_000. Ensures withdrawal amount has at most 2 decimal places. */ + private static final long CRYPTO_WITHDRAWAL_MIN_STEP = 10_000L; + + /** + * Validates that crypto withdrawal total has at most 2 decimal places (e.g. 125.25 allowed, 125.125 not). + * Total is in units where 1 ticket = 1_000_000, so 0.01 tickets = 10_000. + */ + private void validateCryptoWithdrawalMaxTwoDecimals(Long total) { + if (total != null && total % CRYPTO_WITHDRAWAL_MIN_STEP != 0) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.withdrawalAmountMaxTwoDecimals")); + } + } + + /** + * Validates tickets amount and user balance. + */ + private void validateTicketsAmount(Integer userId, Long total) { + if (total == null || total < 0) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.ticketsAmountNonNegative")); + } + + Optional userBOpt = userBRepository.findById(userId); + if (userBOpt.isEmpty()) { + throw new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound")); + } + + UserB userB = userBOpt.get(); + if (userB.getBalanceA() < total) { + throw new IllegalArgumentException(localizationService.getMessage("payout.error.insufficientBalanceDetailed", + String.valueOf(userB.getBalanceA() / 1_000_000.0), String.valueOf(total / 1_000_000.0))); + } + } + + /** + * Deducts balance from user's balance_a. Uses pessimistic lock (SELECT FOR UPDATE) on the UserB row + * so that concurrent withdrawal requests for the same user are serialized and cannot double-spend. + */ + private void deductBalance(Integer userId, Long total) { + UserB userB = userBRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound"))); + applyDeductToUserB(userB, userId, total); + } + + /** + * Applies balance and totalWinAfterDeposit deduction to an already-loaded (and locked) UserB. + * Caller must hold a pessimistic lock on the UserB row (e.g. from findByIdForUpdate). + */ + private void applyDeductToUserB(UserB userB, Integer userId, Long total) { + if (userB.getBalanceA() < total) { + throw new IllegalStateException(localizationService.getMessage("payout.error.insufficientBalance")); + } + userB.setBalanceA(userB.getBalanceA() - total); + long currentWinAfterDeposit = userB.getTotalWinAfterDeposit() != null ? userB.getTotalWinAfterDeposit() : 0L; + userB.setTotalWinAfterDeposit(Math.max(0L, currentWinAfterDeposit - total)); + userBRepository.save(userB); + + try { + transactionService.createWithdrawalTransaction(userId, total); + } catch (Exception e) { + log.error("Error creating withdrawal transaction: userId={}, amount={}", userId, total, e); + } + log.info("Balance deducted for payout: userId={}, amount={}, newBalance={}", userId, total, userB.getBalanceA()); + } + + /** + * Converts Payout entity to PayoutResponse DTO. + */ + public PayoutResponse toResponse(Payout payout) { + return PayoutResponse.builder() + .id(payout.getId()) + .username(payout.getUsername()) + .type(payout.getType().name()) + .giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null) + .total(payout.getTotal()) + .starsAmount(payout.getStarsAmount()) + .quantity(payout.getQuantity()) + .status(payout.getStatus().name()) + .createdAt(payout.getCreatedAt() != null ? payout.getCreatedAt().toEpochMilli() : null) + .resolvedAt(payout.getResolvedAt() != null ? payout.getResolvedAt().toEpochMilli() : null) + .build(); + } + + /** + * Gets the last 20 payouts for the current user as history entries. + * Uses index for optimal performance. + * + * @param userId User ID + * @param timezone Optional timezone (e.g., "Europe/London"). If null, uses UTC. + * @param languageCode User's language code for localization (e.g., "EN", "RU") + */ + public List getUserPayoutHistory(Integer userId, String timezone, String languageCode) { + List payouts = payoutRepository.findLastPayoutsByUserId( + userId, + PageRequest.of(0, 20) + ); + + // Determine timezone to use + ZoneId zoneId; + try { + zoneId = (timezone != null && !timezone.trim().isEmpty()) + ? ZoneId.of(timezone) + : ZoneId.of("UTC"); + } catch (Exception e) { + // Invalid timezone, fallback to UTC + zoneId = ZoneId.of("UTC"); + } + + // Get localized "at" word + String atWord = localizationService.getMessage("dateTime.at", languageCode); + if (atWord == null || atWord.isEmpty()) { + atWord = "at"; // Fallback to English + } + + // Create formatter with localized "at" word + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") + .withZone(zoneId); + + return payouts.stream() + .map(payout -> { + // Format date + String date = dateFormatter.format(payout.getCreatedAt()); + + // Status + String status = payout.getStatus().name(); + + return PayoutHistoryEntryDto.builder() + .amount(payout.getTotal()) // Return raw bigint value + .date(date) + .status(status) + .build(); + }) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java b/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java new file mode 100644 index 0000000..8012a6d --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/PersonaBetDecisionService.java @@ -0,0 +1,137 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.exception.BetDecisionException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Bet decision using persona + loss streak and zone logic (no external API). + * Zones are defined as percentages of the room/bot range [min, max]; actual ticket + * bounds are computed from (minPct, maxPct) so the same behaviour applies for any range. + */ +@Slf4j +@Service +public class PersonaBetDecisionService implements BetDecisionService { + + private static final String LOSS = "L"; + + @Override + public long decideBetAmountTickets(BotBetContext context) { + long botMin = context.getBotMinTickets(); + long botMax = context.getBotMaxTickets(); + if (botMin <= 0 || botMax < botMin) { + throw new BetDecisionException("Invalid bot range [" + botMin + ", " + botMax + "]"); + } + + String persona = context.getConfig() != null && context.getConfig().getPersona() != null + ? context.getConfig().getPersona().trim().toLowerCase() : "balanced"; + List results = context.getLastResults10(); + if (results == null) results = List.of(); + + int streak = countConsecutiveLossesFromEnd(results); + long[] zone = getZoneTicketsForPersonaAndStreak(persona, streak, botMin, botMax); + long zoneMin = zone[0]; + long zoneMax = zone[1]; + + // Clamp zone to bot range + long clampedMin = Math.max(zoneMin, botMin); + long clampedMax = Math.min(zoneMax, botMax); + if (clampedMin > clampedMax) { + clampedMin = botMin; + clampedMax = botMax; + } + + // Pick random in [clampedMin, clampedMax] for variety + long rawBet = clampedMin == clampedMax ? clampedMin + : clampedMin + ThreadLocalRandom.current().nextLong(clampedMax - clampedMin + 1); + + long step = getStep(botMin); + long bet = roundToStep(rawBet, botMin, botMax, step); + + log.debug("Persona bet decision: persona={}, streak={}, zone=[{}, {}] -> {} tickets", persona, streak, zoneMin, zoneMax, bet); + return bet; + } + + /** Step 1 if min < 10, step 10 if 10 <= min < 1000, step 100 if min >= 1000. */ + private static long getStep(long botMin) { + if (botMin >= 1000) return 100; + if (botMin >= 10) return 10; + return 1; + } + + /** Count consecutive L from the end of last results (oldest → newest). */ + private static int countConsecutiveLossesFromEnd(List results) { + int count = 0; + for (int i = results.size() - 1; i >= 0; i--) { + if (LOSS.equals(results.get(i))) count++; + else break; + } + return count; + } + + /** + * Zone rule: when loss streak >= streakThreshold, use [minPct, maxPct] of range (0 = min, 100 = max). + * Rules are evaluated in descending streak order (highest threshold first). + */ + private static final class ZoneRule { + final int streakThreshold; + final double minPct; + final double maxPct; + + ZoneRule(int streakThreshold, double minPct, double maxPct) { + this.streakThreshold = streakThreshold; + this.minPct = minPct; + this.maxPct = maxPct; + } + } + + // Conservative: usually 1–10%, streak 5 → 19–24%, streak 7 → 39–50% + private static final List CONSERVATIVE_RULES = List.of( + new ZoneRule(7, 45, 68), + new ZoneRule(5, 17, 28), + new ZoneRule(0, 1, 10) + ); + // Balanced: usually 5–15%, streak 3 → 29–39%, streak 5 → 59–69% + private static final List BALANCED_RULES = List.of( + new ZoneRule(5, 64, 79), + new ZoneRule(3, 25, 37), + new ZoneRule(0, 2, 12) + ); + // Aggressive: usually 15–25%, streak 2 → 39–50%, streak 3 → 79–100% + private static final List AGGRESSIVE_RULES = List.of( + new ZoneRule(3, 79, 100), + new ZoneRule(2, 33, 42), + new ZoneRule(0, 5, 15) + ); + + /** Returns [zoneMinTickets, zoneMaxTickets] from percentage-of-range rules for persona and streak. */ + private static long[] getZoneTicketsForPersonaAndStreak(String persona, int streak, long rangeMin, long rangeMax) { + List rules = switch (persona) { + case "conservative" -> CONSERVATIVE_RULES; + case "aggressive" -> AGGRESSIVE_RULES; + default -> BALANCED_RULES; + }; + ZoneRule rule = rules.stream() + .filter(r -> streak >= r.streakThreshold) + .findFirst() + .orElse(rules.get(rules.size() - 1)); + long range = rangeMax - rangeMin; + long zoneMin = rangeMin + Math.round(range * rule.minPct / 100.0); + long zoneMax = rangeMin + Math.round(range * rule.maxPct / 100.0); + zoneMin = Math.max(rangeMin, Math.min(zoneMin, rangeMax)); + zoneMax = Math.max(rangeMin, Math.min(zoneMax, rangeMax)); + if (zoneMin > zoneMax) zoneMin = zoneMax; + return new long[]{zoneMin, zoneMax}; + } + + /** Round value to nearest valid step in [min, max]. */ + private static long roundToStep(long value, long min, long max, long step) { + long offset = value - min; + long steps = Math.round((double) offset / step); + long rounded = min + steps * step; + return Math.max(min, Math.min(max, rounded)); + } +} diff --git a/src/main/java/com/lottery/lottery/service/PromotionService.java b/src/main/java/com/lottery/lottery/service/PromotionService.java new file mode 100644 index 0000000..efe8132 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/PromotionService.java @@ -0,0 +1,121 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.Promotion; +import com.lottery.lottery.model.Promotion.PromotionType; +import com.lottery.lottery.model.PromotionUser; +import com.lottery.lottery.repository.PromotionRepository; +import com.lottery.lottery.repository.PromotionUserRepository; +import com.lottery.lottery.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PromotionService { + + /** 1 ticket = 1_000_000 in balance/bet storage; points are stored as ticket count with 2 decimals. */ + private static final long TICKETS_MULTIPLIER = 1_000_000L; + + private final PromotionRepository promotionRepository; + private final PromotionUserRepository promotionUserRepository; + private final UserDRepository userDRepository; + + /** + * Add net win points to the user for all currently active NET_WIN promotions. + * Called when a user wins a round: netWinBigint = payout - winnerBet (in bigint). + * Points are stored as ticket count with 2 decimal places (e.g. 82.25 tickets → points += 82.25). + */ + @Transactional + public void addNetWinPoints(int userId, long netWinBigint) { + if (netWinBigint <= 0) { + return; + } + BigDecimal pointsToAdd = BigDecimal.valueOf(netWinBigint) + .divide(BigDecimal.valueOf(TICKETS_MULTIPLIER), 2, RoundingMode.HALF_UP); + if (pointsToAdd.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + Instant now = Instant.now(); + List activePromos = promotionRepository.findActiveByTypeAndTimeRange(PromotionType.NET_WIN, now); + for (Promotion promo : activePromos) { + addPointsToPromoUser(promo.getId(), userId, pointsToAdd, now); + } + } + + /** + * Add net win points for all active NET_WIN_MAX_BET promotions (only when winner made max bet in the room). + * Same points logic as NET_WIN: netWinBigint = payout - winnerBet. + */ + @Transactional + public void addNetWinMaxBetPoints(int userId, long netWinBigint) { + if (netWinBigint <= 0) { + return; + } + BigDecimal pointsToAdd = BigDecimal.valueOf(netWinBigint) + .divide(BigDecimal.valueOf(TICKETS_MULTIPLIER), 2, RoundingMode.HALF_UP); + if (pointsToAdd.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + Instant now = Instant.now(); + List activePromos = promotionRepository.findActiveByTypeAndTimeRange(PromotionType.NET_WIN_MAX_BET, now); + for (Promotion promo : activePromos) { + addPointsToPromoUser(promo.getId(), userId, pointsToAdd, now); + } + } + + /** + * Add 1 point for the referer (level 1) in active REF_COUNT promotions where the referral's + * registration date falls within the promotion timeframe. + * Called when a referral completes their first round (rounds_played was 0 before this round). + * Only promotions for which referralRegistrationTime is between startTime and endTime (inclusive) get the point. + * Masters (users where userId = masterId) are excluded and never receive REF_COUNT points. + */ + @Transactional + public void addRefCountPoints(Integer refererUserId, Instant referralRegistrationTime) { + if (refererUserId == null || refererUserId <= 0 || referralRegistrationTime == null) { + return; + } + // Exclude masters: do not add REF_COUNT points for referrers who are masters (id = masterId and masterId > 0) + if (userDRepository.findById(refererUserId) + .filter(ud -> ud.getMasterId() != null && ud.getMasterId() > 0 && refererUserId.equals(ud.getMasterId())) + .isPresent()) { + return; + } + BigDecimal onePoint = BigDecimal.ONE; + Instant now = Instant.now(); + List activePromos = promotionRepository.findActiveByTypeAndTimeRange(PromotionType.REF_COUNT, now); + for (Promotion promo : activePromos) { + // Only add point if the referral was registered during this promotion's timeframe + if (!referralRegistrationTime.isBefore(promo.getStartTime()) && !referralRegistrationTime.isAfter(promo.getEndTime())) { + addPointsToPromoUser(promo.getId(), refererUserId, onePoint, now); + } + } + } + + private void addPointsToPromoUser(int promoId, int userId, BigDecimal pointsToAdd, Instant now) { + promotionUserRepository.findByPromoIdAndUserId(promoId, userId) + .ifPresentOrElse( + pu -> { + pu.setPoints(pu.getPoints().add(pointsToAdd)); + promotionUserRepository.save(pu); + }, + () -> { + PromotionUser newPu = PromotionUser.builder() + .promoId(promoId) + .userId(userId) + .points(pointsToAdd) + .updatedAt(now) + .build(); + promotionUserRepository.save(newPu); + } + ); + } +} diff --git a/src/main/java/com/lottery/lottery/service/PublicPromotionService.java b/src/main/java/com/lottery/lottery/service/PublicPromotionService.java new file mode 100644 index 0000000..4b63ba1 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/PublicPromotionService.java @@ -0,0 +1,135 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.PromotionDetailDto; +import com.lottery.lottery.dto.PromotionLeaderboardEntryDto; +import com.lottery.lottery.dto.PromotionListItemDto; +import com.lottery.lottery.model.Promotion; +import com.lottery.lottery.model.Promotion.PromotionStatus; +import com.lottery.lottery.model.PromotionReward; +import com.lottery.lottery.model.PromotionUser; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.PromotionRepository; +import com.lottery.lottery.repository.PromotionRewardRepository; +import com.lottery.lottery.repository.PromotionUserRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.security.UserContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PublicPromotionService { + + private static final int LEADERBOARD_SIZE = 30; + private static final long TICKETS_MULTIPLIER = 1_000_000L; + + private final PromotionRepository promotionRepository; + private final PromotionUserRepository promotionUserRepository; + private final PromotionRewardRepository promotionRewardRepository; + private final UserARepository userARepository; + + /** + * List promotions for app: ACTIVE and FINISHED only (exclude INACTIVE and PLANNED). + */ + @Transactional(readOnly = true) + public List listForApp() { + List allowed = List.of(PromotionStatus.ACTIVE, PromotionStatus.FINISHED); + List list = promotionRepository.findByStatusInOrderByStartTimeDesc(allowed); + return list.stream().map(this::toListItemDto).collect(Collectors.toList()); + } + + /** + * Get promotion detail for app. Returns empty if not found or status is INACTIVE or PLANNED. + */ + @Transactional(readOnly = true) + public Optional getDetailForApp(int promotionId) { + Optional promoOpt = promotionRepository.findById(promotionId); + if (promoOpt.isEmpty()) { + return Optional.empty(); + } + Promotion promo = promoOpt.get(); + if (promo.getStatus() == PromotionStatus.INACTIVE || promo.getStatus() == PromotionStatus.PLANNED) { + return Optional.empty(); + } + + int userId = UserContext.get().getId(); + + List topUsers = promotionUserRepository.findByPromoId( + promo.getId(), + PageRequest.of(0, LEADERBOARD_SIZE, Sort.by(Sort.Direction.DESC, "points")) + ).getContent(); + + List rewards = promotionRewardRepository.findByPromotionIdOrderByPlaceAsc(promo.getId()); + Map rewardTicketsByPlace = rewards.stream() + .collect(Collectors.toMap(PromotionReward::getPlace, r -> r.getReward() / TICKETS_MULTIPLIER)); + + List userIds = topUsers.stream().map(PromotionUser::getUserId).distinct().toList(); + Map screenNameByUserId = userARepository.findAllById(userIds).stream() + .collect(Collectors.toMap(UserA::getId, u -> u.getScreenName() != null ? u.getScreenName() : "-")); + + List leaderboard = new ArrayList<>(); + for (int i = 0; i < LEADERBOARD_SIZE; i++) { + int place = i + 1; + String screenName = "-"; + BigDecimal points = BigDecimal.ZERO; + Long rewardTickets = rewardTicketsByPlace.get(place); + if (i < topUsers.size()) { + PromotionUser pu = topUsers.get(i); + screenName = screenNameByUserId.getOrDefault(pu.getUserId(), "-"); + points = pu.getPoints() != null ? pu.getPoints() : BigDecimal.ZERO; + } + leaderboard.add(PromotionLeaderboardEntryDto.builder() + .place(place) + .screenName(screenName) + .points(points) + .rewardTickets(rewardTickets) + .build()); + } + + int totalParticipants = (int) promotionUserRepository.countByPromoId(promo.getId()); + Optional currentUserEntry = promotionUserRepository.findByPromoIdAndUserId(promo.getId(), userId); + BigDecimal userPoints = currentUserEntry.map(pu -> pu.getPoints() != null ? pu.getPoints() : BigDecimal.ZERO).orElse(BigDecimal.ZERO); + + int userPosition = 0; + if (currentUserEntry.isPresent()) { + BigDecimal myPoints = currentUserEntry.get().getPoints(); + long countBetter = promotionUserRepository.countByPromoIdAndPointsGreaterThan(promo.getId(), myPoints != null ? myPoints : BigDecimal.ZERO); + userPosition = (int) countBetter + 1; + } + + PromotionDetailDto dto = PromotionDetailDto.builder() + .id(promo.getId()) + .type(promo.getType().name()) + .status(promo.getStatus().name()) + .startTime(promo.getStartTime()) + .endTime(promo.getEndTime()) + .totalReward(promo.getTotalReward()) + .leaderboard(leaderboard) + .userPosition(userPosition) + .userTotal(totalParticipants) + .userPoints(userPoints) + .build(); + return Optional.of(dto); + } + + private PromotionListItemDto toListItemDto(Promotion p) { + return PromotionListItemDto.builder() + .id(p.getId()) + .type(p.getType().name()) + .status(p.getStatus().name()) + .startTime(p.getStartTime()) + .endTime(p.getEndTime()) + .totalReward(p.getTotalReward()) + .build(); + } +} diff --git a/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java b/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java new file mode 100644 index 0000000..c66f06b --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/ReferralCommissionService.java @@ -0,0 +1,289 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.model.Transaction; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.model.UserD; +import com.lottery.lottery.repository.TransactionRepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.repository.UserDRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.Set; + +/** + * Service for handling referral commission logic. + * Processes commissions for referers when their referrals win or lose game rounds. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReferralCommissionService { + + private final UserDRepository userDRepository; + private final UserBRepository userBRepository; + private final TransactionRepository transactionRepository; + + // Commission rates (as percentages) + private static final double WIN_PROFIT_COMMISSION_RATE = 0.01; // 1% of net profit for winners (all levels) + // Loss commission rates per referrer level + private static final double LOSS_COMMISSION_RATE_LEVEL1 = 0.04; // 4% of loss for referrer 1 + private static final double LOSS_COMMISSION_RATE_LEVEL2 = 0.02; // 2% of loss for referrer 2 + private static final double LOSS_COMMISSION_RATE_LEVEL3 = 0.01; // 1% of loss for referrer 3 + + /** + * Processes referral commissions for a user who won a round. + * + * @param userId The user who won + * @param userBet The user's bet amount (in bigint format) + * @param totalBet The total bet of the round (in bigint format) + * @param houseCommission The house commission amount (20% of totalBet - userBet, in bigint format) + * @return Set of referer user IDs who received commissions (for balance update notifications) + */ + @Transactional + public Set processWinnerCommissions(Integer userId, Long userBet, Long totalBet, Long houseCommission) { + Set refererIds = new HashSet<>(); + // Calculate user's net profit: (totalBet - houseCommission) - userBet + // This is the actual profit after the house takes its 20% commission + long userProfit = totalBet - houseCommission - userBet; + + if (userProfit <= 0) { + log.debug("No profit to distribute for winner userId={}, userProfit={}", userId, userProfit); + return refererIds; + } + + // Calculate referral commission amount: 1% of user's net profit + long commissionAmount = (long) (userProfit * WIN_PROFIT_COMMISSION_RATE); + + if (commissionAmount <= 0) { + log.debug("Commission amount too small for winner userId={}, commissionAmount={}", userId, commissionAmount); + return refererIds; + } + + log.info("Processing winner commissions: userId={}, userProfit={}, houseCommission={}, commissionAmount={}", + userId, userProfit, houseCommission, commissionAmount); + + // Get user's referral chain + UserD userD = userDRepository.findById(userId).orElse(null); + if (userD == null) { + log.warn("UserD not found for userId={}, skipping referral commissions", userId); + return refererIds; + } + + // Process commissions for referer_1, referer_2, referer_3 + Integer referer1 = processRefererCommission(userD.getRefererId1(), commissionAmount, 1, userId, true); + if (referer1 != null) refererIds.add(referer1); + + Integer referer2 = processRefererCommission(userD.getRefererId2(), commissionAmount, 2, userId, true); + if (referer2 != null) refererIds.add(referer2); + + Integer referer3 = processRefererCommission(userD.getRefererId3(), commissionAmount, 3, userId, true); + if (referer3 != null) refererIds.add(referer3); + + return refererIds; + } + + /** + * Processes referral commissions for a user who lost a round. + * + * @param userId The user who lost + * @param userBet The user's bet amount (in bigint format) + * @return Set of referer user IDs who received commissions (for balance update notifications) + */ + @Transactional + public Set processLoserCommissions(Integer userId, Long userBet) { + Set refererIds = new HashSet<>(); + if (userBet <= 0) { + log.debug("No bet to process commissions for loser userId={}, userBet={}", userId, userBet); + return refererIds; + } + + log.info("Processing loser commissions: userId={}, userBet={}, rates: level1={}%, level2={}%, level3={}%", + userId, userBet, LOSS_COMMISSION_RATE_LEVEL1 * 100, LOSS_COMMISSION_RATE_LEVEL2 * 100, LOSS_COMMISSION_RATE_LEVEL3 * 100); + + // Get user's referral chain + UserD userD = userDRepository.findById(userId).orElse(null); + if (userD == null) { + log.warn("UserD not found for userId={}, skipping referral commissions", userId); + return refererIds; + } + + // Process commissions for referer_1, referer_2, referer_3 with different rates + // Referrer 1: 4% of user's bet + long commissionAmount1 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL1); + if (commissionAmount1 > 0) { + Integer referer1 = processRefererCommission(userD.getRefererId1(), commissionAmount1, 1, userId, false); + if (referer1 != null) refererIds.add(referer1); + } + + // Referrer 2: 2% of user's bet + long commissionAmount2 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL2); + if (commissionAmount2 > 0) { + Integer referer2 = processRefererCommission(userD.getRefererId2(), commissionAmount2, 2, userId, false); + if (referer2 != null) refererIds.add(referer2); + } + + // Referrer 3: 1% of user's bet + long commissionAmount3 = (long) (userBet * LOSS_COMMISSION_RATE_LEVEL3); + if (commissionAmount3 > 0) { + Integer referer3 = processRefererCommission(userD.getRefererId3(), commissionAmount3, 3, userId, false); + if (referer3 != null) refererIds.add(referer3); + } + + return refererIds; + } + + /** + * Processes commission for a single referer. + * + * @param refererId The referer's user ID (0 if no referer) + * @param commissionAmount The commission amount to award (in bigint format) + * @param refererLevel The referer level (1, 2, or 3) + * @param userId The user who triggered the commission + * @param isWinner Whether the user won (true) or lost (false) + * @return The referer ID if commission was processed, null otherwise + */ + private Integer processRefererCommission(Integer refererId, Long commissionAmount, int refererLevel, + Integer userId, boolean isWinner) { + // Skip if no referer + if (refererId == null || refererId <= 0) { + return null; + } + + try { + // Get referer's UserB (balance) and UserD (referral stats) + UserB refererBalance = userBRepository.findById(refererId).orElse(null); + UserD refererD = userDRepository.findById(refererId).orElse(null); + + if (refererBalance == null || refererD == null) { + log.warn("Referer not found: refererId={}, refererLevel={}, userId={}", + refererId, refererLevel, userId); + return null; + } + + // Credit referer's balance + refererBalance.setBalanceA(refererBalance.getBalanceA() + commissionAmount); + userBRepository.save(refererBalance); + + // Update referer's from_referals_X (they earned from their referral) + switch (refererLevel) { + case 1: + refererD.setFromReferals1(refererD.getFromReferals1() + commissionAmount); + break; + case 2: + refererD.setFromReferals2(refererD.getFromReferals2() + commissionAmount); + break; + case 3: + refererD.setFromReferals3(refererD.getFromReferals3() + commissionAmount); + break; + } + userDRepository.save(refererD); + + // Update user's to_referer_X (they paid commission to their referer) + UserD userD = userDRepository.findById(userId).orElse(null); + if (userD != null) { + switch (refererLevel) { + case 1: + userD.setToReferer1(userD.getToReferer1() + commissionAmount); + break; + case 2: + userD.setToReferer2(userD.getToReferer2() + commissionAmount); + break; + case 3: + userD.setToReferer3(userD.getToReferer3() + commissionAmount); + break; + } + userDRepository.save(userD); + } + + log.info("Commission processed: refererId={}, refererLevel={}, userId={}, " + + "commissionAmount={}, isWinner={}", + refererId, refererLevel, userId, commissionAmount, isWinner); + + return refererId; + + } catch (Exception e) { + log.error("Error processing commission for refererId={}, refererLevel={}, userId={}", + refererId, refererLevel, userId, e); + // Continue processing other referers even if one fails + return null; + } + } + + /** + * Checks if the next bet will be the user's 3rd bet. + * + * @param userId The user who is about to place a bet + * @return true if this will be the 3rd bet, false otherwise + */ + public boolean willBeThirdBet(Integer userId) { + try { + // Get user's rounds_played count from db_users_b + UserB userB = userBRepository.findById(userId).orElse(null); + if (userB == null) { + log.warn("UserB not found for userId={}", userId); + return false; + } + // If current rounds_played is 2, the next round will be the 3rd + return userB.getRoundsPlayed() == 2; + } catch (Exception e) { + log.error("Error checking rounds_played for userId={}", userId, e); + return false; + } + } + + /** + * Gives a one-time bonus of 1 ticket to referrer 1 when user places their 3rd bet. + * + * @param userId The user who placed their 3rd bet + * @return The referrer 1 user ID if bonus was given, null otherwise + */ + @Transactional + public Integer giveThirdBetBonus(Integer userId) { + try { + // Get user's referral chain + UserD userD = userDRepository.findById(userId).orElse(null); + if (userD == null || userD.getRefererId1() == null || userD.getRefererId1() <= 0) { + log.debug("No referrer 1 for userId={}, skipping 3rd bet bonus", userId); + return null; + } + + Integer referer1Id = userD.getRefererId1(); + + // Get referrer's balance and referral stats + UserB refererBalance = userBRepository.findById(referer1Id).orElse(null); + UserD refererD = userDRepository.findById(referer1Id).orElse(null); + + if (refererBalance == null || refererD == null) { + log.warn("Referrer 1 not found: refererId={}, userId={}", referer1Id, userId); + return null; + } + + // Give 1 ticket bonus (1,000,000 in bigint format) + long bonusAmount = 1_000_000L; + refererBalance.setBalanceA(refererBalance.getBalanceA() + bonusAmount); + userBRepository.save(refererBalance); + + // Update referrer's from_referals_1 (they earned from their referral) + refererD.setFromReferals1(refererD.getFromReferals1() + bonusAmount); + userDRepository.save(refererD); + + // Update user's to_referer_1 (they gave bonus to their referrer) + userD.setToReferer1(userD.getToReferer1() + bonusAmount); + userDRepository.save(userD); + + log.info("3rd bet bonus given: userId={}, referer1Id={}, bonusAmount={}", + userId, referer1Id, bonusAmount); + + return referer1Id; + } catch (Exception e) { + log.error("Error giving 3rd bet bonus for userId={}", userId, e); + // Don't throw - this is a bonus, shouldn't break the main flow + return null; + } + } +} + diff --git a/src/main/java/com/lottery/lottery/service/RoomConnectionService.java b/src/main/java/com/lottery/lottery/service/RoomConnectionService.java new file mode 100644 index 0000000..c816f42 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/RoomConnectionService.java @@ -0,0 +1,309 @@ +package com.lottery.lottery.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.function.BiConsumer; + +/** + * Tracks room-level WebSocket connections. + * Tracks which users are connected to which rooms, regardless of round participation. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomConnectionService { + + // Callback to notify when room connections change (set by GameRoomService) + private BiConsumer connectionChangeCallback; + + // Track room connections: roomNumber -> userId -> Set of sessionIds + // This allows tracking multiple sessions per user (e.g., web + iOS) + private final Map>> roomConnections = new ConcurrentHashMap<>(); + + // Track session to user mapping: sessionId -> userId (for disconnect events when principal is lost) + private final Map sessionToUser = new ConcurrentHashMap<>(); + + /** + * Sets callback to be notified when room connections change. + * Called by GameRoomService during initialization. + */ + public void setConnectionChangeCallback(BiConsumer callback) { + this.connectionChangeCallback = callback; + } + + /** + * Registers a session-to-user mapping. + * Called when user connects to track sessions for disconnect events. + */ + public void registerSession(String sessionId, Integer userId) { + sessionToUser.put(sessionId, userId); + log.debug("Registered session {} for user {}", sessionId, userId); + } + + /** + * Removes a session-to-user mapping. + * Called when user disconnects. + */ + public Integer removeSession(String sessionId) { + Integer userId = sessionToUser.remove(sessionId); + if (userId != null) { + log.debug("Removed session {} for user {}", sessionId, userId); + } + return userId; + } + + /** + * Adds a user to a room's connection list. + * Called when user subscribes to room topic. + * + * @param userId The user ID + * @param roomNumber The room number + * @param sessionId The WebSocket session ID + */ + public void addUserToRoom(Integer userId, Integer roomNumber, String sessionId) { + if (userId == null || roomNumber == null || sessionId == null) { + log.warn("Attempted to add user to room with null parameters: userId={}, roomNumber={}, sessionId={}", + userId, roomNumber, sessionId); + return; + } + + // Get or create the map of users for this room + Map> roomUsers = roomConnections.computeIfAbsent(roomNumber, k -> new ConcurrentHashMap<>()); + + // Get or create the set of sessions for this user in this room + Set userSessions = roomUsers.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()); + + // Add the session + boolean isNewUser = userSessions.isEmpty(); + userSessions.add(sessionId); + + int connectedCount = getConnectedUsersCount(roomNumber); + + if (isNewUser) { + log.debug("User {} connected to room {} (session: {}). Total connected users: {}", + userId, roomNumber, sessionId, connectedCount); + } else { + log.debug("User {} added additional session to room {} (session: {}). Total sessions for user: {}, Total connected users: {}", + userId, roomNumber, sessionId, userSessions.size(), connectedCount); + } + + // Notify callback to broadcast updated state (only if this is a new user, not just a new session) + if (connectionChangeCallback != null && isNewUser) { + connectionChangeCallback.accept(roomNumber, connectedCount); + } + } + + /** + * Legacy method for backward compatibility. Uses sessionId from sessionToUser mapping. + * @deprecated Use addUserToRoom(userId, roomNumber, sessionId) instead + */ + @Deprecated + public void addUserToRoom(Integer userId, Integer roomNumber) { + // Try to find a session for this user (not ideal, but for backward compatibility) + String sessionId = sessionToUser.entrySet().stream() + .filter(entry -> entry.getValue().equals(userId)) + .map(Map.Entry::getKey) + .findFirst() + .orElse("legacy-" + userId + "-" + System.currentTimeMillis()); + + addUserToRoom(userId, roomNumber, sessionId); + } + + /** + * Removes a user's session from a room's connection list. + * Only removes the user from the room if this is their last session. + * Called when user disconnects or unsubscribes. + * + * @param userId The user ID + * @param roomNumber The room number + * @param sessionId The WebSocket session ID + */ + public void removeUserFromRoom(Integer userId, Integer roomNumber, String sessionId) { + if (userId == null || roomNumber == null || sessionId == null) { + log.warn("Attempted to remove user from room with null parameters: userId={}, roomNumber={}, sessionId={}", + userId, roomNumber, sessionId); + return; + } + + Map> roomUsers = roomConnections.get(roomNumber); + if (roomUsers == null) { + return; + } + + Set userSessions = roomUsers.get(userId); + if (userSessions == null) { + return; + } + + // Remove the session + boolean removed = userSessions.remove(sessionId); + if (!removed) { + log.debug("Session {} not found for user {} in room {}", sessionId, userId, roomNumber); + return; + } + + // Check if this was the last session for this user in this room + boolean wasLastSession = userSessions.isEmpty(); + + if (wasLastSession) { + // Remove the user from the room + roomUsers.remove(userId); + log.debug("User {} disconnected from room {} (last session: {}). Total connected users: {}", + userId, roomNumber, sessionId, getConnectedUsersCount(roomNumber)); + } else { + log.debug("User {} removed session from room {} (session: {}). Remaining sessions: {}, Total connected users: {}", + userId, roomNumber, sessionId, userSessions.size(), getConnectedUsersCount(roomNumber)); + } + + // Clean up empty room + if (roomUsers.isEmpty()) { + roomConnections.remove(roomNumber); + } + + int connectedCount = getConnectedUsersCount(roomNumber); + + // Notify callback to broadcast updated state (only if user was actually removed) + if (connectionChangeCallback != null && wasLastSession) { + connectionChangeCallback.accept(roomNumber, connectedCount); + } + } + + /** + * Legacy method for backward compatibility. Removes all sessions for the user. + * @deprecated Use removeUserFromRoom(userId, roomNumber, sessionId) instead + */ + @Deprecated + public void removeUserFromRoom(Integer userId, Integer roomNumber) { + // Remove all sessions for this user in this room + Map> roomUsers = roomConnections.get(roomNumber); + if (roomUsers == null) { + return; + } + + Set userSessions = roomUsers.get(userId); + if (userSessions == null || userSessions.isEmpty()) { + return; + } + + // Remove all sessions (create a copy to avoid concurrent modification) + Set sessionsToRemove = new java.util.HashSet<>(userSessions); + for (String sessionId : sessionsToRemove) { + removeUserFromRoom(userId, roomNumber, sessionId); + } + } + + /** + * Removes a specific session from all rooms. + * Only removes the user from a room if this is their last session in that room. + * Called when a session disconnects completely. + * + * @param userId The user ID + * @param sessionId The WebSocket session ID + */ + public void removeUserFromAllRooms(Integer userId, String sessionId) { + if (userId == null || sessionId == null) { + log.warn("Attempted to remove user from all rooms with null parameters: userId={}, sessionId={}", + userId, sessionId); + return; + } + + // Iterate through all rooms and remove this session + roomConnections.forEach((roomNumber, roomUsers) -> { + Set userSessions = roomUsers.get(userId); + if (userSessions != null && userSessions.contains(sessionId)) { + // Use the existing method which handles the logic correctly + removeUserFromRoom(userId, roomNumber, sessionId); + } + }); + } + + /** + * Legacy method that removes all sessions for a user from all rooms. + * @deprecated Use removeUserFromAllRooms(userId, sessionId) instead + */ + @Deprecated + public void removeUserFromAllRooms(Integer userId) { + if (userId == null) { + log.warn("Attempted to remove null user from all rooms"); + return; + } + + // Find all sessions for this user and remove them + roomConnections.forEach((roomNumber, roomUsers) -> { + Set userSessions = roomUsers.get(userId); + if (userSessions != null && !userSessions.isEmpty()) { + // Remove all sessions (create a copy to avoid concurrent modification) + Set sessionsToRemove = new java.util.HashSet<>(userSessions); + for (String sessionId : sessionsToRemove) { + removeUserFromRoom(userId, roomNumber, sessionId); + } + } + }); + } + + /** + * Removes a user from all rooms by session ID. + * Used when principal is lost during disconnect. + * + * @param sessionId The WebSocket session ID + */ + public void removeUserFromAllRoomsBySession(String sessionId) { + if (sessionId == null) { + log.warn("Attempted to remove user from all rooms with null sessionId"); + return; + } + + Integer userId = sessionToUser.get(sessionId); + if (userId != null) { + // Remove this specific session from all rooms + removeUserFromAllRooms(userId, sessionId); + // Also remove session mapping + removeSession(sessionId); + } else { + log.warn("Session {} not found in session-to-user mapping", sessionId); + } + } + + /** + * Gets the count of connected users in a room. + * Counts unique users, not sessions (a user with multiple sessions counts as 1). + */ + public int getConnectedUsersCount(Integer roomNumber) { + Map> roomUsers = roomConnections.get(roomNumber); + return roomUsers != null ? roomUsers.size() : 0; + } + + /** + * Checks if a user is connected to a room. + * Returns true if the user has at least one active session in the room. + */ + public boolean isUserConnectedToRoom(Integer userId, Integer roomNumber) { + Map> roomUsers = roomConnections.get(roomNumber); + if (roomUsers == null) { + return false; + } + Set userSessions = roomUsers.get(userId); + return userSessions != null && !userSessions.isEmpty(); + } + + /** + * Gets the list of user IDs currently connected (viewing) a room. + * Used by admin room management. + */ + public List getConnectedUserIds(Integer roomNumber) { + Map> roomUsers = roomConnections.get(roomNumber); + if (roomUsers == null || roomUsers.isEmpty()) { + return Collections.emptyList(); + } + return roomUsers.keySet().stream().sorted().collect(Collectors.toList()); + } +} + diff --git a/src/main/java/com/honey/honey/service/SessionCleanupService.java b/src/main/java/com/lottery/lottery/service/SessionCleanupService.java similarity index 95% rename from src/main/java/com/honey/honey/service/SessionCleanupService.java rename to src/main/java/com/lottery/lottery/service/SessionCleanupService.java index cf5a8eb..8c9b6a3 100644 --- a/src/main/java/com/honey/honey/service/SessionCleanupService.java +++ b/src/main/java/com/lottery/lottery/service/SessionCleanupService.java @@ -1,6 +1,6 @@ -package com.honey.honey.service; +package com.lottery.lottery.service; -import com.honey.honey.repository.SessionRepository; +import com.lottery.lottery.repository.SessionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/honey/honey/service/SessionService.java b/src/main/java/com/lottery/lottery/service/SessionService.java similarity index 80% rename from src/main/java/com/honey/honey/service/SessionService.java rename to src/main/java/com/lottery/lottery/service/SessionService.java index c436443..0846429 100644 --- a/src/main/java/com/honey/honey/service/SessionService.java +++ b/src/main/java/com/lottery/lottery/service/SessionService.java @@ -1,9 +1,9 @@ -package com.honey.honey.service; +package com.lottery.lottery.service; -import com.honey.honey.model.Session; -import com.honey.honey.model.UserA; -import com.honey.honey.repository.SessionRepository; -import com.honey.honey.repository.UserARepository; +import com.lottery.lottery.model.Session; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.SessionRepository; +import com.lottery.lottery.repository.UserARepository; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,7 +27,7 @@ public class SessionService { private final SessionRepository sessionRepository; private final UserARepository userARepository; - private static final int SESSION_TTL_HOURS = 24; // 1 day + private static final int SESSION_TTL_HOURS = 1; // 1 hour private static final SecureRandom secureRandom = new SecureRandom(); /** @@ -40,7 +40,7 @@ public class SessionService { /** * Creates a new session for a user. - * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest active sessions if limit exceeded. + * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest sessions (active or expired) if limit exceeded. * Returns the raw session ID (to be sent to frontend) and stores the hash in DB. */ @Transactional @@ -71,31 +71,33 @@ public class SessionService { sessionRepository.save(session); - log.info("Created session for userId={}, expiresAt={}", user.getId(), expiresAt); + log.debug("Session created: userId={}", user.getId()); return sessionId; } /** - * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest active sessions if limit exceeded. + * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest sessions (active or expired) if limit exceeded. + * Counts ALL sessions regardless of expiration status. */ private void enforceMaxActiveSessions(Integer userId, LocalDateTime now) { - long activeCount = sessionRepository.countActiveSessionsByUserId(userId, now); + // Count ALL sessions for the user (active + expired) + long totalCount = sessionRepository.countByUserId(userId); - if (activeCount >= maxActiveSessionsPerUser) { - // Calculate how many to delete - int toDelete = (int) (activeCount - maxActiveSessionsPerUser + 1); + if (totalCount >= maxActiveSessionsPerUser) { + // Calculate how many to delete to stay under limit + // If user has 5 sessions and limit is 5, we need to delete 1 to make room for the new one + int toDelete = (int) (totalCount - maxActiveSessionsPerUser + 1); - // Get oldest active sessions - List oldestSessions = sessionRepository.findOldestActiveSessionsByUserId( + // Get oldest sessions (active or expired, ordered by createdAt ASC) + List oldestSessions = sessionRepository.findOldestSessionsByUserId( userId, - now, PageRequest.of(0, toDelete) ); // Delete oldest sessions if (!oldestSessions.isEmpty()) { sessionRepository.deleteAll(oldestSessions); - log.info("Deleted {} oldest active session(s) for userId={} to enforce max limit of {}", + log.debug("Deleted {} oldest session(s) for userId={} (limit: {})", oldestSessions.size(), userId, maxActiveSessionsPerUser); } } @@ -115,14 +117,12 @@ public class SessionService { Optional sessionOpt = sessionRepository.findBySessionIdHash(sessionIdHash); if (sessionOpt.isEmpty()) { - log.debug("Session not found: {}", maskSessionId(sessionId)); return Optional.empty(); } Session session = sessionOpt.get(); if (session.isExpired()) { - log.debug("Session expired: {}", maskSessionId(sessionId)); // Optionally delete expired session sessionRepository.delete(session); return Optional.empty(); @@ -143,7 +143,7 @@ public class SessionService { String sessionIdHash = hashSessionId(sessionId); sessionRepository.deleteBySessionIdHash(sessionIdHash); - log.info("Invalidated session: {}", maskSessionId(sessionId)); + log.debug("Session invalidated: userId={}", maskSessionId(sessionId)); } /** diff --git a/src/main/java/com/lottery/lottery/service/SupportTicketService.java b/src/main/java/com/lottery/lottery/service/SupportTicketService.java new file mode 100644 index 0000000..56db236 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/SupportTicketService.java @@ -0,0 +1,225 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.*; +import com.lottery.lottery.exception.GameException; +import com.lottery.lottery.model.SupportMessage; +import com.lottery.lottery.model.SupportTicket; +import com.lottery.lottery.model.SupportTicket.TicketStatus; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.repository.SupportMessageRepository; +import com.lottery.lottery.repository.SupportTicketRepository; +import com.lottery.lottery.repository.UserARepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SupportTicketService { + + private static final int MAX_OPENED_TICKETS = 5; + private static final int MAX_MESSAGES_PER_TICKET = 20; + private static final int RATE_LIMIT_SECONDS = 10; + + private final SupportTicketRepository ticketRepository; + private final SupportMessageRepository messageRepository; + private final UserARepository userARepository; + private final LocalizationService localizationService; + + /** + * Creates a new support ticket with the first message. + * Validates limits and rate limiting. + */ + @Transactional + public TicketDto createTicket(Integer userId, CreateTicketRequest request) { + // Validate user exists + UserA user = userARepository.findById(userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("user.error.notFound"))); + + // Check limit: max 5 OPENED tickets per user + long openedTicketsCount = ticketRepository.countByUserIdAndStatus(userId, TicketStatus.OPENED); + if (openedTicketsCount >= MAX_OPENED_TICKETS) { + throw new GameException(localizationService.getMessage("support.error.maxTicketsReached", + String.valueOf(MAX_OPENED_TICKETS))); + } + + // Create ticket + SupportTicket ticket = SupportTicket.builder() + .user(user) + .subject(request.getSubject().trim()) + .status(TicketStatus.OPENED) + .build(); + + ticket = ticketRepository.save(ticket); + + // Create first message + SupportMessage message = SupportMessage.builder() + .ticket(ticket) + .user(user) + .message(request.getMessage().trim()) + .build(); + + messageRepository.save(message); + + log.info("Created support ticket {} for user {}", ticket.getId(), userId); + + return mapToTicketDto(ticket, 1); + } + + /** + * Adds a message to an existing ticket. + * Validates ticket status, message limit, and rate limiting. + */ + @Transactional + public MessageDto addMessage(Integer userId, Long ticketId, CreateMessageRequest request) { + // Validate user exists + UserA user = userARepository.findById(userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("user.error.notFound"))); + + // Get ticket (user can only access their own tickets) + SupportTicket ticket = ticketRepository.findByIdAndUserId(ticketId, userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("support.error.ticketNotFound"))); + + // Check if ticket is closed + if (ticket.getStatus() == TicketStatus.CLOSED) { + throw new GameException(localizationService.getMessage("support.error.ticketClosed")); + } + + // Check limit: max 20 messages per OPENED ticket + long messageCount = messageRepository.countByTicketId(ticketId); + if (messageCount >= MAX_MESSAGES_PER_TICKET) { + throw new GameException(localizationService.getMessage("support.error.maxMessagesReached", + String.valueOf(MAX_MESSAGES_PER_TICKET))); + } + + // Rate limiting: not more than once per 10 seconds + List lastMessages = messageRepository.findLastMessageByTicketIdAndUserId( + ticketId, userId, PageRequest.of(0, 1)); + if (!lastMessages.isEmpty()) { + SupportMessage lastMessage = lastMessages.get(0); + long secondsSinceLastMessage = ChronoUnit.SECONDS.between(lastMessage.getCreatedAt(), Instant.now()); + if (secondsSinceLastMessage < RATE_LIMIT_SECONDS) { + long remainingSeconds = RATE_LIMIT_SECONDS - secondsSinceLastMessage; + throw new GameException(localizationService.getMessage("support.error.rateLimitWait", + String.valueOf(remainingSeconds))); + } + } + + // Create message + SupportMessage message = SupportMessage.builder() + .ticket(ticket) + .user(user) + .message(request.getMessage().trim()) + .build(); + + message = messageRepository.save(message); + + log.info("Added message {} to ticket {} for user {}", message.getId(), ticketId, userId); + + return mapToMessageDto(message, ticket.getUser().getId()); + } + + /** + * Closes a ticket. + */ + @Transactional + public void closeTicket(Integer userId, Long ticketId) { + // Get ticket (user can only access their own tickets) + SupportTicket ticket = ticketRepository.findByIdAndUserId(ticketId, userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("support.error.ticketNotFound"))); + + if (ticket.getStatus() == TicketStatus.CLOSED) { + throw new GameException(localizationService.getMessage("support.error.ticketAlreadyClosed")); + } + + ticket.setStatus(TicketStatus.CLOSED); + ticketRepository.save(ticket); + + log.info("Closed ticket {} for user {}", ticketId, userId); + } + + /** + * Gets ticket details with all messages. + */ + @Transactional(readOnly = true) + public TicketDetailDto getTicketDetail(Integer userId, Long ticketId) { + // Get ticket (user can only access their own tickets) + SupportTicket ticket = ticketRepository.findByIdAndUserId(ticketId, userId) + .orElseThrow(() -> new GameException(localizationService.getMessage("support.error.ticketNotFound"))); + + // Get all messages + List messages = messageRepository.findByTicketIdOrderByCreatedAtAsc(ticketId); + + // Map to DTOs + List messageDtos = messages.stream() + .map(msg -> mapToMessageDto(msg, ticket.getUser().getId())) + .collect(Collectors.toList()); + + return TicketDetailDto.builder() + .id(ticket.getId()) + .subject(ticket.getSubject()) + .status(ticket.getStatus()) + .createdAt(ticket.getCreatedAt()) + .updatedAt(ticket.getUpdatedAt()) + .messages(messageDtos) + .build(); + } + + /** + * Gets ticket history for a user (last 20 tickets). + */ + @Transactional(readOnly = true) + public List getTicketHistory(Integer userId) { + Pageable pageable = PageRequest.of(0, 20); + Page tickets = ticketRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + return tickets.getContent().stream() + .map(ticket -> { + long messageCount = messageRepository.countByTicketId(ticket.getId()); + return mapToTicketDto(ticket, (int) messageCount); + }) + .collect(Collectors.toList()); + } + + /** + * Maps SupportTicket to TicketDto. + */ + private TicketDto mapToTicketDto(SupportTicket ticket, int messageCount) { + return TicketDto.builder() + .id(ticket.getId()) + .subject(ticket.getSubject()) + .status(ticket.getStatus()) + .createdAt(ticket.getCreatedAt()) + .updatedAt(ticket.getUpdatedAt()) + .messageCount(messageCount) + .build(); + } + + /** + * Maps SupportMessage to MessageDto. + * isFromSupport is true if the message is from a different user than the ticket owner. + */ + private MessageDto mapToMessageDto(SupportMessage message, Integer ticketOwnerUserId) { + boolean isFromSupport = !message.getUser().getId().equals(ticketOwnerUserId); + + return MessageDto.builder() + .id(message.getId()) + .ticketId(message.getTicket().getId()) + .userId(message.getUser().getId()) + .message(message.getMessage()) + .createdAt(message.getCreatedAt()) + .isFromSupport(isFromSupport) + .build(); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/TaskService.java b/src/main/java/com/lottery/lottery/service/TaskService.java new file mode 100644 index 0000000..da49d66 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/TaskService.java @@ -0,0 +1,654 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.config.LocaleConfig; +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.dto.RecentBonusClaimDto; +import com.lottery.lottery.dto.TaskDto; +import com.lottery.lottery.model.Task; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.model.UserD; +import com.lottery.lottery.model.UserTaskClaim; +import com.lottery.lottery.model.UserDailyBonusClaim; +import com.lottery.lottery.repository.TaskRepository; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.repository.UserDRepository; +import com.lottery.lottery.repository.UserTaskClaimRepository; +import com.lottery.lottery.repository.UserDailyBonusClaimRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskService { + + private final TaskRepository taskRepository; + private final UserTaskClaimRepository userTaskClaimRepository; + private final UserDailyBonusClaimRepository userDailyBonusClaimRepository; + private final UserDRepository userDRepository; + private final UserBRepository userBRepository; + private final UserARepository userARepository; + private final TelegramService telegramService; + private final TelegramProperties telegramProperties; + private final TransactionService transactionService; + private final EntityManager entityManager; + private final LocalizationService localizationService; + private final FeatureSwitchService featureSwitchService; + + /** + * Gets all tasks for a specific type. + * For referral tasks, includes user progress and claim status. + * @param userId User ID to get tasks for + * @param type Task type (referral, follow, other) + * @param languageCode User's language code for localization (optional, defaults to EN) + */ + public List getTasksByType(Integer userId, String type, String languageCode) { + List tasks = taskRepository.findByTypeOrderByDisplayOrderAsc(type); + + // Get user progress for different task types + Integer userReferals1 = null; + Long userDepositTotal = null; + if ("referral".equals(type)) { + userReferals1 = getUserReferals1(userId); + } else if ("other".equals(type)) { + userDepositTotal = getUserDepositTotal(userId); + } + + // Get user's claimed tasks + List claimedTaskIds = userTaskClaimRepository.findByUserId(userId) + .stream() + .map(UserTaskClaim::getTaskId) + .collect(Collectors.toList()); + + final Integer finalUserReferals1 = userReferals1; + final Long finalUserDepositTotal = userDepositTotal; + final List finalClaimedTaskIds = claimedTaskIds; + + return tasks.stream() + .filter(task -> !finalClaimedTaskIds.contains(task.getId())) + .filter(task -> isReferralTaskEnabled(task)) + .map(task -> { + boolean isClaimed = finalClaimedTaskIds.contains(task.getId()); + String progress = buildProgressString(task, finalUserReferals1, finalUserDepositTotal, isClaimed, languageCode); + Long currentValue = getCurrentValue(task, finalUserReferals1, finalUserDepositTotal); + + // Localize title and description + String localizedTitle = localizeTaskTitle(task, languageCode); + String localizedDescription = localizeTaskDescription(task, languageCode); + String localizedRewardText = localizeRewardText(task, languageCode); + + return TaskDto.builder() + .id(task.getId()) + .type(task.getType()) + .requirement(task.getRequirement()) + .rewardAmount(task.getRewardAmount()) + .rewardType(task.getRewardType()) + .title(localizedTitle) + .description(localizedDescription) + .displayOrder(task.getDisplayOrder()) + .claimed(isClaimed) + .progress(progress) + .currentValue(currentValue) + .localizedRewardText(localizedRewardText) + .build(); + }) + .collect(Collectors.toList()); + } + + /** + * Returns true if the task should be shown/claimable. Referral tasks with requirement 50 or 100 + * are gated by feature switches (task_referral_50_enabled, task_referral_100_enabled). + */ + private boolean isReferralTaskEnabled(Task task) { + if (!"referral".equals(task.getType())) { + return true; + } + long req = task.getRequirement() != null ? task.getRequirement() : 0; + if (req == 50) { + return featureSwitchService.isTaskReferral50Enabled(); + } + if (req == 100) { + return featureSwitchService.isTaskReferral100Enabled(); + } + return true; + } + + /** + * Returns true if the user has claimed at least one of the referral tasks with requirement 50 or 100. + * Used to set manual_pay=1 on crypto withdrawal requests when applicable. + */ + @Transactional(readOnly = true) + public boolean hasCompletedReferral50Or100(Integer userId) { + List tasks = taskRepository.findByTypeAndRequirementIn("referral", List.of(50L, 100L)); + if (tasks.isEmpty()) { + return false; + } + List taskIds = tasks.stream().map(Task::getId).collect(Collectors.toList()); + return userTaskClaimRepository.existsByUserIdAndTaskIdIn(userId, taskIds); + } + + /** + * Gets user's level 1 referrals count (referals_1). + */ + private Integer getUserReferals1(Integer userId) { + Optional userDOpt = userDRepository.findById(userId); + return userDOpt.map(UserD::getReferals1).orElse(0); + } + + /** + * Gets user's total deposit amount (deposit_total). + */ + private Long getUserDepositTotal(Integer userId) { + Optional userBOpt = userBRepository.findById(userId); + return userBOpt.map(UserB::getDepositTotal).orElse(0L); + } + + /** + * Builds progress string for a task. + * For referral tasks: shows "current / requirement" or "CLAIMED" if already claimed. + * For other tasks and follow tasks: returns null - frontend will build progress string from currentValue and requirement. + */ + private String buildProgressString(Task task, Integer userReferals1, Long userDepositTotal, boolean isClaimed, String languageCode) { + if (isClaimed) { + // Return localized "CLAIMED" text + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + return localizationService.getMessage(locale, "task.claimed"); + } + + if ("referral".equals(task.getType()) && userReferals1 != null) { + Long requirement = task.getRequirement(); + return userReferals1 + " / " + requirement; + } + + // For other and follow tasks, return null - frontend will build progress from currentValue and requirement + return null; + } + + /** + * Gets current value for progress calculation (in bigint format). + * Frontend will convert to display format. + */ + private Long getCurrentValue(Task task, Integer userReferals1, Long userDepositTotal) { + if ("referral".equals(task.getType()) && userReferals1 != null) { + return (long) userReferals1; + } + + if ("other".equals(task.getType()) && userDepositTotal != null) { + return userDepositTotal; + } + + return null; + } + + /** + * Claims a task for a user. + * Checks if task is completed, and if so, marks it as claimed and gives reward. + * + * IMPORTANT: For non-daily tasks, this method prevents abuse by checking if the task is already claimed FIRST, + * before checking completion. This ensures that even if a user leaves and rejoins a channel, + * they cannot claim the reward multiple times. Once a task is claimed, the claim record + * persists and prevents any further claims for that user-task combination. + * + * For daily tasks, the method allows claiming again after 24 hours have passed since the last claim. + * The old claim record is deleted and a new one is created with the current timestamp. + * + * @return true if task was successfully claimed, false if task is not completed or already claimed (for non-daily tasks) + */ + @Transactional + public boolean claimTask(Integer userId, Integer taskId) { + // Get task first to check its type + Optional taskOpt = taskRepository.findById(taskId); + if (taskOpt.isEmpty()) { + log.warn("Task not found: taskId={}", taskId); + return false; + } + + Task task = taskOpt.get(); + + // Reject claim if this referral task (50 or 100 friends) is temporarily disabled + if (!isReferralTaskEnabled(task)) { + log.debug("Task disabled by feature switch: taskId={}, type={}, requirement={}", taskId, task.getType(), task.getRequirement()); + return false; + } + + // For non-daily tasks, check if already claimed FIRST to prevent abuse + // This prevents users from claiming rewards multiple times by leaving/rejoining channels + if (!"daily".equals(task.getType()) && userTaskClaimRepository.existsByUserIdAndTaskId(userId, taskId)) { + log.debug("Task already claimed: userId={}, taskId={}", userId, taskId); + return false; + } + + // Check if task is completed + // For daily tasks, this checks if 24 hours have passed since last claim + if (!isTaskCompleted(userId, task)) { + log.debug("Task not completed: userId={}, taskId={}", userId, taskId); + return false; + } + + // For daily tasks, save to user_daily_bonus_claims table with user info + if ("daily".equals(task.getType())) { + // Get user data for the claim record + Optional userOpt = userARepository.findById(userId); + String avatarUrl = null; + String screenName = "-"; + if (userOpt.isPresent()) { + UserA user = userOpt.get(); + avatarUrl = user.getAvatarUrl(); + screenName = user.getScreenName() != null ? user.getScreenName() : "-"; + } + + // Save to user_daily_bonus_claims table + UserDailyBonusClaim dailyClaim = UserDailyBonusClaim.builder() + .userId(userId) + .avatarUrl(avatarUrl) + .screenName(screenName) + .build(); + userDailyBonusClaimRepository.save(dailyClaim); + } else { + // For non-daily tasks, save to user_task_claims table + UserTaskClaim claim = UserTaskClaim.builder() + .userId(userId) + .taskId(taskId) + .build(); + userTaskClaimRepository.save(claim); + } + + // Give reward (rewardAmount is already in bigint format) + giveReward(userId, task.getRewardAmount()); + + // Create transaction - use DAILY_BONUS for daily tasks, TASK_BONUS for others + try { + if ("daily".equals(task.getType())) { + transactionService.createDailyBonusTransaction(userId, task.getRewardAmount()); + } else { + transactionService.createTaskBonusTransaction(userId, task.getRewardAmount(), taskId); + } + } catch (Exception e) { + log.error("Error creating transaction: userId={}, taskId={}, type={}", userId, taskId, task.getType(), e); + // Continue even if transaction record creation fails + } + + log.info("Task claimed: userId={}, taskId={}, reward={}", + userId, taskId, task.getRewardAmount()); + + return true; + } + + /** + * Checks if a task is completed by the user. + */ + private boolean isTaskCompleted(Integer userId, Task task) { + if ("referral".equals(task.getType())) { + Integer referals1 = getUserReferals1(userId); + return referals1 >= task.getRequirement(); + } + + if ("other".equals(task.getType())) { + // For other tasks, requirement is deposit_total threshold in bigint format + Long depositTotal = getUserDepositTotal(userId); + return depositTotal >= task.getRequirement(); + } + + if ("follow".equals(task.getType())) { + // For follow tasks, check if user is a member of the Telegram channel + Optional userAOpt = userARepository.findById(userId); + if (userAOpt.isEmpty() || userAOpt.get().getTelegramId() == null) { + log.warn("User not found or has no telegram ID: userId={}", userId); + return false; + } + + Long telegramUserId = userAOpt.get().getTelegramId(); + // Get channel ID from configuration based on task requirement + // requirement=1 for News channel, requirement=2 for Withdrawals channel + String chatId; + Long requirement = task.getRequirement(); + if (requirement != null && requirement.equals(1L)) { + chatId = telegramProperties.getFollowTaskChannelId(); + } else if (requirement != null && requirement.equals(2L)) { + chatId = telegramProperties.getFollowTaskChannelId2(); + } else { + // Fallback to first channel for backward compatibility + chatId = telegramProperties.getFollowTaskChannelId(); + } + + if (chatId == null || chatId.isEmpty()) { + log.error("Follow task channel ID is not configured for requirement={}", task.getRequirement()); + return false; + } + + try { + return telegramService.isUserMemberOfChat(telegramUserId, chatId); + } catch (IllegalStateException e) { + // Handle Telegram API errors gracefully + // If it's a "member not found" error, it's already handled in TelegramService + // and returns false. Other errors should be logged but not crash the task check. + log.warn("Error checking channel membership for follow task: userId={}, chatId={}", + telegramUserId, chatId, e); + return false; + } + } + + if ("daily".equals(task.getType())) { + // For daily bonus, check if 24 hours have passed since last claim + // Use user_daily_bonus_claims table instead of user_task_claims + Optional claimOpt = userDailyBonusClaimRepository.findFirstByUserIdOrderByClaimedAtDesc(userId); + if (claimOpt.isEmpty()) { + // Never claimed, so it's available + return true; + } + + UserDailyBonusClaim claim = claimOpt.get(); + LocalDateTime claimedAt = claim.getClaimedAt(); + LocalDateTime now = LocalDateTime.now(); + long hoursSinceClaim = java.time.Duration.between(claimedAt, now).toHours(); + + // Available if 24 hours or more have passed + return hoursSinceClaim >= 24; + } + + return false; + } + + /** + * Gets daily bonus status for a user. + * Returns availability status and cooldown time if on cooldown. + */ + public com.lottery.lottery.dto.DailyBonusStatusDto getDailyBonusStatus(Integer userId) { + // Find daily bonus task + List dailyTasks = taskRepository.findByTypeOrderByDisplayOrderAsc("daily"); + if (dailyTasks.isEmpty()) { + log.warn("Daily bonus task not found"); + return com.lottery.lottery.dto.DailyBonusStatusDto.builder() + .available(false) + .cooldownSeconds(0L) + .rewardAmount(0L) + .build(); + } + + Task dailyTask = dailyTasks.get(0); + + // Check if user has claimed before using user_daily_bonus_claims table + Optional claimOpt = userDailyBonusClaimRepository.findFirstByUserIdOrderByClaimedAtDesc(userId); + + if (claimOpt.isEmpty()) { + // Never claimed, so it's available + return com.lottery.lottery.dto.DailyBonusStatusDto.builder() + .taskId(dailyTask.getId()) + .available(true) + .cooldownSeconds(null) + .rewardAmount(dailyTask.getRewardAmount()) + .build(); + } + + // Check cooldown + UserDailyBonusClaim claim = claimOpt.get(); + LocalDateTime claimedAt = claim.getClaimedAt(); + LocalDateTime now = LocalDateTime.now(); + long secondsSinceClaim = java.time.Duration.between(claimedAt, now).getSeconds(); + long hoursSinceClaim = secondsSinceClaim / 3600; + + if (hoursSinceClaim >= 24) { + // Cooldown expired, available + return com.lottery.lottery.dto.DailyBonusStatusDto.builder() + .taskId(dailyTask.getId()) + .available(true) + .cooldownSeconds(null) + .rewardAmount(dailyTask.getRewardAmount()) + .build(); + } else { + // Still on cooldown + long secondsUntilAvailable = (24 * 3600) - secondsSinceClaim; + return com.lottery.lottery.dto.DailyBonusStatusDto.builder() + .taskId(dailyTask.getId()) + .available(false) + .cooldownSeconds(secondsUntilAvailable) + .rewardAmount(dailyTask.getRewardAmount()) + .build(); + } + } + + /** + * Gets the 50 most recent daily bonus claims with user information. + * Returns claims ordered by claimed_at DESC (most recent first). + * Simple query without JOINs - all data is in user_daily_bonus_claims table. + * + * @param timezone Optional timezone (e.g., "Europe/Kiev"). If null, uses UTC. + * @param languageCode User's language code for localization (e.g., "EN", "RU") + * @return List of RecentBonusClaimDto with avatar URL, screen name, and formatted claim timestamp + */ + public List getRecentDailyBonusClaims(String timezone, String languageCode) { + // Get recent claims - simple query, no JOINs needed + List claims = userDailyBonusClaimRepository.findTop50ByOrderByClaimedAtDesc(); + + // Determine timezone to use + java.time.ZoneId zoneId; + try { + zoneId = (timezone != null && !timezone.trim().isEmpty()) + ? java.time.ZoneId.of(timezone) + : java.time.ZoneId.of("UTC"); + } catch (Exception e) { + // Invalid timezone, fallback to UTC + zoneId = java.time.ZoneId.of("UTC"); + } + + // Get localized "at" word + String atWord = localizationService.getMessage("dateTime.at", languageCode); + if (atWord == null || atWord.isEmpty()) { + atWord = "at"; // Fallback to English + } + + // Create formatter with localized "at" word + final java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") + .withZone(zoneId); + + // Convert to DTOs with formatted date + return claims.stream() + .map(claim -> { + // Convert LocalDateTime to Instant (assuming it's stored in UTC) + // LocalDateTime doesn't have timezone info, so we treat it as UTC + java.time.Instant instant = claim.getClaimedAt().atZone(java.time.ZoneId.of("UTC")).toInstant(); + String formattedDate = formatter.format(instant); + + return RecentBonusClaimDto.builder() + .avatarUrl(claim.getAvatarUrl()) + .screenName(claim.getScreenName()) + .claimedAt(claim.getClaimedAt()) + .date(formattedDate) + .build(); + }) + .collect(Collectors.toList()); + } + + /** + * Gives reward to user's balance. + * rewardAmount is already in bigint format from frontend. + */ + private void giveReward(Integer userId, Long rewardAmount) { + Optional userBOpt = userBRepository.findById(userId); + if (userBOpt.isEmpty()) { + log.warn("User balance not found: userId={}", userId); + return; + } + + UserB userB = userBOpt.get(); + userB.setBalanceA(userB.getBalanceA() + rewardAmount); + userBRepository.save(userB); + + log.debug("Reward given: userId={}, amount={}", userId, rewardAmount); + } + + /** + * Localizes task title based on task type and requirement. + * Falls back to database title if no translation key matches. + */ + private String localizeTaskTitle(Task task, String languageCode) { + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + String key = null; + + if ("referral".equals(task.getType())) { + // Use task-specific key based on requirement for proper grammar + key = "task.title.inviteFriends." + task.getRequirement(); + } else if ("follow".equals(task.getType())) { + // Pattern: "Follow our News channel" or "Follow Proof of payment channel" + // Use requirement to distinguish: 1=News, 2=Withdrawals + Long requirement = task.getRequirement(); + if (requirement != null && requirement.equals(1L)) { + key = "task.title.followChannel"; + } else if (requirement != null && requirement.equals(2L)) { + key = "task.title.followChannelWithdrawals"; + } else { + key = "task.title.followChannel"; // Fallback + } + } else if ("daily".equals(task.getType())) { + // Pattern: "Daily Bonus" + key = "task.title.dailyBonus"; + } else if ("other".equals(task.getType())) { + // Pattern: "Deposit {0} tickets" + key = "task.title.deposit"; + } + + if (key != null) { + try { + if ("referral".equals(task.getType())) { + // For referral tasks, use task-specific key (no parameters needed) + return localizationService.getMessage(locale, key); + } else if ("other".equals(task.getType())) { + // Convert requirement (bigint) to tickets for display + long tickets = task.getRequirement() / 1_000_000L; + return localizationService.getMessage(locale, key, tickets); + } else { + return localizationService.getMessage(locale, key); + } + } catch (Exception e) { + log.warn("Failed to localize task title for taskId={}, key={}, languageCode={}", + task.getId(), key, languageCode, e); + } + } + + // Fallback to database title + return task.getTitle(); + } + + /** + * Localizes task description based on task type and requirement. + * Falls back to database description if no translation key matches. + */ + private String localizeTaskDescription(Task task, String languageCode) { + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + String key = null; + + if ("referral".equals(task.getType())) { + // Use task-specific key based on requirement for proper grammar + key = "task.description.inviteFriends." + task.getRequirement(); + } else if ("follow".equals(task.getType())) { + // Pattern: "Follow our Telegram channel" or "Follow Proof of payment channel" + // Use requirement to distinguish: 1=News, 2=Withdrawals + Long requirement = task.getRequirement(); + if (requirement != null && requirement.equals(1L)) { + key = "task.description.followChannel"; + } else if (requirement != null && requirement.equals(2L)) { + key = "task.description.followChannelWithdrawals"; + } else { + key = "task.description.followChannel"; // Fallback + } + } else if ("daily".equals(task.getType())) { + // Pattern: "Claim your daily free ticket!" + key = "task.description.dailyBonus"; + } else if ("other".equals(task.getType())) { + // Pattern: "Deposit {0} tickets" + key = "task.description.deposit"; + } + + if (key != null) { + try { + if ("referral".equals(task.getType())) { + // For referral tasks, use task-specific key (no parameters needed) + return localizationService.getMessage(locale, key); + } else if ("other".equals(task.getType())) { + // Convert requirement (bigint) to tickets for display + long tickets = task.getRequirement() / 1_000_000L; + return localizationService.getMessage(locale, key, tickets); + } else { + return localizationService.getMessage(locale, key); + } + } catch (Exception e) { + log.warn("Failed to localize task description for taskId={}, key={}, languageCode={}", + task.getId(), key, languageCode, e); + } + } + + // Fallback to database description + return task.getDescription(); + } + + /** + * Localizes reward text based on task type and reward amount. + * Returns formatted string like "+2 Билеты" or "+5 Билетов" for proper grammar. + */ + private String localizeRewardText(Task task, String languageCode) { + if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) { + languageCode = "EN"; + } + + Locale locale = LocaleConfig.languageCodeToLocale(languageCode); + + // Convert reward amount from bigint to tickets + long tickets = task.getRewardAmount() / 1_000_000L; + + // Build key based on task type and requirement for proper grammar + String key = null; + if ("referral".equals(task.getType())) { + key = "task.reward.tickets." + task.getRequirement(); + } else if ("follow".equals(task.getType())) { + key = "task.reward.tickets.follow"; + } else if ("daily".equals(task.getType())) { + key = "task.reward.tickets.daily"; + } else if ("other".equals(task.getType())) { + // For other tasks, use the requirement (deposit amount) as key + long requirementTickets = task.getRequirement() / 1_000_000L; + key = "task.reward.tickets.other." + requirementTickets; + } + + if (key != null) { + try { + return localizationService.getMessage(locale, key, tickets); + } catch (Exception e) { + log.warn("Failed to localize reward text for taskId={}, key={}, languageCode={}", + task.getId(), key, languageCode, e); + } + } + + // Fallback: use generic tickets translation + try { + String ticketsKey = "tasks.tickets"; + String ticketsText = localizationService.getMessage(locale, ticketsKey); + return "+" + tickets + " " + ticketsText; + } catch (Exception e) { + // Final fallback + return "+" + tickets + " Tickets"; + } + } +} + diff --git a/src/main/java/com/honey/honey/service/TelegramAuthService.java b/src/main/java/com/lottery/lottery/service/TelegramAuthService.java similarity index 83% rename from src/main/java/com/honey/honey/service/TelegramAuthService.java rename to src/main/java/com/lottery/lottery/service/TelegramAuthService.java index d45d8e7..74d5e31 100644 --- a/src/main/java/com/honey/honey/service/TelegramAuthService.java +++ b/src/main/java/com/lottery/lottery/service/TelegramAuthService.java @@ -1,8 +1,8 @@ -package com.honey.honey.service; +package com.lottery.lottery.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.honey.honey.config.TelegramProperties; -import com.honey.honey.exception.UnauthorizedException; +import com.lottery.lottery.config.TelegramProperties; +import com.lottery.lottery.exception.UnauthorizedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -26,18 +26,19 @@ public class TelegramAuthService { private static final String WEB_APP_DATA_CONSTANT = "WebAppData"; private final TelegramProperties telegramProperties; + private final LocalizationService localizationService; private final ObjectMapper objectMapper = new ObjectMapper(); /** * Validates and parses Telegram initData string. * Returns a map containing: * - "user": parsed user data (Map) - * - "start": referral start parameter from URL (e.g., "774876" from /honey?start=774876) (String, can be null) + * Note: Referral handling is done via bot, not through WebApp initData. */ public Map validateAndParseInitData(String initData) { if (initData == null || initData.isBlank()) { - throw new UnauthorizedException("Telegram initData is missing"); + throw new UnauthorizedException(localizationService.getMessage("auth.error.initDataMissing")); } try { @@ -46,7 +47,7 @@ public class TelegramAuthService { String receivedHash = parsedData.remove("hash"); if (receivedHash == null) { - throw new UnauthorizedException("Missing Telegram hash"); + throw new UnauthorizedException(localizationService.getMessage("auth.error.missingHash")); } // Step 2. Build data check string. @@ -60,22 +61,22 @@ public class TelegramAuthService { if (!receivedHash.equals(calculatedHash)) { log.warn("Telegram signature mismatch. Expected={}, Received={}", calculatedHash, receivedHash); - throw new UnauthorizedException("Invalid Telegram signature"); + throw new UnauthorizedException(localizationService.getMessage("auth.error.invalidSignature")); } - // Step 5. Extract the user JSON and start parameter from initData. + // Step 5. Extract the user JSON from initData. Map decoded = decodeQueryParams(initData); String userJson = decoded.get("user"); - String start = decoded.get("start"); // Referral parameter from URL: /honey?start=774876 if (userJson == null) { - throw new UnauthorizedException("initData does not contain 'user' field"); + throw new UnauthorizedException(localizationService.getMessage("auth.error.userFieldMissing")); } - // Step 6. Parse JSON into map and add start parameter. + // Step 6. Parse JSON into map. + // Note: Referral handling is done via bot registration endpoint, not through WebApp initData. Map result = new HashMap<>(); result.put("user", objectMapper.readValue(userJson, Map.class)); - result.put("start", start); + // "start" parameter is not included for WebApp - referrals handled by bot return result; @@ -84,7 +85,7 @@ public class TelegramAuthService { } catch (Exception ex) { log.error("Telegram initData validation failed: {}", ex.getMessage(), ex); - throw new UnauthorizedException("Invalid Telegram initData"); + throw new UnauthorizedException(localizationService.getMessage("auth.error.invalidInitData")); } } diff --git a/src/main/java/com/lottery/lottery/service/TelegramBotApiService.java b/src/main/java/com/lottery/lottery/service/TelegramBotApiService.java new file mode 100644 index 0000000..582ba09 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/TelegramBotApiService.java @@ -0,0 +1,161 @@ +package com.lottery.lottery.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lottery.lottery.dto.TelegramApiResponse; +import com.lottery.lottery.dto.TelegramSendResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; + +import jakarta.annotation.PreDestroy; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Sends requests to Telegram Bot API. No client-side rate limiting; on 429 Too Many Requests + * from Telegram, schedules a single retry after retry_after seconds. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TelegramBotApiService { + + private static final int MAX_RETRY_AFTER_SECONDS = 120; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final RestTemplate restTemplate = new RestTemplate(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "telegram-api-retry"); + t.setDaemon(true); + return t; + }); + + /** Max 429 retries for broadcast (total attempts = 1 + this value). */ + private static final int BROADCAST_429_MAX_RETRIES = 2; + + /** + * POST for broadcast with 429 retry: on 429, reads retry_after from response, waits that long + * (capped, with jitter), then retries. Returns final result for audit (success + status code). + */ + public TelegramSendResult postForBroadcast(String url, HttpEntity entity) { + int attempt = 0; + int maxAttempts = 1 + BROADCAST_429_MAX_RETRIES; + while (true) { + try { + ResponseEntity response = restTemplate.postForEntity( + url, entity, TelegramApiResponse.class); + int code = response.getStatusCode().value(); + boolean ok = response.getBody() != null && Boolean.TRUE.equals(response.getBody().getOk()); + return new TelegramSendResult(ok && code == 200, code); + } catch (HttpClientErrorException e) { + if (e.getStatusCode().value() == 429 && attempt < maxAttempts - 1) { + int retryAfterSeconds = parseRetryAfter(e); + int waitSeconds = Math.min(Math.max(1, retryAfterSeconds), MAX_RETRY_AFTER_SECONDS); + double jitter = 0.9 + (Math.random() * 0.2); + long waitMs = (long) (waitSeconds * jitter * 1000); + log.warn("Telegram API 429 during broadcast, waiting {} ms then retry (attempt {}/{})", waitMs, attempt + 1, maxAttempts); + try { + Thread.sleep(waitMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return new TelegramSendResult(false, 429); + } + attempt++; + continue; + } + return new TelegramSendResult(false, e.getStatusCode().value()); + } catch (HttpServerErrorException e) { + return new TelegramSendResult(false, e.getStatusCode().value()); + } catch (Exception e) { + log.debug("Telegram send failed: {}", e.getMessage()); + return new TelegramSendResult(false, 0); + } + } + } + + /** + * Posts to Telegram API. On 429, schedules a single retry after retry_after seconds. + * + * @param url Telegram API URL + * @param entity Request entity (must be reusable for retry, e.g. no streams) + * @return Response body, or null if call failed and was scheduled for retry + */ + public ResponseEntity post(String url, HttpEntity entity) { + return post(url, entity, 0); + } + + private ResponseEntity post(String url, HttpEntity entity, int retryCount) { + try { + ResponseEntity response = restTemplate.postForEntity( + url, + entity, + TelegramApiResponse.class + ); + return response; + } catch (HttpClientErrorException e) { + if (e.getStatusCode().value() == 429 && retryCount == 0) { + int retryAfterSeconds = parseRetryAfter(e); + if (retryAfterSeconds > 0 && retryAfterSeconds <= MAX_RETRY_AFTER_SECONDS) { + log.warn("Telegram API 429 Too Many Requests, scheduling retry in {}s", retryAfterSeconds); + scheduler.schedule(() -> { + try { + post(url, entity, 1); + } catch (Exception ex) { + log.warn("Telegram API retry failed after 429 – outbound message lost, may affect registration welcome: {}", ex.getMessage()); + log.error("Telegram API retry failed", ex); + } + }, retryAfterSeconds, TimeUnit.SECONDS); + return null; + } + } + throw e; + } + } + + /** + * Parse retry_after from Telegram 429 response body (parameters.retry_after). + * Value may be in seconds or milliseconds (if > 120 we treat as ms and convert to seconds). + */ + private int parseRetryAfter(HttpClientErrorException e) { + try { + String body = e.getResponseBodyAsString(); + if (body == null || body.isEmpty()) { + return 60; + } + JsonNode root = objectMapper.readTree(body); + JsonNode params = root != null ? root.get("parameters") : null; + if (params != null && params.has("retry_after")) { + JsonNode ra = params.get("retry_after"); + if (!ra.isNumber()) return 60; + double value = ra.asDouble(); + if (value > 120) { + value = value / 1000.0; + } + return (int) Math.ceil(Math.max(1, value)); + } + } catch (Exception parseEx) { + log.debug("Could not parse retry_after from 429 response: {}", parseEx.getMessage()); + } + return 60; + } + + @PreDestroy + public void shutdown() { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/lottery/lottery/service/TelegramService.java b/src/main/java/com/lottery/lottery/service/TelegramService.java new file mode 100644 index 0000000..ee85146 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/TelegramService.java @@ -0,0 +1,106 @@ +package com.lottery.lottery.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.lottery.lottery.config.TelegramProperties; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Service for interacting with Telegram Bot API. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TelegramService { + + private final TelegramProperties telegramProperties; + private final RestTemplate restTemplate = new RestTemplate(); + + /** + * Checks if a user is a member of a Telegram channel/chat. + * Requires channelCheckerBotToken to be configured. + * + * @param telegramUserId Telegram user ID + * @param chatId Chat ID (can be channel username like "@lottery_2026_test_channel" or numeric ID) + * @return true if user is a member (member, administrator, or creator), false otherwise + * @throws IllegalStateException if channelCheckerBotToken is not configured + */ + public boolean isUserMemberOfChat(Long telegramUserId, String chatId) { + String botToken = telegramProperties.getChannelCheckerBotToken(); + if (botToken == null || botToken.isEmpty()) { + log.error("Channel checker bot token is not configured"); + throw new IllegalStateException("Channel checker bot token is not configured. Please set TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN environment variable or configure it in the mounted secret file."); + } + + String url = "https://api.telegram.org/bot" + botToken + "/getChatMember"; + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url) + .queryParam("chat_id", chatId) + .queryParam("user_id", telegramUserId); + + try { + ResponseEntity response = + restTemplate.getForEntity( + builder.toUriString(), + TelegramGetChatMemberResponse.class + ); + + if (response.getBody() == null || !Boolean.TRUE.equals(response.getBody().getOk())) { + log.warn("getChatMember returned not ok: userId={}, chatId={}", telegramUserId, chatId); + return false; + } + + String status = response.getBody().getResult().getStatus(); + + return switch (status) { + case "member", "administrator", "creator" -> true; + default -> false; // left, kicked, restricted + }; + + } catch (HttpClientErrorException.NotFound e) { + // user or chat not found + log.warn("User or chat not found: userId={}, chatId={}", telegramUserId, chatId); + return false; + } catch (HttpClientErrorException.BadRequest e) { + // Check if this is PARTICIPANT_ID_INVALID or "member not found" (user is not a member of the channel) + String responseBody = e.getResponseBodyAsString(); + if (responseBody != null && (responseBody.contains("PARTICIPANT_ID_INVALID") || + responseBody.contains("member not found"))) { + return false; + } + // Other 400 errors are real problems + log.error("Bad request when checking chat membership: userId={}, chatId={}, response={}", + telegramUserId, chatId, responseBody); + throw new IllegalStateException("Telegram API bad request", e); + } catch (Exception e) { + // timeout, 5xx, network, etc + log.error("Error checking chat membership: userId={}, chatId={}", telegramUserId, chatId, e); + throw new IllegalStateException("Telegram API error", e); + } + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TelegramGetChatMemberResponse { + @JsonProperty("ok") + private Boolean ok; + + @JsonProperty("result") + private ChatMemberResult result; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ChatMemberResult { + @JsonProperty("status") + private String status; + } +} + diff --git a/src/main/java/com/lottery/lottery/service/TransactionService.java b/src/main/java/com/lottery/lottery/service/TransactionService.java new file mode 100644 index 0000000..b291e0d --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/TransactionService.java @@ -0,0 +1,234 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.TransactionDto; +import com.lottery.lottery.model.Transaction; +import com.lottery.lottery.repository.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing transaction records. + * Creates transaction entries for all user balance changes. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TransactionService { + + private final TransactionRepository transactionRepository; + private final LocalizationService localizationService; + + /** + * Creates a deposit transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive) + */ + @Transactional + public void createDepositTransaction(Integer userId, Long amount) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(amount) + .type(Transaction.TransactionType.DEPOSIT) + .build(); + transactionRepository.save(transaction); + log.debug("Created deposit transaction: userId={}, amount={}", userId, amount); + } + + /** + * Creates a withdrawal transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive, will be stored as negative) + */ + @Transactional + public void createWithdrawalTransaction(Integer userId, Long amount) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(-amount) // Store as negative + .type(Transaction.TransactionType.WITHDRAWAL) + .build(); + transactionRepository.save(transaction); + log.debug("Created withdrawal transaction: userId={}, amount={}", userId, amount); + } + + /** + * Creates a win transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive, total payout) + * @param roundId Round ID + */ + @Transactional + public void createWinTransaction(Integer userId, Long amount, Long roundId) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(amount) + .type(Transaction.TransactionType.WIN) + .roundId(roundId) + .build(); + transactionRepository.save(transaction); + log.debug("Created win transaction: userId={}, amount={}, roundId={}", userId, amount, roundId); + } + + /** + * Creates a bet transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive, will be stored as negative) + * @param roundId Round ID + */ + @Transactional + public void createBetTransaction(Integer userId, Long amount, Long roundId) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(-amount) // Store as negative + .type(Transaction.TransactionType.BET) + .roundId(roundId) + .build(); + transactionRepository.save(transaction); + log.debug("Created bet transaction: userId={}, amount={}, roundId={}", userId, amount, roundId); + } + + /** + * Creates a task bonus transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive) + * @param taskId Task ID + */ + @Transactional + public void createTaskBonusTransaction(Integer userId, Long amount, Integer taskId) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(amount) + .type(Transaction.TransactionType.TASK_BONUS) + .taskId(taskId) + .build(); + transactionRepository.save(transaction); + log.debug("Created task bonus transaction: userId={}, amount={}, taskId={}", userId, amount, taskId); + } + + /** + * Creates a daily bonus transaction. + * + * @param userId User ID + * @param amount Amount in bigint format (positive) + */ + @Transactional + public void createDailyBonusTransaction(Integer userId, Long amount) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(amount) + .type(Transaction.TransactionType.DAILY_BONUS) + .taskId(null) // Daily bonus doesn't have taskId + .build(); + transactionRepository.save(transaction); + log.debug("Created daily bonus transaction: userId={}, amount={}", userId, amount); + } + + /** + * Creates a cancellation of withdrawal transaction. + * Used when admin cancels a payout - refunds tickets to user. + * + * @param userId User ID + * @param amount Amount in bigint format (positive, refund amount) + * @param createdAt Timestamp to use (from payout resolved_at) + */ + @Transactional + public void createCancellationOfWithdrawalTransaction(Integer userId, Long amount, Instant createdAt) { + Transaction transaction = Transaction.builder() + .userId(userId) + .amount(amount) // Positive amount (credit back to user) + .type(Transaction.TransactionType.CANCELLATION_OF_WITHDRAWAL) + .taskId(null) + .roundId(null) + .createdAt(createdAt) // Set custom timestamp (@PrePersist will check if null) + .build(); + transactionRepository.save(transaction); + log.info("Created cancellation of withdrawal transaction: userId={}, amount={}, createdAt={}", + userId, amount, createdAt); + } + + /** + * Gets transactions for a user with pagination. + * Returns 50 transactions per page, ordered by creation time descending (newest first). + * + * @param userId User ID + * @param page Page number (0-indexed) + * @param timezone Optional timezone (e.g., "Europe/London"). If null, uses UTC. + * @param languageCode User's language code for localization (e.g., "EN", "RU") + * @return Page of transactions + */ + @Transactional(readOnly = true) + public Page getUserTransactions(Integer userId, int page, String timezone, String languageCode) { + // Hardcoded page size: 50 transactions per page + Pageable pageable = PageRequest.of(page, 50); + + Page transactions = transactionRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + // Determine timezone to use + ZoneId zoneId; + try { + zoneId = (timezone != null && !timezone.trim().isEmpty()) + ? ZoneId.of(timezone) + : ZoneId.of("UTC"); + } catch (Exception e) { + // Invalid timezone, fallback to UTC + zoneId = ZoneId.of("UTC"); + } + + // Get localized "at" word + String atWord = localizationService.getMessage("dateTime.at", languageCode); + if (atWord == null || atWord.isEmpty()) { + atWord = "at"; // Fallback to English + } + + // Create formatter with localized "at" word + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM '" + atWord + "' HH:mm") + .withZone(zoneId); + + return transactions.map(transaction -> { + // Format date + String date = formatter.format(transaction.getCreatedAt()); + + // Send enum value as string (e.g., "TASK_BONUS", "WIN") - frontend will handle localization + String typeEnumValue = transaction.getType().name(); + + // For DAILY_BONUS, don't include taskId (it should be null) + // For TASK_BONUS, include taskId + Integer taskIdToInclude = (transaction.getType() == Transaction.TransactionType.DAILY_BONUS) + ? null + : transaction.getTaskId(); + + return TransactionDto.builder() + .amount(transaction.getAmount()) + .date(date) + .type(typeEnumValue) // Send enum value, not localized string + .taskId(taskIdToInclude) + .roundId(transaction.getRoundId()) + .build(); + }); + } + + // Note: Transaction type localization is now handled in the frontend. + // Backend sends enum values (TASK_BONUS, WIN, etc.) and frontend translates them. + // This method is no longer used but kept for reference. + @Deprecated + private String mapTransactionTypeToDisplayName(Transaction.TransactionType type, String languageCode) { + // This method is deprecated - frontend handles all transaction type localization + return type.name(); + } +} + diff --git a/src/main/java/com/lottery/lottery/service/UserService.java b/src/main/java/com/lottery/lottery/service/UserService.java new file mode 100644 index 0000000..fcb35b0 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/UserService.java @@ -0,0 +1,478 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.ReferralDto; +import com.lottery.lottery.model.UserA; +import com.lottery.lottery.model.UserB; +import com.lottery.lottery.model.UserD; +import com.lottery.lottery.repository.UserARepository; +import com.lottery.lottery.repository.UserBRepository; +import com.lottery.lottery.repository.UserDRepository; +import com.lottery.lottery.util.IpUtils; +import com.lottery.lottery.util.TimeProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Optional; + +/** + * Service for user management with sharded tables. + * Handles registration, login, and referral system. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserARepository userARepository; + private final UserBRepository userBRepository; + private final AvatarService avatarService; + private final UserDRepository userDRepository; + private final CountryCodeService countryCodeService; + private final LocalizationService localizationService; + + /** + * Gets user balance. + */ + public Long getUserBalance(Integer userId) { + return userBRepository.findById(userId) + .map(UserB::getBalanceA) + .orElse(null); + } + + /** + * Gets or creates a user based on Telegram data. + * Updates user data on each login. + * Handles referral system if start parameter is present (only for bot registration). + * + * @param tgUserData Parsed Telegram data (contains "user" map and optional "start" string for bot registration) + * @param request HTTP request for IP extraction + * @return UserA entity + */ + @Transactional + public UserA getOrCreateUser(Map tgUserData, HttpServletRequest request) { + // Extract user data and start parameter (only used for bot registration, not WebApp) + @SuppressWarnings("unchecked") + Map tgUser = (Map) tgUserData.get("user"); + String start = (String) tgUserData.get("start"); + Long telegramId = ((Number) tgUser.get("id")).longValue(); + String firstName = (String) tgUser.get("first_name"); + String lastName = (String) tgUser.get("last_name"); + String username = (String) tgUser.get("username"); + Boolean isPremium = (Boolean) tgUser.get("is_premium"); + String languageCode = (String) tgUser.get("language_code"); + + // Build screen_name from first_name and last_name + String screenName = buildScreenName(firstName, lastName); + + // device_code: language_code from initData (uppercase), max 5 chars (DB column limit). + // Telegram may send BCP 47 codes like "en-US", "zh-Hans" which exceed VARCHAR(5). + String deviceCode = normalizeDeviceCode(languageCode); + + // Get client IP and convert to bytes + String clientIp = IpUtils.getClientIp(request); + byte[] ipBytes = IpUtils.ipToBytes(clientIp); + + // Determine if this is a bot registration (bot registration always has start parameter set, even if null) + // WebApp initData never has start parameter (it's always missing, not null) + boolean isBotRegistration = tgUserData.containsKey("start"); + + // Get country code from IP + // For bot registration, IP will be bot server's IP (incorrect), so use "XX" for new users + // For existing users, country_code is preserved anyway + String countryCode; + if (isBotRegistration) { + // Bot registration: can't determine user's real country from bot server IP + // Set to "XX" for new users (existing users preserve their original country_code) + countryCode = "XX"; + log.debug("Bot registration detected: using country_code=XX (cannot determine from bot server IP)"); + } else { + // WebApp registration: IP is user's real IP, so we can determine country + countryCode = countryCodeService.getCountryCode(clientIp); + } + + // Get current timestamp + long nowSeconds = TimeProvider.nowSeconds(); + + // Check if user exists + Optional existingUserOpt = userARepository.findByTelegramId(telegramId); + + if (existingUserOpt.isPresent()) { + // User exists + UserA userA = existingUserOpt.get(); + + // Use the isBotRegistration variable already determined above + // Bot registration = user opening referral link when already registered (no updates) + // WebApp login = actual login/update (update fields) + if (isBotRegistration) { + // Bot registration: User is already registered, just opening referral link + // Do NOT update any fields - just return the user as-is + log.debug("Existing user opening referral link: userId={}, telegramId={}", userA.getId(), telegramId); + return userA; + } else { + // WebApp login: User is actually logging in/updating, so update their data + log.debug("Updating user data on login: userId={}", userA.getId()); + + // Update avatar if Telegram file_id changed (only downloads if changed) + avatarService.updateAvatarIfNeeded(userA, telegramId, tgUser); + + // Update user data on login (including country_code from real IP) + updateUserOnLogin(userA, screenName, username, isPremium, countryCode, deviceCode, languageCode, ipBytes, nowSeconds); + return userA; + } + } else { + // New user - create in all 3 tables + log.info("New user registration: telegramId={}, referral={}", telegramId, start != null && !start.isEmpty() ? "yes" : "no"); + try { + return createNewUser(telegramId, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds, start, tgUser); + } catch (Exception e) { + log.warn("New user creation failed for telegramId={}, possible duplicate or DB error: {}", telegramId, e.getMessage()); + throw e; + } + } + } + + // Supported languages for the application + private static final java.util.Set SUPPORTED_LANGUAGES = java.util.Set.of( + "EN", "RU", "DE", "IT", "NL", "PL", "FR", "ES", "ID", "TR" + ); + + /** + * Updates user data on login (when session is created via WebApp). + * Sets language_code only if it's null, empty, or 'XX' (not set yet). + * + * @param userA User to update + * @param screenName Updated screen name + * @param username Updated username + * @param isPremium Updated premium status + * @param countryCode Country code from user's real IP (WebApp only, not bot) + * @param deviceCode Device code (always updated) + * @param languageCode Language code from initData (only set if user's languageCode is null, empty, or 'XX') + * @param ipBytes IP address bytes + * @param nowSeconds Current timestamp + */ + private void updateUserOnLogin(UserA userA, String screenName, String username, Boolean isPremium, + String countryCode, String deviceCode, String languageCode, + byte[] ipBytes, long nowSeconds) { + userA.setScreenName(screenName); + userA.setTelegramName(username != null ? username : "-"); + userA.setIsPremium(isPremium != null && isPremium ? 1 : 0); + // Update country_code from user's real IP (WebApp login) + userA.setCountryCode(countryCode); + // Update device_code always + userA.setDeviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX"); + // Update language_code only if it's null, empty, or 'XX' (not set yet) + String currentLanguageCode = userA.getLanguageCode(); + if (currentLanguageCode == null || currentLanguageCode.isEmpty() || "XX".equals(currentLanguageCode)) { + String newLanguageCode = determineLanguageCode(languageCode); + userA.setLanguageCode(newLanguageCode); + log.debug("Set language_code on login: userId={}, languageCode={}", userA.getId(), newLanguageCode); + } else { + log.debug("Preserved existing language_code on login: userId={}, languageCode={}", userA.getId(), currentLanguageCode); + } + userA.setIp(ipBytes); + userA.setDateLogin((int) nowSeconds); + + userARepository.save(userA); + log.debug("Updated user data on login: userId={}", userA.getId()); + } + + /** + * Determines the language code to use. + * If the language from initData is supported, use it. Otherwise, default to EN. + */ + private String determineLanguageCode(String languageCodeFromInitData) { + if (languageCodeFromInitData == null || languageCodeFromInitData.isEmpty()) { + return "EN"; + } + String upperCode = languageCodeFromInitData.toUpperCase(); + // Check if it's a supported language + if (SUPPORTED_LANGUAGES.contains(upperCode)) { + return upperCode; + } + // Default to EN if not supported + return "EN"; + } + + /** + * Updates user's language code (called when user changes language in app header). + */ + @Transactional + public void updateLanguageCode(Integer userId, String languageCode) { + Optional userOpt = userARepository.findById(userId); + if (userOpt.isPresent()) { + UserA user = userOpt.get(); + user.setLanguageCode(languageCode != null && languageCode.length() == 2 ? languageCode.toUpperCase() : "XX"); + userARepository.save(user); + log.debug("Updated language_code for userId={}: {}", userId, user.getLanguageCode()); + } + } + + /** + * Creates a new user in all 3 tables with referral handling. + */ + private UserA createNewUser(Long telegramId, String screenName, String username, Boolean isPremium, + String languageCode, String countryCode, String deviceCode, + byte[] ipBytes, long nowSeconds, String start, Map tgUser) { + + // Determine language code (use supported language or default to EN) + String determinedLanguageCode = determineLanguageCode(languageCode); + + // Create UserA (avatar will be set after user is created and we have userId) + UserA userA = UserA.builder() + .screenName(screenName) + .telegramId(telegramId) + .telegramName(username != null ? username : "-") + .isPremium(isPremium != null && isPremium ? 1 : 0) + .languageCode(determinedLanguageCode) + .countryCode(countryCode) + .deviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX") + .ip(ipBytes) + .dateReg((int) nowSeconds) + .dateLogin((int) nowSeconds) + .banned(0) + .build(); + + userA = userARepository.save(userA); + Integer userId = userA.getId(); + + log.info("New user created: userId={}, telegramId={}", userId, telegramId); + + // Update avatar (downloads and saves, stores URL in DB with ?v=timestamp) + avatarService.updateAvatarIfNeeded(userA, telegramId, tgUser); + + // Create UserB with same ID + // Initial balance_a is 5,000,000 (5.00 tickets in bigint format) + UserB userB = UserB.builder() + .id(userId) + .balanceA(5_000_000L) + .balanceB(0L) + .depositTotal(0L) + .depositCount(0) + .withdrawTotal(0L) + .withdrawCount(0) + .build(); + userBRepository.save(userB); + + // Create UserD with referral handling + UserD userD = createUserDWithReferral(userId, screenName, start); + userDRepository.save(userD); + + return userA; + } + + /** + * Creates UserD entity with referral chain setup. + * IMPORTANT: This method should ONLY be called for NEW users during initial registration. + * Referral cannot be changed after user registration. + * + * @param userId New user's ID + * @param screenName User's screen name (from db_users_a) + * @param start Referral parameter (from bot registration, not WebApp) + */ + private UserD createUserDWithReferral(Integer userId, String screenName, String start) { + log.debug("Creating UserD with referral: userId={}, start={}", userId, start); + + // Defensive check: Ensure UserD doesn't already exist (should never happen, but safety check) + Optional existingUserD = userDRepository.findById(userId); + if (existingUserD.isPresent()) { + log.error("CRITICAL: Attempted to create UserD for existing userId={}. UserD already exists. Skipping UserD creation.", userId); + return existingUserD.get(); // Return existing UserD without modification + } + + UserD.UserDBuilder builder = UserD.builder() + .id(userId) + .screenName(screenName != null ? screenName : "-") + .refererId1(0) + .refererId2(0) + .refererId3(0) + .refererId4(0) + .refererId5(0) + .masterId(1) // Default master_id = 1 + .referals1(0) + .referals2(0) + .referals3(0) + .referals4(0) + .referals5(0) + .fromReferals1(0L) + .fromReferals2(0L) + .fromReferals3(0L) + .fromReferals4(0L) + .fromReferals5(0L) + .toReferer1(0L) + .toReferer2(0L) + .toReferer3(0L) + .toReferer4(0L) + .toReferer5(0L); + + if (start != null && !start.isEmpty()) { + try { + Integer refererId = Integer.parseInt(start); + + // Input validation: bounds check referral ID + if (refererId <= 0) { + log.warn("Invalid referral ID (non-positive) - userId={}, refererId={}", userId, refererId); + } else if (refererId > 2147483647) { + log.warn("Invalid referral ID (too large) - userId={}, refererId={}", userId, refererId); + } else if (refererId.equals(userId)) { + log.warn("Self-referral attempt blocked - userId={}", userId); + } else { + // Validate referer exists in UserA (main user table) + Optional refererUserAOpt = userARepository.findById(refererId); + if (refererUserAOpt.isEmpty()) { + log.warn("Invalid referer (not found) - userId={}, refererId={}", userId, refererId); + } else { + UserA refererUserA = refererUserAOpt.get(); + + // Check if referer is banned + if (refererUserA.getBanned() != null && refererUserA.getBanned() > 0) { + log.warn("Banned referer attempt - userId={}, refererId={}", userId, refererId); + } else { + // Referer is valid, check UserD + Optional refererUserDOpt = userDRepository.findById(refererId); + + if (refererUserDOpt.isPresent()) { + UserD refererUserD = refererUserDOpt.get(); + + // Set referral chain: shift referer's chain down by 1 level (3-level system) + builder.refererId1(refererId) + .masterId(refererUserD.getMasterId()) + .refererId2(refererUserD.getRefererId1()) + .refererId3(refererUserD.getRefererId2()); + + // Increment referal counts for all 3 levels up the chain + setupReferralChain(userId, refererId); + log.info("Referral chain set: userId={}, refererId={}", userId, refererId); + } else { + log.warn("Referer missing UserD record - userId={}, refererId={}", userId, refererId); + builder.refererId1(refererId); + } + } + } + } + } catch (NumberFormatException e) { + log.warn("Invalid referral format - userId={}, start='{}'", userId, start); + } + } + + UserD userD = builder.build(); + log.debug("Created UserD: userId={}, refererId1={}, refererId2={}, refererId3={}", + userId, userD.getRefererId1(), userD.getRefererId2(), userD.getRefererId3()); + + return userD; + } + + /** + * Sets up referral chain and increments referal counts for all 3 levels. + * Example: If user D registers with referer C, increments: + * - referals_1 for C + * - referals_2 for B (C's referer_id_1) + * - referals_3 for A (B's referer_id_1) + */ + private void setupReferralChain(Integer newUserId, Integer refererId) { + log.debug("Setting up referral chain: newUserId={}, refererId={}", newUserId, refererId); + + // Level 1: Direct referer + userDRepository.incrementReferals1(refererId); + + Optional level1Opt = userDRepository.findById(refererId); + if (level1Opt.isEmpty()) { + log.warn("Level 1 referer not found after increment: refererId={}", refererId); + return; + } + + UserD level1 = level1Opt.get(); + + // Level 2 + if (level1.getRefererId1() > 0) { + userDRepository.incrementReferals2(level1.getRefererId1()); + + Optional level2Opt = userDRepository.findById(level1.getRefererId1()); + if (level2Opt.isPresent()) { + UserD level2 = level2Opt.get(); + + // Level 3 + if (level2.getRefererId1() > 0) { + userDRepository.incrementReferals3(level2.getRefererId1()); + } + } + } + } + + /** Max length for device_code column (db_users_a). */ + private static final int DEVICE_CODE_MAX_LENGTH = 5; + + /** + * Normalizes Telegram language_code for storage in device_code (VARCHAR(5)). + * Uses primary part of BCP 47 codes (e.g. "en-US" -> "EN") and truncates to 5 chars. + */ + private static String normalizeDeviceCode(String languageCode) { + if (languageCode == null || languageCode.isEmpty()) { + return "XX"; + } + String primary = languageCode.contains("-") ? languageCode.split("-", 2)[0] : languageCode; + String normalized = primary.toUpperCase().trim(); + return normalized.length() > DEVICE_CODE_MAX_LENGTH ? normalized.substring(0, DEVICE_CODE_MAX_LENGTH) : normalized; + } + + /** + * Builds screen_name from first_name and last_name. + */ + private String buildScreenName(String firstName, String lastName) { + StringBuilder sb = new StringBuilder(); + if (firstName != null && !firstName.isEmpty()) { + sb.append(firstName); + } + if (lastName != null && !lastName.isEmpty()) { + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(lastName); + } + String result = sb.toString().trim(); + return result.isEmpty() ? "-" : (result.length() > 75 ? result.substring(0, 75) : result); + } + + /** + * Gets user by ID. + */ + public Optional getUserById(Integer userId) { + return userARepository.findById(userId); + } + + /** + * Gets user by Telegram ID. + */ + public Optional getUserByTelegramId(Long telegramId) { + return userARepository.findByTelegramId(telegramId); + } + + /** + * Gets referrals for a specific level with pagination. + * Always returns 50 results per page. + * + * @param userId The user ID to get referrals for + * @param level The referral level (1, 2, or 3) + * @param page Page number (0-indexed) + * @return Page of referrals with name and commission + */ + public Page getReferrals(Integer userId, Integer level, Integer page) { + // Fixed page size of 50 to prevent database overload + Pageable pageable = PageRequest.of(page, 50); + + return switch (level) { + case 1 -> userDRepository.findReferralsLevel1(userId, pageable); + case 2 -> userDRepository.findReferralsLevel2(userId, pageable); + case 3 -> userDRepository.findReferralsLevel3(userId, pageable); + default -> throw new IllegalArgumentException( + localizationService.getMessage("user.error.referralLevelInvalid", String.valueOf(level))); + }; + } +} + diff --git a/src/main/java/com/lottery/lottery/service/WithdrawalStatusSyncService.java b/src/main/java/com/lottery/lottery/service/WithdrawalStatusSyncService.java new file mode 100644 index 0000000..d125134 --- /dev/null +++ b/src/main/java/com/lottery/lottery/service/WithdrawalStatusSyncService.java @@ -0,0 +1,127 @@ +package com.lottery.lottery.service; + +import com.lottery.lottery.dto.WithdrawalInfoApiResponse; +import com.lottery.lottery.model.Payout; +import com.lottery.lottery.repository.PayoutRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Cron job: once per minute, fetches PROCESSING payouts (with payment_id), checks status + * via GET api/v1/withdrawals-info/{payment_id}, and updates DB (status, updated_at, and + * for COMPLETED/CANCELLED the same logic as admin complete/cancel). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WithdrawalStatusSyncService { + + private static final int BATCH_SIZE = 20; + + private final PayoutRepository payoutRepository; + private final CryptoWithdrawalService cryptoWithdrawalService; + private final PayoutService payoutService; + + @Scheduled(cron = "0 * * * * *") // Every minute at second 0 + @Transactional + public void syncWithdrawalStatuses() { + List payouts = payoutRepository.findByTypeAndStatusInAndPaymentIdIsNotNullOrderByUpdatedAtAsc( + Payout.PayoutType.CRYPTO, + Set.of(Payout.PayoutStatus.PROCESSING, Payout.PayoutStatus.WAITING), + PageRequest.of(0, BATCH_SIZE) + ); + log.info("Withdrawal sync cron: starting, {} CRYPTO payout(s) to process", payouts.size()); + if (payouts.isEmpty()) { + return; + } + Instant now = Instant.now(); + + for (Payout payout : payouts) { + Integer paymentId = payout.getPaymentId(); + try { + if (paymentId == null) { + log.warn("Withdrawal sync: skipping payout id={} (payment_id is null)", payout.getId()); + continue; + } + log.debug("Withdrawal sync: checking payout id={}, paymentId={}, status={}", payout.getId(), paymentId, payout.getStatus()); + Optional infoOpt = cryptoWithdrawalService.getWithdrawalInfo(paymentId); + if (infoOpt.isEmpty() || infoOpt.get().getResult() == null + || infoOpt.get().getResult().getPaymentList() == null + || infoOpt.get().getResult().getPaymentList().isEmpty()) { + log.warn("Withdrawal info empty or no payment_list: payoutId={}, paymentId={}", payout.getId(), paymentId); + payout.setUpdatedAt(now); + payoutRepository.save(payout); + continue; + } + WithdrawalInfoApiResponse.PaymentItem paymentItem = infoOpt.get().getResult().getPaymentList().get(0); + String apiStatus = paymentItem.getStatus(); + if (paymentItem.getTxhash() != null && !paymentItem.getTxhash().isBlank()) { + payout.setTxhash(paymentItem.getTxhash()); + } + Payout.PayoutStatus newStatus = mapApiStatusToPayoutStatus(apiStatus); + if (newStatus == null) { + log.warn("Unknown withdrawal API status: payoutId={}, paymentId={}, status={}", payout.getId(), paymentId, apiStatus); + payout.setUpdatedAt(now); + payoutRepository.save(payout); + continue; + } + + switch (newStatus) { + case PROCESSING: + payout.setUpdatedAt(now); + payoutRepository.save(payout); + log.debug("Withdrawal sync: payout id={} still PROCESSING, updated_at refreshed", payout.getId()); + break; + case WAITING: + payout.setStatus(Payout.PayoutStatus.WAITING); + payout.setUpdatedAt(now); + payoutRepository.save(payout); + log.debug("Withdrawal sync: payout id={}, paymentId={} -> WAITING", payout.getId(), paymentId); + break; + case COMPLETED: + payoutRepository.save(payout); // persist txhash if set + payoutService.markPayoutCompleted(payout.getId()); + log.debug("Withdrawal sync: payout id={}, paymentId={} -> COMPLETED", payout.getId(), paymentId); + break; + case CANCELLED: + payoutRepository.save(payout); // persist txhash if set + payoutService.markPayoutCancelled(payout.getId()); + log.debug("Withdrawal sync: payout id={}, paymentId={} -> CANCELLED", payout.getId(), paymentId); + break; + default: + payout.setUpdatedAt(now); + payoutRepository.save(payout); + } + } catch (Exception e) { + log.warn("Withdrawal sync error for payout id={}, paymentId={}: {}", payout.getId(), paymentId, e.getMessage(), e); + // Still update updated_at so we don't block the batch forever on one bad record + payout.setUpdatedAt(now); + payoutRepository.save(payout); + } + } + log.debug("Withdrawal sync cron: finished"); + } + + /** -1 PROCESSING, 0 WAITING, 1 COMPLETED, 2 CANCELLED */ + private static Payout.PayoutStatus mapApiStatusToPayoutStatus(String status) { + if (status == null) { + return null; + } + return switch (status.trim()) { + case "-1" -> Payout.PayoutStatus.PROCESSING; + case "0" -> Payout.PayoutStatus.WAITING; + case "1" -> Payout.PayoutStatus.COMPLETED; + case "2" -> Payout.PayoutStatus.CANCELLED; + default -> null; + }; + } +} diff --git a/src/main/java/com/honey/honey/util/IpUtils.java b/src/main/java/com/lottery/lottery/util/IpUtils.java similarity index 99% rename from src/main/java/com/honey/honey/util/IpUtils.java rename to src/main/java/com/lottery/lottery/util/IpUtils.java index 4dc06e9..8b71e1a 100644 --- a/src/main/java/com/honey/honey/util/IpUtils.java +++ b/src/main/java/com/lottery/lottery/util/IpUtils.java @@ -1,4 +1,4 @@ -package com.honey.honey.util; +package com.lottery.lottery.util; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/lottery/lottery/util/TelegramTokenRedactor.java b/src/main/java/com/lottery/lottery/util/TelegramTokenRedactor.java new file mode 100644 index 0000000..e7b3de2 --- /dev/null +++ b/src/main/java/com/lottery/lottery/util/TelegramTokenRedactor.java @@ -0,0 +1,26 @@ +package com.lottery.lottery.util; + +import java.util.regex.Pattern; + +/** + * Redacts Telegram Bot API token from strings before logging. + * Token appears in URLs like https://api.telegram.org/bot123456:AAHxxx.../methodName + */ +public final class TelegramTokenRedactor { + + private static final Pattern BOT_TOKEN_IN_URL = Pattern.compile("(/bot)([^/]+)(/)"); + + private TelegramTokenRedactor() { + } + + /** + * Replaces the bot token in Telegram API URLs with a redaction placeholder. + * Safe to call with null (returns null). + */ + public static String redact(String message) { + if (message == null || message.isEmpty()) { + return message; + } + return BOT_TOKEN_IN_URL.matcher(message).replaceAll("$1***REDACTED***$3"); + } +} diff --git a/src/main/java/com/honey/honey/util/TimeProvider.java b/src/main/java/com/lottery/lottery/util/TimeProvider.java similarity index 94% rename from src/main/java/com/honey/honey/util/TimeProvider.java rename to src/main/java/com/lottery/lottery/util/TimeProvider.java index 9c4fee3..d24e7e3 100644 --- a/src/main/java/com/honey/honey/util/TimeProvider.java +++ b/src/main/java/com/lottery/lottery/util/TimeProvider.java @@ -1,4 +1,4 @@ -package com.honey.honey.util; +package com.lottery.lottery.util; import java.time.Instant; @@ -25,3 +25,4 @@ public class TimeProvider { } } + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4f7ddbf..8dcc5aa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,17 +1,32 @@ server: port: 8080 + # Tomcat thread pool settings for high concurrency + tomcat: + threads: + max: 500 + min-spare: 50 + # Allow large headers (for Telegram auth tokens) + max-http-header-size: 20KB + # Graceful shutdown for zero-downtime deployments + shutdown: graceful spring: application: - name: honey-be + name: lottery-be + lifecycle: + timeout-per-shutdown-phase: 30s datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/honey_db} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/lottery_db} username: ${SPRING_DATASOURCE_USERNAME:root} password: ${SPRING_DATASOURCE_PASSWORD:password} driver-class-name: com.mysql.cj.jdbc.Driver hikari: + # Connection pool settings for high concurrency + maximum-pool-size: 50 + minimum-idle: 20 connection-timeout: 30000 + idle-timeout: 600000 initialization-fail-timeout: -1 jpa: @@ -34,6 +49,11 @@ spring: telegram: bot-token: ${TELEGRAM_BOT_TOKEN} + # Bot token for checking channel membership (separate from main bot token) + channel-checker-bot-token: ${TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN:} + # Channel ID for follow tasks (e.g., "@lottery_2026_test_channel" or numeric ID) + follow-task-channel-id: ${TELEGRAM_FOLLOW_TASK_CHANNEL_ID:@win_spin_news} + follow-task-channel-id-2: ${TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2:@win_spin_withdrawals} app: session: @@ -46,18 +66,85 @@ app: # Maximum number of batches to process per cleanup run max-batches-per-run: ${APP_SESSION_CLEANUP_MAX_BATCHES:20} + data-cleanup: + # Batch cleanup configuration for game_round_participants and transactions + cleanup: + # Number of records to delete per batch + batch-size: ${APP_DATA_CLEANUP_BATCH_SIZE:5000} + # Maximum number of batches to process per cleanup run + max-batches-per-run: ${APP_DATA_CLEANUP_MAX_BATCHES:20} + # Sleep time in milliseconds between batches (to let database process WebSocket inserts) + batch-sleep-ms: ${APP_DATA_CLEANUP_BATCH_SLEEP_MS:500} + + avatar: + # Storage path for avatar files (relative to app root or absolute path) + # Railway: ephemeral filesystem (acceptable for testing) + # Inferno: should be mounted as Docker volume (e.g., /data/avatars) + storage-path: ${APP_AVATAR_STORAGE_PATH:./data/avatars} + # Enable Spring ResourceHandler for avatar serving + # Railway: Set ENABLE_SPRING_AVATAR_HANDLER=true (Spring Boot serves avatars) + # VPS: Leave unset or false (Nginx serves avatars via location ^~ /avatars/) + enable-spring-handler: ${ENABLE_SPRING_AVATAR_HANDLER:false} + # Public base URL for avatar URLs (e.g., https://api.example.com) + # If empty, will use server's base URL + public-base-url: ${APP_AVATAR_PUBLIC_BASE_URL:} + # Maximum avatar file size in bytes (default: 2MB) + max-size-bytes: ${APP_AVATAR_MAX_SIZE_BYTES:2097152} + # Maximum avatar dimension in pixels (default: 110x110) + max-dimension: ${APP_AVATAR_MAX_DIMENSION:110} + # Avatar URL cache TTL in minutes (default: 5 minutes) + cache-ttl-minutes: ${APP_AVATAR_CACHE_TTL_MINUTES:5} + + websocket: + # Allowed origins for WebSocket CORS (comma-separated) + # Default includes production domain and Telegram WebView domains + allowed-origins: ${APP_WEBSOCKET_ALLOWED_ORIGINS:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org} + + # Lottery bot scheduler: auto-joins bots from lottery_bot_configs into joinable rounds. Toggle via admin Feature Switches (lottery_bot_scheduler_enabled). + # Bet amount is decided in-process by persona + loss-streak and zone logic (no external API). + lottery-bot: + schedule-fixed-delay-ms: ${APP_LOTTERY_BOT_SCHEDULE_FIXED_DELAY_MS:5000} + + # Secret token for remote bet API (GET /api/remotebet/{token}?user_id=&room=&amount=). No auth; enable via Feature Switchers in admin. + remote-bet: + token: ${APP_REMOTE_BET_TOKEN:} + # Token in path for open user-check API: GET /api/check_user/{token}/{telegramId}. Set on VPS only. + check-user: + token: ${APP_CHECK_USER_TOKEN:} + # Token in path for 3rd party deposit webhook: POST /api/deposit_webhook/{token}. Body: user_id, usd_amount (bigint: 1_000_000 = 1 USD). Set on VPS only. + deposit-webhook: + token: ${APP_PASSIM_WEBHOOK_TOKEN:} + # Token in path for Telegram webhook: POST /api/telegram/webhook/{token}. Set on VPS only; use same value when registering webhook URL with Telegram. + telegram-webhook: + token: ${APP_TELEGRAM_WEBHOOK_TOKEN:} + # Token in path for notify broadcast: POST /api/notify_broadcast/{token} and POST /api/notify_broadcast/{token}/stop. Set on VPS only. + notify-broadcast: + token: ${APP_NOTIFY_BROADCAST_TOKEN:} + + # Crypto payment/payout API (deposit methods, etc.). Base URL and API key for https://spin-passim.tech/ + crypto-api: + base-url: ${APP_CRYPTO_API_BASE_URL:https://spin-passim.tech/} + api-key: ${APP_CRYPTO_API_KEY:} + + admin: + jwt: + # JWT secret key (MUST be at least 256 bits / 32 characters) + # Generate a secure random string for production + # Example: openssl rand -base64 32 + secret: ${APP_ADMIN_JWT_SECRET:change-this-to-a-secure-random-string-in-production-min-32-characters} + # JWT expiration time in milliseconds (default: 24 hours) + expiration: ${APP_ADMIN_JWT_EXPIRATION:86400000} + # GeoIP configuration # Set GEOIP_DB_PATH environment variable to use external file (recommended for production) # If not set, falls back to classpath:geoip/GeoLite2-Country.mmdb geoip: db-path: ${GEOIP_DB_PATH:} -logging: - level: - root: INFO - org.springframework.boot.context.config: DEBUG - org.springframework.core.env: DEBUG - com.honey: DEBUG +# Logging configuration moved to logback-spring.xml +# To use external logback-spring.xml on VPS, set system property: +# -Dlogging.config=/path/to/logback-spring.xml +# Or environment variable: LOGGING_CONFIG=/path/to/logback-spring.xml management: endpoints: diff --git a/src/main/resources/assets/bot_start.mp4 b/src/main/resources/assets/bot_start.mp4 new file mode 100644 index 0000000..d36c8dc Binary files /dev/null and b/src/main/resources/assets/bot_start.mp4 differ diff --git a/src/main/resources/assets/winspin_5.mp4 b/src/main/resources/assets/winspin_5.mp4 new file mode 100644 index 0000000..fd5bc5e Binary files /dev/null and b/src/main/resources/assets/winspin_5.mp4 differ diff --git a/src/main/resources/db/migration/V10__add_indexes_to_payouts.sql b/src/main/resources/db/migration/V10__add_indexes_to_payouts.sql new file mode 100644 index 0000000..2ba4464 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_indexes_to_payouts.sql @@ -0,0 +1,7 @@ +-- Add indexes for faster queries on payouts table +-- Index for fetching user's payouts ordered by creation date +CREATE INDEX idx_payouts_user_created ON payouts(user_id, created_at DESC); + + + + diff --git a/src/main/resources/db/migration/V11__create_payments_table.sql b/src/main/resources/db/migration/V11__create_payments_table.sql new file mode 100644 index 0000000..4becc33 --- /dev/null +++ b/src/main/resources/db/migration/V11__create_payments_table.sql @@ -0,0 +1,22 @@ +-- Create payments table for Telegram Stars payments +CREATE TABLE IF NOT EXISTS payments ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + order_id VARCHAR(255) NOT NULL UNIQUE COMMENT 'Unique order ID for Telegram invoice', + stars_amount INT NOT NULL COMMENT 'Amount in Stars', + tickets_amount BIGINT UNSIGNED NOT NULL COMMENT 'Tickets amount in bigint format (stars * 0.9 * 1,000,000)', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING, COMPLETED, FAILED, CANCELLED', + telegram_payment_charge_id VARCHAR(255) NULL COMMENT 'Telegram payment charge ID', + telegram_provider_payment_charge_id VARCHAR(255) NULL COMMENT 'Telegram provider payment charge ID', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + INDEX idx_user_id (user_id), + INDEX idx_order_id (order_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + + + diff --git a/src/main/resources/db/migration/V12__create_transactions_table.sql b/src/main/resources/db/migration/V12__create_transactions_table.sql new file mode 100644 index 0000000..5154dfb --- /dev/null +++ b/src/main/resources/db/migration/V12__create_transactions_table.sql @@ -0,0 +1,16 @@ +-- Create transactions table for transaction history +CREATE TABLE transactions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + amount BIGINT NOT NULL COMMENT 'Amount in bigint format (positive for credits, negative for debits)', + type VARCHAR(50) NOT NULL COMMENT 'Transaction type: DEPOSIT, WITHDRAWAL, WIN, LOSS, TASK_BONUS', + task_id INT NULL COMMENT 'Task ID for TASK_BONUS type', + round_id BIGINT NULL COMMENT 'Round ID for WIN/LOSS type', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_id_created_at (user_id, created_at DESC), + INDEX idx_user_id_type (user_id, type), + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + + diff --git a/src/main/resources/db/migration/V13__update_task_reward_type_to_tickets.sql b/src/main/resources/db/migration/V13__update_task_reward_type_to_tickets.sql new file mode 100644 index 0000000..919f283 --- /dev/null +++ b/src/main/resources/db/migration/V13__update_task_reward_type_to_tickets.sql @@ -0,0 +1,10 @@ +-- Update task reward_type from 'Stars' to 'Tickets' +-- Update all existing tasks +UPDATE tasks SET reward_type = 'Tickets' WHERE reward_type = 'Stars'; + +-- Update table default value and comment +ALTER TABLE tasks +MODIFY COLUMN reward_type VARCHAR(20) NOT NULL DEFAULT 'Tickets' COMMENT 'Tickets (all tasks use Tickets as reward type)'; + + + diff --git a/src/main/resources/db/migration/V14__update_other_task_title_to_tickets.sql b/src/main/resources/db/migration/V14__update_other_task_title_to_tickets.sql new file mode 100644 index 0000000..3444c93 --- /dev/null +++ b/src/main/resources/db/migration/V14__update_other_task_title_to_tickets.sql @@ -0,0 +1,9 @@ +-- Update "Other" task title and description from "$5" to Tickets format +-- requirement is 500000000 in bigint = 500 tickets +-- Title: "Top up Balance" (short for task list) +-- Description: "Top up Balance: 500 Tickets" (full for task modal) +UPDATE tasks +SET title = 'Top up Balance', + description = 'Top up Balance: 500 Tickets' +WHERE type = 'other' AND title LIKE '%$5%'; + diff --git a/src/main/resources/db/migration/V15__add_avatar_fields_to_users_a.sql b/src/main/resources/db/migration/V15__add_avatar_fields_to_users_a.sql new file mode 100644 index 0000000..1521371 --- /dev/null +++ b/src/main/resources/db/migration/V15__add_avatar_fields_to_users_a.sql @@ -0,0 +1,12 @@ +-- Add avatar_url and last_telegram_file_id columns to db_users_a +-- avatar_url: Stores the public URL with ?v=timestamp parameter for cache busting +-- last_telegram_file_id: Stores the Telegram file_id to avoid re-downloading unchanged avatars +ALTER TABLE `db_users_a` + ADD COLUMN `avatar_url` VARCHAR(500) DEFAULT NULL AFTER `banned`, + ADD COLUMN `last_telegram_file_id` VARCHAR(255) DEFAULT NULL AFTER `avatar_url`; + +-- Add index on avatar_url for faster lookups (optional, but helps if we query by avatar_url) +-- Note: We don't index last_telegram_file_id as it's only used during avatar update + + + diff --git a/src/main/resources/db/migration/V16__create_support_tickets_and_messages.sql b/src/main/resources/db/migration/V16__create_support_tickets_and_messages.sql new file mode 100644 index 0000000..aedb571 --- /dev/null +++ b/src/main/resources/db/migration/V16__create_support_tickets_and_messages.sql @@ -0,0 +1,33 @@ +-- Create support_tickets table +CREATE TABLE support_tickets ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + subject VARCHAR(100) NOT NULL, + status ENUM('OPENED', 'CLOSED') NOT NULL DEFAULT 'OPENED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_user_status (user_id, status), + INDEX idx_created_at (created_at), + + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create support_messages table +CREATE TABLE support_messages ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT NOT NULL, + user_id INT NOT NULL, + message VARCHAR(2000) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_ticket_id (ticket_id), + INDEX idx_user_id (user_id), + INDEX idx_ticket_created (ticket_id, created_at), + + FOREIGN KEY (ticket_id) REFERENCES support_tickets(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql b/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql new file mode 100644 index 0000000..9d87ab8 --- /dev/null +++ b/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql @@ -0,0 +1,12 @@ +-- Add index on game_rounds for join query optimization +-- This helps with the query: SELECT p FROM GameRoundParticipant p WHERE p.userId = :userId +-- AND p.round.phase = 'RESOLUTION' AND p.round.resolvedAt IS NOT NULL ORDER BY p.round.resolvedAt DESC +CREATE INDEX idx_round_phase_resolved ON game_rounds (id, phase, resolved_at DESC); + +-- Add index on game_round_participants for cleanup by joined_at +CREATE INDEX idx_joined_at ON game_round_participants (joined_at); + +-- Add index on transactions for game history queries (filtering by WIN type) and cleanup by created_at +CREATE INDEX idx_type_created_at ON transactions (type, created_at); + + diff --git a/src/main/resources/db/migration/V18__remove_unused_indexes.sql b/src/main/resources/db/migration/V18__remove_unused_indexes.sql new file mode 100644 index 0000000..746407a --- /dev/null +++ b/src/main/resources/db/migration/V18__remove_unused_indexes.sql @@ -0,0 +1,9 @@ +-- Remove unused index on game_round_participants +-- This index was only used for cleanup, which is no longer performed on this table +-- Participants are now deleted immediately after rounds finish +DROP INDEX idx_joined_at ON game_round_participants; + +-- Update comment: idx_type_created_at is now used for game history queries (filtering by WIN type) +-- The cleanup query no longer filters by type, but the index is still useful for game history + + diff --git a/src/main/resources/db/migration/V19__add_daily_bonus_task.sql b/src/main/resources/db/migration/V19__add_daily_bonus_task.sql new file mode 100644 index 0000000..03cadb2 --- /dev/null +++ b/src/main/resources/db/migration/V19__add_daily_bonus_task.sql @@ -0,0 +1,8 @@ +-- Insert Daily Bonus task +-- reward_amount is in bigint format (1 ticket = 1000000) +-- requirement is 24 hours in milliseconds (86400000), but we'll use 0 as placeholder since we check claimed_at timestamp +-- The actual 24h check is done in TaskService.isTaskCompleted() for "daily" type +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES +('daily', 0, 1000000, 'Tickets', 1, 'Daily Bonus', 'Claim 1 free ticket every 24 hours'); + + diff --git a/src/main/resources/db/migration/V1__create_users_table.sql b/src/main/resources/db/migration/V1__create_users_table.sql deleted file mode 100644 index 7389d6c..0000000 --- a/src/main/resources/db/migration/V1__create_users_table.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Create users table -CREATE TABLE IF NOT EXISTS users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - telegram_id BIGINT NOT NULL UNIQUE, - username VARCHAR(255), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_telegram_id (telegram_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - diff --git a/src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql b/src/main/resources/db/migration/V1__initial_schema.sql similarity index 60% rename from src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql rename to src/main/resources/db/migration/V1__initial_schema.sql index 6720f0c..7794759 100644 --- a/src/main/resources/db/migration/V3__replace_users_with_sharded_tables.sql +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -1,27 +1,19 @@ --- Drop foreign key constraint from sessions table if it exists --- Find the foreign key name dynamically (MySQL auto-generates names like sessions_ibfk_1) -SET @fk_name = NULL; -SELECT CONSTRAINT_NAME INTO @fk_name -FROM information_schema.TABLE_CONSTRAINTS -WHERE CONSTRAINT_SCHEMA = DATABASE() -AND TABLE_NAME = 'sessions' -AND CONSTRAINT_TYPE = 'FOREIGN KEY' -LIMIT 1; - -SET @sql = IF(@fk_name IS NOT NULL, - CONCAT('ALTER TABLE sessions DROP FOREIGN KEY ', @fk_name), - 'DO 1'); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- Drop old users table -DROP TABLE IF EXISTS users; +-- Create sessions table for Bearer token authentication +CREATE TABLE IF NOT EXISTS sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id_hash VARCHAR(255) NOT NULL UNIQUE, + user_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + INDEX idx_session_hash (session_id_hash), + INDEX idx_expires_at (expires_at), + INDEX idx_user_id (user_id), + INDEX idx_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Create db_users_a table CREATE TABLE `db_users_a` ( - `id` int NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, `screen_name` varchar(75) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '-', `telegram_id` bigint UNSIGNED DEFAULT NULL, `telegram_name` varchar(33) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '-', @@ -32,7 +24,11 @@ CREATE TABLE `db_users_a` ( `ip` varbinary(16) DEFAULT NULL, `date_reg` int NOT NULL DEFAULT '0', `date_login` int NOT NULL DEFAULT '0', - `banned` int NOT NULL DEFAULT '0' + `banned` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `telegram_id` (`telegram_id`), + KEY `telegram_name` (`telegram_name`), + KEY `ip` (`ip`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- Create db_users_b table @@ -43,7 +39,8 @@ CREATE TABLE `db_users_b` ( `deposit_total` bigint NOT NULL DEFAULT '0', `deposit_count` int NOT NULL DEFAULT '0', `withdraw_total` bigint NOT NULL DEFAULT '0', - `withdraw_count` int NOT NULL DEFAULT '0' + `withdraw_count` int NOT NULL DEFAULT '0', + KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- Create db_users_d table @@ -69,39 +66,21 @@ CREATE TABLE `db_users_d` ( `to_referer_2` bigint NOT NULL DEFAULT '0', `to_referer_3` bigint NOT NULL DEFAULT '0', `to_referer_4` bigint NOT NULL DEFAULT '0', - `to_referer_5` bigint NOT NULL DEFAULT '0' + `to_referer_5` bigint NOT NULL DEFAULT '0', + KEY `id` (`id`), + KEY `referer_id_1` (`referer_id_1`), + KEY `referer_id_2` (`referer_id_2`), + KEY `referer_id_3` (`referer_id_3`), + KEY `referer_id_4` (`referer_id_4`), + KEY `referer_id_5` (`referer_id_5`), + KEY `master_id` (`master_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; --- Add indexes for db_users_a -ALTER TABLE `db_users_a` - ADD PRIMARY KEY (`id`), - ADD UNIQUE KEY `telegram_id` (`telegram_id`), - ADD KEY `telegram_name` (`telegram_name`), - ADD KEY `ip` (`ip`); - --- Add indexes for db_users_b -ALTER TABLE `db_users_b` - ADD KEY `id` (`id`); - --- Add indexes for db_users_d -ALTER TABLE `db_users_d` - ADD KEY `id` (`id`), - ADD KEY `referer_id_1` (`referer_id_1`), - ADD KEY `referer_id_2` (`referer_id_2`), - ADD KEY `referer_id_3` (`referer_id_3`), - ADD KEY `referer_id_4` (`referer_id_4`), - ADD KEY `referer_id_5` (`referer_id_5`), - ADD KEY `master_id` (`master_id`); - --- Set auto increment for db_users_a -ALTER TABLE `db_users_a` - MODIFY `id` int NOT NULL AUTO_INCREMENT; - --- Update sessions table: change user_id from BIGINT to INT to match db_users_a.id -ALTER TABLE `sessions` - MODIFY `user_id` int NOT NULL; - -- Add foreign key constraint from sessions to db_users_a ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `db_users_a`(`id`) ON DELETE CASCADE; + + + + diff --git a/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql b/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql new file mode 100644 index 0000000..7cdcb86 --- /dev/null +++ b/src/main/resources/db/migration/V20__create_user_daily_bonus_claims_table.sql @@ -0,0 +1,14 @@ +-- Create user_daily_bonus_claims table for daily bonus claims +-- This table stores daily bonus claims with user information to avoid JOINs +CREATE TABLE IF NOT EXISTS `user_daily_bonus_claims` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `avatar_url` varchar(255) DEFAULT NULL, + `screen_name` varchar(75) NOT NULL DEFAULT '-', + `claimed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_claimed_at` (`claimed_at` DESC), + CONSTRAINT `fk_user_daily_bonus_claims_user` FOREIGN KEY (`user_id`) REFERENCES `db_users_a` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + diff --git a/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql b/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql new file mode 100644 index 0000000..5540fd8 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_rounds_played_to_users_b.sql @@ -0,0 +1,6 @@ +-- Add rounds_played column to db_users_b table +-- This column tracks how many rounds each user has participated in +-- Used for third bet bonus logic instead of counting transactions +ALTER TABLE `db_users_b` + ADD COLUMN `rounds_played` int NOT NULL DEFAULT '0' AFTER `withdraw_count`; + diff --git a/src/main/resources/db/migration/V22__add_composite_index_payments_user_status.sql b/src/main/resources/db/migration/V22__add_composite_index_payments_user_status.sql new file mode 100644 index 0000000..4f91aa5 --- /dev/null +++ b/src/main/resources/db/migration/V22__add_composite_index_payments_user_status.sql @@ -0,0 +1,21 @@ +-- Add composite index for faster queries on payments table +-- Used for: SELECT SUM(stars_amount) WHERE user_id = ? AND status = 'COMPLETED' + +-- First, try to drop the index if it exists (for idempotency) +-- This will fail silently if the index doesn't exist, which is fine +SET @drop_sql = CONCAT('DROP INDEX idx_payments_user_status ON payments'); +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'payments' + AND index_name = 'idx_payments_user_status') > 0, + @drop_sql, + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Now create the index +CREATE INDEX idx_payments_user_status ON payments(user_id, status); + diff --git a/src/main/resources/db/migration/V23__create_admins_table.sql b/src/main/resources/db/migration/V23__create_admins_table.sql new file mode 100644 index 0000000..78b8081 --- /dev/null +++ b/src/main/resources/db/migration/V23__create_admins_table.sql @@ -0,0 +1,13 @@ +-- Create separate admins table for internal staff +CREATE TABLE `admins` ( + `id` INT NOT NULL AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL UNIQUE, + `password_hash` VARCHAR(255) NOT NULL, + `role` VARCHAR(20) NOT NULL DEFAULT 'ROLE_ADMIN', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_username` (`username`), + KEY `idx_role` (`role`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql b/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql new file mode 100644 index 0000000..7c66f9c --- /dev/null +++ b/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql @@ -0,0 +1,55 @@ +-- Add indexes for admin panel queries to improve performance +-- This migration adds indexes for filtering, searching, and dashboard statistics + +-- ============================================ +-- db_users_a indexes +-- ============================================ +-- Index for registration date filtering and counting (dashboard stats) +CREATE INDEX idx_users_date_reg ON db_users_a(date_reg); + +-- Index for login date filtering and counting active users (dashboard stats) +CREATE INDEX idx_users_date_login ON db_users_a(date_login); + +-- Index for banned status filtering +CREATE INDEX idx_users_banned ON db_users_a(banned); + +-- Index for country code filtering +CREATE INDEX idx_users_country_code ON db_users_a(country_code); + +-- Index for language code filtering +CREATE INDEX idx_users_language_code ON db_users_a(language_code); + +-- ============================================ +-- payments indexes +-- ============================================ +-- Composite index for dashboard queries: sumTicketsAmountByStatusAndCreatedAtAfter +-- This optimizes queries that filter by status and created_at (e.g., revenue stats) +CREATE INDEX idx_payments_status_created_at ON payments(status, created_at); + +-- ============================================ +-- payouts indexes +-- ============================================ +-- Composite index for dashboard queries: sumTotalByStatusAndCreatedAtAfter +-- This optimizes queries that filter by status and created_at (e.g., payout stats) +CREATE INDEX idx_payouts_status_created_at ON payouts(status, created_at); + +-- Index for payout type filtering +CREATE INDEX idx_payouts_type ON payouts(type); + +-- ============================================ +-- game_rounds indexes +-- ============================================ +-- Composite index for queries filtering by phase and resolved_at +-- This helps with queries like countByResolvedAtAfter when combined with phase filters +CREATE INDEX idx_game_rounds_phase_resolved_at ON game_rounds(phase, resolved_at); + +-- ============================================ +-- support_tickets indexes +-- ============================================ +-- Index for updated_at filtering (dashboard: tickets resolved today) +CREATE INDEX idx_support_tickets_updated_at ON support_tickets(updated_at); + +-- Composite index for common query pattern: status + updated_at +-- This helps with queries like countByStatusAndUpdatedAtAfter +CREATE INDEX idx_support_tickets_status_updated_at ON support_tickets(status, updated_at); + diff --git a/src/main/resources/db/migration/V25__add_user_id_to_admins.sql b/src/main/resources/db/migration/V25__add_user_id_to_admins.sql new file mode 100644 index 0000000..740bd32 --- /dev/null +++ b/src/main/resources/db/migration/V25__add_user_id_to_admins.sql @@ -0,0 +1,7 @@ +-- Add user_id column to admins table to link admin accounts to db_users_a +-- This allows admin messages in support tickets to use the admin's user_id +ALTER TABLE `admins` +ADD COLUMN `user_id` INT NULL AFTER `id`, +ADD CONSTRAINT `fk_admins_user_id` FOREIGN KEY (`user_id`) REFERENCES `db_users_a`(`id`) ON DELETE RESTRICT, +ADD INDEX `idx_user_id` (`user_id`); + diff --git a/src/main/resources/db/migration/V26__update_follow_tasks.sql b/src/main/resources/db/migration/V26__update_follow_tasks.sql new file mode 100644 index 0000000..8baad07 --- /dev/null +++ b/src/main/resources/db/migration/V26__update_follow_tasks.sql @@ -0,0 +1,17 @@ +-- Update existing follow task to use requirement=1 for News channel +-- Update title to "Follow our News channel" +UPDATE `tasks` +SET `title` = 'Follow our News channel', + `description` = 'Follow our News channel', + `requirement` = 1 +WHERE `type` = 'follow' AND `requirement` = 1; + +-- Add new follow task for Withdrawals channel (requirement=2) +-- reward_amount is in bigint format (5 Stars = 5000000) +-- Only insert if it doesn't already exist (check by type and requirement) +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'follow', 2, 5000000, 'Stars', 2, 'Follow Proof of payment channel', 'Follow Proof of payment channel' +WHERE NOT EXISTS ( + SELECT 1 FROM `tasks` WHERE `type` = 'follow' AND `requirement` = 2 +); + diff --git a/src/main/resources/db/migration/V27__update_invite_30_friends_reward.sql b/src/main/resources/db/migration/V27__update_invite_30_friends_reward.sql new file mode 100644 index 0000000..2489bb7 --- /dev/null +++ b/src/main/resources/db/migration/V27__update_invite_30_friends_reward.sql @@ -0,0 +1,8 @@ +-- Update reward amount for "Invite 30 friends" task from 40 to 45 tickets +-- This changes the reward_amount from 40000000 (40 tickets) to 45000000 (45 tickets) +UPDATE `tasks` +SET `reward_amount` = 45000000 +WHERE `type` = 'referral' + AND `requirement` = 30 + AND `reward_amount` = 40000000; + diff --git a/src/main/resources/db/migration/V28__add_top_up_balance_tasks.sql b/src/main/resources/db/migration/V28__add_top_up_balance_tasks.sql new file mode 100644 index 0000000..dc43c21 --- /dev/null +++ b/src/main/resources/db/migration/V28__add_top_up_balance_tasks.sql @@ -0,0 +1,41 @@ +-- Convert existing 500 tickets task to 50 tickets task and add 3 new "Other" type tasks +-- These tasks are displayed in ascending order by requirement (display_order) +-- requirement is in bigint format (tickets * 1,000,000) +-- reward_amount is in bigint format (tickets * 1,000,000) + +-- Alter requirement column to BIGINT to support large values for "other" type tasks +-- The column was originally INT, but "other" tasks need BIGINT for deposit_total values +ALTER TABLE `tasks` MODIFY COLUMN `requirement` BIGINT NOT NULL COMMENT 'Number required (e.g., number of friends to invite, or deposit_total in bigint for other tasks)'; + +-- Convert existing 500 tickets task to 50 tickets task +-- Update requirement from 500000000 (500 tickets) to 50000000 (50 tickets) +-- Update reward from 100000000 (100 tickets) to 5000000 (5 tickets) +-- Update description and display_order +UPDATE `tasks` +SET `requirement` = 50000000, + `reward_amount` = 5000000, + `description` = 'Top up Balance: 50 Tickets', + `display_order` = 1 +WHERE `type` = 'other' AND `requirement` = 500000000; + +-- Task 2: 250 tickets requirement, 25 tickets reward +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 250000000, 25000000, 'Stars', 2, 'Top up Balance', 'Top up Balance: 250 Tickets' +WHERE NOT EXISTS ( + SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 250000000 +); + +-- Task 3: 1000 tickets requirement, 100 tickets reward +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 1000000000, 100000000, 'Stars', 3, 'Top up Balance', 'Top up Balance: 1000 Tickets' +WHERE NOT EXISTS ( + SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 1000000000 +); + +-- Task 4: 5000 tickets requirement, 500 tickets reward +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 5000000000, 500000000, 'Stars', 4, 'Top up Balance', 'Top up Balance: 5000 Tickets' +WHERE NOT EXISTS ( + SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 5000000000 +); + diff --git a/src/main/resources/db/migration/V29__set_initial_balance_a.sql b/src/main/resources/db/migration/V29__set_initial_balance_a.sql new file mode 100644 index 0000000..4deefc4 --- /dev/null +++ b/src/main/resources/db/migration/V29__set_initial_balance_a.sql @@ -0,0 +1,5 @@ +-- Set initial balance_a default value to 3,000,000 (3.00 tickets) +-- This represents 3 tickets in bigint format (1 ticket = 1,000,000) +ALTER TABLE `db_users_b` +MODIFY COLUMN `balance_a` BIGINT UNSIGNED NOT NULL DEFAULT 3000000; + diff --git a/src/main/resources/db/migration/V2__create_game_tables.sql b/src/main/resources/db/migration/V2__create_game_tables.sql new file mode 100644 index 0000000..336337b --- /dev/null +++ b/src/main/resources/db/migration/V2__create_game_tables.sql @@ -0,0 +1,55 @@ +-- Create game_rooms table +CREATE TABLE IF NOT EXISTS game_rooms ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + room_number INT NOT NULL UNIQUE, + current_phase VARCHAR(20) NOT NULL DEFAULT 'WAITING', + countdown_end_at TIMESTAMP NULL, + total_tickets BIGINT UNSIGNED NOT NULL DEFAULT 0, + registered_players INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_phase (current_phase), + INDEX idx_countdown (countdown_end_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create game_rounds table (completed rounds history) +CREATE TABLE IF NOT EXISTS game_rounds ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + room_id INT NOT NULL, + phase VARCHAR(20) NOT NULL, + total_tickets BIGINT UNSIGNED NOT NULL, + winner_user_id INT NULL, + winner_tickets BIGINT UNSIGNED NOT NULL DEFAULT 0, + commission BIGINT UNSIGNED NOT NULL DEFAULT 0, + payout BIGINT UNSIGNED NOT NULL DEFAULT 0, + started_at TIMESTAMP NOT NULL, + countdown_started_at TIMESTAMP NULL, + countdown_ended_at TIMESTAMP NULL, + resolved_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_room (room_id), + INDEX idx_winner (winner_user_id), + INDEX idx_resolved (resolved_at), + FOREIGN KEY (room_id) REFERENCES game_rooms(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create game_round_participants table (who joined which round) +CREATE TABLE IF NOT EXISTS game_round_participants ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + round_id BIGINT NOT NULL, + user_id INT NOT NULL, + tickets BIGINT UNSIGNED NOT NULL, + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_round (round_id), + INDEX idx_user (user_id), + INDEX idx_round_user (round_id, user_id), + FOREIGN KEY (round_id) REFERENCES game_rounds(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default rooms (Room 1, 2, 3) +INSERT INTO game_rooms (room_number, current_phase, total_tickets, registered_players) +VALUES (1, 'WAITING', 0, 0), + (2, 'WAITING', 0, 0), + (3, 'WAITING', 0, 0); + diff --git a/src/main/resources/db/migration/V2__create_sessions_table.sql b/src/main/resources/db/migration/V2__create_sessions_table.sql deleted file mode 100644 index f8e20a9..0000000 --- a/src/main/resources/db/migration/V2__create_sessions_table.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Create sessions table for Bearer token authentication -CREATE TABLE IF NOT EXISTS sessions ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - session_id_hash VARCHAR(255) NOT NULL UNIQUE, - user_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP NOT NULL, - INDEX idx_session_hash (session_id_hash), - INDEX idx_expires_at (expires_at), - INDEX idx_user_id (user_id), - INDEX idx_user_created (user_id, created_at), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - diff --git a/src/main/resources/db/migration/V30__create_quick_answers_table.sql b/src/main/resources/db/migration/V30__create_quick_answers_table.sql new file mode 100644 index 0000000..964a8f8 --- /dev/null +++ b/src/main/resources/db/migration/V30__create_quick_answers_table.sql @@ -0,0 +1,13 @@ +-- Create quick_answers table for admin quick response templates +CREATE TABLE `quick_answers` ( + `id` INT NOT NULL AUTO_INCREMENT, + `admin_id` INT NOT NULL, + `text` TEXT NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_admin_id` (`admin_id`), + KEY `idx_admin_created` (`admin_id`, `created_at`), + CONSTRAINT `fk_quick_answers_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/src/main/resources/db/migration/V31__add_usd_amount_to_payments_and_payouts.sql b/src/main/resources/db/migration/V31__add_usd_amount_to_payments_and_payouts.sql new file mode 100644 index 0000000..cce4752 --- /dev/null +++ b/src/main/resources/db/migration/V31__add_usd_amount_to_payments_and_payouts.sql @@ -0,0 +1,7 @@ +-- Add usd_amount to payments (crypto): BIGINT, 1_000_000 in DB = 1 USD. Keep stars_amount for backward compatibility. +ALTER TABLE payments +ADD COLUMN usd_amount BIGINT UNSIGNED NULL COMMENT 'USD amount: 1_000_000 = 1 USD (crypto deposits)' AFTER stars_amount; + +-- Add usd_amount to payouts. Same scale: 1_000_000 = 1 USD. +ALTER TABLE payouts +ADD COLUMN usd_amount BIGINT UNSIGNED NULL COMMENT 'USD amount: 1_000_000 = 1 USD (crypto withdrawals)' AFTER stars_amount; diff --git a/src/main/resources/db/migration/V32__update_other_tasks_requirements_and_rewards.sql b/src/main/resources/db/migration/V32__update_other_tasks_requirements_and_rewards.sql new file mode 100644 index 0000000..80f7191 --- /dev/null +++ b/src/main/resources/db/migration/V32__update_other_tasks_requirements_and_rewards.sql @@ -0,0 +1,26 @@ +-- Update "other" type tasks: remove 50M task, change requirements and rewards for the rest +-- requirement and reward_amount are in bigint (tickets * 1_000_000) + +-- Remove the other task with requirement 50M (50 tickets) +DELETE FROM `tasks` WHERE `type` = 'other' AND `requirement` = 50000000; + +-- 250M -> 2000 tickets requirement, 200 tickets reward +UPDATE `tasks` +SET `requirement` = 2000000000, + `reward_amount` = 200000000, + `description` = 'Top up Balance: 2000 Tickets' +WHERE `type` = 'other' AND `requirement` = 250000000; + +-- 1000M -> 5000 tickets requirement, 500 tickets reward +UPDATE `tasks` +SET `requirement` = 5000000000, + `reward_amount` = 500000000, + `description` = 'Top up Balance: 5000 Tickets' +WHERE `type` = 'other' AND `requirement` = 1000000000; + +-- 5000M -> 10000 tickets requirement, 1000 tickets reward +UPDATE `tasks` +SET `requirement` = 10000000000, + `reward_amount` = 1000000000, + `description` = 'Top up Balance: 10000 Tickets' +WHERE `type` = 'other' AND `requirement` = 5000000000; diff --git a/src/main/resources/db/migration/V33__fix_other_task_duplicate_10000.sql b/src/main/resources/db/migration/V33__fix_other_task_duplicate_10000.sql new file mode 100644 index 0000000..2e16ee6 --- /dev/null +++ b/src/main/resources/db/migration/V33__fix_other_task_duplicate_10000.sql @@ -0,0 +1,9 @@ +-- Fix duplicate 10000-ticket "other" task: the row with display_order=3 that incorrectly +-- has requirement 10000 should be the 5000-ticket task (requirement/reward in bigint). +UPDATE `tasks` +SET `requirement` = 5000000000, + `reward_amount` = 500000000, + `description` = 'Top up Balance: 5000 Tickets' +WHERE `type` = 'other' + AND `display_order` = 3 + AND `requirement` = 10000000000; diff --git a/src/main/resources/db/migration/V34__add_referral_commission_indexes.sql b/src/main/resources/db/migration/V34__add_referral_commission_indexes.sql new file mode 100644 index 0000000..9d537e8 --- /dev/null +++ b/src/main/resources/db/migration/V34__add_referral_commission_indexes.sql @@ -0,0 +1,5 @@ +-- Indexes for referral list ordered by commission DESC (level 1, 2, 3). +-- Queries filter by referer_id_N and order by to_referer_N DESC. +CREATE INDEX idx_users_d_referer1_commission ON db_users_d (referer_id_1, to_referer_1 DESC); +CREATE INDEX idx_users_d_referer2_commission ON db_users_d (referer_id_2, to_referer_2 DESC); +CREATE INDEX idx_users_d_referer3_commission ON db_users_d (referer_id_3, to_referer_3 DESC); diff --git a/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql b/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql new file mode 100644 index 0000000..95111af --- /dev/null +++ b/src/main/resources/db/migration/V35__add_total_win_after_deposit_to_users_b.sql @@ -0,0 +1,4 @@ +-- Add total_win_after_deposit to db_users_b (bigint: 1 ticket = 1_000_000). +-- Reset to 0 on each deposit; incremented by round win amount when user wins; reduced when user creates a payout. +ALTER TABLE `db_users_b` + ADD COLUMN `total_win_after_deposit` BIGINT NOT NULL DEFAULT 0 AFTER `rounds_played`; diff --git a/src/main/resources/db/migration/V36__create_feature_switches.sql b/src/main/resources/db/migration/V36__create_feature_switches.sql new file mode 100644 index 0000000..9e01e7b --- /dev/null +++ b/src/main/resources/db/migration/V36__create_feature_switches.sql @@ -0,0 +1,10 @@ +-- Runtime feature toggles (e.g. remote bet endpoint). Can be changed from admin panel without restart. +CREATE TABLE `feature_switches` ( + `key` VARCHAR(64) NOT NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default: remote bet endpoint disabled until explicitly enabled from admin +INSERT INTO `feature_switches` (`key`, `enabled`) VALUES ('remote_bet_enabled', 1); diff --git a/src/main/resources/db/migration/V37__create_crypto_deposit_tables.sql b/src/main/resources/db/migration/V37__create_crypto_deposit_tables.sql new file mode 100644 index 0000000..718544d --- /dev/null +++ b/src/main/resources/db/migration/V37__create_crypto_deposit_tables.sql @@ -0,0 +1,24 @@ +-- Crypto deposit methods from external API (spin-passim.tech). Synced on Store open. +-- Single row: hash for future cache; minimum_deposit = min of all methods' min_deposit_sum. +CREATE TABLE `crypto_deposit_config` ( + `id` INT NOT NULL DEFAULT 1, + `methods_hash` VARCHAR(255) NULL COMMENT 'Future: skip sync when unchanged', + `minimum_deposit` DECIMAL(10,2) NOT NULL DEFAULT 2.50, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `crypto_deposit_config` (`id`, `minimum_deposit`) VALUES (1, 2.50); + +-- One row per active deposit method (PID = icon filename, e.g. 235.png). +CREATE TABLE `crypto_deposit_methods` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `pid` INT NOT NULL COMMENT 'External API PID; equals icon filename without extension', + `name` VARCHAR(100) NOT NULL, + `network` VARCHAR(50) NOT NULL, + `example` VARCHAR(255) NULL, + `min_deposit_sum` DECIMAL(10,2) NOT NULL, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pid` (`pid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V38__seed_crypto_deposit_methods.sql b/src/main/resources/db/migration/V38__seed_crypto_deposit_methods.sql new file mode 100644 index 0000000..dced465 --- /dev/null +++ b/src/main/resources/db/migration/V38__seed_crypto_deposit_methods.sql @@ -0,0 +1,40 @@ +-- Seed default crypto deposit methods and config hash (from external API snapshot). +-- minimum_deposit = 2.50 (min of all min_deposit_sum). Hash for future cron compare. +UPDATE `crypto_deposit_config` SET `methods_hash` = 'a86b5f20528bc9e385d4d12fb50f5e3d', `minimum_deposit` = 2.50 WHERE `id` = 1; + +INSERT INTO `crypto_deposit_methods` (`pid`, `name`, `network`, `example`, `min_deposit_sum`) VALUES +(235, 'TON', 'TON', 'UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD', 2.5), +(90, 'TRON', 'TRC20', 'TMQqX43PAMZPXPxX6Qj1fCyeiMWLgW35yF', 2.5), +(10, 'Bitcoin', 'BTC', '131qX9kauDpCGyn2MfAFwHcrrVk7JTLAYj', 2.5), +(100, 'USDC', 'ERC20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 40), +(20, 'Ethereum', 'ERC20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 40), +(71, 'USDT', 'TRC20', 'TMQqX43PAMZPXPxX6Qj1fCyeiMWLgW35yF', 2.5), +(30, 'LiteCoin', 'LTC', 'LRcJGhbbSkpSXAq3qMWibRmaxQzNAuQ1ZM', 2.5), +(130, 'BNB', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(244, 'NOT', 'TON', 'UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD', 2.5), +(245, 'DOGS', 'TON', 'UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD', 2.5), +(206, 'Shiba Inu', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(40, 'DOGE', 'DOGE', 'DSNgQwNKp4RzNwAbVhxokw1sadyq5kbD1n', 2.5), +(60, 'Solana', 'SOL', '86drvKWPo6mN2RauveLSu4TLkBUxMTxKsjpTNbjuHzA3', 2.5), +(120, 'XRP', 'XRP', 'rQN6FpoqGsSe1dHG665SnKmKesNQhnd4UB:12345', 2.5), +(73, 'USDT', 'SOL', '86drvKWPo6mN2RauveLSu4TLkBUxMTxKsjpTNbjuHzA3', 2.5), +(50, 'BCH', 'BCH', 'qq0pg56eg90m7rv6en7l0vv4gpudh8wf3swa0hqsu2', 2.5), +(70, 'USDT', 'ERC20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 40), +(72, 'USDT', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(75, 'USDT', 'TON', 'UQAm3JwwV_wMgmJ05AzHqtlHAdkyJt58N-JHV2Uhf80hOEKD', 2.5), +(76, 'USDT', 'ARBITRUM', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(201, 'Bitcoin', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(74, 'USDT', 'MATIC', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(77, 'USDT', 'OPTIMISM', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(78, 'USDT', 'Avalanche', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(102, 'USDC', 'MATIC', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(202, 'Ethereum', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(207, 'Shiba Inu', 'ERC20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 40), +(101, 'USDC', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(64, 'AVAX', 'Avalanche', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(61, 'MATIC', 'MATIC', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(242, 'BONK', 'SOL', '86drvKWPo6mN2RauveLSu4TLkBUxMTxKsjpTNbjuHzA3', 2.5), +(240, 'FLOKI', 'BEP20', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(62, 'ARB', 'ARBITRUM', '0x153aDC5BdF47D1f68F1ffdcEeaD7a458ca9CEd13', 2.5), +(208, 'DASH', 'DASH', 'Xg9j6CmK9DY822o8YsTtu183KEHYxiVdbr', 2.5) +ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `network` = VALUES(`network`), `example` = VALUES(`example`), `min_deposit_sum` = VALUES(`min_deposit_sum`), `updated_at` = CURRENT_TIMESTAMP; diff --git a/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql b/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql new file mode 100644 index 0000000..89c9e9d --- /dev/null +++ b/src/main/resources/db/migration/V3__rename_tickets_to_bet.sql @@ -0,0 +1,10 @@ +-- Rename tickets columns to bet for currency-agnostic naming +ALTER TABLE game_rooms CHANGE COLUMN total_tickets total_bet BIGINT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE game_rounds CHANGE COLUMN total_tickets total_bet BIGINT UNSIGNED NOT NULL; +ALTER TABLE game_rounds CHANGE COLUMN winner_tickets winner_bet BIGINT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE game_round_participants CHANGE COLUMN tickets bet BIGINT UNSIGNED NOT NULL; + + + + + diff --git a/src/main/resources/db/migration/V40__usd_amount_to_decimal.sql b/src/main/resources/db/migration/V40__usd_amount_to_decimal.sql new file mode 100644 index 0000000..8351349 --- /dev/null +++ b/src/main/resources/db/migration/V40__usd_amount_to_decimal.sql @@ -0,0 +1,13 @@ +-- Store USD as decimal (e.g. 1.25 USD = 1.25). Convert existing bigint (1_000_000 = 1 USD) to decimal. + +-- payments +ALTER TABLE payments ADD COLUMN usd_amount_new DECIMAL(20,2) NULL COMMENT 'USD amount as decimal' AFTER stars_amount; +UPDATE payments SET usd_amount_new = usd_amount / 1000000.0 WHERE usd_amount IS NOT NULL; +ALTER TABLE payments DROP COLUMN usd_amount; +ALTER TABLE payments CHANGE usd_amount_new usd_amount DECIMAL(20,2) NULL COMMENT 'USD amount (e.g. 1.25)'; + +-- payouts +ALTER TABLE payouts ADD COLUMN usd_amount_new DECIMAL(20,2) NULL COMMENT 'USD amount as decimal' AFTER stars_amount; +UPDATE payouts SET usd_amount_new = usd_amount / 1000000.0 WHERE usd_amount IS NOT NULL; +ALTER TABLE payouts DROP COLUMN usd_amount; +ALTER TABLE payouts CHANGE usd_amount_new usd_amount DECIMAL(20,2) NULL COMMENT 'USD amount (e.g. 1.25)'; diff --git a/src/main/resources/db/migration/V41__create_crypto_withdrawal_tables.sql b/src/main/resources/db/migration/V41__create_crypto_withdrawal_tables.sql new file mode 100644 index 0000000..bfa3ca3 --- /dev/null +++ b/src/main/resources/db/migration/V41__create_crypto_withdrawal_tables.sql @@ -0,0 +1,22 @@ +-- Crypto withdrawal methods from external API (GET /api/v1/withdrawal-methods). Synced every 30 min. +-- Methods table overwritten on each sync (no hash check). min_withdrawal is per method. +-- One row per active withdrawal method (pid = icon_id from API, used for icon filename e.g. 30.png). +CREATE TABLE `crypto_withdrawal_methods` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `pid` INT NOT NULL COMMENT 'Icon ID from API; equals icon filename without extension', + `name` VARCHAR(50) NOT NULL COMMENT 'Display name (ticker from API, e.g. TON, LTC)', + `network` VARCHAR(100) NOT NULL COMMENT 'Network name from API (e.g. Toncoin, Litecoin)', + `way_id` INT NOT NULL COMMENT 'External API way_id', + `min_withdrawal` DECIMAL(10,2) NOT NULL DEFAULT 0.10, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pid` (`pid`), + KEY `ix_way_id` (`way_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Seed initial methods (LTC, BNB, TRX, TON) +INSERT INTO `crypto_withdrawal_methods` (`pid`, `name`, `network`, `way_id`, `min_withdrawal`) VALUES +(30, 'LTC', 'Litecoin', 1, 0.10), +(130, 'BNB', 'BNB', 2, 0.10), +(90, 'TRX', 'TRON', 3, 0.10), +(235, 'TON', 'Toncoin', 4, 0.10); diff --git a/src/main/resources/db/migration/V42__withdrawal_methods_pid_and_icon_id.sql b/src/main/resources/db/migration/V42__withdrawal_methods_pid_and_icon_id.sql new file mode 100644 index 0000000..517cf7a --- /dev/null +++ b/src/main/resources/db/migration/V42__withdrawal_methods_pid_and_icon_id.sql @@ -0,0 +1,16 @@ +-- Withdrawal methods API now returns pid (method id) and icon_id (for icons). +-- Map: api pid -> pid, api icon_id -> icon_id. Remove way_id. +ALTER TABLE `crypto_withdrawal_methods` + ADD COLUMN `icon_id` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'Icon ID from API for icon filename' AFTER `network`, + DROP INDEX `ix_way_id`, + DROP COLUMN `way_id`; + +-- Replace V41 seed rows with new structure (pid = method id, icon_id = icon filename) +DELETE FROM `crypto_withdrawal_methods`; + +-- Seed initial methods (pid from API, icon_id for icon filename) +INSERT INTO `crypto_withdrawal_methods` (`pid`, `name`, `network`, `icon_id`, `min_withdrawal`) VALUES +(1, 'LTC', 'Litecoin', '30', 0.10), +(2, 'BNB', 'BNB', '130', 0.10), +(3, 'TRX', 'TRON', '90', 0.10), +(4, 'TON', 'Toncoin', '235', 0.10); diff --git a/src/main/resources/db/migration/V43__payouts_crypto_columns.sql b/src/main/resources/db/migration/V43__payouts_crypto_columns.sql new file mode 100644 index 0000000..a8aa97b --- /dev/null +++ b/src/main/resources/db/migration/V43__payouts_crypto_columns.sql @@ -0,0 +1,8 @@ +-- Crypto withdrawal payouts: store ticker and amounts from crypto API. +-- type 'CRYPTO' is used for such payouts (no enum change in DB, VARCHAR already). +ALTER TABLE payouts + ADD COLUMN crypto_name VARCHAR(20) NULL COMMENT 'Ticker from crypto API (e.g. TRX, TON)' AFTER gift_name, + ADD COLUMN amount_coins VARCHAR(50) NULL COMMENT 'Withdrawal amount in coins from API' AFTER usd_amount, + ADD COLUMN commission_coins VARCHAR(50) NULL COMMENT 'Commission in coins from API' AFTER amount_coins, + ADD COLUMN amount_to_send VARCHAR(50) NULL COMMENT 'Final amount to send from API' AFTER commission_coins, + ADD COLUMN wallet VARCHAR(120) NULL COMMENT 'Wallet address for CRYPTO type' AFTER username; diff --git a/src/main/resources/db/migration/V44__payouts_payment_id.sql b/src/main/resources/db/migration/V44__payouts_payment_id.sql new file mode 100644 index 0000000..dda5806 --- /dev/null +++ b/src/main/resources/db/migration/V44__payouts_payment_id.sql @@ -0,0 +1,12 @@ +-- Store crypto API payment_id from POST api/v1/withdrawal response (result.payment.payment_id). +ALTER TABLE payouts + ADD COLUMN payment_id INT NULL COMMENT 'Crypto API payment id from withdrawal response' AFTER amount_to_send; + +-- Extend payout status with WAITING. +ALTER TABLE payouts + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING, COMPLETED, CANCELLED, WAITING'; + +-- Add updated_at; initially equal to created_at. +ALTER TABLE payouts + ADD COLUMN updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at; +UPDATE payouts SET updated_at = created_at; diff --git a/src/main/resources/db/migration/V45__payouts_crypto_indexes.sql b/src/main/resources/db/migration/V45__payouts_crypto_indexes.sql new file mode 100644 index 0000000..47a8376 --- /dev/null +++ b/src/main/resources/db/migration/V45__payouts_crypto_indexes.sql @@ -0,0 +1,5 @@ +-- Indexes for crypto payout logic: +-- 1) existsByUserIdAndStatus(userId, status) - at most one PROCESSING per user check +-- 2) findByStatusInAndPaymentIdIsNotNullOrderByUpdatedAtAsc - cron sync batch (status + updated_at) +CREATE INDEX idx_payouts_user_status ON payouts(user_id, status); +CREATE INDEX idx_payouts_status_updated_at ON payouts(status, updated_at); diff --git a/src/main/resources/db/migration/V46__payouts_type_status_updated_at_index.sql b/src/main/resources/db/migration/V46__payouts_type_status_updated_at_index.sql new file mode 100644 index 0000000..a9a0bd6 --- /dev/null +++ b/src/main/resources/db/migration/V46__payouts_type_status_updated_at_index.sql @@ -0,0 +1,3 @@ +-- Index for withdrawal sync cron: findByTypeAndStatusInAndPaymentIdIsNotNullOrderByUpdatedAtAsc(CRYPTO, PROCESSING|WAITING). +-- Covers type + status filter and ORDER BY updated_at ASC. +CREATE INDEX idx_payouts_type_status_updated_at ON payouts(type, status, updated_at); diff --git a/src/main/resources/db/migration/V47__payouts_drop_redundant_index.sql b/src/main/resources/db/migration/V47__payouts_drop_redundant_index.sql new file mode 100644 index 0000000..79c3a62 --- /dev/null +++ b/src/main/resources/db/migration/V47__payouts_drop_redundant_index.sql @@ -0,0 +1,3 @@ +-- Drop redundant index: cron now uses findByTypeAndStatusIn... with idx_payouts_type_status_updated_at (type, status, updated_at). +-- No query uses (status, updated_at) without type anymore. +DROP INDEX idx_payouts_status_updated_at ON payouts; diff --git a/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql b/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql new file mode 100644 index 0000000..643978e --- /dev/null +++ b/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql @@ -0,0 +1,4 @@ +-- Feature switchers for payment (deposits) and payout (withdrawals). Enabled by default. +INSERT INTO `feature_switches` (`key`, `enabled`) VALUES + ('payment_enabled', 1), + ('payout_enabled', 1); diff --git a/src/main/resources/db/migration/V49__update_tasks_rewards_and_add_deposit_tasks.sql b/src/main/resources/db/migration/V49__update_tasks_rewards_and_add_deposit_tasks.sql new file mode 100644 index 0000000..638f469 --- /dev/null +++ b/src/main/resources/db/migration/V49__update_tasks_rewards_and_add_deposit_tasks.sql @@ -0,0 +1,43 @@ +-- Update task rewards and add new deposit (other) tasks. +-- requirement and reward_amount are in bigint (tickets * 1_000_000). + +-- ========== FOLLOW: change 5 tickets reward to 7 tickets ========== +UPDATE `tasks` SET `reward_amount` = 7000000 WHERE `type` = 'follow' AND `reward_amount` = 5000000; + +-- ========== OTHER (deposit): update existing rewards ========== +-- 2000 tickets: reward 200 -> 100 +UPDATE `tasks` SET `reward_amount` = 100000000, `description` = 'Top up Balance: 2000 Tickets' +WHERE `type` = 'other' AND `requirement` = 2000000000; + +-- 5000 tickets: reward 500 -> 250 +UPDATE `tasks` SET `reward_amount` = 250000000, `description` = 'Top up Balance: 5000 Tickets' +WHERE `type` = 'other' AND `requirement` = 5000000000; + +-- 10000 tickets: reward 1000 -> 500 +UPDATE `tasks` SET `reward_amount` = 500000000, `description` = 'Top up Balance: 10000 Tickets' +WHERE `type` = 'other' AND `requirement` = 10000000000; + +-- ========== OTHER: add new deposit tasks (50000, 150000, 500000 tickets) ========== +-- display_order: existing are 1..4 (50, 250, 2000, 5000, 10000 from V28/V32). Get max display_order for other type then add 5,6,7. +-- 50000 tickets -> 2500 reward; 150000 -> 7500; 500000 -> 25000 +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 50000000000, 2500000000, 'Tickets', 5, 'Top up Balance', 'Top up Balance: 50000 Tickets' +WHERE NOT EXISTS (SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 50000000000); + +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 150000000000, 7500000000, 'Tickets', 6, 'Top up Balance', 'Top up Balance: 150000 Tickets' +WHERE NOT EXISTS (SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 150000000000); + +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) +SELECT 'other', 500000000000, 25000000000, 'Tickets', 7, 'Top up Balance', 'Top up Balance: 500000 Tickets' +WHERE NOT EXISTS (SELECT 1 FROM `tasks` WHERE `type` = 'other' AND `requirement` = 500000000000); + +-- ========== REFERRAL: update rewards (requirement -> new reward in tickets) ========== +-- 1 -> 5; 3 -> 15; 7 -> 35; 15 -> 75; 30 -> 110; 50 -> 150; 100 -> 375 +UPDATE `tasks` SET `reward_amount` = 5000000 WHERE `type` = 'referral' AND `requirement` = 1; +UPDATE `tasks` SET `reward_amount` = 15000000 WHERE `type` = 'referral' AND `requirement` = 3; +UPDATE `tasks` SET `reward_amount` = 35000000 WHERE `type` = 'referral' AND `requirement` = 7; +UPDATE `tasks` SET `reward_amount` = 75000000 WHERE `type` = 'referral' AND `requirement` = 15; +UPDATE `tasks` SET `reward_amount` = 110000000 WHERE `type` = 'referral' AND `requirement` = 30; +UPDATE `tasks` SET `reward_amount` = 150000000 WHERE `type` = 'referral' AND `requirement` = 50; +UPDATE `tasks` SET `reward_amount` = 375000000 WHERE `type` = 'referral' AND `requirement` = 100; diff --git a/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql b/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql new file mode 100644 index 0000000..da69639 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_composite_index_for_completed_rounds.sql @@ -0,0 +1,10 @@ +-- Add composite index for optimized querying of completed rounds with winners +-- This index supports the query: WHERE room_id = X AND phase = 'RESOLUTION' AND resolved_at IS NOT NULL AND winner_user_id IS NOT NULL ORDER BY resolved_at DESC +-- Index order: +-- 1. room_id (exact match, most selective WHERE filter) +-- 2. phase (exact match WHERE filter) +-- 3. resolved_at (ORDER BY column - placed after equality filters for efficient sorting) +-- 4. winner_user_id (IS NOT NULL filter - MySQL can still use index efficiently) +-- This allows MySQL to efficiently filter by room_id and phase, then sort by resolved_at using the index +CREATE INDEX idx_room_phase_resolved_winner ON game_rounds (room_id, phase, resolved_at, winner_user_id); + diff --git a/src/main/resources/db/migration/V50__set_initial_balance_a_5_tickets.sql b/src/main/resources/db/migration/V50__set_initial_balance_a_5_tickets.sql new file mode 100644 index 0000000..8094123 --- /dev/null +++ b/src/main/resources/db/migration/V50__set_initial_balance_a_5_tickets.sql @@ -0,0 +1,4 @@ +-- Set initial balance_a default value to 5,000,000 (5.00 tickets) +-- New users get 5 tickets on registration (1 ticket = 1,000,000 in bigint) +ALTER TABLE `db_users_b` +MODIFY COLUMN `balance_a` BIGINT UNSIGNED NOT NULL DEFAULT 5000000; diff --git a/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql b/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql new file mode 100644 index 0000000..3ca5618 --- /dev/null +++ b/src/main/resources/db/migration/V51__feature_switches_referral_tasks_50_100.sql @@ -0,0 +1,5 @@ +-- Toggle "Invite 50 friends" and "Invite 100 friends" referral tasks. When disabled (0), tasks are hidden and cannot be claimed. +INSERT INTO `feature_switches` (`key`, `enabled`) VALUES + ('task_referral_50_enabled', 0), + ('task_referral_100_enabled', 0) +ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V52__bot_config_tables.sql b/src/main/resources/db/migration/V52__bot_config_tables.sql new file mode 100644 index 0000000..673d01b --- /dev/null +++ b/src/main/resources/db/migration/V52__bot_config_tables.sql @@ -0,0 +1,14 @@ +-- Safe bot users: when balance < threshold they get 100% win rate at resolution (display unchanged) +CREATE TABLE IF NOT EXISTS safe_bot_users ( + user_id INT NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Flexible bot config: user has fixed win rate (0-1) regardless of bet +CREATE TABLE IF NOT EXISTS flexible_bot_configs ( + user_id INT NOT NULL PRIMARY KEY, + win_rate DECIMAL(5,4) NOT NULL COMMENT '0.0000-1.0000', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT chk_win_rate CHECK (win_rate >= 0 AND win_rate <= 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql b/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql new file mode 100644 index 0000000..f2b4355 --- /dev/null +++ b/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql @@ -0,0 +1,15 @@ +-- Indexes for admin users list sorting (Balance, Profit, Deposits, Withdraws, Rounds, Referrals) +-- db_users_b: balance_a, deposit_total, withdraw_total, rounds_played +CREATE INDEX idx_users_b_balance_a ON db_users_b(balance_a); +CREATE INDEX idx_users_b_deposit_total ON db_users_b(deposit_total); +CREATE INDEX idx_users_b_withdraw_total ON db_users_b(withdraw_total); +CREATE INDEX idx_users_b_rounds_played ON db_users_b(rounds_played); + +-- db_users_d: for referral count (sum of referals_1..5) we filter by referer_id_N; indexes already exist (V34) +-- For sorting by total referral count we could use a composite; referer_id_1 is used for "referrals of user X" +CREATE INDEX idx_users_d_referals1 ON db_users_d(referals_1); +CREATE INDEX idx_users_d_referer_id_1 ON db_users_d(referer_id_1); +CREATE INDEX idx_users_d_referer_id_2 ON db_users_d(referer_id_2); +CREATE INDEX idx_users_d_referer_id_3 ON db_users_d(referer_id_3); +CREATE INDEX idx_users_d_referer_id_4 ON db_users_d(referer_id_4); +CREATE INDEX idx_users_d_referer_id_5 ON db_users_d(referer_id_5); diff --git a/src/main/resources/db/migration/V54__lottery_bot_configs.sql b/src/main/resources/db/migration/V54__lottery_bot_configs.sql new file mode 100644 index 0000000..ed8e505 --- /dev/null +++ b/src/main/resources/db/migration/V54__lottery_bot_configs.sql @@ -0,0 +1,20 @@ +-- Bot behaviour config: links a real user (db_users_a/b) to play as a bot with time windows, rooms, bet range. +-- One config per user; user_id must exist in db_users_a. +CREATE TABLE IF NOT EXISTS lottery_bot_configs ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + room_1 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 1', + room_2 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 2', + room_3 TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Can play room 3', + time_utc_start TIME NOT NULL COMMENT 'Start of active window (UTC)', + time_utc_end TIME NOT NULL COMMENT 'End of active window (UTC)', + bet_min BIGINT NOT NULL COMMENT 'Min bet in bigint (1 ticket = 1000000)', + bet_max BIGINT NOT NULL COMMENT 'Max bet in bigint', + persona VARCHAR(20) NOT NULL DEFAULT 'balanced' COMMENT 'conservative, aggressive, balanced', + active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_lottery_bot_configs_user_id (user_id), + KEY idx_lottery_bot_configs_active_rooms (active, room_1, room_2, room_3), + CONSTRAINT fk_lottery_bot_configs_user FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql b/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql new file mode 100644 index 0000000..a940824 --- /dev/null +++ b/src/main/resources/db/migration/V55__feature_switch_lottery_bot_scheduler.sql @@ -0,0 +1,4 @@ +-- Toggle for lottery bot scheduler (auto-join bots from lottery_bot_configs into joinable rounds). When disabled (0), scheduler skips registration. +INSERT INTO `feature_switches` (`key`, `enabled`) VALUES + ('lottery_bot_scheduler_enabled', 1) +ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V56__create_promotions_tables.sql b/src/main/resources/db/migration/V56__create_promotions_tables.sql new file mode 100644 index 0000000..32d004f --- /dev/null +++ b/src/main/resources/db/migration/V56__create_promotions_tables.sql @@ -0,0 +1,39 @@ +-- Promotions: each has type, time range, status +CREATE TABLE IF NOT EXISTS promotions ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(32) NOT NULL COMMENT 'e.g. NET_WIN', + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PLANNED' COMMENT 'ACTIVE, INACTIVE, FINISHED, PLANNED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_promotions_type_status (type, status), + KEY idx_promotions_times (start_time, end_time), + KEY idx_promotions_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Prizes per place for a promotion +CREATE TABLE IF NOT EXISTS promotions_rewards ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + promo_id INT NOT NULL, + place INT NOT NULL COMMENT '1 = first place, 2 = second, etc.', + reward BIGINT NOT NULL COMMENT 'Tickets in bigint (1 ticket = 1000000)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_promotions_rewards_promo_place (promo_id, place), + KEY idx_promotions_rewards_promo (promo_id), + CONSTRAINT fk_promotions_rewards_promo FOREIGN KEY (promo_id) REFERENCES promotions(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- User progress per promotion (one row per user per promo) +CREATE TABLE IF NOT EXISTS promotions_users ( + promo_id INT NOT NULL, + user_id INT NOT NULL, + points DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT 'Points as ticket count, 2 decimal places (e.g. 100.25)', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (promo_id, user_id), + KEY idx_promotions_users_promo_points (promo_id, points DESC), + KEY idx_promotions_users_user (user_id), + CONSTRAINT fk_promotions_users_promo FOREIGN KEY (promo_id) REFERENCES promotions(id) ON DELETE CASCADE, + CONSTRAINT fk_promotions_users_user FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql b/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql new file mode 100644 index 0000000..ae0febe --- /dev/null +++ b/src/main/resources/db/migration/V57__seed_first_net_win_promotion.sql @@ -0,0 +1,20 @@ +-- First NET_WIN promotion: 26.02.2026 12:00 UTC -> 01.03.2026 12:00 UTC +INSERT INTO promotions (type, start_time, end_time, status) VALUES +('NET_WIN', '2026-02-26 12:00:00', '2026-03-01 12:00:00', 'PLANNED'); + +-- Rewards: 1 ticket = 1,000,000 in bigint +-- place 1: 50,000 tickets = 50000000000 +-- place 2: 30,000 = 30000000000, 3: 20,000 = 20000000000, 4: 15,000 = 15000000000, 5: 10,000 = 10000000000 +-- places 6-10: 5,000 each = 5000000000 +SET @promo_id = LAST_INSERT_ID(); +INSERT INTO promotions_rewards (promo_id, place, reward) VALUES +(@promo_id, 1, 50000000000), +(@promo_id, 2, 30000000000), +(@promo_id, 3, 20000000000), +(@promo_id, 4, 15000000000), +(@promo_id, 5, 10000000000), +(@promo_id, 6, 5000000000), +(@promo_id, 7, 5000000000), +(@promo_id, 8, 5000000000), +(@promo_id, 9, 5000000000), +(@promo_id, 10, 5000000000); diff --git a/src/main/resources/db/migration/V58__add_promotions_total_reward.sql b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql new file mode 100644 index 0000000..a1ba390 --- /dev/null +++ b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql @@ -0,0 +1,9 @@ +-- total_reward in tickets (BIGINT: 1 ticket = 1_000_000) +ALTER TABLE promotions +ADD COLUMN total_reward BIGINT NULL DEFAULT NULL COMMENT 'Total prize fund in bigint (1 ticket = 1000000)' AFTER status; + +-- First promo: 150 000 tickets = 150_000_000_000 +UPDATE promotions SET total_reward = 150000000000 WHERE id = 1; + +-- Index for filtering by status (already have idx_promotions_status) +-- total_reward is for display only, no extra index needed diff --git a/src/main/resources/db/migration/V59__create_notifications_audit.sql b/src/main/resources/db/migration/V59__create_notifications_audit.sql new file mode 100644 index 0000000..f1dc375 --- /dev/null +++ b/src/main/resources/db/migration/V59__create_notifications_audit.sql @@ -0,0 +1,10 @@ +-- Audit of notification broadcast sends: one row per user send attempt. +CREATE TABLE notifications_audit ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT 'Internal user id from db_users_a', + status VARCHAR(20) NOT NULL COMMENT 'SUCCESS or FAILED', + telegram_status_code INT NULL COMMENT 'HTTP status from Telegram API response (e.g. 200, 403)', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_notifications_audit_user_id (user_id), + INDEX idx_notifications_audit_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V5__add_screen_name_to_users_d.sql b/src/main/resources/db/migration/V5__add_screen_name_to_users_d.sql new file mode 100644 index 0000000..6b69c0b --- /dev/null +++ b/src/main/resources/db/migration/V5__add_screen_name_to_users_d.sql @@ -0,0 +1,12 @@ +-- Add screen_name column to db_users_d table +ALTER TABLE `db_users_d` +ADD COLUMN `screen_name` VARCHAR(75) NOT NULL DEFAULT '-' AFTER `id`; + +-- Update existing records to copy screen_name from db_users_a +UPDATE `db_users_d` ud +INNER JOIN `db_users_a` ua ON ud.id = ua.id +SET ud.screen_name = ua.screen_name; + + + + diff --git a/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql b/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql new file mode 100644 index 0000000..1389e00 --- /dev/null +++ b/src/main/resources/db/migration/V60__update_promotion_1_rewards.sql @@ -0,0 +1,15 @@ +-- Update promotion id=1 rewards (place -> tickets; stored as tickets * 1_000_000) +-- 1->300k, 2->200k, 3->150k, 4->100k, 5->80k, 6->55k, 7->40k, 8->30k, 9->25k, 10->20k +UPDATE promotions_rewards SET reward = 300000000000 WHERE promo_id = 1 AND place = 1; +UPDATE promotions_rewards SET reward = 200000000000 WHERE promo_id = 1 AND place = 2; +UPDATE promotions_rewards SET reward = 150000000000 WHERE promo_id = 1 AND place = 3; +UPDATE promotions_rewards SET reward = 100000000000 WHERE promo_id = 1 AND place = 4; +UPDATE promotions_rewards SET reward = 80000000000 WHERE promo_id = 1 AND place = 5; +UPDATE promotions_rewards SET reward = 55000000000 WHERE promo_id = 1 AND place = 6; +UPDATE promotions_rewards SET reward = 40000000000 WHERE promo_id = 1 AND place = 7; +UPDATE promotions_rewards SET reward = 30000000000 WHERE promo_id = 1 AND place = 8; +UPDATE promotions_rewards SET reward = 25000000000 WHERE promo_id = 1 AND place = 9; +UPDATE promotions_rewards SET reward = 20000000000 WHERE promo_id = 1 AND place = 10; + +-- Total prize fund: 1_000_000 tickets = 1_000_000_000_000 +UPDATE promotions SET total_reward = 1000000000000 WHERE id = 1; diff --git a/src/main/resources/db/migration/V62__notifications_audit_user_created_index.sql b/src/main/resources/db/migration/V62__notifications_audit_user_created_index.sql new file mode 100644 index 0000000..a37cf54 --- /dev/null +++ b/src/main/resources/db/migration/V62__notifications_audit_user_created_index.sql @@ -0,0 +1,3 @@ +-- Composite index for "latest notification audit per user" (ignoreBlocked: skip users whose last send was FAILED). +-- findTopByUserIdOrderByCreatedAtDesc(user_id) uses this for efficient lookup. +CREATE INDEX idx_notifications_audit_user_created ON notifications_audit (user_id, created_at DESC); diff --git a/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql b/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql new file mode 100644 index 0000000..1e8d948 --- /dev/null +++ b/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql @@ -0,0 +1,9 @@ +-- Configurations: key-value store for app-wide settings (e.g. lottery bot scheduler). +CREATE TABLE IF NOT EXISTS configurations ( + `key` VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(512) NOT NULL DEFAULT '' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Bots may join a round only when participant count <= this value (default 1 = join when 0 or 1 participant). +INSERT INTO configurations (`key`, value) VALUES ('lottery_bot_max_participants_before_join', '1') +ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql b/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql new file mode 100644 index 0000000..e65ac00 --- /dev/null +++ b/src/main/resources/db/migration/V64__game_rounds_room_phase_started_at_index.sql @@ -0,0 +1,4 @@ +-- Index for admin online-users and other flows that fetch "most recent active round" per room. +-- Query: WHERE room_id = ? AND phase IN (...) ORDER BY started_at DESC LIMIT 1 +-- Covers filter by room_id and phase, and sort by started_at without filesort. +CREATE INDEX idx_game_rounds_room_phase_started_at ON game_rounds (room_id, phase, started_at DESC); diff --git a/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql b/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql new file mode 100644 index 0000000..544ae6a --- /dev/null +++ b/src/main/resources/db/migration/V65__feature_switch_manual_pay_for_all_payouts.sql @@ -0,0 +1,4 @@ +-- When enabled (1), send manual_pay=1 for all crypto payouts. When disabled (0), send manual_pay=1 only for users who completed 50 or 100 referrals (first withdrawal). Default on. +INSERT INTO `feature_switches` (`key`, `enabled`) VALUES + ('manual_pay_for_all_payouts', 1) +ON DUPLICATE KEY UPDATE `key` = VALUES(`key`); diff --git a/src/main/resources/db/migration/V66__payouts_add_txhash.sql b/src/main/resources/db/migration/V66__payouts_add_txhash.sql new file mode 100644 index 0000000..c0fe7e8 --- /dev/null +++ b/src/main/resources/db/migration/V66__payouts_add_txhash.sql @@ -0,0 +1,2 @@ +-- Store transaction hash from crypto withdrawal API (WithdrawalInfoApiResponse.PaymentItem.txhash) +ALTER TABLE `payouts` ADD COLUMN `txhash` VARCHAR(255) NULL AFTER `payment_id`; diff --git a/src/main/resources/db/migration/V67__admin_user_deposits_withdrawals_indexes.sql b/src/main/resources/db/migration/V67__admin_user_deposits_withdrawals_indexes.sql new file mode 100644 index 0000000..22b65fd --- /dev/null +++ b/src/main/resources/db/migration/V67__admin_user_deposits_withdrawals_indexes.sql @@ -0,0 +1,9 @@ +-- Indexes for admin User Detail tabs: Deposits and Withdrawals. +-- Deposits: GET /admin/users/{id}/payments — WHERE user_id = ? ORDER BY created_at DESC (default). +-- Withdrawals: GET /admin/users/{id}/payouts — WHERE user_id = ? AND type = 'CRYPTO' ORDER BY created_at DESC (default). + +-- payments: optimize "list payments by user" with default sort by created_at +CREATE INDEX idx_payments_user_created_at ON payments(user_id, created_at); + +-- payouts: optimize "list CRYPTO payouts by user" with default sort by created_at +CREATE INDEX idx_payouts_user_type_created_at ON payouts(user_id, type, created_at); diff --git a/src/main/resources/db/migration/V68__payouts_user_type_crypto_name_index.sql b/src/main/resources/db/migration/V68__payouts_user_type_crypto_name_index.sql new file mode 100644 index 0000000..5da923b --- /dev/null +++ b/src/main/resources/db/migration/V68__payouts_user_type_crypto_name_index.sql @@ -0,0 +1,2 @@ +-- Withdrawals tab: sort by Ticker (crypto_name). Query: WHERE user_id = ? AND type = 'CRYPTO' ORDER BY crypto_name. +CREATE INDEX idx_payouts_user_type_crypto_name ON payouts(user_id, type, crypto_name); diff --git a/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql b/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql new file mode 100644 index 0000000..f8c1cde --- /dev/null +++ b/src/main/resources/db/migration/V69__users_b_withdrawals_disabled.sql @@ -0,0 +1,2 @@ +-- Per-user withdrawal restriction. When 1, the user cannot create any payout request (STARS, GIFT, CRYPTO). +ALTER TABLE `db_users_b` ADD COLUMN `withdrawals_disabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `total_win_after_deposit`; diff --git a/src/main/resources/db/migration/V6__create_tasks_tables.sql b/src/main/resources/db/migration/V6__create_tasks_tables.sql new file mode 100644 index 0000000..d2725fe --- /dev/null +++ b/src/main/resources/db/migration/V6__create_tasks_tables.sql @@ -0,0 +1,41 @@ +-- Create tasks table +CREATE TABLE `tasks` ( + `id` int NOT NULL AUTO_INCREMENT, + `type` varchar(20) NOT NULL COMMENT 'referral, follow, other', + `requirement` int NOT NULL COMMENT 'Number required (e.g., number of friends to invite)', + `reward_amount` bigint NOT NULL COMMENT 'Reward amount in bigint format', + `reward_type` varchar(20) NOT NULL DEFAULT 'Stars' COMMENT 'Stars, Power, etc.', + `display_order` int NOT NULL DEFAULT 0 COMMENT 'Order for display', + `title` varchar(255) NOT NULL COMMENT 'Task title (e.g., "Invite 1 friend")', + `description` text COMMENT 'Task description', + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_display_order` (`display_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Create user_task_claims table to track claimed tasks +-- If record exists, it means the task was claimed and reward was given +CREATE TABLE `user_task_claims` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `task_id` int NOT NULL, + `claimed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_task` (`user_id`, `task_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_task_id` (`task_id`), + CONSTRAINT `fk_user_task_claims_user` FOREIGN KEY (`user_id`) REFERENCES `db_users_a` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_task_claims_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Insert default referral tasks +-- reward_amount is in bigint format (e.g., 2 Stars = 2000000, 5 Stars = 5000000) +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES +('referral', 1, 2000000, 'Stars', 1, 'Invite 1 friend', 'Invite 1 friend using your unique referral link'), +('referral', 3, 5000000, 'Stars', 2, 'Invite 3 friends', 'Invite 3 friends using your unique referral link'), +('referral', 7, 15000000, 'Stars', 3, 'Invite 7 friends', 'Invite 7 friends using your unique referral link'), +('referral', 15, 25000000, 'Stars', 4, 'Invite 15 friends', 'Invite 15 friends using your unique referral link'), +('referral', 30, 40000000, 'Stars', 5, 'Invite 30 friends', 'Invite 30 friends using your unique referral link'), +('referral', 50, 60000000, 'Stars', 6, 'Invite 50 friends', 'Invite 50 friends using your unique referral link'), +('referral', 100, 150000000, 'Stars', 7, 'Invite 100 friends', 'Invite 100 friends using your unique referral link'); + diff --git a/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql b/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql new file mode 100644 index 0000000..6e47c93 --- /dev/null +++ b/src/main/resources/db/migration/V70__seed_net_win_max_bet_and_ref_count_promotions.sql @@ -0,0 +1,33 @@ +-- Two new promotions: NET_WIN_MAX_BET and REF_COUNT, start 4 March 2026 14:00 UTC, same rewards as promotion 1 (V60) +-- End time: 11 March 2026 14:00 UTC (1 week) +INSERT INTO promotions (type, start_time, end_time, status, total_reward) VALUES +('NET_WIN_MAX_BET', '2026-03-04 14:00:00', '2026-03-11 14:00:00', 'PLANNED', 1000000000000), +('REF_COUNT', '2026-03-04 14:00:00', '2026-03-11 14:00:00', 'PLANNED', 1000000000000); + +-- NET_WIN_MAX_BET rewards (same as promotion 1) +SET @promo_max_bet = (SELECT id FROM promotions WHERE type = 'NET_WIN_MAX_BET' AND start_time = '2026-03-04 14:00:00' LIMIT 1); +INSERT INTO promotions_rewards (promo_id, place, reward) VALUES +(@promo_max_bet, 1, 300000000000), +(@promo_max_bet, 2, 200000000000), +(@promo_max_bet, 3, 150000000000), +(@promo_max_bet, 4, 100000000000), +(@promo_max_bet, 5, 80000000000), +(@promo_max_bet, 6, 55000000000), +(@promo_max_bet, 7, 40000000000), +(@promo_max_bet, 8, 30000000000), +(@promo_max_bet, 9, 25000000000), +(@promo_max_bet, 10, 20000000000); + +-- REF_COUNT rewards (same structure) +SET @promo_ref = (SELECT id FROM promotions WHERE type = 'REF_COUNT' AND start_time = '2026-03-04 14:00:00' LIMIT 1); +INSERT INTO promotions_rewards (promo_id, place, reward) VALUES +(@promo_ref, 1, 300000000000), +(@promo_ref, 2, 200000000000), +(@promo_ref, 3, 150000000000), +(@promo_ref, 4, 100000000000), +(@promo_ref, 5, 80000000000), +(@promo_ref, 6, 55000000000), +(@promo_ref, 7, 40000000000), +(@promo_ref, 8, 30000000000), +(@promo_ref, 9, 25000000000), +(@promo_ref, 10, 20000000000); diff --git a/src/main/resources/db/migration/V7__add_follow_and_other_tasks.sql b/src/main/resources/db/migration/V7__add_follow_and_other_tasks.sql new file mode 100644 index 0000000..43d1e33 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_follow_and_other_tasks.sql @@ -0,0 +1,14 @@ +-- Insert Follow task +-- reward_amount is in bigint format (5 Stars = 5000000) +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES +('follow', 1, 5000000, 'Stars', 1, 'Follow our News channel', 'Follow our News channel'); + +-- Insert Other task +-- reward_amount is in bigint format (100 Stars = 100000000) +-- requirement is deposit_total in bigint format (500000000 = 500 USD in bigint) +INSERT INTO `tasks` (`type`, `requirement`, `reward_amount`, `reward_type`, `display_order`, `title`, `description`) VALUES +('other', 500000000, 100000000, 'Stars', 1, 'Top Up Balance: $5', 'Top Up Balance: $5'); + + + + diff --git a/src/main/resources/db/migration/V8__create_payouts_table.sql b/src/main/resources/db/migration/V8__create_payouts_table.sql new file mode 100644 index 0000000..a394738 --- /dev/null +++ b/src/main/resources/db/migration/V8__create_payouts_table.sql @@ -0,0 +1,18 @@ +-- Create payouts table +CREATE TABLE IF NOT EXISTS payouts ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + username VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL COMMENT 'STARS, GIFT', + gift_name VARCHAR(50) NULL COMMENT 'Gift name for GIFT type (HEART, BEAR, etc)', + total BIGINT UNSIGNED NOT NULL COMMENT 'Tickets amount in bigint format', + stars_amount INT NOT NULL COMMENT 'Stars amount', + status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING, COMPLETED, CANCELLED', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP NULL, + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES db_users_a(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/src/main/resources/db/migration/V9__add_quantity_to_payouts.sql b/src/main/resources/db/migration/V9__add_quantity_to_payouts.sql new file mode 100644 index 0000000..f3e1e88 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_quantity_to_payouts.sql @@ -0,0 +1,7 @@ +-- Add quantity column to payouts table +ALTER TABLE payouts +ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT 'Quantity of gifts/stars (1-100)' AFTER stars_amount; + + + + diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..412d5e9 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + ${LOG_DIR}/${APP_NAME}.log + + + + + ${LOG_DIR}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log + + + 50MB + + + 14 + days + + + 10GB + + + true + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + 0 + + + 256 + + + false + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..f7be116 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,201 @@ +# Default English messages +# This file contains all user-facing messages in the application + +# Telegram Bot Messages +bot.button.startSpinning=Start Game +bot.button.startSpinningInline=↘️ START GAME ↙️ +bot.button.usersPayouts=Users payouts +bot.button.infoChannel=Info channel +bot.button.openChannel=Open Channel +bot.button.goToChannel=Go to the channel +bot.welcome.firstMessage=Try your luck with Win Spin! +bot.welcome.message=Start betting, win and withdraw straight to your wallet.\n\n👉 To get started, watch the video above. +bot.message.startSpinning=Use this button to open the Win Spin app: +bot.message.usersPayouts=🔒 Real-time updates.\n\nAll withdrawals are shown in the channel. You can check everything yourself: +bot.message.infoChannel=📢 Stay informed with all the latest project updates — right here on our channel +bot.message.paySupport=Hello! In order to create a support request regarding your payment, please contact @winspinpaysupport.\n\nPlease note: Refunds are only granted if the purchased tickets have not yet been used in game rounds. +bot.message.unrecognized=We couldn't recognize that. Please use one of the buttons below. + +# Common +common.error.unknown=An unexpected error occurred +common.error.validation=Validation error +common.success=Success + +# Feature switches (payment / payout disabled) +feature.depositsUnavailable=Deposits are temporarily unavailable. We apologise for the inconvenience. Please try again later. +feature.payoutsUnavailable=Withdrawals are temporarily unavailable. We apologise for the inconvenience. Please try again later. + +# Authentication +auth.error.invalid=Invalid authentication +auth.error.expired=Session expired +auth.error.required=Authentication required +auth.error.accessRestricted=Application access restricted. +auth.error.initDataRequired=initData is required +auth.error.initDataMissing=Telegram initData is missing +auth.error.missingHash=Missing Telegram hash +auth.error.invalidSignature=Invalid Telegram signature +auth.error.userFieldMissing=initData does not contain 'user' field +auth.error.invalidInitData=Invalid Telegram initData + +# User +user.error.notFound=User not found +user.error.banned=User is banned +user.language.updated=Language updated successfully + +# Game +game.error.roomNotFound=Room not found. Please try again. +game.error.invalidBetAmount=Bet amount must be between {0} and {1} +game.error.insufficientBalance=Insufficient balance +game.error.rateLimit=Too many requests. Please wait a moment before trying again. +game.error.roundNotActive=Round is not active +game.error.maxBetExceeded=You have exceeded the maximum bet limit of {0} for this room. Your current total bet is {1}, so you can bet up to {2} more. +game.error.betMustBePositive=Bet amount must be a positive integer. +game.error.roomNumberInvalid=Room number must be between 1 and 3. + +# Task +task.error.notFound=Task not found +task.error.notCompleted=Task not completed! +task.error.alreadyClaimed=Task already claimed +task.success.claimed=Task claimed successfully +task.claimed=Claimed + +# Transaction +transaction.error.notFound=Transaction not found + +# Payout +payout.error.withdrawalsRestrictedForAccount=We're sorry, but withdrawals have been restricted for your account. Please contact Support for assistance. +payout.error.depositRequiredToWithdraw=Due to the increasing incidence of fraud, at least one deposit is required to create a withdrawal request. +payout.error.withdrawExceedsWinAfterDeposit=Due to increased fraud, withdrawal requests cannot exceed your total winnings since your last deposit. You can currently withdraw a maximum of {0} tickets. +payout.error.invalidAmount=Invalid payout amount +payout.error.minimumNotMet=Minimum payout amount not met +payout.error.insufficientBalance=Insufficient balance for payout +payout.error.quantityMustBeOne=Quantity must be 1 for this payout type +payout.error.invalidPayoutType=Invalid payout type +payout.error.starsAmountNonNegative=Amount must be a non-negative integer +payout.error.minimumStarsRequired=Minimum amount required +payout.error.starsAmountNotAllowed=Amount must be one of the allowed options +payout.error.starsAmountAndTotalRequired=Amount and total are required +payout.error.totalMustEqualStars=Total must match the expected value. Expected: {0}, provided: {1} +payout.error.giftNameRequired=Gift name is required for GIFT type payout +payout.error.invalidGiftName=Invalid gift name: {0} +payout.error.starsAmountNotDefined=Amount not defined for gift: {0} +payout.error.totalNotDefined=Total not defined for gift: {0} +payout.error.giftNameTotalQuantityRequired=Gift name, total, and quantity are required +payout.error.totalForGiftMismatch=Total for gift {0} (quantity {1}) must be {2}, but provided: {3} +payout.error.quantityRequired=Quantity is required +payout.error.quantityRange=Quantity must be between 1 and 100 +payout.error.usernameRequired=Username is required +payout.error.usernamePattern=Username must start with @ followed by at least one English letter +payout.error.ticketsAmountNonNegative=Tickets amount must be a non-negative number +payout.error.insufficientBalanceDetailed=Insufficient balance. Available: {0}, Required: {1} +payout.error.notFound=Payout not found: {0} +payout.error.onlyProcessingCanComplete=Only PROCESSING payouts can be completed. Current status: {0} +payout.error.onlyProcessingCanCancel=Only PROCESSING payouts can be cancelled. Current status: {0} +payout.error.withdrawalInProgress=You already have a withdrawal in progress. Please wait for it to complete. +payout.error.withdrawalAmountMaxTwoDecimals=Withdrawal amount must have at most 2 decimal places (e.g. 125.25). Values like 125.125 are not supported. +payout.success.requested=Payout request submitted successfully + +# Withdraw (crypto) +withdraw.error.walletInvalidFormat=The wallet address format is incorrect. Please check and try again. +withdraw.error.tryLater=There is an issue with withdrawals at the moment. Please try again a bit later. + +# Payment +payment.error.invalidAmount=Invalid payment amount +payment.error.minStars=Amount must be at least {0} +payment.error.maxStars=Amount cannot exceed {0} +payment.error.usdRange=USD amount must be between {0} and {1} +payment.error.usdMaxTwoDecimals=USD amount must have at most 2 decimal places (e.g. 2.45). +payment.error.legacyNotSupported=This payment method is no longer supported. Please use available methods to purchase tickets. +payment.error.botTokenNotConfigured=Bot token is not configured +payment.error.failedToCreateInvoice=Failed to create invoice link +payment.error.paymentNotFound=Payment not found for order ID: {0} +payment.error.userNotFound=User not found for Telegram ID: {0} +payment.error.userIdMismatch=User ID mismatch. Expected: {0}, but got: {1} +payment.error.starsAmountMismatch=Amount mismatch. Expected: {0}, but got: {1} +payment.error.failed=Payment failed. Please try again. +payment.error.statusUnknown=Payment status unknown. Please check your balance. +payment.error.invalidPid=Invalid payment method selected. +payment.error.depositAddressFailed=Failed to get deposit address. Please try again. +payment.success.purchased=Successfully purchased {0} tickets! + +# Support +support.error.ticketNotFound=Ticket not found +support.error.messageFailed=Failed to send message +support.error.closeFailed=Failed to close ticket +support.error.maxTicketsReached=You have reached the maximum limit of {0} open tickets. Please close some tickets before creating a new one. +support.error.ticketClosed=Cannot add message to a closed ticket. Please create a new ticket. +support.error.maxMessagesReached=You have reached the maximum limit of {0} messages per ticket. Please create a new ticket. +support.error.rateLimitWait=Please wait {0} second(s) before sending another message. +support.error.ticketAlreadyClosed=Ticket is already closed. + +# Validation +validation.error.required={0} is required +validation.error.positive={0} must be positive +validation.error.range={0} must be between {1} and {2} + +# Game Room (additional messages) +game.error.roundNotFound=Game round not found. Please try again. +game.error.participantNotFound=Participant not found. Please try again. +game.error.rateLimitWait=Please wait before placing another bet. Rate limit: 1 bet per 1 second. +game.error.roomNotJoinable=Room is not joinable at this time +game.error.invalidRequest=Invalid request. Request body is required. + +# User Service +user.error.referralLevelInvalid=Invalid referral level: {0}. Must be 1, 2, or 3. +user.error.depositAmountInvalid=Deposit amount must be positive +user.error.balanceNotFound=User balance not found + +# Task Controller +task.message.claimed=Task claimed successfully +task.message.notCompleted=Task not completed! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Invite 1 friend +task.title.inviteFriends.3=Invite 3 friends +task.title.inviteFriends.7=Invite 7 friends +task.title.inviteFriends.15=Invite 15 friends +task.title.inviteFriends.30=Invite 30 friends +task.title.inviteFriends.50=Invite 50 friends +task.title.inviteFriends.100=Invite 100 friends +task.title.followChannel=Follow our News channel +task.title.followChannelWithdrawals=Follow Proof of payment channel +task.title.dailyBonus=Daily Bonus +task.title.deposit=Deposit {0} tickets +task.description.inviteFriends.1=Invite 1 friend using your unique referral link +task.description.inviteFriends.3=Invite 3 friends using your unique referral link +task.description.inviteFriends.7=Invite 7 friends using your unique referral link +task.description.inviteFriends.15=Invite 15 friends using your unique referral link +task.description.inviteFriends.30=Invite 30 friends using your unique referral link +task.description.inviteFriends.50=Invite 50 friends using your unique referral link +task.description.inviteFriends.100=Invite 100 friends using your unique referral link +task.description.followChannel=Follow our News channel +task.description.followChannelWithdrawals=Follow Proof of payment channel +task.description.dailyBonus=Claim your daily free ticket! +task.description.deposit=Deposit {0} tickets to your account + +# Task Reward Text (localized with proper grammar) +# Referral tasks - separate keys for each requirement +task.reward.tickets.1=+5 Tickets +task.reward.tickets.3=+15 Tickets +task.reward.tickets.7=+35 Tickets +task.reward.tickets.15=+75 Tickets +task.reward.tickets.30=+110 Tickets +task.reward.tickets.50=+150 Tickets +task.reward.tickets.100=+375 Tickets +task.reward.tickets.follow=+7 Tickets +task.reward.tickets.daily=+1 Ticket +task.reward.tickets.other.50=+5 Tickets +task.reward.tickets.other.250=+25 Tickets +task.reward.tickets.other.1000=+100 Tickets +task.reward.tickets.other.2000=+100 Tickets +task.reward.tickets.other.5000=+250 Tickets +task.reward.tickets.other.10000=+500 Tickets +task.reward.tickets.other.500=+100 Tickets +task.reward.tickets.other.50000=+2500 Tickets +task.reward.tickets.other.150000=+7500 Tickets +task.reward.tickets.other.500000=+25000 Tickets + +# Date/Time formatting +dateTime.at=at + diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties new file mode 100644 index 0000000..7a6f590 --- /dev/null +++ b/src/main/resources/messages_de.properties @@ -0,0 +1,144 @@ +# German messages +common.error.unknown=Ein unerwarteter Fehler ist aufgetreten +common.error.validation=Validierungsfehler +common.success=Erfolg + +feature.depositsUnavailable=Einzahlungen sind vorübergehend nicht verfügbar. Wir entschuldigen uns für die Unannehmlichkeiten. Bitte versuchen Sie es später erneut. +feature.payoutsUnavailable=Auszahlungen sind vorübergehend nicht verfügbar. Wir entschuldigen uns für die Unannehmlichkeiten. Bitte versuchen Sie es später erneut. + +auth.error.invalid=Ungültige Authentifizierung +auth.error.expired=Sitzung abgelaufen +auth.error.required=Authentifizierung erforderlich +auth.error.accessRestricted=Zugriff auf die Anwendung eingeschränkt. + +user.error.notFound=Benutzer nicht gefunden +user.error.banned=Benutzer ist gesperrt +user.language.updated=Sprache erfolgreich aktualisiert + +game.error.roomNotFound=Raum nicht gefunden. Bitte versuchen Sie es erneut. +game.error.invalidBetAmount=Der Einsatzbetrag muss zwischen {0} und {1} liegen +game.error.insufficientBalance=Unzureichendes Guthaben +game.error.rateLimit=Zu viele Anfragen. Bitte warten Sie einen Moment, bevor Sie es erneut versuchen. +game.error.roundNotActive=Runde ist nicht aktiv +game.error.maxBetExceeded=Sie haben das maximale Einsatzlimit von {0} für diesen Raum überschritten. Ihr aktueller Gesamteinsatz beträgt {1}, daher können Sie noch bis zu {2} mehr setzen. +game.error.betMustBePositive=Der Einsatzbetrag muss eine positive ganze Zahl sein. +game.error.roomNumberInvalid=Die Raumnummer muss zwischen 1 und 3 liegen. + +task.error.notFound=Aufgabe nicht gefunden +task.error.notCompleted=Aufgabe nicht abgeschlossen! +task.error.alreadyClaimed=Aufgabe bereits erhalten +task.success.claimed=Aufgabe erfolgreich erhalten +task.claimed=Eingelöst + +transaction.error.notFound=Transaktion nicht gefunden + +payout.error.withdrawalsRestrictedForAccount=Wir bedauern, aber Auszahlungen wurden für Ihr Konto eingeschränkt. Bitte kontaktieren Sie den Support. +payout.error.depositRequiredToWithdraw=Aufgrund des zunehmenden Betrugs ist mindestens eine Einzahlung erforderlich, um eine Auszahlungsanfrage zu erstellen. +payout.error.withdrawExceedsWinAfterDeposit=Aufgrund des zunehmenden Betrugs dürfen Auszahlungsanfragen Ihre Gesamtgewinne seit Ihrer letzten Einzahlung nicht überschreiten. Sie können derzeit maximal {0} Tickets abheben. +payout.error.invalidAmount=Ungültiger Auszahlungsbetrag +payout.error.minimumNotMet=Mindestauszahlungsbetrag nicht erreicht +payout.error.insufficientBalance=Unzureichendes Guthaben für Auszahlung +payout.error.withdrawalInProgress=Sie haben bereits eine Auszahlung in Bearbeitung. Bitte warten Sie auf den Abschluss. +payout.success.requested=Auszahlungsanfrage erfolgreich übermittelt + +withdraw.error.walletInvalidFormat=Das Format der Wallet-Adresse ist falsch. Bitte überprüfen Sie und versuchen Sie es erneut. +withdraw.error.tryLater=Derzeit gibt es ein Problem mit Auszahlungen. Bitte versuchen Sie es etwas später erneut. + +payment.error.invalidAmount=Ungültiger Zahlungsbetrag +payment.error.minStars=Der Betrag muss mindestens {0} betragen +payment.error.maxStars=Der Betrag darf {0} nicht überschreiten +payment.error.usdMaxTwoDecimals=Der USD-Betrag darf höchstens 2 Nachkommastellen haben (z. B. 2,45). +payment.error.legacyNotSupported=Diese Zahlungsmethode wird nicht mehr unterstützt. Bitte verwenden Sie die verfügbaren Methoden, um Tickets zu kaufen. +payment.error.failed=Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut. +payment.error.statusUnknown=Zahlungsstatus unbekannt. Bitte überprüfen Sie Ihr Guthaben. +payment.success.purchased=Erfolgreich {0} Tickets gekauft! + +support.error.ticketNotFound=Ticket nicht gefunden +support.error.messageFailed=Nachricht konnte nicht gesendet werden +support.error.closeFailed=Ticket konnte nicht geschlossen werden +support.error.rateLimitWait=Bitte warten Sie {0} Sekunde(n), bevor Sie eine weitere Nachricht senden. + +validation.error.required={0} ist erforderlich +validation.error.positive={0} muss positiv sein +validation.error.range={0} muss zwischen {1} und {2} liegen + +# Game Room (additional messages) +game.error.roundNotFound=Spielrunde nicht gefunden. Bitte versuchen Sie es erneut. +game.error.participantNotFound=Teilnehmer nicht gefunden. Bitte versuchen Sie es erneut. +game.error.rateLimitWait=Bitte warten Sie, bevor Sie einen weiteren Einsatz platzieren. Rate-Limit: 1 Einsatz pro Sekunde. +game.error.roomNotJoinable=Raum ist derzeit nicht beitretbar +game.error.invalidRequest=Ungültige Anfrage. Anfragekörper ist erforderlich. + +# User Service +user.error.referralLevelInvalid=Ungültiges Empfehlungslevel: {0}. Muss 1, 2 oder 3 sein. +user.error.depositAmountInvalid=Einzahlungsbetrag muss positiv sein +user.error.balanceNotFound=Benutzerguthaben nicht gefunden + +# Task Controller +task.message.claimed=Aufgabe erfolgreich erhalten +task.message.notCompleted=Aufgabe nicht abgeschlossen! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Lade 1 Freund ein +task.title.inviteFriends.3=Lade 3 Freunde ein +task.title.inviteFriends.7=Lade 7 Freunde ein +task.title.inviteFriends.15=Lade 15 Freunde ein +task.title.inviteFriends.30=Lade 30 Freunde ein +task.title.inviteFriends.50=Lade 50 Freunde ein +task.title.inviteFriends.100=Lade 100 Freunde ein +task.title.followChannel=Folgen Sie unserem Nachrichtenkanal +task.title.followChannelWithdrawals=Folgen Sie dem Kanal für Zahlungsnachweise +task.title.dailyBonus=Täglicher Bonus +task.title.deposit=Zahle {0} Tickets ein +task.description.inviteFriends.1=Lade 1 Freund mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.3=Lade 3 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.7=Lade 7 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.15=Lade 15 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.30=Lade 30 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.50=Lade 50 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.inviteFriends.100=Lade 100 Freunde mit deinem eindeutigen Empfehlungslink ein +task.description.followChannel=Folgen Sie unserem Nachrichtenkanal +task.description.followChannelWithdrawals=Folgen Sie dem Kanal für Zahlungsnachweise +task.description.dailyBonus=Hole dir dein tägliches kostenloses Ticket! +task.description.deposit=Zahle {0} Tickets auf dein Konto ein + +# Task Reward Text +task.reward.tickets.1=+5 Tickets +task.reward.tickets.3=+15 Tickets +task.reward.tickets.7=+35 Tickets +task.reward.tickets.15=+75 Tickets +task.reward.tickets.30=+110 Tickets +task.reward.tickets.50=+150 Tickets +task.reward.tickets.100=+375 Tickets +task.reward.tickets.follow=+7 Tickets +task.reward.tickets.daily=+1 Ticket +task.reward.tickets.other.50=+5 Tickets +task.reward.tickets.other.250=+25 Tickets +task.reward.tickets.other.1000=+100 Tickets +task.reward.tickets.other.2000=+100 Tickets +task.reward.tickets.other.5000=+250 Tickets +task.reward.tickets.other.10000=+500 Tickets +task.reward.tickets.other.500=+100 Tickets +task.reward.tickets.other.50000=+2500 Tickets +task.reward.tickets.other.150000=+7500 Tickets +task.reward.tickets.other.500000=+25000 Tickets + +# Date/Time formatting +dateTime.at=um + +# Telegram Bot Messages +bot.button.startSpinning=Spiel starten +bot.button.startSpinningInline=↘️ SPIEL STARTEN ↙️ +bot.button.usersPayouts=Benutzerauszahlungen +bot.button.infoChannel=Info-Kanal +bot.button.openChannel=Kanal öffnen +bot.button.goToChannel=Zum Kanal gehen +bot.welcome.firstMessage=Versuchen Sie Ihr Glück mit Win Spin! +bot.welcome.message=Beginnen Sie zu wetten, gewinnen Sie und ziehen Sie direkt auf Ihre Wallet ab.\n\n👉 Um zu beginnen, schauen Sie sich das Video oben an. +bot.message.startSpinning=Verwenden Sie diese Schaltfläche, um die Win Spin-App zu öffnen: +bot.message.usersPayouts=🔒 Echtzeit-Updates.\n\nAlle Auszahlungen werden im Kanal angezeigt. Sie können alles selbst überprüfen: +bot.message.infoChannel=📢 Bleiben Sie über alle neuesten Projekt-Updates informiert — direkt hier in unserem Kanal +bot.message.paySupport=Hallo! Um eine Supportanfrage bezüglich Ihrer Zahlung zu erstellen, kontaktieren Sie bitte @winspinpaysupport.\n\nBitte beachten Sie: Rückerstattungen werden nur gewährt, wenn die gekauften Tickets noch nicht in Spielrunden verwendet wurden. +bot.message.unrecognized=Wir konnten das nicht erkennen. Bitte nutzen Sie einen der Buttons unten. + diff --git a/src/main/resources/messages_es.properties b/src/main/resources/messages_es.properties new file mode 100644 index 0000000..b57d0cf --- /dev/null +++ b/src/main/resources/messages_es.properties @@ -0,0 +1,144 @@ +# Spanish messages +common.error.unknown=Ha ocurrido un error inesperado +common.error.validation=Error de validación +common.success=Éxito + +feature.depositsUnavailable=Los depósitos no están disponibles temporalmente. Nos disculpamos por las molestias. Por favor, inténtelo de nuevo más tarde. +feature.payoutsUnavailable=Los retiros no están disponibles temporalmente. Nos disculpamos por las molestias. Por favor, inténtelo de nuevo más tarde. + +auth.error.invalid=Autenticación inválida +auth.error.expired=Sesión expirada +auth.error.required=Autenticación requerida +auth.error.accessRestricted=Acceso a la aplicación restringido. + +user.error.notFound=Usuario no encontrado +user.error.banned=El usuario está baneado +user.language.updated=Idioma actualizado exitosamente + +game.error.roomNotFound=Sala no encontrada. Por favor, inténtelo de nuevo. +game.error.invalidBetAmount=El monto de la apuesta debe estar entre {0} y {1} +game.error.insufficientBalance=Saldo insuficiente +game.error.rateLimit=Demasiadas solicitudes. Por favor, espere un momento antes de intentar de nuevo. +game.error.roundNotActive=La ronda no está activa +game.error.maxBetExceeded=Has excedido el límite máximo de apuesta de {0} para esta sala. Tu apuesta total actual es {1}, por lo que puedes apostar hasta {2} más. +game.error.betMustBePositive=El monto de la apuesta debe ser un número entero positivo. +game.error.roomNumberInvalid=El número de sala debe estar entre 1 y 3. + +task.error.notFound=Tarea no encontrada +task.error.notCompleted=¡Tarea no completada! +task.error.alreadyClaimed=Tarea ya reclamada +task.success.claimed=Tarea reclamada exitosamente +task.claimed=Reclamado + +transaction.error.notFound=Transacción no encontrada + +payout.error.withdrawalsRestrictedForAccount=Lo sentimos, pero los retiros han sido restringidos para su cuenta. Por favor, contacte con Soporte. +payout.error.depositRequiredToWithdraw=Debido al aumento del fraude, se requiere al menos un depósito para crear una solicitud de retiro. +payout.error.withdrawExceedsWinAfterDeposit=Debido al aumento del fraude, las solicitudes de retiro no pueden exceder sus ganancias totales desde su último depósito. Actualmente puede retirar un máximo de {0} tickets. +payout.error.invalidAmount=Monto de retiro inválido +payout.error.minimumNotMet=Monto mínimo de retiro no alcanzado +payout.error.insufficientBalance=Saldo insuficiente para el retiro +payout.error.withdrawalInProgress=Ya tiene un retiro en curso. Espere a que se complete. +payout.success.requested=Solicitud de retiro enviada exitosamente + +withdraw.error.walletInvalidFormat=El formato de la dirección de la wallet es incorrecto. Compruebe e inténtelo de nuevo. +withdraw.error.tryLater=Hay un problema con los retiros en este momento. Por favor, inténtelo de nuevo un poco más tarde. + +payment.error.invalidAmount=Monto de pago inválido +payment.error.minStars=El monto debe ser al menos {0} +payment.error.maxStars=El monto no puede exceder {0} +payment.error.usdMaxTwoDecimals=El monto en USD debe tener como máximo 2 decimales (ej. 2,45). +payment.error.legacyNotSupported=Este método de pago ya no es compatible. Utilice los métodos disponibles para comprar boletos. +payment.error.failed=Pago fallido. Por favor, inténtelo de nuevo. +payment.error.statusUnknown=Estado de pago desconocido. Por favor, verifique su saldo. +payment.success.purchased=¡{0} boletos comprados exitosamente! + +support.error.ticketNotFound=Boleto no encontrado +support.error.messageFailed=No se pudo enviar el mensaje +support.error.closeFailed=No se pudo cerrar el boleto +support.error.rateLimitWait=Por favor, espere {0} segundo(s) antes de enviar otro mensaje. + +validation.error.required={0} es requerido +validation.error.positive={0} debe ser positivo +validation.error.range={0} debe estar entre {1} y {2} + +# Game Room (additional messages) +game.error.roundNotFound=Ronda de juego no encontrada. Por favor, inténtelo de nuevo. +game.error.participantNotFound=Participante no encontrado. Por favor, inténtelo de nuevo. +game.error.rateLimitWait=Por favor, espere antes de realizar otra apuesta. Límite de velocidad: 1 apuesta por segundo. +game.error.roomNotJoinable=La sala no es accesible en este momento +game.error.invalidRequest=Solicitud inválida. El cuerpo de la solicitud es requerido. + +# User Service +user.error.referralLevelInvalid=Nivel de referido inválido: {0}. Debe ser 1, 2 o 3. +user.error.depositAmountInvalid=El monto del depósito debe ser positivo +user.error.balanceNotFound=Saldo de usuario no encontrado + +# Task Controller +task.message.claimed=Tarea reclamada exitosamente +task.message.notCompleted=¡Tarea no completada! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Invita 1 amigo +task.title.inviteFriends.3=Invita 3 amigos +task.title.inviteFriends.7=Invita 7 amigos +task.title.inviteFriends.15=Invita 15 amigos +task.title.inviteFriends.30=Invita 30 amigos +task.title.inviteFriends.50=Invita 50 amigos +task.title.inviteFriends.100=Invita 100 amigos +task.title.followChannel=Sigue nuestro canal de noticias +task.title.followChannelWithdrawals=Sigue nuestro canal de comprobantes de pago +task.title.dailyBonus=Bonus diario +task.title.deposit=Deposita {0} boletos +task.description.inviteFriends.1=Invita 1 amigo usando tu enlace de referido único +task.description.inviteFriends.3=Invita 3 amigos usando tu enlace de referido único +task.description.inviteFriends.7=Invita 7 amigos usando tu enlace de referido único +task.description.inviteFriends.15=Invita 15 amigos usando tu enlace de referido único +task.description.inviteFriends.30=Invita 30 amigos usando tu enlace de referido único +task.description.inviteFriends.50=Invita 50 amigos usando tu enlace de referido único +task.description.inviteFriends.100=Invita 100 amigos usando tu enlace de referido único +task.description.followChannel=Sigue nuestro canal de noticias +task.description.followChannelWithdrawals=Sigue nuestro canal de comprobantes de pago +task.description.dailyBonus=¡Reclama tu boleto gratuito diario! +task.description.deposit=Deposita {0} boletos en tu cuenta + +# Task Reward Text +task.reward.tickets.1=+5 Boletos +task.reward.tickets.3=+15 Boletos +task.reward.tickets.7=+35 Boletos +task.reward.tickets.15=+75 Boletos +task.reward.tickets.30=+110 Boletos +task.reward.tickets.50=+150 Boletos +task.reward.tickets.100=+375 Boletos +task.reward.tickets.follow=+7 Boletos +task.reward.tickets.daily=+1 Boleto +task.reward.tickets.other.50=+5 Boletos +task.reward.tickets.other.250=+25 Boletos +task.reward.tickets.other.1000=+100 Boletos +task.reward.tickets.other.2000=+100 Boletos +task.reward.tickets.other.5000=+250 Boletos +task.reward.tickets.other.10000=+500 Boletos +task.reward.tickets.other.500=+100 Boletos +task.reward.tickets.other.50000=+2500 Boletos +task.reward.tickets.other.150000=+7500 Boletos +task.reward.tickets.other.500000=+25000 Boletos + +# Date/Time formatting +dateTime.at=a las + +# Telegram Bot Messages +bot.button.startSpinning=Empezar a jugar +bot.button.startSpinningInline=↘️ EMPEZAR A JUGAR ↙️ +bot.button.usersPayouts=Pagos de usuarios +bot.button.infoChannel=Canal de información +bot.button.openChannel=Abrir canal +bot.button.goToChannel=Ir al canal +bot.welcome.firstMessage=¡Prueba tu suerte con Win Spin! +bot.welcome.message=Comienza a apostar, gana y retira directamente a tu billetera.\n\n👉 Para comenzar, mira el video de arriba. +bot.message.startSpinning=Usa este botón para abrir la aplicación Win Spin: +bot.message.usersPayouts=🔒 Actualizaciones en tiempo real.\n\nTodas las retiradas se muestran en el canal. Puedes verificar todo tú mismo: +bot.message.infoChannel=📢 Mantente informado con todas las últimas actualizaciones del proyecto — aquí mismo en nuestro canal +bot.message.paySupport=¡Hola! Para crear una solicitud de soporte sobre tu pago, por favor contacta a @winspinpaysupport.\n\nNota: Los reembolsos solo se otorgan si los boletos comprados aún no han sido utilizados en rondas de juego. +bot.message.unrecognized=No hemos podido reconocer eso. Por favor, usa uno de los botones de abajo. + diff --git a/src/main/resources/messages_fr.properties b/src/main/resources/messages_fr.properties new file mode 100644 index 0000000..3797def --- /dev/null +++ b/src/main/resources/messages_fr.properties @@ -0,0 +1,144 @@ +# French messages +common.error.unknown=Une erreur inattendue s'est produite +common.error.validation=Erreur de validation +common.success=Succès + +feature.depositsUnavailable=Les dépôts sont temporairement indisponibles. Nous nous excusons pour la gêne occasionnée. Veuillez réessayer plus tard. +feature.payoutsUnavailable=Les retraits sont temporairement indisponibles. Nous nous excusons pour la gêne occasionnée. Veuillez réessayer plus tard. + +auth.error.invalid=Authentification invalide +auth.error.expired=Session expirée +auth.error.required=Authentification requise +auth.error.accessRestricted=Accès à l'application restreint. + +user.error.notFound=Utilisateur introuvable +user.error.banned=L'utilisateur est banni +user.language.updated=Langue mise à jour avec succès + +game.error.roomNotFound=Salle introuvable. Veuillez réessayer. +game.error.invalidBetAmount=Le montant du pari doit être entre {0} et {1} +game.error.insufficientBalance=Solde insuffisant +game.error.rateLimit=Trop de demandes. Veuillez attendre un moment avant de réessayer. +game.error.roundNotActive=Le round n'est pas actif +game.error.maxBetExceeded=Vous avez dépassé la limite maximale de pari de {0} pour cette salle. Votre pari total actuel est {1}, vous pouvez donc parier jusqu'à {2} de plus. +game.error.betMustBePositive=Le montant du pari doit être un entier positif. +game.error.roomNumberInvalid=Le numéro de salle doit être entre 1 et 3. + +task.error.notFound=Tâche introuvable +task.error.notCompleted=Tâche non terminée! +task.error.alreadyClaimed=Tâche déjà réclamée +task.success.claimed=Tâche réclamée avec succès +task.claimed=Réclamé + +transaction.error.notFound=Transaction introuvable + +payout.error.withdrawalsRestrictedForAccount=Nous sommes désolés, mais les retraits ont été restreints pour votre compte. Veuillez contacter le support. +payout.error.depositRequiredToWithdraw=En raison de l'augmentation des fraudes, au moins un dépôt est requis pour créer une demande de retrait. +payout.error.withdrawExceedsWinAfterDeposit=En raison de l'augmentation des fraudes, les demandes de retrait ne peuvent pas dépasser vos gains totaux depuis votre dernier dépôt. Vous pouvez actuellement retirer un maximum de {0} tickets. +payout.error.invalidAmount=Montant de retrait invalide +payout.error.minimumNotMet=Montant minimum de retrait non atteint +payout.error.insufficientBalance=Solde insuffisant pour le retrait +payout.error.withdrawalInProgress=Vous avez déjà un retrait en cours. Veuillez attendre qu'il soit terminé. +payout.success.requested=Demande de retrait envoyée avec succès + +withdraw.error.walletInvalidFormat=Le format de l'adresse du portefeuille est incorrect. Veuillez vérifier et réessayer. +withdraw.error.tryLater=Un problème est survenu avec les retraits pour le moment. Veuillez réessayer un peu plus tard. + +payment.error.invalidAmount=Montant de paiement invalide +payment.error.minStars=Le montant doit être d'au moins {0} +payment.error.maxStars=Le montant ne peut pas dépasser {0} +payment.error.usdMaxTwoDecimals=Le montant en USD doit avoir au plus 2 décimales (ex. 2,45). +payment.error.legacyNotSupported=Ce mode de paiement n'est plus pris en charge. Veuillez utiliser les méthodes disponibles pour acheter des billets. +payment.error.failed=Paiement échoué. Veuillez réessayer. +payment.error.statusUnknown=Statut de paiement inconnu. Veuillez vérifier votre solde. +payment.success.purchased={0} tickets achetés avec succès! + +support.error.ticketNotFound=Ticket introuvable +support.error.messageFailed=Impossible d'envoyer le message +support.error.closeFailed=Impossible de fermer le ticket +support.error.rateLimitWait=Veuillez attendre {0} seconde(s) avant d'envoyer un autre message. + +validation.error.required={0} est requis +validation.error.positive={0} doit être positif +validation.error.range={0} doit être entre {1} et {2} + +# Game Room (additional messages) +game.error.roundNotFound=Round de jeu introuvable. Veuillez réessayer. +game.error.participantNotFound=Participant introuvable. Veuillez réessayer. +game.error.rateLimitWait=Veuillez attendre avant de placer un autre pari. Limite de débit: 1 pari par seconde. +game.error.roomNotJoinable=La salle n'est pas accessible en ce moment +game.error.invalidRequest=Demande invalide. Le corps de la demande est requis. + +# User Service +user.error.referralLevelInvalid=Niveau de parrainage invalide: {0}. Doit être 1, 2 ou 3. +user.error.depositAmountInvalid=Le montant du dépôt doit être positif +user.error.balanceNotFound=Solde utilisateur introuvable + +# Task Controller +task.message.claimed=Tâche réclamée avec succès +task.message.notCompleted=Tâche non terminée! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Invitez 1 ami +task.title.inviteFriends.3=Invitez 3 amis +task.title.inviteFriends.7=Invitez 7 amis +task.title.inviteFriends.15=Invitez 15 amis +task.title.inviteFriends.30=Invitez 30 amis +task.title.inviteFriends.50=Invitez 50 amis +task.title.inviteFriends.100=Invitez 100 amis +task.title.followChannel=Suivez notre chaîne d'actualités +task.title.followChannelWithdrawals=Suivez notre chaîne de preuves de paiement +task.title.dailyBonus=Bonus quotidien +task.title.deposit=Déposez {0} billets +task.description.inviteFriends.1=Invitez 1 ami en utilisant votre lien de parrainage unique +task.description.inviteFriends.3=Invitez 3 amis en utilisant votre lien de parrainage unique +task.description.inviteFriends.7=Invitez 7 amis en utilisant votre lien de parrainage unique +task.description.inviteFriends.15=Invitez 15 amis en utilisant votre lien de parrainage unique +task.description.inviteFriends.30=Invitez 30 amis en utilisant votre lien de parrainage unique +task.description.inviteFriends.50=Invitez 50 amis en utilisant votre lien de parrainage unique +task.description.inviteFriends.100=Invitez 100 amis en utilisant votre lien de parrainage unique +task.description.followChannel=Suivez notre chaîne d'actualités +task.description.followChannelWithdrawals=Suivez notre chaîne de preuves de paiement +task.description.dailyBonus=Réclamez votre billet gratuit quotidien! +task.description.deposit=Déposez {0} billets sur votre compte + +# Task Reward Text +task.reward.tickets.1=+5 Billets +task.reward.tickets.3=+15 Billets +task.reward.tickets.7=+35 Billets +task.reward.tickets.15=+75 Billets +task.reward.tickets.30=+110 Billets +task.reward.tickets.50=+150 Billets +task.reward.tickets.100=+375 Billets +task.reward.tickets.follow=+7 Billets +task.reward.tickets.daily=+1 Billet +task.reward.tickets.other.50=+5 Billets +task.reward.tickets.other.250=+25 Billets +task.reward.tickets.other.1000=+100 Billets +task.reward.tickets.other.2000=+100 Billets +task.reward.tickets.other.5000=+250 Billets +task.reward.tickets.other.10000=+500 Billets +task.reward.tickets.other.500=+100 Billets +task.reward.tickets.other.50000=+2500 Billets +task.reward.tickets.other.150000=+7500 Billets +task.reward.tickets.other.500000=+25000 Billets + +# Date/Time formatting +dateTime.at=à + +# Telegram Bot Messages +bot.button.startSpinning=Commencer à jouer +bot.button.startSpinningInline=↘️ COMMENCER À JOUER ↙️ +bot.button.usersPayouts=Paiements des utilisateurs +bot.button.infoChannel=Canal d'information +bot.button.openChannel=Ouvrir le canal +bot.button.goToChannel=Aller au canal +bot.welcome.firstMessage=Essayez votre chance avec Win Spin! +bot.welcome.message=Commencez à parier, gagnez et retirez directement vers votre portefeuille.\n\n👉 Pour commencer, regardez la vidéo ci-dessus. +bot.message.startSpinning=Utilisez ce bouton pour ouvrir l'application Win Spin: +bot.message.usersPayouts=🔒 Mises à jour en temps réel.\n\nTous les retraits sont affichés dans le canal. Vous pouvez tout vérifier vous-même: +bot.message.infoChannel=📢 Restez informé de toutes les dernières mises à jour du projet — ici même sur notre canal +bot.message.paySupport=Bonjour! Pour créer une demande de support concernant votre paiement, veuillez contacter @winspinpaysupport.\n\nVeuillez noter: Les remboursements ne sont accordés que si les billets achetés n'ont pas encore été utilisés dans les rounds de jeu. +bot.message.unrecognized=Nous n'avons pas pu reconnaître cela. Veuillez utiliser l'un des boutons ci-dessous. + diff --git a/src/main/resources/messages_id.properties b/src/main/resources/messages_id.properties new file mode 100644 index 0000000..3b335bf --- /dev/null +++ b/src/main/resources/messages_id.properties @@ -0,0 +1,144 @@ +# Indonesian messages +common.error.unknown=Terjadi kesalahan yang tidak terduga +common.error.validation=Kesalahan validasi +common.success=Berhasil + +feature.depositsUnavailable=Setoran untuk sementara tidak tersedia. Kami minta maaf atas ketidaknyamanannya. Silakan coba lagi nanti. +feature.payoutsUnavailable=Penarikan untuk sementara tidak tersedia. Kami minta maaf atas ketidaknyamanannya. Silakan coba lagi nanti. + +auth.error.invalid=Autentikasi tidak valid +auth.error.expired=Sesi telah berakhir +auth.error.required=Autentikasi diperlukan +auth.error.accessRestricted=Akses aplikasi dibatasi. + +user.error.notFound=Pengguna tidak ditemukan +user.error.banned=Pengguna dilarang +user.language.updated=Bahasa berhasil diperbarui + +game.error.roomNotFound=Ruangan tidak ditemukan. Silakan coba lagi. +game.error.invalidBetAmount=Jumlah taruhan harus antara {0} dan {1} +game.error.insufficientBalance=Saldo tidak mencukupi +game.error.rateLimit=Terlalu banyak permintaan. Silakan tunggu sebentar sebelum mencoba lagi. +game.error.roundNotActive=Putaran tidak aktif +game.error.maxBetExceeded=Anda telah melebihi batas taruhan maksimum {0} untuk ruangan ini. Total taruhan Anda saat ini adalah {1}, jadi Anda dapat bertaruh hingga {2} lagi. +game.error.betMustBePositive=Jumlah taruhan harus berupa bilangan bulat positif. +game.error.roomNumberInvalid=Nomor ruangan harus antara 1 dan 3. + +task.error.notFound=Tugas tidak ditemukan +task.error.notCompleted=Tugas tidak selesai! +task.error.alreadyClaimed=Tugas sudah diklaim +task.success.claimed=Tugas berhasil diklaim +task.claimed=Diklaim + +transaction.error.notFound=Transaksi tidak ditemukan + +payout.error.withdrawalsRestrictedForAccount=Maaf, penarikan telah dibatasi untuk akun Anda. Silakan hubungi Dukungan. +payout.error.depositRequiredToWithdraw=Karena meningkatnya penipuan, setidaknya satu setoran diperlukan untuk membuat permintaan penarikan. +payout.error.withdrawExceedsWinAfterDeposit=Karena meningkatnya penipuan, permintaan penarikan tidak boleh melebihi total kemenangan Anda sejak setoran terakhir. Saat ini Anda dapat menarik maksimal {0} tiket. +payout.error.invalidAmount=Jumlah penarikan tidak valid +payout.error.minimumNotMet=Jumlah penarikan minimum tidak tercapai +payout.error.insufficientBalance=Saldo tidak mencukupi untuk penarikan +payout.error.withdrawalInProgress=Anda sudah memiliki penarikan yang sedang diproses. Harap tunggu sampai selesai. +payout.success.requested=Permintaan penarikan berhasil dikirim + +withdraw.error.walletInvalidFormat=Format alamat dompet tidak benar. Silakan periksa dan coba lagi. +withdraw.error.tryLater=Ada masalah dengan penarikan saat ini. Silakan coba lagi nanti. + +payment.error.invalidAmount=Jumlah pembayaran tidak valid +payment.error.minStars=Jumlah harus setidaknya {0} +payment.error.maxStars=Jumlah tidak boleh melebihi {0} +payment.error.usdMaxTwoDecimals=Jumlah USD harus memiliki paling banyak 2 desimal (mis. 2,45). +payment.error.legacyNotSupported=Metode pembayaran ini tidak lagi didukung. Gunakan metode yang tersedia untuk membeli tiket. +payment.error.failed=Pembayaran gagal. Silakan coba lagi. +payment.error.statusUnknown=Status pembayaran tidak diketahui. Silakan periksa saldo Anda. +payment.success.purchased=Berhasil membeli {0} tiket! + +support.error.ticketNotFound=Tiket tidak ditemukan +support.error.messageFailed=Gagal mengirim pesan +support.error.closeFailed=Gagal menutup tiket +support.error.rateLimitWait=Harap tunggu {0} detik sebelum mengirim pesan lain. + +validation.error.required={0} diperlukan +validation.error.positive={0} harus positif +validation.error.range={0} harus antara {1} dan {2} + +# Game Room (additional messages) +game.error.roundNotFound=Putaran permainan tidak ditemukan. Silakan coba lagi. +game.error.participantNotFound=Peserta tidak ditemukan. Silakan coba lagi. +game.error.rateLimitWait=Silakan tunggu sebelum menempatkan taruhan lain. Batas kecepatan: 1 taruhan per detik. +game.error.roomNotJoinable=Ruangan tidak dapat diakses saat ini +game.error.invalidRequest=Permintaan tidak valid. Badan permintaan diperlukan. + +# User Service +user.error.referralLevelInvalid=Level referral tidak valid: {0}. Harus 1, 2, atau 3. +user.error.depositAmountInvalid=Jumlah deposit harus positif +user.error.balanceNotFound=Saldo pengguna tidak ditemukan + +# Task Controller +task.message.claimed=Tugas berhasil diklaim +task.message.notCompleted=Tugas tidak selesai! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Undang 1 teman +task.title.inviteFriends.3=Undang 3 teman +task.title.inviteFriends.7=Undang 7 teman +task.title.inviteFriends.15=Undang 15 teman +task.title.inviteFriends.30=Undang 30 teman +task.title.inviteFriends.50=Undang 50 teman +task.title.inviteFriends.100=Undang 100 teman +task.title.followChannel=Ikuti saluran berita kami +task.title.followChannelWithdrawals=Ikuti saluran bukti pembayaran kami +task.title.dailyBonus=Bonus harian +task.title.deposit=Setor {0} tiket +task.description.inviteFriends.1=Undang 1 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.3=Undang 3 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.7=Undang 7 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.15=Undang 15 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.30=Undang 30 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.50=Undang 50 teman menggunakan tautan referral unik Anda +task.description.inviteFriends.100=Undang 100 teman menggunakan tautan referral unik Anda +task.description.followChannel=Ikuti saluran berita kami +task.description.followChannelWithdrawals=Ikuti saluran bukti pembayaran kami +task.description.dailyBonus=Klaim tiket gratis harian Anda! +task.description.deposit=Setor {0} tiket ke akun Anda + +# Task Reward Text +task.reward.tickets.1=+5 Tiket +task.reward.tickets.3=+15 Tiket +task.reward.tickets.7=+35 Tiket +task.reward.tickets.15=+75 Tiket +task.reward.tickets.30=+110 Tiket +task.reward.tickets.50=+150 Tiket +task.reward.tickets.100=+375 Tiket +task.reward.tickets.follow=+7 Tiket +task.reward.tickets.daily=+1 Tiket +task.reward.tickets.other.50=+5 Tiket +task.reward.tickets.other.250=+25 Tiket +task.reward.tickets.other.1000=+100 Tiket +task.reward.tickets.other.2000=+100 Tiket +task.reward.tickets.other.5000=+250 Tiket +task.reward.tickets.other.10000=+500 Tiket +task.reward.tickets.other.500=+100 Tiket +task.reward.tickets.other.50000=+2500 Tiket +task.reward.tickets.other.150000=+7500 Tiket +task.reward.tickets.other.500000=+25000 Tiket + +# Date/Time formatting +dateTime.at=pada + +# Telegram Bot Messages +bot.button.startSpinning=Mulai Bermain +bot.button.startSpinningInline=↘️ MULAI BERMAIN ↙️ +bot.button.usersPayouts=Pembayaran pengguna +bot.button.infoChannel=Saluran informasi +bot.button.openChannel=Buka saluran +bot.button.goToChannel=Pergi ke saluran +bot.welcome.firstMessage=Coba keberuntungan Anda dengan Win Spin! +bot.welcome.message=Mulai bertaruh, menang, dan tarik langsung ke dompet Anda.\n\n👉 Untuk memulai, tonton video di atas. +bot.message.startSpinning=Gunakan tombol ini untuk membuka aplikasi Win Spin: +bot.message.usersPayouts=🔒 Pembaruan waktu nyata.\n\nSemua penarikan ditampilkan di saluran. Anda dapat memeriksa semuanya sendiri: +bot.message.infoChannel=📢 Tetap terinformasi dengan semua pembaruan proyek terbaru — di sini di saluran kami +bot.message.paySupport=Halo! Untuk membuat permintaan dukungan terkait pembayaran Anda, silakan hubungi @winspinpaysupport.\n\nHarap dicatat: Pengembalian dana hanya diberikan jika tiket yang dibeli belum digunakan dalam putaran permainan. +bot.message.unrecognized=Kami tidak dapat mengenali itu. Silakan gunakan salah satu tombol di bawah. + diff --git a/src/main/resources/messages_it.properties b/src/main/resources/messages_it.properties new file mode 100644 index 0000000..700fde5 --- /dev/null +++ b/src/main/resources/messages_it.properties @@ -0,0 +1,144 @@ +# Italian messages +common.error.unknown=Si è verificato un errore imprevisto +common.error.validation=Errore di validazione +common.success=Successo + +feature.depositsUnavailable=I depositi sono temporaneamente non disponibili. Ci scusiamo per il disagio. Si prega di riprovare più tardi. +feature.payoutsUnavailable=I prelievi sono temporaneamente non disponibili. Ci scusiamo per il disagio. Si prega di riprovare più tardi. + +auth.error.invalid=Autenticazione non valida +auth.error.expired=Sessione scaduta +auth.error.required=Autenticazione richiesta +auth.error.accessRestricted=Accesso all'applicazione limitato. + +user.error.notFound=Utente non trovato +user.error.banned=L'utente è stato bannato +user.language.updated=Lingua aggiornata con successo + +game.error.roomNotFound=Stanza non trovata. Si prega di riprovare. +game.error.invalidBetAmount=L'importo della scommessa deve essere compreso tra {0} e {1} +game.error.insufficientBalance=Saldo insufficiente +game.error.rateLimit=Troppe richieste. Si prega di attendere un momento prima di riprovare. +game.error.roundNotActive=Il round non è attivo +game.error.maxBetExceeded=Hai superato il limite massimo di scommessa di {0} per questa stanza. La tua scommessa totale attuale è {1}, quindi puoi scommettere fino a {2} in più. +game.error.betMustBePositive=L'importo della scommessa deve essere un numero intero positivo. +game.error.roomNumberInvalid=Il numero della stanza deve essere compreso tra 1 e 3. + +task.error.notFound=Attività non trovata +task.error.notCompleted=Attività non completata! +task.error.alreadyClaimed=Attività già ottenuta +task.success.claimed=Attività ottenuta con successo +task.claimed=Ottenuto + +transaction.error.notFound=Transazione non trovata + +payout.error.withdrawalsRestrictedForAccount=Ci scusiamo, ma i prelievi sono stati limitati per il tuo account. Contatta l'Assistenza. +payout.error.depositRequiredToWithdraw=A causa dell'aumento delle frodi, è necessario effettuare almeno un deposito per creare una richiesta di prelievo. +payout.error.withdrawExceedsWinAfterDeposit=A causa dell'aumento delle frodi, le richieste di prelievo non possono superare le vincite totali dall'ultimo deposito. Attualmente puoi prelevare un massimo di {0} biglietti. +payout.error.invalidAmount=Importo di prelievo non valido +payout.error.minimumNotMet=Importo minimo di prelievo non raggiunto +payout.error.insufficientBalance=Saldo insufficiente per il prelievo +payout.error.withdrawalInProgress=Hai già un prelievo in corso. Attendi che venga completato. +payout.success.requested=Richiesta di prelievo inviata con successo + +withdraw.error.walletInvalidFormat=Il formato dell'indirizzo del portafoglio non è corretto. Controlla e riprova. +withdraw.error.tryLater=C'è un problema con i prelievi al momento. Riprova tra poco. + +payment.error.invalidAmount=Importo di pagamento non valido +payment.error.minStars=L'importo deve essere almeno {0} +payment.error.maxStars=L'importo non può superare {0} +payment.error.usdMaxTwoDecimals=L'importo in USD deve avere al massimo 2 decimali (es. 2,45). +payment.error.legacyNotSupported=Questo metodo di pagamento non è più supportato. Usa i metodi disponibili per acquistare biglietti. +payment.error.failed=Pagamento fallito. Si prega di riprovare. +payment.error.statusUnknown=Stato del pagamento sconosciuto. Si prega di controllare il saldo. +payment.success.purchased=Acquistati con successo {0} biglietti! + +support.error.ticketNotFound=Biglietto non trovato +support.error.messageFailed=Impossibile inviare il messaggio +support.error.closeFailed=Impossibile chiudere il biglietto +support.error.rateLimitWait=Attendere {0} secondo/i prima di inviare un altro messaggio. + +validation.error.required={0} è richiesto +validation.error.positive={0} deve essere positivo +validation.error.range={0} deve essere compreso tra {1} e {2} + +# Game Room (additional messages) +game.error.roundNotFound=Round di gioco non trovato. Si prega di riprovare. +game.error.participantNotFound=Partecipante non trovato. Si prega di riprovare. +game.error.rateLimitWait=Si prega di attendere prima di piazzare un'altra scommessa. Limite di frequenza: 1 scommessa al secondo. +game.error.roomNotJoinable=La stanza non è accessibile in questo momento +game.error.invalidRequest=Richiesta non valida. Il corpo della richiesta è richiesto. + +# User Service +user.error.referralLevelInvalid=Livello di referral non valido: {0}. Deve essere 1, 2 o 3. +user.error.depositAmountInvalid=L'importo del deposito deve essere positivo +user.error.balanceNotFound=Saldo utente non trovato + +# Task Controller +task.message.claimed=Attività ottenuta con successo +task.message.notCompleted=Attività non completata! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Invita 1 amico +task.title.inviteFriends.3=Invita 3 amici +task.title.inviteFriends.7=Invita 7 amici +task.title.inviteFriends.15=Invita 15 amici +task.title.inviteFriends.30=Invita 30 amici +task.title.inviteFriends.50=Invita 50 amici +task.title.inviteFriends.100=Invita 100 amici +task.title.followChannel=Seguici sul nostro canale notizie +task.title.followChannelWithdrawals=Seguici sul canale delle prove di pagamento +task.title.dailyBonus=Bonus giornaliero +task.title.deposit=Deposita {0} biglietti +task.description.inviteFriends.1=Invita 1 amico usando il tuo link di referral unico +task.description.inviteFriends.3=Invita 3 amici usando il tuo link di referral unico +task.description.inviteFriends.7=Invita 7 amici usando il tuo link di referral unico +task.description.inviteFriends.15=Invita 15 amici usando il tuo link di referral unico +task.description.inviteFriends.30=Invita 30 amici usando il tuo link di referral unico +task.description.inviteFriends.50=Invita 50 amici usando il tuo link di referral unico +task.description.inviteFriends.100=Invita 100 amici usando il tuo link di referral unico +task.description.followChannel=Seguici sul nostro canale notizie +task.description.followChannelWithdrawals=Seguici sul canale delle prove di pagamento +task.description.dailyBonus=Reclama il tuo biglietto gratuito giornaliero! +task.description.deposit=Deposita {0} biglietti sul tuo account + +# Task Reward Text +task.reward.tickets.1=+5 Biglietti +task.reward.tickets.3=+15 Biglietti +task.reward.tickets.7=+35 Biglietti +task.reward.tickets.15=+75 Biglietti +task.reward.tickets.30=+110 Biglietti +task.reward.tickets.50=+150 Biglietti +task.reward.tickets.100=+375 Biglietti +task.reward.tickets.follow=+7 Biglietti +task.reward.tickets.daily=+1 Biglietto +task.reward.tickets.other.50=+5 Biglietti +task.reward.tickets.other.250=+25 Biglietti +task.reward.tickets.other.1000=+100 Biglietti +task.reward.tickets.other.2000=+100 Biglietti +task.reward.tickets.other.5000=+250 Biglietti +task.reward.tickets.other.10000=+500 Biglietti +task.reward.tickets.other.500=+100 Biglietti +task.reward.tickets.other.50000=+2500 Biglietti +task.reward.tickets.other.150000=+7500 Biglietti +task.reward.tickets.other.500000=+25000 Biglietti + +# Date/Time formatting +dateTime.at=alle + +# Telegram Bot Messages +bot.button.startSpinning=Inizia a giocare +bot.button.startSpinningInline=↘️ INIZIA A GIOCARE ↙️ +bot.button.usersPayouts=Pagamenti degli utenti +bot.button.infoChannel=Canale informazioni +bot.button.openChannel=Apri canale +bot.button.goToChannel=Vai al canale +bot.welcome.firstMessage=Prova la tua fortuna con Win Spin! +bot.welcome.message=Inizia a scommettere, vinci e preleva direttamente sul tuo portafoglio.\n\n👉 Per iniziare, guarda il video sopra. +bot.message.startSpinning=Usa questo pulsante per aprire l'app Win Spin: +bot.message.usersPayouts=🔒 Aggiornamenti in tempo reale.\n\nTutti i prelievi sono mostrati nel canale. Puoi controllare tutto da solo: +bot.message.infoChannel=📢 Rimani informato su tutti gli ultimi aggiornamenti del progetto — proprio qui nel nostro canale +bot.message.paySupport=Ciao! Per creare una richiesta di supporto riguardo al tuo pagamento, contatta @winspinpaysupport.\n\nNota: I rimborsi vengono concessi solo se i biglietti acquistati non sono ancora stati utilizzati nei round di gioco. +bot.message.unrecognized=Non abbiamo riconosciuto il messaggio. Usa uno dei pulsanti qui sotto. + diff --git a/src/main/resources/messages_nl.properties b/src/main/resources/messages_nl.properties new file mode 100644 index 0000000..8bea47c --- /dev/null +++ b/src/main/resources/messages_nl.properties @@ -0,0 +1,144 @@ +# Dutch messages +common.error.unknown=Er is een onverwachte fout opgetreden +common.error.validation=Validatiefout +common.success=Succes + +feature.depositsUnavailable=Stortingen zijn tijdelijk niet beschikbaar. Onze excuses voor het ongemak. Probeer het later opnieuw. +feature.payoutsUnavailable=Opnames zijn tijdelijk niet beschikbaar. Onze excuses voor het ongemak. Probeer het later opnieuw. + +auth.error.invalid=Ongeldige authenticatie +auth.error.expired=Sessie verlopen +auth.error.required=Authenticatie vereist +auth.error.accessRestricted=Toegang tot de applicatie beperkt. + +user.error.notFound=Gebruiker niet gevonden +user.error.banned=Gebruiker is geblokkeerd +user.language.updated=Taal succesvol bijgewerkt + +game.error.roomNotFound=Ruimte niet gevonden. Probeer het opnieuw. +game.error.invalidBetAmount=Inzetbedrag moet tussen {0} en {1} liggen +game.error.insufficientBalance=Onvoldoende saldo +game.error.rateLimit=Te veel verzoeken. Wacht even voordat u het opnieuw probeert. +game.error.roundNotActive=Ronde is niet actief +game.error.maxBetExceeded=U heeft de maximale inzetlimiet van {0} voor deze ruimte overschreden. Uw huidige totale inzet is {1}, dus u kunt nog tot {2} meer inzetten. +game.error.betMustBePositive=Inzetbedrag moet een positief geheel getal zijn. +game.error.roomNumberInvalid=Kamernummer moet tussen 1 en 3 liggen. + +task.error.notFound=Taak niet gevonden +task.error.notCompleted=Taak niet voltooid! +task.error.alreadyClaimed=Taak al ontvangen +task.success.claimed=Taak succesvol ontvangen +task.claimed=Ontvangen + +transaction.error.notFound=Transactie niet gevonden + +payout.error.withdrawalsRestrictedForAccount=Het spijt ons, maar opnames zijn beperkt voor uw account. Neem contact op met de klantenservice. +payout.error.depositRequiredToWithdraw=Vanwege toegenomen fraude is minstens één storting vereist om een opnameverzoek aan te maken. +payout.error.withdrawExceedsWinAfterDeposit=Vanwege toegenomen fraude mogen opnameverzoeken uw totale winsten sinds uw laatste storting niet overschrijden. U kunt momenteel maximaal {0} tickets opnemen. +payout.error.invalidAmount=Ongeldig uitbetalingsbedrag +payout.error.minimumNotMet=Minimum uitbetalingsbedrag niet bereikt +payout.error.insufficientBalance=Onvoldoende saldo voor uitbetaling +payout.error.withdrawalInProgress=U heeft al een opname in behandeling. Wacht tot deze is voltooid. +payout.success.requested=Uitbetalingsverzoek succesvol verzonden + +withdraw.error.walletInvalidFormat=Het formaat van het walletadres is onjuist. Controleer en probeer het opnieuw. +withdraw.error.tryLater=Er is momenteel een probleem met opnames. Probeer het later opnieuw. + +payment.error.invalidAmount=Ongeldig betalingsbedrag +payment.error.minStars=Het bedrag moet minimaal {0} zijn +payment.error.maxStars=Het bedrag mag {0} niet overschrijden +payment.error.usdMaxTwoDecimals=Het USD-bedrag mag maximaal 2 decimalen hebben (bijv. 2,45). +payment.error.legacyNotSupported=Deze betaalmethode wordt niet meer ondersteund. Gebruik de beschikbare methoden om tickets te kopen. +payment.error.failed=Betaling mislukt. Probeer het opnieuw. +payment.error.statusUnknown=Betalingsstatus onbekend. Controleer uw saldo. +payment.success.purchased=Succesvol {0} tickets gekocht! + +support.error.ticketNotFound=Ticket niet gevonden +support.error.messageFailed=Bericht kon niet worden verzonden +support.error.closeFailed=Ticket kon niet worden gesloten +support.error.rateLimitWait=Wacht {0} seconde(n) voordat u een ander bericht verzendt. + +validation.error.required={0} is vereist +validation.error.positive={0} moet positief zijn +validation.error.range={0} moet tussen {1} en {2} liggen + +# Game Room (additional messages) +game.error.roundNotFound=Spelronde niet gevonden. Probeer het opnieuw. +game.error.participantNotFound=Deelnemer niet gevonden. Probeer het opnieuw. +game.error.rateLimitWait=Wacht even voordat u een nieuwe inzet plaatst. Snelheidslimiet: 1 inzet per seconde. +game.error.roomNotJoinable=Ruimte is op dit moment niet toegankelijk +game.error.invalidRequest=Ongeldig verzoek. Verzoekbody is vereist. + +# User Service +user.error.referralLevelInvalid=Ongeldig verwijzingsniveau: {0}. Moet 1, 2 of 3 zijn. +user.error.depositAmountInvalid=Stortingsbedrag moet positief zijn +user.error.balanceNotFound=Gebruikerssaldo niet gevonden + +# Task Controller +task.message.claimed=Taak succesvol ontvangen +task.message.notCompleted=Taak niet voltooid! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Nodig 1 vriend uit +task.title.inviteFriends.3=Nodig 3 vrienden uit +task.title.inviteFriends.7=Nodig 7 vrienden uit +task.title.inviteFriends.15=Nodig 15 vrienden uit +task.title.inviteFriends.30=Nodig 30 vrienden uit +task.title.inviteFriends.50=Nodig 50 vrienden uit +task.title.inviteFriends.100=Nodig 100 vrienden uit +task.title.followChannel=Volg ons nieuwskanaal +task.title.followChannelWithdrawals=Volg ons kanaal voor betalingsbewijzen +task.title.dailyBonus=Dagelijkse bonus +task.title.deposit=Stort {0} tickets +task.description.inviteFriends.1=Nodig 1 vriend uit met je unieke verwijzingslink +task.description.inviteFriends.3=Nodig 3 vrienden uit met je unieke verwijzingslink +task.description.inviteFriends.7=Nodig 7 vrienden uit met je unieke verwijzingslink +task.description.inviteFriends.15=Nodig 15 vrienden uit met je unieke verwijzingslink +task.description.inviteFriends.30=Nodig 30 vrienden uit met je unieke verwijzingslink +task.description.inviteFriends.50=Nodig 50 vrienden uit met je unieke verwijzingslink +task.description.inviteFriends.100=Nodig 100 vrienden uit met je unieke verwijzingslink +task.description.followChannel=Volg ons nieuwskanaal +task.description.followChannelWithdrawals=Volg ons kanaal voor betalingsbewijzen +task.description.dailyBonus=Claim je dagelijkse gratis ticket! +task.description.deposit=Stort {0} tickets op je account + +# Task Reward Text +task.reward.tickets.1=+5 Tickets +task.reward.tickets.3=+15 Tickets +task.reward.tickets.7=+35 Tickets +task.reward.tickets.15=+75 Tickets +task.reward.tickets.30=+110 Tickets +task.reward.tickets.50=+150 Tickets +task.reward.tickets.100=+375 Tickets +task.reward.tickets.follow=+7 Tickets +task.reward.tickets.daily=+1 Ticket +task.reward.tickets.other.50=+5 Tickets +task.reward.tickets.other.250=+25 Tickets +task.reward.tickets.other.1000=+100 Tickets +task.reward.tickets.other.2000=+100 Tickets +task.reward.tickets.other.5000=+250 Tickets +task.reward.tickets.other.10000=+500 Tickets +task.reward.tickets.other.500=+100 Tickets +task.reward.tickets.other.50000=+2500 Tickets +task.reward.tickets.other.150000=+7500 Tickets +task.reward.tickets.other.500000=+25000 Tickets + +# Date/Time formatting +dateTime.at=om + +# Telegram Bot Messages +bot.button.startSpinning=Begin met spelen +bot.button.startSpinningInline=↘️ BEGIN MET SPELEN ↙️ +bot.button.usersPayouts=Gebruikersuitbetalingen +bot.button.infoChannel=Informatiekanaal +bot.button.openChannel=Kanaal openen +bot.button.goToChannel=Ga naar het kanaal +bot.welcome.firstMessage=Probeer uw geluk met Win Spin! +bot.welcome.message=Begin met wedden, win en trek direct op naar je portemonnee.\n\n👉 Om te beginnen, bekijk de video hierboven. +bot.message.startSpinning=Gebruik deze knop om de Win Spin-app te openen: +bot.message.usersPayouts=🔒 Updates in realtime.\n\nAlle opnames worden getoond in het kanaal. U kunt alles zelf controleren: +bot.message.infoChannel=📢 Blijf op de hoogte van alle laatste projectupdates — hier in ons kanaal +bot.message.paySupport=Hallo! Om een ondersteuningsverzoek met betrekking tot uw betaling aan te maken, neem contact op met @winspinpaysupport.\n\nLet op: Restituties worden alleen verleend als de gekochte tickets nog niet zijn gebruikt in spelsessies. +bot.message.unrecognized=We konden dat niet herkennen. Gebruik een van de knoppen hieronder. + diff --git a/src/main/resources/messages_pl.properties b/src/main/resources/messages_pl.properties new file mode 100644 index 0000000..7f2ac51 --- /dev/null +++ b/src/main/resources/messages_pl.properties @@ -0,0 +1,144 @@ +# Polish messages +common.error.unknown=Wystąpił nieoczekiwany błąd +common.error.validation=Błąd walidacji +common.success=Sukces + +feature.depositsUnavailable=Wpłaty są tymczasowo niedostępne. Przepraszamy za niedogodności. Proszę spróbować później. +feature.payoutsUnavailable=Wypłaty są tymczasowo niedostępne. Przepraszamy za niedogodności. Proszę spróbować później. + +auth.error.invalid=Nieprawidłowe uwierzytelnienie +auth.error.expired=Sesja wygasła +auth.error.required=Wymagane uwierzytelnienie +auth.error.accessRestricted=Dostęp do aplikacji ograniczony. + +user.error.notFound=Użytkownik nie znaleziony +user.error.banned=Użytkownik jest zablokowany +user.language.updated=Język został zaktualizowany pomyślnie + +game.error.roomNotFound=Pokój nie znaleziony. Spróbuj ponownie. +game.error.invalidBetAmount=Kwota zakładu musi być między {0} a {1} +game.error.insufficientBalance=Niewystarczające saldo +game.error.rateLimit=Zbyt wiele żądań. Poczekaj chwilę przed ponowną próbą. +game.error.roundNotActive=Runda nie jest aktywna +game.error.maxBetExceeded=Przekroczyłeś maksymalny limit zakładu {0} dla tego pokoju. Twój obecny całkowity zakład to {1}, więc możesz postawić jeszcze do {2}. +game.error.betMustBePositive=Kwota zakładu musi być dodatnią liczbą całkowitą. +game.error.roomNumberInvalid=Numer pokoju musi być między 1 a 3. + +task.error.notFound=Zadanie nie znalezione +task.error.notCompleted=Zadanie nie ukończone! +task.error.alreadyClaimed=Zadanie już odebrane +task.success.claimed=Zadanie pomyślnie odebrane +task.claimed=Odebrane + +transaction.error.notFound=Transakcja nie znaleziona + +payout.error.withdrawalsRestrictedForAccount=Przepraszamy, ale wypłaty zostały ograniczone dla Twojego konta. Skontaktuj się z pomocą techniczną. +payout.error.depositRequiredToWithdraw=Ze względu na wzrost oszustw wymagana jest co najmniej jedna wpłata, aby utworzyć wniosek o wypłatę. +payout.error.withdrawExceedsWinAfterDeposit=Ze względu na wzrost oszustw wnioski o wypłatę nie mogą przekraczać łącznych wygranych od ostatniej wpłaty. Obecnie możesz wypłacić maksymalnie {0} biletów. +payout.error.invalidAmount=Nieprawidłowa kwota wypłaty +payout.error.minimumNotMet=Minimalna kwota wypłaty nie osiągnięta +payout.error.insufficientBalance=Niewystarczające saldo do wypłaty +payout.error.withdrawalInProgress=Masz już wniosek o wypłatę w toku. Poczekaj na jego zakończenie. +payout.success.requested=Wniosek o wypłatę został pomyślnie wysłany + +withdraw.error.walletInvalidFormat=Format adresu portfela jest nieprawidłowy. Sprawdź i spróbuj ponownie. +withdraw.error.tryLater=Wystąpił problem z wypłatami. Spróbuj ponownie za chwilę. + +payment.error.invalidAmount=Nieprawidłowa kwota płatności +payment.error.minStars=Kwota musi wynosić co najmniej {0} +payment.error.maxStars=Kwota nie może przekraczać {0} +payment.error.usdMaxTwoDecimals=Kwota w USD może mieć co najwyżej 2 miejsca po przecinku (np. 2,45). +payment.error.legacyNotSupported=Ta metoda płatności nie jest już obsługiwana. Użyj dostępnych metod do zakupu biletów. +payment.error.failed=Płatność nie powiodła się. Spróbuj ponownie. +payment.error.statusUnknown=Status płatności nieznany. Sprawdź saldo. +payment.success.purchased=Pomyślnie zakupiono {0} biletów! + +support.error.ticketNotFound=Bilet nie znaleziony +support.error.messageFailed=Nie udało się wysłać wiadomości +support.error.closeFailed=Nie udało się zamknąć biletu +support.error.rateLimitWait=Poczekaj {0} sekund(y), zanim wyślesz kolejną wiadomość. + +validation.error.required={0} jest wymagane +validation.error.positive={0} musi być dodatnie +validation.error.range={0} musi być między {1} a {2} + +# Game Room (additional messages) +game.error.roundNotFound=Runda gry nie znaleziona. Spróbuj ponownie. +game.error.participantNotFound=Uczestnik nie znaleziony. Spróbuj ponownie. +game.error.rateLimitWait=Poczekaj przed postawieniem kolejnego zakładu. Limit: 1 zakład na sekundę. +game.error.roomNotJoinable=Pokój nie jest dostępny w tym momencie +game.error.invalidRequest=Nieprawidłowe żądanie. Treść żądania jest wymagana. + +# User Service +user.error.referralLevelInvalid=Nieprawidłowy poziom polecającego: {0}. Musi być 1, 2 lub 3. +user.error.depositAmountInvalid=Kwota wpłaty musi być dodatnia +user.error.balanceNotFound=Saldo użytkownika nie znalezione + +# Task Controller +task.message.claimed=Zadanie pomyślnie odebrane +task.message.notCompleted=Zadanie nie ukończone! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Zaproś 1 znajomego +task.title.inviteFriends.3=Zaproś 3 znajomych +task.title.inviteFriends.7=Zaproś 7 znajomych +task.title.inviteFriends.15=Zaproś 15 znajomych +task.title.inviteFriends.30=Zaproś 30 znajomych +task.title.inviteFriends.50=Zaproś 50 znajomych +task.title.inviteFriends.100=Zaproś 100 znajomych +task.title.followChannel=Śledź nasz kanał informacyjny +task.title.followChannelWithdrawals=Śledź nasz kanał potwierdzeń wypłat +task.title.dailyBonus=Bonus dzienny +task.title.deposit=Wpłać {0} biletów +task.description.inviteFriends.1=Zaproś 1 znajomego używając swojego unikalnego linku polecającego +task.description.inviteFriends.3=Zaproś 3 znajomych używając swojego unikalnego linku polecającego +task.description.inviteFriends.7=Zaproś 7 znajomych używając swojego unikalnego linku polecającego +task.description.inviteFriends.15=Zaproś 15 znajomych używając swojego unikalnego linku polecającego +task.description.inviteFriends.30=Zaproś 30 znajomych używając swojego unikalnego linku polecającego +task.description.inviteFriends.50=Zaproś 50 znajomych używając swojego unikalnego linku polecającego +task.description.inviteFriends.100=Zaproś 100 znajomych używając swojego unikalnego linku polecającego +task.description.followChannel=Śledź nasz kanał informacyjny +task.description.followChannelWithdrawals=Śledź nasz kanał potwierdzeń wypłat +task.description.dailyBonus=Odbierz swój dzienny darmowy bilet! +task.description.deposit=Wpłać {0} biletów na swoje konto + +# Task Reward Text +task.reward.tickets.1=+5 Biletów +task.reward.tickets.3=+15 Biletów +task.reward.tickets.7=+35 Biletów +task.reward.tickets.15=+75 Biletów +task.reward.tickets.30=+110 Biletów +task.reward.tickets.50=+150 Biletów +task.reward.tickets.100=+375 Biletów +task.reward.tickets.follow=+7 Biletów +task.reward.tickets.daily=+1 Bilet +task.reward.tickets.other.50=+5 Biletów +task.reward.tickets.other.250=+25 Biletów +task.reward.tickets.other.1000=+100 Biletów +task.reward.tickets.other.2000=+100 Biletów +task.reward.tickets.other.5000=+250 Biletów +task.reward.tickets.other.10000=+500 Biletów +task.reward.tickets.other.500=+100 Biletów +task.reward.tickets.other.50000=+2500 Biletów +task.reward.tickets.other.150000=+7500 Biletów +task.reward.tickets.other.500000=+25000 Biletów + +# Date/Time formatting +dateTime.at=o + +# Telegram Bot Messages +bot.button.startSpinning=Zacznij grę +bot.button.startSpinningInline=↘️ ZACZNIJ GRĘ ↙️ +bot.button.usersPayouts=Wypłaty użytkowników +bot.button.infoChannel=Kanał informacyjny +bot.button.openChannel=Otwórz kanał +bot.button.goToChannel=Przejdź do kanału +bot.welcome.firstMessage=Spróbuj szczęścia z Win Spin! +bot.welcome.message=Zacznij obstawiać, wygrywaj i wypłacaj prosto na swój portfel.\n\n👉 Aby rozpocząć, obejrzyj wideo powyżej. +bot.message.startSpinning=Użyj tego przycisku, aby otworzyć aplikację Win Spin: +bot.message.usersPayouts=🔒 Aktualizacje w czasie rzeczywistym.\n\nWszystkie wypłaty są pokazane w kanale. Możesz sprawdzić wszystko samodzielnie: +bot.message.infoChannel=📢 Bądź na bieżąco ze wszystkimi najnowszymi aktualizacjami projektu — właśnie tutaj w naszym kanale +bot.message.paySupport=Witaj! Aby utworzyć zgłoszenie dotyczące płatności, skontaktuj się z @winspinpaysupport.\n\nUwaga: Zwroty są przyznawane tylko wtedy, gdy zakupione bilety nie zostały jeszcze wykorzystane w rundach gry. +bot.message.unrecognized=Nie rozpoznaliśmy tego. Użyj jednego z przycisków poniżej. + diff --git a/src/main/resources/messages_ru.properties b/src/main/resources/messages_ru.properties new file mode 100644 index 0000000..19f19de --- /dev/null +++ b/src/main/resources/messages_ru.properties @@ -0,0 +1,145 @@ +# Russian messages +common.error.unknown=Произошла непредвиденная ошибка +common.error.validation=Ошибка валидации +common.success=Успешно + +feature.depositsUnavailable=Пополнения временно недоступны. Приносим извинения за неудобства. Пожалуйста, попробуйте позже. +feature.payoutsUnavailable=Выводы временно недоступны. Приносим извинения за неудобства. Пожалуйста, попробуйте позже. + +auth.error.invalid=Неверная аутентификация +auth.error.expired=Сессия истекла +auth.error.required=Требуется аутентификация +auth.error.accessRestricted=Доступ к приложению ограничен. + +user.error.notFound=Пользователь не найден +user.error.banned=Пользователь заблокирован +user.language.updated=Язык успешно обновлен + +game.error.roomNotFound=Комната не найдена. Пожалуйста, попробуйте снова. +game.error.invalidBetAmount=Сумма ставки должна быть между {0} и {1} +game.error.insufficientBalance=Недостаточно средств +game.error.rateLimit=Слишком много запросов. Пожалуйста, подождите немного перед повторной попыткой. +game.error.roundNotActive=Раунд не активен +game.error.maxBetExceeded=Вы превысили максимальный лимит ставки {0} для этой комнаты. Ваша текущая общая ставка составляет {1}, поэтому вы можете поставить еще до {2}. +game.error.betMustBePositive=Сумма ставки должна быть положительным целым числом. +game.error.roomNumberInvalid=Номер комнаты должен быть от 1 до 3. + +task.error.notFound=Задача не найдена +task.error.notCompleted=Задача не выполнена! +task.error.alreadyClaimed=Задача уже получена +task.success.claimed=Задача успешно получена +task.claimed=Получено + +transaction.error.notFound=Транзакция не найдена + +payout.error.withdrawalsRestrictedForAccount=Извините, но вывод средств для вашего аккаунта ограничен. Пожалуйста, обратитесь в поддержку. +payout.error.depositRequiredToWithdraw=Из-за участившихся случаев мошенничества для создания запроса на вывод необходимо сделать хотя бы один депозит. +payout.error.withdrawExceedsWinAfterDeposit=Из-за участившихся случаев мошенничества сумма вывода не может превышать ваши выигрыши после последнего депозита. Сейчас вы можете вывести максимум {0} билетов. +payout.error.invalidAmount=Неверная сумма вывода +payout.error.minimumNotMet=Минимальная сумма вывода не достигнута +payout.error.insufficientBalance=Недостаточно средств для вывода +payout.error.withdrawalInProgress=У вас уже есть запрос на вывод. Дождитесь его завершения. +payout.error.withdrawalAmountMaxTwoDecimals=Сумма вывода должна содержать не более 2 знаков после запятой (например, 125.25). Значения вроде 125.125 не поддерживаются. +payout.success.requested=Запрос на вывод успешно отправлен + +withdraw.error.walletInvalidFormat=Неверный формат адреса кошелька. Проверьте и попробуйте снова. +withdraw.error.tryLater=В данный момент возникла проблема с выводами. Попробуйте позже. + +payment.error.invalidAmount=Неверная сумма платежа +payment.error.minStars=Сумма должна быть не менее {0} +payment.error.maxStars=Сумма не может превышать {0} +payment.error.usdMaxTwoDecimals=Сумма в USD должна содержать не более 2 знаков после запятой (например, 2.45). +payment.error.legacyNotSupported=Этот способ оплаты больше не поддерживается. Используйте доступные способы для покупки билетов. +payment.error.failed=Платеж не выполнен. Пожалуйста, попробуйте снова. +payment.error.statusUnknown=Статус платежа неизвестен. Пожалуйста, проверьте ваш баланс. +payment.success.purchased=Успешно приобретено {0} билетов! + +support.error.ticketNotFound=Тикет не найден +support.error.messageFailed=Не удалось отправить сообщение +support.error.closeFailed=Не удалось закрыть тикет +support.error.rateLimitWait=Пожалуйста, подождите {0} сек. перед отправкой следующего сообщения. + +validation.error.required={0} обязательно +validation.error.positive={0} должно быть положительным +validation.error.range={0} должно быть между {1} и {2} + +# Game Room (additional messages) +game.error.roundNotFound=Игровой раунд не найден. Пожалуйста, попробуйте снова. +game.error.participantNotFound=Участник не найден. Пожалуйста, попробуйте снова. +game.error.rateLimitWait=Пожалуйста, подождите перед следующей ставкой. Лимит: 1 ставка в секунду. +game.error.roomNotJoinable=Комната недоступна для присоединения в данный момент +game.error.invalidRequest=Неверный запрос. Тело запроса обязательно. + +# User Service +user.error.referralLevelInvalid=Неверный уровень реферала: {0}. Должен быть 1, 2 или 3. +user.error.depositAmountInvalid=Сумма депозита должна быть положительной +user.error.balanceNotFound=Баланс пользователя не найден + +# Task Controller +task.message.claimed=Задача успешно получена +task.message.notCompleted=Задача не выполнена! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=Пригласите 1 друга +task.title.inviteFriends.3=Пригласите 3 друзей +task.title.inviteFriends.7=Пригласите 7 друзей +task.title.inviteFriends.15=Пригласите 15 друзей +task.title.inviteFriends.30=Пригласите 30 друзей +task.title.inviteFriends.50=Пригласите 50 друзей +task.title.inviteFriends.100=Пригласите 100 друзей +task.title.followChannel=Подпишитесь на наш новостной канал +task.title.followChannelWithdrawals=Подпишитесь на канал подтверждений выплат +task.title.dailyBonus=Ежедневный бонус +task.title.deposit=Пополните {0} билетов +task.description.inviteFriends.1=Пригласите 1 друга, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.3=Пригласите 3 друзей, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.7=Пригласите 7 друзей, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.15=Пригласите 15 друзей, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.30=Пригласите 30 друзей, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.50=Пригласите 50 друзей, используя вашу уникальную реферальную ссылку +task.description.inviteFriends.100=Пригласите 100 друзей, используя вашу уникальную реферальную ссылку +task.description.followChannel=Подпишитесь на наш новостной канал +task.description.followChannelWithdrawals=Подпишитесь на канал подтверждений выплат +task.description.dailyBonus=Получите свой ежедневный бесплатный билет! +task.description.deposit=Пополните {0} билетов на ваш счет + +# Task Reward Text (localized with proper grammar) +task.reward.tickets.1=+5 Билетов +task.reward.tickets.3=+15 Билетов +task.reward.tickets.7=+35 Билетов +task.reward.tickets.15=+75 Билетов +task.reward.tickets.30=+110 Билетов +task.reward.tickets.50=+150 Билетов +task.reward.tickets.100=+375 Билетов +task.reward.tickets.follow=+7 Билетов +task.reward.tickets.daily=+1 Билет +task.reward.tickets.other.50=+5 Билетов +task.reward.tickets.other.250=+25 Билетов +task.reward.tickets.other.1000=+100 Билетов +task.reward.tickets.other.2000=+100 Билетов +task.reward.tickets.other.5000=+250 Билетов +task.reward.tickets.other.10000=+500 Билетов +task.reward.tickets.other.500=+100 Билетов +task.reward.tickets.other.50000=+2500 Билетов +task.reward.tickets.other.150000=+7500 Билетов +task.reward.tickets.other.500000=+25000 Билетов + +# Date/Time formatting +dateTime.at=в + +# Telegram Bot Messages +bot.button.startSpinning=Начать игру +bot.button.startSpinningInline=↘️ НАЧАТЬ ИГРУ ↙️ +bot.button.usersPayouts=Выплаты пользователей +bot.button.infoChannel=Информационный канал +bot.button.openChannel=Открыть канал +bot.button.goToChannel=Перейти в канал +bot.welcome.firstMessage=Попробуйте удачу с Win Spin! +bot.welcome.message=Начните делать ставки, выигрывайте и выводите средства прямо на свой кошелёк.\n\n👉 Чтобы начать, посмотрите видео выше. +bot.message.startSpinning=Используйте эту кнопку, чтобы открыть приложение Win Spin: +bot.message.usersPayouts=🔒 Обновления в реальном времени.\n\nВсе выплаты отображаются в канале. Вы можете проверить все самостоятельно: +bot.message.infoChannel=📢 Будьте в курсе всех последних обновлений проекта — прямо здесь, в нашем канале +bot.message.paySupport=Привет! Чтобы создать запрос в службу поддержки по поводу вашего платежа, пожалуйста, свяжитесь с @winspinpaysupport.\n\nОбратите внимание: Возврат средств предоставляется только в том случае, если купленные билеты еще не были использованы в игровых раундах. +bot.message.unrecognized=Мы не смогли это распознать. Пожалуйста, воспользуйтесь одной из кнопок ниже. + diff --git a/src/main/resources/messages_tr.properties b/src/main/resources/messages_tr.properties new file mode 100644 index 0000000..70d4c5f --- /dev/null +++ b/src/main/resources/messages_tr.properties @@ -0,0 +1,145 @@ +# Turkish messages +common.error.unknown=Beklenmeyen bir hata oluştu +common.error.validation=Doğrulama hatası +common.success=Başarılı + +feature.depositsUnavailable=Yatırımlar geçici olarak kullanılamıyor. Verdiğimiz rahatsızlıktan dolayı özür dileriz. Lütfen daha sonra tekrar deneyin. +feature.payoutsUnavailable=Çekimler geçici olarak kullanılamıyor. Verdiğimiz rahatsızlıktan dolayı özür dileriz. Lütfen daha sonra tekrar deneyin. + +auth.error.invalid=Geçersiz kimlik doğrulama +auth.error.expired=Oturum süresi doldu +auth.error.required=Kimlik doğrulama gerekli +auth.error.accessRestricted=Uygulama erişimi kısıtlandı. + +user.error.notFound=Kullanıcı bulunamadı +user.error.banned=Kullanıcı yasaklandı +user.language.updated=Dil başarıyla güncellendi + +game.error.roomNotFound=Oda bulunamadı. Lütfen tekrar deneyin. +game.error.invalidBetAmount=Bahis tutarı {0} ile {1} arasında olmalıdır +game.error.insufficientBalance=Yetersiz bakiye +game.error.rateLimit=Çok fazla istek. Lütfen tekrar denemeden önce bir süre bekleyin. +game.error.roundNotActive=Tur aktif değil +game.error.maxBetExceeded=Bu oda için maksimum bahis limiti olan {0}'ı aştınız. Mevcut toplam bahsiniz {1}, bu yüzden {2} daha fazla bahis yapabilirsiniz. +game.error.betMustBePositive=Bahis tutarı pozitif bir tam sayı olmalıdır. +game.error.roomNumberInvalid=Oda numarası 1 ile 3 arasında olmalıdır. + +task.error.notFound=Görev bulunamadı +task.error.notCompleted=Görev tamamlanmadı! +task.error.alreadyClaimed=Görev zaten alındı +task.success.claimed=Görev başarıyla alındı +task.claimed=Alındı + +transaction.error.notFound=İşlem bulunamadı + +payout.error.withdrawalsRestrictedForAccount=Üzgünüz, ancak hesabınız için para çekme işlemleri kısıtlandı. Lütfen Destek ile iletişime geçin. +payout.error.depositRequiredToWithdraw=Artan dolandırıcılık nedeniyle para çekme talebi oluşturmak için en az bir yatırım yapmış olmanız gerekir. +payout.error.withdrawExceedsWinAfterDeposit=Artan dolandırıcılık nedeniyle para çekme talepleri son yatırımınızdan bu yana toplam kazançlarınızı aşamaz. Şu anda maksimum {0} bilet çekebilirsiniz. +payout.error.invalidAmount=Geçersiz çekim tutarı +payout.error.minimumNotMet=Minimum çekim tutarına ulaşılmadı +payout.error.insufficientBalance=Çekim için yetersiz bakiye +payout.error.withdrawalInProgress=Zaten devam eden bir çekim talebiniz var. Tamamlanmasını bekleyin. +payout.error.withdrawalAmountMaxTwoDecimals=Çekim tutarı en fazla 2 ondalık basamak içermelidir (örn. 125,25). 125,125 gibi değerler desteklenmez. +payout.success.requested=Çekim talebi başarıyla gönderildi + +withdraw.error.walletInvalidFormat=Cüzdan adresi formatı yanlış. Lütfen kontrol edip tekrar deneyin. +withdraw.error.tryLater=Şu anda çekimlerle ilgili bir sorun var. Lütfen biraz sonra tekrar deneyin. + +payment.error.invalidAmount=Geçersiz ödeme tutarı +payment.error.minStars=Miktar en az {0} olmalıdır +payment.error.maxStars=Miktar {0} değerini aşamaz +payment.error.usdMaxTwoDecimals=USD tutarı en fazla 2 ondalık basamak içermelidir (örn. 2,45). +payment.error.legacyNotSupported=Bu ödeme yöntemi artık desteklenmemektedir. Bilet satın almak için mevcut yöntemleri kullanın. +payment.error.failed=Ödeme başarısız. Lütfen tekrar deneyin. +payment.error.statusUnknown=Ödeme durumu bilinmiyor. Lütfen bakiyenizi kontrol edin. +payment.success.purchased={0} bilet başarıyla satın alındı! + +support.error.ticketNotFound=Bilet bulunamadı +support.error.messageFailed=Mesaj gönderilemedi +support.error.closeFailed=Bilet kapatılamadı +support.error.rateLimitWait=Lütfen başka bir mesaj göndermeden önce {0} saniye bekleyin. + +validation.error.required={0} gereklidir +validation.error.positive={0} pozitif olmalıdır +validation.error.range={0} {1} ile {2} arasında olmalıdır + +# Game Room (additional messages) +game.error.roundNotFound=Oyun turu bulunamadı. Lütfen tekrar deneyin. +game.error.participantNotFound=Katılımcı bulunamadı. Lütfen tekrar deneyin. +game.error.rateLimitWait=Lütfen başka bir bahis yapmadan önce bekleyin. Hız limiti: saniyede 1 bahis. +game.error.roomNotJoinable=Oda şu anda erişilebilir değil +game.error.invalidRequest=Geçersiz istek. İstek gövdesi gereklidir. + +# User Service +user.error.referralLevelInvalid=Geçersiz referans seviyesi: {0}. 1, 2 veya 3 olmalıdır. +user.error.depositAmountInvalid=Depozito tutarı pozitif olmalıdır +user.error.balanceNotFound=Kullanıcı bakiyesi bulunamadı + +# Task Controller +task.message.claimed=Görev başarıyla alındı +task.message.notCompleted=Görev tamamlanmadı! + +# Task Titles and Descriptions +# Referral tasks - separate keys for each requirement for proper grammar +task.title.inviteFriends.1=1 arkadaş davet et +task.title.inviteFriends.3=3 arkadaş davet et +task.title.inviteFriends.7=7 arkadaş davet et +task.title.inviteFriends.15=15 arkadaş davet et +task.title.inviteFriends.30=30 arkadaş davet et +task.title.inviteFriends.50=50 arkadaş davet et +task.title.inviteFriends.100=100 arkadaş davet et +task.title.followChannel=Haber kanalımızı takip edin +task.title.followChannelWithdrawals=Ödeme kanıtı kanalımızı takip edin +task.title.dailyBonus=Günlük bonus +task.title.deposit={0} bilet yatır +task.description.inviteFriends.1=Benzersiz referans bağlantınızı kullanarak 1 arkadaş davet edin +task.description.inviteFriends.3=Benzersiz referans bağlantınızı kullanarak 3 arkadaş davet edin +task.description.inviteFriends.7=Benzersiz referans bağlantınızı kullanarak 7 arkadaş davet edin +task.description.inviteFriends.15=Benzersiz referans bağlantınızı kullanarak 15 arkadaş davet et +task.description.inviteFriends.30=Benzersiz referans bağlantınızı kullanarak 30 arkadaş davet edin +task.description.inviteFriends.50=Benzersiz referans bağlantınızı kullanarak 50 arkadaş davet edin +task.description.inviteFriends.100=Benzersiz referans bağlantınızı kullanarak 100 arkadaş davet edin +task.description.followChannel=Haber kanalımızı takip edin +task.description.followChannelWithdrawals=Ödeme kanıtı kanalımızı takip edin +task.description.dailyBonus=Günlük ücretsiz biletinizi talep edin! +task.description.deposit=Hesabınıza {0} bilet yatırın + +# Task Reward Text +task.reward.tickets.1=+5 Bilet +task.reward.tickets.3=+15 Bilet +task.reward.tickets.7=+35 Bilet +task.reward.tickets.15=+75 Bilet +task.reward.tickets.30=+110 Bilet +task.reward.tickets.50=+150 Bilet +task.reward.tickets.100=+375 Bilet +task.reward.tickets.follow=+7 Bilet +task.reward.tickets.daily=+1 Bilet +task.reward.tickets.other.50=+5 Bilet +task.reward.tickets.other.250=+25 Bilet +task.reward.tickets.other.1000=+100 Bilet +task.reward.tickets.other.2000=+100 Bilet +task.reward.tickets.other.5000=+250 Bilet +task.reward.tickets.other.10000=+500 Bilet +task.reward.tickets.other.500=+100 Bilet +task.reward.tickets.other.50000=+2500 Bilet +task.reward.tickets.other.150000=+7500 Bilet +task.reward.tickets.other.500000=+25000 Bilet + +# Date/Time formatting +dateTime.at=saat + +# Telegram Bot Messages +bot.button.startSpinning=Oyunu Başlat +bot.button.startSpinningInline=↘️ OYUNU BAŞLAT ↙️ +bot.button.usersPayouts=Kullanıcı ödemeleri +bot.button.infoChannel=Bilgi kanalı +bot.button.openChannel=Kanalı aç +bot.button.goToChannel=Kanala git +bot.welcome.firstMessage=Win Spin ile şansınızı deneyin! +bot.welcome.message=Bahis yapmaya başlayın, kazanın ve paranızı doğrudan cüzdanınıza çekin.\n\n👉 Başlamak için yukarıdaki videoyu izleyin. +bot.message.startSpinning=Win Spin uygulamasını açmak için bu düğmeyi kullanın: +bot.message.usersPayouts=🔒 Gerçek zamanlı güncellemeler.\n\nTüm para çekme işlemleri kanalda gösterilir. Her şeyi kendiniz kontrol edebilirsiniz: +bot.message.infoChannel=📢 Tüm en son proje güncellemelerinden haberdar olun — tam burada, kanalımızda +bot.message.paySupport=Merhaba! Ödemenizle ilgili bir destek talebi oluşturmak için lütfen @winspinpaysupport ile iletişime geçin.\n\nLütfen dikkat: İade, yalnızca satın alınan biletler henüz oyun turlarında kullanılmadıysa verilir. +bot.message.unrecognized=Bunu tanıyamadık. Lütfen aşağıdaki düğmelerden birini kullanın. +