replaced everything with ws
This commit is contained in:
114
ADMIN_SETUP.md
Normal file
114
ADMIN_SETUP.md
Normal 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
1024
APPLICATION_OVERVIEW.md
Normal file
File diff suppressed because it is too large
Load Diff
327
BACKUP_SETUP.md
Normal file
327
BACKUP_SETUP.md
Normal 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
492
BACKUP_TROUBLESHOOTING.md
Normal 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
765
DEPLOYMENT_GUIDE.md
Normal 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
202
DOCKER_LOGGING_SETUP.md
Normal 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! 🚀
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -18,15 +18,28 @@ WORKDIR /app
|
||||
# Copy fat jar from build stage
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
# Copy startup script
|
||||
# Copy startup script (optional - only used if secret file doesn't exist)
|
||||
COPY scripts/create-secret-file.sh /app/create-secret-file.sh
|
||||
RUN chmod +x /app/create-secret-file.sh
|
||||
|
||||
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
|
||||
COPY src/main/resources/logback-spring.xml /app/config/logback-spring.xml
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /app/logs && chmod 755 /app/logs
|
||||
|
||||
# Expose port (for local/docker-compose/documentation)
|
||||
EXPOSE 8080
|
||||
|
||||
# Default environment variables (can be overridden in docker-compose)
|
||||
ENV JAVA_OPTS=""
|
||||
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
|
||||
ENV LOG_DIR="/app/logs"
|
||||
|
||||
# Create secret file from env vars (for testing ConfigLoader) then start app
|
||||
ENTRYPOINT ["sh", "-c", "/app/create-secret-file.sh && java $JAVA_OPTS -jar app.jar"]
|
||||
# Start app
|
||||
# If /run/secrets/lottery-config.properties exists (mounted), ConfigLoader will use it
|
||||
# Otherwise, create-secret-file.sh will create it from env vars (for development/testing)
|
||||
# Uses external logback-spring.xml for runtime log level changes
|
||||
# Ensure logback-spring.xml exists and is a file (not a directory)
|
||||
ENTRYPOINT ["sh", "-c", "if [ ! -f /run/secrets/lottery-config.properties ]; then /app/create-secret-file.sh; fi && if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]
|
||||
|
||||
|
||||
@@ -18,10 +18,22 @@ WORKDIR /app
|
||||
# Copy fat jar from build stage
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
|
||||
COPY --from=build /app/src/main/resources/logback-spring.xml /app/config/logback-spring.xml
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /app/logs && chmod 755 /app/logs
|
||||
|
||||
# Expose port (for internal communication with nginx)
|
||||
EXPOSE 8080
|
||||
|
||||
# Default environment variables (can be overridden in docker-compose)
|
||||
ENV JAVA_OPTS=""
|
||||
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
|
||||
ENV LOG_DIR="/app/logs"
|
||||
|
||||
# Start app with external logback config
|
||||
# Ensure logback-spring.xml exists and is a file (not a directory)
|
||||
ENTRYPOINT ["sh", "-c", "if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||
|
||||
|
||||
88
EXTERNAL_API.md
Normal file
88
EXTERNAL_API.md
Normal 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
341
LOGGING_GUIDE.md
Normal 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
94
PHPMYADMIN_QUICK_START.md
Normal 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
355
PHPMYADMIN_SETUP.md
Normal 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
217
QUICK_REFERENCE.md
Normal 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`).
|
||||
|
||||
61
README.md
61
README.md
@@ -1,6 +1,6 @@
|
||||
# Honey Backend
|
||||
# Lottery Backend
|
||||
|
||||
Spring Boot backend application for Honey project.
|
||||
Spring Boot backend application for Lottery project.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@@ -30,7 +30,7 @@ Spring Boot backend application for Honey project.
|
||||
|
||||
3. **Create `.env` file** (for local development):
|
||||
```env
|
||||
DB_NAME=honey_db
|
||||
DB_NAME=lottery_db
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=password
|
||||
DB_ROOT_PASSWORD=password
|
||||
@@ -75,7 +75,7 @@ Railway is the primary deployment platform for staging. It provides built-in log
|
||||
1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**)
|
||||
2. If using GitHub:
|
||||
- Connect your GitHub account
|
||||
- Select the `honey-be` repository
|
||||
- Select the `lottery-be` repository
|
||||
- Railway will automatically detect it's a Java/Maven project
|
||||
3. If using Empty Service:
|
||||
- Click **"Empty Service"**
|
||||
@@ -122,12 +122,12 @@ PORT=8080
|
||||
1. In your backend service, go to **"Settings"** → **"Networking"**
|
||||
2. Click **"Generate Domain"** to get a public URL
|
||||
3. Or use the default Railway domain
|
||||
4. Copy the URL (e.g., `https://honey-be-production.up.railway.app`)
|
||||
4. Copy the URL (e.g., `https://lottery-be-production.up.railway.app`)
|
||||
|
||||
#### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway)
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"GitHub Repo"**
|
||||
2. Select your `honey-fe` repository
|
||||
2. Select your `lottery-fe` repository
|
||||
3. Railway will detect it's a Node.js project
|
||||
4. Add environment variable:
|
||||
```env
|
||||
@@ -140,7 +140,7 @@ PORT=8080
|
||||
If you need persistent storage:
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"Volume"**
|
||||
2. Name it (e.g., `honey-data`)
|
||||
2. Name it (e.g., `lottery-data`)
|
||||
3. Mount it to your service if needed
|
||||
|
||||
### Inferno Deployment (Production Environment)
|
||||
@@ -184,16 +184,16 @@ Inferno Solution provides the production environment. It requires manual server
|
||||
|
||||
5. **Create project directory**:
|
||||
```bash
|
||||
mkdir -p /opt/honey
|
||||
cd /opt/honey
|
||||
mkdir -p /opt/lottery
|
||||
cd /opt/lottery
|
||||
```
|
||||
|
||||
#### Step 2: Clone Repository
|
||||
|
||||
```bash
|
||||
cd /opt/honey
|
||||
git clone https://github.com/your-username/honey-be.git
|
||||
cd honey-be
|
||||
cd /opt/lottery
|
||||
git clone https://github.com/your-username/lottery-be.git
|
||||
cd lottery-be
|
||||
```
|
||||
|
||||
#### Step 3: Create Secret Configuration File
|
||||
@@ -206,14 +206,14 @@ sudo mkdir -p /run/secrets
|
||||
sudo chmod 700 /run/secrets
|
||||
|
||||
# Create secret file
|
||||
sudo nano /run/secrets/honey-config.properties
|
||||
sudo nano /run/secrets/lottery-config.properties
|
||||
```
|
||||
|
||||
Add the following content (replace with your actual values):
|
||||
|
||||
```properties
|
||||
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
|
||||
SPRING_DATASOURCE_USERNAME=honey_user
|
||||
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
|
||||
SPRING_DATASOURCE_USERNAME=lottery_user
|
||||
SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
FRONTEND_URL=https://your-frontend-domain.com
|
||||
@@ -233,7 +233,7 @@ The `docker-compose.inferno.yml` file is already configured. Make sure it's pres
|
||||
#### Step 5: Build and Start Services
|
||||
|
||||
```bash
|
||||
cd /opt/honey/honey-be
|
||||
cd /opt/lottery/lottery-be
|
||||
|
||||
# Build and start all services
|
||||
docker-compose -f docker-compose.inferno.yml up -d --build
|
||||
@@ -249,7 +249,7 @@ This will:
|
||||
|
||||
1. **Edit nginx configuration**:
|
||||
```bash
|
||||
nano nginx/conf.d/honey.conf
|
||||
nano nginx/conf.d/lottery.conf
|
||||
```
|
||||
|
||||
2. **Update server_name** (if using HTTPS):
|
||||
@@ -278,7 +278,7 @@ This will:
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/honey.conf`)
|
||||
3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`)
|
||||
|
||||
4. **Reload nginx**:
|
||||
```bash
|
||||
@@ -304,21 +304,21 @@ sudo ufw enable
|
||||
Create a systemd service to ensure services start on boot:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/honey.service
|
||||
sudo nano /etc/systemd/system/lottery.service
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Honey Application
|
||||
Description=Lottery Application
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/honey/honey-be
|
||||
WorkingDirectory=/opt/lottery/lottery-be
|
||||
ExecStart=/usr/local/bin/docker-compose -f docker-compose.inferno.yml up -d
|
||||
ExecStop=/usr/local/bin/docker-compose -f docker-compose.inferno.yml down
|
||||
TimeoutStartSec=0
|
||||
@@ -331,8 +331,8 @@ Enable the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable honey.service
|
||||
sudo systemctl start honey.service
|
||||
sudo systemctl enable lottery.service
|
||||
sudo systemctl start lottery.service
|
||||
```
|
||||
|
||||
#### Step 10: Set Up Grafana Integration (Production Logging)
|
||||
@@ -357,13 +357,13 @@ sudo systemctl start honey.service
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
- job_name: honey-backend
|
||||
- job_name: lottery-backend
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
regex: honey-backend
|
||||
regex: lottery-backend
|
||||
action: keep
|
||||
```
|
||||
|
||||
@@ -398,14 +398,14 @@ docker-compose -f docker-compose.inferno.yml logs -f app
|
||||
|
||||
**Update application**:
|
||||
```bash
|
||||
cd /opt/honey/honey-be
|
||||
cd /opt/lottery/lottery-be
|
||||
git pull
|
||||
docker-compose -f docker-compose.inferno.yml up -d --build
|
||||
```
|
||||
|
||||
**Backup database**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p honey_db > backup_$(date +%Y%m%d).sql
|
||||
docker-compose -f docker-compose.inferno.yml exec db mysqldump -u lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -415,7 +415,7 @@ docker-compose -f docker-compose.inferno.yml exec db mysqldump -u honey_user -p
|
||||
The application supports two configuration strategies:
|
||||
|
||||
1. **Environment Variables** (Railway): Set variables in Railway dashboard
|
||||
2. **Secret File** (Inferno): Mount file at `/run/secrets/honey-config.properties`
|
||||
2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties`
|
||||
|
||||
Priority: Secret file → Environment variables
|
||||
|
||||
@@ -511,10 +511,10 @@ docker-compose up --build
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
honey-be/
|
||||
lottery-be/
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── java/com/honey/honey/
|
||||
│ │ ├── java/com/lottery/lottery/
|
||||
│ │ │ ├── config/ # Configuration classes
|
||||
│ │ │ ├── controller/ # REST controllers
|
||||
│ │ │ ├── dto/ # Data transfer objects
|
||||
@@ -540,3 +540,4 @@ honey-be/
|
||||
|
||||
[Your License Here]
|
||||
|
||||
|
||||
|
||||
356
ROLLING_UPDATE_GUIDE.md
Normal file
356
ROLLING_UPDATE_GUIDE.md
Normal 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
208
VPS_DEPLOYMENT_NOTES.md
Normal 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
188
VPS_DEPLOYMENT_SUMMARY.md
Normal 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
|
||||
|
||||
@@ -3,48 +3,56 @@ version: "3.9"
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: honey-mysql
|
||||
container_name: lottery-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: honey_db
|
||||
MYSQL_USER: honey_user
|
||||
MYSQL_DATABASE: lottery_db
|
||||
MYSQL_USER: lottery_user
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- honey_mysql_data:/var/lib/mysql
|
||||
- lottery_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- honey-network
|
||||
- lottery-network
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.inferno
|
||||
container_name: honey-backend
|
||||
container_name: lottery-backend
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
|
||||
- SPRING_DATASOURCE_USERNAME=honey_user
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
|
||||
- SPRING_DATASOURCE_USERNAME=lottery_user
|
||||
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- FRONTEND_URL=${FRONTEND_URL}
|
||||
# Logging configuration (external logback-spring.xml)
|
||||
- LOGGING_CONFIG=/app/config/logback-spring.xml
|
||||
- LOG_DIR=/app/logs
|
||||
volumes:
|
||||
# Mount secret file from tmpfs
|
||||
- /run/secrets:/run/secrets:ro
|
||||
# Mount logback config directory (editable on VPS without rebuilding)
|
||||
# Note: File must exist on host before mounting. Run setup-logging.sh first.
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
# Mount logs directory (persistent storage)
|
||||
- /opt/app/logs:/app/logs
|
||||
networks:
|
||||
- honey-network
|
||||
- lottery-network
|
||||
# Don't expose port directly - nginx will handle it
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: honey-nginx
|
||||
container_name: lottery-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -57,12 +65,13 @@ services:
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- honey-network
|
||||
- lottery-network
|
||||
|
||||
volumes:
|
||||
honey_mysql_data:
|
||||
lottery_mysql_data:
|
||||
|
||||
networks:
|
||||
honey-network:
|
||||
lottery-network:
|
||||
driver: bridge
|
||||
|
||||
|
||||
|
||||
193
docker-compose.prod.yml
Normal file
193
docker-compose.prod.yml
Normal 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
|
||||
|
||||
@@ -3,17 +3,17 @@ version: "3.9"
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: honey-mysql
|
||||
container_name: lottery-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:honey_db}
|
||||
MYSQL_DATABASE: ${DB_NAME:lottery_db}
|
||||
MYSQL_USER: ${DB_USERNAME:root}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:password}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- honey_mysql_data:/var/lib/mysql
|
||||
- lottery_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"]
|
||||
interval: 10s
|
||||
@@ -24,17 +24,18 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
build: .
|
||||
container_name: honey-backend
|
||||
container_name: lottery-backend
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db}
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:lottery_db}
|
||||
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root}
|
||||
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password}
|
||||
|
||||
volumes:
|
||||
honey_mysql_data:
|
||||
lottery_mysql_data:
|
||||
|
||||
|
||||
|
||||
62
lottery-config.properties.template
Normal file
62
lottery-config.properties.template
Normal 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
128
nginx.conf.template
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
upstream honey_backend {
|
||||
server app:8080;
|
||||
upstream lottery_backend {
|
||||
# Primary backend (port 8080)
|
||||
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
|
||||
# Standby backend (port 8082) - used during rolling updates
|
||||
# Uncomment the line below to switch traffic to new backend
|
||||
# server 127.0.0.1:8082 max_fails=3 fail_timeout=30s backup;
|
||||
|
||||
# Health check configuration
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -16,7 +23,7 @@ server {
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_pass http://lottery_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -34,7 +41,7 @@ server {
|
||||
|
||||
# Actuator endpoints (for health checks)
|
||||
location /actuator/ {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_pass http://lottery_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -44,7 +51,7 @@ server {
|
||||
|
||||
# Ping endpoint
|
||||
location /ping {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_pass http://lottery_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
@@ -74,3 +81,4 @@ server {
|
||||
# # ...
|
||||
# }
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ http {
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
|
||||
|
||||
|
||||
49
pom.xml
49
pom.xml
@@ -5,8 +5,8 @@
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.honey</groupId>
|
||||
<artifactId>honey-be</artifactId>
|
||||
<groupId>com.lottery</groupId>
|
||||
<artifactId>lottery-be</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
@@ -79,6 +79,51 @@
|
||||
<version>4.2.0</version>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
216
scripts/backup-database.sh
Normal file
216
scripts/backup-database.sh
Normal 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}"
|
||||
|
||||
30
scripts/create-secret-file-from-template.sh
Normal file
30
scripts/create-secret-file-from-template.sh
Normal 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"
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Create secret file from environment variables for testing ConfigLoader
|
||||
# This simulates the mounted secret file approach used in Inferno
|
||||
|
||||
SECRET_FILE="/run/secrets/honey-config.properties"
|
||||
SECRET_FILE="/run/secrets/lottery-config.properties"
|
||||
SECRET_DIR="/run/secrets"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
@@ -25,3 +25,4 @@ chmod 644 "$SECRET_FILE"
|
||||
|
||||
echo "✅ Secret file created at $SECRET_FILE from environment variables"
|
||||
|
||||
|
||||
|
||||
227
scripts/diagnose-backup-permissions.sh
Normal file
227
scripts/diagnose-backup-permissions.sh
Normal 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 "=========================================="
|
||||
|
||||
38
scripts/load-db-password.sh
Normal file
38
scripts/load-db-password.sh
Normal 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
183
scripts/restore-database.sh
Normal 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
628
scripts/rolling-update.sh
Normal 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
119
scripts/setup-logging.sh
Normal 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)."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
package com.honey.honey;
|
||||
package com.lottery.lottery;
|
||||
|
||||
import com.honey.honey.config.ConfigLoader;
|
||||
import com.honey.honey.config.TelegramProperties;
|
||||
import com.lottery.lottery.config.ConfigLoader;
|
||||
import com.lottery.lottery.config.TelegramProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableAsync
|
||||
@EnableConfigurationProperties({TelegramProperties.class})
|
||||
public class HoneyBackendApplication {
|
||||
public class LotteryBackendApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication app = new SpringApplication(HoneyBackendApplication.class);
|
||||
SpringApplication app = new SpringApplication(LotteryBackendApplication.class);
|
||||
app.addListeners(new ConfigLoader());
|
||||
app.run(args);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.honey.honey.config;
|
||||
package com.lottery.lottery.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
|
||||
@@ -19,13 +19,13 @@ import java.util.Properties;
|
||||
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno)
|
||||
* 1. Mounted file at /run/secrets/lottery-config.properties (Inferno)
|
||||
* 2. Environment variables (Railway)
|
||||
*/
|
||||
@Slf4j
|
||||
public class ConfigLoader implements ApplicationListener<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
|
||||
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.honey.honey.config;
|
||||
package com.lottery.lottery.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
61
src/main/java/com/lottery/lottery/config/LocaleConfig.java
Normal file
61
src/main/java/com/lottery/lottery/config/LocaleConfig.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
44
src/main/java/com/lottery/lottery/config/WebConfig.java
Normal file
44
src/main/java/com/lottery/lottery/config/WebConfig.java
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package com.honey.honey.controller;
|
||||
package com.lottery.lottery.controller;
|
||||
|
||||
import com.honey.honey.dto.CreateSessionRequest;
|
||||
import com.honey.honey.dto.CreateSessionResponse;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.service.SessionService;
|
||||
import com.honey.honey.service.TelegramAuthService;
|
||||
import com.honey.honey.service.UserService;
|
||||
import com.lottery.lottery.dto.CreateSessionRequest;
|
||||
import com.lottery.lottery.dto.CreateSessionResponse;
|
||||
import com.lottery.lottery.exception.BannedUserException;
|
||||
import com.lottery.lottery.model.UserA;
|
||||
import com.lottery.lottery.service.LocalizationService;
|
||||
import com.lottery.lottery.service.SessionService;
|
||||
import com.lottery.lottery.service.TelegramAuthService;
|
||||
import com.lottery.lottery.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@@ -23,6 +27,7 @@ public class AuthController {
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final SessionService sessionService;
|
||||
private final UserService userService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
/**
|
||||
* Creates a session by validating Telegram initData.
|
||||
@@ -36,19 +41,25 @@ public class AuthController {
|
||||
String initData = request.getInitData();
|
||||
|
||||
if (initData == null || initData.isBlank()) {
|
||||
throw new IllegalArgumentException("initData is required");
|
||||
throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired"));
|
||||
}
|
||||
|
||||
// Validate Telegram initData signature and parse data
|
||||
Map<String, Object> tgUserData = telegramAuthService.validateAndParseInitData(initData);
|
||||
|
||||
// Get or create user (handles registration, login update, and referral system)
|
||||
// Note: Referral handling is done via bot registration endpoint, not through WebApp initData
|
||||
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
|
||||
|
||||
if (user.getBanned() != null && user.getBanned() == 1) {
|
||||
String message = localizationService.getMessageForUser(user.getId(), "auth.error.accessRestricted");
|
||||
throw new BannedUserException(message);
|
||||
}
|
||||
|
||||
// Create session
|
||||
String sessionId = sessionService.createSession(user);
|
||||
|
||||
log.info("Session created for userId={}, telegramId={}", user.getId(), user.getTelegramId());
|
||||
log.debug("Session created: userId={}", user.getId());
|
||||
|
||||
return CreateSessionResponse.builder()
|
||||
.access_token(sessionId)
|
||||
@@ -71,7 +82,7 @@ public class AuthController {
|
||||
String sessionId = extractBearerToken(authHeader);
|
||||
if (sessionId != null) {
|
||||
sessionService.invalidateSession(sessionId);
|
||||
log.info("Session invalidated via logout");
|
||||
log.debug("Session invalidated via logout");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.honey.honey.controller;
|
||||
package com.lottery.lottery.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -17,3 +17,4 @@ public class PingController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
100
src/main/java/com/lottery/lottery/controller/TaskController.java
Normal file
100
src/main/java/com/lottery/lottery/controller/TaskController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
src/main/java/com/lottery/lottery/controller/UserController.java
Normal file
146
src/main/java/com/lottery/lottery/controller/UserController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java
Normal file
34
src/main/java/com/lottery/lottery/dto/AdminBotConfigDto.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
28
src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java
Normal file
28
src/main/java/com/lottery/lottery/dto/AdminGameRoundDto.java
Normal 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;
|
||||
}
|
||||
|
||||
10
src/main/java/com/lottery/lottery/dto/AdminLoginRequest.java
Normal file
10
src/main/java/com/lottery/lottery/dto/AdminLoginRequest.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.lottery.lottery.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminLoginRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
31
src/main/java/com/lottery/lottery/dto/AdminMasterDto.java
Normal file
31
src/main/java/com/lottery/lottery/dto/AdminMasterDto.java
Normal 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;
|
||||
}
|
||||
27
src/main/java/com/lottery/lottery/dto/AdminPaymentDto.java
Normal file
27
src/main/java/com/lottery/lottery/dto/AdminPaymentDto.java
Normal 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;
|
||||
}
|
||||
|
||||
28
src/main/java/com/lottery/lottery/dto/AdminPayoutDto.java
Normal file
28
src/main/java/com/lottery/lottery/dto/AdminPayoutDto.java
Normal 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;
|
||||
}
|
||||
|
||||
23
src/main/java/com/lottery/lottery/dto/AdminPromotionDto.java
Normal file
23
src/main/java/com/lottery/lottery/dto/AdminPromotionDto.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user