commit 15498c8337b90ae09408653d0355ccfc30690b45 Author: Tihon Date: Sat Mar 7 23:10:41 2026 +0200 Initial setup, cleanup, VPS setup diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..69c31df Binary files /dev/null and b/.DS_Store differ diff --git a/.gitea/workflows/deploy-vps.yaml b/.gitea/workflows/deploy-vps.yaml new file mode 100644 index 0000000..3a0dfff --- /dev/null +++ b/.gitea/workflows/deploy-vps.yaml @@ -0,0 +1,58 @@ +# Deploy honey-be to VPS on push to main. +# Required secret: DEPLOY_SSH_PRIVATE_KEY. +# Optional: DEPLOY_VPS_HOST (default 188.116.23.7), DEPLOY_VPS_USER (default root). +name: Deploy to VPS +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + env: + VPS_HOST: ${{ secrets.DEPLOY_VPS_HOST }} + VPS_USER: ${{ secrets.DEPLOY_VPS_USER }} + steps: + # Manual checkout: job container cannot resolve 'server', so clone via host IP (gateway). + # Set secret GITEA_HOST_IP to your runner host's gateway (from job: ip route show default | awk '{print $3}'). + - name: Checkout + run: | + GITEA_HOST="${GITEA_HOST_IP:-172.20.0.1}" + git clone --depth 1 "http://oauth2:${GITHUB_TOKEN}@${GITEA_HOST}:3000/admin/honey-be.git" . + git fetch --depth 1 origin "${{ github.sha }}" + git checkout -q "${{ github.sha }}" + env: + GITHUB_TOKEN: ${{ github.token }} + GITEA_HOST_IP: ${{ secrets.GITEA_HOST_IP }} + + - name: Install SSH and Rsync + run: | + apt-get update -qq + apt-get install -y -qq openssh-client rsync + + - name: Setup SSH + env: + SSH_HOST: ${{ secrets.DEPLOY_VPS_HOST }} + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + eval "$(ssh-agent -s)" + ssh-add ~/.ssh/deploy_key + HOST="${SSH_HOST:-188.116.23.7}" + ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Sync code to VPS + run: | + HOST="${VPS_HOST:-188.116.23.7}" + USER="${VPS_USER:-root}" + rsync -avz --delete -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \ + --exclude '.git' \ + --exclude 'target' \ + ./ "$USER@$HOST:/opt/app/backend/honey-be/" + + - name: Run rolling update on VPS + run: | + HOST="${VPS_HOST:-188.116.23.7}" + USER="${VPS_USER:-root}" + ssh -i ~/.ssh/deploy_key "$USER@$HOST" "cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.staged.sh && sudo ./scripts/rolling-update.staged.sh" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2549690 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Environment files ### +.env +.env.local +.env.*.local + +### Logs ### +*.log 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_old.md b/APPLICATION_OVERVIEW_old.md new file mode 100644 index 0000000..c602d61 --- /dev/null +++ b/APPLICATION_OVERVIEW_old.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..803d485 --- /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 honey-config.properties.template + +# If it exists, copy it +sudo cp honey-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 new file mode 100644 index 0000000..61741f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# ====== Build stage ====== +FROM maven:3.9.9-eclipse-temurin-17 AS build + +WORKDIR /app + +COPY pom.xml . +RUN mvn -q -e -B dependency:go-offline + +COPY src ./src + +RUN mvn -q -e -B clean package -DskipTests + +# ====== Runtime stage ====== +FROM eclipse-temurin:17-jre + +WORKDIR /app + +# Copy fat jar from build stage +COPY --from=build /app/target/*.jar app.jar + +# 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" + +# 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 new file mode 100644 index 0000000..7bfc0a7 --- /dev/null +++ b/Dockerfile.inferno @@ -0,0 +1,39 @@ +# ====== Build stage ====== +FROM maven:3.9.9-eclipse-temurin-17 AS build + +WORKDIR /app + +COPY pom.xml . +RUN mvn -q -e -B dependency:go-offline + +COPY src ./src + +RUN mvn -q -e -B clean package -DskipTests + +# ====== Runtime stage ====== +FROM eclipse-temurin:17-jre + +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"] + + diff --git a/EXTERNAL_API_old.md b/EXTERNAL_API_old.md new file mode 100644 index 0000000..b788fa1 --- /dev/null +++ b/EXTERNAL_API_old.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 new file mode 100644 index 0000000..5f2db8c --- /dev/null +++ b/README.md @@ -0,0 +1,543 @@ +# Lottery Backend + +Spring Boot backend application for Lottery project. + +## Technology Stack + +- **Java 17** +- **Spring Boot 3.2.0** +- **MySQL 8.0** (using INT/BIGINT only, no floating point numbers) +- **Flyway** (database migrations) +- **Docker** (containerization) +- **Maven** (build tool) + +## Local Development + +### Prerequisites + +- Java 17 JDK +- Maven 3.9+ +- Docker and Docker Compose (for local MySQL) + +### Setup + +1. **Clone the repository** + +2. **Start MySQL using Docker Compose**: + ```bash + docker-compose up -d db + ``` + +3. **Create `.env` file** (for local development): + ```env + DB_NAME=lottery_db + DB_USERNAME=root + DB_PASSWORD=password + DB_ROOT_PASSWORD=password + TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here + FRONTEND_URL=http://localhost:5173 + ``` + +4. **Run the application**: + ```bash + mvn spring-boot:run + ``` + + Or build and run with Docker: + ```bash + docker-compose up --build + ``` + +### Database Migrations + +Flyway automatically runs migrations on startup. Migrations are located in `src/main/resources/db/migration/`. + +## Deployment Guides + +### Railway Deployment (Staging Environment) + +Railway is the primary deployment platform for staging. It provides built-in logging and easy environment variable management. + +#### Step 1: Create Railway Project + +1. Go to [Railway](https://railway.app) and sign in +2. Click **"New Project"** +3. Select **"Empty Project"** + +#### Step 2: Create MySQL Database Service + +1. In your Railway project, click **"+ New"** → **"Database"** → **"Add MySQL"** +2. Railway will automatically create a MySQL database +3. Note the connection details (you'll need them for the backend service) + +#### Step 3: Create Backend Service + +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 + - Railway will automatically detect it's a Java/Maven project +3. If using Empty Service: + - Click **"Empty Service"** + - Connect to your repository or upload files + +#### Step 4: Configure Environment Variables + +In your backend service settings, go to **"Variables"** and add: + +```env +SPRING_DATASOURCE_URL=${MYSQL_URL} +SPRING_DATASOURCE_USERNAME=${MYSQLUSER} +SPRING_DATASOURCE_PASSWORD=${MYSQLPASSWORD} +TELEGRAM_BOT_TOKEN=your_telegram_bot_token +FRONTEND_URL=https://your-frontend-url.railway.app +PORT=8080 +``` + +**Note**: Railway automatically provides `MYSQL_URL`, `MYSQLUSER`, and `MYSQLPASSWORD` when you add a MySQL database. You can reference them using `${MYSQL_URL}` syntax. + +#### Step 5: Link Database to Backend + +1. In your backend service, go to **"Settings"** → **"Connect"** +2. Find your MySQL database service +3. Click **"Connect"** - Railway will automatically add the MySQL connection variables + +#### Step 6: Configure Health Check + +1. In your backend service, go to **"Settings"** → **"Healthcheck"** +2. Set **Healthcheck Path** to: + ``` + /actuator/health/readiness + ``` +3. Railway will poll this endpoint and wait for HTTP 200 before marking deployment as active + +#### Step 7: Deploy + +1. Railway will automatically deploy when you push to the connected branch +2. Or manually trigger deployment from the Railway dashboard +3. Check the **"Deployments"** tab to monitor the deployment + +#### Step 8: Get Backend URL + +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://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 `lottery-fe` repository +3. Railway will detect it's a Node.js project +4. Add environment variable: + ```env + VITE_API_BASE_URL=https://your-backend-url.railway.app + ``` +5. Railway will automatically build and deploy + +#### Step 10: Create Volume (Optional - for persistent data) + +If you need persistent storage: + +1. In your Railway project, click **"+ New"** → **"Volume"** +2. Name it (e.g., `lottery-data`) +3. Mount it to your service if needed + +### Inferno Deployment (Production Environment) + +Inferno Solution provides the production environment. It requires manual server setup and uses Docker Compose with nginx. + +#### Prerequisites + +- Access to Inferno Solution server (via SSH) +- Docker and Docker Compose installed on the server +- JDK 17 installed on the server (for building, though Docker handles runtime) +- Domain name configured (optional, for HTTPS) + +#### Step 1: Prepare Server + +1. **SSH into your Inferno server**: + ```bash + ssh user@your-server-ip + ``` + +2. **Install Docker** (if not installed): + ```bash + # For Ubuntu/Debian + curl -fsSL https://get.docker.com -o get-docker.sh + sh get-docker.sh + sudo usermod -aG docker $USER + ``` + +3. **Install Docker Compose** (if not installed): + ```bash + 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 + ``` + +4. **Install JDK 17** (for building): + ```bash + # For Ubuntu/Debian + sudo apt update + sudo apt install openjdk-17-jdk -y + ``` + +5. **Create project directory**: + ```bash + mkdir -p /opt/lottery + cd /opt/lottery + ``` + +#### Step 2: Clone Repository + +```bash +cd /opt/lottery +git clone https://github.com/your-username/lottery-be.git +cd lottery-be +``` + +#### Step 3: Create Secret Configuration File + +Create a tmpfs mount for secrets (more secure than .env files): + +```bash +# Create tmpfs mount point +sudo mkdir -p /run/secrets +sudo chmod 700 /run/secrets + +# Create secret file +sudo nano /run/secrets/lottery-config.properties +``` + +Add the following content (replace with your actual values): + +```properties +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 +MYSQL_PASSWORD=your_secure_mysql_password +MYSQL_ROOT_PASSWORD=your_secure_mysql_root_password +``` + +**Important**: +- Use strong, unique passwords +- Keep this file secure (it's in tmpfs, so it's in-memory only) +- The file will be lost on reboot - you'll need to recreate it or use a startup script + +#### Step 4: Configure Docker Compose for Inferno + +The `docker-compose.inferno.yml` file is already configured. Make sure it's present in your repository. + +#### Step 5: Build and Start Services + +```bash +cd /opt/lottery/lottery-be + +# Build and start all services +docker-compose -f docker-compose.inferno.yml up -d --build +``` + +This will: +- Build the backend application +- Start MySQL database +- Start backend service +- Start nginx reverse proxy + +#### Step 6: Configure Nginx (if using custom domain) + +1. **Edit nginx configuration**: + ```bash + nano nginx/conf.d/lottery.conf + ``` + +2. **Update server_name** (if using HTTPS): + ```nginx + server { + listen 80; + server_name your-domain.com; + # ... rest of config + } + ``` + +3. **Reload nginx**: + ```bash + docker-compose -f docker-compose.inferno.yml restart nginx + ``` + +#### Step 7: Set Up SSL (Optional - Recommended for Production) + +1. **Install Certbot** (Let's Encrypt): + ```bash + sudo apt install certbot python3-certbot-nginx -y + ``` + +2. **Obtain SSL certificate**: + ```bash + sudo certbot --nginx -d your-domain.com + ``` + +3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`) + +4. **Reload nginx**: + ```bash + docker-compose -f docker-compose.inferno.yml restart nginx + ``` + +#### Step 8: Configure Firewall + +```bash +# Allow HTTP and HTTPS +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Allow SSH (if not already allowed) +sudo ufw allow 22/tcp + +# Enable firewall +sudo ufw enable +``` + +#### Step 9: Set Up Auto-Start on Boot + +Create a systemd service to ensure services start on boot: + +```bash +sudo nano /etc/systemd/system/lottery.service +``` + +Add: + +```ini +[Unit] +Description=Lottery Application +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +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 + +[Install] +WantedBy=multi-user.target +``` + +Enable the service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable lottery.service +sudo systemctl start lottery.service +``` + +#### Step 10: Set Up Grafana Integration (Production Logging) + +1. **Install Grafana and Loki** (on a separate server or same server): + ```bash + # Follow Grafana/Loki installation guide + # https://grafana.com/docs/loki/latest/installation/ + ``` + +2. **Configure Promtail** to collect logs from Docker containers: + ```yaml + # promtail-config.yml + server: + http_listen_port: 9080 + grpc_listen_port: 0 + + positions: + filename: /tmp/positions.yaml + + clients: + - url: http://loki:3100/loki/api/v1/push + + scrape_configs: + - 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: lottery-backend + action: keep + ``` + +3. **Update docker-compose.inferno.yml** to add logging driver: + ```yaml + app: + # ... existing config + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + ``` + +4. **Configure Grafana datasource** to connect to Loki + +#### Step 11: Monitor and Maintain + +**Check service status**: +```bash +docker-compose -f docker-compose.inferno.yml ps +``` + +**View logs**: +```bash +# All services +docker-compose -f docker-compose.inferno.yml logs -f + +# Specific service +docker-compose -f docker-compose.inferno.yml logs -f app +``` + +**Update application**: +```bash +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 lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql +``` + +## Configuration + +### Environment Variables + +The application supports two configuration strategies: + +1. **Environment Variables** (Railway): Set variables in Railway dashboard +2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties` + +Priority: Secret file → Environment variables + +### Required Variables + +- `SPRING_DATASOURCE_URL` - MySQL connection URL +- `SPRING_DATASOURCE_USERNAME` - MySQL username +- `SPRING_DATASOURCE_PASSWORD` - MySQL password +- `TELEGRAM_BOT_TOKEN` - Telegram bot token for authentication +- `FRONTEND_URL` - Frontend URL for CORS configuration + +### Database Schema + +The database uses **INT** and **BIGINT** only - no floating point numbers. + +Current tables: +- `users` - User information (id, telegram_id, username, created_at) + +## API Endpoints + +### Public Endpoints + +- `GET /ping` - Health check (no auth required) +- `GET /actuator/health` - Application health +- `GET /actuator/health/readiness` - Readiness probe (checks database) +- `GET /actuator/health/liveness` - Liveness probe + +### Protected Endpoints (require Telegram auth) + +- `GET /api/users/current` - Get current user information + +## Authorization + +The application uses Telegram Mini App authentication: + +1. Frontend sends `Authorization: tma ` header +2. Backend validates Telegram signature +3. Backend creates/updates user in database +4. User is stored in thread-local context for the request + +## Health Checks + +- **Readiness**: `/actuator/health/readiness` - Checks database connectivity +- **Liveness**: `/actuator/health/liveness` - Checks if application is running + +Configure Railway to use `/actuator/health/readiness` as the health check path. + +## Logging + +- **Railway**: Built-in logging available in Railway dashboard +- **Inferno**: Configure Grafana/Loki for log aggregation (see Grafana setup above) + +## Troubleshooting + +### Database Connection Issues + +- Verify MySQL is running: `docker-compose ps` +- Check connection credentials in environment variables +- Review application logs for connection errors + +### Authorization Failures + +- Verify `TELEGRAM_BOT_TOKEN` is correct +- Check that frontend is sending `Authorization: tma ` header +- Review backend logs for validation errors + +### Deployment Issues + +- Check health check endpoint: `curl http://your-url/actuator/health/readiness` +- Review Railway deployment logs +- Verify all environment variables are set correctly + +## Development + +### Running Tests + +```bash +mvn test +``` + +### Building JAR + +```bash +mvn clean package +``` + +### Running Locally with Docker + +```bash +docker-compose up --build +``` + +## Project Structure + +``` +lottery-be/ +├── src/ +│ ├── main/ +│ │ ├── java/com/lottery/lottery/ +│ │ │ ├── config/ # Configuration classes +│ │ │ ├── controller/ # REST controllers +│ │ │ ├── dto/ # Data transfer objects +│ │ │ ├── exception/ # Exception handlers +│ │ │ ├── health/ # Health indicators +│ │ │ ├── logging/ # Grafana logging config +│ │ │ ├── model/ # JPA entities +│ │ │ ├── repository/ # JPA repositories +│ │ │ ├── security/ # Auth interceptor, UserContext +│ │ │ └── service/ # Business logic +│ │ └── resources/ +│ │ ├── db/migration/ # Flyway migrations +│ │ └── application.yml # Application config +├── nginx/ # Nginx config (for Inferno) +├── Dockerfile # Dockerfile for Railway +├── Dockerfile.inferno # Dockerfile for Inferno +├── docker-compose.yml # Docker Compose for local/Railway +├── docker-compose.inferno.yml # Docker Compose for Inferno +└── pom.xml # Maven configuration +``` + +## License + +[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/VPS_SETUP_FROM_SCRATCH.md b/VPS_SETUP_FROM_SCRATCH.md new file mode 100644 index 0000000..66b1ebe --- /dev/null +++ b/VPS_SETUP_FROM_SCRATCH.md @@ -0,0 +1,457 @@ +# Honey VPS Setup from Scratch (Inferno) + +This guide walks through setting up a new VPS for the **Honey** app with the same layout as your existing lottery VPS: backend + MySQL + phpMyAdmin in Docker, frontend and admin panel served by Nginx, logging under `/opt/app/logs`, secrets in `/run/secrets`, and MySQL backups to a backup VPS. + +**Target layout (mirrors your lottery setup):** + +- **Containers:** backend (honey-be), MySQL (honey_db), phpMyAdmin +- **Served by Nginx:** frontend (honey-fe), admin panel (honey-admin) +- **Paths:** `/opt/app` for app files, `/run/secrets` for config, `/opt/app/logs` for logs +- **Nginx:** main config + site config (e.g. `nginx.conf` + `sites-enabled/your-domain`) + +--- + +## 1. VPS basics + +### 1.1 System update and installs + +```bash +sudo apt update && sudo apt upgrade -y +``` + +```bash +# Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER + +# Docker Compose (plugin) +sudo apt-get update +sudo apt-get install -y docker-compose-plugin + +# Nginx + Certbot +sudo apt install -y nginx certbot python3-certbot-nginx +``` + +Log out and back in so `docker` group applies. + +### 1.2 Directory structure under `/opt/app` + +Create the same layout as lottery (backend, frontend, admin, nginx, data, logs, backups, mysql): + +```bash +sudo mkdir -p /opt/app/backend +sudo mkdir -p /opt/app/frontend +sudo mkdir -p /opt/app/admin +sudo mkdir -p /opt/app/admin-panel +sudo mkdir -p /opt/app/nginx +sudo mkdir -p /opt/app/data/avatars +sudo mkdir -p /opt/app/logs +sudo mkdir -p /opt/app/backups +sudo mkdir -p /opt/app/mysql/data +sudo mkdir -p /opt/app/mysql/conf + +sudo chown -R $USER:$USER /opt/app +sudo chmod -R 755 /opt/app +``` + +### 1.3 Git + +```bash +sudo apt install git -y +git config --global alias.st status +ssh-keygen -t ed25519 -C "your_email@example.com" (replace email) +cat ~/.ssh/id_ed25519.pub (copy the key and add to origin) + +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up (and login in local browser to tailscale) +``` + +Clone all repositories to respective folders. + +--- + +## 2. Backend (honey-be) on VPS + +### 2.1 Secret file (honey-config.properties) + +Backend reads **`/run/secrets/honey-config.properties`** (see `ConfigLoader` and `docker-compose.prod.yml`). Create it from the template; **do not commit real values**. + +On the VPS: + +```bash +sudo mkdir -p /run/secrets +sudo cp /opt/app/backend/honey-be/honey-config.properties.template /run/secrets/honey-config.properties +sudo chmod 640 /run/secrets/honey-config.properties +sudo chown root:docker /run/secrets/honey-config.properties # if your user is in docker group, or root:$USER +``` + +Edit and set real values: + +```bash +sudo nano /run/secrets/honey-config.properties +``` + +Notes: + +- `SPRING_DATASOURCE_URL` - set to new DB URL +- `SPRING_DATASOURCE_PASSWORD` - just generate new secret +- `TELEGRAM_BOT_TOKEN` - token for Telegram bot +- `FRONTEND_URL` - put new domain here +- `APP_ADMIN_JWT_SECRET` - generate new secret using `openssl rand -base64 48` on VPS and put here +- `APP_TELEGRAM_WEBHOOK_TOKEN` - generate a new secret and set it using `POST https://api.telegram.org/bot/setWebhook?url=https:///api/telegram/webhook/&max_connections=100` +- `PMA_ABSOLUTE_URI` - generate a new secret and set it. Don't forget to set the same to nginx + +Create 2 files `admin_api_url` and `admin_base_path` with URL and secret path in `/run/secrets` folder. + +### 2.3 Load DB password for Docker Compose + +`docker-compose.prod.yml` expects `DB_ROOT_PASSWORD` (and MySQL healthcheck uses it). The repo has `scripts/load-db-password.sh` which reads the secret file; it’s currently wired to **lottery** path. For Honey, either: + +- Edit `scripts/load-db-password.sh` and set: + - `SECRET_FILE="/run/secrets/honey-config.properties"` +- Or create a small wrapper that exports the same variables from `honey-config.properties`. + +**When you need to source it:** Only for one-off manual `docker compose` runs (e.g. first-time start in §2.6, or starting phpMyAdmin in §4.1). You do **not** need to source it for deployment: `scripts/rolling-update.sh` loads the password from the secret file automatically when `DB_ROOT_PASSWORD` is not set. + +**IMPORTANT** +- `Change Java memory in docker-compose.prod.yml when you know the PROD VPS characteristics.` +- `Change the rolling-update.sh script to match sites-enabled nginx file name for PROD when you know the domain` +### 2.4 Logging (logback) and config dir + +Backend uses an external **logback** config so you can change log level without rebuilding. Create the config dir and put `logback-spring.xml` there: + +```bash +mkdir -p /opt/app/backend/config +mkdir -p /opt/app/logs +``` + +Either copy from the JAR or from source: + +```bash +# From backend dir +cp src/main/resources/logback-spring.xml /opt/app/backend/config/ +# or extract from built JAR: +# unzip -p target/honey-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml +``` + +Optional: run the existing setup script (it may still reference lottery paths; adjust or run the copy above): + +```bash +cd /opt/app/backend/honey-be +./scripts/setup-logging.sh +``` + +Edit log level at any time: + +```bash +nano /opt/app/backend/config/logback-spring.xml +# e.g. change to DEBUG +# Logback rescans periodically (e.g. 30s); no restart needed if scan is enabled +``` + +### 2.5 MySQL my.cnf (optional) + +If you use a custom MySQL config in prod (e.g. for buffer pool): + +```bash +# Create /opt/app/mysql/conf/my.cnf with your tuning; then in docker-compose.prod.yml +# the volume is already: /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro +``` + +Note: `on Staged VPS it has 4G RAM, so don't forget to change it for PROD accordingly.` + +### 2.6 First start (backend + DB only) + +```bash +cd /opt/app/backend/honey-be +source scripts/load-db-password.sh +docker compose -f docker-compose.prod.yml up -d db +# wait for DB healthy +docker compose -f docker-compose.prod.yml up -d backend +``` + +Note: `for Staged use a separate docker compose.` + +Check: + +```bash +docker ps +curl -s http://127.0.0.1:8080/actuator/health/readiness +``` + +Backend should listen only on `127.0.0.1:8080` (Nginx will proxy to it). Do **not** expose 8080 to the internet. + +--- + +## 3. Nginx + +### 3.1 Split config (like your lottery VPS) + +- `Take 2 files from already working VPS: nginx.conf and sites-enabled/ and put to new VPS.` +- `Remove or comment lines reg certificates and change 2 listen lines`: + +```bash + # SSL Certificates + ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; + Change 'listen 443 ssl http2;' to 'listen 443;` + Change `listen [::]:443 ssl http2;` to `listen [::]:443;` +``` +Enable and test: + +```bash +sudo ln -s /etc/nginx/sites-available/testforapp.website /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +### 3.3 SSL (Let’s Encrypt) + +```bash +sudo certbot --nginx -d testforapp.website +``` + +Certbot will adjust the server block for certificates. Reload Nginx if needed. +And remove redundant ssl/listen lines from server block to not override certbot's configs. + +--- + +## 4. phpMyAdmin + +### 4.1 Start phpMyAdmin container + +`docker-compose.prod.yml` already defines a **phpmyadmin** service (port 8081, same network as `db`). Start it: + +```bash +cd /opt/app/backend/honey-be +source scripts/load-db-password.sh +docker compose -f docker-compose.prod.yml up -d phpmyadmin +``` + +### 4.2 Access via Nginx (recommended) + +Do **not** expose 8081 publicly. Proxy it via Nginx under a secret path (e.g. `/your-secret-pma-path/`), as in the example above. Set `PMA_ABSOLUTE_URI` in the secret file so phpMyAdmin generates correct URLs: + +In `/run/secrets/honey-config.properties` add (or use env when running compose): + +```properties +PMA_ABSOLUTE_URI=https://your-domain.com/your-secret-pma-path/ +``` + +Then reload Nginx and open `https://your-domain.com/your-secret-pma-path/`. Login: user `root`, password = `SPRING_DATASOURCE_PASSWORD` from the same secret file. + +### 4.3 Optional: UFW for phpMyAdmin + +If you ever expose 8081 temporarily, restrict it: + +```bash +sudo ufw allow from YOUR_IP to any port 8081 +sudo ufw reload +``` + +Prefer keeping 8081 bound to 127.0.0.1 and using only Nginx proxy. + +--- + +## 5. Frontend (honey-fe) + +### 5.1 Build locally and upload + +On your machine (e.g. in `honey-test-fe` or your honey-fe repo): + +```bash +cd honey-test-fe # or honey-fe +npm install +npm run build +scp -r dist/* root@YOUR_VPS_IP:/opt/app/frontend/dist/ +``` + +Or with rsync: + +```bash +rsync -avz dist/ root@YOUR_VPS_IP:/opt/app/frontend/dist/ +``` + +Ensure the app’s API base URL is correct for production (e.g. relative `""` or `VITE_API_BASE_URL` for your domain). + +--- + +## 6. Admin panel (honey-admin) + +### 6.1 Build with secret (on VPS or locally) + +Admin often has a build that injects a public URL or env. From the repo: + +```bash +cd honey-admin +npm install +npm run build:with-secret +``` + +Then copy the built output to the Nginx admin root: + +**If you build on the VPS:** + +```bash +cd /opt/app/admin/honey-admin +npm run build:with-secret +cp -r dist/* /opt/app/admin-panel/ +``` + +**If you build locally:** + +```bash +scp -r dist/* root@YOUR_VPS_IP:/opt/app/admin-panel/ +``` + +The Nginx location for admin (e.g. `/your-secret-admin-path/`) must serve this directory and support SPA routing (`try_files` to `index.html`). + +--- + +## 7. Rolling backend updates + +Use the existing **rolling-update** script so Nginx switches to a new backend container with no downtime. + +### 7.1 Script adaptation for Honey + +The script in the repo may still reference **lottery** container names and Nginx paths. For Honey: + +- **Containers:** `honey-backend` (primary), `honey-backend-new` (standby). +- **Nginx:** same idea as lottery: one upstream `backend` with `server 127.0.0.1:8080` and optionally `server 127.0.0.1:8082 backup;`. The script flips which port is primary. + +Edit `scripts/rolling-update.sh` and replace: + +- `lottery-backend` → `honey-backend` +- `lottery-backend-new` → `honey-backend-new` + +The script auto-detects Nginx config from paths like `/etc/nginx/sites-enabled/win-spin.live`. For Honey, either: + +- Symlink or name your site config so the script finds it (e.g. add a similar check for `honey.conf` in the script), or +- Set the path explicitly before running: `export NGINX_CONF=/etc/nginx/sites-enabled/your-domain && sudo ./scripts/rolling-update.sh` + +### 7.2 Run rolling update + +From the backend directory, run (no need to source `load-db-password.sh` — the script does it): + +```bash +cd /opt/app/backend/honey-be +chmod +x scripts/rolling-update.sh +sudo ./scripts/rolling-update.sh +``` + +The script loads `DB_ROOT_PASSWORD` from the secret file if not set, then: builds the new image, starts `backend-new` on 8082, health-checks it, points Nginx to 8082, reloads Nginx, then stops the old backend. + +--- + +## 8. Logging + +- **App logs:** `/opt/app/logs/` (mounted into backend container; path can be set via `LOG_DIR` / logback). +- **Config:** `/opt/app/backend/config/logback-spring.xml` (edit to change level; no restart if scan is enabled). +- **Nginx:** `/var/log/nginx/access.log`, `/var/log/nginx/error.log`. + +View backend logs: + +```bash +docker logs -f honey-backend +# or +tail -f /opt/app/logs/honey-be.log +``` + +--- + +## 9. MySQL backups to backup VPS + +### 9.1 Backup script for Honey + +Copy and adapt the existing **`scripts/backup-database.sh`** (or create a Honey-specific one). Set: + +- `MYSQL_CONTAINER="honey-mysql"` +- `MYSQL_DATABASE="honey_db"` +- `SECRET_FILE="/run/secrets/honey-config.properties"` +- `BACKUP_FILENAME="honey_db_backup_${TIMESTAMP}.sql"` (and `.gz` if compressing) +- Remote path and retention (e.g. `BACKUP_VPS_PATH`, keep last 30 days) to match your backup server. + +Ensure the script runs as root (or with sudo) so it can read `/run/secrets/honey-config.properties`, and that it uses the same `DB_PASSWORD` / `SPRING_DATASOURCE_PASSWORD` as in the secret file. + +### 9.2 SSH key to backup VPS + +On the Honey VPS: + +```bash +ssh-keygen -t ed25519 -C "backup@honey-vps" -f ~/.ssh/backup_key +ssh-copy-id -i ~/.ssh/backup_key.pub user@BACKUP_VPS_IP +``` + +Test: + +```bash +ssh -i ~/.ssh/backup_key user@BACKUP_VPS_IP "echo OK" +``` + +### 9.3 Cron (daily at 2 AM) + +```bash +sudo crontab -e +``` + +Add: + +```cron +0 2 * * * /opt/app/backend/honey-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1 +``` + +Use the Honey-adapted backup script path and ensure `backup-database.sh` uses `honey_db` and `honey-mysql`. + +--- + +## 10. Quick reference + +| Item | Honey | +|------|-----------------------------------------------------------------| +| **App root** | `/opt/app` | +| **Backend code** | `/opt/app/backend/honey-be` (honey-be) | +| **Frontend static** | `/opt/app/frontend/dist` (honey-fe build) | +| **Admin static** | `/opt/app/admin-panel` (honey-admin build) | +| **Secret file** | `/run/secrets/honey-config.properties` | +| **Logs** | `/opt/app/logs` (+ logback config in `/opt/app/backend/config`) | +| **Avatars** | `/opt/app/data/avatars` | +| **Nginx** | `/etc/nginx/nginx.conf` + `/etc/nginx/sites-enabled/your-domain` | +| **DB container** | `honey-mysql` | +| **DB name** | `honey_db` | +| **Backend containers** | `honey-backend`, `honey-backend-new` (rolling) | +| **phpMyAdmin** | Container `honey-phpmyadmin`, port 8081 → proxy via Nginx secret path | + +### Deploy commands (summary) + +- **Backend (rolling):** + `cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.sh && sudo ./scripts/rolling-update.sh` + (Password is loaded from the secret file inside the script.) + +- **Frontend:** + Local: `npm run build` then `scp -r dist/* root@VPS:/opt/app/frontend/dist/` + +- **Admin:** + On VPS: `cd /opt/app/admin/honey-admin && npm run build:with-secret && cp -r dist/* /opt/app/admin-panel/` + Or build locally and `scp -r dist/* root@VPS:/opt/app/admin-panel/` + +- **Log level:** + Edit `/opt/app/backend/config/logback-spring.xml` (no restart if scan enabled). + +--- + +## 11. Checklist after setup + +- [ ] `/opt/app` structure created; ownership and permissions correct. +- [ ] `/run/secrets/honey-config.properties` created and filled (no placeholders). +- [ ] `load-db-password.sh` (and backup/rolling scripts) use Honey secret path and container/db names. +- [ ] Backend + DB + phpMyAdmin start; health check returns 200. +- [ ] Nginx site config in place; `nginx -t` OK; HTTPS works. +- [ ] Frontend and admin builds deployed to `/opt/app/frontend/dist` and `/opt/app/admin-panel`. +- [ ] API and WebSocket work through Nginx; avatars and admin paths load. +- [ ] phpMyAdmin reachable only via Nginx secret path; 8081 not public. +- [ ] Rolling update script updated for `honey-backend` / `honey-backend-new` and tested. +- [ ] Backup script adapted for `honey_db` / `honey-mysql`; cron runs and backups appear on backup VPS. +- [ ] Logs under `/opt/app/logs` and logback config under `/opt/app/backend/config`; log level change works. + +This gives you the same layout and workflow as your lottery VPS, but for Honey (honey-be, honey-fe, honey-admin) with Nginx, phpMyAdmin, logging, and backups. diff --git a/docker-compose.inferno.yml b/docker-compose.inferno.yml new file mode 100644 index 0000000..0478b79 --- /dev/null +++ b/docker-compose.inferno.yml @@ -0,0 +1,77 @@ +version: "3.9" + +services: + db: + image: mysql:8.0 + container_name: honey-mysql + restart: always + environment: + MYSQL_DATABASE: honey_db + MYSQL_USER: honey_user + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + volumes: + - honey_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 + + app: + build: + context: . + dockerfile: Dockerfile.inferno + container_name: honey-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_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 + # Don't expose port directly - nginx will handle it + + nginx: + image: nginx:alpine + container_name: honey-nginx + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + # SSL certificates (if using HTTPS) + # - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - app + networks: + - honey-network + +volumes: + honey_mysql_data: + +networks: + honey-network: + driver: bridge + + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fd19bf4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,193 @@ +version: "3.9" + +services: + db: + image: mysql:8.0 + container_name: honey-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: honey_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: + - honey-network + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: honey-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/honey-config.properties:/run/secrets/honey-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: + - honey-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: honey-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/honey-config.properties:/run/secrets/honey-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: + - honey-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: honey-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: + - honey-network + # Resource limits for phpMyAdmin + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + +volumes: + mysql_data: + driver: local + +networks: + honey-network: + driver: bridge + diff --git a/docker-compose.staged.yml b/docker-compose.staged.yml new file mode 100644 index 0000000..719b4ef --- /dev/null +++ b/docker-compose.staged.yml @@ -0,0 +1,138 @@ +# Staged environment: same as prod but tuned for 8GB VPS (lower heap and container limits). +# Use with: docker compose -f docker-compose.staged.yml up -d +# Rolling update: scripts/rolling-update.staged.sh + +version: "3.9" + +services: + db: + image: mysql:8.0 + container_name: honey-mysql + restart: always + environment: + MYSQL_DATABASE: honey_db + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + deploy: + resources: + limits: + cpus: '1.0' + memory: 2G + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - honey-network + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: honey-backend + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:8080:8080" + labels: + - "deployment.role=primary" + - "deployment.version=current" + volumes: + - /opt/app/data/avatars:/app/data/avatars + - /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro + - /opt/app/backend/config:/app/config:rw + - /opt/app/logs:/app/logs + environment: + # Staged: 2GB heap (prod uses 10GB) + JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 + LOGGING_CONFIG: /app/config/logback-spring.xml + LOG_DIR: /app/logs + deploy: + resources: + limits: + cpus: '2.0' + memory: 3G + networks: + - honey-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: + build: + context: . + dockerfile: Dockerfile + container_name: honey-backend-new + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:8082:8080" + profiles: + - rolling-update + labels: + - "deployment.role=standby" + - "deployment.version=new" + volumes: + - /opt/app/data/avatars:/app/data/avatars + - /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro + - /opt/app/backend/config:/app/config:rw + - /opt/app/logs:/app/logs + environment: + JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 + LOGGING_CONFIG: /app/config/logback-spring.xml + LOG_DIR: /app/logs + deploy: + resources: + limits: + cpus: '2.0' + memory: 3G + networks: + - honey-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: honey-phpmyadmin + restart: always + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:8081:80" + environment: + PMA_HOST: db + PMA_PORT: 3306 + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + UPLOAD_LIMIT: 64M + PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-} + PMA_SSL: "true" + PMA_TRUSTED_PROXIES: "127.0.0.1" + networks: + - honey-network + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + +volumes: + mysql_data: + driver: local + +networks: + honey-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d91bd9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.9" + +services: + db: + image: mysql:8.0 + container_name: honey-mysql + restart: always + environment: + MYSQL_DATABASE: ${DB_NAME:honey_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 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + env_file: + - .env + build: . + container_name: honey-backend + depends_on: + db: + condition: service_healthy + ports: + - "8080:8080" + environment: + - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db} + - SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root} + - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password} + +volumes: + honey_mysql_data: + + diff --git a/docs/GITEA_VPS_DEPLOY.md b/docs/GITEA_VPS_DEPLOY.md new file mode 100644 index 0000000..7f31ccd --- /dev/null +++ b/docs/GITEA_VPS_DEPLOY.md @@ -0,0 +1,360 @@ +# Gitea Actions: Deploy honey-be to VPS on push to main + + +This guide sets up automatic deployment of **honey-be** to your Honey VPS (188.116.23.7) when you push to the `main` branch. The workflow runs on your Gitea runner, syncs the repo via rsync over SSH, then runs the rolling-update script on the VPS. + +## Prerequisites + +- Gitea with Actions enabled and at least one runner registered (e.g. `ubuntu-latest`). +- Honey VPS (188.116.23.7) with backend at `/opt/app/backend/honey-be` and `scripts/rolling-update.staged.sh`. +- The **Gitea server (or the machine where the runner runs)** must be able to reach 188.116.23.7 over the network (e.g. outbound SSH). Gitea itself can stay Tailscale-only. + +--- + +## 1. Create the deploy SSH key on your Mac + +**Where:** Local Mac (Terminal). + +**1.1** Generate a dedicated deploy key (Ed25519, no passphrase): + +```bash +ssh-keygen -t ed25519 -C "gitea-deploy-honey-be" -f ~/.ssh/gitea_deploy_honey_be -N "" +``` + +**1.2** Where the keys are on your Mac: + +| File on your Mac | Purpose | +|------------------|--------| +| `~/.ssh/gitea_deploy_honey_be` | **Private key** — you will paste this into Gitea (Step 3). Never put this on the Staged VPS. | +| `~/.ssh/gitea_deploy_honey_be.pub` | **Public key** — you will put this on the Staged VPS (Step 2). | + +**1.3** Optional: display the keys so you can copy them later. + +To show the **public** key (for Step 2): + +```bash +cat ~/.ssh/gitea_deploy_honey_be.pub +``` + +To show the **private** key (for Step 3 — paste into Gitea): + +```bash +cat ~/.ssh/gitea_deploy_honey_be +``` + +Copy each output as a whole (including `ssh-ed25519 ...` for the public key and `-----BEGIN ... KEY-----` / `-----END ... KEY-----` for the private key). You can run these commands again anytime. + +--- + +## 2. Put the public key on the Staged VPS + +The **Staged VPS** is your Honey server: **188.116.23.7**. The Gitea runner will SSH into this machine as `root` (or your deploy user), so the **public** key must be in that user’s `authorized_keys` on the Staged VPS. + +**2.1 — On your Local Mac:** Copy the public key to the clipboard (so you can paste it on the VPS): + +```bash +cat ~/.ssh/gitea_deploy_honey_be.pub | pbcopy +``` + +Or just note the single line that looks like: +`ssh-ed25519 AAAAC3... gitea-deploy-honey-be` + +**2.2 — Log in to the Staged VPS** from your Mac: + +```bash +ssh root@188.116.23.7 +``` + +(Use the same user you normally use to manage this server, e.g. `root` or a user with sudo. If you use a different user, replace `root` in the next steps with that user.) + +**2.3 — On the Staged VPS (188.116.23.7):** Ensure `.ssh` exists and add the public key. + +Run these commands **one by one** on the Staged VPS (after you’re logged in via SSH): + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +``` + +Then add the public key. **Either** paste the line you copied (replace `PASTE_YOUR_PUBLIC_KEY_LINE_HERE` with the actual line): + +```bash +echo 'PASTE_YOUR_PUBLIC_KEY_LINE_HERE' >> ~/.ssh/authorized_keys +``` + +**Or**, if you have the key in your Mac clipboard, on the Staged VPS you can do (from Mac, one line): + +```bash +ssh root@188.116.23.7 "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$(cat ~/.ssh/gitea_deploy_honey_be.pub)' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" +``` + +If you ran the `echo ... >> authorized_keys` manually on the VPS, set permissions: + +```bash +chmod 600 ~/.ssh/authorized_keys +``` + +**2.4 — Verify from your Mac:** Check that the deploy key can log in without a password: + +```bash +ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK" +``` + +You should see `OK`. If you get "Permission denied (publickey)", the public key was not added correctly to `~/.ssh/authorized_keys` on the Staged VPS. + +--- + +## 3. Put the private key into Gitea (repository secret) + +**Where:** Private key stays on your Mac; you only **paste its contents** into Gitea in the browser. + +**3.1 — On your Local Mac:** Show the private key so you can copy it: + +```bash +cat ~/.ssh/gitea_deploy_honey_be +``` + +Copy the **entire** output, including: + +- `-----BEGIN OPENSSH PRIVATE KEY-----` +- all lines in the middle +- `-----END OPENSSH PRIVATE KEY-----` + +**3.2 — In Gitea (browser):** Add it as a repository secret. + +1. Open your repo: `http://100.122.146.65:3000/admin/honey-be` (or your Gitea URL over Tailscale). +2. Go to **Settings** → **Secrets and Variables** → **Actions**. +3. Under **Repository Secrets**, click **Add Secret**. +4. **Name:** `DEPLOY_SSH_PRIVATE_KEY` +5. **Value:** Paste the full private key you copied from the previous command. Paste once, with no extra spaces or blank lines before/after; the key must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`. Do not paste the `.pub` (public) file by mistake — that will cause "Error loading key ... invalid format" in the workflow. +6. Save. + +Optional secrets (workflow has defaults): + +| Name | Value | When to set | +|------|--------|-------------| +| `DEPLOY_VPS_HOST` | `188.116.23.7` | Only if your Staged VPS has a different IP. | +| `DEPLOY_VPS_USER` | `root` | Only if the deploy user is not `root`. | +| `GITEA_HOST_IP` | e.g. `172.20.0.1` | If checkout fails with "Could not resolve host: server", set this to the **default gateway** seen from the job container. Run a debug job that runs `ip route show default \| awk '{print $3}'` and use that value. The workflow defaults to `172.20.0.1` if unset. | + +--- + +## 4. Add the workflow file to the repo and push it + +The workflow is a YAML file that tells Gitea **what to do** when you push to `main`. It does not get “installed” anywhere except inside the repository. + +**4.1 — What the file is and where it lives** + +- **Path in repo:** `.gitea/workflows/deploy-vps.yaml` +- **What it does:** On every push to `main`, Gitea runs a job that: checks out the code → installs SSH/rsync → syncs the repo to the Staged VPS at `/opt/app/backend/honey-be` → runs `sudo ./scripts/rolling-update.staged.sh` on the VPS. +- Gitea only runs workflows that exist **on the branch you push**. So this file must be committed and pushed to `main`. + +**4.2 — What you need to do** + +**Where:** Local Mac (in your honey-be project directory). + +1. Confirm the workflow file exists: + + ```bash + cd /path/to/your/honey-be + ls -la .gitea/workflows/deploy-vps.yaml + ``` + +2. If it’s there, add it to git, commit, and push to `main`: + + ```bash + git add .gitea/workflows/deploy-vps.yaml + git commit -m "Add VPS deploy workflow" + git push origin main + ``` + +3. If the file is not there, create the directory and the file (the file contents are in this repo), then run the same `git add` / `git commit` / `git push` as above. + +After the push, Gitea will see the workflow. It will run **only when** a runner is available (Step 5). So you can push the workflow first and set up the runner next, or the other way around. + +--- + +## 5. Set up Gitea Actions and the runner + +Gitea needs **Actions enabled** and at least one **runner** registered. The runner is the machine (or container) that actually runs the workflow steps. Your Gitea runs on a VPS with Docker; the runner is usually the **act_runner** container next to Gitea. + +**5.1 — Enable Actions (if not already)** + +**Where:** Gitea in the browser (admin or repo). + +- **Site-wide:** Log in as admin → **Site Administration** → **Actions** → enable **Enable Actions**. +- Or at **repo level:** open **honey-be** → **Settings** → **Actions** → enable if there is an option. + +**5.2 — Ensure the runner container is running** + +**Where:** Gitea VPS (the server where Gitea’s Docker runs, e.g. 100.122.146.65 over Tailscale). + +SSH into the Gitea server and check that both `gitea` and `gitea_runner` (or your runner container name) are up: + +```bash +docker ps +``` + +You should see something like `gitea` and `gitea_runner`. If the runner container is stopped: + +```bash +cd ~/gitea # or wherever your docker-compose.yml is +docker compose up -d +``` + +**5.3 — Get the runner registration token from Gitea** + +**Where:** Gitea in the browser. + +1. Open the **honey-be** repository. +2. Go to **Settings** → **Actions** → **Runners**. +3. Click **Create new runner** (or **Add runner** / **Register runner**). +4. Gitea will show a **registration token** and often a command or instructions. Copy the token (you’ll use it in the next step if the runner is not already registered). + +**5.4 — Register the runner (if it isn’t already)** + +**Where:** Gitea VPS (SSH). + +Your `docker-compose` must set **GITEA_INSTANCE_URL** to a URL the runner container can reach. Use the **Docker service name**, not `127.0.0.1`: + +- **Use:** `GITEA_INSTANCE_URL=http://server:3000` (so the runner resolves `server` on the Docker network). +- **Do not use:** `GITEA_INSTANCE_URL=http://127.0.0.1:3000` — from inside the runner container, that is the container itself, so the runner stays "Offline" and never connects. + +Your `docker-compose` may already set `GITEA_RUNNER_REGISTRATION_TOKEN` and the runner registers on startup. Check in Gitea: **Settings** → **Actions** → **Runners**. If you see a runner with status **Idle** and label **ubuntu-latest**, you can skip to 5.5. + +If no runner appears (or it stays "Offline"), register or re-register on the Gitea VPS. If you changed `GITEA_INSTANCE_URL`, clear the runner’s persisted state first so it uses the new URL: + +```bash +cd ~/gitea +docker compose stop runner +rm -rf ./data/runner/* +docker compose up -d --force-recreate runner +``` + +If you still need to register manually: + +```bash +docker exec -it gitea_runner sh +``` + +Inside the container: + +```bash +act_runner register --instance http://server:3000 --token PASTE_TOKEN_FROM_STEP_5.3 +``` + +Then exit and restart the runner container so it runs the daemon: + +```bash +exit +docker restart gitea_runner +``` + +**5.5 — Check that the runner has the right label** + +**Where:** Gitea in the browser. + +1. Go to **honey-be** → **Settings** → **Actions** → **Runners**. +2. You should see at least one runner with: + - **Status:** Idle (or Running when a job is active) + - **Labels:** must include **ubuntu-latest**, because the workflow file has `runs-on: ubuntu-latest`. + +If your runner has a different label (e.g. `linux`), you have two options: + +- **Option A:** In Gitea when registering the runner, add the label **ubuntu-latest** (if the UI lets you choose labels). +- **Option B:** Edit `.gitea/workflows/deploy-vps.yaml` and change the line `runs-on: ubuntu-latest` to your runner’s label (e.g. `runs-on: linux`), then commit and push. + +**5.6 — Summary** + +- Actions enabled in Gitea. +- Runner container running on the Gitea VPS. +- Runner registered and visible under **Settings** → **Actions** → **Runners** with label **ubuntu-latest** (or workflow updated to match your label). + +--- + +## 6. Test the deployment + +Do this **after** the workflow file is on `main` (Step 4) and the runner is set up (Step 5). + +**6.1 — Trigger a run** + +Push a commit to `main` (e.g. a small change or the workflow/docs you added): + +```bash +cd /path/to/your/honey-be +git add .gitea/workflows/deploy-vps.yaml docs/GITEA_VPS_DEPLOY.md +git commit -m "Add VPS deploy workflow and guide" +git push origin main +``` + +**6.2 — Check the run in Gitea** + +**Where:** Gitea in the browser. + +1. Open **honey-be** → **Actions** (tab in the repo). +2. You should see a run for the “Deploy to VPS” workflow. Open it. +3. Confirm all steps are green. If something fails, open the failed step to see the log. + +**6.3 — Check the Staged VPS** + +**Where:** Staged VPS (188.116.23.7) or your Mac (SSH to VPS). + +1. SSH in: `ssh root@188.116.23.7` +2. Check that code was updated: `ls -la /opt/app/backend/honey-be` +3. Check that the app restarted: `docker ps` (or your usual way to verify the backend is running). + +--- + +## Troubleshooting + +### Runner stays "Offline" / "Last Online Time: Never" / "Cannot ping the Gitea instance server" + +- **Cause:** The runner container is trying to reach Gitea at `http://127.0.0.1:3000`. From inside the runner container, `127.0.0.1` is the container itself, not the Gitea server, so the connection is refused. +- **Fix:** + 1. In your **docker-compose** on the Gitea VPS, set `GITEA_INSTANCE_URL=http://server:3000` (use the Docker service name of the Gitea container, e.g. `server`). + 2. Clear the runner's persisted state and recreate the container so it re-registers with the new URL: + ```bash + cd ~/gitea + docker compose stop runner + rm -rf ./data/runner/* + docker compose up -d --force-recreate runner + ``` + 3. In Gitea → **Settings** → **Actions** → **Runners**, the runner should show **Idle** within a few seconds. If you see a new runner and the old one still "Offline", you can remove the old one in the UI. + +--- + +### Checkout fails: "Could not resolve host: server" / "fatal: unable to access 'http://server:3000/...'" + +- **Cause:** The workflow job runs inside a **separate** Docker container (started by the runner). That job container is not on the same Docker network as Gitea, so the hostname `server` does not resolve there. Changing Gitea's `LOCAL_ROOT_URL` or the host's `/etc/hosts` does not help, because the job container has its own network. +- **Fix:** The deploy workflow in this repo already uses a **manual checkout** that clones via the runner host's IP instead of `server`. If checkout still fails: + 1. Add repository secret **GITEA_HOST_IP** with the default gateway as seen from the job container. To get it: run a one-off debug workflow that runs `ip route show default | awk '{print $3}'` and use the printed value (often `172.20.0.1` or `172.17.0.1`). + 2. If you don't set the secret, the workflow defaults to `172.20.0.1`; if your Docker uses a different gateway, set **GITEA_HOST_IP** to that value. + +--- + +### Setup SSH fails: "Error loading key ... invalid format" + +- **Cause:** The **DEPLOY_SSH_PRIVATE_KEY** secret in Gitea is not valid: pasted with wrong line breaks, truncated, or the **public** key (`.pub`) was pasted by mistake. +- **Fix:** + 1. On your Mac, verify the private key file: `ssh-keygen -y -f ~/.ssh/gitea_deploy_honey_be`. If that fails, generate a new key (Step 1) and add the new public key to the VPS and the new private key to Gitea. + 2. Copy the **full** private key again: `cat ~/.ssh/gitea_deploy_honey_be | pbcopy` (or open the file and copy everything). + 3. In Gitea → **Settings** → **Secrets and Variables** → **Actions**, edit **DEPLOY_SSH_PRIVATE_KEY** and paste the entire key. It must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`, with no extra blank lines or spaces at the start/end. Make sure you are pasting the **private** key file, not the `.pub` file. + 4. Save and re-run the workflow. + +--- + +### Other issues + +- **Permission denied (publickey)** (during rsync or SSH to Staged VPS) + Check: public key in `authorized_keys` on the Staged VPS, private key in `DEPLOY_SSH_PRIVATE_KEY`, no extra spaces/newlines when pasting. From your Mac, test: `ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK"`. + +- **Runner doesn’t pick up the job** + In Gitea: **Settings** → **Actions** → **Runners**. Confirm runner is “idle” and has label `ubuntu-latest`. + +- **rsync or SSH fails from the job** + The job runs on the Gitea VPS (in a container). Ensure the Gitea VPS can reach the Staged VPS (188.116.23.7) on port 22. From the Gitea VPS host, test: `ssh -i /path/to/private_key root@188.116.23.7 "echo OK"`. + +- **sudo or script fails on VPS** + SSH in as the deploy user and run manually: + `cd /opt/app/backend/honey-be && sudo ./scripts/rolling-update.staged.sh` + Fix permissions or sudo rules as needed. diff --git a/honey-config.properties.template b/honey-config.properties.template new file mode 100644 index 0000000..0478e79 --- /dev/null +++ b/honey-config.properties.template @@ -0,0 +1,62 @@ +# Honey Application Configuration +# Copy this file to /run/secrets/honey-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: 'honey_db' (must match MYSQL_DATABASE in docker-compose.prod.yml) +# +# Example: jdbc:mysql://db:3306/honey_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/honey_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-testforapp-test-base.conf b/nginx-testforapp-test-base.conf new file mode 100644 index 0000000..95eb4e7 --- /dev/null +++ b/nginx-testforapp-test-base.conf @@ -0,0 +1,242 @@ +# Two frontends on the same backend: +# - Frontend 1: /opt/app/frontend/dist -> https://testforapp.website/ (build with base: '/') +# - Frontend 2: /opt/app/frontend/test-dist -> https://testforapp.website/test/ (build with base: '/test/') + +upstream backend { + server 127.0.0.1:8082; + server 127.0.0.1:8080 backup; + keepalive 500; +} + +map $http_referer $is_phpmyadmin_request { + ~*18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 1; + default 0; +} + +# HTTPS server +server { + + server_name testforapp.website; + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4 valid=300s; + resolver_timeout 5s; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()"; + + root /opt/app/frontend/dist; + index index.html; + +# --- Frontend 2 at /test/ (from test-dist) --- + location = /test { + return 301 /test/; + } + location ^~ /test/ { + alias /opt/app/frontend/test-dist/; + index index.html; + try_files $uri $uri/ /test/index.html; + expires 0; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()"; + } + +# Admin Panel - root path (redirects to trailing slash) +location = /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa { + return 301 /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/; +} + +location ~ ^/dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/(assets/.+)$ { + alias /opt/app/admin-panel/$1; + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; +} + +location /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/ { + alias /opt/app/admin-panel/; + index index.html; + try_files $uri $uri/ /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/index.html; + expires 0; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} + +location /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 { + proxy_pass http://127.0.0.1:8081/; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + gzip off; + proxy_set_header Accept-Encoding ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 16 8k; + proxy_busy_buffers_size 16k; + proxy_cookie_path / /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/; + sub_filter 'action="index.php?route=/' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php?route=/'; + sub_filter 'action="index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter 'action="/index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter 'Location: /index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter 'Location: index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter 'href="index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter 'href="/index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php'; + sub_filter_once off; + sub_filter_types text/html; +} + +location ~ ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) { + rewrite ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering off; +} + +location ~ ^/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) { + if ($is_phpmyadmin_request = 0) { + return 404; + } + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering off; +} + + location /videos/ { + root /usr/share/nginx/html; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Access-Control-Allow-Origin "*"; + } + + location ^~ /images/ { + root /usr/share/nginx/html; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Access-Control-Allow-Origin "*"; + autoindex off; + } + + # Frontend 1: static assets at root (from dist) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Frontend 1: SPA at / (from dist) + location / { + try_files $uri $uri/ /index.html; + expires 0; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()"; + } + + location ^~ /avatars/ { + alias /opt/app/data/avatars/; + expires 24h; + add_header Cache-Control "public, must-revalidate"; + access_log off; + location ~ \.(php|html)$ { + deny all; + } + } + + location /api/telegram/webhook/ { + access_log off; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + 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_read_timeout 30s; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + location /api/ { + limit_req zone=api_limit burst=200 nodelay; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location = /phpmyadmin { + return 404; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} + +server { + if ($host = testforapp.website) { + return 301 https://$host$request_uri; + } + listen 80; + listen [::]:80; + server_name testforapp.website; + return 404; +} diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 0000000..1bc31e5 --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,128 @@ +# Nginx configuration for Honey 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/honey.conf new file mode 100644 index 0000000..383731d --- /dev/null +++ b/nginx/conf.d/honey.conf @@ -0,0 +1,84 @@ +upstream honey_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 { + listen 80; + server_name _; + + # Increase body size limit for API requests + client_max_body_size 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; + + # API endpoints + location /api/ { + proxy_pass http://honey_backend; + 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 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Actuator endpoints (for health checks) + location /actuator/ { + proxy_pass http://honey_backend; + proxy_http_version 1.1; + 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; + } + + # Ping endpoint + location /ping { + proxy_pass http://honey_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # Frontend (if serving static files) + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + index index.html; + } +} + +# HTTPS configuration (uncomment and configure when SSL certificates are available) +# server { +# listen 443 ssl http2; +# server_name your-domain.com; +# +# ssl_certificate /etc/nginx/ssl/cert.pem; +# ssl_certificate_key /etc/nginx/ssl/key.pem; +# +# # SSL configuration +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers HIGH:!aNULL:!MD5; +# ssl_prefer_server_ciphers on; +# +# # Same location blocks as HTTP server +# # ... +# } + + diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..b380f0d --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,35 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml; + + include /etc/nginx/conf.d/*.conf; +} + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a6abc22 --- /dev/null +++ b/pom.xml @@ -0,0 +1,141 @@ + + + 4.0.0 + + com.honey + honey-be + 1.0.0 + jar + + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + 17 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + com.maxmind.geoip2 + geoip2 + 4.2.0 + + + + + org.telegram + telegrambots + 6.9.0 + + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + + + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/scripts/backup-database.sh b/scripts/backup-database.sh new file mode 100644 index 0000000..604636c --- /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/honey-config.properties +# 3. Docker container 'honey-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="honey-mysql" +MYSQL_DATABASE="lottery_db" +SECRET_FILE="/run/secrets/honey-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..51c1287 --- /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:-honey-config.properties.template}" +OUTPUT_FILE="${2:-/run/secrets/honey-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 new file mode 100644 index 0000000..aaa6167 --- /dev/null +++ b/scripts/create-secret-file.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# 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_DIR="/run/secrets" + +# Create directory if it doesn't exist +mkdir -p "$SECRET_DIR" + +# Create properties file from environment variables +cat > "$SECRET_FILE" << EOF +# Configuration loaded from secret file (created from env vars for testing) +SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} +SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} +SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} +TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} +FRONTEND_URL=${FRONTEND_URL} +PORT=${PORT} +EOF + +# Set permissions (readable by the application user) +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..71c5966 --- /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/honey-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/honey-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/fix-nginx-redirect-loop.md b/scripts/fix-nginx-redirect-loop.md new file mode 100644 index 0000000..6c4ea9d --- /dev/null +++ b/scripts/fix-nginx-redirect-loop.md @@ -0,0 +1,77 @@ +# Fix 301 redirect loop (Certbot duplicate 443 block) + +## Cause + +Certbot added a **second** HTTPS server block that only has: +- `location / { return 301 https://$host$request_uri; }` +- `listen 443 ssl` + SSL cert paths + +That block is matched first for `https://testforapp.website/`, so every request gets 301 → same URL → loop. Your real HTTPS block (frontend, API, phpMyAdmin) is never used for `/`. + +## Fix on VPS + +1. **Open the site config** + ```bash + sudo nano /etc/nginx/sites-enabled/testforapp.website + ``` + +2. **Find and remove the Certbot-only HTTPS block** + + Look for a block that looks like this (it may be at the **top** of the file, before the `map` and your big HTTPS server): + + ```nginx + server { + server_name testforapp.website; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + } + ``` + + **Delete this entire `server { ... }` block** (from `server {` through the closing `}`). + +3. **Ensure your main HTTPS block has SSL and listen** + + Find your main HTTPS server (the one with `# HTTPS server`, `root /opt/app/frontend/dist`, all the `location` blocks). It must have at the top of that block (right after `server {`): + + - `listen 443 ssl;` and `listen [::]:443 ssl;` + - `ssl_certificate` and `ssl_certificate_key` (and optionally `include /etc/letsencrypt/options-ssl-nginx.conf;` and `ssl_dhparam`) + + If those lines are missing, add them (copy from the block you deleted): + + ```nginx + server { + listen 443 ssl; + listen [::]:443 ssl; + server_name testforapp.website; + ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # ... rest of your config (root, locations, etc.) + } + ``` + +4. **Test and reload** + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +5. **Verify** + ```bash + curl -I -k https://127.0.0.1/ -H "Host: testforapp.website" + ``` + You should see `200 OK` (or `304`) and no `Location` header, and https://testforapp.website/ should load in the browser. diff --git a/scripts/load-db-password.sh b/scripts/load-db-password.sh new file mode 100644 index 0000000..715f8cc --- /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/honey-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..675286f --- /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/honey-config.properties +# 2. Docker container 'honey-mysql' running +# 3. Database will be DROPPED and RECREATED (all data will be lost!) + +set -euo pipefail + +# Configuration +MYSQL_CONTAINER="honey-mysql" +MYSQL_DATABASE="lottery_db" +SECRET_FILE="/run/secrets/honey-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 honey-backend" + diff --git a/scripts/rolling-update.sh b/scripts/rolling-update.sh new file mode 100644 index 0000000..821080e --- /dev/null +++ b/scripts/rolling-update.sh @@ -0,0 +1,629 @@ +#!/bin/bash +# Rolling Update Deployment Script (production) +# Uses docker-compose.prod.yml. For staged (8GB VPS) use: scripts/rolling-update.staged.sh +# 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/testforapp.website" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then + NGINX_CONF="/etc/nginx/conf.d/honey.conf" + log "Using Nginx config: $NGINX_CONF (conf.d)" + elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then + warn "Found config at /opt/app/nginx/testforapp.website.conf" + warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..." + if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then + # Find the actual target + local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.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/testforapp.website.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/testforapp.website.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/testforapp.website" + error " - /etc/nginx/sites-enabled/testforapp.website.conf" + error " - /etc/nginx/conf.d/honey.conf" + error " - /opt/app/nginx/testforapp.website.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="honey-backend-new" + STANDBY_CONTAINER="honey-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="honey-backend" + STANDBY_CONTAINER="honey-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="honey-backend-new" + else + SERVICE_NAME="backend" + CONTAINER_NAME="honey-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="honey-backend-new" + else + local container_name="honey-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" = "honey-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" = "honey-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 'honey-backend-new' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs honey-backend-new" + warn " docker logs --tail 100 honey-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 'honey-backend' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs honey-backend" + warn " docker logs --tail 100 honey-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" = "honey-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" = "honey-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/rolling-update.staged.sh b/scripts/rolling-update.staged.sh new file mode 100644 index 0000000..a6f8949 --- /dev/null +++ b/scripts/rolling-update.staged.sh @@ -0,0 +1,622 @@ +#!/bin/bash +# Rolling Update Deployment Script (staged / 8GB VPS) +# Same as rolling-update.sh but uses docker-compose.staged.yml (lower memory limits). +# 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 (staged: use docker-compose.staged.yml) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${PROJECT_DIR}/docker-compose.staged.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/testforapp.website" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then + NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf" + log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)" + elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then + NGINX_CONF="/etc/nginx/conf.d/honey.conf" + log "Using Nginx config: $NGINX_CONF (conf.d)" + elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then + warn "Found config at /opt/app/nginx/testforapp.website.conf" + warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..." + if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then + # Find the actual target + local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.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/testforapp.website.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/testforapp.website.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/testforapp.website" + error " - /etc/nginx/sites-enabled/testforapp.website.conf" + error " - /etc/nginx/conf.d/honey.conf" + error " - /opt/app/nginx/testforapp.website.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="honey-backend-new" + STANDBY_CONTAINER="honey-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="honey-backend" + STANDBY_CONTAINER="honey-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" + log "Using compose file: $COMPOSE_FILE (staged)" + + # 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="honey-backend-new" + else + SERVICE_NAME="backend" + CONTAINER_NAME="honey-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="honey-backend-new" + else + local container_name="honey-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" = "honey-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 +} + +# 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 'honey-backend-new' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs honey-backend-new" + warn " docker logs --tail 100 honey-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 'honey-backend' is STOPPED but NOT REMOVED" + warn "" + warn "To check logs:" + warn " docker logs honey-backend" + warn " docker logs --tail 100 honey-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" = "honey-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 (staged)..." + + # 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" = "honey-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..f0b3ebd --- /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/honey-be" ]; then + CONFIG_DIR="/opt/app/backend/honey-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/honey-be" "$(dirname "$CONFIG_DIR")" "$(dirname "$(dirname "$CONFIG_DIR")")"; do + if [ -d "$search_path" ]; then + found_jar=$(find "$search_path" -name "honey-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/honey-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/honey-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/honey-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/HoneyBackendApplication.java b/src/main/java/com/honey/honey/HoneyBackendApplication.java new file mode 100644 index 0000000..9155b1a --- /dev/null +++ b/src/main/java/com/honey/honey/HoneyBackendApplication.java @@ -0,0 +1,22 @@ +package com.honey.honey; + +import com.honey.honey.config.ConfigLoader; +import com.honey.honey.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 static void main(String[] args) { + SpringApplication app = new SpringApplication(HoneyBackendApplication.class); + app.addListeners(new ConfigLoader()); + app.run(args); + } +} + diff --git a/src/main/java/com/honey/honey/config/AdminSecurityConfig.java b/src/main/java/com/honey/honey/config/AdminSecurityConfig.java new file mode 100644 index 0000000..c84fe8d --- /dev/null +++ b/src/main/java/com/honey/honey/config/AdminSecurityConfig.java @@ -0,0 +1,138 @@ +package com.honey.honey.config; + +import com.honey.honey.security.admin.AdminDetailsService; +import com.honey.honey.security.admin.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +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.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.beans.factory.annotation.Value; +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; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class AdminSecurityConfig { + + @Value("${FRONTEND_URL:}") + private String frontendUrl; + + 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()); + } + + /** + * Swagger/OpenAPI docs: permitAll with highest precedence so the default Spring Boot chain + * (which requires auth for /**) never handles these paths. Includes webjars and resources + * so the UI can load CSS/JS. + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception { + RequestMatcher swaggerMatcher = new OrRequestMatcher( + new AntPathRequestMatcher("/swagger-ui/**"), + new AntPathRequestMatcher("/swagger-ui.html"), + new AntPathRequestMatcher("/v3/api-docs"), + new AntPathRequestMatcher("/v3/api-docs/**"), + new AntPathRequestMatcher("/webjars/**"), + new AntPathRequestMatcher("/swagger-resources/**"), + new AntPathRequestMatcher("/configuration/**") + ); + http + .securityMatcher(swaggerMatcher) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + return http.build(); + } + + @Bean + @Order(2) + 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() { + List allowedOrigins = Stream.concat( + Stream.of( + "http://localhost:5173", + "http://localhost:3000" + ), + frontendUrl != null && !frontendUrl.isBlank() + ? Arrays.stream(frontendUrl.split("\\s*,\\s*")).filter(s -> !s.isBlank()) + : Stream.empty() + ).distinct().collect(Collectors.toList()); + + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(allowedOrigins); + 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/honey/honey/config/ConfigLoader.java new file mode 100644 index 0000000..76b6bbb --- /dev/null +++ b/src/main/java/com/honey/honey/config/ConfigLoader.java @@ -0,0 +1,82 @@ +package com.honey.honey.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * Loads configuration from a mounted secret file (tmpfs) with fallback to environment variables. + * This allows switching between Railway (env vars) and Inferno (mounted file) deployments. + * + * Priority: + * 1. Mounted file at /run/secrets/honey-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"; + + @Override + public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { + ConfigurableEnvironment environment = event.getEnvironment(); + MutablePropertySources propertySources = environment.getPropertySources(); + + Map configProperties = new HashMap<>(); + + // Try to load from mounted file first (Inferno deployment) + File secretFile = new File(SECRET_FILE_PATH); + if (secretFile.exists() && secretFile.isFile() && secretFile.canRead()) { + log.info("📁 Loading configuration from mounted secret file: {}", SECRET_FILE_PATH); + try { + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(secretFile)) { + props.load(fis); + } + + for (String key : props.stringPropertyNames()) { + String value = props.getProperty(key); + configProperties.put(key, value); + log.debug("Loaded from file: {} = {}", key, maskSensitiveValue(key, value)); + } + log.info("✅ Successfully loaded {} properties from secret file", configProperties.size()); + } catch (IOException e) { + log.warn("⚠️ Failed to read secret file, falling back to environment variables: {}", e.getMessage()); + } + } else { + log.info("📝 Secret file not found at {}, using environment variables", SECRET_FILE_PATH); + } + + // Environment variables are already loaded by Spring Boot by default + // We just add file-based config as a higher priority source if it exists + if (!configProperties.isEmpty()) { + propertySources.addFirst(new MapPropertySource("secretFileConfig", configProperties)); + log.info("✅ Configuration loaded: {} properties from file, environment variables as fallback", + configProperties.size()); + } else { + log.info("✅ Using environment variables for configuration"); + } + } + + private String maskSensitiveValue(String key, String value) { + if (value == null) return "null"; + if (key.toLowerCase().contains("password") || + key.toLowerCase().contains("token") || + key.toLowerCase().contains("secret") || + key.toLowerCase().contains("key")) { + return value.length() > 4 ? value.substring(0, 2) + "***" + value.substring(value.length() - 2) : "***"; + } + return value; + } +} + diff --git a/src/main/java/com/honey/honey/config/CorsConfig.java b/src/main/java/com/honey/honey/config/CorsConfig.java new file mode 100644 index 0000000..29ec490 --- /dev/null +++ b/src/main/java/com/honey/honey/config/CorsConfig.java @@ -0,0 +1,53 @@ +package com.honey.honey.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Configuration +public class CorsConfig { + + @Value("${FRONTEND_URL:}") + private String frontendUrl; + + private static final List ADDITIONAL_ORIGINS = Arrays.asList( + "https://honey-test-fe-production.up.railway.app", + "https://web.telegram.org", + "https://webk.telegram.org", + "https://t.me" + ); + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + List origins = Stream.concat( + Arrays.stream(frontendUrl != null && !frontendUrl.isBlank() + ? frontendUrl.split("\\s*,\\s*") + : new String[]{}), + ADDITIONAL_ORIGINS.stream() + ).filter(s -> s != null && !s.isBlank()).distinct().collect(Collectors.toList()); + + if (origins.isEmpty()) { + origins = ADDITIONAL_ORIGINS; + } + + registry.addMapping("/**") + .allowedOrigins(origins.toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + }; + } +} + diff --git a/src/main/java/com/honey/honey/config/LocaleConfig.java b/src/main/java/com/honey/honey/config/LocaleConfig.java new file mode 100644 index 0000000..91924aa --- /dev/null +++ b/src/main/java/com/honey/honey/config/LocaleConfig.java @@ -0,0 +1,61 @@ +package com.honey.honey.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/honey/honey/config/OpenApiConfig.java b/src/main/java/com/honey/honey/config/OpenApiConfig.java new file mode 100644 index 0000000..aa51c08 --- /dev/null +++ b/src/main/java/com/honey/honey/config/OpenApiConfig.java @@ -0,0 +1,33 @@ +package com.honey.honey.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springdoc.core.models.GroupedOpenApi; + +/** + * OpenAPI / Swagger configuration for the public API only. + * Admin endpoints (/api/admin/**) are excluded from the documentation. + */ +@Configuration +public class OpenApiConfig { + + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder() + .group("public") + .pathsToMatch("/**") + .pathsToExclude("/api/admin/**") + .build(); + } + + @Bean + public OpenAPI honeyOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Honey Public API") + .description("API for the Honey frontend. Admin panel endpoints are not included.") + .version("1.0")); + } +} diff --git a/src/main/java/com/honey/honey/config/TelegramProperties.java b/src/main/java/com/honey/honey/config/TelegramProperties.java new file mode 100644 index 0000000..f26a0d6 --- /dev/null +++ b/src/main/java/com/honey/honey/config/TelegramProperties.java @@ -0,0 +1,35 @@ +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; + + /** + * Bot token for checking channel membership. + * Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN + * or in mounted file at /run/secrets/honey-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/honey-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/honey-config.properties as telegram.follow-task-channel-id-2 + */ + private String followTaskChannelId2; +} + + diff --git a/src/main/java/com/honey/honey/config/WebConfig.java b/src/main/java/com/honey/honey/config/WebConfig.java new file mode 100644 index 0000000..c8b32c2 --- /dev/null +++ b/src/main/java/com/honey/honey/config/WebConfig.java @@ -0,0 +1,51 @@ +package com.honey.honey.config; + +import com.honey.honey.security.AuthInterceptor; +import com.honey.honey.security.RateLimitInterceptor; +import com.honey.honey.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/admin/**", // Admin endpoints are handled by Spring Security + // Swagger / OpenAPI docs (no auth required for documentation) + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs", + "/v3/api-docs/**", + "/webjars/**", + "/swagger-resources/**", + "/configuration/**" + ); + + // 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/honey/honey/controller/AdminAnalyticsController.java b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java new file mode 100644 index 0000000..c64be77 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminAnalyticsController.java @@ -0,0 +1,201 @@ +package com.honey.honey.controller; + +import com.honey.honey.model.Payment; +import com.honey.honey.model.Payout; +import com.honey.honey.repository.PaymentRepository; +import com.honey.honey.repository.PayoutRepository; +import com.honey.honey.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; + + /** + * 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); + + Map point = new HashMap<>(); + point.put("date", current.getEpochSecond()); + point.put("newUsers", newUsers); + point.put("activePlayers", activePlayers); + point.put("rounds", 0L); + + 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/honey/honey/controller/AdminDashboardController.java b/src/main/java/com/honey/honey/controller/AdminDashboardController.java new file mode 100644 index 0000000..2e39814 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminDashboardController.java @@ -0,0 +1,181 @@ +package com.honey.honey.controller; + +import com.honey.honey.model.Payment; +import com.honey.honey.model.Payout; +import com.honey.honey.model.SupportTicket; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.PaymentRepository; +import com.honey.honey.repository.PayoutRepository; +import com.honey.honey.repository.SupportTicketRepository; +import com.honey.honey.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 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); + + // 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", 0L, + "today", 0L, + "week", 0L, + "month", 0L, + "avgPool", 0 + )); + + stats.put("supportTickets", Map.of( + "open", openTickets, + "resolvedToday", ticketsResolvedToday + )); + + return ResponseEntity.ok(stats); + } +} + diff --git a/src/main/java/com/honey/honey/controller/AdminFeatureSwitchController.java b/src/main/java/com/honey/honey/controller/AdminFeatureSwitchController.java new file mode 100644 index 0000000..0deb348 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminFeatureSwitchController.java @@ -0,0 +1,36 @@ +package com.honey.honey.controller; + +import com.honey.honey.service.FeatureSwitchService; +import com.honey.honey.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/honey/honey/controller/AdminLoginController.java b/src/main/java/com/honey/honey/controller/AdminLoginController.java new file mode 100644 index 0000000..61f5ece --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminLoginController.java @@ -0,0 +1,51 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.AdminLoginRequest; +import com.honey.honey.dto.AdminLoginResponse; +import com.honey.honey.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/honey/honey/controller/AdminMasterController.java b/src/main/java/com/honey/honey/controller/AdminMasterController.java new file mode 100644 index 0000000..8c84cbe --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminMasterController.java @@ -0,0 +1,26 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.AdminMasterDto; +import com.honey.honey.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/honey/honey/controller/AdminNotificationController.java b/src/main/java/com/honey/honey/controller/AdminNotificationController.java new file mode 100644 index 0000000..8210ed5 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminNotificationController.java @@ -0,0 +1,42 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.NotifyBroadcastRequest; +import com.honey.honey.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/honey/honey/controller/AdminPaymentController.java b/src/main/java/com/honey/honey/controller/AdminPaymentController.java new file mode 100644 index 0000000..2228b0f --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminPaymentController.java @@ -0,0 +1,147 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.AdminPaymentDto; +import com.honey.honey.model.Payment; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.PaymentRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.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/honey/honey/controller/AdminPayoutController.java b/src/main/java/com/honey/honey/controller/AdminPayoutController.java new file mode 100644 index 0000000..6ce647e --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminPayoutController.java @@ -0,0 +1,204 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.AdminPayoutDto; +import com.honey.honey.model.Payout; +import com.honey.honey.model.UserA; +import com.honey.honey.model.UserB; +import com.honey.honey.repository.PayoutRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.repository.UserBRepository; +import com.honey.honey.repository.UserDRepository; +import com.honey.honey.service.LocalizationService; +import com.honey.honey.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/honey/honey/controller/AdminPromotionController.java b/src/main/java/com/honey/honey/controller/AdminPromotionController.java new file mode 100644 index 0000000..827c76c --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminPromotionController.java @@ -0,0 +1,139 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.*; +import com.honey.honey.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/honey/honey/controller/AdminSupportTicketController.java b/src/main/java/com/honey/honey/controller/AdminSupportTicketController.java new file mode 100644 index 0000000..f8115d1 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminSupportTicketController.java @@ -0,0 +1,316 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.*; +import com.honey.honey.model.Admin; +import com.honey.honey.model.SupportMessage; +import com.honey.honey.model.SupportTicket; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.AdminRepository; +import com.honey.honey.repository.SupportMessageRepository; +import com.honey.honey.repository.SupportTicketRepository; +import com.honey.honey.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/honey/honey/controller/AdminUserController.java b/src/main/java/com/honey/honey/controller/AdminUserController.java new file mode 100644 index 0000000..3a80f1a --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AdminUserController.java @@ -0,0 +1,250 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.*; +import com.honey.honey.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", "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 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, referralCount) are handled in service via custom query. + Set sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "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, + 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}/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.honey.honey.dto.BalanceAdjustmentRequest request) { + try { + com.honey.honey.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/honey/honey/controller/AuthController.java new file mode 100644 index 0000000..c47ce85 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/AuthController.java @@ -0,0 +1,99 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.CreateSessionRequest; +import com.honey.honey.dto.CreateSessionResponse; +import com.honey.honey.exception.BannedUserException; +import com.honey.honey.model.UserA; +import com.honey.honey.service.LocalizationService; +import com.honey.honey.service.SessionService; +import com.honey.honey.service.TelegramAuthService; +import com.honey.honey.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 +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +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. + * This is the only endpoint that accepts initData. + * Handles user registration/login and referral system. + */ + @PostMapping("/tma/session") + public CreateSessionResponse createSession( + @RequestBody CreateSessionRequest request, + HttpServletRequest httpRequest) { + String initData = request.getInitData(); + + if (initData == null || initData.isBlank()) { + 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.debug("Session created: userId={}", user.getId()); + + return CreateSessionResponse.builder() + .access_token(sessionId) + .expires_in(sessionService.getSessionTtlSeconds()) + .build(); + } + + /** + * Logs out by invalidating the session. + * This endpoint requires authentication (Bearer token). + */ + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(@RequestHeader(value = "Authorization", required = false) String authHeader) { + if (authHeader == null) { + log.warn("Logout called without Authorization header"); + return; + } + + String sessionId = extractBearerToken(authHeader); + if (sessionId != null) { + sessionService.invalidateSession(sessionId); + log.debug("Session invalidated via logout"); + } + } + + /** + * Extracts Bearer token from Authorization header. + */ + private String extractBearerToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } +} + diff --git a/src/main/java/com/honey/honey/controller/DepositWebhookController.java b/src/main/java/com/honey/honey/controller/DepositWebhookController.java new file mode 100644 index 0000000..da0513a --- /dev/null +++ b/src/main/java/com/honey/honey/controller/DepositWebhookController.java @@ -0,0 +1,56 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.ExternalDepositWebhookRequest; +import com.honey.honey.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/honey/honey/controller/NotifyBroadcastController.java b/src/main/java/com/honey/honey/controller/NotifyBroadcastController.java new file mode 100644 index 0000000..4ac6785 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/NotifyBroadcastController.java @@ -0,0 +1,56 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.NotifyBroadcastRequest; +import com.honey.honey.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/honey/honey/controller/PaymentController.java b/src/main/java/com/honey/honey/controller/PaymentController.java new file mode 100644 index 0000000..36513e6 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/PaymentController.java @@ -0,0 +1,237 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.CryptoWithdrawalResponse; +import com.honey.honey.dto.CreateCryptoWithdrawalRequest; +import com.honey.honey.dto.CreatePaymentRequest; +import com.honey.honey.dto.DepositAddressRequest; +import com.honey.honey.dto.DepositAddressResultDto; +import com.honey.honey.dto.DepositMethodsDto; +import com.honey.honey.dto.ErrorResponse; +import com.honey.honey.dto.PaymentInvoiceResponse; +import com.honey.honey.model.Payout; +import com.honey.honey.model.UserA; +import com.honey.honey.security.UserContext; +import com.honey.honey.dto.WithdrawalMethodDetailsDto; +import com.honey.honey.dto.WithdrawalMethodsDto; +import com.honey.honey.service.CryptoDepositService; +import com.honey.honey.service.CryptoWithdrawalService; +import com.honey.honey.service.FeatureSwitchService; +import com.honey.honey.service.LocalizationService; +import com.honey.honey.service.PaymentService; +import com.honey.honey.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/honey/honey/controller/PayoutController.java b/src/main/java/com/honey/honey/controller/PayoutController.java new file mode 100644 index 0000000..43cd684 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/PayoutController.java @@ -0,0 +1,66 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.CreatePayoutRequest; +import com.honey.honey.dto.ErrorResponse; +import com.honey.honey.dto.PayoutHistoryEntryDto; +import com.honey.honey.dto.PayoutResponse; +import com.honey.honey.model.Payout; +import com.honey.honey.model.UserA; +import com.honey.honey.security.UserContext; +import com.honey.honey.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/honey/honey/controller/PingController.java new file mode 100644 index 0000000..7e0aa0d --- /dev/null +++ b/src/main/java/com/honey/honey/controller/PingController.java @@ -0,0 +1,20 @@ +package com.honey.honey.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class PingController { + + @GetMapping("/ping") + public Map ping() { + Map response = new HashMap<>(); + response.put("status", "ok"); + return response; + } +} + + diff --git a/src/main/java/com/honey/honey/controller/PromotionController.java b/src/main/java/com/honey/honey/controller/PromotionController.java new file mode 100644 index 0000000..7fada10 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/PromotionController.java @@ -0,0 +1,43 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.PromotionDetailDto; +import com.honey.honey.dto.PromotionListItemDto; +import com.honey.honey.service.FeatureSwitchService; +import com.honey.honey.service.PublicPromotionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Public API: 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/honey/honey/controller/QuickAnswerController.java b/src/main/java/com/honey/honey/controller/QuickAnswerController.java new file mode 100644 index 0000000..6809abe --- /dev/null +++ b/src/main/java/com/honey/honey/controller/QuickAnswerController.java @@ -0,0 +1,134 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.QuickAnswerCreateRequest; +import com.honey.honey.dto.QuickAnswerDto; +import com.honey.honey.model.Admin; +import com.honey.honey.model.QuickAnswer; +import com.honey.honey.repository.AdminRepository; +import com.honey.honey.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/honey/honey/controller/SupportController.java b/src/main/java/com/honey/honey/controller/SupportController.java new file mode 100644 index 0000000..c1b3472 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/SupportController.java @@ -0,0 +1,104 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.*; +import com.honey.honey.model.UserA; +import com.honey.honey.security.UserContext; +import com.honey.honey.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/honey/honey/controller/TaskController.java b/src/main/java/com/honey/honey/controller/TaskController.java new file mode 100644 index 0000000..d4adc2e --- /dev/null +++ b/src/main/java/com/honey/honey/controller/TaskController.java @@ -0,0 +1,100 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.ClaimTaskResponse; +import com.honey.honey.dto.DailyBonusStatusDto; +import com.honey.honey.dto.RecentBonusClaimDto; +import com.honey.honey.dto.TaskDto; +import com.honey.honey.model.UserA; +import com.honey.honey.security.UserContext; +import com.honey.honey.service.LocalizationService; +import com.honey.honey.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/honey/honey/controller/TelegramWebhookController.java b/src/main/java/com/honey/honey/controller/TelegramWebhookController.java new file mode 100644 index 0000000..9885b26 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/TelegramWebhookController.java @@ -0,0 +1,865 @@ +package com.honey.honey.controller; + +import com.honey.honey.config.TelegramProperties; +import com.honey.honey.dto.TelegramApiResponse; +import com.honey.honey.dto.PaymentWebhookRequest; +import com.honey.honey.model.UserA; +import com.honey.honey.service.FeatureSwitchService; +import com.honey.honey.service.PaymentService; +import com.honey.honey.service.TelegramBotApiService; +import com.honey.honey.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.honey.honey.service.LocalizationService; +import com.honey.honey.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 { + + private static final String MINI_APP_URL = "https://testforapp.website/test/auth"; + + @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 FeatureSwitchService featureSwitchService; + 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); + String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage"); + sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard); + + String welcomeText = localizationService.getMessage(locale, "bot.welcome.message"); + if (featureSwitchService.isStartGameButtonEnabled()) { + InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup(); + List> inlineRows = new ArrayList<>(); + List inlineRow = new ArrayList<>(); + InlineKeyboardButton startInlineButton = new InlineKeyboardButton(); + String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline"); + startInlineButton.setText(startSpinningButtonText); + WebAppInfo webAppInfo = new WebAppInfo(); + webAppInfo.setUrl(MINI_APP_URL); + startInlineButton.setWebApp(webAppInfo); + inlineRow.add(startInlineButton); + inlineRows.add(inlineRow); + inlineKeyboard.setKeyboard(inlineRows); + sendMessage(chatId, welcomeText, inlineKeyboard); + } else { + sendMessage(chatId, welcomeText, null); + } + } + + /** + * Sends message with Start Spinning button. + */ + private void sendStartSpinningMessage(Long chatId, Locale locale) { + String message = localizationService.getMessage(locale, "bot.message.startSpinning"); + if (!featureSwitchService.isStartGameButtonEnabled()) { + sendMessage(chatId, message, null); + return; + } + InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup(); + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + InlineKeyboardButton button = new InlineKeyboardButton(); + String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline"); + button.setText(startSpinningButtonText); + WebAppInfo webAppInfo = new WebAppInfo(); + webAppInfo.setUrl(MINI_APP_URL); + 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/honey/honey/controller/TransactionController.java b/src/main/java/com/honey/honey/controller/TransactionController.java new file mode 100644 index 0000000..fd3159d --- /dev/null +++ b/src/main/java/com/honey/honey/controller/TransactionController.java @@ -0,0 +1,50 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.TransactionDto; +import com.honey.honey.model.UserA; +import com.honey.honey.security.UserContext; +import com.honey.honey.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/honey/honey/controller/UserCheckController.java b/src/main/java/com/honey/honey/controller/UserCheckController.java new file mode 100644 index 0000000..5b80ccf --- /dev/null +++ b/src/main/java/com/honey/honey/controller/UserCheckController.java @@ -0,0 +1,101 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.UserCheckDto; +import com.honey.honey.model.UserA; +import com.honey.honey.model.UserB; +import com.honey.honey.model.UserD; +import com.honey.honey.repository.PaymentRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.repository.UserBRepository; +import com.honey.honey.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 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) + .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/honey/honey/controller/UserController.java b/src/main/java/com/honey/honey/controller/UserController.java new file mode 100644 index 0000000..c036392 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/UserController.java @@ -0,0 +1,146 @@ +package com.honey.honey.controller; + +import com.honey.honey.dto.ReferralDto; +import com.honey.honey.dto.UserDto; +import com.honey.honey.model.UserA; +import com.honey.honey.model.UserB; +import com.honey.honey.repository.UserBRepository; +import com.honey.honey.security.UserContext; +import com.honey.honey.service.AvatarService; +import com.honey.honey.service.FeatureSwitchService; +import com.honey.honey.service.LocalizationService; +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.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/honey/honey/dto/AdminLoginRequest.java b/src/main/java/com/honey/honey/dto/AdminLoginRequest.java new file mode 100644 index 0000000..bf31a5e --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminLoginRequest.java @@ -0,0 +1,10 @@ +package com.honey.honey.dto; + +import lombok.Data; + +@Data +public class AdminLoginRequest { + private String username; + private String password; +} + diff --git a/src/main/java/com/honey/honey/dto/AdminLoginResponse.java b/src/main/java/com/honey/honey/dto/AdminLoginResponse.java new file mode 100644 index 0000000..625e49c --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminLoginResponse.java @@ -0,0 +1,13 @@ +package com.honey.honey.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/honey/honey/dto/AdminMasterDto.java b/src/main/java/com/honey/honey/dto/AdminMasterDto.java new file mode 100644 index 0000000..3173e96 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminMasterDto.java @@ -0,0 +1,31 @@ +package com.honey.honey.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/honey/honey/dto/AdminPaymentDto.java b/src/main/java/com/honey/honey/dto/AdminPaymentDto.java new file mode 100644 index 0000000..54e7016 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPaymentDto.java @@ -0,0 +1,27 @@ +package com.honey.honey.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/honey/honey/dto/AdminPayoutDto.java b/src/main/java/com/honey/honey/dto/AdminPayoutDto.java new file mode 100644 index 0000000..17edbe2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPayoutDto.java @@ -0,0 +1,28 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionDto.java b/src/main/java/com/honey/honey/dto/AdminPromotionDto.java new file mode 100644 index 0000000..e679658 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionDto.java @@ -0,0 +1,23 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionRequest.java b/src/main/java/com/honey/honey/dto/AdminPromotionRequest.java new file mode 100644 index 0000000..fc928f4 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionRequest.java @@ -0,0 +1,25 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionRewardDto.java b/src/main/java/com/honey/honey/dto/AdminPromotionRewardDto.java new file mode 100644 index 0000000..4ba412c --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionRewardDto.java @@ -0,0 +1,22 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionRewardRequest.java b/src/main/java/com/honey/honey/dto/AdminPromotionRewardRequest.java new file mode 100644 index 0000000..9a7a3da --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionRewardRequest.java @@ -0,0 +1,18 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionUserDto.java b/src/main/java/com/honey/honey/dto/AdminPromotionUserDto.java new file mode 100644 index 0000000..7f0c370 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionUserDto.java @@ -0,0 +1,21 @@ +package com.honey.honey.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/honey/honey/dto/AdminPromotionUserPointsRequest.java b/src/main/java/com/honey/honey/dto/AdminPromotionUserPointsRequest.java new file mode 100644 index 0000000..8a5c7bf --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminPromotionUserPointsRequest.java @@ -0,0 +1,18 @@ +package com.honey.honey.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/honey/honey/dto/AdminSupportMessageDto.java b/src/main/java/com/honey/honey/dto/AdminSupportMessageDto.java new file mode 100644 index 0000000..5d872e2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminSupportMessageDto.java @@ -0,0 +1,22 @@ +package com.honey.honey.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/honey/honey/dto/AdminSupportTicketDetailDto.java b/src/main/java/com/honey/honey/dto/AdminSupportTicketDetailDto.java new file mode 100644 index 0000000..9d97380 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminSupportTicketDetailDto.java @@ -0,0 +1,25 @@ +package com.honey.honey.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/honey/honey/dto/AdminSupportTicketDto.java b/src/main/java/com/honey/honey/dto/AdminSupportTicketDto.java new file mode 100644 index 0000000..ab1fca2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminSupportTicketDto.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/dto/AdminTaskClaimDto.java b/src/main/java/com/honey/honey/dto/AdminTaskClaimDto.java new file mode 100644 index 0000000..3c8cbd3 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminTaskClaimDto.java @@ -0,0 +1,21 @@ +package com.honey.honey.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/honey/honey/dto/AdminTransactionDto.java b/src/main/java/com/honey/honey/dto/AdminTransactionDto.java new file mode 100644 index 0000000..782f72c --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminTransactionDto.java @@ -0,0 +1,21 @@ +package com.honey.honey.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 Instant createdAt; +} + diff --git a/src/main/java/com/honey/honey/dto/AdminUserDetailDto.java b/src/main/java/com/honey/honey/dto/AdminUserDetailDto.java new file mode 100644 index 0000000..0230a5e --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminUserDetailDto.java @@ -0,0 +1,52 @@ +package com.honey.honey.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; + + // 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/honey/honey/dto/AdminUserDto.java b/src/main/java/com/honey/honey/dto/AdminUserDto.java new file mode 100644 index 0000000..4ffecdd --- /dev/null +++ b/src/main/java/com/honey/honey/dto/AdminUserDto.java @@ -0,0 +1,41 @@ +package com.honey.honey.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 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/honey/honey/dto/BalanceAdjustmentRequest.java b/src/main/java/com/honey/honey/dto/BalanceAdjustmentRequest.java new file mode 100644 index 0000000..914a13f --- /dev/null +++ b/src/main/java/com/honey/honey/dto/BalanceAdjustmentRequest.java @@ -0,0 +1,36 @@ +package com.honey.honey.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/honey/honey/dto/BalanceAdjustmentResponse.java b/src/main/java/com/honey/honey/dto/BalanceAdjustmentResponse.java new file mode 100644 index 0000000..0d568ed --- /dev/null +++ b/src/main/java/com/honey/honey/dto/BalanceAdjustmentResponse.java @@ -0,0 +1,20 @@ +package com.honey.honey.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/honey/honey/dto/BalanceUpdateDto.java b/src/main/java/com/honey/honey/dto/BalanceUpdateDto.java new file mode 100644 index 0000000..cb4d7f0 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/BalanceUpdateDto.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/BotRegisterRequest.java b/src/main/java/com/honey/honey/dto/BotRegisterRequest.java new file mode 100644 index 0000000..319902b --- /dev/null +++ b/src/main/java/com/honey/honey/dto/BotRegisterRequest.java @@ -0,0 +1,37 @@ +package com.honey.honey.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/honey/honey/dto/BotRegisterResponse.java b/src/main/java/com/honey/honey/dto/BotRegisterResponse.java new file mode 100644 index 0000000..efa5862 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/BotRegisterResponse.java @@ -0,0 +1,25 @@ +package com.honey.honey.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/ClaimTaskResponse.java b/src/main/java/com/honey/honey/dto/ClaimTaskResponse.java new file mode 100644 index 0000000..ea48622 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ClaimTaskResponse.java @@ -0,0 +1,19 @@ +package com.honey.honey.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClaimTaskResponse { + private boolean success; + private String message; +} + + + + diff --git a/src/main/java/com/honey/honey/dto/CreateCryptoWithdrawalRequest.java b/src/main/java/com/honey/honey/dto/CreateCryptoWithdrawalRequest.java new file mode 100644 index 0000000..001ee1b --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateCryptoWithdrawalRequest.java @@ -0,0 +1,23 @@ +package com.honey.honey.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/honey/honey/dto/CreateMessageRequest.java b/src/main/java/com/honey/honey/dto/CreateMessageRequest.java new file mode 100644 index 0000000..6ce4e52 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateMessageRequest.java @@ -0,0 +1,22 @@ +package com.honey.honey.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/honey/honey/dto/CreatePaymentRequest.java b/src/main/java/com/honey/honey/dto/CreatePaymentRequest.java new file mode 100644 index 0000000..edc6506 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreatePaymentRequest.java @@ -0,0 +1,13 @@ +package com.honey.honey.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/honey/honey/dto/CreatePayoutRequest.java b/src/main/java/com/honey/honey/dto/CreatePayoutRequest.java new file mode 100644 index 0000000..2f8b209 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreatePayoutRequest.java @@ -0,0 +1,14 @@ +package com.honey.honey.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/honey/honey/dto/CreateSessionRequest.java new file mode 100644 index 0000000..a1d0bbb --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateSessionRequest.java @@ -0,0 +1,9 @@ +package com.honey.honey.dto; + +import lombok.Data; + +@Data +public class CreateSessionRequest { + private String initData; +} + diff --git a/src/main/java/com/honey/honey/dto/CreateSessionResponse.java b/src/main/java/com/honey/honey/dto/CreateSessionResponse.java new file mode 100644 index 0000000..7f08372 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateSessionResponse.java @@ -0,0 +1,16 @@ +package com.honey.honey.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateSessionResponse { + private String access_token; + private Integer expires_in; +} + diff --git a/src/main/java/com/honey/honey/dto/CreateTicketRequest.java b/src/main/java/com/honey/honey/dto/CreateTicketRequest.java new file mode 100644 index 0000000..15ef35e --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CreateTicketRequest.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/dto/CryptoDepositMethodsResponse.java b/src/main/java/com/honey/honey/dto/CryptoDepositMethodsResponse.java new file mode 100644 index 0000000..665f2f2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CryptoDepositMethodsResponse.java @@ -0,0 +1,52 @@ +package com.honey.honey.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/honey/honey/dto/CryptoWithdrawalResponse.java b/src/main/java/com/honey/honey/dto/CryptoWithdrawalResponse.java new file mode 100644 index 0000000..75ea6f2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/CryptoWithdrawalResponse.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/DailyBonusStatusDto.java b/src/main/java/com/honey/honey/dto/DailyBonusStatusDto.java new file mode 100644 index 0000000..546cd96 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DailyBonusStatusDto.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/DepositAddressApiRequest.java b/src/main/java/com/honey/honey/dto/DepositAddressApiRequest.java new file mode 100644 index 0000000..111a091 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DepositAddressApiRequest.java @@ -0,0 +1,43 @@ +package com.honey.honey.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/honey/honey/dto/DepositAddressRequest.java b/src/main/java/com/honey/honey/dto/DepositAddressRequest.java new file mode 100644 index 0000000..fa8d923 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DepositAddressRequest.java @@ -0,0 +1,13 @@ +package com.honey.honey.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/honey/honey/dto/DepositAddressResponse.java b/src/main/java/com/honey/honey/dto/DepositAddressResponse.java new file mode 100644 index 0000000..40411a6 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DepositAddressResponse.java @@ -0,0 +1,43 @@ +package com.honey.honey.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/honey/honey/dto/DepositAddressResultDto.java b/src/main/java/com/honey/honey/dto/DepositAddressResultDto.java new file mode 100644 index 0000000..9c9480a --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DepositAddressResultDto.java @@ -0,0 +1,20 @@ +package com.honey.honey.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/honey/honey/dto/DepositMethodsDto.java b/src/main/java/com/honey/honey/dto/DepositMethodsDto.java new file mode 100644 index 0000000..f462044 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/DepositMethodsDto.java @@ -0,0 +1,32 @@ +package com.honey.honey.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/honey/honey/dto/ErrorResponse.java b/src/main/java/com/honey/honey/dto/ErrorResponse.java new file mode 100644 index 0000000..f65f1b0 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.honey.honey.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/honey/honey/dto/ExternalDepositWebhookRequest.java b/src/main/java/com/honey/honey/dto/ExternalDepositWebhookRequest.java new file mode 100644 index 0000000..e5a2314 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ExternalDepositWebhookRequest.java @@ -0,0 +1,18 @@ +package com.honey.honey.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/honey/honey/dto/JoinRoundRequest.java b/src/main/java/com/honey/honey/dto/JoinRoundRequest.java new file mode 100644 index 0000000..464688d --- /dev/null +++ b/src/main/java/com/honey/honey/dto/JoinRoundRequest.java @@ -0,0 +1,24 @@ +package com.honey.honey.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/honey/honey/dto/MessageDto.java b/src/main/java/com/honey/honey/dto/MessageDto.java new file mode 100644 index 0000000..5da60f0 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/MessageDto.java @@ -0,0 +1,24 @@ +package com.honey.honey.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/honey/honey/dto/NotifyBroadcastRequest.java b/src/main/java/com/honey/honey/dto/NotifyBroadcastRequest.java new file mode 100644 index 0000000..8b79dd0 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/NotifyBroadcastRequest.java @@ -0,0 +1,21 @@ +package com.honey.honey.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/honey/honey/dto/ParticipantDto.java b/src/main/java/com/honey/honey/dto/ParticipantDto.java new file mode 100644 index 0000000..fd67da2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ParticipantDto.java @@ -0,0 +1,23 @@ +package com.honey.honey.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/honey/honey/dto/PaymentInvoiceResponse.java b/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java new file mode 100644 index 0000000..83ade87 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/PaymentWebhookRequest.java b/src/main/java/com/honey/honey/dto/PaymentWebhookRequest.java new file mode 100644 index 0000000..16b4b69 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PaymentWebhookRequest.java @@ -0,0 +1,16 @@ +package com.honey.honey.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/honey/honey/dto/PayoutHistoryEntryDto.java b/src/main/java/com/honey/honey/dto/PayoutHistoryEntryDto.java new file mode 100644 index 0000000..a92dda3 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PayoutHistoryEntryDto.java @@ -0,0 +1,20 @@ +package com.honey.honey.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/honey/honey/dto/PayoutResponse.java b/src/main/java/com/honey/honey/dto/PayoutResponse.java new file mode 100644 index 0000000..cc476e7 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PayoutResponse.java @@ -0,0 +1,24 @@ +package com.honey.honey.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/honey/honey/dto/PromotionDetailDto.java b/src/main/java/com/honey/honey/dto/PromotionDetailDto.java new file mode 100644 index 0000000..cba19cd --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PromotionDetailDto.java @@ -0,0 +1,29 @@ +package com.honey.honey.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/honey/honey/dto/PromotionLeaderboardEntryDto.java b/src/main/java/com/honey/honey/dto/PromotionLeaderboardEntryDto.java new file mode 100644 index 0000000..8f851af --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PromotionLeaderboardEntryDto.java @@ -0,0 +1,20 @@ +package com.honey.honey.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/honey/honey/dto/PromotionListItemDto.java b/src/main/java/com/honey/honey/dto/PromotionListItemDto.java new file mode 100644 index 0000000..146bc33 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/PromotionListItemDto.java @@ -0,0 +1,22 @@ +package com.honey.honey.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/honey/honey/dto/QuickAnswerCreateRequest.java b/src/main/java/com/honey/honey/dto/QuickAnswerCreateRequest.java new file mode 100644 index 0000000..3947f9f --- /dev/null +++ b/src/main/java/com/honey/honey/dto/QuickAnswerCreateRequest.java @@ -0,0 +1,13 @@ +package com.honey.honey.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/honey/honey/dto/QuickAnswerDto.java b/src/main/java/com/honey/honey/dto/QuickAnswerDto.java new file mode 100644 index 0000000..98b1917 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/QuickAnswerDto.java @@ -0,0 +1,18 @@ +package com.honey.honey.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/honey/honey/dto/RecentBonusClaimDto.java b/src/main/java/com/honey/honey/dto/RecentBonusClaimDto.java new file mode 100644 index 0000000..de22962 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/RecentBonusClaimDto.java @@ -0,0 +1,25 @@ +package com.honey.honey.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/honey/honey/dto/ReferralDto.java b/src/main/java/com/honey/honey/dto/ReferralDto.java new file mode 100644 index 0000000..63b0610 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ReferralDto.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/ReferralLevelDto.java b/src/main/java/com/honey/honey/dto/ReferralLevelDto.java new file mode 100644 index 0000000..c2ca7a5 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/ReferralLevelDto.java @@ -0,0 +1,25 @@ +package com.honey.honey.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/honey/honey/dto/SupportTicketReplyRequest.java b/src/main/java/com/honey/honey/dto/SupportTicketReplyRequest.java new file mode 100644 index 0000000..cf294d5 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/SupportTicketReplyRequest.java @@ -0,0 +1,18 @@ +package com.honey.honey.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/honey/honey/dto/TaskDto.java b/src/main/java/com/honey/honey/dto/TaskDto.java new file mode 100644 index 0000000..9344f0d --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TaskDto.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/dto/TelegramApiResponse.java b/src/main/java/com/honey/honey/dto/TelegramApiResponse.java new file mode 100644 index 0000000..c1839b8 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TelegramApiResponse.java @@ -0,0 +1,19 @@ +package com.honey.honey.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/honey/honey/dto/TelegramSendResult.java b/src/main/java/com/honey/honey/dto/TelegramSendResult.java new file mode 100644 index 0000000..4709e0a --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TelegramSendResult.java @@ -0,0 +1,17 @@ +package com.honey.honey.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/honey/honey/dto/TicketDetailDto.java b/src/main/java/com/honey/honey/dto/TicketDetailDto.java new file mode 100644 index 0000000..0e0a907 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TicketDetailDto.java @@ -0,0 +1,26 @@ +package com.honey.honey.dto; + +import com.honey.honey.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/honey/honey/dto/TicketDto.java b/src/main/java/com/honey/honey/dto/TicketDto.java new file mode 100644 index 0000000..aacf89c --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TicketDto.java @@ -0,0 +1,25 @@ +package com.honey.honey.dto; + +import com.honey.honey.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/honey/honey/dto/TransactionDto.java b/src/main/java/com/honey/honey/dto/TransactionDto.java new file mode 100644 index 0000000..c81a7a2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/TransactionDto.java @@ -0,0 +1,39 @@ +package com.honey.honey.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, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL + */ + private String type; + + /** + * Task ID for TASK_BONUS type (null for other types) + */ + private Integer taskId; +} + + diff --git a/src/main/java/com/honey/honey/dto/UserCheckDto.java b/src/main/java/com/honey/honey/dto/UserCheckDto.java new file mode 100644 index 0000000..b7f3a4c --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserCheckDto.java @@ -0,0 +1,25 @@ +package com.honey.honey.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 +} + diff --git a/src/main/java/com/honey/honey/dto/UserDepositDto.java b/src/main/java/com/honey/honey/dto/UserDepositDto.java new file mode 100644 index 0000000..77e2337 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserDepositDto.java @@ -0,0 +1,23 @@ +package com.honey.honey.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/honey/honey/dto/UserDto.java b/src/main/java/com/honey/honey/dto/UserDto.java new file mode 100644 index 0000000..fa9ade3 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserDto.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/dto/UserProgressDto.java b/src/main/java/com/honey/honey/dto/UserProgressDto.java new file mode 100644 index 0000000..49f5447 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserProgressDto.java @@ -0,0 +1,15 @@ +package com.honey.honey.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/honey/honey/dto/UserWithdrawalDto.java b/src/main/java/com/honey/honey/dto/UserWithdrawalDto.java new file mode 100644 index 0000000..1cf7d82 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserWithdrawalDto.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/dto/WinnerDto.java b/src/main/java/com/honey/honey/dto/WinnerDto.java new file mode 100644 index 0000000..2a2b78d --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WinnerDto.java @@ -0,0 +1,35 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalApiRequest.java b/src/main/java/com/honey/honey/dto/WithdrawalApiRequest.java new file mode 100644 index 0000000..cc1abd4 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalApiRequest.java @@ -0,0 +1,55 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalApiResponse.java b/src/main/java/com/honey/honey/dto/WithdrawalApiResponse.java new file mode 100644 index 0000000..0036246 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalApiResponse.java @@ -0,0 +1,62 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalInfoApiResponse.java b/src/main/java/com/honey/honey/dto/WithdrawalInfoApiResponse.java new file mode 100644 index 0000000..d7106ee --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalInfoApiResponse.java @@ -0,0 +1,71 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalMethodDetailsDto.java b/src/main/java/com/honey/honey/dto/WithdrawalMethodDetailsDto.java new file mode 100644 index 0000000..8ed19f2 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalMethodDetailsDto.java @@ -0,0 +1,30 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalMethodsApiResponse.java b/src/main/java/com/honey/honey/dto/WithdrawalMethodsApiResponse.java new file mode 100644 index 0000000..2876a83 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalMethodsApiResponse.java @@ -0,0 +1,62 @@ +package com.honey.honey.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/honey/honey/dto/WithdrawalMethodsDto.java b/src/main/java/com/honey/honey/dto/WithdrawalMethodsDto.java new file mode 100644 index 0000000..82d6276 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/WithdrawalMethodsDto.java @@ -0,0 +1,31 @@ +package com.honey.honey.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/honey/honey/exception/BannedUserException.java b/src/main/java/com/honey/honey/exception/BannedUserException.java new file mode 100644 index 0000000..65c2f07 --- /dev/null +++ b/src/main/java/com/honey/honey/exception/BannedUserException.java @@ -0,0 +1,12 @@ +package com.honey.honey.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/honey/honey/exception/ErrorResponse.java b/src/main/java/com/honey/honey/exception/ErrorResponse.java new file mode 100644 index 0000000..86ab04e --- /dev/null +++ b/src/main/java/com/honey/honey/exception/ErrorResponse.java @@ -0,0 +1,15 @@ +package com.honey.honey.exception; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + private String code; + private String message; +} + + diff --git a/src/main/java/com/honey/honey/exception/GameException.java b/src/main/java/com/honey/honey/exception/GameException.java new file mode 100644 index 0000000..c0744af --- /dev/null +++ b/src/main/java/com/honey/honey/exception/GameException.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/exception/GlobalExceptionHandler.java b/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9898093 --- /dev/null +++ b/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java @@ -0,0 +1,115 @@ +package com.honey.honey.exception; + +import com.honey.honey.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/honey/honey/exception/UnauthorizedException.java b/src/main/java/com/honey/honey/exception/UnauthorizedException.java new file mode 100644 index 0000000..cae0100 --- /dev/null +++ b/src/main/java/com/honey/honey/exception/UnauthorizedException.java @@ -0,0 +1,9 @@ +package com.honey.honey.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} + + diff --git a/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java b/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java new file mode 100644 index 0000000..663afb8 --- /dev/null +++ b/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java @@ -0,0 +1,130 @@ +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.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/honey/honey/logging/GrafanaLoggingConfig.java b/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java new file mode 100644 index 0000000..3f40199 --- /dev/null +++ b/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java @@ -0,0 +1,30 @@ +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. + */ +/** + * 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/honey/honey/model/Admin.java b/src/main/java/com/honey/honey/model/Admin.java new file mode 100644 index 0000000..3817ebf --- /dev/null +++ b/src/main/java/com/honey/honey/model/Admin.java @@ -0,0 +1,48 @@ +package com.honey.honey.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/honey/honey/model/Configuration.java b/src/main/java/com/honey/honey/model/Configuration.java new file mode 100644 index 0000000..5491298 --- /dev/null +++ b/src/main/java/com/honey/honey/model/Configuration.java @@ -0,0 +1,21 @@ +package com.honey.honey.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/honey/honey/model/CryptoDepositConfig.java b/src/main/java/com/honey/honey/model/CryptoDepositConfig.java new file mode 100644 index 0000000..8a90029 --- /dev/null +++ b/src/main/java/com/honey/honey/model/CryptoDepositConfig.java @@ -0,0 +1,30 @@ +package com.honey.honey.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/honey/honey/model/CryptoDepositMethod.java b/src/main/java/com/honey/honey/model/CryptoDepositMethod.java new file mode 100644 index 0000000..8b0fdc9 --- /dev/null +++ b/src/main/java/com/honey/honey/model/CryptoDepositMethod.java @@ -0,0 +1,39 @@ +package com.honey.honey.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/honey/honey/model/CryptoWithdrawalMethod.java b/src/main/java/com/honey/honey/model/CryptoWithdrawalMethod.java new file mode 100644 index 0000000..9c60bf7 --- /dev/null +++ b/src/main/java/com/honey/honey/model/CryptoWithdrawalMethod.java @@ -0,0 +1,39 @@ +package com.honey.honey.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/honey/honey/model/FeatureSwitch.java b/src/main/java/com/honey/honey/model/FeatureSwitch.java new file mode 100644 index 0000000..10726b7 --- /dev/null +++ b/src/main/java/com/honey/honey/model/FeatureSwitch.java @@ -0,0 +1,32 @@ +package com.honey.honey.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/honey/honey/model/NotificationAudit.java b/src/main/java/com/honey/honey/model/NotificationAudit.java new file mode 100644 index 0000000..452015e --- /dev/null +++ b/src/main/java/com/honey/honey/model/NotificationAudit.java @@ -0,0 +1,35 @@ +package com.honey.honey.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/honey/honey/model/Payment.java b/src/main/java/com/honey/honey/model/Payment.java new file mode 100644 index 0000000..9305ba1 --- /dev/null +++ b/src/main/java/com/honey/honey/model/Payment.java @@ -0,0 +1,67 @@ +package com.honey.honey.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/honey/honey/model/Payout.java b/src/main/java/com/honey/honey/model/Payout.java new file mode 100644 index 0000000..7a2718f --- /dev/null +++ b/src/main/java/com/honey/honey/model/Payout.java @@ -0,0 +1,109 @@ +package com.honey.honey.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/honey/honey/model/Promotion.java b/src/main/java/com/honey/honey/model/Promotion.java new file mode 100644 index 0000000..010ab27 --- /dev/null +++ b/src/main/java/com/honey/honey/model/Promotion.java @@ -0,0 +1,72 @@ +package com.honey.honey.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/honey/honey/model/PromotionReward.java b/src/main/java/com/honey/honey/model/PromotionReward.java new file mode 100644 index 0000000..62cdd5a --- /dev/null +++ b/src/main/java/com/honey/honey/model/PromotionReward.java @@ -0,0 +1,48 @@ +package com.honey.honey.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/honey/honey/model/PromotionUser.java b/src/main/java/com/honey/honey/model/PromotionUser.java new file mode 100644 index 0000000..84c6462 --- /dev/null +++ b/src/main/java/com/honey/honey/model/PromotionUser.java @@ -0,0 +1,67 @@ +package com.honey.honey.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/honey/honey/model/QuickAnswer.java b/src/main/java/com/honey/honey/model/QuickAnswer.java new file mode 100644 index 0000000..4a777a1 --- /dev/null +++ b/src/main/java/com/honey/honey/model/QuickAnswer.java @@ -0,0 +1,42 @@ +package com.honey.honey.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/honey/honey/model/Session.java b/src/main/java/com/honey/honey/model/Session.java new file mode 100644 index 0000000..bc58daf --- /dev/null +++ b/src/main/java/com/honey/honey/model/Session.java @@ -0,0 +1,37 @@ +package com.honey.honey.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "sessions") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Session { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id_hash", unique = true, nullable = false, length = 255) + private String sessionIdHash; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} + diff --git a/src/main/java/com/honey/honey/model/SupportMessage.java b/src/main/java/com/honey/honey/model/SupportMessage.java new file mode 100644 index 0000000..8bd4885 --- /dev/null +++ b/src/main/java/com/honey/honey/model/SupportMessage.java @@ -0,0 +1,40 @@ +package com.honey.honey.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/honey/honey/model/SupportTicket.java b/src/main/java/com/honey/honey/model/SupportTicket.java new file mode 100644 index 0000000..2921e12 --- /dev/null +++ b/src/main/java/com/honey/honey/model/SupportTicket.java @@ -0,0 +1,57 @@ +package com.honey.honey.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/honey/honey/model/Task.java b/src/main/java/com/honey/honey/model/Task.java new file mode 100644 index 0000000..6e30c8e --- /dev/null +++ b/src/main/java/com/honey/honey/model/Task.java @@ -0,0 +1,43 @@ +package com.honey.honey.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/honey/honey/model/Transaction.java b/src/main/java/com/honey/honey/model/Transaction.java new file mode 100644 index 0000000..80e2529 --- /dev/null +++ b/src/main/java/com/honey/honey/model/Transaction.java @@ -0,0 +1,53 @@ +package com.honey.honey.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 = "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 + TASK_BONUS, // Task reward + 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/honey/honey/model/UserA.java new file mode 100644 index 0000000..2c1ea2d --- /dev/null +++ b/src/main/java/com/honey/honey/model/UserA.java @@ -0,0 +1,69 @@ +package com.honey.honey.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "db_users_a") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserA { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "screen_name", nullable = false, length = 75) + @Builder.Default + private String screenName = "-"; + + @Column(name = "telegram_id", unique = true) + private Long telegramId; + + @Column(name = "telegram_name", nullable = false, length = 33) + @Builder.Default + private String telegramName = "-"; + + @Column(name = "is_premium", nullable = false) + @Builder.Default + private Integer isPremium = 0; + + @Column(name = "language_code", nullable = false, length = 2) + @Builder.Default + private String languageCode = "XX"; + + @Column(name = "country_code", nullable = false, length = 2) + @Builder.Default + private String countryCode = "XX"; + + @Column(name = "device_code", nullable = false, length = 5) + @Builder.Default + private String deviceCode = "XX"; + + @Column(name = "ip", columnDefinition = "VARBINARY(16)") + private byte[] ip; + + @Column(name = "date_reg", nullable = false) + @Builder.Default + private Integer dateReg = 0; + + @Column(name = "date_login", nullable = false) + @Builder.Default + private Integer dateLogin = 0; + + @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/honey/honey/model/UserB.java new file mode 100644 index 0000000..323fd11 --- /dev/null +++ b/src/main/java/com/honey/honey/model/UserB.java @@ -0,0 +1,49 @@ +package com.honey.honey.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "db_users_b") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserB { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "balance_a", nullable = false) + @Builder.Default + private Long balanceA = 0L; + + @Column(name = "balance_b", nullable = false) + @Builder.Default + private Long balanceB = 0L; + + @Column(name = "deposit_total", nullable = false) + @Builder.Default + private Long depositTotal = 0L; + + @Column(name = "deposit_count", nullable = false) + @Builder.Default + private Integer depositCount = 0; + + @Column(name = "withdraw_total", nullable = false) + @Builder.Default + private Long withdrawTotal = 0L; + + @Column(name = "withdraw_count", nullable = false) + @Builder.Default + private Integer withdrawCount = 0; + + /** 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/honey/honey/model/UserD.java new file mode 100644 index 0000000..fd2fa42 --- /dev/null +++ b/src/main/java/com/honey/honey/model/UserD.java @@ -0,0 +1,108 @@ +package com.honey.honey.model; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "db_users_d") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserD { + + @Id + @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; + + @Column(name = "referer_id_2", nullable = false) + @Builder.Default + private Integer refererId2 = 0; + + @Column(name = "referer_id_3", nullable = false) + @Builder.Default + private Integer refererId3 = 0; + + @Column(name = "referer_id_4", nullable = false) + @Builder.Default + private Integer refererId4 = 0; + + @Column(name = "referer_id_5", nullable = false) + @Builder.Default + private Integer refererId5 = 0; + + @Column(name = "master_id", nullable = false) + @Builder.Default + private Integer masterId = 0; + + @Column(name = "referals_1", nullable = false) + @Builder.Default + private Integer referals1 = 0; + + @Column(name = "referals_2", nullable = false) + @Builder.Default + private Integer referals2 = 0; + + @Column(name = "referals_3", nullable = false) + @Builder.Default + private Integer referals3 = 0; + + @Column(name = "referals_4", nullable = false) + @Builder.Default + private Integer referals4 = 0; + + @Column(name = "referals_5", nullable = false) + @Builder.Default + private Integer referals5 = 0; + + @Column(name = "from_referals_1", nullable = false) + @Builder.Default + private Long fromReferals1 = 0L; + + @Column(name = "from_referals_2", nullable = false) + @Builder.Default + private Long fromReferals2 = 0L; + + @Column(name = "from_referals_3", nullable = false) + @Builder.Default + private Long fromReferals3 = 0L; + + @Column(name = "from_referals_4", nullable = false) + @Builder.Default + private Long fromReferals4 = 0L; + + @Column(name = "from_referals_5", nullable = false) + @Builder.Default + private Long fromReferals5 = 0L; + + @Column(name = "to_referer_1", nullable = false) + @Builder.Default + private Long toReferer1 = 0L; + + @Column(name = "to_referer_2", nullable = false) + @Builder.Default + private Long toReferer2 = 0L; + + @Column(name = "to_referer_3", nullable = false) + @Builder.Default + private Long toReferer3 = 0L; + + @Column(name = "to_referer_4", nullable = false) + @Builder.Default + private Long toReferer4 = 0L; + + @Column(name = "to_referer_5", nullable = false) + @Builder.Default + private Long toReferer5 = 0L; +} + + diff --git a/src/main/java/com/honey/honey/model/UserTaskClaim.java b/src/main/java/com/honey/honey/model/UserTaskClaim.java new file mode 100644 index 0000000..cadf7fc --- /dev/null +++ b/src/main/java/com/honey/honey/model/UserTaskClaim.java @@ -0,0 +1,42 @@ +package com.honey.honey.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/honey/honey/repository/AdminRepository.java b/src/main/java/com/honey/honey/repository/AdminRepository.java new file mode 100644 index 0000000..9756fb9 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/AdminRepository.java @@ -0,0 +1,13 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/ConfigurationRepository.java b/src/main/java/com/honey/honey/repository/ConfigurationRepository.java new file mode 100644 index 0000000..bacc587 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/ConfigurationRepository.java @@ -0,0 +1,9 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/CryptoDepositConfigRepository.java b/src/main/java/com/honey/honey/repository/CryptoDepositConfigRepository.java new file mode 100644 index 0000000..53ab542 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/CryptoDepositConfigRepository.java @@ -0,0 +1,9 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/CryptoDepositMethodRepository.java b/src/main/java/com/honey/honey/repository/CryptoDepositMethodRepository.java new file mode 100644 index 0000000..19d020d --- /dev/null +++ b/src/main/java/com/honey/honey/repository/CryptoDepositMethodRepository.java @@ -0,0 +1,13 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/CryptoWithdrawalMethodRepository.java b/src/main/java/com/honey/honey/repository/CryptoWithdrawalMethodRepository.java new file mode 100644 index 0000000..c2c7357 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/CryptoWithdrawalMethodRepository.java @@ -0,0 +1,13 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/FeatureSwitchRepository.java b/src/main/java/com/honey/honey/repository/FeatureSwitchRepository.java new file mode 100644 index 0000000..f40710a --- /dev/null +++ b/src/main/java/com/honey/honey/repository/FeatureSwitchRepository.java @@ -0,0 +1,9 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/NotificationAuditRepository.java b/src/main/java/com/honey/honey/repository/NotificationAuditRepository.java new file mode 100644 index 0000000..13604d7 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/NotificationAuditRepository.java @@ -0,0 +1,14 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/PaymentRepository.java b/src/main/java/com/honey/honey/repository/PaymentRepository.java new file mode 100644 index 0000000..92c3d1e --- /dev/null +++ b/src/main/java/com/honey/honey/repository/PaymentRepository.java @@ -0,0 +1,91 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/PayoutRepository.java b/src/main/java/com/honey/honey/repository/PayoutRepository.java new file mode 100644 index 0000000..e459ec7 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/PayoutRepository.java @@ -0,0 +1,111 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/PromotionRepository.java b/src/main/java/com/honey/honey/repository/PromotionRepository.java new file mode 100644 index 0000000..0d84fb8 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/PromotionRepository.java @@ -0,0 +1,30 @@ +package com.honey.honey.repository; + +import com.honey.honey.model.Promotion; +import com.honey.honey.model.Promotion.PromotionStatus; +import com.honey.honey.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/honey/honey/repository/PromotionRewardRepository.java b/src/main/java/com/honey/honey/repository/PromotionRewardRepository.java new file mode 100644 index 0000000..620ffaf --- /dev/null +++ b/src/main/java/com/honey/honey/repository/PromotionRewardRepository.java @@ -0,0 +1,15 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/PromotionUserRepository.java b/src/main/java/com/honey/honey/repository/PromotionUserRepository.java new file mode 100644 index 0000000..2bafbe3 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/PromotionUserRepository.java @@ -0,0 +1,32 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/QuickAnswerRepository.java b/src/main/java/com/honey/honey/repository/QuickAnswerRepository.java new file mode 100644 index 0000000..f93efda --- /dev/null +++ b/src/main/java/com/honey/honey/repository/QuickAnswerRepository.java @@ -0,0 +1,13 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/SessionRepository.java b/src/main/java/com/honey/honey/repository/SessionRepository.java new file mode 100644 index 0000000..d0e5e65 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/SessionRepository.java @@ -0,0 +1,57 @@ +package com.honey.honey.repository; + +import com.honey.honey.model.Session; +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface SessionRepository extends JpaRepository { + Optional findBySessionIdHash(String sessionIdHash); + + /** + * Counts active (non-expired) sessions for a user. + */ + @Query("SELECT COUNT(s) FROM Session s WHERE s.userId = :userId AND s.expiresAt > :now") + long countActiveSessionsByUserId(@Param("userId") Integer userId, @Param("now") LocalDateTime now); + + /** + * Finds oldest active sessions 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 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. + * Note: MySQL requires LIMIT to be a literal or bound parameter, so we use a native query. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true) + int deleteExpiredSessionsBatch(@Param("now") LocalDateTime now, @Param("batchSize") int batchSize); + + @Modifying + @Query("DELETE FROM Session s WHERE s.sessionIdHash = :sessionIdHash") + void deleteBySessionIdHash(@Param("sessionIdHash") String sessionIdHash); +} + diff --git a/src/main/java/com/honey/honey/repository/SupportMessageRepository.java b/src/main/java/com/honey/honey/repository/SupportMessageRepository.java new file mode 100644 index 0000000..9d95466 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/SupportMessageRepository.java @@ -0,0 +1,32 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/SupportTicketRepository.java b/src/main/java/com/honey/honey/repository/SupportTicketRepository.java new file mode 100644 index 0000000..4a15475 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/SupportTicketRepository.java @@ -0,0 +1,51 @@ +package com.honey.honey.repository; + +import com.honey.honey.model.SupportTicket; +import com.honey.honey.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/honey/honey/repository/TaskRepository.java b/src/main/java/com/honey/honey/repository/TaskRepository.java new file mode 100644 index 0000000..ea80f08 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/TaskRepository.java @@ -0,0 +1,14 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/TransactionRepository.java b/src/main/java/com/honey/honey/repository/TransactionRepository.java new file mode 100644 index 0000000..7155e7e --- /dev/null +++ b/src/main/java/com/honey/honey/repository/TransactionRepository.java @@ -0,0 +1,45 @@ +package com.honey.honey.repository; + +import com.honey.honey.model.Transaction; +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.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); + + /** + * 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. + */ + 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); + +} + diff --git a/src/main/java/com/honey/honey/repository/UserARepository.java b/src/main/java/com/honey/honey/repository/UserARepository.java new file mode 100644 index 0000000..f2917c3 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/UserARepository.java @@ -0,0 +1,54 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/UserBRepository.java b/src/main/java/com/honey/honey/repository/UserBRepository.java new file mode 100644 index 0000000..6e4cfbf --- /dev/null +++ b/src/main/java/com/honey/honey/repository/UserBRepository.java @@ -0,0 +1,25 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/repository/UserDRepository.java b/src/main/java/com/honey/honey/repository/UserDRepository.java new file mode 100644 index 0000000..cd26f1d --- /dev/null +++ b/src/main/java/com/honey/honey/repository/UserDRepository.java @@ -0,0 +1,114 @@ +package com.honey.honey.repository; + +import com.honey.honey.dto.ReferralDto; +import com.honey.honey.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.honey.honey.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.honey.honey.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.honey.honey.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/honey/honey/repository/UserTaskClaimRepository.java b/src/main/java/com/honey/honey/repository/UserTaskClaimRepository.java new file mode 100644 index 0000000..90e3897 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/UserTaskClaimRepository.java @@ -0,0 +1,20 @@ +package com.honey.honey.repository; + +import com.honey.honey.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/honey/honey/security/AuthInterceptor.java new file mode 100644 index 0000000..55e400f --- /dev/null +++ b/src/main/java/com/honey/honey/security/AuthInterceptor.java @@ -0,0 +1,101 @@ +package com.honey.honey.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.honey.honey.model.UserA; +import com.honey.honey.service.LocalizationService; +import com.honey.honey.service.SessionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +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 +@Component +@RequiredArgsConstructor +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) { + + // Allow CORS preflight (OPTIONS) without auth + if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { + return true; + } + + // Get Bearer token from Authorization header + String authHeader = req.getHeader("Authorization"); + String sessionId = extractBearerToken(authHeader); + + // If no Bearer token, fail + if (sessionId == null || sessionId.isBlank()) { + log.debug("Missing Bearer token"); + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + // Validate session and get user + Optional userOpt = sessionService.getUserBySession(sessionId); + + if (userOpt.isEmpty()) { + log.debug("Invalid or expired session: {}", maskSessionId(sessionId)); + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + 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); + + return true; + } + + /** + * Extracts Bearer token from Authorization header. + */ + private String extractBearerToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7).trim(); + } + return null; + } + + /** + * Masks session ID for logging (security). + */ + private String maskSessionId(String sessionId) { + if (sessionId == null || sessionId.length() < 8) { + return "***"; + } + return sessionId.substring(0, 4) + "***" + sessionId.substring(sessionId.length() - 4); + } + + @Override + public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { + UserContext.clear(); + } +} + diff --git a/src/main/java/com/honey/honey/security/RateLimitInterceptor.java b/src/main/java/com/honey/honey/security/RateLimitInterceptor.java new file mode 100644 index 0000000..5870ccc --- /dev/null +++ b/src/main/java/com/honey/honey/security/RateLimitInterceptor.java @@ -0,0 +1,98 @@ +package com.honey.honey.security; + +import com.honey.honey.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/honey/honey/security/UserContext.java new file mode 100644 index 0000000..90b9f4f --- /dev/null +++ b/src/main/java/com/honey/honey/security/UserContext.java @@ -0,0 +1,21 @@ +package com.honey.honey.security; + +import com.honey.honey.model.UserA; + +public class UserContext { + + private static final ThreadLocal current = new ThreadLocal<>(); + + public static void set(UserA user) { + current.set(user); + } + + public static UserA get() { + return current.get(); + } + + public static void clear() { + current.remove(); + } +} + diff --git a/src/main/java/com/honey/honey/security/UserRateLimitInterceptor.java b/src/main/java/com/honey/honey/security/UserRateLimitInterceptor.java new file mode 100644 index 0000000..83e874b --- /dev/null +++ b/src/main/java/com/honey/honey/security/UserRateLimitInterceptor.java @@ -0,0 +1,113 @@ +package com.honey.honey.security; + +import com.honey.honey.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/honey/honey/security/admin/AdminDetailsService.java b/src/main/java/com/honey/honey/security/admin/AdminDetailsService.java new file mode 100644 index 0000000..a00609a --- /dev/null +++ b/src/main/java/com/honey/honey/security/admin/AdminDetailsService.java @@ -0,0 +1,39 @@ +package com.honey.honey.security.admin; + +import com.honey.honey.model.Admin; +import com.honey.honey.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/honey/honey/security/admin/JwtAuthenticationFilter.java b/src/main/java/com/honey/honey/security/admin/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3b6c581 --- /dev/null +++ b/src/main/java/com/honey/honey/security/admin/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package com.honey.honey.security.admin; + +import com.honey.honey.model.Admin; +import com.honey.honey.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/honey/honey/security/admin/JwtUtil.java b/src/main/java/com/honey/honey/security/admin/JwtUtil.java new file mode 100644 index 0000000..0a7892b --- /dev/null +++ b/src/main/java/com/honey/honey/security/admin/JwtUtil.java @@ -0,0 +1,81 @@ +package com.honey.honey.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/honey/honey/service/AdminMasterService.java b/src/main/java/com/honey/honey/service/AdminMasterService.java new file mode 100644 index 0000000..b56e621 --- /dev/null +++ b/src/main/java/com/honey/honey/service/AdminMasterService.java @@ -0,0 +1,79 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.AdminMasterDto; +import com.honey.honey.model.UserD; +import com.honey.honey.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/honey/honey/service/AdminPromotionService.java b/src/main/java/com/honey/honey/service/AdminPromotionService.java new file mode 100644 index 0000000..8640a2a --- /dev/null +++ b/src/main/java/com/honey/honey/service/AdminPromotionService.java @@ -0,0 +1,180 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.*; +import com.honey.honey.model.Promotion; +import com.honey.honey.model.Promotion.PromotionStatus; +import com.honey.honey.model.Promotion.PromotionType; +import com.honey.honey.model.PromotionReward; +import com.honey.honey.model.PromotionUser; +import com.honey.honey.repository.PromotionRepository; +import com.honey.honey.repository.PromotionRewardRepository; +import com.honey.honey.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/honey/honey/service/AdminService.java b/src/main/java/com/honey/honey/service/AdminService.java new file mode 100644 index 0000000..09711ea --- /dev/null +++ b/src/main/java/com/honey/honey/service/AdminService.java @@ -0,0 +1,43 @@ +package com.honey.honey.service; + +import com.honey.honey.model.Admin; +import com.honey.honey.repository.AdminRepository; +import com.honey.honey.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/honey/honey/service/AdminUserService.java b/src/main/java/com/honey/honey/service/AdminUserService.java new file mode 100644 index 0000000..83b4b4c --- /dev/null +++ b/src/main/java/com/honey/honey/service/AdminUserService.java @@ -0,0 +1,774 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.*; +import com.honey.honey.model.*; +import com.honey.honey.repository.*; +import com.honey.honey.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 PaymentRepository paymentRepository; + private final PayoutRepository payoutRepository; + private final UserTaskClaimRepository userTaskClaimRepository; + private final TaskRepository taskRepository; + private final EntityManager entityManager; + + public Page getUsers( + Pageable pageable, + String search, + Integer banned, + String countryCode, + String languageCode, + Integer dateRegFrom, + Integer dateRegTo, + Long balanceMin, + Long balanceMax, + 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 / referral filters via subqueries so DB handles pagination + if (balanceMin != null || balanceMax != 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)); + } + 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", "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, + 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) + .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()) + .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 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 (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 != null ? sortBy : "") { + case "balanceA" -> "b.balance_a"; + case "depositTotal" -> "b.deposit_total"; + case "withdrawTotal" -> "b.withdraw_total"; + 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) + .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())) + .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()) + .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()); + } + + 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()); + + return Map.of( + "completed", completedTasks, + "available", availableTasks + ); + } + + @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) + .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/honey/honey/service/AvatarService.java b/src/main/java/com/honey/honey/service/AvatarService.java new file mode 100644 index 0000000..0301a3d --- /dev/null +++ b/src/main/java/com/honey/honey/service/AvatarService.java @@ -0,0 +1,693 @@ +package com.honey.honey.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.honey.honey.config.TelegramProperties; +import com.honey.honey.model.UserA; +import com.honey.honey.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/honey/honey/service/ConfigurationService.java b/src/main/java/com/honey/honey/service/ConfigurationService.java new file mode 100644 index 0000000..59e032f --- /dev/null +++ b/src/main/java/com/honey/honey/service/ConfigurationService.java @@ -0,0 +1,15 @@ +package com.honey.honey.service; + +import com.honey.honey.repository.ConfigurationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Configurations: key-value store for app-wide settings. + */ +@Service +@RequiredArgsConstructor +public class ConfigurationService { + + private final ConfigurationRepository configurationRepository; +} diff --git a/src/main/java/com/honey/honey/service/CountryCodeService.java b/src/main/java/com/honey/honey/service/CountryCodeService.java new file mode 100644 index 0000000..f694aa9 --- /dev/null +++ b/src/main/java/com/honey/honey/service/CountryCodeService.java @@ -0,0 +1,156 @@ +package com.honey.honey.service; + +import com.maxmind.db.CHMCache; +import com.maxmind.geoip2.DatabaseReader; +import com.maxmind.geoip2.exception.GeoIp2Exception; +import com.maxmind.geoip2.model.CountryResponse; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; + +/** + * Service for determining country code from IP address using MaxMind GeoLite2 database. + * Thread-safe singleton that maintains a single DatabaseReader instance. + */ +@Slf4j +@Service +public class CountryCodeService { + + private final String dbPath; + private final ResourceLoader resourceLoader; + private DatabaseReader reader; + + public CountryCodeService( + @Value("${geoip.db-path:}") String dbPath, + ResourceLoader resourceLoader) { + this.dbPath = dbPath; + this.resourceLoader = resourceLoader; + } + + @PostConstruct + public void init() throws IOException { + File dbFile = null; + + // Try external file path first (from config/env) + if (dbPath != null && !dbPath.isBlank()) { + dbFile = new File(dbPath); + if (dbFile.exists() && dbFile.isFile()) { + log.info("Loading GeoIP database from external path: {}", dbFile.getAbsolutePath()); + } else { + log.warn("GeoIP database file not found at configured path: {}, falling back to resources", dbFile.getAbsolutePath()); + dbFile = null; + } + } + + // Fallback to resources directory + if (dbFile == null) { + try { + Resource resource = resourceLoader.getResource("classpath:geoip/GeoLite2-Country.mmdb"); + if (resource.exists() && resource.isReadable()) { + // Try to get file path first (works if resource is a file system resource) + try { + dbFile = resource.getFile(); + log.info("Loading GeoIP database from resources: {}", dbFile.getAbsolutePath()); + } catch (Exception e) { + // Resource is in JAR, need to extract to temp file + log.debug("GeoIP database is in JAR, extracting to temp file"); + InputStream inputStream = resource.getInputStream(); + File tempFile = File.createTempFile("GeoLite2-Country", ".mmdb"); + tempFile.deleteOnExit(); + + try (inputStream) { + java.nio.file.Files.copy(inputStream, tempFile.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + + dbFile = tempFile; + log.info("Loading GeoIP database from JAR resources (extracted to temp file)"); + } + } else { + throw new IllegalStateException("GeoIP database not found in resources: classpath:geoip/GeoLite2-Country.mmdb"); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to load GeoIP database from resources: " + e.getMessage(), e); + } + } + + // Initialize DatabaseReader with cache for better performance + try { + this.reader = new DatabaseReader.Builder(dbFile) + .withCache(new CHMCache()) + .build(); + log.info("GeoIP database loaded successfully"); + } catch (IOException e) { + log.error("Failed to initialize GeoIP database reader", e); + throw new IllegalStateException("Failed to initialize GeoIP database reader", e); + } + } + + @PreDestroy + public void destroy() { + if (reader != null) { + try { + reader.close(); + log.info("GeoIP database reader closed"); + } catch (IOException e) { + log.warn("Error closing GeoIP database reader", e); + } + } + } + + /** + * Determines country code from IP address using MaxMind GeoLite2 database. + * + * @param ipAddress IP address as string (IPv4 or IPv6) + * @return ISO 3166-1 alpha-2 country code (e.g., "UA", "PL", "DE"), or "XX" if unknown/invalid/private + */ + public String getCountryCode(String ipAddress) { + if (ipAddress == null || ipAddress.isBlank()) { + return "XX"; + } + + if (reader == null) { + log.warn("GeoIP database reader not initialized, returning 'XX' for IP: {}", ipAddress); + return "XX"; + } + + try { + InetAddress addr = InetAddress.getByName(ipAddress); + + // Check if it's a private/local IP (won't be in GeoIP database) + if (addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) { + log.debug("Private/local IP address detected: {}, returning 'XX'", ipAddress); + return "XX"; + } + + CountryResponse response = reader.country(addr); + String iso = response.getCountry().getIsoCode(); + + if (iso != null && iso.length() == 2) { + return iso.toUpperCase(); // Ensure uppercase (ISO codes should be uppercase) + } else { + log.debug("Invalid ISO code returned for IP {}: {}", ipAddress, iso); + return "XX"; + } + } catch (IOException e) { + log.debug("Failed to resolve IP address: {}", ipAddress, e); + return "XX"; + } catch (GeoIp2Exception e) { + log.debug("GeoIP lookup failed for IP {}: {}", ipAddress, e.getMessage()); + return "XX"; + } catch (IllegalArgumentException e) { + log.debug("Invalid IP address format: {}", ipAddress, e); + return "XX"; + } + } + +} + diff --git a/src/main/java/com/honey/honey/service/CryptoDepositService.java b/src/main/java/com/honey/honey/service/CryptoDepositService.java new file mode 100644 index 0000000..f63584d --- /dev/null +++ b/src/main/java/com/honey/honey/service/CryptoDepositService.java @@ -0,0 +1,216 @@ +package com.honey.honey.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.honey.honey.dto.CryptoDepositMethodsResponse; +import com.honey.honey.dto.DepositAddressApiRequest; +import com.honey.honey.dto.DepositAddressResponse; +import com.honey.honey.dto.DepositMethodsDto; +import com.honey.honey.model.CryptoDepositConfig; +import com.honey.honey.model.CryptoDepositMethod; +import com.honey.honey.repository.CryptoDepositConfigRepository; +import com.honey.honey.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/honey/honey/service/CryptoWithdrawalService.java b/src/main/java/com/honey/honey/service/CryptoWithdrawalService.java new file mode 100644 index 0000000..4db11ee --- /dev/null +++ b/src/main/java/com/honey/honey/service/CryptoWithdrawalService.java @@ -0,0 +1,332 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.WithdrawalApiRequest; +import com.honey.honey.dto.WithdrawalApiResponse; +import com.honey.honey.dto.WithdrawalInfoApiResponse; +import com.honey.honey.dto.WithdrawalMethodDetailsDto; +import com.honey.honey.dto.WithdrawalMethodsApiResponse; +import com.honey.honey.dto.WithdrawalMethodsDto; +import com.honey.honey.model.CryptoWithdrawalMethod; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.CryptoWithdrawalMethodRepository; +import com.honey.honey.repository.UserARepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.honey.honey.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/honey/honey/service/DataCleanupService.java b/src/main/java/com/honey/honey/service/DataCleanupService.java new file mode 100644 index 0000000..269e631 --- /dev/null +++ b/src/main/java/com/honey/honey/service/DataCleanupService.java @@ -0,0 +1,87 @@ +package com.honey.honey.service; + +import com.honey.honey.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 between batches + 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/honey/honey/service/FeatureSwitchService.java b/src/main/java/com/honey/honey/service/FeatureSwitchService.java new file mode 100644 index 0000000..835ac70 --- /dev/null +++ b/src/main/java/com/honey/honey/service/FeatureSwitchService.java @@ -0,0 +1,128 @@ +package com.honey.honey.service; + +import com.honey.honey.model.FeatureSwitch; +import com.honey.honey.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 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, 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"; + /** When enabled, "Start Game" inline button is sent (welcome and start-spinning messages). When disabled, the button is not sent. Default true. */ + public static final String START_GAME_BUTTON_ENABLED = "start_game_button_enabled"; + + private final FeatureSwitchRepository featureSwitchRepository; + + /** + * 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 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 whether the "Start Game" inline button is shown (welcome message and start-spinning reply). Default true when switch is missing. + */ + @Transactional(readOnly = true) + public boolean isStartGameButtonEnabled() { + return featureSwitchRepository.findById(START_GAME_BUTTON_ENABLED) + .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/honey/honey/service/LocalizationService.java b/src/main/java/com/honey/honey/service/LocalizationService.java new file mode 100644 index 0000000..1bb05c9 --- /dev/null +++ b/src/main/java/com/honey/honey/service/LocalizationService.java @@ -0,0 +1,84 @@ +package com.honey.honey.service; + +import com.honey.honey.config.LocaleConfig; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.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., "error.code") + * @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/honey/honey/service/NotificationBroadcastService.java b/src/main/java/com/honey/honey/service/NotificationBroadcastService.java new file mode 100644 index 0000000..0bf4bfb --- /dev/null +++ b/src/main/java/com/honey/honey/service/NotificationBroadcastService.java @@ -0,0 +1,184 @@ +package com.honey.honey.service; + +import com.honey.honey.config.TelegramProperties; +import com.honey.honey.dto.TelegramSendResult; +import com.honey.honey.model.NotificationAudit; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.NotificationAuditRepository; +import com.honey.honey.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 FeatureSwitchService featureSwitchService; + + 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://testforapp.website/test/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) && featureSwitchService.isStartGameButtonEnabled()) { + 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/honey/honey/service/PaymentService.java b/src/main/java/com/honey/honey/service/PaymentService.java new file mode 100644 index 0000000..a646c52 --- /dev/null +++ b/src/main/java/com/honey/honey/service/PaymentService.java @@ -0,0 +1,446 @@ +package com.honey.honey.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.honey.honey.config.TelegramProperties; +import com.honey.honey.dto.CreatePaymentRequest; +import com.honey.honey.dto.DepositAddressApiRequest; +import com.honey.honey.dto.DepositAddressResponse; +import com.honey.honey.dto.DepositAddressResultDto; +import com.honey.honey.dto.PaymentInvoiceResponse; +import com.honey.honey.dto.PaymentWebhookRequest; +import com.honey.honey.model.CryptoDepositMethod; +import com.honey.honey.model.Payment; +import com.honey.honey.model.UserA; +import com.honey.honey.model.UserB; +import com.honey.honey.repository.CryptoDepositMethodRepository; +import com.honey.honey.repository.PaymentRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.repository.UserBRepository; +import com.honey.honey.util.IpUtils; +import com.honey.honey.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); + + 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); + 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/honey/honey/service/PayoutService.java b/src/main/java/com/honey/honey/service/PayoutService.java new file mode 100644 index 0000000..9cbe746 --- /dev/null +++ b/src/main/java/com/honey/honey/service/PayoutService.java @@ -0,0 +1,587 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.CreateCryptoWithdrawalRequest; +import com.honey.honey.dto.CreatePayoutRequest; +import com.honey.honey.dto.PayoutHistoryEntryDto; +import com.honey.honey.dto.PayoutResponse; +import com.honey.honey.dto.WithdrawalApiResponse; +import com.honey.honey.model.Payout; +import com.honey.honey.model.UserB; +import com.honey.honey.repository.PayoutRepository; +import com.honey.honey.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")); + } + + // 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); + + 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))); + } + + 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 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); + 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/honey/honey/service/PromotionService.java b/src/main/java/com/honey/honey/service/PromotionService.java new file mode 100644 index 0000000..30c4f3e --- /dev/null +++ b/src/main/java/com/honey/honey/service/PromotionService.java @@ -0,0 +1,121 @@ +package com.honey.honey.service; + +import com.honey.honey.model.Promotion; +import com.honey.honey.model.Promotion.PromotionType; +import com.honey.honey.model.PromotionUser; +import com.honey.honey.repository.PromotionRepository; +import com.honey.honey.repository.PromotionUserRepository; +import com.honey.honey.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/honey/honey/service/PublicPromotionService.java b/src/main/java/com/honey/honey/service/PublicPromotionService.java new file mode 100644 index 0000000..a915aba --- /dev/null +++ b/src/main/java/com/honey/honey/service/PublicPromotionService.java @@ -0,0 +1,135 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.PromotionDetailDto; +import com.honey.honey.dto.PromotionLeaderboardEntryDto; +import com.honey.honey.dto.PromotionListItemDto; +import com.honey.honey.model.Promotion; +import com.honey.honey.model.Promotion.PromotionStatus; +import com.honey.honey.model.PromotionReward; +import com.honey.honey.model.PromotionUser; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.PromotionRepository; +import com.honey.honey.repository.PromotionRewardRepository; +import com.honey.honey.repository.PromotionUserRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.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/honey/honey/service/SessionCleanupService.java b/src/main/java/com/honey/honey/service/SessionCleanupService.java new file mode 100644 index 0000000..cf5a8eb --- /dev/null +++ b/src/main/java/com/honey/honey/service/SessionCleanupService.java @@ -0,0 +1,63 @@ +package com.honey.honey.service; + +import com.honey.honey.repository.SessionRepository; +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.LocalDateTime; + +/** + * Scheduled service for batch cleanup of expired sessions. + * Runs every hour to delete expired sessions in batches to avoid long transactions. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionCleanupService { + + private final SessionRepository sessionRepository; + + @Value("${app.session.cleanup.batch-size:5000}") + private int batchSize; + + @Value("${app.session.cleanup.max-batches-per-run:20}") + private int maxBatchesPerRun; + + /** + * Batch deletes expired sessions. + * Runs every hour at minute 0. + * Processes up to MAX_BATCHES_PER_RUN batches per run. + */ + @Scheduled(cron = "0 0 * * * ?") // Every hour at minute 0 + @Transactional + public void cleanupExpiredSessions() { + LocalDateTime now = LocalDateTime.now(); + int totalDeleted = 0; + int batchesProcessed = 0; + + log.info("Starting expired session cleanup (batchSize={}, maxBatches={})", batchSize, maxBatchesPerRun); + + while (batchesProcessed < maxBatchesPerRun) { + int deleted = sessionRepository.deleteExpiredSessionsBatch(now, batchSize); + totalDeleted += deleted; + batchesProcessed++; + + // If we deleted less than batch size, we've caught up + if (deleted < batchSize) { + break; + } + } + + if (totalDeleted > 0) { + log.info("Session cleanup completed: deleted {} expired session(s) in {} batch(es)", + totalDeleted, batchesProcessed); + } else { + log.debug("Session cleanup completed: no expired sessions found"); + } + } +} + diff --git a/src/main/java/com/honey/honey/service/SessionService.java b/src/main/java/com/honey/honey/service/SessionService.java new file mode 100644 index 0000000..68a52b9 --- /dev/null +++ b/src/main/java/com/honey/honey/service/SessionService.java @@ -0,0 +1,181 @@ +package com.honey.honey.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 lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionService { + + private final SessionRepository sessionRepository; + private final UserARepository userARepository; + private static final int SESSION_TTL_HOURS = 1; // 1 hour + private static final SecureRandom secureRandom = new SecureRandom(); + + /** + * -- GETTER -- + * Gets max active sessions per user. + */ + @Getter + @Value("${app.session.max-active-per-user:5}") + private int maxActiveSessionsPerUser; + + /** + * Creates a new session for a user. + * 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 + public String createSession(UserA user) { + LocalDateTime now = LocalDateTime.now(); + + // Generate cryptographically random session ID + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + String sessionId = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + // Hash the session ID for storage + String sessionIdHash = hashSessionId(sessionId); + + // Calculate expiration + LocalDateTime expiresAt = now.plusHours(SESSION_TTL_HOURS); + + // Enforce max active sessions per user + enforceMaxActiveSessions(user.getId(), now); + + // Create and save session + Session session = Session.builder() + .sessionIdHash(sessionIdHash) + .userId(user.getId()) + .createdAt(now) + .expiresAt(expiresAt) + .build(); + + sessionRepository.save(session); + + log.debug("Session created: userId={}", user.getId()); + return sessionId; + } + + /** + * 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) { + // Count ALL sessions for the user (active + expired) + long totalCount = sessionRepository.countByUserId(userId); + + 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 sessions (active or expired, ordered by createdAt ASC) + List oldestSessions = sessionRepository.findOldestSessionsByUserId( + userId, + PageRequest.of(0, toDelete) + ); + + // Delete oldest sessions + if (!oldestSessions.isEmpty()) { + sessionRepository.deleteAll(oldestSessions); + log.debug("Deleted {} oldest session(s) for userId={} (limit: {})", + oldestSessions.size(), userId, maxActiveSessionsPerUser); + } + } + } + + /** + * Validates a session ID and returns the associated user. + * Returns empty if session is invalid or expired. + */ + @Transactional(readOnly = true) + public Optional getUserBySession(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return Optional.empty(); + } + + String sessionIdHash = hashSessionId(sessionId); + Optional sessionOpt = sessionRepository.findBySessionIdHash(sessionIdHash); + + if (sessionOpt.isEmpty()) { + return Optional.empty(); + } + + Session session = sessionOpt.get(); + + if (session.isExpired()) { + // Optionally delete expired session + sessionRepository.delete(session); + return Optional.empty(); + } + + // Load user by ID + return userARepository.findById(session.getUserId()); + } + + /** + * Invalidates a session (logout). + */ + @Transactional + public void invalidateSession(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return; + } + + String sessionIdHash = hashSessionId(sessionId); + sessionRepository.deleteBySessionIdHash(sessionIdHash); + log.debug("Session invalidated: userId={}", maskSessionId(sessionId)); + } + + /** + * Hashes a session ID using SHA-256. + */ + private String hashSessionId(String sessionId) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (Exception e) { + log.error("Failed to hash session ID", e); + throw new RuntimeException("Failed to hash session ID", e); + } + } + + /** + * Masks session ID for logging (security). + */ + private String maskSessionId(String sessionId) { + if (sessionId == null || sessionId.length() < 8) { + return "***"; + } + return sessionId.substring(0, 4) + "***" + sessionId.substring(sessionId.length() - 4); + } + + /** + * Gets session TTL in seconds. + */ + public int getSessionTtlSeconds() { + return SESSION_TTL_HOURS * 3600; + } + +} + diff --git a/src/main/java/com/honey/honey/service/SupportTicketService.java b/src/main/java/com/honey/honey/service/SupportTicketService.java new file mode 100644 index 0000000..26a6942 --- /dev/null +++ b/src/main/java/com/honey/honey/service/SupportTicketService.java @@ -0,0 +1,225 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.*; +import com.honey.honey.exception.GameException; +import com.honey.honey.model.SupportMessage; +import com.honey.honey.model.SupportTicket; +import com.honey.honey.model.SupportTicket.TicketStatus; +import com.honey.honey.model.UserA; +import com.honey.honey.repository.SupportMessageRepository; +import com.honey.honey.repository.SupportTicketRepository; +import com.honey.honey.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/honey/honey/service/TaskService.java b/src/main/java/com/honey/honey/service/TaskService.java new file mode 100644 index 0000000..1a8d467 --- /dev/null +++ b/src/main/java/com/honey/honey/service/TaskService.java @@ -0,0 +1,535 @@ +package com.honey.honey.service; + +import com.honey.honey.config.LocaleConfig; +import com.honey.honey.config.TelegramProperties; +import com.honey.honey.dto.RecentBonusClaimDto; +import com.honey.honey.dto.TaskDto; +import com.honey.honey.model.Task; +import com.honey.honey.model.UserA; +import com.honey.honey.model.UserB; +import com.honey.honey.model.UserD; +import com.honey.honey.model.UserTaskClaim; +import com.honey.honey.repository.TaskRepository; +import com.honey.honey.repository.UserARepository; +import com.honey.honey.repository.UserBRepository; +import com.honey.honey.repository.UserDRepository; +import com.honey.honey.repository.UserTaskClaimRepository; +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 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 -> !"daily".equals(task.getType())) + .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(); + + // Daily bonus removed - reject daily task claims + if ("daily".equals(task.getType())) { + return false; + } + + // 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; + } + + // Check if already claimed to prevent abuse + if (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; + } + + // 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()); + + try { + 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())) { + // Daily bonus removed - never completed + return false; + } + + return false; + } + + /** + * Gets daily bonus status for a user. + * Returns availability status and cooldown time if on cooldown. + */ + /** Daily bonus removed - always returns unavailable. */ + public com.honey.honey.dto.DailyBonusStatusDto getDailyBonusStatus(Integer userId) { + return com.honey.honey.dto.DailyBonusStatusDto.builder() + .taskId(null) + .available(false) + .cooldownSeconds(null) + .rewardAmount(0L) + .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 + */ + /** Daily bonus removed - always returns empty list. */ + public List getRecentDailyBonusClaims(String timezone, String languageCode) { + return List.of(); + } + + /** + * 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/honey/honey/service/TelegramAuthService.java new file mode 100644 index 0000000..c1e4507 --- /dev/null +++ b/src/main/java/com/honey/honey/service/TelegramAuthService.java @@ -0,0 +1,188 @@ +package com.honey.honey.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.honey.honey.config.TelegramProperties; +import com.honey.honey.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TelegramAuthService { + + private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256"; + 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) + * Note: Referral handling is done via bot, not through WebApp initData. + */ + public Map validateAndParseInitData(String initData) { + + if (initData == null || initData.isBlank()) { + throw new UnauthorizedException(localizationService.getMessage("auth.error.initDataMissing")); + } + + try { + // Step 1. Parse query string into key/value pairs. + Map parsedData = parseQueryString(initData); + + String receivedHash = parsedData.remove("hash"); + if (receivedHash == null) { + throw new UnauthorizedException(localizationService.getMessage("auth.error.missingHash")); + } + + // Step 2. Build data check string. + String dataCheckString = createDataCheckString(parsedData); + + // Step 3. Derive secret key based on Telegram WebApp rules. + byte[] secretKey = deriveSecretKey(telegramProperties.getBotToken()); + + // Step 4. Calculate our own hash and compare. + String calculatedHash = calculateHmacSha256(dataCheckString, secretKey); + + if (!receivedHash.equals(calculatedHash)) { + log.warn("Telegram signature mismatch. Expected={}, Received={}", calculatedHash, receivedHash); + throw new UnauthorizedException(localizationService.getMessage("auth.error.invalidSignature")); + } + + // Step 5. Extract the user JSON from initData. + Map decoded = decodeQueryParams(initData); + String userJson = decoded.get("user"); + + if (userJson == null) { + throw new UnauthorizedException(localizationService.getMessage("auth.error.userFieldMissing")); + } + + // 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)); + // "start" parameter is not included for WebApp - referrals handled by bot + + return result; + + } catch (UnauthorizedException ex) { + throw ex; + + } catch (Exception ex) { + log.error("Telegram initData validation failed: {}", ex.getMessage(), ex); + throw new UnauthorizedException(localizationService.getMessage("auth.error.invalidInitData")); + } + } + + // ------------------------------------------- + // Internal helper methods + // ------------------------------------------- + + private Map parseQueryString(String queryString) { + Map params = new HashMap<>(); + + try { + String[] pairs = queryString.split("&"); + + for (String pair : pairs) { + int idx = pair.indexOf("="); + + if (idx <= 0) continue; + + String key = URLDecoder.decode(pair.substring(0, idx), UTF_8); + String value = URLDecoder.decode(pair.substring(idx + 1), UTF_8); + + params.put(key, value); + } + } catch (Exception ex) { + log.warn("Failed to parse initData query string: {}", ex.getMessage()); + } + + return params; + } + + private String createDataCheckString(Map data) { + List sortedKeys = new ArrayList<>(data.keySet()); + Collections.sort(sortedKeys); + + StringBuilder sb = new StringBuilder(); + + for (String key : sortedKeys) { + if (sb.length() > 0) sb.append("\n"); + sb.append(key).append("=").append(data.get(key)); + } + + return sb.toString(); + } + + private byte[] deriveSecretKey(String botToken) throws NoSuchAlgorithmException, InvalidKeyException { + Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM); + + // Telegram requires using "WebAppData" as the key for deriving secret + SecretKeySpec secretKeySpec = + new SecretKeySpec(WEB_APP_DATA_CONSTANT.getBytes(StandardCharsets.UTF_8), HMAC_SHA256_ALGORITHM); + + hmacSha256.init(secretKeySpec); + + return hmacSha256.doFinal(botToken.getBytes(StandardCharsets.UTF_8)); + } + + private String calculateHmacSha256(String data, byte[] key) + throws NoSuchAlgorithmException, InvalidKeyException { + + Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM); + + hmacSha256.init(secretKeySpec); + + byte[] hashBytes = hmacSha256.doFinal(data.getBytes(StandardCharsets.UTF_8)); + + return bytesToHex(hashBytes); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder hexString = new StringBuilder(bytes.length * 2); + + for (byte b : bytes) { + String hex = Integer.toHexString(0xff & b); + + if (hex.length() == 1) hexString.append('0'); + + hexString.append(hex); + } + + return hexString.toString(); + } + + private Map decodeQueryParams(String qs) { + Map map = new HashMap<>(); + + for (String part : qs.split("&")) { + int idx = part.indexOf('='); + + if (idx > 0) { + String key = URLDecoder.decode(part.substring(0, idx), UTF_8); + String val = URLDecoder.decode(part.substring(idx + 1), UTF_8); + map.put(key, val); + } + } + + return map; + } +} + diff --git a/src/main/java/com/honey/honey/service/TelegramBotApiService.java b/src/main/java/com/honey/honey/service/TelegramBotApiService.java new file mode 100644 index 0000000..81fba09 --- /dev/null +++ b/src/main/java/com/honey/honey/service/TelegramBotApiService.java @@ -0,0 +1,161 @@ +package com.honey.honey.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.honey.honey.dto.TelegramApiResponse; +import com.honey.honey.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/honey/honey/service/TelegramService.java b/src/main/java/com/honey/honey/service/TelegramService.java new file mode 100644 index 0000000..4c2667e --- /dev/null +++ b/src/main/java/com/honey/honey/service/TelegramService.java @@ -0,0 +1,106 @@ +package com.honey.honey.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.honey.honey.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 "@honey_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/honey/honey/service/TransactionService.java b/src/main/java/com/honey/honey/service/TransactionService.java new file mode 100644 index 0000000..7ff82fc --- /dev/null +++ b/src/main/java/com/honey/honey/service/TransactionService.java @@ -0,0 +1,169 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.TransactionDto; +import com.honey.honey.model.Transaction; +import com.honey.honey.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 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 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) + .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()); + + String typeEnumValue = transaction.getType().name(); + Integer taskIdToInclude = transaction.getTaskId(); + + return TransactionDto.builder() + .amount(transaction.getAmount()) + .date(date) + .type(typeEnumValue) + .taskId(taskIdToInclude) + .build(); + }); + } + + // Transaction type localization is handled in the frontend. + // 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/honey/honey/service/UserService.java b/src/main/java/com/honey/honey/service/UserService.java new file mode 100644 index 0000000..59e2796 --- /dev/null +++ b/src/main/java/com/honey/honey/service/UserService.java @@ -0,0 +1,478 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.ReferralDto; +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.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 0 + 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, 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/honey/honey/service/WithdrawalStatusSyncService.java b/src/main/java/com/honey/honey/service/WithdrawalStatusSyncService.java new file mode 100644 index 0000000..6900fff --- /dev/null +++ b/src/main/java/com/honey/honey/service/WithdrawalStatusSyncService.java @@ -0,0 +1,127 @@ +package com.honey.honey.service; + +import com.honey.honey.dto.WithdrawalInfoApiResponse; +import com.honey.honey.model.Payout; +import com.honey.honey.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/honey/honey/util/IpUtils.java new file mode 100644 index 0000000..4dc06e9 --- /dev/null +++ b/src/main/java/com/honey/honey/util/IpUtils.java @@ -0,0 +1,177 @@ +package com.honey.honey.util; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Utility for IP address handling with reverse proxy support. + */ +@Slf4j +public class IpUtils { + + /** + * Extracts client IP address from request, handling reverse proxies. + * Priority: + * 1. X-Forwarded-For header (first IP in chain) + * 2. Forwarded header (RFC 7239) + * 3. X-Real-IP header + * 4. Remote address (fallback) + * + * @param request HTTP request + * @return Client IP address as string (normalized), or null if not available + */ + public static String getClientIp(HttpServletRequest request) { + // 1) Standard proxy header (most common) + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank() && !"unknown".equalsIgnoreCase(xff)) { + // Format: "client, proxy1, proxy2" - take first IP + String first = xff.split(",")[0].trim(); + if (!first.isEmpty()) { + return normalize(first); + } + } + + // 2) RFC 7239 Forwarded header (optional, more complex) + String forwarded = request.getHeader("Forwarded"); + if (forwarded != null && !forwarded.isBlank()) { + String ip = extractFromForwardedHeader(forwarded); + if (ip != null) { + return normalize(ip); + } + } + + // 3) X-Real-IP header (some proxies use this) + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isBlank() && !"unknown".equalsIgnoreCase(xRealIp)) { + return normalize(xRealIp); + } + + // 4) Fallback to remote address + return normalize(request.getRemoteAddr()); + } + + /** + * Extracts IP from RFC 7239 Forwarded header. + * Format: "for=192.0.2.60;proto=http;by=203.0.113.43" + */ + private static String extractFromForwardedHeader(String forwarded) { + try { + // Look for "for=" pattern + int forIndex = forwarded.toLowerCase().indexOf("for="); + if (forIndex == -1) { + return null; + } + + // Extract value after "for=" + String afterFor = forwarded.substring(forIndex + 4); + // Value can be quoted or unquoted, and may contain port + // Stop at semicolon, comma, quote, or space + StringBuilder ipBuilder = new StringBuilder(); + for (int i = 0; i < afterFor.length(); i++) { + char c = afterFor.charAt(i); + if (c == ';' || c == ',' || c == ' ' || c == '"') { + break; + } + ipBuilder.append(c); + } + + String candidate = ipBuilder.toString().trim(); + return candidate.isEmpty() ? null : candidate; + } catch (Exception e) { + log.debug("Failed to parse Forwarded header: {}", forwarded, e); + return null; + } + } + + /** + * Normalizes IP address string: + * - Removes brackets from IPv6 (e.g., "[2001:db8::1]" -> "2001:db8::1") + * - Removes port number if present (e.g., "192.168.1.1:8080" -> "192.168.1.1") + * - Trims whitespace + * + * @param ip IP address string (may contain brackets, port, etc.) + * @return Normalized IP address, or null if input is null + */ + private static String normalize(String ip) { + if (ip == null) { + return null; + } + + ip = ip.trim(); + if (ip.isEmpty()) { + return null; + } + + // Handle IPv6 in brackets, e.g., "[2001:db8::1]" + if (ip.startsWith("[") && ip.endsWith("]")) { + ip = ip.substring(1, ip.length() - 1); + } + + // Handle "ip:port" formats + // For IPv4: "192.168.1.1:8080" -> "192.168.1.1" + // For IPv6: "2001:db8::1:8080" is ambiguous, but we try to detect port + int lastColon = ip.lastIndexOf(':'); + if (lastColon > 0) { + // Check if part after last colon looks like a port number + String afterColon = ip.substring(lastColon + 1); + if (afterColon.chars().allMatch(Character::isDigit)) { + // Likely a port number + String beforeColon = ip.substring(0, lastColon); + // For IPv4, we can safely remove port + if (beforeColon.contains(".") && !beforeColon.contains("::")) { + ip = beforeColon; + } + // For IPv6, we need to be more careful - only remove if it's clearly a port + // IPv6 addresses can contain colons, so we check if it's a valid IPv6 format + // For simplicity, if it contains "::" or more than 2 colons, assume it's IPv6 and don't remove + } + } + + return ip; + } + + /** + * Converts IP address string to varbinary(16) format for database storage. + * Supports both IPv4 and IPv6. + * + * @param ipAddress IP address as string + * @return IP address as byte array (4 bytes for IPv4, 16 bytes for IPv6), or null if invalid + */ + public static byte[] ipToBytes(String ipAddress) { + if (ipAddress == null || ipAddress.isEmpty()) { + return null; + } + + try { + InetAddress inetAddress = InetAddress.getByName(ipAddress); + return inetAddress.getAddress(); + } catch (UnknownHostException e) { + log.warn("Failed to parse IP address: {}", ipAddress, e); + return null; + } + } + + /** + * Converts IP address from varbinary(16) format to string. + * + * @param ipBytes IP address as byte array + * @return IP address as string, or null if invalid + */ + public static String bytesToIp(byte[] ipBytes) { + if (ipBytes == null || ipBytes.length == 0) { + return null; + } + + try { + InetAddress inetAddress = InetAddress.getByAddress(ipBytes); + return inetAddress.getHostAddress(); + } catch (UnknownHostException e) { + log.warn("Failed to convert IP bytes to string", e); + return null; + } + } +} + diff --git a/src/main/java/com/honey/honey/util/TelegramTokenRedactor.java b/src/main/java/com/honey/honey/util/TelegramTokenRedactor.java new file mode 100644 index 0000000..1ba9382 --- /dev/null +++ b/src/main/java/com/honey/honey/util/TelegramTokenRedactor.java @@ -0,0 +1,26 @@ +package com.honey.honey.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/honey/honey/util/TimeProvider.java new file mode 100644 index 0000000..c91268e --- /dev/null +++ b/src/main/java/com/honey/honey/util/TimeProvider.java @@ -0,0 +1,28 @@ +package com.honey.honey.util; + +import java.time.Instant; + +/** + * Centralized time provider for consistent time handling across the application. + * All time-related operations should use this provider. + */ +public class TimeProvider { + + /** + * Gets current Unix timestamp in seconds. + * @return Current epoch seconds + */ + public static long nowSeconds() { + return Instant.now().getEpochSecond(); + } + + /** + * Gets current Unix timestamp in milliseconds. + * @return Current epoch milliseconds + */ + public static long nowMillis() { + return Instant.now().toEpochMilli(); + } +} + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4d27811 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,168 @@ +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 + lifecycle: + timeout-per-shutdown-phase: 30s + + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/honey_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: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: false + dialect: org.hibernate.dialect.MySQLDialect + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + connect-retries: 20 + connect-retry-interval: 3000 + validate-on-migrate: false + repair: true + +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., "@honey_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: + # Maximum number of active sessions per user (multi-device support) + max-active-per-user: ${APP_SESSION_MAX_ACTIVE_PER_USER:5} + # Batch cleanup configuration + cleanup: + # Number of expired sessions to delete per batch + batch-size: ${APP_SESSION_CLEANUP_BATCH_SIZE:5000} + # 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 transactions and other data + 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 + 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} + + # 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:} + +# OpenAPI / Swagger (public API only; admin endpoints excluded via OpenApiConfig) +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + default-consumes-media-type: application/json + default-produces-media-type: application/json + +# 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: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + group: + readiness: + include: db,ping + liveness: + include: ping + health: + db: + enabled: true + diskspace: + enabled: false + ping: + enabled: true + 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..d3c014a --- /dev/null +++ b/src/main/resources/db/migration/V12__create_transactions_table.sql @@ -0,0 +1,15 @@ +-- 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, TASK_BONUS, CANCELLATION_OF_WITHDRAWAL', + task_id INT NULL COMMENT 'Task ID for TASK_BONUS 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..a70445d --- /dev/null +++ b/src/main/resources/db/migration/V17__add_indexes_for_performance_and_cleanup.sql @@ -0,0 +1,4 @@ +-- Add index on transactions for cleanup by created_at +CREATE INDEX idx_type_created_at ON transactions (type, created_at); + + 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..180fd41 --- /dev/null +++ b/src/main/resources/db/migration/V19__add_daily_bonus_task.sql @@ -0,0 +1,3 @@ +-- Daily bonus task removed (user_daily_bonus_claims table and related logic removed). + + diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 0000000..7794759 --- /dev/null +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,86 @@ +-- 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 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 '-', + `is_premium` int NOT NULL DEFAULT '0', + `language_code` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX', + `country_code` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX', + `device_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'XX', + `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', + 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 +CREATE TABLE `db_users_b` ( + `id` int NOT NULL DEFAULT '0', + `balance_a` bigint UNSIGNED NOT NULL DEFAULT '0', + `balance_b` bigint UNSIGNED NOT NULL DEFAULT '0', + `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', + KEY `id` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Create db_users_d table +CREATE TABLE `db_users_d` ( + `id` int NOT NULL DEFAULT '0', + `referer_id_1` int NOT NULL DEFAULT '0', + `referer_id_2` int NOT NULL DEFAULT '0', + `referer_id_3` int NOT NULL DEFAULT '0', + `referer_id_4` int NOT NULL DEFAULT '0', + `referer_id_5` int NOT NULL DEFAULT '0', + `master_id` int NOT NULL DEFAULT '0', + `referals_1` int NOT NULL DEFAULT '0', + `referals_2` int NOT NULL DEFAULT '0', + `referals_3` int NOT NULL DEFAULT '0', + `referals_4` int NOT NULL DEFAULT '0', + `referals_5` int NOT NULL DEFAULT '0', + `from_referals_1` bigint NOT NULL DEFAULT '0', + `from_referals_2` bigint NOT NULL DEFAULT '0', + `from_referals_3` bigint NOT NULL DEFAULT '0', + `from_referals_4` bigint NOT NULL DEFAULT '0', + `from_referals_5` bigint NOT NULL DEFAULT '0', + `to_referer_1` bigint NOT NULL DEFAULT '0', + `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', + 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 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/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..046d9de --- /dev/null +++ b/src/main/resources/db/migration/V24__add_admin_panel_indexes.sql @@ -0,0 +1,48 @@ +-- 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); + +-- ============================================ +-- 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/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/V36__create_feature_switches.sql b/src/main/resources/db/migration/V36__create_feature_switches.sql new file mode 100644 index 0000000..53796ca --- /dev/null +++ b/src/main/resources/db/migration/V36__create_feature_switches.sql @@ -0,0 +1,7 @@ +-- Runtime feature toggles. Can be changed from admin panel without restart. Kept empty (no seeds). +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; 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/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..ced6038 --- /dev/null +++ b/src/main/resources/db/migration/V48__feature_switches_payment_payout.sql @@ -0,0 +1 @@ +-- Feature switches: no seeds (kept empty). 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/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..7285f72 --- /dev/null +++ b/src/main/resources/db/migration/V53__admin_users_list_sort_indexes.sql @@ -0,0 +1,14 @@ +-- Indexes for admin users list sorting (Balance, Deposits, Withdraws, Referrals) +-- db_users_b: balance_a, deposit_total, withdraw_total +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); + +-- 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/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/V58__add_promotions_total_reward.sql b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql new file mode 100644 index 0000000..3a232f6 --- /dev/null +++ b/src/main/resources/db/migration/V58__add_promotions_total_reward.sql @@ -0,0 +1,3 @@ +-- 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; 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/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..80589fe --- /dev/null +++ b/src/main/resources/db/migration/V63__system_settings_bot_max_participants.sql @@ -0,0 +1,5 @@ +-- Configurations: key-value store for app-wide settings. +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; 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..3093b71 --- /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 `withdraw_count`; 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__feature_switch_start_game_button_enabled.sql b/src/main/resources/db/migration/V70__feature_switch_start_game_button_enabled.sql new file mode 100644 index 0000000..60a1755 --- /dev/null +++ b/src/main/resources/db/migration/V70__feature_switch_start_game_button_enabled.sql @@ -0,0 +1,4 @@ +-- Seed feature switch: "Start Game" inline button in Telegram bot (enabled by default). +INSERT INTO `feature_switches` (`key`, `enabled`, `updated_at`) +VALUES ('start_game_button_enabled', 1, CURRENT_TIMESTAMP) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP; 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/geoip/GeoLite2-Country.mmdb b/src/main/resources/geoip/GeoLite2-Country.mmdb new file mode 100644 index 0000000..280252c Binary files /dev/null and b/src/main/resources/geoip/GeoLite2-Country.mmdb differ diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..7c104be --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + %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..5056a6a --- /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 Honey! +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 Honey 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..0e69b27 --- /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 Honey! +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 Honey-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..038ff6d --- /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 Honey! +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 Honey: +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..be21d53 --- /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 Honey! +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 Honey: +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..47eae7e --- /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 Honey! +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 Honey: +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..76a9711 --- /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 Honey! +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 Honey: +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..cce2538 --- /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 Honey! +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 Honey-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..607fd2f --- /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 Honey! +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ę Honey: +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..a323f0c --- /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=Попробуйте удачу с Honey! +bot.welcome.message=Начните делать ставки, выигрывайте и выводите средства прямо на свой кошелёк.\n\n👉 Чтобы начать, посмотрите видео выше. +bot.message.startSpinning=Используйте эту кнопку, чтобы открыть приложение Honey: +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..10025d4 --- /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=Honey 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=Honey 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. +