replaced everything with ws

This commit is contained in:
Mykhailo Svishchov
2026-03-04 21:42:35 +02:00
parent 68d04f2203
commit 313bd13ef9
378 changed files with 29072 additions and 824 deletions

114
ADMIN_SETUP.md Normal file
View File

@@ -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 <token>`
- 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`

1024
APPLICATION_OVERVIEW.md Normal file

File diff suppressed because it is too large Load Diff

327
BACKUP_SETUP.md Normal file
View File

@@ -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!)

492
BACKUP_TROUBLESHOOTING.md Normal file
View File

@@ -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.

765
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -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 <your-backend-repo-url> .
```
### 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://<hostname>:<port>/<database-name>`
**For this deployment, use: `jdbc:mysql://db:3306/lottery_db`**
Breaking it down:
- `jdbc:mysql://` - JDBC protocol for MySQL
- `db` - This is the **service name** in `docker-compose.prod.yml` (acts as hostname in Docker network)
- `3306` - Default MySQL port (internal to Docker network)
- `lottery_db` - Database name (must match `MYSQL_DATABASE` in docker-compose)
**Why `db` as hostname?**
- In Docker Compose, services communicate using their **service names** as hostnames
- The MySQL service is named `db` in `docker-compose.prod.yml` (line 4: `services: db:`)
- Both containers are on the same Docker network (`lottery-network`)
- The backend container connects to MySQL using `db:3306` (not `localhost` or the VPS IP)
- This is an **internal Docker network connection** - MySQL is not exposed to the host
**Quick Reference:**
- ✅ Correct: `jdbc:mysql://db:3306/lottery_db` (uses service name)
- ❌ Wrong: `jdbc:mysql://localhost:3306/lottery_db` (won't work - localhost refers to the container itself)
- ❌ Wrong: `jdbc:mysql://127.0.0.1:3306/lottery_db` (won't work - same reason)
**Example credentials (use your own secure password!):**
- Database URL: `jdbc:mysql://db:3306/lottery_db`
- Database Name: `lottery_db`
- Username: `root`
- Password: `MySecurePassword123!`
**Note:** These credentials will be used in:
- The secret file (`SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD`)
- MySQL container environment variables (`DB_PASSWORD`, `DB_ROOT_PASSWORD`)
The MySQL container will be created automatically when you run `docker-compose`, and the database will be initialized with these credentials.
### 3.3 Create Secret Configuration File
The application uses a mounted secret file instead of environment variables for security. Create the secret file:
**Option 1: Copy from template (if template file exists)**
```bash
# Create the secrets directory (if it doesn't exist)
sudo mkdir -p /run/secrets
# Navigate to backend directory
cd /opt/app/backend
# Check if template file exists
ls -la lottery-config.properties.template
# If it exists, copy it
sudo cp lottery-config.properties.template /run/secrets/lottery-config.properties
```
**Option 2: Create the file directly (if template wasn't copied)**
If the template file doesn't exist in `/opt/app/backend/`, create the secret file directly:
```bash
# Create the secrets directory (if it doesn't exist)
sudo mkdir -p /run/secrets
# Create the secret file
sudo nano /run/secrets/lottery-config.properties
```
Then paste the following content (replace placeholder values):
```properties
# Lottery Application Configuration
# Replace all placeholder values with your actual configuration
# ============================================
# Database Configuration
# ============================================
# SPRING_DATASOURCE_URL format: jdbc:mysql://<hostname>:<port>/<database-name>
#
# 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<YOUR_BOT_TOKEN>/setWebhook" \
-d "url=https://yourdomain.com/api/telegram/webhook/<YOUR_WEBHOOK_TOKEN>"
```
Verify webhook:
```bash
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/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

202
DOCKER_LOGGING_SETUP.md Normal file
View File

@@ -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
<logger name="com.lottery" level="DEBUG"/>
```
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! 🚀

View File

@@ -18,15 +18,28 @@ WORKDIR /app
# Copy fat jar from build stage # Copy fat jar from build stage
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar
# Copy startup script # Copy startup script (optional - only used if secret file doesn't exist)
COPY scripts/create-secret-file.sh /app/create-secret-file.sh COPY scripts/create-secret-file.sh /app/create-secret-file.sh
RUN chmod +x /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 port (for local/docker-compose/documentation)
EXPOSE 8080 EXPOSE 8080
# Default environment variables (can be overridden in docker-compose)
ENV JAVA_OPTS="" ENV JAVA_OPTS=""
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
ENV LOG_DIR="/app/logs"
# Create secret file from env vars (for testing ConfigLoader) then start app # Start app
ENTRYPOINT ["sh", "-c", "/app/create-secret-file.sh && java $JAVA_OPTS -jar app.jar"] # 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"]

View File

@@ -18,10 +18,22 @@ WORKDIR /app
# Copy fat jar from build stage # Copy fat jar from build stage
COPY --from=build /app/target/*.jar app.jar 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 port (for internal communication with nginx)
EXPOSE 8080 EXPOSE 8080
# Default environment variables (can be overridden in docker-compose)
ENV JAVA_OPTS="" ENV JAVA_OPTS=""
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
ENV LOG_DIR="/app/logs"
# Start app with external logback config
# Ensure logback-spring.xml exists and is a file (not a directory)
ENTRYPOINT ["sh", "-c", "if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

88
EXTERNAL_API.md Normal file
View File

@@ -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

341
LOGGING_GUIDE.md Normal file
View File

@@ -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: <logger name="com.lottery" level="INFO"/>
# Change to: <logger name="com.lottery" level="DEBUG"/>
# 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
<!-- Change from: -->
<logger name="com.lottery" level="INFO"/>
<!-- To: -->
<logger name="com.lottery" level="DEBUG"/>
```
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
<!-- In logback-spring.xml, change: -->
<logger name="com.lottery.lottery.service.GameRoomService" level="WARN"/>
<!-- To: -->
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
### Method 3: Change Root Level
To change the root level for all loggers:
```xml
<!-- In logback-spring.xml, change: -->
<root level="INFO">
<!-- To: -->
<root level="DEBUG">
```
**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
<configuration scan="true" scanPeriod="30 seconds">
```
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
<root level="WARN">
```
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
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
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
<logger name="com.lottery.lottery.service.GameRoomService" level="WARN"/>
```
## 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
```

94
PHPMYADMIN_QUICK_START.md Normal file
View File

@@ -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.

355
PHPMYADMIN_SETUP.md Normal file
View File

@@ -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

217
QUICK_REFERENCE.md Normal file
View File

@@ -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`).

View File

@@ -1,6 +1,6 @@
# Honey Backend # Lottery Backend
Spring Boot backend application for Honey project. Spring Boot backend application for Lottery project.
## Technology Stack ## Technology Stack
@@ -30,7 +30,7 @@ Spring Boot backend application for Honey project.
3. **Create `.env` file** (for local development): 3. **Create `.env` file** (for local development):
```env ```env
DB_NAME=honey_db DB_NAME=lottery_db
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=password DB_PASSWORD=password
DB_ROOT_PASSWORD=password DB_ROOT_PASSWORD=password
@@ -75,7 +75,7 @@ Railway is the primary deployment platform for staging. It provides built-in log
1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**) 1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**)
2. If using GitHub: 2. If using GitHub:
- Connect your GitHub account - Connect your GitHub account
- Select the `honey-be` repository - Select the `lottery-be` repository
- Railway will automatically detect it's a Java/Maven project - Railway will automatically detect it's a Java/Maven project
3. If using Empty Service: 3. If using Empty Service:
- Click **"Empty Service"** - Click **"Empty Service"**
@@ -122,12 +122,12 @@ PORT=8080
1. In your backend service, go to **"Settings"** → **"Networking"** 1. In your backend service, go to **"Settings"** → **"Networking"**
2. Click **"Generate Domain"** to get a public URL 2. Click **"Generate Domain"** to get a public URL
3. Or use the default Railway domain 3. Or use the default Railway domain
4. Copy the URL (e.g., `https://honey-be-production.up.railway.app`) 4. Copy the URL (e.g., `https://lottery-be-production.up.railway.app`)
#### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway) #### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway)
1. In your Railway project, click **"+ New"** → **"GitHub Repo"** 1. In your Railway project, click **"+ New"** → **"GitHub Repo"**
2. Select your `honey-fe` repository 2. Select your `lottery-fe` repository
3. Railway will detect it's a Node.js project 3. Railway will detect it's a Node.js project
4. Add environment variable: 4. Add environment variable:
```env ```env
@@ -140,7 +140,7 @@ PORT=8080
If you need persistent storage: If you need persistent storage:
1. In your Railway project, click **"+ New"** → **"Volume"** 1. In your Railway project, click **"+ New"** → **"Volume"**
2. Name it (e.g., `honey-data`) 2. Name it (e.g., `lottery-data`)
3. Mount it to your service if needed 3. Mount it to your service if needed
### Inferno Deployment (Production Environment) ### Inferno Deployment (Production Environment)
@@ -184,16 +184,16 @@ Inferno Solution provides the production environment. It requires manual server
5. **Create project directory**: 5. **Create project directory**:
```bash ```bash
mkdir -p /opt/honey mkdir -p /opt/lottery
cd /opt/honey cd /opt/lottery
``` ```
#### Step 2: Clone Repository #### Step 2: Clone Repository
```bash ```bash
cd /opt/honey cd /opt/lottery
git clone https://github.com/your-username/honey-be.git git clone https://github.com/your-username/lottery-be.git
cd honey-be cd lottery-be
``` ```
#### Step 3: Create Secret Configuration File #### Step 3: Create Secret Configuration File
@@ -206,14 +206,14 @@ sudo mkdir -p /run/secrets
sudo chmod 700 /run/secrets sudo chmod 700 /run/secrets
# Create secret file # Create secret file
sudo nano /run/secrets/honey-config.properties sudo nano /run/secrets/lottery-config.properties
``` ```
Add the following content (replace with your actual values): Add the following content (replace with your actual values):
```properties ```properties
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
SPRING_DATASOURCE_USERNAME=honey_user SPRING_DATASOURCE_USERNAME=lottery_user
SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password
TELEGRAM_BOT_TOKEN=your_telegram_bot_token TELEGRAM_BOT_TOKEN=your_telegram_bot_token
FRONTEND_URL=https://your-frontend-domain.com FRONTEND_URL=https://your-frontend-domain.com
@@ -233,7 +233,7 @@ The `docker-compose.inferno.yml` file is already configured. Make sure it's pres
#### Step 5: Build and Start Services #### Step 5: Build and Start Services
```bash ```bash
cd /opt/honey/honey-be cd /opt/lottery/lottery-be
# Build and start all services # Build and start all services
docker-compose -f docker-compose.inferno.yml up -d --build docker-compose -f docker-compose.inferno.yml up -d --build
@@ -249,7 +249,7 @@ This will:
1. **Edit nginx configuration**: 1. **Edit nginx configuration**:
```bash ```bash
nano nginx/conf.d/honey.conf nano nginx/conf.d/lottery.conf
``` ```
2. **Update server_name** (if using HTTPS): 2. **Update server_name** (if using HTTPS):
@@ -278,7 +278,7 @@ This will:
sudo certbot --nginx -d your-domain.com sudo certbot --nginx -d your-domain.com
``` ```
3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/honey.conf`) 3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`)
4. **Reload nginx**: 4. **Reload nginx**:
```bash ```bash
@@ -304,21 +304,21 @@ sudo ufw enable
Create a systemd service to ensure services start on boot: Create a systemd service to ensure services start on boot:
```bash ```bash
sudo nano /etc/systemd/system/honey.service sudo nano /etc/systemd/system/lottery.service
``` ```
Add: Add:
```ini ```ini
[Unit] [Unit]
Description=Honey Application Description=Lottery Application
Requires=docker.service Requires=docker.service
After=docker.service After=docker.service
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
WorkingDirectory=/opt/honey/honey-be WorkingDirectory=/opt/lottery/lottery-be
ExecStart=/usr/local/bin/docker-compose -f docker-compose.inferno.yml up -d 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 ExecStop=/usr/local/bin/docker-compose -f docker-compose.inferno.yml down
TimeoutStartSec=0 TimeoutStartSec=0
@@ -331,8 +331,8 @@ Enable the service:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable honey.service sudo systemctl enable lottery.service
sudo systemctl start honey.service sudo systemctl start lottery.service
``` ```
#### Step 10: Set Up Grafana Integration (Production Logging) #### Step 10: Set Up Grafana Integration (Production Logging)
@@ -357,13 +357,13 @@ sudo systemctl start honey.service
- url: http://loki:3100/loki/api/v1/push - url: http://loki:3100/loki/api/v1/push
scrape_configs: scrape_configs:
- job_name: honey-backend - job_name: lottery-backend
docker_sd_configs: docker_sd_configs:
- host: unix:///var/run/docker.sock - host: unix:///var/run/docker.sock
refresh_interval: 5s refresh_interval: 5s
relabel_configs: relabel_configs:
- source_labels: [__meta_docker_container_name] - source_labels: [__meta_docker_container_name]
regex: honey-backend regex: lottery-backend
action: keep action: keep
``` ```
@@ -398,14 +398,14 @@ docker-compose -f docker-compose.inferno.yml logs -f app
**Update application**: **Update application**:
```bash ```bash
cd /opt/honey/honey-be cd /opt/lottery/lottery-be
git pull git pull
docker-compose -f docker-compose.inferno.yml up -d --build docker-compose -f docker-compose.inferno.yml up -d --build
``` ```
**Backup database**: **Backup database**:
```bash ```bash
docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p honey_db > backup_$(date +%Y%m%d).sql docker-compose -f docker-compose.inferno.yml exec db mysqldump -u lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql
``` ```
## Configuration ## Configuration
@@ -415,7 +415,7 @@ docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p
The application supports two configuration strategies: The application supports two configuration strategies:
1. **Environment Variables** (Railway): Set variables in Railway dashboard 1. **Environment Variables** (Railway): Set variables in Railway dashboard
2. **Secret File** (Inferno): Mount file at `/run/secrets/honey-config.properties` 2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties`
Priority: Secret file → Environment variables Priority: Secret file → Environment variables
@@ -511,10 +511,10 @@ docker-compose up --build
## Project Structure ## Project Structure
``` ```
honey-be/ lottery-be/
├── src/ ├── src/
│ ├── main/ │ ├── main/
│ │ ├── java/com/honey/honey/ │ │ ├── java/com/lottery/lottery/
│ │ │ ├── config/ # Configuration classes │ │ │ ├── config/ # Configuration classes
│ │ │ ├── controller/ # REST controllers │ │ │ ├── controller/ # REST controllers
│ │ │ ├── dto/ # Data transfer objects │ │ │ ├── dto/ # Data transfer objects
@@ -540,3 +540,4 @@ honey-be/
[Your License Here] [Your License Here]

356
ROLLING_UPDATE_GUIDE.md Normal file
View File

@@ -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

208
VPS_DEPLOYMENT_NOTES.md Normal file
View File

@@ -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
<logger name="com.lottery" level="DEBUG"/>
```
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
<logger name="com.lottery" level="DEBUG"/>
```
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
<logger name="com.lottery" level="DEBUG"/>
```
**Enable DEBUG for specific service:**
```xml
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
**Enable DEBUG for WebSocket:**
```xml
<logger name="com.lottery.lottery.controller.GameWebSocketController" level="DEBUG"/>
```
**Change root level (affects everything):**
```xml
<root level="DEBUG">
```
## 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

188
VPS_DEPLOYMENT_SUMMARY.md Normal file
View File

@@ -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

View File

@@ -3,48 +3,56 @@ version: "3.9"
services: services:
db: db:
image: mysql:8.0 image: mysql:8.0
container_name: honey-mysql container_name: lottery-mysql
restart: always restart: always
environment: environment:
MYSQL_DATABASE: honey_db MYSQL_DATABASE: lottery_db
MYSQL_USER: honey_user MYSQL_USER: lottery_user
MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes: volumes:
- honey_mysql_data:/var/lib/mysql - lottery_mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks: networks:
- honey-network - lottery-network
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile.inferno dockerfile: Dockerfile.inferno
container_name: honey-backend container_name: lottery-backend
restart: always restart: always
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
- SPRING_DATASOURCE_USERNAME=honey_user - SPRING_DATASOURCE_USERNAME=lottery_user
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD} - SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- FRONTEND_URL=${FRONTEND_URL} - FRONTEND_URL=${FRONTEND_URL}
# Logging configuration (external logback-spring.xml)
- LOGGING_CONFIG=/app/config/logback-spring.xml
- LOG_DIR=/app/logs
volumes: volumes:
# Mount secret file from tmpfs # Mount secret file from tmpfs
- /run/secrets:/run/secrets:ro - /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: networks:
- honey-network - lottery-network
# Don't expose port directly - nginx will handle it # Don't expose port directly - nginx will handle it
nginx: nginx:
image: nginx:alpine image: nginx:alpine
container_name: honey-nginx container_name: lottery-nginx
restart: always restart: always
ports: ports:
- "80:80" - "80:80"
@@ -57,12 +65,13 @@ services:
depends_on: depends_on:
- app - app
networks: networks:
- honey-network - lottery-network
volumes: volumes:
honey_mysql_data: lottery_mysql_data:
networks: networks:
honey-network: lottery-network:
driver: bridge driver: bridge

193
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,193 @@
version: "3.9"
services:
db:
image: mysql:8.0
container_name: lottery-mysql
restart: always
# Database credentials are read from the secret file via backend container
# The backend will construct the connection URL from SPRING_DATASOURCE_* properties
# For MySQL container, we need to set these via environment or use a separate secret
# Option 1: Use environment variables (for MySQL container only)
# Note: MYSQL_USER cannot be "root" - root user is configured via MYSQL_ROOT_PASSWORD only
environment:
MYSQL_DATABASE: lottery_db
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
# Option 2: Mount secret file and read values (more secure)
# This requires parsing the secret file or using a script
# For simplicity, we'll use environment variables for MySQL container
# The secret file is primarily for the backend application
# Do NOT expose MySQL port to host - only accessible within Docker network
# ports:
# - "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
# Mount MySQL performance configuration (created on VPS at /opt/app/mysql/conf/my.cnf)
- /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
# Resource limits for MySQL (16GB buffer pool + 2GB overhead)
deploy:
resources:
limits:
cpus: '2.0'
memory: 18G
healthcheck:
# Use shell to access environment variable (Docker Compose doesn't interpolate in healthcheck arrays)
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lottery-network
backend:
build:
context: .
dockerfile: Dockerfile
container_name: lottery-backend
depends_on:
db:
condition: service_healthy
# Expose backend port to localhost only (for Nginx on host to access)
# This is safe - only accessible from the host, not from internet
# Port 8080 is the primary/active backend
ports:
- "127.0.0.1:8080:8080"
# Labels for rolling update management
labels:
- "deployment.role=primary"
- "deployment.version=current"
volumes:
# Mount persistent avatar storage (absolute path for consistency)
- /opt/app/data/avatars:/app/data/avatars
# Mount secret configuration file (read-only)
- /run/secrets/lottery-config.properties:/run/secrets/lottery-config.properties:ro
# Mount logback config directory (editable on VPS without rebuilding)
# Note: File must exist on host before mounting. Run setup-logging.sh first.
- /opt/app/backend/config:/app/config:rw
# Mount logs directory (persistent storage)
- /opt/app/logs:/app/logs
environment:
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
# -Xms: Start with 10GB (prevents resizing overhead)
# -Xmx: Max limit 10GB
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
# -XX:MaxGCPauseMillis=200: Target max GC pause time
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# Logging configuration (external logback-spring.xml)
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
deploy:
resources:
limits:
cpus: '4.0'
memory: 12G
networks:
- lottery-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
backend-new:
# This service is used during rolling updates
# It will be started manually via deployment script
build:
context: .
dockerfile: Dockerfile
container_name: lottery-backend-new
depends_on:
db:
condition: service_healthy
# Port 8082 is the new/standby backend during deployment (8081 is used by phpMyAdmin)
ports:
- "127.0.0.1:8082:8080"
profiles:
- rolling-update
# Labels for rolling update management
labels:
- "deployment.role=standby"
- "deployment.version=new"
volumes:
# Mount persistent avatar storage (absolute path for consistency)
- /opt/app/data/avatars:/app/data/avatars
# Mount secret configuration file (read-only)
- /run/secrets/lottery-config.properties:/run/secrets/lottery-config.properties:ro
# Mount logback config directory (editable on VPS without rebuilding)
# Note: File must exist on host before mounting. Run setup-logging.sh first.
- /opt/app/backend/config:/app/config:rw
# Mount logs directory (persistent storage)
- /opt/app/logs:/app/logs
environment:
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
# -Xms: Start with 10GB (prevents resizing overhead)
# -Xmx: Max limit 10GB
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
# -XX:MaxGCPauseMillis=200: Target max GC pause time
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# Logging configuration (external logback-spring.xml)
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
deploy:
resources:
limits:
cpus: '4.0'
memory: 12G
networks:
- lottery-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
phpmyadmin:
image: phpmyadmin:latest
container_name: lottery-phpmyadmin
restart: always
depends_on:
db:
condition: service_healthy
# Expose phpMyAdmin to localhost only (Nginx will proxy it with path protection)
ports:
- "127.0.0.1:8081:80"
environment:
# Connect to MySQL service using Docker service name
PMA_HOST: db
PMA_PORT: 3306
# Use the same root password as MySQL container
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
# Security: Set upload limit
UPLOAD_LIMIT: 64M
# Configure absolute URI so phpMyAdmin generates correct URLs for assets
# This variable must be set from a secret file on the VPS (not in git)
# Example: export PMA_ABSOLUTE_URI="https://win-spin.live/your-secret-path"
PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
# Tell phpMyAdmin it's behind a proxy using HTTPS
PMA_SSL: "true"
# Trust proxy headers (X-Forwarded-Proto, etc.)
PMA_TRUSTED_PROXIES: "127.0.0.1"
networks:
- lottery-network
# Resource limits for phpMyAdmin
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
volumes:
mysql_data:
driver: local
networks:
lottery-network:
driver: bridge

View File

@@ -3,17 +3,17 @@ version: "3.9"
services: services:
db: db:
image: mysql:8.0 image: mysql:8.0
container_name: honey-mysql container_name: lottery-mysql
restart: always restart: always
environment: environment:
MYSQL_DATABASE: ${DB_NAME:honey_db} MYSQL_DATABASE: ${DB_NAME:lottery_db}
MYSQL_USER: ${DB_USERNAME:root} MYSQL_USER: ${DB_USERNAME:root}
MYSQL_PASSWORD: ${DB_PASSWORD:password} MYSQL_PASSWORD: ${DB_PASSWORD:password}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password}
ports: ports:
- "3306:3306" - "3306:3306"
volumes: volumes:
- honey_mysql_data:/var/lib/mysql - lottery_mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"]
interval: 10s interval: 10s
@@ -24,17 +24,18 @@ services:
env_file: env_file:
- .env - .env
build: . build: .
container_name: honey-backend container_name: lottery-backend
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db} - SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:lottery_db}
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root} - SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root}
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password} - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password}
volumes: volumes:
honey_mysql_data: lottery_mysql_data:

View File

@@ -0,0 +1,62 @@
# Lottery Application Configuration
# Copy this file to /run/secrets/lottery-config.properties on your VPS
# Replace all placeholder values with your actual configuration
# ============================================
# Database Configuration
# ============================================
# SPRING_DATASOURCE_URL format: jdbc:mysql://<hostname>:<port>/<database-name>
#
# How to determine the URL:
# - Hostname: 'db' (this is the MySQL service name in docker-compose.prod.yml)
# * In Docker Compose, services communicate using their service names
# * The MySQL service is named 'db', so use 'db' as the hostname
# * Both containers are on the same Docker network, so 'db' resolves to the MySQL container
# - Port: '3306' (default MySQL port, internal to Docker network)
# - Database name: 'lottery_db' (must match MYSQL_DATABASE in docker-compose.prod.yml)
#
# Example: jdbc:mysql://db:3306/lottery_db
# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘
# │ │ │ └─ Database name
# │ │ └─ Port (3306 is MySQL default)
# │ └─ Service name in docker-compose (acts as hostname)
# └─ JDBC protocol for MySQL
#
# IMPORTANT: Use 'db' as hostname, NOT 'localhost' or '127.0.0.1'
# This is an internal Docker network connection
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here
# ============================================
# Telegram Bot Configuration
# ============================================
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here
TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name
# ============================================
# Frontend Configuration
# ============================================
FRONTEND_URL=https://yourdomain.com
# ============================================
# Avatar Storage Configuration
# ============================================
APP_AVATAR_STORAGE_PATH=/app/data/avatars
APP_AVATAR_PUBLIC_BASE_URL=
APP_AVATAR_MAX_SIZE_BYTES=2097152
APP_AVATAR_MAX_DIMENSION=512
# ============================================
# Session Configuration (Optional - defaults shown)
# ============================================
APP_SESSION_MAX_ACTIVE_PER_USER=5
APP_SESSION_CLEANUP_BATCH_SIZE=5000
APP_SESSION_CLEANUP_MAX_BATCHES=20
# ============================================
# GeoIP Configuration (Optional)
# ============================================
GEOIP_DB_PATH=

128
nginx.conf.template Normal file
View File

@@ -0,0 +1,128 @@
# Nginx configuration for Lottery Application
# Place this file at: /opt/app/nginx/nginx.conf
#
# This configuration assumes:
# - Frontend static files are at: /opt/app/frontend/dist
# - Avatar files are at: /opt/app/data/avatars
# - Backend is accessible at: http://127.0.0.1:8080 (exposed to localhost only)
# - HTTPS is handled by Nginx (SSL certificates should be configured separately)
# Upstream backend (using localhost since Nginx runs on host, not in Docker)
upstream backend {
server 127.0.0.1:8080;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name _; # Replace with your domain name
# For Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name _; # Replace with your domain name
# SSL certificate configuration
# Update these paths to your actual certificate files
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# SSL configuration (recommended settings)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Root directory for frontend static files
root /opt/app/frontend/dist;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Serve avatar files with aggressive caching
# Avatars are served by backend, but we can also serve them directly from filesystem
location /avatars/ {
alias /opt/app/data/avatars/;
expires 1h;
add_header Cache-Control "public, immutable";
access_log off;
}
# Backend API endpoints
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts for long-running requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket endpoint (for game updates)
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Frontend static files (SPA routing)
location / {
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public";
}
# Cache static assets (JS, CSS, images)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Health check endpoint (optional, for monitoring)
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@@ -1,5 +1,12 @@
upstream honey_backend { upstream lottery_backend {
server app:8080; # 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 { server {
@@ -16,7 +23,7 @@ server {
# API endpoints # API endpoints
location /api/ { location /api/ {
proxy_pass http://honey_backend; proxy_pass http://lottery_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
@@ -34,7 +41,7 @@ server {
# Actuator endpoints (for health checks) # Actuator endpoints (for health checks)
location /actuator/ { location /actuator/ {
proxy_pass http://honey_backend; proxy_pass http://lottery_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -44,7 +51,7 @@ server {
# Ping endpoint # Ping endpoint
location /ping { location /ping {
proxy_pass http://honey_backend; proxy_pass http://lottery_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
@@ -74,3 +81,4 @@ server {
# # ... # # ...
# } # }

View File

@@ -32,3 +32,4 @@ http {
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
} }

49
pom.xml
View File

@@ -5,8 +5,8 @@
http://maven.apache.org/xsd/maven-4.0.0.xsd"> http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.honey</groupId> <groupId>com.lottery</groupId>
<artifactId>honey-be</artifactId> <artifactId>lottery-be</artifactId>
<version>1.0.0</version> <version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
@@ -79,6 +79,51 @@
<version>4.2.0</version> <version>4.2.0</version>
</dependency> </dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Telegram Bot API -->
<dependency>
<groupId>org.telegram</groupId>
<artifactId>telegrambots</artifactId>
<version>6.9.0</version>
<exclusions>
<!-- Exclude Jackson JAXB module that uses old javax.xml.bind API -->
<exclusion>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-jaxb-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

216
scripts/backup-database.sh Normal file
View File

@@ -0,0 +1,216 @@
#!/bin/bash
# Database Backup Script for Lottery Application
# This script creates a MySQL dump and transfers it to the backup VPS
#
# Usage:
# ./scripts/backup-database.sh [--keep-local] [--compress]
#
# Options:
# --keep-local Keep a local copy of the backup (default: delete after transfer)
# --compress Compress the backup before transfer (default: gzip)
#
# Prerequisites:
# 1. SSH key-based authentication to backup VPS (5.45.77.77)
# 2. Database password accessible via /run/secrets/lottery-config.properties
# 3. Docker container 'lottery-mysql' running
#
# Backup location on backup VPS: /raid/backup/acc_260182/
set -euo pipefail
# Configuration
BACKUP_VPS_HOST="5.45.77.77"
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
BACKUP_VPS_PATH="/raid/backup/acc_260182"
MYSQL_CONTAINER="lottery-mysql"
MYSQL_DATABASE="lottery_db"
SECRET_FILE="/run/secrets/lottery-config.properties"
BACKUP_DIR="/opt/app/backups"
KEEP_LOCAL=false
COMPRESS=true
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--keep-local)
KEEP_LOCAL=true
shift
;;
--no-compress)
COMPRESS=false
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--keep-local] [--no-compress]"
exit 1
;;
esac
done
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging function
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Load database password
if [ ! -f "$SECRET_FILE" ]; then
error "Secret file not found at $SECRET_FILE"
exit 1
fi
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
error "SPRING_DATASOURCE_PASSWORD not found in secret file"
exit 1
fi
# Check if MySQL container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then
error "MySQL container '${MYSQL_CONTAINER}' is not running"
exit 1
fi
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_DIR"
# Generate backup filename with timestamp
TIMESTAMP=$(date +'%Y%m%d_%H%M%S')
BACKUP_FILENAME="lottery_db_backup_${TIMESTAMP}.sql"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
# If compression is enabled, add .gz extension
if [ "$COMPRESS" = true ]; then
BACKUP_FILENAME="${BACKUP_FILENAME}.gz"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
fi
log "Starting database backup..."
log "Database: ${MYSQL_DATABASE}"
log "Container: ${MYSQL_CONTAINER}"
log "Backup file: ${BACKUP_FILENAME}"
# Create MySQL dump
log "Creating MySQL dump..."
if [ "$COMPRESS" = true ]; then
# Dump and compress in one step (saves disk space)
if docker exec "${MYSQL_CONTAINER}" mysqldump \
-u root \
-p"${DB_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--events \
--quick \
--lock-tables=false \
"${MYSQL_DATABASE}" | gzip > "${BACKUP_PATH}"; then
log "✅ Database dump created and compressed: ${BACKUP_PATH}"
else
error "Failed to create database dump"
exit 1
fi
else
# Dump without compression
if docker exec "${MYSQL_CONTAINER}" mysqldump \
-u root \
-p"${DB_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--events \
--quick \
--lock-tables=false \
"${MYSQL_DATABASE}" > "${BACKUP_PATH}"; then
log "✅ Database dump created: ${BACKUP_PATH}"
else
error "Failed to create database dump"
exit 1
fi
fi
# Get backup file size
BACKUP_SIZE=$(du -h "${BACKUP_PATH}" | cut -f1)
log "Backup size: ${BACKUP_SIZE}"
# Transfer to backup VPS
log "Transferring backup to backup VPS (${BACKUP_VPS_HOST})..."
# Test SSH connection first
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
error "Cannot connect to backup VPS via SSH"
error "Please ensure:"
error " 1. SSH key-based authentication is set up"
error " 2. Backup VPS is accessible from this server"
error " 3. User '${BACKUP_VPS_USER}' has access to ${BACKUP_VPS_PATH}"
if [ "$KEEP_LOCAL" = true ]; then
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
else
rm -f "${BACKUP_PATH}"
fi
exit 1
fi
# Create backup directory on remote VPS if it doesn't exist
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "mkdir -p ${BACKUP_VPS_PATH}"
# Transfer the backup file
if scp "${BACKUP_PATH}" "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/"; then
log "✅ Backup transferred successfully to ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"
# Verify remote file exists
REMOTE_SIZE=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "du -h ${BACKUP_VPS_PATH}/${BACKUP_FILENAME} 2>/dev/null | cut -f1" || echo "0")
if [ "$REMOTE_SIZE" != "0" ]; then
log "✅ Remote backup verified (size: ${REMOTE_SIZE})"
else
warn "Could not verify remote backup file"
fi
else
error "Failed to transfer backup to backup VPS"
if [ "$KEEP_LOCAL" = true ]; then
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
else
rm -f "${BACKUP_PATH}"
fi
exit 1
fi
# Clean up local backup if not keeping it
if [ "$KEEP_LOCAL" = false ]; then
rm -f "${BACKUP_PATH}"
log "Local backup file removed (transferred successfully)"
fi
# Clean up old backups on remote VPS (keep last 10 days)
log "Cleaning up old backups on remote VPS (keeping last 10 days)..."
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +10 -delete" || warn "Failed to clean up old backups"
# Count remaining backups
BACKUP_COUNT=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "ls -1 ${BACKUP_VPS_PATH}/lottery_db_backup_*.sql* 2>/dev/null | wc -l" || echo "0")
log "Total backups on remote VPS: ${BACKUP_COUNT}"
log "✅ Backup completed successfully!"
log " Remote location: ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Script to create secret file from template
# Usage: ./create-secret-file-from-template.sh /path/to/template /path/to/output
TEMPLATE_FILE="${1:-lottery-config.properties.template}"
OUTPUT_FILE="${2:-/run/secrets/lottery-config.properties}"
OUTPUT_DIR=$(dirname "$OUTPUT_FILE")
# Check if template exists
if [ ! -f "$TEMPLATE_FILE" ]; then
echo "❌ Template file not found: $TEMPLATE_FILE"
exit 1
fi
# Create output directory if it doesn't exist
mkdir -p "$OUTPUT_DIR"
# Copy template to output
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
# Set secure permissions (read-only for owner, no access for others)
chmod 600 "$OUTPUT_FILE"
echo "✅ Secret file created at $OUTPUT_FILE"
echo "⚠️ IMPORTANT: Edit this file and replace all placeholder values with your actual configuration!"
echo "⚠️ After editing, ensure permissions are secure: chmod 600 $OUTPUT_FILE"

View File

@@ -3,7 +3,7 @@
# Create secret file from environment variables for testing ConfigLoader # Create secret file from environment variables for testing ConfigLoader
# This simulates the mounted secret file approach used in Inferno # This simulates the mounted secret file approach used in Inferno
SECRET_FILE="/run/secrets/honey-config.properties" SECRET_FILE="/run/secrets/lottery-config.properties"
SECRET_DIR="/run/secrets" SECRET_DIR="/run/secrets"
# Create directory if it doesn't exist # Create directory if it doesn't exist
@@ -25,3 +25,4 @@ chmod 644 "$SECRET_FILE"
echo "✅ Secret file created at $SECRET_FILE from environment variables" echo "✅ Secret file created at $SECRET_FILE from environment variables"

View File

@@ -0,0 +1,227 @@
#!/bin/bash
# Diagnostic script for backup-database.sh permission issues
# Run this on your VPS to identify the root cause
SCRIPT="/opt/app/backend/lottery-be/scripts/backup-database.sh"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "=========================================="
echo "Backup Script Permission Diagnostic"
echo "=========================================="
echo ""
# 1. File exists
echo "1. Checking if file exists..."
if [ -f "$SCRIPT" ]; then
echo -e " ${GREEN}✅ File exists${NC}"
else
echo -e " ${RED}❌ File NOT found at: $SCRIPT${NC}"
echo " Please verify the path."
exit 1
fi
echo ""
# 2. File permissions
echo "2. File permissions:"
ls -la "$SCRIPT"
echo ""
# 3. Is executable
echo "3. Is file executable?"
if [ -x "$SCRIPT" ]; then
echo -e " ${GREEN}✅ File is executable${NC}"
else
echo -e " ${RED}❌ File is NOT executable${NC}"
echo " Fix: chmod +x $SCRIPT"
fi
echo ""
# 4. Shebang line
echo "4. Shebang line (first line):"
SHEBANG=$(head -1 "$SCRIPT")
echo " $SHEBANG"
if [[ "$SHEBANG" == "#!/bin/bash" ]] || [[ "$SHEBANG" == "#!/usr/bin/bash" ]]; then
echo -e " ${GREEN}✅ Shebang looks correct${NC}"
else
echo -e " ${YELLOW}⚠️ Unexpected shebang${NC}"
fi
echo ""
# 5. Bash exists
echo "5. Checking if bash interpreter exists:"
if [ -f /bin/bash ]; then
echo -e " ${GREEN}✅ /bin/bash exists${NC}"
/bin/bash --version | head -1
elif [ -f /usr/bin/bash ]; then
echo -e " ${GREEN}✅ /usr/bin/bash exists${NC}"
/usr/bin/bash --version | head -1
else
echo -e " ${RED}❌ bash not found in /bin/bash or /usr/bin/bash${NC}"
echo " Found at: $(which bash 2>/dev/null || echo 'NOT FOUND')"
fi
echo ""
# 6. Line endings
echo "6. Checking line endings:"
FILE_TYPE=$(file "$SCRIPT")
echo " $FILE_TYPE"
if echo "$FILE_TYPE" | grep -q "CRLF"; then
echo -e " ${RED}❌ File has Windows line endings (CRLF)${NC}"
echo " Fix: dos2unix $SCRIPT"
echo " Or: sed -i 's/\r$//' $SCRIPT"
elif echo "$FILE_TYPE" | grep -q "ASCII text"; then
echo -e " ${GREEN}✅ Line endings look correct (LF)${NC}"
else
echo -e " ${YELLOW}⚠️ Could not determine line endings${NC}"
fi
echo ""
# 7. Mount options
echo "7. Checking filesystem mount options:"
MOUNT_INFO=$(mount | grep -E "(/opt|/app)" || echo "Not a separate mount")
echo " $MOUNT_INFO"
if echo "$MOUNT_INFO" | grep -q "noexec"; then
echo -e " ${RED}❌ Filesystem mounted with 'noexec' flag${NC}"
echo " This prevents script execution!"
echo " Fix: Remove 'noexec' from /etc/fstab and remount"
else
echo -e " ${GREEN}✅ No 'noexec' flag detected${NC}"
fi
echo ""
# 8. SELinux
echo "8. Checking SELinux:"
if command -v getenforce &> /dev/null; then
SELINUX_STATUS=$(getenforce 2>/dev/null)
echo " Status: $SELINUX_STATUS"
if [ "$SELINUX_STATUS" = "Enforcing" ]; then
echo -e " ${YELLOW}⚠️ SELinux is enforcing - may block execution${NC}"
echo " Check context: ls -Z $SCRIPT"
else
echo -e " ${GREEN}✅ SELinux not blocking (or disabled)${NC}"
fi
else
echo -e " ${GREEN}✅ SELinux not installed${NC}"
fi
echo ""
# 9. Directory permissions
echo "9. Parent directory permissions:"
DIR=$(dirname "$SCRIPT")
ls -ld "$DIR"
if [ -x "$DIR" ]; then
echo -e " ${GREEN}✅ Directory is executable${NC}"
else
echo -e " ${RED}❌ Directory is NOT executable${NC}"
echo " Fix: chmod +x $DIR"
fi
echo ""
# 10. Syntax check
echo "10. Checking script syntax:"
if bash -n "$SCRIPT" 2>&1; then
echo -e " ${GREEN}✅ Syntax is valid${NC}"
else
echo -e " ${RED}❌ Syntax errors found${NC}"
bash -n "$SCRIPT"
fi
echo ""
# 11. Test execution
echo "11. Testing script execution (dry run):"
echo " Attempting to read first 10 lines..."
if head -10 "$SCRIPT" > /dev/null 2>&1; then
echo -e " ${GREEN}✅ Can read script${NC}"
else
echo -e " ${RED}❌ Cannot read script${NC}"
fi
echo ""
# 12. Cron job check
echo "12. Checking cron configuration:"
if [ "$EUID" -eq 0 ]; then
echo " Root's crontab:"
crontab -l 2>/dev/null | grep -i backup || echo " (No backup cron job found in root's crontab)"
echo ""
echo " To check cron job, run: sudo crontab -l"
else
echo " (Run as root to check crontab: sudo crontab -l)"
fi
echo ""
# 13. Environment check
echo "13. Checking required commands:"
REQUIRED_COMMANDS=("docker" "ssh" "gzip" "bash")
for cmd in "${REQUIRED_COMMANDS[@]}"; do
if command -v "$cmd" &> /dev/null; then
CMD_PATH=$(which "$cmd")
echo -e " ${GREEN}$cmd${NC} found at: $CMD_PATH"
else
echo -e " ${RED}$cmd${NC} NOT found in PATH"
fi
done
echo ""
# 14. Secret file check
echo "14. Checking secret file:"
SECRET_FILE="/run/secrets/lottery-config.properties"
if [ -f "$SECRET_FILE" ]; then
echo -e " ${GREEN}✅ Secret file exists${NC}"
if [ -r "$SECRET_FILE" ]; then
echo -e " ${GREEN}✅ Secret file is readable${NC}"
else
echo -e " ${RED}❌ Secret file is NOT readable${NC}"
fi
else
echo -e " ${YELLOW}⚠️ Secret file not found (script will fail at runtime)${NC}"
fi
echo ""
# Summary
echo "=========================================="
echo "Summary & Recommendations"
echo "=========================================="
ISSUES=0
if [ ! -x "$SCRIPT" ]; then
echo -e "${RED}❌ Issue: File is not executable${NC}"
echo " Fix: chmod +x $SCRIPT"
ISSUES=$((ISSUES + 1))
fi
if file "$SCRIPT" | grep -q "CRLF"; then
echo -e "${RED}❌ Issue: Windows line endings detected${NC}"
echo " Fix: dos2unix $SCRIPT (or: sed -i 's/\r$//' $SCRIPT)"
ISSUES=$((ISSUES + 1))
fi
if mount | grep -E "(/opt|/app)" | grep -q "noexec"; then
echo -e "${RED}❌ Issue: Filesystem mounted with noexec${NC}"
echo " Fix: Remove noexec from /etc/fstab and remount"
ISSUES=$((ISSUES + 1))
fi
if [ "$ISSUES" -eq 0 ]; then
echo -e "${GREEN}✅ No obvious issues found${NC}"
echo ""
echo "If cron still fails, try:"
echo " 1. Update cron to use bash explicitly:"
echo " 0 2 * * * /bin/bash $SCRIPT >> /opt/app/logs/backup.log 2>&1"
echo ""
echo " 2. Check cron logs:"
echo " sudo journalctl -u cron | tail -50"
echo ""
echo " 3. Test manual execution:"
echo " sudo $SCRIPT --keep-local"
else
echo ""
echo -e "${YELLOW}Found $ISSUES issue(s) that need to be fixed.${NC}"
fi
echo ""
echo "=========================================="

View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Script to load database password from secret file
# This ensures DB_PASSWORD and DB_ROOT_PASSWORD match SPRING_DATASOURCE_PASSWORD
# Usage: source ./load-db-password.sh
SECRET_FILE="/run/secrets/lottery-config.properties"
if [ ! -f "$SECRET_FILE" ]; then
echo "❌ Error: Secret file not found at $SECRET_FILE"
echo " Please create the secret file first (see deployment guide Step 3.3)"
return 1 2>/dev/null || exit 1
fi
# Read SPRING_DATASOURCE_PASSWORD from secret file
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
echo "❌ Error: SPRING_DATASOURCE_PASSWORD not found in secret file"
echo " Please ensure the secret file contains: SPRING_DATASOURCE_PASSWORD=your_password"
return 1 2>/dev/null || exit 1
fi
# Export both variables (MySQL uses both)
export DB_PASSWORD="$DB_PASSWORD"
export DB_ROOT_PASSWORD="$DB_PASSWORD"
# Optionally load PMA_ABSOLUTE_URI from secret file (for phpMyAdmin path protection)
PMA_ABSOLUTE_URI=$(grep "^PMA_ABSOLUTE_URI=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/^"//;s/"$//' | sed "s/^'//;s/'$//")
if [ -n "$PMA_ABSOLUTE_URI" ]; then
export PMA_ABSOLUTE_URI="$PMA_ABSOLUTE_URI"
echo "✅ PMA_ABSOLUTE_URI loaded from secret file"
fi
echo "✅ Database password loaded from secret file"
echo " DB_PASSWORD and DB_ROOT_PASSWORD are now set (matching SPRING_DATASOURCE_PASSWORD)"

183
scripts/restore-database.sh Normal file
View File

@@ -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 <backup-file>
#
# Examples:
# # Restore from local file
# ./scripts/restore-database.sh /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz
#
# # Restore from backup VPS
# ./scripts/restore-database.sh 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz
#
# Prerequisites:
# 1. Database password accessible via /run/secrets/lottery-config.properties
# 2. Docker container 'lottery-mysql' running
# 3. Database will be DROPPED and RECREATED (all data will be lost!)
set -euo pipefail
# Configuration
MYSQL_CONTAINER="lottery-mysql"
MYSQL_DATABASE="lottery_db"
SECRET_FILE="/run/secrets/lottery-config.properties"
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging function
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# Check arguments
if [ $# -eq 0 ]; then
error "No backup file specified"
echo "Usage: $0 <backup-file>"
echo ""
echo "Examples:"
echo " $0 /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz"
echo " $0 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz"
exit 1
fi
BACKUP_SOURCE="$1"
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Load database password
if [ ! -f "$SECRET_FILE" ]; then
error "Secret file not found at $SECRET_FILE"
exit 1
fi
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
error "SPRING_DATASOURCE_PASSWORD not found in secret file"
exit 1
fi
# Check if MySQL container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then
error "MySQL container '${MYSQL_CONTAINER}' is not running"
exit 1
fi
# Determine if backup is remote or local
TEMP_BACKUP="/tmp/restore_backup_$$"
BACKUP_IS_COMPRESSED=false
if [[ "$BACKUP_SOURCE" == *":"* ]]; then
# Remote backup (format: host:/path/to/file)
log "Detected remote backup: ${BACKUP_SOURCE}"
HOST_PATH=(${BACKUP_SOURCE//:/ })
REMOTE_HOST="${HOST_PATH[0]}"
REMOTE_PATH="${HOST_PATH[1]}"
log "Downloading backup from ${REMOTE_HOST}..."
if scp "${REMOTE_HOST}:${REMOTE_PATH}" "${TEMP_BACKUP}"; then
log "✅ Backup downloaded successfully"
else
error "Failed to download backup from remote VPS"
exit 1
fi
else
# Local backup
if [ ! -f "$BACKUP_SOURCE" ]; then
error "Backup file not found: ${BACKUP_SOURCE}"
exit 1
fi
log "Using local backup: ${BACKUP_SOURCE}"
cp "$BACKUP_SOURCE" "${TEMP_BACKUP}"
fi
# Check if backup is compressed
if [[ "$TEMP_BACKUP" == *.gz ]] || file "$TEMP_BACKUP" | grep -q "gzip compressed"; then
BACKUP_IS_COMPRESSED=true
log "Backup is compressed (gzip)"
fi
# Get backup file size
BACKUP_SIZE=$(du -h "${TEMP_BACKUP}" | cut -f1)
log "Backup size: ${BACKUP_SIZE}"
# WARNING: This will destroy all existing data!
warn "⚠️ WARNING: This will DROP and RECREATE the database '${MYSQL_DATABASE}'"
warn "⚠️ ALL EXISTING DATA WILL BE LOST!"
echo ""
read -p "Are you sure you want to continue? Type 'YES' to confirm: " CONFIRM
if [ "$CONFIRM" != "YES" ]; then
log "Restore cancelled by user"
rm -f "${TEMP_BACKUP}"
exit 0
fi
log "Starting database restore..."
# Drop and recreate database
log "Dropping existing database (if exists)..."
docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "DROP DATABASE IF EXISTS ${MYSQL_DATABASE};" || true
log "Creating fresh database..."
docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "CREATE DATABASE ${MYSQL_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# Restore database
log "Restoring database from backup..."
if [ "$BACKUP_IS_COMPRESSED" = true ]; then
# Restore from compressed backup
if gunzip -c "${TEMP_BACKUP}" | docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}"; then
log "✅ Database restored successfully from compressed backup"
else
error "Failed to restore database"
rm -f "${TEMP_BACKUP}"
exit 1
fi
else
# Restore from uncompressed backup
if docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}" < "${TEMP_BACKUP}"; then
log "✅ Database restored successfully"
else
error "Failed to restore database"
rm -f "${TEMP_BACKUP}"
exit 1
fi
fi
# Clean up temporary file
rm -f "${TEMP_BACKUP}"
# Verify restore
log "Verifying restore..."
TABLE_COUNT=$(docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${MYSQL_DATABASE}';" 2>/dev/null || echo "0")
if [ "$TABLE_COUNT" -gt 0 ]; then
log "✅ Restore verified: ${TABLE_COUNT} tables found in database"
else
warn "⚠️ Warning: No tables found in database after restore"
fi
log "✅ Database restore completed!"
warn "⚠️ Remember to restart the backend container if it's running:"
warn " docker restart lottery-backend"

628
scripts/rolling-update.sh Normal file
View File

@@ -0,0 +1,628 @@
#!/bin/bash
# Rolling Update Deployment Script
# This script performs zero-downtime deployment by:
# 1. Building new backend image
# 2. Starting new backend container on port 8082
# 3. Health checking the new container
# 4. Updating Nginx to point to new container
# 5. Reloading Nginx (zero downtime)
# 6. Stopping old container after grace period
set -euo pipefail
# Colors (define early for use in config detection)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Logging functions (define early)
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.prod.yml"
# Detect Nginx config file (try common locations)
# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths
NGINX_CONF="${NGINX_CONF:-}"
if [ -z "$NGINX_CONF" ]; then
if [ -f "/etc/nginx/sites-enabled/win-spin.live" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/win-spin.live"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/sites-enabled/win-spin.live.conf" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/win-spin.live.conf"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/conf.d/lottery.conf" ]; then
NGINX_CONF="/etc/nginx/conf.d/lottery.conf"
log "Using Nginx config: $NGINX_CONF (conf.d)"
elif [ -f "/opt/app/nginx/win-spin.live.conf" ]; then
warn "Found config at /opt/app/nginx/win-spin.live.conf"
warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..."
if [ -L "/etc/nginx/sites-enabled/win-spin.live" ] || [ -L "/etc/nginx/sites-enabled/win-spin.live.conf" ]; then
# Find the actual target
local target=$(readlink -f /etc/nginx/sites-enabled/win-spin.live 2>/dev/null || readlink -f /etc/nginx/sites-enabled/win-spin.live.conf 2>/dev/null)
if [ -n "$target" ]; then
NGINX_CONF="$target"
log "Using Nginx config: $NGINX_CONF (symlink target)"
else
NGINX_CONF="/opt/app/nginx/win-spin.live.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
NGINX_CONF="/opt/app/nginx/win-spin.live.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
error "Cannot find Nginx config file."
error "Searched:"
error " - /etc/nginx/sites-enabled/win-spin.live"
error " - /etc/nginx/sites-enabled/win-spin.live.conf"
error " - /etc/nginx/conf.d/lottery.conf"
error " - /opt/app/nginx/win-spin.live.conf"
error ""
error "Please set NGINX_CONF environment variable with the correct path."
exit 1
fi
else
log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)"
fi
# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files)
NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)"
# Ports for backends (will be swapped dynamically)
PRIMARY_PORT=8080
STANDBY_PORT=8082
# Detect which backend is currently active
detect_active_backend() {
# Check which port Nginx is currently using in upstream block
# Look for server line that is NOT marked as backup
local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1)
if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then
# Port 8082 is active (not backup)
ACTIVE_PORT=8082
STANDBY_PORT=8080
ACTIVE_CONTAINER="lottery-backend-new"
STANDBY_CONTAINER="lottery-backend"
log "Detected: Port 8082 is currently active"
else
# Port 8080 is active (default or only one present)
ACTIVE_PORT=8080
STANDBY_PORT=8082
ACTIVE_CONTAINER="lottery-backend"
STANDBY_CONTAINER="lottery-backend-new"
log "Detected: Port 8080 is currently active"
fi
PRIMARY_PORT=$ACTIVE_PORT
HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness"
}
HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max)
HEALTH_CHECK_INTERVAL=2
GRACE_PERIOD=10
# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback)
# This allows keeping failed containers for debugging even when using sudo
if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then
SCRIPT_KEEP_FAILED_CONTAINER="true"
export SCRIPT_KEEP_FAILED_CONTAINER
log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging"
fi
# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose')
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
error "Neither 'docker compose' nor 'docker-compose' is available"
exit 1
fi
# Check prerequisites
check_prerequisites() {
log "Checking prerequisites..."
# Check if running as root
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Check if docker compose is available (already detected above)
log "Using Docker Compose command: $DOCKER_COMPOSE_CMD"
# Check if Nginx config exists
if [ ! -f "$NGINX_CONF" ]; then
error "Nginx config not found at $NGINX_CONF"
exit 1
fi
# Check if DB_ROOT_PASSWORD is set
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..."
if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then
source "${SCRIPT_DIR}/load-db-password.sh"
else
error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh"
exit 1
fi
fi
# Detect which backend is currently active
detect_active_backend
# Check if active backend is running
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
error "Active backend container (${ACTIVE_CONTAINER}) is not running"
error "Please start it first: docker-compose -f ${COMPOSE_FILE} up -d backend"
exit 1
fi
log "✅ Prerequisites check passed"
log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}"
log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}"
}
# Build new backend image
build_new_image() {
log "Building new backend image..."
cd "$PROJECT_DIR"
# Determine which service to build based on which container will be used
# Both services use the same Dockerfile, but we need to build the correct one
# to ensure the image cache is updated for the service that will be started
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_TO_BUILD="backend-new"
else
SERVICE_TO_BUILD="backend"
fi
log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..."
# Build the image for the service that will be used
# This ensures the correct service's image cache is updated with latest migrations
if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
fi
}
# Start new backend container
start_new_container() {
log "Starting new backend container on port ${STANDBY_PORT}..."
cd "$PROJECT_DIR"
# Determine which service to start based on standby port
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_NAME="backend-new"
CONTAINER_NAME="lottery-backend-new"
else
SERVICE_NAME="backend"
CONTAINER_NAME="lottery-backend"
fi
# Check if standby container exists (running or stopped)
# We need to remove it to ensure a fresh start with migrations
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
warn "${CONTAINER_NAME} container is already running, stopping it first..."
else
warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..."
fi
if [ "$SERVICE_NAME" = "backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true
fi
fi
# Start the new container
if [ "$SERVICE_NAME" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
fi
# Wait for container to initialize (Spring Boot needs time to start)
log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..."
sleep 10
# Check if container is still running (might have crashed)
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:"
error " docker logs ${CONTAINER_NAME}"
exit 1
fi
}
# Health check new container
health_check_new_container() {
log "Performing health check on new backend container (port ${STANDBY_PORT})..."
# First, check if container is still running
if [ "$STANDBY_PORT" = "8082" ]; then
local container_name="lottery-backend-new"
else
local container_name="lottery-backend"
fi
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} is not running!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Check container health status
local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none")
if [ "$health_status" != "none" ]; then
info "Container health status: $health_status"
fi
local retries=0
while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do
# Check if container is still running
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} stopped during health check!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Try health check
if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then
log "✅ New backend container is healthy"
return 0
fi
retries=$((retries + 1))
if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then
# Show container status every 5 attempts
if [ $((retries % 5)) -eq 0 ]; then
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)"
info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')"
info "Last 5 log lines:"
docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /'
else
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..."
fi
sleep $HEALTH_CHECK_INTERVAL
fi
done
error "Health check failed after $HEALTH_CHECK_RETRIES attempts"
error "New backend container is not responding at $HEALTH_CHECK_URL"
error ""
error "Container status:"
docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
error ""
error "Last 200 log lines:"
docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /'
error ""
error "To debug, keep container running and check:"
error " docker logs -f ${container_name}"
error " docker logs --tail 500 ${container_name} # For even more logs"
error " curl -v $HEALTH_CHECK_URL"
return 1
}
# Update Nginx configuration
update_nginx_config() {
log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..."
# Backup current config
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"
log "Backed up Nginx config to: $NGINX_CONF_BACKUP"
# Use Python for reliable config manipulation
# Pass variables directly to Python (not via sys.argv)
python3 << PYTHON_SCRIPT
import re
import sys
config_file = "$NGINX_CONF"
standby_port = "$STANDBY_PORT"
active_port = "$ACTIVE_PORT"
try:
# Read the entire file
with open(config_file, 'r') as f:
lines = f.readlines()
# Find and update upstream block
new_lines = []
in_upstream = False
upstream_start_idx = -1
upstream_end_idx = -1
keepalive_line = None
keepalive_idx = -1
# First pass: find upstream block boundaries
for i, line in enumerate(lines):
if re.match(r'^\s*upstream\s+backend\s*\{', line):
upstream_start_idx = i
in_upstream = True
elif in_upstream and re.match(r'^\s*\}', line):
upstream_end_idx = i
break
elif in_upstream and re.search(r'keepalive', line):
keepalive_line = line
keepalive_idx = i
if upstream_start_idx == -1 or upstream_end_idx == -1:
raise Exception("Could not find upstream backend block")
# Build new lines
for i, line in enumerate(lines):
if i < upstream_start_idx:
# Before upstream block - keep as is
new_lines.append(line)
elif i == upstream_start_idx:
# Start of upstream block
new_lines.append(line)
elif i > upstream_start_idx and i < upstream_end_idx:
# Inside upstream block
# Skip old server lines
if re.search(r'server\s+127\.0\.0\.1:808[02]', line):
continue
# Skip keepalive (we'll add it at the end)
if re.search(r'keepalive', line):
continue
# Keep comments and other lines
new_lines.append(line)
elif i == upstream_end_idx:
# Before closing brace - add server lines and keepalive
new_lines.append(f" server 127.0.0.1:{standby_port};\n")
new_lines.append(f" server 127.0.0.1:{active_port} backup;\n")
if keepalive_line:
new_lines.append(keepalive_line)
else:
new_lines.append(" keepalive 200;\n")
new_lines.append(line)
else:
# After upstream block - keep as is
new_lines.append(line)
# Write updated config
with open(config_file, 'w') as f:
f.writelines(new_lines)
print("Nginx config updated successfully")
except Exception as e:
print(f"Error updating Nginx config: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
PYTHON_SCRIPT
if [ $? -ne 0 ]; then
error "Failed to update Nginx config"
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
# Test Nginx configuration
if nginx -t; then
log "✅ Nginx configuration is valid"
else
error "Nginx configuration test failed, restoring backup..."
error "Error details:"
nginx -t 2>&1 | sed 's/^/ /'
error ""
error "Current config (first 50 lines):"
head -50 "$NGINX_CONF" | sed 's/^/ /'
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
}
# Reload Nginx (zero downtime)
reload_nginx() {
log "Reloading Nginx (zero downtime)..."
if systemctl reload nginx; then
log "✅ Nginx reloaded successfully"
log "✅ Traffic is now being served by new backend (port 8082)"
else
error "Failed to reload Nginx, restoring backup config..."
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
exit 1
fi
}
# Stop old container after grace period
stop_old_container() {
log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..."
sleep $GRACE_PERIOD
log "Stopping old backend container (${ACTIVE_CONTAINER})..."
cd "$PROJECT_DIR"
if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
fi
# Optionally remove the old container (comment out if you want to keep it for rollback)
# if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then
# docker-compose -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new
# else
# docker-compose -f "$COMPOSE_FILE" rm -f backend
# fi
}
# Rollback function
rollback() {
error "Rolling back to previous version..."
# Check KEEP_FAILED_CONTAINER (check both current env and script-level variable)
local keep_container="${KEEP_FAILED_CONTAINER:-false}"
if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then
keep_container="true"
fi
# Restore Nginx config
if [ -f "$NGINX_CONF_BACKUP" ]; then
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
log "✅ Nginx config restored"
fi
# Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set)
cd "$PROJECT_DIR"
if [ "$keep_container" = "true" ]; then
warn ""
warn "═══════════════════════════════════════════════════════════════"
warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging"
warn "═══════════════════════════════════════════════════════════════"
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
warn ""
warn "Container 'lottery-backend-new' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs lottery-backend-new"
warn " docker logs --tail 100 lottery-backend-new"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new"
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
warn ""
warn "Container 'lottery-backend' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs lottery-backend"
warn " docker logs --tail 100 lottery-backend"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend"
fi
warn "═══════════════════════════════════════════════════════════════"
else
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true
fi
fi
# Start old container if it was stopped
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend
fi
fi
error "Rollback completed"
exit 1
}
# Main deployment flow
main() {
log "Starting rolling update deployment..."
# Trap errors for rollback
trap rollback ERR
check_prerequisites
build_new_image
start_new_container
if ! health_check_new_container; then
rollback
fi
update_nginx_config
reload_nginx
# Clear error trap after successful switch
trap - ERR
stop_old_container
log "✅ Rolling update completed successfully!"
log ""
log "Summary:"
log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})"
log " - Nginx is serving traffic from new backend"
log " - Old backend (${ACTIVE_CONTAINER}) has been stopped"
log ""
log "To rollback (if needed):"
log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF"
log " 2. Reload Nginx: systemctl reload nginx"
if [ "$ACTIVE_CONTAINER" = "lottery-backend-new" ]; then
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE --profile rolling-update start backend-new"
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE stop backend"
else
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE start backend"
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE --profile rolling-update stop backend-new"
fi
}
# Run main function
main "$@"

119
scripts/setup-logging.sh Normal file
View File

@@ -0,0 +1,119 @@
#!/bin/bash
# Setup script for external logback-spring.xml on VPS
# This script extracts logback-spring.xml from the JAR and places it in the config directory
# MUST be run before starting Docker containers to create the required files
set -e
# Determine config directory based on current location
if [ -d "/opt/app/backend" ]; then
CONFIG_DIR="/opt/app/backend/config"
LOG_DIR="/opt/app/logs"
elif [ -d "/opt/app/backend/lottery-be" ]; then
CONFIG_DIR="/opt/app/backend/lottery-be/config"
LOG_DIR="/opt/app/logs"
else
# Try to find from current directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_DIR="$BACKEND_DIR/config"
LOG_DIR="/opt/app/logs"
fi
echo "Setting up external logging configuration..."
echo "Config directory: $CONFIG_DIR"
echo "Log directory: $LOG_DIR"
# Create config directory if it doesn't exist
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
# Create log directory if it doesn't exist
mkdir -p "$LOG_DIR"
chmod 755 "$LOG_DIR"
# Extract logback-spring.xml from JAR if it doesn't exist
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Extracting logback-spring.xml from JAR..."
# Try multiple locations for JAR file
JAR_PATH=""
for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")" "$(dirname "$(dirname "$CONFIG_DIR")")"; do
if [ -d "$search_path" ]; then
found_jar=$(find "$search_path" -name "lottery-be-*.jar" -type f 2>/dev/null | head -n 1)
if [ -n "$found_jar" ]; then
JAR_PATH="$found_jar"
break
fi
fi
done
# Try to find in target directory
if [ -z "$JAR_PATH" ]; then
for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do
if [ -d "$search_path/target" ]; then
found_jar=$(find "$search_path/target" -name "*.jar" -type f | head -n 1)
if [ -n "$found_jar" ]; then
JAR_PATH="$found_jar"
break
fi
fi
done
fi
if [ -z "$JAR_PATH" ]; then
echo "Warning: JAR file not found. Trying to copy from source..."
# If JAR not found, copy from source (if available)
for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
echo "Copied from source: $search_path/src/main/resources/logback-spring.xml"
break
fi
done
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Error: Cannot find logback-spring.xml in JAR or source."
echo "Please ensure the file exists or copy it manually to: $CONFIG_DIR/logback-spring.xml"
exit 1
fi
else
echo "Found JAR: $JAR_PATH"
# Extract from JAR
unzip -p "$JAR_PATH" BOOT-INF/classes/logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || \
unzip -p "$JAR_PATH" logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || {
echo "Warning: Could not extract from JAR. Trying to copy from source..."
# Try copying from source
for search_path in "/opt/app/backend" "/opt/app/backend/lottery-be" "$(dirname "$CONFIG_DIR")"; do
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
break
fi
done
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Error: Cannot extract or find logback-spring.xml."
echo "Please copy it manually to: $CONFIG_DIR/logback-spring.xml"
exit 1
fi
}
echo "Extracted from JAR: $JAR_PATH"
fi
echo "logback-spring.xml created at $CONFIG_DIR/logback-spring.xml"
else
echo "logback-spring.xml already exists at $CONFIG_DIR/logback-spring.xml"
fi
# Set proper permissions
chmod 644 "$CONFIG_DIR/logback-spring.xml"
chown $USER:$USER "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || true
echo "Logging configuration setup complete!"
echo ""
echo "Configuration file: $CONFIG_DIR/logback-spring.xml"
echo "Log directory: $LOG_DIR"
echo ""
echo "You can now edit $CONFIG_DIR/logback-spring.xml to change log levels at runtime."
echo "Changes will take effect within 30 seconds (no restart needed)."

View File

@@ -1,13 +0,0 @@
package com.honey.honey.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "telegram")
@Data
public class TelegramProperties {
private String botToken;
}

View File

@@ -1,24 +0,0 @@
package com.honey.honey.config;
import com.honey.honey.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.excludePathPatterns(
"/ping",
"/actuator/**",
"/api/auth/tma/session" // Session creation endpoint doesn't require auth
);
}
}

View File

@@ -1,51 +0,0 @@
package com.honey.honey.controller;
import com.honey.honey.dto.UserDto;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.UserService;
import com.honey.honey.util.IpUtils;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/current")
public UserDto getCurrentUser() {
UserA user = UserContext.get();
// Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp());
return UserDto.builder()
.telegram_id(user.getTelegramId())
.username(user.getTelegramName())
.ip(ipAddress)
.build();
}
/**
* Updates user's language code.
* Called when user changes language in app header.
*/
@PutMapping("/language")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
UserA user = UserContext.get();
userService.updateLanguageCode(user.getId(), request.getLanguageCode());
}
@Data
public static class UpdateLanguageRequest {
private String languageCode;
}
}

View File

@@ -1,27 +0,0 @@
package com.honey.honey.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponse> handleUnauthorized(UnauthorizedException ex) {
log.warn("Unauthorized: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("UNAUTHORIZED", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}

View File

@@ -1,39 +0,0 @@
package com.honey.honey.health;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Slf4j
@Component
@RequiredArgsConstructor
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(1)) {
return Health.up()
.withDetail("database", "MySQL")
.withDetail("status", "Connected")
.build();
}
} catch (SQLException e) {
log.error("Database health check failed", e);
return Health.down()
.withDetail("database", "MySQL")
.withDetail("error", e.getMessage())
.build();
}
return Health.down().withDetail("database", "MySQL").build();
}
}

View File

@@ -1,55 +0,0 @@
package com.honey.honey.logging;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
/**
* Configuration for Grafana integration.
* This class prepares the logging infrastructure for Grafana.
*
* In production (Inferno), logs will be sent to Grafana via:
* - Loki (log aggregation)
* - Prometheus (metrics)
*
* For now, this is a placeholder that ensures structured logging
* is ready for Grafana integration.
*/
@Slf4j
@Configuration
public class GrafanaLoggingConfig {
@PostConstruct
public void init() {
log.info("📊 Grafana logging configuration initialized");
log.info("📊 Logs are structured and ready for Grafana/Loki integration");
log.info("📊 Metrics will be available for Prometheus when configured");
}
/**
* Log structured data for Grafana.
* This method can be used to send custom logs to Grafana/Loki.
*
* @param level Log level (INFO, WARN, ERROR, etc.)
* @param message Log message
* @param metadata Additional metadata as key-value pairs
*/
public static void logToGrafana(String level, String message, java.util.Map<String, Object> metadata) {
// For now, just use standard logging
// In production, this will send logs to Grafana/Loki
switch (level.toUpperCase()) {
case "ERROR":
log.error("{} | Metadata: {}", message, metadata);
break;
case "WARN":
log.warn("{} | Metadata: {}", message, metadata);
break;
case "INFO":
default:
log.info("{} | Metadata: {}", message, metadata);
break;
}
}
}

View File

@@ -1,13 +0,0 @@
package com.honey.honey.repository;
import com.honey.honey.model.UserA;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserARepository extends JpaRepository<UserA, Integer> {
Optional<UserA> findByTelegramId(Long telegramId);
}

View File

@@ -1,10 +0,0 @@
package com.honey.honey.repository;
import com.honey.honey.model.UserB;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserBRepository extends JpaRepository<UserB, Integer> {
}

View File

@@ -1,48 +0,0 @@
package com.honey.honey.repository;
import com.honey.honey.model.UserD;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface UserDRepository extends JpaRepository<UserD, Integer> {
/**
* 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);
}

View File

@@ -1,317 +0,0 @@
package com.honey.honey.service;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.repository.UserDRepository;
import com.honey.honey.util.IpUtils;
import com.honey.honey.util.TimeProvider;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.Optional;
/**
* Service for user management with sharded tables.
* Handles registration, login, and referral system.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final CountryCodeService countryCodeService;
/**
* Gets or creates a user based on Telegram initData.
* Updates user data on each login.
* Handles referral system if start parameter is present.
*
* @param tgUserData Parsed Telegram data from initData (contains "user" map and "start" string)
* @param request HTTP request for IP extraction
* @return UserA entity
*/
@Transactional
public UserA getOrCreateUser(Map<String, Object> tgUserData, HttpServletRequest request) {
// Extract user data and start parameter (from URL: /honey?start=774876)
@SuppressWarnings("unchecked")
Map<String, Object> tgUser = (Map<String, Object>) tgUserData.get("user");
String start = (String) tgUserData.get("start");
Long telegramId = ((Number) tgUser.get("id")).longValue();
String firstName = (String) tgUser.get("first_name");
String lastName = (String) tgUser.get("last_name");
String username = (String) tgUser.get("username");
Boolean isPremium = (Boolean) tgUser.get("is_premium");
String languageCode = (String) tgUser.get("language_code");
// Build screen_name from first_name and last_name
String screenName = buildScreenName(firstName, lastName);
// device_code should be language_code from initData (uppercase)
String deviceCode = languageCode != null ? languageCode.toUpperCase() : "XX";
// Get client IP and convert to bytes
String clientIp = IpUtils.getClientIp(request);
byte[] ipBytes = IpUtils.ipToBytes(clientIp);
// Get country code from IP
String countryCode = countryCodeService.getCountryCode(clientIp);
// Get current timestamp
long nowSeconds = TimeProvider.nowSeconds();
// Check if user exists
Optional<UserA> existingUserOpt = userARepository.findByTelegramId(telegramId);
if (existingUserOpt.isPresent()) {
// User exists - update login data
UserA userA = existingUserOpt.get();
updateUserOnLogin(userA, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds);
return userA;
} else {
// New user - create in all 3 tables
return createNewUser(telegramId, screenName, username, isPremium, languageCode, countryCode, deviceCode, ipBytes, nowSeconds, start);
}
}
/**
* Updates user data on login (when session is created).
* Note: language_code is NOT updated here - it should be updated via separate endpoint when user changes language in app.
*/
private void updateUserOnLogin(UserA userA, String screenName, String username, Boolean isPremium,
String languageCode, String countryCode, String deviceCode,
byte[] ipBytes, long nowSeconds) {
userA.setScreenName(screenName);
userA.setTelegramName(username != null ? username : "-");
userA.setIsPremium(isPremium != null && isPremium ? 1 : 0);
userA.setCountryCode(countryCode);
userA.setDeviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX");
userA.setIp(ipBytes);
userA.setDateLogin((int) nowSeconds);
// language_code is NOT updated here - it's updated via separate endpoint when user changes language
userARepository.save(userA);
log.debug("Updated user data on login: userId={}", userA.getId());
}
/**
* Updates user's language code (called when user changes language in app header).
*/
@Transactional
public void updateLanguageCode(Integer userId, String languageCode) {
Optional<UserA> userOpt = userARepository.findById(userId);
if (userOpt.isPresent()) {
UserA user = userOpt.get();
user.setLanguageCode(languageCode != null && languageCode.length() == 2 ? languageCode.toUpperCase() : "XX");
userARepository.save(user);
log.debug("Updated language_code for userId={}: {}", userId, user.getLanguageCode());
}
}
/**
* Creates a new user in all 3 tables with referral handling.
*/
private UserA createNewUser(Long telegramId, String screenName, String username, Boolean isPremium,
String languageCode, String countryCode, String deviceCode,
byte[] ipBytes, long nowSeconds, String start) {
// Create UserA
UserA userA = UserA.builder()
.screenName(screenName)
.telegramId(telegramId)
.telegramName(username != null ? username : "-")
.isPremium(isPremium != null && isPremium ? 1 : 0)
.languageCode(languageCode != null ? languageCode.toUpperCase() : "XX")
.countryCode(countryCode)
.deviceCode(deviceCode != null ? deviceCode.toUpperCase() : "XX")
.ip(ipBytes)
.dateReg((int) nowSeconds)
.dateLogin((int) nowSeconds)
.banned(0)
.build();
userA = userARepository.save(userA);
Integer userId = userA.getId();
log.info("Created new user: userId={}, telegramId={}", userId, telegramId);
// Create UserB with same ID
UserB userB = UserB.builder()
.id(userId)
.balanceA(0L)
.balanceB(0L)
.depositTotal(0L)
.depositCount(0)
.withdrawTotal(0L)
.withdrawCount(0)
.build();
userBRepository.save(userB);
// Create UserD with referral handling
UserD userD = createUserDWithReferral(userId, start);
userDRepository.save(userD);
return userA;
}
/**
* Creates UserD entity with referral chain setup.
* @param userId New user's ID
* @param start Referral parameter from URL (e.g., "774876" from /honey?start=774876)
*/
private UserD createUserDWithReferral(Integer userId, String start) {
UserD.UserDBuilder builder = UserD.builder()
.id(userId)
.refererId1(0)
.refererId2(0)
.refererId3(0)
.refererId4(0)
.refererId5(0)
.masterId(1) // Default master_id = 1
.referals1(0)
.referals2(0)
.referals3(0)
.referals4(0)
.referals5(0)
.fromReferals1(0L)
.fromReferals2(0L)
.fromReferals3(0L)
.fromReferals4(0L)
.fromReferals5(0L)
.toReferer1(0L)
.toReferer2(0L)
.toReferer3(0L)
.toReferer4(0L)
.toReferer5(0L);
if (start != null && !start.isEmpty()) {
try {
Integer refererId = Integer.parseInt(start);
Optional<UserD> refererUserDOpt = userDRepository.findById(refererId);
if (refererUserDOpt.isPresent()) {
UserD refererUserD = refererUserDOpt.get();
// Set referral chain: shift referer's chain down by 1 level
builder.refererId1(refererId)
.masterId(refererUserD.getMasterId())
.refererId2(refererUserD.getRefererId1())
.refererId3(refererUserD.getRefererId2())
.refererId4(refererUserD.getRefererId3())
.refererId5(refererUserD.getRefererId4());
// Increment referal counts for all 5 levels up the chain
setupReferralChain(userId, refererId);
} else {
// Referer doesn't exist, just set referer_id_1
log.warn("Referer with id {} not found, setting only referer_id_1", refererId);
builder.refererId1(refererId);
}
} catch (NumberFormatException e) {
log.warn("Invalid start parameter format: {}", start);
}
}
return builder.build();
}
/**
* Sets up referral chain and increments referal counts for all 5 levels.
* Example: If user F registers with referer E, increments:
* - referals_1 for E
* - referals_2 for D (E's referer_id_1)
* - referals_3 for C (D's referer_id_1)
* - referals_4 for B (C's referer_id_1)
* - referals_5 for A (B's referer_id_1)
*/
private void setupReferralChain(Integer newUserId, Integer refererId) {
// Level 1: Direct referer
userDRepository.incrementReferals1(refererId);
Optional<UserD> level1Opt = userDRepository.findById(refererId);
if (level1Opt.isEmpty()) {
return;
}
UserD level1 = level1Opt.get();
// Level 2
if (level1.getRefererId1() > 0) {
userDRepository.incrementReferals2(level1.getRefererId1());
Optional<UserD> level2Opt = userDRepository.findById(level1.getRefererId1());
if (level2Opt.isPresent()) {
UserD level2 = level2Opt.get();
// Level 3
if (level2.getRefererId1() > 0) {
userDRepository.incrementReferals3(level2.getRefererId1());
Optional<UserD> level3Opt = userDRepository.findById(level2.getRefererId1());
if (level3Opt.isPresent()) {
UserD level3 = level3Opt.get();
// Level 4
if (level3.getRefererId1() > 0) {
userDRepository.incrementReferals4(level3.getRefererId1());
Optional<UserD> level4Opt = userDRepository.findById(level3.getRefererId1());
if (level4Opt.isPresent()) {
UserD level4 = level4Opt.get();
// Level 5
if (level4.getRefererId1() > 0) {
userDRepository.incrementReferals5(level4.getRefererId1());
}
}
}
}
}
}
}
log.info("Referral chain setup completed: newUserId={}, refererId={}", newUserId, refererId);
}
/**
* Builds screen_name from first_name and last_name.
*/
private String buildScreenName(String firstName, String lastName) {
StringBuilder sb = new StringBuilder();
if (firstName != null && !firstName.isEmpty()) {
sb.append(firstName);
}
if (lastName != null && !lastName.isEmpty()) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(lastName);
}
String result = sb.toString().trim();
return result.isEmpty() ? "-" : (result.length() > 75 ? result.substring(0, 75) : result);
}
/**
* Gets user by ID.
*/
public Optional<UserA> getUserById(Integer userId) {
return userARepository.findById(userId);
}
/**
* Gets user by Telegram ID.
*/
public Optional<UserA> getUserByTelegramId(Long telegramId) {
return userARepository.findByTelegramId(telegramId);
}
}

View File

@@ -1,18 +1,20 @@
package com.honey.honey; package com.lottery.lottery;
import com.honey.honey.config.ConfigLoader; import com.lottery.lottery.config.ConfigLoader;
import com.honey.honey.config.TelegramProperties; import com.lottery.lottery.config.TelegramProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync
@EnableConfigurationProperties({TelegramProperties.class}) @EnableConfigurationProperties({TelegramProperties.class})
public class HoneyBackendApplication { public class LotteryBackendApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication app = new SpringApplication(HoneyBackendApplication.class); SpringApplication app = new SpringApplication(LotteryBackendApplication.class);
app.addListeners(new ConfigLoader()); app.addListeners(new ConfigLoader());
app.run(args); app.run(args);
} }

View File

@@ -0,0 +1,95 @@
package com.lottery.lottery.config;
import com.lottery.lottery.security.admin.AdminDetailsService;
import com.lottery.lottery.security.admin.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class AdminSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AdminDetailsService adminDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider adminAuthenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(adminDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager adminAuthenticationManager() {
return new ProviderManager(adminAuthenticationProvider());
}
@Bean
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/admin/**")
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/login").permitAll()
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:5173",
"http://localhost:3000",
"https://win-spin.live" // Main domain (admin panel is on same domain with secret path)
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/admin/**", configuration);
return source;
}
}

View File

@@ -1,4 +1,4 @@
package com.honey.honey.config; package com.lottery.lottery.config;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
@@ -19,13 +19,13 @@ import java.util.Properties;
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments. * This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
* *
* Priority: * Priority:
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno) * 1. Mounted file at /run/secrets/lottery-config.properties (Inferno)
* 2. Environment variables (Railway) * 2. Environment variables (Railway)
*/ */
@Slf4j @Slf4j
public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> { public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties"; private static final String SECRET_FILE_PATH = "/run/secrets/lottery-config.properties";
@Override @Override
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {

View File

@@ -1,4 +1,4 @@
package com.honey.honey.config; package com.lottery.lottery.config;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;

View File

@@ -0,0 +1,61 @@
package com.lottery.lottery.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
@Configuration
public class LocaleConfig {
// Supported languages
public static final List<String> 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
}
}
}
}

View File

@@ -0,0 +1,35 @@
package com.lottery.lottery.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "telegram")
@Data
public class TelegramProperties {
private String botToken;
/**
* Bot token for checking channel membership.
* Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN
* or in mounted file at /run/secrets/lottery-config.properties as telegram.channel-checker-bot-token
*/
private String channelCheckerBotToken;
/**
* Channel ID for follow tasks (e.g., "@win_spin_news" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID
* or in mounted file at /run/secrets/lottery-config.properties as telegram.follow-task-channel-id
*/
private String followTaskChannelId;
/**
* Channel ID for follow withdrawals channel task (e.g., "@win_spin_withdrawals" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2
* or in mounted file at /run/secrets/lottery-config.properties as telegram.follow-task-channel-id-2
*/
private String followTaskChannelId2;
}

View File

@@ -0,0 +1,44 @@
package com.lottery.lottery.config;
import com.lottery.lottery.security.AuthInterceptor;
import com.lottery.lottery.security.RateLimitInterceptor;
import com.lottery.lottery.security.UserRateLimitInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
private final UserRateLimitInterceptor userRateLimitInterceptor;
@Override
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
// NOTE: Rate limiting is NOT applied to Telegram webhook endpoint
// Telegram sends webhooks from multiple IPs and we need to process all updates, especially payments
// Rate limiting interceptor is only for bot registration endpoint (if needed elsewhere)
// User session interceptor for all other authenticated endpoints
registry.addInterceptor(authInterceptor)
.excludePathPatterns(
"/ping",
"/actuator/**",
"/api/auth/tma/session", // Session creation endpoint doesn't require auth
"/api/telegram/webhook/**", // Telegram webhook (token in path, validated in controller)
"/avatars/**", // Avatar static files don't require auth (served by Nginx in production)
"/api/check_user/**", // User check endpoint for external applications (open endpoint)
"/api/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth)
"/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth)
"/api/remotebet/**", // Remote bet: token + feature switch protected, no user auth
"/api/admin/**" // Admin endpoints are handled by Spring Security
);
// User-based rate limiting for payment creation and payout creation (applied after auth interceptor)
registry.addInterceptor(userRateLimitInterceptor)
.addPathPatterns("/api/payments/create", "/api/payouts");
}
}

View File

@@ -0,0 +1,106 @@
package com.lottery.lottery.config;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.SessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.security.Principal;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketAuthInterceptor implements ChannelInterceptor {
private final SessionService sessionService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
// Extract Bearer token from headers
List<String> authHeaders = accessor.getNativeHeader("Authorization");
String token = null;
if (authHeaders != null && !authHeaders.isEmpty()) {
String authHeader = authHeaders.get(0);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
}
}
// Also check query parameter (for SockJS fallback)
if (token == null) {
String query = accessor.getFirstNativeHeader("query");
if (query != null && query.contains("token=")) {
int tokenStart = query.indexOf("token=") + 6;
int tokenEnd = query.indexOf("&", tokenStart);
if (tokenEnd == -1) {
tokenEnd = query.length();
}
token = query.substring(tokenStart, tokenEnd);
}
}
if (token == null || token.isBlank()) {
log.warn("WebSocket connection rejected: No token provided");
throw new SecurityException("Authentication required");
}
// Validate token and get user
var userOpt = sessionService.getUserBySession(token);
if (userOpt.isEmpty()) {
log.warn("WebSocket connection rejected: Invalid token");
throw new SecurityException("Invalid authentication token");
}
UserA user = userOpt.get();
accessor.setUser(new StompPrincipal(user.getId(), user));
UserContext.set(user);
log.debug("WebSocket connection authenticated for user {}", user.getId());
}
return message;
}
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
UserContext.clear();
}
// Simple principal class to store user info
public static class StompPrincipal implements Principal {
private final Integer userId;
private final UserA user;
public StompPrincipal(Integer userId, UserA user) {
this.userId = userId;
this.user = user;
}
public Integer getUserId() {
return userId;
}
public UserA getUser() {
return user;
}
@Override
public String getName() {
return String.valueOf(userId);
}
}
}

View File

@@ -0,0 +1,62 @@
package com.lottery.lottery.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthInterceptor authInterceptor;
@Value("${app.websocket.allowed-origins:https://win-spin.live,https://lottery-fe-production.up.railway.app,https://web.telegram.org,https://webk.telegram.org,https://webz.telegram.org}")
private String allowedOrigins;
public WebSocketConfig(WebSocketAuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Enable simple broker for sending messages to clients
config.enableSimpleBroker("/topic", "/queue");
// Prefix for messages from client to server
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Parse allowed origins from configuration
// Spring's setAllowedOriginPatterns uses Ant-style patterns, not regex
// For exact matches, use the URL as-is
// For subdomain matching, use https://*.example.com
List<String> origins = Arrays.asList(allowedOrigins.split(","));
String[] originPatterns = origins.stream()
.map(String::trim)
.filter(origin -> !origin.isEmpty())
.toArray(String[]::new);
log.info("[WEBSOCKET] Configuring WebSocket endpoint /ws with allowed origins: {}", Arrays.toString(originPatterns));
// WebSocket endpoint - clients connect here
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(originPatterns) // Restricted to configured domains
.withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authInterceptor);
}
}

View File

@@ -0,0 +1,178 @@
package com.lottery.lottery.config;
import com.lottery.lottery.dto.GameRoomStateDto;
import com.lottery.lottery.service.GameRoomService;
import com.lottery.lottery.service.RoomConnectionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
import org.springframework.web.socket.messaging.SessionUnsubscribeEvent;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketSubscriptionListener {
private final GameRoomService gameRoomService;
private final SimpMessagingTemplate messagingTemplate;
private final RoomConnectionService roomConnectionService;
// Pattern to match room subscription: /topic/room/{roomNumber}
private static final Pattern ROOM_SUBSCRIPTION_PATTERN = Pattern.compile("/topic/room/(\\d+)");
/**
* Listens for WebSocket subscription events.
* When a client subscribes to a room topic, sends the current room state immediately.
*/
@EventListener
public void handleSubscription(SessionSubscribeEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String destination = accessor.getDestination();
if (destination == null) {
return;
}
// Check if this is a room subscription
Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination);
if (matcher.matches()) {
try {
Integer roomNumber = Integer.parseInt(matcher.group(1));
// Get the user ID from the principal
Object principal = accessor.getUser();
Integer userId = null;
if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) {
userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId();
}
// Get session ID
String sessionId = accessor.getSessionId();
log.info("Client subscribed to room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId);
// Register session for disconnect tracking
if (sessionId != null && userId != null) {
roomConnectionService.registerSession(sessionId, userId);
}
// Track room-level connection (not just round participation)
if (userId != null && sessionId != null) {
roomConnectionService.addUserToRoom(userId, roomNumber, sessionId);
} else {
log.warn("Cannot track room connection: userId={}, sessionId={}", userId, sessionId);
}
// Get current room state and send it to the subscribing client
// This ensures client gets authoritative state immediately on subscribe
GameRoomStateDto state = gameRoomService.getRoomState(roomNumber);
// Send state directly to the destination (room topic)
// This will be received by the subscribing client
messagingTemplate.convertAndSend(destination, state);
log.debug("Sent initial room state to subscriber: room={}, phase={}, participants={}, connectedUsers={}",
roomNumber, state.getPhase(),
state.getParticipants() != null ? state.getParticipants().size() : 0,
state.getConnectedUsers());
} catch (NumberFormatException e) {
log.warn("Invalid room number in subscription destination: {}", destination);
} catch (Exception e) {
log.error("Error sending initial state for room subscription: {}", destination, e);
}
}
}
/**
* Listens for WebSocket unsubscribe events.
* When a client unsubscribes from a room topic, removes them from room connections.
*/
@EventListener
public void handleUnsubscribe(SessionUnsubscribeEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String destination = accessor.getDestination();
// Skip if destination is null (Spring WebSocket sometimes sends unsubscribe events without destination during cleanup)
if (destination == null) {
return;
}
log.debug("Unsubscribe event received for destination: {}", destination);
// Check if this is a room unsubscription
Matcher matcher = ROOM_SUBSCRIPTION_PATTERN.matcher(destination);
if (matcher.matches()) {
try {
Integer roomNumber = Integer.parseInt(matcher.group(1));
// Get the user ID from the principal
Object principal = accessor.getUser();
Integer userId = null;
if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) {
userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId();
} else {
log.warn("Unsubscribe event: principal is not StompPrincipal, type: {}",
principal != null ? principal.getClass().getName() : "null");
}
// Get session ID
String sessionId = accessor.getSessionId();
if (userId != null && sessionId != null) {
log.info("Client unsubscribed from room {} (userId: {}, sessionId: {})", roomNumber, userId, sessionId);
roomConnectionService.removeUserFromRoom(userId, roomNumber, sessionId);
} else {
log.warn("Unsubscribe event: userId or sessionId is null for destination: {} (userId: {}, sessionId: {})",
destination, userId, sessionId);
}
} catch (NumberFormatException e) {
log.warn("Invalid room number in unsubscription destination: {}", destination);
} catch (Exception e) {
log.error("Error handling room unsubscription: {}", destination, e);
}
} else {
log.debug("Unsubscribe event destination does not match room pattern: {}", destination);
}
}
/**
* Listens for WebSocket disconnect events.
* When a client disconnects completely, removes them from all rooms.
*/
@EventListener
public void handleDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = accessor.getSessionId();
// Try to get user ID from principal first
Object principal = accessor.getUser();
Integer userId = null;
if (principal instanceof WebSocketAuthInterceptor.StompPrincipal) {
userId = ((WebSocketAuthInterceptor.StompPrincipal) principal).getUserId();
}
if (userId != null && sessionId != null) {
log.info("Client disconnected (userId: {}, sessionId: {}), removing session from all rooms", userId, sessionId);
// Remove only this specific session from all rooms
roomConnectionService.removeUserFromAllRooms(userId, sessionId);
// Also remove session mapping
roomConnectionService.removeSession(sessionId);
} else if (sessionId != null) {
// Principal might be lost, try to get userId from session mapping
log.info("Client disconnected (sessionId: {}), principal lost, using session mapping", sessionId);
roomConnectionService.removeUserFromAllRoomsBySession(sessionId);
} else {
log.warn("Disconnect event: both userId and sessionId are null, cannot remove from rooms");
}
}
}

View File

@@ -0,0 +1,206 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.model.Payment;
import com.lottery.lottery.model.Payout;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/analytics")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final GameRoundRepository gameRoundRepository;
/**
* Get revenue and payout time series data for charts.
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data with daily/weekly/monthly aggregation
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// Convert to Unix timestamps for UserA queries
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
// Count new registrations in this period (between current and periodEnd)
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
// Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
// Count rounds resolved in this period
long rounds = gameRoundRepository.countByResolvedAtBetween(current, periodEnd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", rounds);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,96 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminBotConfigDto;
import com.lottery.lottery.dto.AdminBotConfigRequest;
import com.lottery.lottery.service.AdminBotConfigService;
import com.lottery.lottery.service.ConfigurationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin/bots")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminBotConfigController {
private final AdminBotConfigService adminBotConfigService;
private final ConfigurationService configurationService;
@GetMapping
public ResponseEntity<List<AdminBotConfigDto>> list() {
return ResponseEntity.ok(adminBotConfigService.listAll());
}
@GetMapping("/{id}")
public ResponseEntity<AdminBotConfigDto> getById(@PathVariable Integer id) {
Optional<AdminBotConfigDto> dto = adminBotConfigService.getById(id);
return dto.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> create(@Valid @RequestBody AdminBotConfigRequest request) {
try {
AdminBotConfigDto created = adminBotConfigService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable Integer id, @Valid @RequestBody AdminBotConfigRequest request) {
try {
Optional<AdminBotConfigDto> updated = adminBotConfigService.update(id, request);
return updated.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Integer id) {
boolean deleted = adminBotConfigService.delete(id);
return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
/**
* Shuffle time windows for bots that have the given room enabled.
* Redistributes the same set of time windows randomly across those bots.
*/
@PostMapping("/shuffle")
public ResponseEntity<?> shuffleTimeWindows(@RequestParam int roomNumber) {
if (roomNumber != 2 && roomNumber != 3) {
return ResponseEntity.badRequest().body(Map.of("error", "roomNumber must be 2 or 3"));
}
try {
adminBotConfigService.shuffleTimeWindowsForRoom(roomNumber);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/settings")
public ResponseEntity<Map<String, Integer>> getBotSettings() {
return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", configurationService.getMaxParticipantsBeforeBotJoin()));
}
@PatchMapping("/settings")
public ResponseEntity<?> updateBotSettings(@RequestBody Map<String, Integer> body) {
Integer v = body != null ? body.get("maxParticipantsBeforeBotJoin") : null;
if (v == null) {
return ResponseEntity.badRequest().body(Map.of("error", "maxParticipantsBeforeBotJoin is required"));
}
int updated = configurationService.setMaxParticipantsBeforeBotJoin(v);
return ResponseEntity.ok(Map.of("maxParticipantsBeforeBotJoin", updated));
}
}

View File

@@ -0,0 +1,49 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminConfigurationsRequest;
import com.lottery.lottery.service.BotConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* Admin API for safe bots and flexible bots (winner-override config used e.g. with /remotebet).
* Configurations tab in admin panel uses GET/PUT /api/admin/configurations.
*/
@RestController
@RequestMapping("/api/admin/configurations")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public class AdminConfigurationsController {
private final BotConfigService botConfigService;
@GetMapping
public ResponseEntity<BotConfigService.BotConfigDto> getConfig() {
return ResponseEntity.ok(botConfigService.getConfig());
}
@PutMapping
public ResponseEntity<BotConfigService.BotConfigDto> updateConfig(
@RequestBody AdminConfigurationsRequest request
) {
List<Integer> safeIds = request.getSafeBotUserIds() != null
? request.getSafeBotUserIds()
: Collections.emptyList();
List<BotConfigService.FlexibleBotEntryDto> flexibleBots = Collections.emptyList();
if (request.getFlexibleBots() != null) {
flexibleBots = request.getFlexibleBots().stream()
.filter(e -> e != null && e.getUserId() != null && e.getWinRate() != null)
.map(e -> new BotConfigService.FlexibleBotEntryDto(e.getUserId(), e.getWinRate()))
.collect(Collectors.toList());
}
botConfigService.setSafeBotUserIds(safeIds);
botConfigService.setFlexibleBots(flexibleBots);
return ResponseEntity.ok(botConfigService.getConfig());
}
}

View File

@@ -0,0 +1,194 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.model.Payment;
import com.lottery.lottery.model.Payout;
import com.lottery.lottery.model.SupportTicket;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.SupportTicketRepository;
import com.lottery.lottery.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/dashboard")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminDashboardController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final GameRoundRepository gameRoundRepository;
private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> 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<String, Object> stats = new HashMap<>();
// Total Users
long totalUsers = userARepository.count();
long newUsersToday = userARepository.countByDateRegAfter(todayStartTs);
long newUsersWeek = userARepository.countByDateRegAfter(weekStartTs);
long newUsersMonth = userARepository.countByDateRegAfter(monthStartTs);
// Active Players (users who logged in recently)
long activePlayers24h = userARepository.countByDateLoginAfter(dayAgoTs);
long activePlayers7d = userARepository.countByDateLoginAfter(weekAgoTs);
long activePlayers30d = userARepository.countByDateLoginAfter(monthAgoTs);
// Revenue (from completed payments) - in Stars
int totalRevenue = paymentRepository.sumStarsAmountByStatus(Payment.PaymentStatus.COMPLETED)
.orElse(0);
int revenueToday = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, todayStart).orElse(0);
int revenueWeek = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, weekStart).orElse(0);
int revenueMonth = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, monthStart).orElse(0);
// Payouts (from completed payouts) - in Stars
int totalPayouts = payoutRepository.sumStarsAmountByStatus(Payout.PayoutStatus.COMPLETED)
.orElse(0);
int payoutsToday = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, todayStart).orElse(0);
int payoutsWeek = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, weekStart).orElse(0);
int payoutsMonth = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, monthStart).orElse(0);
// Net Revenue (in Stars)
int netRevenue = totalRevenue - totalPayouts;
int netRevenueToday = revenueToday - payoutsToday;
int netRevenueWeek = revenueWeek - payoutsWeek;
int netRevenueMonth = revenueMonth - payoutsMonth;
// CRYPTO only: revenue and payouts in USD (for dashboard / financial analytics)
BigDecimal cryptoRevenueTotal = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNull(Payment.PaymentStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueToday = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueWeek = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueMonth = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsTotal = payoutRepository.sumUsdAmountByTypeCryptoAndStatus(Payout.PayoutStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsToday = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsWeek = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsMonth = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoNetRevenueTotal = cryptoRevenueTotal.subtract(cryptoPayoutsTotal);
BigDecimal cryptoNetRevenueToday = cryptoRevenueToday.subtract(cryptoPayoutsToday);
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth);
// Game Rounds
long totalRounds = gameRoundRepository.count();
long roundsToday = gameRoundRepository.countByResolvedAtAfter(todayStart);
long roundsWeek = gameRoundRepository.countByResolvedAtAfter(weekStart);
long roundsMonth = gameRoundRepository.countByResolvedAtAfter(monthStart);
// Average Round Pool (from resolved rounds) - round to int
Double avgPoolDouble = gameRoundRepository.avgTotalBetByResolvedAtAfter(monthStart)
.orElse(0.0);
int avgPool = (int) Math.round(avgPoolDouble);
// Support Tickets
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
// Count tickets closed today
long ticketsResolvedToday = supportTicketRepository.findAll().stream()
.filter(t -> t.getStatus() == SupportTicket.TicketStatus.CLOSED &&
t.getUpdatedAt() != null &&
t.getUpdatedAt().isAfter(todayStart))
.count();
// Build response
stats.put("users", Map.of(
"total", totalUsers,
"newToday", newUsersToday,
"newWeek", newUsersWeek,
"newMonth", newUsersMonth
));
stats.put("activePlayers", Map.of(
"last24h", activePlayers24h,
"last7d", activePlayers7d,
"last30d", activePlayers30d
));
stats.put("revenue", Map.of(
"total", totalRevenue,
"today", revenueToday,
"week", revenueWeek,
"month", revenueMonth
));
stats.put("payouts", Map.of(
"total", totalPayouts,
"today", payoutsToday,
"week", payoutsWeek,
"month", payoutsMonth
));
stats.put("netRevenue", Map.of(
"total", netRevenue,
"today", netRevenueToday,
"week", netRevenueWeek,
"month", netRevenueMonth
));
Map<String, Object> crypto = new HashMap<>();
crypto.put("revenueUsd", cryptoRevenueTotal);
crypto.put("revenueUsdToday", cryptoRevenueToday);
crypto.put("revenueUsdWeek", cryptoRevenueWeek);
crypto.put("revenueUsdMonth", cryptoRevenueMonth);
crypto.put("payoutsUsd", cryptoPayoutsTotal);
crypto.put("payoutsUsdToday", cryptoPayoutsToday);
crypto.put("payoutsUsdWeek", cryptoPayoutsWeek);
crypto.put("payoutsUsdMonth", cryptoPayoutsMonth);
crypto.put("profitUsd", cryptoNetRevenueTotal);
crypto.put("profitUsdToday", cryptoNetRevenueToday);
crypto.put("profitUsdWeek", cryptoNetRevenueWeek);
crypto.put("profitUsdMonth", cryptoNetRevenueMonth);
stats.put("crypto", crypto);
stats.put("rounds", Map.of(
"total", totalRounds,
"today", roundsToday,
"week", roundsWeek,
"month", roundsMonth,
"avgPool", avgPool
));
stats.put("supportTickets", Map.of(
"open", openTickets,
"resolvedToday", ticketsResolvedToday
));
return ResponseEntity.ok(stats);
}
}

View File

@@ -0,0 +1,36 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.service.FeatureSwitchService;
import com.lottery.lottery.service.FeatureSwitchService.FeatureSwitchDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/feature-switches")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminFeatureSwitchController {
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<FeatureSwitchDto>> getAll() {
return ResponseEntity.ok(featureSwitchService.getAll());
}
@PatchMapping("/{key}")
public ResponseEntity<FeatureSwitchDto> update(
@PathVariable String key,
@RequestBody Map<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(featureSwitchService.setEnabled(key, enabled));
}
}

View File

@@ -0,0 +1,51 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminLoginRequest;
import com.lottery.lottery.dto.AdminLoginResponse;
import com.lottery.lottery.service.AdminService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminLoginController {
private final AdminService adminService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
if (request.getUsername() == null || request.getPassword() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Username and password are required");
}
Optional<String> 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
));
}
}

View File

@@ -0,0 +1,26 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminMasterDto;
import com.lottery.lottery.service.AdminMasterService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/masters")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminMasterController {
private final AdminMasterService adminMasterService;
@GetMapping
public ResponseEntity<List<AdminMasterDto>> getMasters() {
return ResponseEntity.ok(adminMasterService.getMasters());
}
}

View File

@@ -0,0 +1,42 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.NotifyBroadcastRequest;
import com.lottery.lottery.service.NotificationBroadcastService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* Admin API to trigger or stop notification broadcast (ADMIN only).
*/
@Slf4j
@RestController
@RequestMapping("/api/admin/notifications")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminNotificationController {
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/send")
public ResponseEntity<Void> 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<Void> stop() {
notificationBroadcastService.requestStop();
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,147 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminPaymentDto;
import com.lottery.lottery.model.Payment;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.UserARepository;
import com.lottery.lottery.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payments")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public class AdminPaymentController {
private final PaymentRepository paymentRepository;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> 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<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payment> spec = (root, query, cb) -> {
List<Predicate> 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<Payment> paymentPage = paymentRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = paymentPage.getContent().stream()
.map(Payment::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> 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<AdminPaymentDto> 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<String, Object> 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);
}
}

View File

@@ -0,0 +1,204 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminPayoutDto;
import com.lottery.lottery.model.Payout;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.model.UserB;
import com.lottery.lottery.repository.PayoutRepository;
import com.lottery.lottery.repository.UserARepository;
import com.lottery.lottery.repository.UserBRepository;
import com.lottery.lottery.repository.UserDRepository;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.service.PayoutService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import org.springframework.http.HttpStatus;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payouts")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
public class AdminPayoutController {
private final PayoutRepository payoutRepository;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PayoutService payoutService;
private final LocalizationService localizationService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> 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<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payout> spec = (root, query, cb) -> {
List<Predicate> 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<Payout> payoutPage = payoutRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = payoutPage.getContent().stream()
.map(Payout::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> 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<AdminPayoutDto> 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<String, Object> 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<Payout> 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<Payout> 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()));
}
}
}

View File

@@ -0,0 +1,139 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.*;
import com.lottery.lottery.service.AdminPromotionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin/promotions")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminPromotionController {
private final AdminPromotionService adminPromotionService;
@GetMapping
public ResponseEntity<List<AdminPromotionDto>> listPromotions() {
return ResponseEntity.ok(adminPromotionService.listPromotions());
}
@GetMapping("/{id}")
public ResponseEntity<AdminPromotionDto> getPromotion(@PathVariable int id) {
return adminPromotionService.getPromotion(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<AdminPromotionDto> 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<AdminPromotionDto> 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<Void> deletePromotion(@PathVariable int id) {
return adminPromotionService.deletePromotion(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Rewards ---
@GetMapping("/{promoId}/rewards")
public ResponseEntity<List<AdminPromotionRewardDto>> listRewards(@PathVariable int promoId) {
return ResponseEntity.ok(adminPromotionService.listRewards(promoId));
}
@PostMapping("/{promoId}/rewards")
public ResponseEntity<AdminPromotionRewardDto> 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<AdminPromotionRewardDto> 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<Void> deleteReward(@PathVariable int rewardId) {
return adminPromotionService.deleteReward(rewardId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Promotion users (leaderboard / results) ---
@GetMapping("/{promoId}/users")
public ResponseEntity<Map<String, Object>> 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<AdminPromotionUserDto> dtoPage = adminPromotionService.getPromotionUsers(
promoId, page, size, sortBy, sortDir, userId);
Map<String, Object> 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<AdminPromotionUserDto> updatePromotionUserPoints(
@PathVariable int promoId,
@PathVariable int userId,
@Valid @RequestBody AdminPromotionUserPointsRequest request) {
Optional<AdminPromotionUserDto> updated = adminPromotionService.updatePromotionUserPoints(
promoId, userId, request.getPoints());
return updated.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,57 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.AdminRoomDetailDto;
import com.lottery.lottery.dto.AdminRoomOnlineUserDto;
import com.lottery.lottery.dto.AdminRoomSummaryDto;
import com.lottery.lottery.service.GameRoomService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/rooms")
@RequiredArgsConstructor
public class AdminRoomController {
private final GameRoomService gameRoomService;
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<List<AdminRoomSummaryDto>> listRooms() {
return ResponseEntity.ok(gameRoomService.getAdminRoomSummaries());
}
@GetMapping("/online-users")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<List<AdminRoomOnlineUserDto>> getOnlineUsers() {
return ResponseEntity.ok(gameRoomService.getAdminOnlineUsersAcrossRooms());
}
@GetMapping("/{roomNumber}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<AdminRoomDetailDto> getRoomDetail(@PathVariable Integer roomNumber) {
if (roomNumber == null || roomNumber < 1 || roomNumber > 3) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(gameRoomService.getAdminRoomDetail(roomNumber));
}
@PostMapping("/{roomNumber}/repair")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> repairRoom(@PathVariable Integer roomNumber) {
if (roomNumber == null || roomNumber < 1 || roomNumber > 3) {
return ResponseEntity.badRequest().build();
}
try {
gameRoomService.repairRoom(roomNumber);
return ResponseEntity.ok(Map.of("success", true, "message", "Repair completed for room " + roomNumber));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Repair failed"));
}
}
}

View File

@@ -0,0 +1,316 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.*;
import com.lottery.lottery.model.Admin;
import com.lottery.lottery.model.SupportMessage;
import com.lottery.lottery.model.SupportTicket;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.repository.AdminRepository;
import com.lottery.lottery.repository.SupportMessageRepository;
import com.lottery.lottery.repository.SupportTicketRepository;
import com.lottery.lottery.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import jakarta.validation.Valid;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/tickets")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class AdminSupportTicketController {
private final SupportTicketRepository supportTicketRepository;
private final SupportMessageRepository supportMessageRepository;
private final UserARepository userARepository;
private final AdminRepository adminRepository;
@GetMapping
public ResponseEntity<Map<String, Object>> 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<SupportTicket> spec = (root, query, cb) -> {
List<Predicate> 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<SupportTicket> ticketPage = supportTicketRepository.findAll(spec, pageable);
// Fetch user names and message counts
List<Integer> userIds = ticketPage.getContent().stream()
.map(t -> t.getUser().getId())
.distinct()
.collect(Collectors.toList());
Map<Integer, String> 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<AdminSupportTicketDto> dtoPage = ticketPage.map(ticket -> {
String userName = userNameMap.getOrDefault(ticket.getUser().getId(), "Unknown");
long messageCount = supportMessageRepository.countByTicketId(ticket.getId());
// Get last message preview
List<SupportMessage> 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<String, Object> 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<AdminSupportTicketDetailDto> 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<SupportMessage> messages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(id);
// Get all admin user IDs from admins table
List<Integer> adminUserIds = adminRepository.findAll().stream()
.filter(admin -> admin.getUserId() != null)
.map(Admin::getUserId)
.collect(Collectors.toList());
List<AdminSupportMessageDto> 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());
}
}

View File

@@ -0,0 +1,277 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.*;
import com.lottery.lottery.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
public class AdminUserController {
/** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
private static final Set<String> SORTABLE_FIELDS = Set.of(
"id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit"
);
private static final Set<String> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt");
private static final Set<String> 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<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String search,
@RequestParam(required = false) Integer banned,
@RequestParam(required = false) String countryCode,
@RequestParam(required = false) String languageCode,
@RequestParam(required = false) Integer dateRegFrom,
@RequestParam(required = false) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin,
@RequestParam(required = false) Long balanceMax,
@RequestParam(required = false) Integer roundsPlayedMin,
@RequestParam(required = false) Integer roundsPlayedMax,
@RequestParam(required = false) Integer referralCountMin,
@RequestParam(required = false) Integer referralCountMax,
@RequestParam(required = false) Integer referrerId,
@RequestParam(required = false) Integer referralLevel,
@RequestParam(required = false) String ip) {
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, roundsPlayed, referralCount)
// are handled in service via custom query; others are applied to UserA.
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "roundsPlayed", "referralCount", "profit");
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
// Pass through; service will use custom ordered query
} else if (effectiveSortBy != null && !SORTABLE_FIELDS.contains(effectiveSortBy)) {
effectiveSortBy = null;
}
Sort sort = Sort.by("id").descending();
if (effectiveSortBy != null && !sortRequiresJoin.contains(effectiveSortBy)) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
sort = Sort.by(direction, effectiveSortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
// Convert balance filters from tickets (divide by 1000000) to bigint format
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null;
Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null;
boolean excludeMasters = isGameAdmin();
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
pageable,
search,
banned,
countryCode,
languageCode,
dateRegFrom,
dateRegTo,
balanceMinBigint,
balanceMaxBigint,
roundsPlayedMin,
roundsPlayedMax,
referralCountMin,
referralCountMax,
referrerId,
referralLevel,
ip,
effectiveSortBy,
sortDir,
excludeMasters
);
Map<String, Object> 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<AdminUserDetailDto> 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<Map<String, Object>> 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<AdminTransactionDto> transactions = adminUserService.getUserTransactions(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", transactions.getContent());
response.put("totalElements", transactions.getTotalElements());
response.put("totalPages", transactions.getTotalPages());
response.put("currentPage", transactions.getNumber());
response.put("size", transactions.getSize());
response.put("hasNext", transactions.hasNext());
response.put("hasPrevious", transactions.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/game-rounds")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserGameRounds(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<AdminGameRoundDto> rounds = adminUserService.getUserGameRounds(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", rounds.getContent());
response.put("totalElements", rounds.getTotalElements());
response.put("totalPages", rounds.getTotalPages());
response.put("currentPage", rounds.getNumber());
response.put("size", rounds.getSize());
response.put("hasNext", rounds.hasNext());
response.put("hasPrevious", rounds.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> 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<UserDepositDto> deposits = adminUserService.getUserPayments(id, pageable);
Map<String, Object> 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<Map<String, Object>> 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<UserWithdrawalDto> payouts = adminUserService.getUserPayouts(id, pageable);
Map<String, Object> 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<Map<String, Object>> getUserTasks(@PathVariable Integer id) {
Map<String, Object> tasks = adminUserService.getUserTasks(id);
return ResponseEntity.ok(tasks);
}
@PatchMapping("/{id}/ban")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> setUserBanned(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> 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<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().body(Map.of("error", "enabled is required (true/false)"));
}
try {
adminUserService.setWithdrawalsEnabled(id, enabled);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/balance/adjust")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> adjustBalance(
@PathVariable Integer id,
@Valid @RequestBody com.lottery.lottery.dto.BalanceAdjustmentRequest request) {
try {
com.lottery.lottery.dto.BalanceAdjustmentResponse response = adminUserService.adjustBalance(id, request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -1,17 +1,21 @@
package com.honey.honey.controller; package com.lottery.lottery.controller;
import com.honey.honey.dto.CreateSessionRequest; import com.lottery.lottery.dto.CreateSessionRequest;
import com.honey.honey.dto.CreateSessionResponse; import com.lottery.lottery.dto.CreateSessionResponse;
import com.honey.honey.model.UserA; import com.lottery.lottery.exception.BannedUserException;
import com.honey.honey.service.SessionService; import com.lottery.lottery.model.UserA;
import com.honey.honey.service.TelegramAuthService; import com.lottery.lottery.service.LocalizationService;
import com.honey.honey.service.UserService; import com.lottery.lottery.service.SessionService;
import com.lottery.lottery.service.TelegramAuthService;
import com.lottery.lottery.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
@Slf4j @Slf4j
@@ -23,6 +27,7 @@ public class AuthController {
private final TelegramAuthService telegramAuthService; private final TelegramAuthService telegramAuthService;
private final SessionService sessionService; private final SessionService sessionService;
private final UserService userService; private final UserService userService;
private final LocalizationService localizationService;
/** /**
* Creates a session by validating Telegram initData. * Creates a session by validating Telegram initData.
@@ -36,19 +41,25 @@ public class AuthController {
String initData = request.getInitData(); String initData = request.getInitData();
if (initData == null || initData.isBlank()) { if (initData == null || initData.isBlank()) {
throw new IllegalArgumentException("initData is required"); throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired"));
} }
// Validate Telegram initData signature and parse data // Validate Telegram initData signature and parse data
Map<String, Object> tgUserData = telegramAuthService.validateAndParseInitData(initData); Map<String, Object> tgUserData = telegramAuthService.validateAndParseInitData(initData);
// Get or create user (handles registration, login update, and referral system) // 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); 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 // Create session
String sessionId = sessionService.createSession(user); String sessionId = sessionService.createSession(user);
log.info("Session created for userId={}, telegramId={}", user.getId(), user.getTelegramId()); log.debug("Session created: userId={}", user.getId());
return CreateSessionResponse.builder() return CreateSessionResponse.builder()
.access_token(sessionId) .access_token(sessionId)
@@ -71,7 +82,7 @@ public class AuthController {
String sessionId = extractBearerToken(authHeader); String sessionId = extractBearerToken(authHeader);
if (sessionId != null) { if (sessionId != null) {
sessionService.invalidateSession(sessionId); sessionService.invalidateSession(sessionId);
log.info("Session invalidated via logout"); log.debug("Session invalidated via logout");
} }
} }

View File

@@ -0,0 +1,56 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.ExternalDepositWebhookRequest;
import com.lottery.lottery.service.PaymentService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Controller for 3rd party deposit completion webhook.
* Path: POST /api/deposit_webhook/{token}. Token must match app.deposit-webhook.token (APP_DEPOSIT_WEBHOOK_TOKEN).
* No session auth; token in path only. Set the token on VPS via environment variable.
*/
@Slf4j
@RestController
@RequestMapping("/api/deposit_webhook")
@RequiredArgsConstructor
public class DepositWebhookController {
@Value("${app.deposit-webhook.token:}")
private String expectedToken;
private final PaymentService paymentService;
/**
* Called by 3rd party when a user's crypto deposit was successful.
* Body: user_id (internal id from db_users_a), usd_amount (decimal, e.g. 1.45).
*/
@PostMapping("/{token}")
public ResponseEntity<Void> 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();
}
}
}

View File

@@ -0,0 +1,99 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.CompletedRoundDto;
import com.lottery.lottery.dto.GameHistoryEntryDto;
import com.lottery.lottery.model.GameRound;
import com.lottery.lottery.repository.GameRoundRepository;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.AvatarService;
import com.lottery.lottery.service.GameHistoryService;
import com.lottery.lottery.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/api/game")
@RequiredArgsConstructor
public class GameController {
private final GameRoundRepository gameRoundRepository;
private final UserARepository userARepository;
private final AvatarService avatarService;
private final GameHistoryService gameHistoryService;
/**
* Gets the last 10 completed rounds for a specific room.
* Fetches data from game_rounds table only.
*/
@GetMapping("/room/{roomNumber}/completed-rounds")
public ResponseEntity<List<CompletedRoundDto>> getCompletedRounds(
@PathVariable Integer roomNumber
) {
List<GameRound> rounds = gameRoundRepository.findLastCompletedRoundsByRoomNumber(
roomNumber,
PageRequest.of(0, 10)
);
List<CompletedRoundDto> completedRounds = rounds.stream()
.map(round -> {
// Calculate winner's chance from game_rounds table data
Double winChance = null;
if (round.getWinnerBet() != null && round.getTotalBet() != null && round.getTotalBet() > 0) {
winChance = ((double) round.getWinnerBet() / round.getTotalBet()) * 100.0;
}
// Get winner's screen name and avatar
String screenName = null;
String avatarUrl = null;
if (round.getWinnerUserId() != null) {
screenName = userARepository.findById(round.getWinnerUserId())
.map(userA -> userA.getScreenName())
.orElse(null);
avatarUrl = avatarService.getAvatarUrl(round.getWinnerUserId());
}
return CompletedRoundDto.builder()
.roundId(round.getId())
.winnerUserId(round.getWinnerUserId())
.winnerScreenName(screenName)
.winnerAvatarUrl(avatarUrl)
.winnerBet(round.getWinnerBet())
.payout(round.getPayout())
.totalBet(round.getTotalBet())
.winChance(winChance)
.resolvedAt(round.getResolvedAt() != null ? round.getResolvedAt().toEpochMilli() : null)
.build();
})
.collect(Collectors.toList());
return ResponseEntity.ok(completedRounds);
}
/**
* Gets WIN transactions for the current user from the last 30 days with pagination.
*
* @param page Page number (0-indexed, default 0)
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
*/
@GetMapping("/history")
public ResponseEntity<org.springframework.data.domain.Page<GameHistoryEntryDto>> getUserGameHistory(
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String timezone) {
Integer userId = UserContext.get().getId();
com.lottery.lottery.model.UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
org.springframework.data.domain.Page<GameHistoryEntryDto> history = gameHistoryService.getUserGameHistory(userId, page, timezone, languageCode);
return ResponseEntity.ok(history);
}
}

View File

@@ -0,0 +1,184 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.config.WebSocketAuthInterceptor;
import com.lottery.lottery.dto.BalanceUpdateDto;
import com.lottery.lottery.dto.GameRoomStateDto;
import com.lottery.lottery.dto.JoinRoundRequest;
import com.lottery.lottery.exception.GameException;
import com.lottery.lottery.service.GameRoomService;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.service.UserService;
import jakarta.annotation.PostConstruct;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Controller
@RequiredArgsConstructor
public class GameWebSocketController {
private final GameRoomService gameRoomService;
private final SimpMessagingTemplate messagingTemplate;
private final UserService userService;
private final LocalizationService localizationService;
// Track which users are subscribed to which rooms
private final Map<Integer, Integer> userRoomSubscriptions = new ConcurrentHashMap<>();
// Track winners who have already received balance updates (to avoid duplicates)
private final Map<Integer, Integer> notifiedWinners = new ConcurrentHashMap<>(); // roomNumber -> winnerUserId
/**
* Initializes the controller and sets up balance update callback.
*/
@PostConstruct
public void init() {
// Set callback for balance update notifications
gameRoomService.setBalanceUpdateCallback(this::notifyBalanceUpdate);
// Set callback for state broadcast notifications (event-driven)
gameRoomService.setStateBroadcastCallback(this::broadcastRoomState);
}
/**
* Notifies a user about balance update.
* Called by GameRoomService for single participant refunds (no spin, so immediate update is fine).
*/
private void notifyBalanceUpdate(Integer userId) {
String username = String.valueOf(userId);
sendBalanceUpdate(username, userId);
}
/**
* Handles join round request from client.
*/
@MessageMapping("/game/join")
public void joinRound(@Valid @Payload JoinRoundRequest request, WebSocketAuthInterceptor.StompPrincipal principal) {
Integer userId = principal.getUserId();
// Additional validation beyond @Valid annotations
// @Valid handles null checks and basic constraints, but we add explicit checks for clarity
if (request == null) {
throw new GameException(localizationService.getMessage("game.error.invalidRequest"));
}
// Validate room number range (1-3)
// This is also covered by @Min/@Max, but explicit check provides better error message
if (request.getRoomNumber() == null || request.getRoomNumber() < 1 || request.getRoomNumber() > 3) {
throw new GameException(localizationService.getMessage("game.error.roomNumberInvalid"));
}
// Validate bet amount is positive (also covered by @Positive, but explicit for clarity)
if (request.getBetAmount() == null || request.getBetAmount() <= 0) {
throw new GameException(localizationService.getMessage("game.error.betMustBePositive"));
}
try {
// Join the round
GameRoomStateDto state = gameRoomService.joinRound(userId, request.getRoomNumber(), request.getBetAmount());
// Track subscription
userRoomSubscriptions.put(userId, request.getRoomNumber());
// Send balance update to the user who joined
sendBalanceUpdate(principal.getName(), userId);
// State is already broadcast by GameRoomService.joinRound() via callback (event-driven)
// No need to broadcast again here
} catch (GameException e) {
// User-friendly error message
sendErrorToUser(principal.getName(), e.getUserMessage());
} catch (Exception e) {
// Generic error - don't expose technical details
log.error("Unexpected error joining round for user {}", userId, e);
sendErrorToUser(principal.getName(), localizationService.getMessage("common.error.unknown"));
}
}
/**
* Sends error message to user.
*/
private void sendErrorToUser(String username, String errorMessage) {
messagingTemplate.convertAndSendToUser(
username,
"/queue/errors",
Map.of("error", errorMessage)
);
}
/**
* Global exception handler for WebSocket messages.
*/
@MessageExceptionHandler
public void handleException(Exception ex, WebSocketAuthInterceptor.StompPrincipal principal) {
String userMessage;
if (ex instanceof GameException) {
userMessage = ((GameException) ex).getUserMessage();
} else if (ex instanceof ConstraintViolationException) {
// Handle validation errors from @Valid annotation
ConstraintViolationException cve = (ConstraintViolationException) ex;
userMessage = cve.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.findFirst()
.orElse("Validation failed. Please check your input.");
log.warn("Validation error for user {}: {}", principal.getUserId(), userMessage);
} else {
log.error("Unexpected WebSocket error", ex);
userMessage = localizationService.getMessage("common.error.unknown");
}
sendErrorToUser(principal.getName(), userMessage);
}
/**
* Sends current room state when client subscribes.
* Note: SubscribeMapping doesn't support path variables well, so we'll handle subscription in joinRound
*/
/**
* Broadcasts room state to all subscribers.
* Called by GameRoomService via callback (event-driven).
*/
public void broadcastRoomState(Integer roomNumber, GameRoomStateDto state) {
messagingTemplate.convertAndSend("/topic/room/" + roomNumber, state);
}
/**
* Sends balance update to a specific user.
*/
private void sendBalanceUpdate(String username, Integer userId) {
try {
// Get current balance from database
Long balance = userService.getUserBalance(userId);
if (balance != null) {
BalanceUpdateDto balanceUpdate = BalanceUpdateDto.builder()
.balanceA(balance)
.build();
messagingTemplate.convertAndSendToUser(
username,
"/queue/balance",
balanceUpdate
);
}
} catch (Exception e) {
log.error("Failed to send balance update to user {}", userId, e);
}
}
}

View File

@@ -0,0 +1,56 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.NotifyBroadcastRequest;
import com.lottery.lottery.service.NotificationBroadcastService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Public API to trigger or stop notification broadcast. Token in path; no secrets in codebase.
* Set APP_NOTIFY_BROADCAST_TOKEN on VPS.
*/
@Slf4j
@RestController
@RequestMapping("/api/notify_broadcast")
@RequiredArgsConstructor
public class NotifyBroadcastController {
@Value("${app.notify-broadcast.token:}")
private String expectedToken;
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/{token}")
public ResponseEntity<Void> 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<Void> 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();
}
}

View File

@@ -0,0 +1,237 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.CryptoWithdrawalResponse;
import com.lottery.lottery.dto.CreateCryptoWithdrawalRequest;
import com.lottery.lottery.dto.CreatePaymentRequest;
import com.lottery.lottery.dto.DepositAddressRequest;
import com.lottery.lottery.dto.DepositAddressResultDto;
import com.lottery.lottery.dto.DepositMethodsDto;
import com.lottery.lottery.dto.ErrorResponse;
import com.lottery.lottery.dto.PaymentInvoiceResponse;
import com.lottery.lottery.model.Payout;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.dto.WithdrawalMethodDetailsDto;
import com.lottery.lottery.dto.WithdrawalMethodsDto;
import com.lottery.lottery.service.CryptoDepositService;
import com.lottery.lottery.service.CryptoWithdrawalService;
import com.lottery.lottery.service.FeatureSwitchService;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.service.PaymentService;
import com.lottery.lottery.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
private final CryptoDepositService cryptoDepositService;
private final CryptoWithdrawalService cryptoWithdrawalService;
private final PayoutService payoutService;
private final FeatureSwitchService featureSwitchService;
private final LocalizationService localizationService;
/**
* Returns minimum deposit from DB only (no sync). Used by Store screen for validation.
*/
@GetMapping("/minimum-deposit")
public ResponseEntity<?> getMinimumDeposit() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
return ResponseEntity.ok(java.util.Map.of("minimumDeposit", cryptoDepositService.getMinimumDeposit()));
}
/**
* Returns crypto deposit methods and minimum_deposit from DB only (sync is done every 10 min).
* Called when user opens Payment Options screen.
*/
@GetMapping("/deposit-methods")
public ResponseEntity<?> getDepositMethods() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
DepositMethodsDto dto = cryptoDepositService.getDepositMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns crypto withdrawal methods from DB only (sync is done every 30 min).
* Called when user opens Payout screen.
*/
@GetMapping("/withdrawal-methods")
public ResponseEntity<?> getWithdrawalMethods() {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
WithdrawalMethodsDto dto = cryptoWithdrawalService.getWithdrawalMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns withdrawal method details (rate_usd, misha_fee_usd) from external API for the given pid.
* Called when user opens Payout Confirmation screen to show network fee and compute "You will receive".
*/
@GetMapping("/withdrawal-method-details")
public ResponseEntity<?> getWithdrawalMethodDetails(@RequestParam("pid") int pid) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
return cryptoWithdrawalService.getWithdrawalMethodDetails(pid)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* Creates a crypto withdrawal: calls external API, then on success creates payout and deducts balance.
* Uses in-memory lock to prevent double-submit. Validates deposit total and maxWinAfterDeposit.
*/
@PostMapping("/crypto-withdrawal")
public ResponseEntity<?> createCryptoWithdrawal(@RequestBody CreateCryptoWithdrawalRequest request) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
Payout payout = payoutService.createCryptoPayout(user.getId(), request);
return ResponseEntity.ok(CryptoWithdrawalResponse.builder()
.id(payout.getId())
.status(payout.getStatus().name())
.build());
} catch (IllegalArgumentException e) {
log.warn("Crypto withdrawal validation failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.warn("Crypto withdrawal failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Crypto withdrawal error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Withdrawal failed. Please try again later."));
}
}
/**
* Creates a payment invoice for the current user.
* Returns invoice data that frontend will use to open Telegram payment UI.
*/
@PostMapping("/create")
public ResponseEntity<?> createPaymentInvoice(@RequestBody CreatePaymentRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
PaymentInvoiceResponse response = paymentService.createPaymentInvoice(user.getId(), request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payment invoice creation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment invoice creation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to create payment invoice: " + e.getMessage()));
}
}
/**
* Gets a crypto deposit address from the external API (no payment record is created).
* Call when user selects a payment method on Payment Options screen.
* Returns address, amount_coins, name, network for the Payment Confirmation screen.
*/
@PostMapping("/deposit-address")
public ResponseEntity<?> getDepositAddress(@RequestBody DepositAddressRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
DepositAddressResultDto result = paymentService.requestCryptoDepositAddress(
user.getId(), request.getPid(), request.getUsdAmount());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
log.warn("Deposit address request failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Deposit address error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage() != null ? e.getMessage() : "Failed to get deposit address"));
}
}
/**
* Cancels a payment (e.g., when user cancels in Telegram UI).
*/
@PostMapping("/cancel")
public ResponseEntity<?> cancelPayment(@RequestBody CancelPaymentRequest request) {
try {
String orderId = request.getOrderId();
UserA caller = UserContext.get();
log.info("Payment cancel requested: orderId={}, callerUserId={}", orderId, caller != null ? caller.getId() : null);
paymentService.cancelPayment(orderId);
return ResponseEntity.ok().body(new PaymentWebhookResponse(true, "Payment cancelled"));
} catch (IllegalArgumentException e) {
log.warn("Payment cancellation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment cancellation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to cancel payment: " + e.getMessage()));
}
}
// Response DTOs
private static class PaymentWebhookResponse {
private final boolean success;
private final String message;
public PaymentWebhookResponse(boolean success, String message) {
this.success = success;
this.message = message;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
private static class CancelPaymentRequest {
private String orderId;
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.CreatePayoutRequest;
import com.lottery.lottery.dto.ErrorResponse;
import com.lottery.lottery.dto.PayoutHistoryEntryDto;
import com.lottery.lottery.dto.PayoutResponse;
import com.lottery.lottery.model.Payout;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/payouts")
@RequiredArgsConstructor
public class PayoutController {
private final PayoutService payoutService;
/**
* Creates a payout request for the current user.
* Validates input and deducts balance if validation passes.
*/
@PostMapping
public ResponseEntity<?> createPayout(@RequestBody CreatePayoutRequest request) {
try {
UserA user = UserContext.get();
Payout payout = payoutService.createPayout(user.getId(), request);
PayoutResponse response = payoutService.toResponse(payout);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payout validation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.error("Payout creation failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage()));
}
}
/**
* Gets the last 20 payout history entries for the current user.
*
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
*/
@GetMapping("/history")
public List<PayoutHistoryEntryDto> 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);
}
}

View File

@@ -1,4 +1,4 @@
package com.honey.honey.controller; package com.lottery.lottery.controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -17,3 +17,4 @@ public class PingController {
} }
} }

View File

@@ -0,0 +1,43 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.PromotionDetailDto;
import com.lottery.lottery.dto.PromotionListItemDto;
import com.lottery.lottery.service.FeatureSwitchService;
import com.lottery.lottery.service.PublicPromotionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Public API for the lottery app: list and view promotion details (leaderboard, user progress).
* Excludes INACTIVE promotions. Requires Bearer auth (app user).
* When promotions feature switch is false, all endpoints return 404.
*/
@RestController
@RequestMapping("/api/promotions")
@RequiredArgsConstructor
public class PromotionController {
private final PublicPromotionService publicPromotionService;
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<PromotionListItemDto>> list() {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(publicPromotionService.listForApp());
}
@GetMapping("/{id}")
public ResponseEntity<PromotionDetailDto> getDetail(@PathVariable int id) {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return publicPromotionService.getDetailForApp(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,134 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.QuickAnswerCreateRequest;
import com.lottery.lottery.dto.QuickAnswerDto;
import com.lottery.lottery.model.Admin;
import com.lottery.lottery.model.QuickAnswer;
import com.lottery.lottery.repository.AdminRepository;
import com.lottery.lottery.repository.QuickAnswerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/quick-answers")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class QuickAnswerController {
private final QuickAnswerRepository quickAnswerRepository;
private final AdminRepository adminRepository;
/**
* Get current admin from authentication context
*/
private Admin getCurrentAdmin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
return adminRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("Admin not found: " + username));
}
/**
* Get all quick answers for the current admin
*/
@GetMapping
public ResponseEntity<List<QuickAnswerDto>> getQuickAnswers() {
Admin admin = getCurrentAdmin();
List<QuickAnswer> quickAnswers = quickAnswerRepository.findByAdminIdOrderByCreatedAtDesc(admin.getId());
List<QuickAnswerDto> 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<QuickAnswerDto> 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<QuickAnswerDto> 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<Void> 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();
}
}

View File

@@ -0,0 +1,191 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.exception.GameException;
import com.lottery.lottery.service.FeatureSwitchService;
import com.lottery.lottery.service.GameRoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* Unauthenticated API for 3rd party to register a user into a round (remote bet).
* Protected by a shared token in the path and a runtime feature switch.
* Same business logic as in-app join (balance, commissions, transactions, visibility in app).
*/
@Slf4j
@RestController
@RequestMapping("/api/remotebet")
@RequiredArgsConstructor
public class RemoteBetController {
private static final long TICKETS_TO_BIGINT = 1_000_000L;
@Value("${app.remote-bet.token:}")
private String configuredToken;
private final FeatureSwitchService featureSwitchService;
private final GameRoomService gameRoomService;
/**
* Registers the user to the current round in the given room with the given bet.
* GET /api/remotebet/{token}?user_id=228&room=2&amount=5&unique=false
* Or with random range: at least one of rand_min or rand_max (amount ignored).
* - user_id: db_users_a.id
* - room: room number (1, 2, or 3)
* - amount: bet in tickets. Ignored when rand_min and/or rand_max are provided.
* - unique: optional. If true, user can only have one bet per room per round (repeated calls no-op).
* - rand_min: optional. If only rand_min: random between rand_min and room max. If both: random between rand_min and rand_max.
* - rand_max: optional. If only rand_max: random between room min and rand_max. If both: random between rand_min and rand_max.
* Params are validated against room min/max (rand_min >= room min, rand_max <= room max; when both, rand_min <= rand_max).
*/
@GetMapping("/{token}")
public ResponseEntity<?> remoteBet(
@PathVariable String token,
@RequestParam(name = "user_id") Integer userId,
@RequestParam(name = "room") Integer room,
@RequestParam(name = "amount") Integer amountTickets,
@RequestParam(name = "unique", required = false) Boolean unique,
@RequestParam(name = "rand_min", required = false) Integer randMin,
@RequestParam(name = "rand_max", required = false) Integer randMax) {
if (configuredToken == null || configuredToken.isEmpty() || !configuredToken.equals(token)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (!featureSwitchService.isRemoteBetEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}
boolean useRandomRange = randMin != null || randMax != null;
long betAmount;
int amountTicketsForLog;
if (useRandomRange) {
GameRoomService.BetLimits limits = GameRoomService.getBetLimitsForRoom(room);
long roomMinTickets = limits.minBet() / TICKETS_TO_BIGINT;
long roomMaxTickets = limits.maxBet() / TICKETS_TO_BIGINT;
long effectiveMinTickets;
long effectiveMaxTickets;
if (randMin != null && randMax != null) {
if (randMin < roomMinTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)"));
}
if (randMax > roomMaxTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)"));
}
if (randMin > randMax) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_min must be less than or equal to rand_max"));
}
effectiveMinTickets = randMin;
effectiveMaxTickets = randMax;
} else if (randMin != null) {
if (randMin < roomMinTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_min must not be lower than room min bet (" + roomMinTickets + " tickets)"));
}
if (randMin > roomMaxTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_min must not be higher than room max bet (" + roomMaxTickets + " tickets)"));
}
effectiveMinTickets = randMin;
effectiveMaxTickets = roomMaxTickets;
} else {
if (randMax < roomMinTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_max must not be lower than room min bet (" + roomMinTickets + " tickets)"));
}
if (randMax > roomMaxTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"rand_max must not be higher than room max bet (" + roomMaxTickets + " tickets)"));
}
effectiveMinTickets = roomMinTickets;
effectiveMaxTickets = randMax;
}
long currentUserBetBigint = gameRoomService.getCurrentUserBetInRoom(userId, room);
long maxAdditionalBigint = Math.max(0L, limits.maxBet() - currentUserBetBigint);
long maxAdditionalTickets = maxAdditionalBigint / TICKETS_TO_BIGINT;
if (maxAdditionalTickets < limits.minBet() / TICKETS_TO_BIGINT) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"Max bet for this room already reached"));
}
effectiveMaxTickets = Math.min(effectiveMaxTickets, maxAdditionalTickets);
if (effectiveMinTickets > effectiveMaxTickets) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"Random range exceeds remaining bet capacity for this room"));
}
// Room 1: any integer; Room 2: divisible by 10; Room 3: divisible by 100
long step = room == 2 ? 10L : (room == 3 ? 100L : 1L);
long minAligned = roundUpToMultiple(effectiveMinTickets, step);
long maxAligned = roundDownToMultiple(effectiveMaxTickets, step);
if (minAligned > maxAligned) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, 0,
"No valid random value in range for room " + room + " (room 2 must be multiple of 10, room 3 multiple of 100)"));
}
long randomTickets = minAligned >= maxAligned
? minAligned
: minAligned + step * ThreadLocalRandom.current().nextLong(0, (maxAligned - minAligned) / step + 1);
betAmount = randomTickets * TICKETS_TO_BIGINT;
amountTicketsForLog = (int) randomTickets;
} else {
betAmount = (long) amountTickets * TICKETS_TO_BIGINT;
amountTicketsForLog = amountTickets;
}
boolean uniqueBet = Boolean.TRUE.equals(unique);
try {
var result = gameRoomService.joinRoundWithResult(userId, room, betAmount, uniqueBet);
var state = result.getState();
Long roundId = state.getRoundId();
int betTicketsForResponse = result.getBetTicketsForResponse();
String randRangeLog = useRandomRange ? (randMin != null ? randMin : "roomMin") + "-" + (randMax != null ? randMax : "roomMax") : "no";
log.info("Remote bet: user connected to round remotely, userId={}, roundId={}, roomId={}, betTickets={}, unique={}, randRange={}",
userId, roundId, room, betTicketsForResponse, uniqueBet, randRangeLog);
return ResponseEntity.ok(new RemoteBetResponse(true, roundId != null ? roundId.intValue() : null, room, betTicketsForResponse));
} catch (GameException e) {
return ResponseEntity.badRequest().body(new RemoteBetResponse(false, null, room, amountTicketsForLog, e.getUserMessage()));
} catch (Exception e) {
log.warn("Remote bet failed for userId={}, room={}, amount={}", userId, room, amountTicketsForLog, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new RemoteBetResponse(false, null, room, amountTicketsForLog, "Internal error"));
}
}
@lombok.Data
@lombok.AllArgsConstructor
@lombok.NoArgsConstructor
public static class RemoteBetResponse {
private boolean success;
private Integer roundId;
private Integer room;
private Integer betTickets;
private String error;
public RemoteBetResponse(boolean success, Integer roundId, Integer room, Integer betTickets) {
this(success, roundId, room, betTickets, null);
}
}
/** Round value up to next multiple of step (e.g. 23, 10 -> 30). */
private static long roundUpToMultiple(long value, long step) {
if (step <= 0) return value;
return ((value + step - 1) / step) * step;
}
/** Round value down to previous multiple of step (e.g. 197, 10 -> 190). */
private static long roundDownToMultiple(long value, long step) {
if (step <= 0) return value;
return (value / step) * step;
}
}

View File

@@ -0,0 +1,104 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.*;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.SupportTicketService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/support")
@RequiredArgsConstructor
public class SupportController {
private final SupportTicketService supportTicketService;
/**
* Creates a new support ticket with the first message.
*/
@PostMapping("/tickets")
public ResponseEntity<TicketDto> 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<List<TicketDto>> getTicketHistory() {
UserA user = UserContext.get();
List<TicketDto> tickets = supportTicketService.getTicketHistory(
user.getId()
);
return ResponseEntity.ok(tickets);
}
/**
* Gets ticket details with all messages.
*/
@GetMapping("/tickets/{ticketId}")
public ResponseEntity<TicketDetailDto> 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<MessageDto> 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<Void> closeTicket(
@PathVariable Long ticketId) {
UserA user = UserContext.get();
supportTicketService.closeTicket(
user.getId(),
ticketId
);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,100 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.ClaimTaskResponse;
import com.lottery.lottery.dto.DailyBonusStatusDto;
import com.lottery.lottery.dto.RecentBonusClaimDto;
import com.lottery.lottery.dto.TaskDto;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.service.TaskService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
private final LocalizationService localizationService;
/**
* Gets all tasks for a specific type (referral, follow, other).
* Includes user progress and claim status.
*/
@GetMapping
public List<TaskDto> 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<DailyBonusStatusDto> 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<List<RecentBonusClaimDto>> 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<RecentBonusClaimDto> 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<ClaimTaskResponse> 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;
}
}

View File

@@ -0,0 +1,868 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.config.TelegramProperties;
import com.lottery.lottery.dto.TelegramApiResponse;
import com.lottery.lottery.dto.PaymentWebhookRequest;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.service.PaymentService;
import com.lottery.lottery.service.TelegramBotApiService;
import com.lottery.lottery.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow;
import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.config.LocaleConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
import java.util.HashMap;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StreamUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.core.io.ByteArrayResource;
/**
* Webhook controller for receiving Telegram updates directly.
* Path: POST /api/telegram/webhook/{token}. Token must match APP_TELEGRAM_WEBHOOK_TOKEN.
*/
@Slf4j
@RestController
@RequestMapping("/api/telegram/webhook")
@RequiredArgsConstructor
public class TelegramWebhookController {
@Value("${app.telegram-webhook.token:}")
private String expectedWebhookToken;
private final UserService userService;
private final PaymentService paymentService;
private final TelegramProperties telegramProperties;
private final LocalizationService localizationService;
private final TelegramBotApiService telegramBotApiService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Webhook endpoint for receiving updates from Telegram.
* Path token must match app.telegram-webhook.token (APP_TELEGRAM_WEBHOOK_TOKEN).
*/
@PostMapping("/{token}")
public ResponseEntity<?> handleWebhook(@PathVariable String token, @RequestBody Update update, HttpServletRequest httpRequest) {
if (expectedWebhookToken.isEmpty() || !expectedWebhookToken.equals(token)) {
log.warn("Webhook rejected: invalid or missing token (possible misconfiguration or wrong URL); update dropped");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Handle callback queries (button clicks)
if (update.hasCallbackQuery()) {
handleCallbackQuery(update.getCallbackQuery());
}
// Handle message updates (e.g., /start command or Reply Keyboard button clicks)
if (update.hasMessage() && update.getMessage().hasText()) {
handleMessage(update.getMessage(), httpRequest);
}
// Handle pre-checkout query (before payment confirmation)
if (update.hasPreCheckoutQuery()) {
handlePreCheckoutQuery(update.getPreCheckoutQuery());
}
// Handle successful payment
if (update.hasMessage() && update.getMessage().hasSuccessfulPayment()) {
handleSuccessfulPayment(update.getMessage().getSuccessfulPayment(), update.getMessage().getFrom().getId());
}
return ResponseEntity.ok().build();
} catch (Exception e) {
log.error("Error processing Telegram webhook: {}", e.getMessage(), e);
if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().startsWith("/start")) {
Long telegramId = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getId() : null;
log.warn("Registration attempt failed (webhook error), update dropped: telegramId={}", telegramId);
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleMessage(Message message, HttpServletRequest httpRequest) {
String messageText = message.getText();
if (messageText == null) {
return;
}
User telegramUser = message.getFrom();
Long telegramId = telegramUser.getId();
Long chatId = message.getChatId();
// Handle /start command
if (messageText.startsWith("/start")) {
handleStartCommand(message, httpRequest, telegramUser, telegramId);
return;
}
// Handle Reply Keyboard button clicks
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
// Check if message matches Reply Keyboard button text
String startSpinningText = "🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning");
String usersPayoutsText = "💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts");
String infoChannelText = " " + localizationService.getMessage(locale, "bot.button.infoChannel");
if (messageText.equals(startSpinningText)) {
sendStartSpinningMessage(chatId, locale);
} else if (messageText.equals(usersPayoutsText)) {
sendUsersPayoutsMessage(chatId, locale);
} else if (messageText.equals(infoChannelText)) {
sendInfoChannelMessage(chatId, locale);
} else {
// Unknown message (e.g. old "Start Spinning" button or free text): reply and refresh keyboard
sendUnrecognizedMessageAndUpdateKeyboard(chatId, locale);
}
}
/**
* Handles /start command with optional referral parameter.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleStartCommand(Message message, HttpServletRequest httpRequest, User telegramUser, Long telegramId) {
String messageText = message.getText();
log.debug("Received /start command: telegramId={}", telegramId);
Integer referralUserId = null;
// Parse referral parameter from /start command
// Format: /start or /start 123
String[] parts = messageText.split("\\s+", 2);
if (parts.length > 1 && !parts[1].trim().isEmpty()) {
try {
referralUserId = Integer.parseInt(parts[1].trim());
log.debug("Parsed referral ID: {}", referralUserId);
} catch (NumberFormatException e) {
log.warn("Invalid referral parameter format: '{}'", parts[1]);
return;
}
}
// Check if user already exists
boolean isNewUser = userService.getUserByTelegramId(telegramId).isEmpty();
if (isNewUser) {
log.info("New user registration via bot - telegramId={}, referralUserId={}",
telegramId, referralUserId);
}
// Build tgUserData map similar to what TelegramAuthService.parseInitData returns
Map<String, Object> 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<String, Object> 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<KeyboardRow> keyboardRows = new ArrayList<>();
KeyboardRow row1 = new KeyboardRow();
KeyboardButton startButton = new KeyboardButton();
startButton.setText("🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning"));
row1.add(startButton);
keyboardRows.add(row1);
KeyboardRow row2 = new KeyboardRow();
KeyboardButton payoutsButton = new KeyboardButton();
payoutsButton.setText("💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts"));
row2.add(payoutsButton);
KeyboardButton infoButton = new KeyboardButton();
infoButton.setText(" " + localizationService.getMessage(locale, "bot.button.infoChannel"));
row2.add(infoButton);
keyboardRows.add(row2);
replyKeyboard.setKeyboard(keyboardRows);
return replyKeyboard;
}
/**
* Sends welcome messages: first message with reply keyboard, second message with inline button.
*/
private void sendWelcomeMessage(Long chatId, String languageCode) {
if (telegramProperties.getBotToken() == null || telegramProperties.getBotToken().isEmpty()) {
log.warn("Bot token not configured; welcome message not sent for chatId={} (registration flow affected)", chatId);
return;
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode != null ? languageCode : "EN");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
// Create inline keyboard with only START SPINNING button
InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> inlineRows = new ArrayList<>();
List<InlineKeyboardButton> inlineRow = new ArrayList<>();
InlineKeyboardButton startInlineButton = new InlineKeyboardButton();
// Add arrows on both sides like in the reference app (right arrow on left, left arrow on right)
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
startInlineButton.setText(startSpinningButtonText);
// Use WebAppInfo to open mini app instead of regular URL
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl("https://win-spin.live/auth");
startInlineButton.setWebApp(webAppInfo);
inlineRow.add(startInlineButton);
inlineRows.add(inlineRow);
inlineKeyboard.setKeyboard(inlineRows);
// Send first message with GIF animation and reply keyboard
// Note: Telegram doesn't allow both inline and reply keyboards in the same message
String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage");
sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard);
// Send second message with inline button (START SPINNING)
String welcomeText = localizationService.getMessage(locale, "bot.welcome.message");
sendMessage(chatId, welcomeText, inlineKeyboard);
}
/**
* Sends message with Start Spinning button.
*/
private void sendStartSpinningMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.startSpinning");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
// Add arrows on both sides like in the reference app (right arrow on left, left arrow on right)
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
button.setText(startSpinningButtonText);
// Use WebAppInfo to open mini app instead of regular URL
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl("https://win-spin.live/auth");
button.setWebApp(webAppInfo);
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Sends a friendly "unrecognized message" reply and updates the user's reply keyboard to the current one.
* Used when the user sends unknown text (e.g. old "Start Spinning" button) so they get the new keyboard.
*/
private void sendUnrecognizedMessageAndUpdateKeyboard(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.unrecognized");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
sendMessageWithReplyKeyboard(chatId, message, replyKeyboard);
}
/**
* Sends message with Users payouts button.
*/
private void sendUsersPayoutsMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.usersPayouts");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> 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<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> 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<String, Object> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> 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<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
requestBody.put("text", text);
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
Map<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> 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<String, Object> 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<ByteArrayResource> 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<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<TelegramApiResponse> 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<String, Object> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> 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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
log.debug("Answering pre-checkout query: queryId={}, ok={}", queryId, ok);
ResponseEntity<TelegramApiResponse> 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);
}
}
}

View File

@@ -0,0 +1,50 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.TransactionDto;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.TransactionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Controller for transaction history operations.
*/
@Slf4j
@RestController
@RequestMapping("/api/transactions")
@RequiredArgsConstructor
public class TransactionController {
private final TransactionService transactionService;
/**
* Gets transaction history for the current user.
* Returns 50 transactions per page, ordered by creation time descending (newest first).
*
* @param page Page number (0-indexed, defaults to 0)
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
* @return Page of transactions
*/
@GetMapping
public ResponseEntity<Page<TransactionDto>> 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<TransactionDto> transactions = transactionService.getUserTransactions(userId, page, timezone, languageCode);
return ResponseEntity.ok(transactions);
}
}

View File

@@ -0,0 +1,105 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.UserCheckDto;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.model.UserB;
import com.lottery.lottery.model.UserD;
import com.lottery.lottery.repository.PaymentRepository;
import com.lottery.lottery.repository.UserARepository;
import com.lottery.lottery.repository.UserBRepository;
import com.lottery.lottery.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Controller for user check endpoint (open endpoint for external applications).
* Path token is validated against app.check-user.token (APP_CHECK_USER_TOKEN). No user auth.
*/
@Slf4j
@RestController
@RequestMapping("/api/check_user")
@RequiredArgsConstructor
public class UserCheckController {
@Value("${app.check-user.token:}")
private String expectedToken;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository;
/**
* Gets user information by Telegram ID.
* Path: /api/check_user/{token}/{telegramId}. Token must match APP_CHECK_USER_TOKEN.
*
* @param token Secret token from path (must match config)
* @param telegramId The Telegram ID of the user
* @return 200 with user info (found=true) or 200 with found=false when user not found; 403 if token invalid
*/
@GetMapping("/{token}/{telegramId}")
public ResponseEntity<UserCheckDto> 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<UserA> 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<UserB> userBOpt = userBRepository.findById(userId);
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
// Convert to tickets (balance_a / 1,000,000)
Double tickets = balanceA / 1_000_000.0;
// Get rounds_played from db_users_b
Integer roundsPlayed = userBOpt.map(UserB::getRoundsPlayed).orElse(0);
// Get referer_id_1 from db_users_d
Optional<UserD> userDOpt = userDRepository.findById(userId);
Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0);
// Return 0 if refererId is 0 or negative (not set)
if (refererId <= 0) {
refererId = 0;
}
// Sum completed payments stars_amount
Integer depositTotal = paymentRepository.sumCompletedStarsAmountByUserId(userId);
// Build response
UserCheckDto response = UserCheckDto.builder()
.found(true)
.dateReg(userA.getDateReg())
.tickets(tickets)
.depositTotal(depositTotal)
.refererId(refererId)
.roundsPlayed(roundsPlayed)
.build();
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Error checking user for telegramId={}", telegramId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,146 @@
package com.lottery.lottery.controller;
import com.lottery.lottery.dto.ReferralDto;
import com.lottery.lottery.dto.UserDto;
import com.lottery.lottery.model.UserA;
import com.lottery.lottery.model.UserB;
import com.lottery.lottery.repository.UserBRepository;
import com.lottery.lottery.security.UserContext;
import com.lottery.lottery.service.AvatarService;
import com.lottery.lottery.service.FeatureSwitchService;
import com.lottery.lottery.service.LocalizationService;
import com.lottery.lottery.service.UserService;
import com.lottery.lottery.util.IpUtils;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserBRepository userBRepository;
private final AvatarService avatarService;
private final LocalizationService localizationService;
private final FeatureSwitchService featureSwitchService;
@GetMapping("/current")
public UserDto getCurrentUser() {
UserA user = UserContext.get();
// Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp());
// Get balance
Long balanceA = userBRepository.findById(user.getId())
.map(UserB::getBalanceA)
.orElse(0L);
// Generate avatar URL on-the-fly (deterministic from userId)
String avatarUrl = avatarService.getAvatarUrl(user.getId());
return UserDto.builder()
.id(user.getId())
.telegram_id(user.getTelegramId())
.username(user.getTelegramName())
.screenName(user.getScreenName())
.dateReg(user.getDateReg())
.ip(ipAddress)
.balanceA(balanceA)
.avatarUrl(avatarUrl)
.languageCode(user.getLanguageCode())
.paymentEnabled(featureSwitchService.isPaymentEnabled())
.payoutEnabled(featureSwitchService.isPayoutEnabled())
.promotionsEnabled(featureSwitchService.isPromotionsEnabled())
.build();
}
/**
* Updates user's language code.
* Called when user changes language in app header.
*/
@PutMapping("/language")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
UserA user = UserContext.get();
userService.updateLanguageCode(user.getId(), request.getLanguageCode());
}
/**
* Adds deposit amount to user's balance_a.
* For now, this is a mock implementation that directly adds to balance.
* Will be replaced with payment integration later.
*/
@PostMapping("/deposit")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deposit(@RequestBody DepositRequest request) {
UserA user = UserContext.get();
// Frontend sends amount already in bigint format (no conversion needed)
Long depositAmount = request.getAmount();
if (depositAmount == null || depositAmount <= 0) {
throw new IllegalArgumentException(localizationService.getMessage("user.error.depositAmountInvalid"));
}
UserB userB = userBRepository.findById(user.getId())
.orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound")));
// Add to balance
userB.setBalanceA(userB.getBalanceA() + depositAmount);
// Update deposit statistics
userB.setDepositTotal(userB.getDepositTotal() + depositAmount);
userB.setDepositCount(userB.getDepositCount() + 1);
userBRepository.save(userB);
}
@Data
public static class UpdateLanguageRequest {
private String languageCode;
}
/**
* Gets referrals for a specific level with pagination.
* Always returns 50 results per page.
*
* @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed, defaults to 0)
* @return Page of referrals with name and commission
*/
@GetMapping("/referrals")
public ReferralsResponse getReferrals(
@RequestParam Integer level,
@RequestParam(defaultValue = "0") Integer page) {
UserA user = UserContext.get();
Page<ReferralDto> 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<ReferralDto> referrals;
private final Integer currentPage;
private final Integer totalPages;
private final Long totalElements;
}
}

View File

@@ -0,0 +1,34 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminBotConfigDto {
private Integer id;
private Integer userId;
/** User screen name from db_users_a (for display). */
private String screenName;
private Boolean room1;
private Boolean room2;
private Boolean room3;
/** Time window start UTC, format HH:mm (e.g. "14:00"). */
private String timeUtcStart;
/** Time window end UTC, format HH:mm (e.g. "17:00"). */
private String timeUtcEnd;
/** Min bet in bigint (1 ticket = 1_000_000). */
private Long betMin;
/** Max bet in bigint. */
private Long betMax;
private String persona;
private Boolean active;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,37 @@
package com.lottery.lottery.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminBotConfigRequest {
@NotNull(message = "userId is required")
private Integer userId;
@NotNull
private Boolean room1;
@NotNull
private Boolean room2;
@NotNull
private Boolean room3;
/** Time window start UTC, format HH:mm (e.g. "14:00"). */
@NotNull
private String timeUtcStart;
/** Time window end UTC, format HH:mm (e.g. "17:00"). */
@NotNull
private String timeUtcEnd;
/** Min bet in bigint (1 ticket = 1_000_000). */
@NotNull
private Long betMin;
/** Max bet in bigint. */
@NotNull
private Long betMax;
private String persona; // conservative, aggressive, balanced; default balanced
@NotNull
private Boolean active;
}

View File

@@ -0,0 +1,19 @@
package com.lottery.lottery.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AdminConfigurationsRequest {
private List<Integer> safeBotUserIds = new ArrayList<>();
private List<FlexibleBotEntry> flexibleBots = new ArrayList<>();
@Data
public static class FlexibleBotEntry {
private Integer userId;
private Double winRate;
}
}

View File

@@ -0,0 +1,28 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminGameRoundDto {
private Long roundId;
private Integer roomNumber;
private String phase;
private Long totalBet;
private Long userBet;
private Integer winnerUserId;
private Long winnerBet;
private Long payout;
private Long commission;
private Instant startedAt;
private Instant resolvedAt;
private Boolean isWinner;
}

View File

@@ -0,0 +1,10 @@
package com.lottery.lottery.dto;
import lombok.Data;
@Data
public class AdminLoginRequest {
private String username;
private String password;
}

View File

@@ -0,0 +1,13 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AdminLoginResponse {
private String token;
private String username;
private String role;
}

View File

@@ -0,0 +1,31 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminMasterDto {
private Integer id;
private String screenName;
/** Level 1 referrals count (from master's UserD row). */
private Integer referals1;
/** Level 2 referrals count. */
private Integer referals2;
/** Level 3 referrals count. */
private Integer referals3;
/** Total users with master_id = this master's id. */
private Long totalReferrals;
/** Sum of deposit_total of all referrals, in USD (divided by 1e9). */
private BigDecimal depositTotalUsd;
/** Sum of withdraw_total of all referrals, in USD (divided by 1e9). */
private BigDecimal withdrawTotalUsd;
/** depositTotalUsd - withdrawTotalUsd. */
private BigDecimal profitUsd;
}

View File

@@ -0,0 +1,27 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPaymentDto {
private Long id;
private Integer userId;
private String userName;
private String orderId;
private Integer starsAmount;
private Long ticketsAmount;
private String status;
private String telegramPaymentChargeId;
private String telegramProviderPaymentChargeId;
private Instant createdAt;
private Instant completedAt;
}

View File

@@ -0,0 +1,28 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPayoutDto {
private Long id;
private Integer userId;
private String userName;
private String username;
private String type;
private String giftName;
private Long total;
private Integer starsAmount;
private Integer quantity;
private String status;
private Instant createdAt;
private Instant resolvedAt;
}

View File

@@ -0,0 +1,23 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionDto {
private Integer id;
private String type;
private Instant startTime;
private Instant endTime;
private String status;
private Long totalReward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,25 @@
package com.lottery.lottery.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRequest {
@NotNull
private String type;
@NotNull
private Instant startTime;
@NotNull
private Instant endTime;
@NotNull
private String status;
private Long totalReward;
}

View File

@@ -0,0 +1,22 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardDto {
private Integer id;
private Integer promoId;
private Integer place;
/** Reward in bigint (1 ticket = 1_000_000). */
private Long reward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.lottery.lottery.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardRequest {
@NotNull
private Integer place;
@NotNull
private Long reward; // bigint, 1 ticket = 1_000_000
}

View File

@@ -0,0 +1,21 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserDto {
private Integer promoId;
private Integer userId;
/** Points as ticket count, 2 decimal places (e.g. 100.25). */
private BigDecimal points;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.lottery.lottery.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserPointsRequest {
@NotNull
private BigDecimal points; // ticket count, 2 decimal places
}

View File

@@ -0,0 +1,26 @@
package com.lottery.lottery.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminRoomDetailDto {
private Integer roomNumber;
private String phase;
private Long roundId;
private Long totalBetTickets;
private Double totalBetUsd;
private Integer registeredPlayers;
private Integer connectedUsers;
private List<AdminRoomParticipantDto> participants;
/** Viewers: same as participants section format but without tickets/chances (screen name + id). */
private List<AdminRoomViewerDto> connectedViewers;
private AdminRoomWinnerDto winner; // when phase is SPINNING or RESOLUTION
}

Some files were not shown because too many files have changed in this diff Show More