Initial setup, cleanup, VPS setup
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s
This commit is contained in:
58
.gitea/workflows/deploy-vps.yaml
Normal file
58
.gitea/workflows/deploy-vps.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# Deploy honey-be to VPS on push to main.
|
||||
# Required secret: DEPLOY_SSH_PRIVATE_KEY.
|
||||
# Optional: DEPLOY_VPS_HOST (default 188.116.23.7), DEPLOY_VPS_USER (default root).
|
||||
name: Deploy to VPS
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.DEPLOY_VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.DEPLOY_VPS_USER }}
|
||||
steps:
|
||||
# Manual checkout: job container cannot resolve 'server', so clone via host IP (gateway).
|
||||
# Set secret GITEA_HOST_IP to your runner host's gateway (from job: ip route show default | awk '{print $3}').
|
||||
- name: Checkout
|
||||
run: |
|
||||
GITEA_HOST="${GITEA_HOST_IP:-172.20.0.1}"
|
||||
git clone --depth 1 "http://oauth2:${GITHUB_TOKEN}@${GITEA_HOST}:3000/admin/honey-be.git" .
|
||||
git fetch --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -q "${{ github.sha }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITEA_HOST_IP: ${{ secrets.GITEA_HOST_IP }}
|
||||
|
||||
- name: Install SSH and Rsync
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq openssh-client rsync
|
||||
|
||||
- name: Setup SSH
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.DEPLOY_VPS_HOST }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
eval "$(ssh-agent -s)"
|
||||
ssh-add ~/.ssh/deploy_key
|
||||
HOST="${SSH_HOST:-188.116.23.7}"
|
||||
ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Sync code to VPS
|
||||
run: |
|
||||
HOST="${VPS_HOST:-188.116.23.7}"
|
||||
USER="${VPS_USER:-root}"
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \
|
||||
--exclude '.git' \
|
||||
--exclude 'target' \
|
||||
./ "$USER@$HOST:/opt/app/backend/honey-be/"
|
||||
|
||||
- name: Run rolling update on VPS
|
||||
run: |
|
||||
HOST="${VPS_HOST:-188.116.23.7}"
|
||||
USER="${VPS_USER:-root}"
|
||||
ssh -i ~/.ssh/deploy_key "$USER@$HOST" "cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.staged.sh && sudo ./scripts/rolling-update.staged.sh"
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Environment files ###
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
### Logs ###
|
||||
*.log
|
||||
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_old.md
Normal file
1024
APPLICATION_OVERVIEW_old.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 honey-config.properties.template
|
||||
|
||||
# If it exists, copy it
|
||||
sudo cp honey-config.properties.template /run/secrets/lottery-config.properties
|
||||
```
|
||||
|
||||
**Option 2: Create the file directly (if template wasn't copied)**
|
||||
|
||||
If the template file doesn't exist in `/opt/app/backend/`, create the secret file directly:
|
||||
|
||||
```bash
|
||||
# Create the secrets directory (if it doesn't exist)
|
||||
sudo mkdir -p /run/secrets
|
||||
|
||||
# Create the secret file
|
||||
sudo nano /run/secrets/lottery-config.properties
|
||||
```
|
||||
|
||||
Then paste the following content (replace placeholder values):
|
||||
|
||||
```properties
|
||||
# Lottery Application Configuration
|
||||
# Replace all placeholder values with your actual configuration
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
# SPRING_DATASOURCE_URL format: jdbc:mysql://<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! 🚀
|
||||
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# ====== Build stage ======
|
||||
FROM maven:3.9.9-eclipse-temurin-17 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pom.xml .
|
||||
RUN mvn -q -e -B dependency:go-offline
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mvn -q -e -B clean package -DskipTests
|
||||
|
||||
# ====== Runtime stage ======
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy fat jar from build stage
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
# Copy startup script (optional - only used if secret file doesn't exist)
|
||||
COPY scripts/create-secret-file.sh /app/create-secret-file.sh
|
||||
RUN chmod +x /app/create-secret-file.sh
|
||||
|
||||
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
|
||||
COPY src/main/resources/logback-spring.xml /app/config/logback-spring.xml
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /app/logs && chmod 755 /app/logs
|
||||
|
||||
# Expose port (for local/docker-compose/documentation)
|
||||
EXPOSE 8080
|
||||
|
||||
# Default environment variables (can be overridden in docker-compose)
|
||||
ENV JAVA_OPTS=""
|
||||
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
|
||||
ENV LOG_DIR="/app/logs"
|
||||
|
||||
# Start app
|
||||
# If /run/secrets/lottery-config.properties exists (mounted), ConfigLoader will use it
|
||||
# Otherwise, create-secret-file.sh will create it from env vars (for development/testing)
|
||||
# Uses external logback-spring.xml for runtime log level changes
|
||||
# Ensure logback-spring.xml exists and is a file (not a directory)
|
||||
ENTRYPOINT ["sh", "-c", "if [ ! -f /run/secrets/lottery-config.properties ]; then /app/create-secret-file.sh; fi && if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]
|
||||
|
||||
39
Dockerfile.inferno
Normal file
39
Dockerfile.inferno
Normal file
@@ -0,0 +1,39 @@
|
||||
# ====== Build stage ======
|
||||
FROM maven:3.9.9-eclipse-temurin-17 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pom.xml .
|
||||
RUN mvn -q -e -B dependency:go-offline
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mvn -q -e -B clean package -DskipTests
|
||||
|
||||
# ====== Runtime stage ======
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy fat jar from build stage
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
|
||||
COPY --from=build /app/src/main/resources/logback-spring.xml /app/config/logback-spring.xml
|
||||
|
||||
# Create log directory
|
||||
RUN mkdir -p /app/logs && chmod 755 /app/logs
|
||||
|
||||
# Expose port (for internal communication with nginx)
|
||||
EXPOSE 8080
|
||||
|
||||
# Default environment variables (can be overridden in docker-compose)
|
||||
ENV JAVA_OPTS=""
|
||||
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
|
||||
ENV LOG_DIR="/app/logs"
|
||||
|
||||
# Start app with external logback config
|
||||
# Ensure logback-spring.xml exists and is a file (not a directory)
|
||||
ENTRYPOINT ["sh", "-c", "if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]
|
||||
|
||||
|
||||
88
EXTERNAL_API_old.md
Normal file
88
EXTERNAL_API_old.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`).
|
||||
|
||||
543
README.md
Normal file
543
README.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# Lottery Backend
|
||||
|
||||
Spring Boot backend application for Lottery project.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Java 17**
|
||||
- **Spring Boot 3.2.0**
|
||||
- **MySQL 8.0** (using INT/BIGINT only, no floating point numbers)
|
||||
- **Flyway** (database migrations)
|
||||
- **Docker** (containerization)
|
||||
- **Maven** (build tool)
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Java 17 JDK
|
||||
- Maven 3.9+
|
||||
- Docker and Docker Compose (for local MySQL)
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
2. **Start MySQL using Docker Compose**:
|
||||
```bash
|
||||
docker-compose up -d db
|
||||
```
|
||||
|
||||
3. **Create `.env` file** (for local development):
|
||||
```env
|
||||
DB_NAME=lottery_db
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=password
|
||||
DB_ROOT_PASSWORD=password
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
```
|
||||
|
||||
4. **Run the application**:
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
Or build and run with Docker:
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Flyway automatically runs migrations on startup. Migrations are located in `src/main/resources/db/migration/`.
|
||||
|
||||
## Deployment Guides
|
||||
|
||||
### Railway Deployment (Staging Environment)
|
||||
|
||||
Railway is the primary deployment platform for staging. It provides built-in logging and easy environment variable management.
|
||||
|
||||
#### Step 1: Create Railway Project
|
||||
|
||||
1. Go to [Railway](https://railway.app) and sign in
|
||||
2. Click **"New Project"**
|
||||
3. Select **"Empty Project"**
|
||||
|
||||
#### Step 2: Create MySQL Database Service
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"Database"** → **"Add MySQL"**
|
||||
2. Railway will automatically create a MySQL database
|
||||
3. Note the connection details (you'll need them for the backend service)
|
||||
|
||||
#### Step 3: Create Backend Service
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**)
|
||||
2. If using GitHub:
|
||||
- Connect your GitHub account
|
||||
- Select the `honey-be` repository
|
||||
- Railway will automatically detect it's a Java/Maven project
|
||||
3. If using Empty Service:
|
||||
- Click **"Empty Service"**
|
||||
- Connect to your repository or upload files
|
||||
|
||||
#### Step 4: Configure Environment Variables
|
||||
|
||||
In your backend service settings, go to **"Variables"** and add:
|
||||
|
||||
```env
|
||||
SPRING_DATASOURCE_URL=${MYSQL_URL}
|
||||
SPRING_DATASOURCE_USERNAME=${MYSQLUSER}
|
||||
SPRING_DATASOURCE_PASSWORD=${MYSQLPASSWORD}
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
FRONTEND_URL=https://your-frontend-url.railway.app
|
||||
PORT=8080
|
||||
```
|
||||
|
||||
**Note**: Railway automatically provides `MYSQL_URL`, `MYSQLUSER`, and `MYSQLPASSWORD` when you add a MySQL database. You can reference them using `${MYSQL_URL}` syntax.
|
||||
|
||||
#### Step 5: Link Database to Backend
|
||||
|
||||
1. In your backend service, go to **"Settings"** → **"Connect"**
|
||||
2. Find your MySQL database service
|
||||
3. Click **"Connect"** - Railway will automatically add the MySQL connection variables
|
||||
|
||||
#### Step 6: Configure Health Check
|
||||
|
||||
1. In your backend service, go to **"Settings"** → **"Healthcheck"**
|
||||
2. Set **Healthcheck Path** to:
|
||||
```
|
||||
/actuator/health/readiness
|
||||
```
|
||||
3. Railway will poll this endpoint and wait for HTTP 200 before marking deployment as active
|
||||
|
||||
#### Step 7: Deploy
|
||||
|
||||
1. Railway will automatically deploy when you push to the connected branch
|
||||
2. Or manually trigger deployment from the Railway dashboard
|
||||
3. Check the **"Deployments"** tab to monitor the deployment
|
||||
|
||||
#### Step 8: Get Backend URL
|
||||
|
||||
1. In your backend service, go to **"Settings"** → **"Networking"**
|
||||
2. Click **"Generate Domain"** to get a public URL
|
||||
3. Or use the default Railway domain
|
||||
4. Copy the URL (e.g., `https://lottery-be-production.up.railway.app`)
|
||||
|
||||
#### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway)
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"GitHub Repo"**
|
||||
2. Select your `lottery-fe` repository
|
||||
3. Railway will detect it's a Node.js project
|
||||
4. Add environment variable:
|
||||
```env
|
||||
VITE_API_BASE_URL=https://your-backend-url.railway.app
|
||||
```
|
||||
5. Railway will automatically build and deploy
|
||||
|
||||
#### Step 10: Create Volume (Optional - for persistent data)
|
||||
|
||||
If you need persistent storage:
|
||||
|
||||
1. In your Railway project, click **"+ New"** → **"Volume"**
|
||||
2. Name it (e.g., `lottery-data`)
|
||||
3. Mount it to your service if needed
|
||||
|
||||
### Inferno Deployment (Production Environment)
|
||||
|
||||
Inferno Solution provides the production environment. It requires manual server setup and uses Docker Compose with nginx.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Access to Inferno Solution server (via SSH)
|
||||
- Docker and Docker Compose installed on the server
|
||||
- JDK 17 installed on the server (for building, though Docker handles runtime)
|
||||
- Domain name configured (optional, for HTTPS)
|
||||
|
||||
#### Step 1: Prepare Server
|
||||
|
||||
1. **SSH into your Inferno server**:
|
||||
```bash
|
||||
ssh user@your-server-ip
|
||||
```
|
||||
|
||||
2. **Install Docker** (if not installed):
|
||||
```bash
|
||||
# For Ubuntu/Debian
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
3. **Install Docker Compose** (if not installed):
|
||||
```bash
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
4. **Install JDK 17** (for building):
|
||||
```bash
|
||||
# For Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install openjdk-17-jdk -y
|
||||
```
|
||||
|
||||
5. **Create project directory**:
|
||||
```bash
|
||||
mkdir -p /opt/lottery
|
||||
cd /opt/lottery
|
||||
```
|
||||
|
||||
#### Step 2: Clone Repository
|
||||
|
||||
```bash
|
||||
cd /opt/lottery
|
||||
git clone https://github.com/your-username/lottery-be.git
|
||||
cd lottery-be
|
||||
```
|
||||
|
||||
#### Step 3: Create Secret Configuration File
|
||||
|
||||
Create a tmpfs mount for secrets (more secure than .env files):
|
||||
|
||||
```bash
|
||||
# Create tmpfs mount point
|
||||
sudo mkdir -p /run/secrets
|
||||
sudo chmod 700 /run/secrets
|
||||
|
||||
# Create secret file
|
||||
sudo nano /run/secrets/lottery-config.properties
|
||||
```
|
||||
|
||||
Add the following content (replace with your actual values):
|
||||
|
||||
```properties
|
||||
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
|
||||
SPRING_DATASOURCE_USERNAME=lottery_user
|
||||
SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
FRONTEND_URL=https://your-frontend-domain.com
|
||||
MYSQL_PASSWORD=your_secure_mysql_password
|
||||
MYSQL_ROOT_PASSWORD=your_secure_mysql_root_password
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- Use strong, unique passwords
|
||||
- Keep this file secure (it's in tmpfs, so it's in-memory only)
|
||||
- The file will be lost on reboot - you'll need to recreate it or use a startup script
|
||||
|
||||
#### Step 4: Configure Docker Compose for Inferno
|
||||
|
||||
The `docker-compose.inferno.yml` file is already configured. Make sure it's present in your repository.
|
||||
|
||||
#### Step 5: Build and Start Services
|
||||
|
||||
```bash
|
||||
cd /opt/lottery/lottery-be
|
||||
|
||||
# Build and start all services
|
||||
docker-compose -f docker-compose.inferno.yml up -d --build
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the backend application
|
||||
- Start MySQL database
|
||||
- Start backend service
|
||||
- Start nginx reverse proxy
|
||||
|
||||
#### Step 6: Configure Nginx (if using custom domain)
|
||||
|
||||
1. **Edit nginx configuration**:
|
||||
```bash
|
||||
nano nginx/conf.d/lottery.conf
|
||||
```
|
||||
|
||||
2. **Update server_name** (if using HTTPS):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
# ... rest of config
|
||||
}
|
||||
```
|
||||
|
||||
3. **Reload nginx**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.inferno.yml restart nginx
|
||||
```
|
||||
|
||||
#### Step 7: Set Up SSL (Optional - Recommended for Production)
|
||||
|
||||
1. **Install Certbot** (Let's Encrypt):
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx -y
|
||||
```
|
||||
|
||||
2. **Obtain SSL certificate**:
|
||||
```bash
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`)
|
||||
|
||||
4. **Reload nginx**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.inferno.yml restart nginx
|
||||
```
|
||||
|
||||
#### Step 8: Configure Firewall
|
||||
|
||||
```bash
|
||||
# Allow HTTP and HTTPS
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Allow SSH (if not already allowed)
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# Enable firewall
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
#### Step 9: Set Up Auto-Start on Boot
|
||||
|
||||
Create a systemd service to ensure services start on boot:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/lottery.service
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Lottery Application
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/lottery/lottery-be
|
||||
ExecStart=/usr/local/bin/docker-compose -f docker-compose.inferno.yml up -d
|
||||
ExecStop=/usr/local/bin/docker-compose -f docker-compose.inferno.yml down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable lottery.service
|
||||
sudo systemctl start lottery.service
|
||||
```
|
||||
|
||||
#### Step 10: Set Up Grafana Integration (Production Logging)
|
||||
|
||||
1. **Install Grafana and Loki** (on a separate server or same server):
|
||||
```bash
|
||||
# Follow Grafana/Loki installation guide
|
||||
# https://grafana.com/docs/loki/latest/installation/
|
||||
```
|
||||
|
||||
2. **Configure Promtail** to collect logs from Docker containers:
|
||||
```yaml
|
||||
# promtail-config.yml
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
- job_name: lottery-backend
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
regex: lottery-backend
|
||||
action: keep
|
||||
```
|
||||
|
||||
3. **Update docker-compose.inferno.yml** to add logging driver:
|
||||
```yaml
|
||||
app:
|
||||
# ... existing config
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
4. **Configure Grafana datasource** to connect to Loki
|
||||
|
||||
#### Step 11: Monitor and Maintain
|
||||
|
||||
**Check service status**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.inferno.yml ps
|
||||
```
|
||||
|
||||
**View logs**:
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.inferno.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.inferno.yml logs -f app
|
||||
```
|
||||
|
||||
**Update application**:
|
||||
```bash
|
||||
cd /opt/lottery/lottery-be
|
||||
git pull
|
||||
docker-compose -f docker-compose.inferno.yml up -d --build
|
||||
```
|
||||
|
||||
**Backup database**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.inferno.yml exec db mysqldump -u lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The application supports two configuration strategies:
|
||||
|
||||
1. **Environment Variables** (Railway): Set variables in Railway dashboard
|
||||
2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties`
|
||||
|
||||
Priority: Secret file → Environment variables
|
||||
|
||||
### Required Variables
|
||||
|
||||
- `SPRING_DATASOURCE_URL` - MySQL connection URL
|
||||
- `SPRING_DATASOURCE_USERNAME` - MySQL username
|
||||
- `SPRING_DATASOURCE_PASSWORD` - MySQL password
|
||||
- `TELEGRAM_BOT_TOKEN` - Telegram bot token for authentication
|
||||
- `FRONTEND_URL` - Frontend URL for CORS configuration
|
||||
|
||||
### Database Schema
|
||||
|
||||
The database uses **INT** and **BIGINT** only - no floating point numbers.
|
||||
|
||||
Current tables:
|
||||
- `users` - User information (id, telegram_id, username, created_at)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
- `GET /ping` - Health check (no auth required)
|
||||
- `GET /actuator/health` - Application health
|
||||
- `GET /actuator/health/readiness` - Readiness probe (checks database)
|
||||
- `GET /actuator/health/liveness` - Liveness probe
|
||||
|
||||
### Protected Endpoints (require Telegram auth)
|
||||
|
||||
- `GET /api/users/current` - Get current user information
|
||||
|
||||
## Authorization
|
||||
|
||||
The application uses Telegram Mini App authentication:
|
||||
|
||||
1. Frontend sends `Authorization: tma <initData>` header
|
||||
2. Backend validates Telegram signature
|
||||
3. Backend creates/updates user in database
|
||||
4. User is stored in thread-local context for the request
|
||||
|
||||
## Health Checks
|
||||
|
||||
- **Readiness**: `/actuator/health/readiness` - Checks database connectivity
|
||||
- **Liveness**: `/actuator/health/liveness` - Checks if application is running
|
||||
|
||||
Configure Railway to use `/actuator/health/readiness` as the health check path.
|
||||
|
||||
## Logging
|
||||
|
||||
- **Railway**: Built-in logging available in Railway dashboard
|
||||
- **Inferno**: Configure Grafana/Loki for log aggregation (see Grafana setup above)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
- Verify MySQL is running: `docker-compose ps`
|
||||
- Check connection credentials in environment variables
|
||||
- Review application logs for connection errors
|
||||
|
||||
### Authorization Failures
|
||||
|
||||
- Verify `TELEGRAM_BOT_TOKEN` is correct
|
||||
- Check that frontend is sending `Authorization: tma <initData>` header
|
||||
- Review backend logs for validation errors
|
||||
|
||||
### Deployment Issues
|
||||
|
||||
- Check health check endpoint: `curl http://your-url/actuator/health/readiness`
|
||||
- Review Railway deployment logs
|
||||
- Verify all environment variables are set correctly
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
```
|
||||
|
||||
### Building JAR
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
### Running Locally with Docker
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lottery-be/
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── java/com/lottery/lottery/
|
||||
│ │ │ ├── config/ # Configuration classes
|
||||
│ │ │ ├── controller/ # REST controllers
|
||||
│ │ │ ├── dto/ # Data transfer objects
|
||||
│ │ │ ├── exception/ # Exception handlers
|
||||
│ │ │ ├── health/ # Health indicators
|
||||
│ │ │ ├── logging/ # Grafana logging config
|
||||
│ │ │ ├── model/ # JPA entities
|
||||
│ │ │ ├── repository/ # JPA repositories
|
||||
│ │ │ ├── security/ # Auth interceptor, UserContext
|
||||
│ │ │ └── service/ # Business logic
|
||||
│ │ └── resources/
|
||||
│ │ ├── db/migration/ # Flyway migrations
|
||||
│ │ └── application.yml # Application config
|
||||
├── nginx/ # Nginx config (for Inferno)
|
||||
├── Dockerfile # Dockerfile for Railway
|
||||
├── Dockerfile.inferno # Dockerfile for Inferno
|
||||
├── docker-compose.yml # Docker Compose for local/Railway
|
||||
├── docker-compose.inferno.yml # Docker Compose for Inferno
|
||||
└── pom.xml # Maven configuration
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
|
||||
|
||||
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
|
||||
|
||||
457
VPS_SETUP_FROM_SCRATCH.md
Normal file
457
VPS_SETUP_FROM_SCRATCH.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# Honey VPS Setup from Scratch (Inferno)
|
||||
|
||||
This guide walks through setting up a new VPS for the **Honey** app with the same layout as your existing lottery VPS: backend + MySQL + phpMyAdmin in Docker, frontend and admin panel served by Nginx, logging under `/opt/app/logs`, secrets in `/run/secrets`, and MySQL backups to a backup VPS.
|
||||
|
||||
**Target layout (mirrors your lottery setup):**
|
||||
|
||||
- **Containers:** backend (honey-be), MySQL (honey_db), phpMyAdmin
|
||||
- **Served by Nginx:** frontend (honey-fe), admin panel (honey-admin)
|
||||
- **Paths:** `/opt/app` for app files, `/run/secrets` for config, `/opt/app/logs` for logs
|
||||
- **Nginx:** main config + site config (e.g. `nginx.conf` + `sites-enabled/your-domain`)
|
||||
|
||||
---
|
||||
|
||||
## 1. VPS basics
|
||||
|
||||
### 1.1 System update and installs
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Docker Compose (plugin)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-compose-plugin
|
||||
|
||||
# Nginx + Certbot
|
||||
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
Log out and back in so `docker` group applies.
|
||||
|
||||
### 1.2 Directory structure under `/opt/app`
|
||||
|
||||
Create the same layout as lottery (backend, frontend, admin, nginx, data, logs, backups, mysql):
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/app/backend
|
||||
sudo mkdir -p /opt/app/frontend
|
||||
sudo mkdir -p /opt/app/admin
|
||||
sudo mkdir -p /opt/app/admin-panel
|
||||
sudo mkdir -p /opt/app/nginx
|
||||
sudo mkdir -p /opt/app/data/avatars
|
||||
sudo mkdir -p /opt/app/logs
|
||||
sudo mkdir -p /opt/app/backups
|
||||
sudo mkdir -p /opt/app/mysql/data
|
||||
sudo mkdir -p /opt/app/mysql/conf
|
||||
|
||||
sudo chown -R $USER:$USER /opt/app
|
||||
sudo chmod -R 755 /opt/app
|
||||
```
|
||||
|
||||
### 1.3 Git
|
||||
|
||||
```bash
|
||||
sudo apt install git -y
|
||||
git config --global alias.st status
|
||||
ssh-keygen -t ed25519 -C "your_email@example.com" (replace email)
|
||||
cat ~/.ssh/id_ed25519.pub (copy the key and add to origin)
|
||||
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up (and login in local browser to tailscale)
|
||||
```
|
||||
|
||||
Clone all repositories to respective folders.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend (honey-be) on VPS
|
||||
|
||||
### 2.1 Secret file (honey-config.properties)
|
||||
|
||||
Backend reads **`/run/secrets/honey-config.properties`** (see `ConfigLoader` and `docker-compose.prod.yml`). Create it from the template; **do not commit real values**.
|
||||
|
||||
On the VPS:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /run/secrets
|
||||
sudo cp /opt/app/backend/honey-be/honey-config.properties.template /run/secrets/honey-config.properties
|
||||
sudo chmod 640 /run/secrets/honey-config.properties
|
||||
sudo chown root:docker /run/secrets/honey-config.properties # if your user is in docker group, or root:$USER
|
||||
```
|
||||
|
||||
Edit and set real values:
|
||||
|
||||
```bash
|
||||
sudo nano /run/secrets/honey-config.properties
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `SPRING_DATASOURCE_URL` - set to new DB URL
|
||||
- `SPRING_DATASOURCE_PASSWORD` - just generate new secret
|
||||
- `TELEGRAM_BOT_TOKEN` - token for Telegram bot
|
||||
- `FRONTEND_URL` - put new domain here
|
||||
- `APP_ADMIN_JWT_SECRET` - generate new secret using `openssl rand -base64 48` on VPS and put here
|
||||
- `APP_TELEGRAM_WEBHOOK_TOKEN` - generate a new secret and set it using `POST https://api.telegram.org/bot<token>/setWebhook?url=https://<domain>/api/telegram/webhook/<secret>&max_connections=100`
|
||||
- `PMA_ABSOLUTE_URI` - generate a new secret and set it. Don't forget to set the same to nginx
|
||||
|
||||
Create 2 files `admin_api_url` and `admin_base_path` with URL and secret path in `/run/secrets` folder.
|
||||
|
||||
### 2.3 Load DB password for Docker Compose
|
||||
|
||||
`docker-compose.prod.yml` expects `DB_ROOT_PASSWORD` (and MySQL healthcheck uses it). The repo has `scripts/load-db-password.sh` which reads the secret file; it’s currently wired to **lottery** path. For Honey, either:
|
||||
|
||||
- Edit `scripts/load-db-password.sh` and set:
|
||||
- `SECRET_FILE="/run/secrets/honey-config.properties"`
|
||||
- Or create a small wrapper that exports the same variables from `honey-config.properties`.
|
||||
|
||||
**When you need to source it:** Only for one-off manual `docker compose` runs (e.g. first-time start in §2.6, or starting phpMyAdmin in §4.1). You do **not** need to source it for deployment: `scripts/rolling-update.sh` loads the password from the secret file automatically when `DB_ROOT_PASSWORD` is not set.
|
||||
|
||||
**IMPORTANT**
|
||||
- `Change Java memory in docker-compose.prod.yml when you know the PROD VPS characteristics.`
|
||||
- `Change the rolling-update.sh script to match sites-enabled nginx file name for PROD when you know the domain`
|
||||
### 2.4 Logging (logback) and config dir
|
||||
|
||||
Backend uses an external **logback** config so you can change log level without rebuilding. Create the config dir and put `logback-spring.xml` there:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/app/backend/config
|
||||
mkdir -p /opt/app/logs
|
||||
```
|
||||
|
||||
Either copy from the JAR or from source:
|
||||
|
||||
```bash
|
||||
# From backend dir
|
||||
cp src/main/resources/logback-spring.xml /opt/app/backend/config/
|
||||
# or extract from built JAR:
|
||||
# unzip -p target/honey-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml
|
||||
```
|
||||
|
||||
Optional: run the existing setup script (it may still reference lottery paths; adjust or run the copy above):
|
||||
|
||||
```bash
|
||||
cd /opt/app/backend/honey-be
|
||||
./scripts/setup-logging.sh
|
||||
```
|
||||
|
||||
Edit log level at any time:
|
||||
|
||||
```bash
|
||||
nano /opt/app/backend/config/logback-spring.xml
|
||||
# e.g. change <logger name="com.honey" level="INFO"/> to DEBUG
|
||||
# Logback rescans periodically (e.g. 30s); no restart needed if scan is enabled
|
||||
```
|
||||
|
||||
### 2.5 MySQL my.cnf (optional)
|
||||
|
||||
If you use a custom MySQL config in prod (e.g. for buffer pool):
|
||||
|
||||
```bash
|
||||
# Create /opt/app/mysql/conf/my.cnf with your tuning; then in docker-compose.prod.yml
|
||||
# the volume is already: /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
|
||||
```
|
||||
|
||||
Note: `on Staged VPS it has 4G RAM, so don't forget to change it for PROD accordingly.`
|
||||
|
||||
### 2.6 First start (backend + DB only)
|
||||
|
||||
```bash
|
||||
cd /opt/app/backend/honey-be
|
||||
source scripts/load-db-password.sh
|
||||
docker compose -f docker-compose.prod.yml up -d db
|
||||
# wait for DB healthy
|
||||
docker compose -f docker-compose.prod.yml up -d backend
|
||||
```
|
||||
|
||||
Note: `for Staged use a separate docker compose.`
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
curl -s http://127.0.0.1:8080/actuator/health/readiness
|
||||
```
|
||||
|
||||
Backend should listen only on `127.0.0.1:8080` (Nginx will proxy to it). Do **not** expose 8080 to the internet.
|
||||
|
||||
---
|
||||
|
||||
## 3. Nginx
|
||||
|
||||
### 3.1 Split config (like your lottery VPS)
|
||||
|
||||
- `Take 2 files from already working VPS: nginx.conf and sites-enabled/<domain.com> and put to new VPS.`
|
||||
- `Remove or comment lines reg certificates and change 2 listen lines`:
|
||||
|
||||
```bash
|
||||
# SSL Certificates
|
||||
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem;
|
||||
Change 'listen 443 ssl http2;' to 'listen 443;`
|
||||
Change `listen [::]:443 ssl http2;` to `listen [::]:443;`
|
||||
```
|
||||
Enable and test:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/testforapp.website /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 3.3 SSL (Let’s Encrypt)
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d testforapp.website
|
||||
```
|
||||
|
||||
Certbot will adjust the server block for certificates. Reload Nginx if needed.
|
||||
And remove redundant ssl/listen lines from server block to not override certbot's configs.
|
||||
|
||||
---
|
||||
|
||||
## 4. phpMyAdmin
|
||||
|
||||
### 4.1 Start phpMyAdmin container
|
||||
|
||||
`docker-compose.prod.yml` already defines a **phpmyadmin** service (port 8081, same network as `db`). Start it:
|
||||
|
||||
```bash
|
||||
cd /opt/app/backend/honey-be
|
||||
source scripts/load-db-password.sh
|
||||
docker compose -f docker-compose.prod.yml up -d phpmyadmin
|
||||
```
|
||||
|
||||
### 4.2 Access via Nginx (recommended)
|
||||
|
||||
Do **not** expose 8081 publicly. Proxy it via Nginx under a secret path (e.g. `/your-secret-pma-path/`), as in the example above. Set `PMA_ABSOLUTE_URI` in the secret file so phpMyAdmin generates correct URLs:
|
||||
|
||||
In `/run/secrets/honey-config.properties` add (or use env when running compose):
|
||||
|
||||
```properties
|
||||
PMA_ABSOLUTE_URI=https://your-domain.com/your-secret-pma-path/
|
||||
```
|
||||
|
||||
Then reload Nginx and open `https://your-domain.com/your-secret-pma-path/`. Login: user `root`, password = `SPRING_DATASOURCE_PASSWORD` from the same secret file.
|
||||
|
||||
### 4.3 Optional: UFW for phpMyAdmin
|
||||
|
||||
If you ever expose 8081 temporarily, restrict it:
|
||||
|
||||
```bash
|
||||
sudo ufw allow from YOUR_IP to any port 8081
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
Prefer keeping 8081 bound to 127.0.0.1 and using only Nginx proxy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend (honey-fe)
|
||||
|
||||
### 5.1 Build locally and upload
|
||||
|
||||
On your machine (e.g. in `honey-test-fe` or your honey-fe repo):
|
||||
|
||||
```bash
|
||||
cd honey-test-fe # or honey-fe
|
||||
npm install
|
||||
npm run build
|
||||
scp -r dist/* root@YOUR_VPS_IP:/opt/app/frontend/dist/
|
||||
```
|
||||
|
||||
Or with rsync:
|
||||
|
||||
```bash
|
||||
rsync -avz dist/ root@YOUR_VPS_IP:/opt/app/frontend/dist/
|
||||
```
|
||||
|
||||
Ensure the app’s API base URL is correct for production (e.g. relative `""` or `VITE_API_BASE_URL` for your domain).
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin panel (honey-admin)
|
||||
|
||||
### 6.1 Build with secret (on VPS or locally)
|
||||
|
||||
Admin often has a build that injects a public URL or env. From the repo:
|
||||
|
||||
```bash
|
||||
cd honey-admin
|
||||
npm install
|
||||
npm run build:with-secret
|
||||
```
|
||||
|
||||
Then copy the built output to the Nginx admin root:
|
||||
|
||||
**If you build on the VPS:**
|
||||
|
||||
```bash
|
||||
cd /opt/app/admin/honey-admin
|
||||
npm run build:with-secret
|
||||
cp -r dist/* /opt/app/admin-panel/
|
||||
```
|
||||
|
||||
**If you build locally:**
|
||||
|
||||
```bash
|
||||
scp -r dist/* root@YOUR_VPS_IP:/opt/app/admin-panel/
|
||||
```
|
||||
|
||||
The Nginx location for admin (e.g. `/your-secret-admin-path/`) must serve this directory and support SPA routing (`try_files` to `index.html`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rolling backend updates
|
||||
|
||||
Use the existing **rolling-update** script so Nginx switches to a new backend container with no downtime.
|
||||
|
||||
### 7.1 Script adaptation for Honey
|
||||
|
||||
The script in the repo may still reference **lottery** container names and Nginx paths. For Honey:
|
||||
|
||||
- **Containers:** `honey-backend` (primary), `honey-backend-new` (standby).
|
||||
- **Nginx:** same idea as lottery: one upstream `backend` with `server 127.0.0.1:8080` and optionally `server 127.0.0.1:8082 backup;`. The script flips which port is primary.
|
||||
|
||||
Edit `scripts/rolling-update.sh` and replace:
|
||||
|
||||
- `lottery-backend` → `honey-backend`
|
||||
- `lottery-backend-new` → `honey-backend-new`
|
||||
|
||||
The script auto-detects Nginx config from paths like `/etc/nginx/sites-enabled/win-spin.live`. For Honey, either:
|
||||
|
||||
- Symlink or name your site config so the script finds it (e.g. add a similar check for `honey.conf` in the script), or
|
||||
- Set the path explicitly before running: `export NGINX_CONF=/etc/nginx/sites-enabled/your-domain && sudo ./scripts/rolling-update.sh`
|
||||
|
||||
### 7.2 Run rolling update
|
||||
|
||||
From the backend directory, run (no need to source `load-db-password.sh` — the script does it):
|
||||
|
||||
```bash
|
||||
cd /opt/app/backend/honey-be
|
||||
chmod +x scripts/rolling-update.sh
|
||||
sudo ./scripts/rolling-update.sh
|
||||
```
|
||||
|
||||
The script loads `DB_ROOT_PASSWORD` from the secret file if not set, then: builds the new image, starts `backend-new` on 8082, health-checks it, points Nginx to 8082, reloads Nginx, then stops the old backend.
|
||||
|
||||
---
|
||||
|
||||
## 8. Logging
|
||||
|
||||
- **App logs:** `/opt/app/logs/` (mounted into backend container; path can be set via `LOG_DIR` / logback).
|
||||
- **Config:** `/opt/app/backend/config/logback-spring.xml` (edit to change level; no restart if scan is enabled).
|
||||
- **Nginx:** `/var/log/nginx/access.log`, `/var/log/nginx/error.log`.
|
||||
|
||||
View backend logs:
|
||||
|
||||
```bash
|
||||
docker logs -f honey-backend
|
||||
# or
|
||||
tail -f /opt/app/logs/honey-be.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. MySQL backups to backup VPS
|
||||
|
||||
### 9.1 Backup script for Honey
|
||||
|
||||
Copy and adapt the existing **`scripts/backup-database.sh`** (or create a Honey-specific one). Set:
|
||||
|
||||
- `MYSQL_CONTAINER="honey-mysql"`
|
||||
- `MYSQL_DATABASE="honey_db"`
|
||||
- `SECRET_FILE="/run/secrets/honey-config.properties"`
|
||||
- `BACKUP_FILENAME="honey_db_backup_${TIMESTAMP}.sql"` (and `.gz` if compressing)
|
||||
- Remote path and retention (e.g. `BACKUP_VPS_PATH`, keep last 30 days) to match your backup server.
|
||||
|
||||
Ensure the script runs as root (or with sudo) so it can read `/run/secrets/honey-config.properties`, and that it uses the same `DB_PASSWORD` / `SPRING_DATASOURCE_PASSWORD` as in the secret file.
|
||||
|
||||
### 9.2 SSH key to backup VPS
|
||||
|
||||
On the Honey VPS:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "backup@honey-vps" -f ~/.ssh/backup_key
|
||||
ssh-copy-id -i ~/.ssh/backup_key.pub user@BACKUP_VPS_IP
|
||||
```
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/backup_key user@BACKUP_VPS_IP "echo OK"
|
||||
```
|
||||
|
||||
### 9.3 Cron (daily at 2 AM)
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```cron
|
||||
0 2 * * * /opt/app/backend/honey-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1
|
||||
```
|
||||
|
||||
Use the Honey-adapted backup script path and ensure `backup-database.sh` uses `honey_db` and `honey-mysql`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
|
||||
| Item | Honey |
|
||||
|------|-----------------------------------------------------------------|
|
||||
| **App root** | `/opt/app` |
|
||||
| **Backend code** | `/opt/app/backend/honey-be` (honey-be) |
|
||||
| **Frontend static** | `/opt/app/frontend/dist` (honey-fe build) |
|
||||
| **Admin static** | `/opt/app/admin-panel` (honey-admin build) |
|
||||
| **Secret file** | `/run/secrets/honey-config.properties` |
|
||||
| **Logs** | `/opt/app/logs` (+ logback config in `/opt/app/backend/config`) |
|
||||
| **Avatars** | `/opt/app/data/avatars` |
|
||||
| **Nginx** | `/etc/nginx/nginx.conf` + `/etc/nginx/sites-enabled/your-domain` |
|
||||
| **DB container** | `honey-mysql` |
|
||||
| **DB name** | `honey_db` |
|
||||
| **Backend containers** | `honey-backend`, `honey-backend-new` (rolling) |
|
||||
| **phpMyAdmin** | Container `honey-phpmyadmin`, port 8081 → proxy via Nginx secret path |
|
||||
|
||||
### Deploy commands (summary)
|
||||
|
||||
- **Backend (rolling):**
|
||||
`cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.sh && sudo ./scripts/rolling-update.sh`
|
||||
(Password is loaded from the secret file inside the script.)
|
||||
|
||||
- **Frontend:**
|
||||
Local: `npm run build` then `scp -r dist/* root@VPS:/opt/app/frontend/dist/`
|
||||
|
||||
- **Admin:**
|
||||
On VPS: `cd /opt/app/admin/honey-admin && npm run build:with-secret && cp -r dist/* /opt/app/admin-panel/`
|
||||
Or build locally and `scp -r dist/* root@VPS:/opt/app/admin-panel/`
|
||||
|
||||
- **Log level:**
|
||||
Edit `/opt/app/backend/config/logback-spring.xml` (no restart if scan enabled).
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist after setup
|
||||
|
||||
- [ ] `/opt/app` structure created; ownership and permissions correct.
|
||||
- [ ] `/run/secrets/honey-config.properties` created and filled (no placeholders).
|
||||
- [ ] `load-db-password.sh` (and backup/rolling scripts) use Honey secret path and container/db names.
|
||||
- [ ] Backend + DB + phpMyAdmin start; health check returns 200.
|
||||
- [ ] Nginx site config in place; `nginx -t` OK; HTTPS works.
|
||||
- [ ] Frontend and admin builds deployed to `/opt/app/frontend/dist` and `/opt/app/admin-panel`.
|
||||
- [ ] API and WebSocket work through Nginx; avatars and admin paths load.
|
||||
- [ ] phpMyAdmin reachable only via Nginx secret path; 8081 not public.
|
||||
- [ ] Rolling update script updated for `honey-backend` / `honey-backend-new` and tested.
|
||||
- [ ] Backup script adapted for `honey_db` / `honey-mysql`; cron runs and backups appear on backup VPS.
|
||||
- [ ] Logs under `/opt/app/logs` and logback config under `/opt/app/backend/config`; log level change works.
|
||||
|
||||
This gives you the same layout and workflow as your lottery VPS, but for Honey (honey-be, honey-fe, honey-admin) with Nginx, phpMyAdmin, logging, and backups.
|
||||
77
docker-compose.inferno.yml
Normal file
77
docker-compose.inferno.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: honey-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: honey_db
|
||||
MYSQL_USER: honey_user
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- honey_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- honey-network
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.inferno
|
||||
container_name: honey-backend
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
|
||||
- SPRING_DATASOURCE_USERNAME=honey_user
|
||||
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- FRONTEND_URL=${FRONTEND_URL}
|
||||
# Logging configuration (external logback-spring.xml)
|
||||
- LOGGING_CONFIG=/app/config/logback-spring.xml
|
||||
- LOG_DIR=/app/logs
|
||||
volumes:
|
||||
# Mount secret file from tmpfs
|
||||
- /run/secrets:/run/secrets:ro
|
||||
# Mount logback config directory (editable on VPS without rebuilding)
|
||||
# Note: File must exist on host before mounting. Run setup-logging.sh first.
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
# Mount logs directory (persistent storage)
|
||||
- /opt/app/logs:/app/logs
|
||||
networks:
|
||||
- honey-network
|
||||
# Don't expose port directly - nginx will handle it
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: honey-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
# SSL certificates (if using HTTPS)
|
||||
# - ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- honey-network
|
||||
|
||||
volumes:
|
||||
honey_mysql_data:
|
||||
|
||||
networks:
|
||||
honey-network:
|
||||
driver: bridge
|
||||
|
||||
|
||||
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: honey-mysql
|
||||
restart: always
|
||||
# Database credentials are read from the secret file via backend container
|
||||
# The backend will construct the connection URL from SPRING_DATASOURCE_* properties
|
||||
# For MySQL container, we need to set these via environment or use a separate secret
|
||||
# Option 1: Use environment variables (for MySQL container only)
|
||||
# Note: MYSQL_USER cannot be "root" - root user is configured via MYSQL_ROOT_PASSWORD only
|
||||
environment:
|
||||
MYSQL_DATABASE: honey_db
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
# Option 2: Mount secret file and read values (more secure)
|
||||
# This requires parsing the secret file or using a script
|
||||
# For simplicity, we'll use environment variables for MySQL container
|
||||
# The secret file is primarily for the backend application
|
||||
# Do NOT expose MySQL port to host - only accessible within Docker network
|
||||
# ports:
|
||||
# - "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
# Mount MySQL performance configuration (created on VPS at /opt/app/mysql/conf/my.cnf)
|
||||
- /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
|
||||
# Resource limits for MySQL (16GB buffer pool + 2GB overhead)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 18G
|
||||
healthcheck:
|
||||
# Use shell to access environment variable (Docker Compose doesn't interpolate in healthcheck arrays)
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- honey-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honey-backend
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Expose backend port to localhost only (for Nginx on host to access)
|
||||
# This is safe - only accessible from the host, not from internet
|
||||
# Port 8080 is the primary/active backend
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
# Labels for rolling update management
|
||||
labels:
|
||||
- "deployment.role=primary"
|
||||
- "deployment.version=current"
|
||||
volumes:
|
||||
# Mount persistent avatar storage (absolute path for consistency)
|
||||
- /opt/app/data/avatars:/app/data/avatars
|
||||
# Mount secret configuration file (read-only)
|
||||
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
|
||||
# Mount logback config directory (editable on VPS without rebuilding)
|
||||
# Note: File must exist on host before mounting. Run setup-logging.sh first.
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
# Mount logs directory (persistent storage)
|
||||
- /opt/app/logs:/app/logs
|
||||
environment:
|
||||
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
|
||||
# -Xms: Start with 10GB (prevents resizing overhead)
|
||||
# -Xmx: Max limit 10GB
|
||||
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
|
||||
# -XX:MaxGCPauseMillis=200: Target max GC pause time
|
||||
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
# Logging configuration (external logback-spring.xml)
|
||||
LOGGING_CONFIG: /app/config/logback-spring.xml
|
||||
LOG_DIR: /app/logs
|
||||
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4.0'
|
||||
memory: 12G
|
||||
networks:
|
||||
- honey-network
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
backend-new:
|
||||
# This service is used during rolling updates
|
||||
# It will be started manually via deployment script
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honey-backend-new
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Port 8082 is the new/standby backend during deployment (8081 is used by phpMyAdmin)
|
||||
ports:
|
||||
- "127.0.0.1:8082:8080"
|
||||
profiles:
|
||||
- rolling-update
|
||||
# Labels for rolling update management
|
||||
labels:
|
||||
- "deployment.role=standby"
|
||||
- "deployment.version=new"
|
||||
volumes:
|
||||
# Mount persistent avatar storage (absolute path for consistency)
|
||||
- /opt/app/data/avatars:/app/data/avatars
|
||||
# Mount secret configuration file (read-only)
|
||||
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
|
||||
# Mount logback config directory (editable on VPS without rebuilding)
|
||||
# Note: File must exist on host before mounting. Run setup-logging.sh first.
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
# Mount logs directory (persistent storage)
|
||||
- /opt/app/logs:/app/logs
|
||||
environment:
|
||||
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
|
||||
# -Xms: Start with 10GB (prevents resizing overhead)
|
||||
# -Xmx: Max limit 10GB
|
||||
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
|
||||
# -XX:MaxGCPauseMillis=200: Target max GC pause time
|
||||
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
# Logging configuration (external logback-spring.xml)
|
||||
LOGGING_CONFIG: /app/config/logback-spring.xml
|
||||
LOG_DIR: /app/logs
|
||||
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4.0'
|
||||
memory: 12G
|
||||
networks:
|
||||
- honey-network
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:latest
|
||||
container_name: honey-phpmyadmin
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Expose phpMyAdmin to localhost only (Nginx will proxy it with path protection)
|
||||
ports:
|
||||
- "127.0.0.1:8081:80"
|
||||
environment:
|
||||
# Connect to MySQL service using Docker service name
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
# Use the same root password as MySQL container
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
# Security: Set upload limit
|
||||
UPLOAD_LIMIT: 64M
|
||||
# Configure absolute URI so phpMyAdmin generates correct URLs for assets
|
||||
# This variable must be set from a secret file on the VPS (not in git)
|
||||
# Example: export PMA_ABSOLUTE_URI="https://win-spin.live/your-secret-path"
|
||||
PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
|
||||
# Tell phpMyAdmin it's behind a proxy using HTTPS
|
||||
PMA_SSL: "true"
|
||||
# Trust proxy headers (X-Forwarded-Proto, etc.)
|
||||
PMA_TRUSTED_PROXIES: "127.0.0.1"
|
||||
networks:
|
||||
- honey-network
|
||||
# Resource limits for phpMyAdmin
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
honey-network:
|
||||
driver: bridge
|
||||
|
||||
138
docker-compose.staged.yml
Normal file
138
docker-compose.staged.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
# Staged environment: same as prod but tuned for 8GB VPS (lower heap and container limits).
|
||||
# Use with: docker compose -f docker-compose.staged.yml up -d
|
||||
# Rolling update: scripts/rolling-update.staged.sh
|
||||
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: honey-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: honey_db
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 2G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- honey-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honey-backend
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
labels:
|
||||
- "deployment.role=primary"
|
||||
- "deployment.version=current"
|
||||
volumes:
|
||||
- /opt/app/data/avatars:/app/data/avatars
|
||||
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
- /opt/app/logs:/app/logs
|
||||
environment:
|
||||
# Staged: 2GB heap (prod uses 10GB)
|
||||
JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
LOGGING_CONFIG: /app/config/logback-spring.xml
|
||||
LOG_DIR: /app/logs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 3G
|
||||
networks:
|
||||
- honey-network
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
backend-new:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: honey-backend-new
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8082:8080"
|
||||
profiles:
|
||||
- rolling-update
|
||||
labels:
|
||||
- "deployment.role=standby"
|
||||
- "deployment.version=new"
|
||||
volumes:
|
||||
- /opt/app/data/avatars:/app/data/avatars
|
||||
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
|
||||
- /opt/app/backend/config:/app/config:rw
|
||||
- /opt/app/logs:/app/logs
|
||||
environment:
|
||||
JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
LOGGING_CONFIG: /app/config/logback-spring.xml
|
||||
LOG_DIR: /app/logs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 3G
|
||||
networks:
|
||||
- honey-network
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:latest
|
||||
container_name: honey-phpmyadmin
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8081:80"
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
UPLOAD_LIMIT: 64M
|
||||
PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
|
||||
PMA_SSL: "true"
|
||||
PMA_TRUSTED_PROXIES: "127.0.0.1"
|
||||
networks:
|
||||
- honey-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
honey-network:
|
||||
driver: bridge
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: honey-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:honey_db}
|
||||
MYSQL_USER: ${DB_USERNAME:root}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:password}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- honey_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
env_file:
|
||||
- .env
|
||||
build: .
|
||||
container_name: honey-backend
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db}
|
||||
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root}
|
||||
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password}
|
||||
|
||||
volumes:
|
||||
honey_mysql_data:
|
||||
|
||||
|
||||
360
docs/GITEA_VPS_DEPLOY.md
Normal file
360
docs/GITEA_VPS_DEPLOY.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Gitea Actions: Deploy honey-be to VPS on push to main
|
||||
|
||||
|
||||
This guide sets up automatic deployment of **honey-be** to your Honey VPS (188.116.23.7) when you push to the `main` branch. The workflow runs on your Gitea runner, syncs the repo via rsync over SSH, then runs the rolling-update script on the VPS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gitea with Actions enabled and at least one runner registered (e.g. `ubuntu-latest`).
|
||||
- Honey VPS (188.116.23.7) with backend at `/opt/app/backend/honey-be` and `scripts/rolling-update.staged.sh`.
|
||||
- The **Gitea server (or the machine where the runner runs)** must be able to reach 188.116.23.7 over the network (e.g. outbound SSH). Gitea itself can stay Tailscale-only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the deploy SSH key on your Mac
|
||||
|
||||
**Where:** Local Mac (Terminal).
|
||||
|
||||
**1.1** Generate a dedicated deploy key (Ed25519, no passphrase):
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "gitea-deploy-honey-be" -f ~/.ssh/gitea_deploy_honey_be -N ""
|
||||
```
|
||||
|
||||
**1.2** Where the keys are on your Mac:
|
||||
|
||||
| File on your Mac | Purpose |
|
||||
|------------------|--------|
|
||||
| `~/.ssh/gitea_deploy_honey_be` | **Private key** — you will paste this into Gitea (Step 3). Never put this on the Staged VPS. |
|
||||
| `~/.ssh/gitea_deploy_honey_be.pub` | **Public key** — you will put this on the Staged VPS (Step 2). |
|
||||
|
||||
**1.3** Optional: display the keys so you can copy them later.
|
||||
|
||||
To show the **public** key (for Step 2):
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/gitea_deploy_honey_be.pub
|
||||
```
|
||||
|
||||
To show the **private** key (for Step 3 — paste into Gitea):
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/gitea_deploy_honey_be
|
||||
```
|
||||
|
||||
Copy each output as a whole (including `ssh-ed25519 ...` for the public key and `-----BEGIN ... KEY-----` / `-----END ... KEY-----` for the private key). You can run these commands again anytime.
|
||||
|
||||
---
|
||||
|
||||
## 2. Put the public key on the Staged VPS
|
||||
|
||||
The **Staged VPS** is your Honey server: **188.116.23.7**. The Gitea runner will SSH into this machine as `root` (or your deploy user), so the **public** key must be in that user’s `authorized_keys` on the Staged VPS.
|
||||
|
||||
**2.1 — On your Local Mac:** Copy the public key to the clipboard (so you can paste it on the VPS):
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/gitea_deploy_honey_be.pub | pbcopy
|
||||
```
|
||||
|
||||
Or just note the single line that looks like:
|
||||
`ssh-ed25519 AAAAC3... gitea-deploy-honey-be`
|
||||
|
||||
**2.2 — Log in to the Staged VPS** from your Mac:
|
||||
|
||||
```bash
|
||||
ssh root@188.116.23.7
|
||||
```
|
||||
|
||||
(Use the same user you normally use to manage this server, e.g. `root` or a user with sudo. If you use a different user, replace `root` in the next steps with that user.)
|
||||
|
||||
**2.3 — On the Staged VPS (188.116.23.7):** Ensure `.ssh` exists and add the public key.
|
||||
|
||||
Run these commands **one by one** on the Staged VPS (after you’re logged in via SSH):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
```
|
||||
|
||||
Then add the public key. **Either** paste the line you copied (replace `PASTE_YOUR_PUBLIC_KEY_LINE_HERE` with the actual line):
|
||||
|
||||
```bash
|
||||
echo 'PASTE_YOUR_PUBLIC_KEY_LINE_HERE' >> ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
**Or**, if you have the key in your Mac clipboard, on the Staged VPS you can do (from Mac, one line):
|
||||
|
||||
```bash
|
||||
ssh root@188.116.23.7 "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$(cat ~/.ssh/gitea_deploy_honey_be.pub)' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
If you ran the `echo ... >> authorized_keys` manually on the VPS, set permissions:
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
**2.4 — Verify from your Mac:** Check that the deploy key can log in without a password:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK"
|
||||
```
|
||||
|
||||
You should see `OK`. If you get "Permission denied (publickey)", the public key was not added correctly to `~/.ssh/authorized_keys` on the Staged VPS.
|
||||
|
||||
---
|
||||
|
||||
## 3. Put the private key into Gitea (repository secret)
|
||||
|
||||
**Where:** Private key stays on your Mac; you only **paste its contents** into Gitea in the browser.
|
||||
|
||||
**3.1 — On your Local Mac:** Show the private key so you can copy it:
|
||||
|
||||
```bash
|
||||
cat ~/.ssh/gitea_deploy_honey_be
|
||||
```
|
||||
|
||||
Copy the **entire** output, including:
|
||||
|
||||
- `-----BEGIN OPENSSH PRIVATE KEY-----`
|
||||
- all lines in the middle
|
||||
- `-----END OPENSSH PRIVATE KEY-----`
|
||||
|
||||
**3.2 — In Gitea (browser):** Add it as a repository secret.
|
||||
|
||||
1. Open your repo: `http://100.122.146.65:3000/admin/honey-be` (or your Gitea URL over Tailscale).
|
||||
2. Go to **Settings** → **Secrets and Variables** → **Actions**.
|
||||
3. Under **Repository Secrets**, click **Add Secret**.
|
||||
4. **Name:** `DEPLOY_SSH_PRIVATE_KEY`
|
||||
5. **Value:** Paste the full private key you copied from the previous command. Paste once, with no extra spaces or blank lines before/after; the key must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`. Do not paste the `.pub` (public) file by mistake — that will cause "Error loading key ... invalid format" in the workflow.
|
||||
6. Save.
|
||||
|
||||
Optional secrets (workflow has defaults):
|
||||
|
||||
| Name | Value | When to set |
|
||||
|------|--------|-------------|
|
||||
| `DEPLOY_VPS_HOST` | `188.116.23.7` | Only if your Staged VPS has a different IP. |
|
||||
| `DEPLOY_VPS_USER` | `root` | Only if the deploy user is not `root`. |
|
||||
| `GITEA_HOST_IP` | e.g. `172.20.0.1` | If checkout fails with "Could not resolve host: server", set this to the **default gateway** seen from the job container. Run a debug job that runs `ip route show default \| awk '{print $3}'` and use that value. The workflow defaults to `172.20.0.1` if unset. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Add the workflow file to the repo and push it
|
||||
|
||||
The workflow is a YAML file that tells Gitea **what to do** when you push to `main`. It does not get “installed” anywhere except inside the repository.
|
||||
|
||||
**4.1 — What the file is and where it lives**
|
||||
|
||||
- **Path in repo:** `.gitea/workflows/deploy-vps.yaml`
|
||||
- **What it does:** On every push to `main`, Gitea runs a job that: checks out the code → installs SSH/rsync → syncs the repo to the Staged VPS at `/opt/app/backend/honey-be` → runs `sudo ./scripts/rolling-update.staged.sh` on the VPS.
|
||||
- Gitea only runs workflows that exist **on the branch you push**. So this file must be committed and pushed to `main`.
|
||||
|
||||
**4.2 — What you need to do**
|
||||
|
||||
**Where:** Local Mac (in your honey-be project directory).
|
||||
|
||||
1. Confirm the workflow file exists:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/honey-be
|
||||
ls -la .gitea/workflows/deploy-vps.yaml
|
||||
```
|
||||
|
||||
2. If it’s there, add it to git, commit, and push to `main`:
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/deploy-vps.yaml
|
||||
git commit -m "Add VPS deploy workflow"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. If the file is not there, create the directory and the file (the file contents are in this repo), then run the same `git add` / `git commit` / `git push` as above.
|
||||
|
||||
After the push, Gitea will see the workflow. It will run **only when** a runner is available (Step 5). So you can push the workflow first and set up the runner next, or the other way around.
|
||||
|
||||
---
|
||||
|
||||
## 5. Set up Gitea Actions and the runner
|
||||
|
||||
Gitea needs **Actions enabled** and at least one **runner** registered. The runner is the machine (or container) that actually runs the workflow steps. Your Gitea runs on a VPS with Docker; the runner is usually the **act_runner** container next to Gitea.
|
||||
|
||||
**5.1 — Enable Actions (if not already)**
|
||||
|
||||
**Where:** Gitea in the browser (admin or repo).
|
||||
|
||||
- **Site-wide:** Log in as admin → **Site Administration** → **Actions** → enable **Enable Actions**.
|
||||
- Or at **repo level:** open **honey-be** → **Settings** → **Actions** → enable if there is an option.
|
||||
|
||||
**5.2 — Ensure the runner container is running**
|
||||
|
||||
**Where:** Gitea VPS (the server where Gitea’s Docker runs, e.g. 100.122.146.65 over Tailscale).
|
||||
|
||||
SSH into the Gitea server and check that both `gitea` and `gitea_runner` (or your runner container name) are up:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
You should see something like `gitea` and `gitea_runner`. If the runner container is stopped:
|
||||
|
||||
```bash
|
||||
cd ~/gitea # or wherever your docker-compose.yml is
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**5.3 — Get the runner registration token from Gitea**
|
||||
|
||||
**Where:** Gitea in the browser.
|
||||
|
||||
1. Open the **honey-be** repository.
|
||||
2. Go to **Settings** → **Actions** → **Runners**.
|
||||
3. Click **Create new runner** (or **Add runner** / **Register runner**).
|
||||
4. Gitea will show a **registration token** and often a command or instructions. Copy the token (you’ll use it in the next step if the runner is not already registered).
|
||||
|
||||
**5.4 — Register the runner (if it isn’t already)**
|
||||
|
||||
**Where:** Gitea VPS (SSH).
|
||||
|
||||
Your `docker-compose` must set **GITEA_INSTANCE_URL** to a URL the runner container can reach. Use the **Docker service name**, not `127.0.0.1`:
|
||||
|
||||
- **Use:** `GITEA_INSTANCE_URL=http://server:3000` (so the runner resolves `server` on the Docker network).
|
||||
- **Do not use:** `GITEA_INSTANCE_URL=http://127.0.0.1:3000` — from inside the runner container, that is the container itself, so the runner stays "Offline" and never connects.
|
||||
|
||||
Your `docker-compose` may already set `GITEA_RUNNER_REGISTRATION_TOKEN` and the runner registers on startup. Check in Gitea: **Settings** → **Actions** → **Runners**. If you see a runner with status **Idle** and label **ubuntu-latest**, you can skip to 5.5.
|
||||
|
||||
If no runner appears (or it stays "Offline"), register or re-register on the Gitea VPS. If you changed `GITEA_INSTANCE_URL`, clear the runner’s persisted state first so it uses the new URL:
|
||||
|
||||
```bash
|
||||
cd ~/gitea
|
||||
docker compose stop runner
|
||||
rm -rf ./data/runner/*
|
||||
docker compose up -d --force-recreate runner
|
||||
```
|
||||
|
||||
If you still need to register manually:
|
||||
|
||||
```bash
|
||||
docker exec -it gitea_runner sh
|
||||
```
|
||||
|
||||
Inside the container:
|
||||
|
||||
```bash
|
||||
act_runner register --instance http://server:3000 --token PASTE_TOKEN_FROM_STEP_5.3
|
||||
```
|
||||
|
||||
Then exit and restart the runner container so it runs the daemon:
|
||||
|
||||
```bash
|
||||
exit
|
||||
docker restart gitea_runner
|
||||
```
|
||||
|
||||
**5.5 — Check that the runner has the right label**
|
||||
|
||||
**Where:** Gitea in the browser.
|
||||
|
||||
1. Go to **honey-be** → **Settings** → **Actions** → **Runners**.
|
||||
2. You should see at least one runner with:
|
||||
- **Status:** Idle (or Running when a job is active)
|
||||
- **Labels:** must include **ubuntu-latest**, because the workflow file has `runs-on: ubuntu-latest`.
|
||||
|
||||
If your runner has a different label (e.g. `linux`), you have two options:
|
||||
|
||||
- **Option A:** In Gitea when registering the runner, add the label **ubuntu-latest** (if the UI lets you choose labels).
|
||||
- **Option B:** Edit `.gitea/workflows/deploy-vps.yaml` and change the line `runs-on: ubuntu-latest` to your runner’s label (e.g. `runs-on: linux`), then commit and push.
|
||||
|
||||
**5.6 — Summary**
|
||||
|
||||
- Actions enabled in Gitea.
|
||||
- Runner container running on the Gitea VPS.
|
||||
- Runner registered and visible under **Settings** → **Actions** → **Runners** with label **ubuntu-latest** (or workflow updated to match your label).
|
||||
|
||||
---
|
||||
|
||||
## 6. Test the deployment
|
||||
|
||||
Do this **after** the workflow file is on `main` (Step 4) and the runner is set up (Step 5).
|
||||
|
||||
**6.1 — Trigger a run**
|
||||
|
||||
Push a commit to `main` (e.g. a small change or the workflow/docs you added):
|
||||
|
||||
```bash
|
||||
cd /path/to/your/honey-be
|
||||
git add .gitea/workflows/deploy-vps.yaml docs/GITEA_VPS_DEPLOY.md
|
||||
git commit -m "Add VPS deploy workflow and guide"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**6.2 — Check the run in Gitea**
|
||||
|
||||
**Where:** Gitea in the browser.
|
||||
|
||||
1. Open **honey-be** → **Actions** (tab in the repo).
|
||||
2. You should see a run for the “Deploy to VPS” workflow. Open it.
|
||||
3. Confirm all steps are green. If something fails, open the failed step to see the log.
|
||||
|
||||
**6.3 — Check the Staged VPS**
|
||||
|
||||
**Where:** Staged VPS (188.116.23.7) or your Mac (SSH to VPS).
|
||||
|
||||
1. SSH in: `ssh root@188.116.23.7`
|
||||
2. Check that code was updated: `ls -la /opt/app/backend/honey-be`
|
||||
3. Check that the app restarted: `docker ps` (or your usual way to verify the backend is running).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner stays "Offline" / "Last Online Time: Never" / "Cannot ping the Gitea instance server"
|
||||
|
||||
- **Cause:** The runner container is trying to reach Gitea at `http://127.0.0.1:3000`. From inside the runner container, `127.0.0.1` is the container itself, not the Gitea server, so the connection is refused.
|
||||
- **Fix:**
|
||||
1. In your **docker-compose** on the Gitea VPS, set `GITEA_INSTANCE_URL=http://server:3000` (use the Docker service name of the Gitea container, e.g. `server`).
|
||||
2. Clear the runner's persisted state and recreate the container so it re-registers with the new URL:
|
||||
```bash
|
||||
cd ~/gitea
|
||||
docker compose stop runner
|
||||
rm -rf ./data/runner/*
|
||||
docker compose up -d --force-recreate runner
|
||||
```
|
||||
3. In Gitea → **Settings** → **Actions** → **Runners**, the runner should show **Idle** within a few seconds. If you see a new runner and the old one still "Offline", you can remove the old one in the UI.
|
||||
|
||||
---
|
||||
|
||||
### Checkout fails: "Could not resolve host: server" / "fatal: unable to access 'http://server:3000/...'"
|
||||
|
||||
- **Cause:** The workflow job runs inside a **separate** Docker container (started by the runner). That job container is not on the same Docker network as Gitea, so the hostname `server` does not resolve there. Changing Gitea's `LOCAL_ROOT_URL` or the host's `/etc/hosts` does not help, because the job container has its own network.
|
||||
- **Fix:** The deploy workflow in this repo already uses a **manual checkout** that clones via the runner host's IP instead of `server`. If checkout still fails:
|
||||
1. Add repository secret **GITEA_HOST_IP** with the default gateway as seen from the job container. To get it: run a one-off debug workflow that runs `ip route show default | awk '{print $3}'` and use the printed value (often `172.20.0.1` or `172.17.0.1`).
|
||||
2. If you don't set the secret, the workflow defaults to `172.20.0.1`; if your Docker uses a different gateway, set **GITEA_HOST_IP** to that value.
|
||||
|
||||
---
|
||||
|
||||
### Setup SSH fails: "Error loading key ... invalid format"
|
||||
|
||||
- **Cause:** The **DEPLOY_SSH_PRIVATE_KEY** secret in Gitea is not valid: pasted with wrong line breaks, truncated, or the **public** key (`.pub`) was pasted by mistake.
|
||||
- **Fix:**
|
||||
1. On your Mac, verify the private key file: `ssh-keygen -y -f ~/.ssh/gitea_deploy_honey_be`. If that fails, generate a new key (Step 1) and add the new public key to the VPS and the new private key to Gitea.
|
||||
2. Copy the **full** private key again: `cat ~/.ssh/gitea_deploy_honey_be | pbcopy` (or open the file and copy everything).
|
||||
3. In Gitea → **Settings** → **Secrets and Variables** → **Actions**, edit **DEPLOY_SSH_PRIVATE_KEY** and paste the entire key. It must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`, with no extra blank lines or spaces at the start/end. Make sure you are pasting the **private** key file, not the `.pub` file.
|
||||
4. Save and re-run the workflow.
|
||||
|
||||
---
|
||||
|
||||
### Other issues
|
||||
|
||||
- **Permission denied (publickey)** (during rsync or SSH to Staged VPS)
|
||||
Check: public key in `authorized_keys` on the Staged VPS, private key in `DEPLOY_SSH_PRIVATE_KEY`, no extra spaces/newlines when pasting. From your Mac, test: `ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK"`.
|
||||
|
||||
- **Runner doesn’t pick up the job**
|
||||
In Gitea: **Settings** → **Actions** → **Runners**. Confirm runner is “idle” and has label `ubuntu-latest`.
|
||||
|
||||
- **rsync or SSH fails from the job**
|
||||
The job runs on the Gitea VPS (in a container). Ensure the Gitea VPS can reach the Staged VPS (188.116.23.7) on port 22. From the Gitea VPS host, test: `ssh -i /path/to/private_key root@188.116.23.7 "echo OK"`.
|
||||
|
||||
- **sudo or script fails on VPS**
|
||||
SSH in as the deploy user and run manually:
|
||||
`cd /opt/app/backend/honey-be && sudo ./scripts/rolling-update.staged.sh`
|
||||
Fix permissions or sudo rules as needed.
|
||||
62
honey-config.properties.template
Normal file
62
honey-config.properties.template
Normal file
@@ -0,0 +1,62 @@
|
||||
# Honey Application Configuration
|
||||
# Copy this file to /run/secrets/honey-config.properties on your VPS
|
||||
# Replace all placeholder values with your actual configuration
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
# SPRING_DATASOURCE_URL format: jdbc:mysql://<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: 'honey_db' (must match MYSQL_DATABASE in docker-compose.prod.yml)
|
||||
#
|
||||
# Example: jdbc:mysql://db:3306/honey_db
|
||||
# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘
|
||||
# │ │ │ └─ Database name
|
||||
# │ │ └─ Port (3306 is MySQL default)
|
||||
# │ └─ Service name in docker-compose (acts as hostname)
|
||||
# └─ JDBC protocol for MySQL
|
||||
#
|
||||
# IMPORTANT: Use 'db' as hostname, NOT 'localhost' or '127.0.0.1'
|
||||
# This is an internal Docker network connection
|
||||
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
|
||||
SPRING_DATASOURCE_USERNAME=root
|
||||
SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here
|
||||
|
||||
# ============================================
|
||||
# Telegram Bot Configuration
|
||||
# ============================================
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here
|
||||
TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name
|
||||
|
||||
# ============================================
|
||||
# Frontend Configuration
|
||||
# ============================================
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# ============================================
|
||||
# Avatar Storage Configuration
|
||||
# ============================================
|
||||
APP_AVATAR_STORAGE_PATH=/app/data/avatars
|
||||
APP_AVATAR_PUBLIC_BASE_URL=
|
||||
APP_AVATAR_MAX_SIZE_BYTES=2097152
|
||||
APP_AVATAR_MAX_DIMENSION=512
|
||||
|
||||
# ============================================
|
||||
# Session Configuration (Optional - defaults shown)
|
||||
# ============================================
|
||||
APP_SESSION_MAX_ACTIVE_PER_USER=5
|
||||
APP_SESSION_CLEANUP_BATCH_SIZE=5000
|
||||
APP_SESSION_CLEANUP_MAX_BATCHES=20
|
||||
|
||||
# ============================================
|
||||
# GeoIP Configuration (Optional)
|
||||
# ============================================
|
||||
GEOIP_DB_PATH=
|
||||
|
||||
242
nginx-testforapp-test-base.conf
Normal file
242
nginx-testforapp-test-base.conf
Normal file
@@ -0,0 +1,242 @@
|
||||
# Two frontends on the same backend:
|
||||
# - Frontend 1: /opt/app/frontend/dist -> https://testforapp.website/ (build with base: '/')
|
||||
# - Frontend 2: /opt/app/frontend/test-dist -> https://testforapp.website/test/ (build with base: '/test/')
|
||||
|
||||
upstream backend {
|
||||
server 127.0.0.1:8082;
|
||||
server 127.0.0.1:8080 backup;
|
||||
keepalive 500;
|
||||
}
|
||||
|
||||
map $http_referer $is_phpmyadmin_request {
|
||||
~*18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
|
||||
server_name testforapp.website;
|
||||
|
||||
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
|
||||
|
||||
root /opt/app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# --- Frontend 2 at /test/ (from test-dist) ---
|
||||
location = /test {
|
||||
return 301 /test/;
|
||||
}
|
||||
location ^~ /test/ {
|
||||
alias /opt/app/frontend/test-dist/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /test/index.html;
|
||||
expires 0;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
|
||||
}
|
||||
|
||||
# Admin Panel - root path (redirects to trailing slash)
|
||||
location = /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa {
|
||||
return 301 /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/;
|
||||
}
|
||||
|
||||
location ~ ^/dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/(assets/.+)$ {
|
||||
alias /opt/app/admin-panel/$1;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/ {
|
||||
alias /opt/app/admin-panel/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/index.html;
|
||||
expires 0;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
location /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
gzip off;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 8k;
|
||||
proxy_buffers 16 8k;
|
||||
proxy_busy_buffers_size 16k;
|
||||
proxy_cookie_path / /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/;
|
||||
sub_filter 'action="index.php?route=/' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php?route=/';
|
||||
sub_filter 'action="index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter 'action="/index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter 'Location: /index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter 'Location: index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter 'href="index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter 'href="/index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
}
|
||||
|
||||
location ~ ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) {
|
||||
rewrite ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) {
|
||||
if ($is_phpmyadmin_request = 0) {
|
||||
return 404;
|
||||
}
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /videos/ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
location ^~ /images/ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# Frontend 1: static assets at root (from dist)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Frontend 1: SPA at / (from dist)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 0;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
|
||||
}
|
||||
|
||||
location ^~ /avatars/ {
|
||||
alias /opt/app/data/avatars/;
|
||||
expires 24h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
access_log off;
|
||||
location ~ \.(php|html)$ {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
location /api/telegram/webhook/ {
|
||||
access_log off;
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=200 nodelay;
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location = /phpmyadmin {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = testforapp.website) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name testforapp.website;
|
||||
return 404;
|
||||
}
|
||||
128
nginx.conf.template
Normal file
128
nginx.conf.template
Normal file
@@ -0,0 +1,128 @@
|
||||
# Nginx configuration for Honey Application
|
||||
# Place this file at: /opt/app/nginx/nginx.conf
|
||||
#
|
||||
# This configuration assumes:
|
||||
# - Frontend static files are at: /opt/app/frontend/dist
|
||||
# - Avatar files are at: /opt/app/data/avatars
|
||||
# - Backend is accessible at: http://127.0.0.1:8080 (exposed to localhost only)
|
||||
# - HTTPS is handled by Nginx (SSL certificates should be configured separately)
|
||||
|
||||
# Upstream backend (using localhost since Nginx runs on host, not in Docker)
|
||||
upstream backend {
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _; # Replace with your domain name
|
||||
|
||||
# For Let's Encrypt ACME challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other HTTP traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _; # Replace with your domain name
|
||||
|
||||
# SSL certificate configuration
|
||||
# Update these paths to your actual certificate files
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
|
||||
# SSL configuration (recommended settings)
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Root directory for frontend static files
|
||||
root /opt/app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Serve avatar files with aggressive caching
|
||||
# Avatars are served by backend, but we can also serve them directly from filesystem
|
||||
location /avatars/ {
|
||||
alias /opt/app/data/avatars/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Backend API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts for long-running requests
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# WebSocket endpoint (for game updates)
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeouts
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# Frontend static files (SPA routing)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Cache static assets (JS, CSS, images)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Health check endpoint (optional, for monitoring)
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
|
||||
84
nginx/conf.d/honey.conf
Normal file
84
nginx/conf.d/honey.conf
Normal file
@@ -0,0 +1,84 @@
|
||||
upstream honey_backend {
|
||||
# Primary backend (port 8080)
|
||||
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
|
||||
# Standby backend (port 8082) - used during rolling updates
|
||||
# Uncomment the line below to switch traffic to new backend
|
||||
# server 127.0.0.1:8082 max_fails=3 fail_timeout=30s backup;
|
||||
|
||||
# Health check configuration
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Increase body size limit for API requests
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Actuator endpoints (for health checks)
|
||||
location /actuator/ {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Ping endpoint
|
||||
location /ping {
|
||||
proxy_pass http://honey_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Frontend (if serving static files)
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
index index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS configuration (uncomment and configure when SSL certificates are available)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
#
|
||||
# # SSL configuration
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
# ssl_prefer_server_ciphers on;
|
||||
#
|
||||
# # Same location blocks as HTTP server
|
||||
# # ...
|
||||
# }
|
||||
|
||||
|
||||
35
nginx/nginx.conf
Normal file
35
nginx/nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
|
||||
|
||||
141
pom.xml
Normal file
141
pom.xml
Normal file
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.honey</groupId>
|
||||
<artifactId>honey-be</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<!-- Spring Boot Parent -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MaxMind GeoIP2 -->
|
||||
<dependency>
|
||||
<groupId>com.maxmind.geoip2</groupId>
|
||||
<artifactId>geoip2</artifactId>
|
||||
<version>4.2.0</version>
|
||||
</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>
|
||||
|
||||
<!-- OpenAPI / Swagger (public API docs only; admin excluded) -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.5.0</version>
|
||||
</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>
|
||||
<plugins>
|
||||
<!-- Spring Boot Plugin -->
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
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/honey-config.properties
|
||||
# 3. Docker container 'honey-mysql' running
|
||||
#
|
||||
# Backup location on backup VPS: /raid/backup/acc_260182/
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
BACKUP_VPS_HOST="5.45.77.77"
|
||||
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
|
||||
BACKUP_VPS_PATH="/raid/backup/acc_260182"
|
||||
MYSQL_CONTAINER="honey-mysql"
|
||||
MYSQL_DATABASE="lottery_db"
|
||||
SECRET_FILE="/run/secrets/honey-config.properties"
|
||||
BACKUP_DIR="/opt/app/backups"
|
||||
KEEP_LOCAL=false
|
||||
COMPRESS=true
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--keep-local)
|
||||
KEEP_LOCAL=true
|
||||
shift
|
||||
;;
|
||||
--no-compress)
|
||||
COMPRESS=false
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--keep-local] [--no-compress]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging function
|
||||
log() {
|
||||
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root or with sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "This script must be run as root (or with sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load database password
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
error "Secret file not found at $SECRET_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
if [ -z "$DB_PASSWORD" ]; then
|
||||
error "SPRING_DATASOURCE_PASSWORD not found in secret file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if MySQL container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then
|
||||
error "MySQL container '${MYSQL_CONTAINER}' is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Generate backup filename with timestamp
|
||||
TIMESTAMP=$(date +'%Y%m%d_%H%M%S')
|
||||
BACKUP_FILENAME="lottery_db_backup_${TIMESTAMP}.sql"
|
||||
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
|
||||
|
||||
# If compression is enabled, add .gz extension
|
||||
if [ "$COMPRESS" = true ]; then
|
||||
BACKUP_FILENAME="${BACKUP_FILENAME}.gz"
|
||||
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
|
||||
fi
|
||||
|
||||
log "Starting database backup..."
|
||||
log "Database: ${MYSQL_DATABASE}"
|
||||
log "Container: ${MYSQL_CONTAINER}"
|
||||
log "Backup file: ${BACKUP_FILENAME}"
|
||||
|
||||
# Create MySQL dump
|
||||
log "Creating MySQL dump..."
|
||||
|
||||
if [ "$COMPRESS" = true ]; then
|
||||
# Dump and compress in one step (saves disk space)
|
||||
if docker exec "${MYSQL_CONTAINER}" mysqldump \
|
||||
-u root \
|
||||
-p"${DB_PASSWORD}" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--quick \
|
||||
--lock-tables=false \
|
||||
"${MYSQL_DATABASE}" | gzip > "${BACKUP_PATH}"; then
|
||||
log "✅ Database dump created and compressed: ${BACKUP_PATH}"
|
||||
else
|
||||
error "Failed to create database dump"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Dump without compression
|
||||
if docker exec "${MYSQL_CONTAINER}" mysqldump \
|
||||
-u root \
|
||||
-p"${DB_PASSWORD}" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--quick \
|
||||
--lock-tables=false \
|
||||
"${MYSQL_DATABASE}" > "${BACKUP_PATH}"; then
|
||||
log "✅ Database dump created: ${BACKUP_PATH}"
|
||||
else
|
||||
error "Failed to create database dump"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get backup file size
|
||||
BACKUP_SIZE=$(du -h "${BACKUP_PATH}" | cut -f1)
|
||||
log "Backup size: ${BACKUP_SIZE}"
|
||||
|
||||
# Transfer to backup VPS
|
||||
log "Transferring backup to backup VPS (${BACKUP_VPS_HOST})..."
|
||||
|
||||
# Test SSH connection first
|
||||
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
|
||||
error "Cannot connect to backup VPS via SSH"
|
||||
error "Please ensure:"
|
||||
error " 1. SSH key-based authentication is set up"
|
||||
error " 2. Backup VPS is accessible from this server"
|
||||
error " 3. User '${BACKUP_VPS_USER}' has access to ${BACKUP_VPS_PATH}"
|
||||
|
||||
if [ "$KEEP_LOCAL" = true ]; then
|
||||
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
|
||||
else
|
||||
rm -f "${BACKUP_PATH}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create backup directory on remote VPS if it doesn't exist
|
||||
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "mkdir -p ${BACKUP_VPS_PATH}"
|
||||
|
||||
# Transfer the backup file
|
||||
if scp "${BACKUP_PATH}" "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/"; then
|
||||
log "✅ Backup transferred successfully to ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"
|
||||
|
||||
# Verify remote file exists
|
||||
REMOTE_SIZE=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "du -h ${BACKUP_VPS_PATH}/${BACKUP_FILENAME} 2>/dev/null | cut -f1" || echo "0")
|
||||
if [ "$REMOTE_SIZE" != "0" ]; then
|
||||
log "✅ Remote backup verified (size: ${REMOTE_SIZE})"
|
||||
else
|
||||
warn "Could not verify remote backup file"
|
||||
fi
|
||||
else
|
||||
error "Failed to transfer backup to backup VPS"
|
||||
if [ "$KEEP_LOCAL" = true ]; then
|
||||
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
|
||||
else
|
||||
rm -f "${BACKUP_PATH}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up local backup if not keeping it
|
||||
if [ "$KEEP_LOCAL" = false ]; then
|
||||
rm -f "${BACKUP_PATH}"
|
||||
log "Local backup file removed (transferred successfully)"
|
||||
fi
|
||||
|
||||
# Clean up old backups on remote VPS (keep last 10 days)
|
||||
log "Cleaning up old backups on remote VPS (keeping last 10 days)..."
|
||||
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +10 -delete" || warn "Failed to clean up old backups"
|
||||
|
||||
# Count remaining backups
|
||||
BACKUP_COUNT=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "ls -1 ${BACKUP_VPS_PATH}/lottery_db_backup_*.sql* 2>/dev/null | wc -l" || echo "0")
|
||||
log "Total backups on remote VPS: ${BACKUP_COUNT}"
|
||||
|
||||
log "✅ Backup completed successfully!"
|
||||
log " Remote location: ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"
|
||||
|
||||
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:-honey-config.properties.template}"
|
||||
OUTPUT_FILE="${2:-/run/secrets/honey-config.properties}"
|
||||
OUTPUT_DIR=$(dirname "$OUTPUT_FILE")
|
||||
|
||||
# Check if template exists
|
||||
if [ ! -f "$TEMPLATE_FILE" ]; then
|
||||
echo "❌ Template file not found: $TEMPLATE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Copy template to output
|
||||
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
|
||||
|
||||
# Set secure permissions (read-only for owner, no access for others)
|
||||
chmod 600 "$OUTPUT_FILE"
|
||||
|
||||
echo "✅ Secret file created at $OUTPUT_FILE"
|
||||
echo "⚠️ IMPORTANT: Edit this file and replace all placeholder values with your actual configuration!"
|
||||
echo "⚠️ After editing, ensure permissions are secure: chmod 600 $OUTPUT_FILE"
|
||||
|
||||
|
||||
|
||||
28
scripts/create-secret-file.sh
Normal file
28
scripts/create-secret-file.sh
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Create secret file from environment variables for testing ConfigLoader
|
||||
# This simulates the mounted secret file approach used in Inferno
|
||||
|
||||
SECRET_FILE="/run/secrets/honey-config.properties"
|
||||
SECRET_DIR="/run/secrets"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
mkdir -p "$SECRET_DIR"
|
||||
|
||||
# Create properties file from environment variables
|
||||
cat > "$SECRET_FILE" << EOF
|
||||
# Configuration loaded from secret file (created from env vars for testing)
|
||||
SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL}
|
||||
SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME}
|
||||
SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD}
|
||||
TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
FRONTEND_URL=${FRONTEND_URL}
|
||||
PORT=${PORT}
|
||||
EOF
|
||||
|
||||
# Set permissions (readable by the application user)
|
||||
chmod 644 "$SECRET_FILE"
|
||||
|
||||
echo "✅ Secret file created at $SECRET_FILE from environment variables"
|
||||
|
||||
|
||||
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/honey-be/scripts/backup-database.sh"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "=========================================="
|
||||
echo "Backup Script Permission Diagnostic"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 1. File exists
|
||||
echo "1. Checking if file exists..."
|
||||
if [ -f "$SCRIPT" ]; then
|
||||
echo -e " ${GREEN}✅ File exists${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ File NOT found at: $SCRIPT${NC}"
|
||||
echo " Please verify the path."
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. File permissions
|
||||
echo "2. File permissions:"
|
||||
ls -la "$SCRIPT"
|
||||
echo ""
|
||||
|
||||
# 3. Is executable
|
||||
echo "3. Is file executable?"
|
||||
if [ -x "$SCRIPT" ]; then
|
||||
echo -e " ${GREEN}✅ File is executable${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ File is NOT executable${NC}"
|
||||
echo " Fix: chmod +x $SCRIPT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. Shebang line
|
||||
echo "4. Shebang line (first line):"
|
||||
SHEBANG=$(head -1 "$SCRIPT")
|
||||
echo " $SHEBANG"
|
||||
if [[ "$SHEBANG" == "#!/bin/bash" ]] || [[ "$SHEBANG" == "#!/usr/bin/bash" ]]; then
|
||||
echo -e " ${GREEN}✅ Shebang looks correct${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Unexpected shebang${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. Bash exists
|
||||
echo "5. Checking if bash interpreter exists:"
|
||||
if [ -f /bin/bash ]; then
|
||||
echo -e " ${GREEN}✅ /bin/bash exists${NC}"
|
||||
/bin/bash --version | head -1
|
||||
elif [ -f /usr/bin/bash ]; then
|
||||
echo -e " ${GREEN}✅ /usr/bin/bash exists${NC}"
|
||||
/usr/bin/bash --version | head -1
|
||||
else
|
||||
echo -e " ${RED}❌ bash not found in /bin/bash or /usr/bin/bash${NC}"
|
||||
echo " Found at: $(which bash 2>/dev/null || echo 'NOT FOUND')"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. Line endings
|
||||
echo "6. Checking line endings:"
|
||||
FILE_TYPE=$(file "$SCRIPT")
|
||||
echo " $FILE_TYPE"
|
||||
if echo "$FILE_TYPE" | grep -q "CRLF"; then
|
||||
echo -e " ${RED}❌ File has Windows line endings (CRLF)${NC}"
|
||||
echo " Fix: dos2unix $SCRIPT"
|
||||
echo " Or: sed -i 's/\r$//' $SCRIPT"
|
||||
elif echo "$FILE_TYPE" | grep -q "ASCII text"; then
|
||||
echo -e " ${GREEN}✅ Line endings look correct (LF)${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Could not determine line endings${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. Mount options
|
||||
echo "7. Checking filesystem mount options:"
|
||||
MOUNT_INFO=$(mount | grep -E "(/opt|/app)" || echo "Not a separate mount")
|
||||
echo " $MOUNT_INFO"
|
||||
if echo "$MOUNT_INFO" | grep -q "noexec"; then
|
||||
echo -e " ${RED}❌ Filesystem mounted with 'noexec' flag${NC}"
|
||||
echo " This prevents script execution!"
|
||||
echo " Fix: Remove 'noexec' from /etc/fstab and remount"
|
||||
else
|
||||
echo -e " ${GREEN}✅ No 'noexec' flag detected${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 8. SELinux
|
||||
echo "8. Checking SELinux:"
|
||||
if command -v getenforce &> /dev/null; then
|
||||
SELINUX_STATUS=$(getenforce 2>/dev/null)
|
||||
echo " Status: $SELINUX_STATUS"
|
||||
if [ "$SELINUX_STATUS" = "Enforcing" ]; then
|
||||
echo -e " ${YELLOW}⚠️ SELinux is enforcing - may block execution${NC}"
|
||||
echo " Check context: ls -Z $SCRIPT"
|
||||
else
|
||||
echo -e " ${GREEN}✅ SELinux not blocking (or disabled)${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${GREEN}✅ SELinux not installed${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 9. Directory permissions
|
||||
echo "9. Parent directory permissions:"
|
||||
DIR=$(dirname "$SCRIPT")
|
||||
ls -ld "$DIR"
|
||||
if [ -x "$DIR" ]; then
|
||||
echo -e " ${GREEN}✅ Directory is executable${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ Directory is NOT executable${NC}"
|
||||
echo " Fix: chmod +x $DIR"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 10. Syntax check
|
||||
echo "10. Checking script syntax:"
|
||||
if bash -n "$SCRIPT" 2>&1; then
|
||||
echo -e " ${GREEN}✅ Syntax is valid${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ Syntax errors found${NC}"
|
||||
bash -n "$SCRIPT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 11. Test execution
|
||||
echo "11. Testing script execution (dry run):"
|
||||
echo " Attempting to read first 10 lines..."
|
||||
if head -10 "$SCRIPT" > /dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✅ Can read script${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ Cannot read script${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 12. Cron job check
|
||||
echo "12. Checking cron configuration:"
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
echo " Root's crontab:"
|
||||
crontab -l 2>/dev/null | grep -i backup || echo " (No backup cron job found in root's crontab)"
|
||||
echo ""
|
||||
echo " To check cron job, run: sudo crontab -l"
|
||||
else
|
||||
echo " (Run as root to check crontab: sudo crontab -l)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 13. Environment check
|
||||
echo "13. Checking required commands:"
|
||||
REQUIRED_COMMANDS=("docker" "ssh" "gzip" "bash")
|
||||
for cmd in "${REQUIRED_COMMANDS[@]}"; do
|
||||
if command -v "$cmd" &> /dev/null; then
|
||||
CMD_PATH=$(which "$cmd")
|
||||
echo -e " ${GREEN}✅ $cmd${NC} found at: $CMD_PATH"
|
||||
else
|
||||
echo -e " ${RED}❌ $cmd${NC} NOT found in PATH"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# 14. Secret file check
|
||||
echo "14. Checking secret file:"
|
||||
SECRET_FILE="/run/secrets/honey-config.properties"
|
||||
if [ -f "$SECRET_FILE" ]; then
|
||||
echo -e " ${GREEN}✅ Secret file exists${NC}"
|
||||
if [ -r "$SECRET_FILE" ]; then
|
||||
echo -e " ${GREEN}✅ Secret file is readable${NC}"
|
||||
else
|
||||
echo -e " ${RED}❌ Secret file is NOT readable${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠️ Secret file not found (script will fail at runtime)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "=========================================="
|
||||
echo "Summary & Recommendations"
|
||||
echo "=========================================="
|
||||
|
||||
ISSUES=0
|
||||
|
||||
if [ ! -x "$SCRIPT" ]; then
|
||||
echo -e "${RED}❌ Issue: File is not executable${NC}"
|
||||
echo " Fix: chmod +x $SCRIPT"
|
||||
ISSUES=$((ISSUES + 1))
|
||||
fi
|
||||
|
||||
if file "$SCRIPT" | grep -q "CRLF"; then
|
||||
echo -e "${RED}❌ Issue: Windows line endings detected${NC}"
|
||||
echo " Fix: dos2unix $SCRIPT (or: sed -i 's/\r$//' $SCRIPT)"
|
||||
ISSUES=$((ISSUES + 1))
|
||||
fi
|
||||
|
||||
if mount | grep -E "(/opt|/app)" | grep -q "noexec"; then
|
||||
echo -e "${RED}❌ Issue: Filesystem mounted with noexec${NC}"
|
||||
echo " Fix: Remove noexec from /etc/fstab and remount"
|
||||
ISSUES=$((ISSUES + 1))
|
||||
fi
|
||||
|
||||
if [ "$ISSUES" -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ No obvious issues found${NC}"
|
||||
echo ""
|
||||
echo "If cron still fails, try:"
|
||||
echo " 1. Update cron to use bash explicitly:"
|
||||
echo " 0 2 * * * /bin/bash $SCRIPT >> /opt/app/logs/backup.log 2>&1"
|
||||
echo ""
|
||||
echo " 2. Check cron logs:"
|
||||
echo " sudo journalctl -u cron | tail -50"
|
||||
echo ""
|
||||
echo " 3. Test manual execution:"
|
||||
echo " sudo $SCRIPT --keep-local"
|
||||
else
|
||||
echo ""
|
||||
echo -e "${YELLOW}Found $ISSUES issue(s) that need to be fixed.${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
77
scripts/fix-nginx-redirect-loop.md
Normal file
77
scripts/fix-nginx-redirect-loop.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Fix 301 redirect loop (Certbot duplicate 443 block)
|
||||
|
||||
## Cause
|
||||
|
||||
Certbot added a **second** HTTPS server block that only has:
|
||||
- `location / { return 301 https://$host$request_uri; }`
|
||||
- `listen 443 ssl` + SSL cert paths
|
||||
|
||||
That block is matched first for `https://testforapp.website/`, so every request gets 301 → same URL → loop. Your real HTTPS block (frontend, API, phpMyAdmin) is never used for `/`.
|
||||
|
||||
## Fix on VPS
|
||||
|
||||
1. **Open the site config**
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-enabled/testforapp.website
|
||||
```
|
||||
|
||||
2. **Find and remove the Certbot-only HTTPS block**
|
||||
|
||||
Look for a block that looks like this (it may be at the **top** of the file, before the `map` and your big HTTPS server):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name testforapp.website;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
```
|
||||
|
||||
**Delete this entire `server { ... }` block** (from `server {` through the closing `}`).
|
||||
|
||||
3. **Ensure your main HTTPS block has SSL and listen**
|
||||
|
||||
Find your main HTTPS server (the one with `# HTTPS server`, `root /opt/app/frontend/dist`, all the `location` blocks). It must have at the top of that block (right after `server {`):
|
||||
|
||||
- `listen 443 ssl;` and `listen [::]:443 ssl;`
|
||||
- `ssl_certificate` and `ssl_certificate_key` (and optionally `include /etc/letsencrypt/options-ssl-nginx.conf;` and `ssl_dhparam`)
|
||||
|
||||
If those lines are missing, add them (copy from the block you deleted):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name testforapp.website;
|
||||
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# ... rest of your config (root, locations, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
4. **Test and reload**
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
5. **Verify**
|
||||
```bash
|
||||
curl -I -k https://127.0.0.1/ -H "Host: testforapp.website"
|
||||
```
|
||||
You should see `200 OK` (or `304`) and no `Location` header, and https://testforapp.website/ should load in the browser.
|
||||
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/honey-config.properties"
|
||||
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
echo "❌ Error: Secret file not found at $SECRET_FILE"
|
||||
echo " Please create the secret file first (see deployment guide Step 3.3)"
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
# Read SPRING_DATASOURCE_PASSWORD from secret file
|
||||
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
if [ -z "$DB_PASSWORD" ]; then
|
||||
echo "❌ Error: SPRING_DATASOURCE_PASSWORD not found in secret file"
|
||||
echo " Please ensure the secret file contains: SPRING_DATASOURCE_PASSWORD=your_password"
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
# Export both variables (MySQL uses both)
|
||||
export DB_PASSWORD="$DB_PASSWORD"
|
||||
export DB_ROOT_PASSWORD="$DB_PASSWORD"
|
||||
|
||||
# Optionally load PMA_ABSOLUTE_URI from secret file (for phpMyAdmin path protection)
|
||||
PMA_ABSOLUTE_URI=$(grep "^PMA_ABSOLUTE_URI=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/^"//;s/"$//' | sed "s/^'//;s/'$//")
|
||||
if [ -n "$PMA_ABSOLUTE_URI" ]; then
|
||||
export PMA_ABSOLUTE_URI="$PMA_ABSOLUTE_URI"
|
||||
echo "✅ PMA_ABSOLUTE_URI loaded from secret file"
|
||||
fi
|
||||
|
||||
echo "✅ Database password loaded from secret file"
|
||||
echo " DB_PASSWORD and DB_ROOT_PASSWORD are now set (matching SPRING_DATASOURCE_PASSWORD)"
|
||||
|
||||
|
||||
|
||||
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/honey-config.properties
|
||||
# 2. Docker container 'honey-mysql' running
|
||||
# 3. Database will be DROPPED and RECREATED (all data will be lost!)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
MYSQL_CONTAINER="honey-mysql"
|
||||
MYSQL_DATABASE="lottery_db"
|
||||
SECRET_FILE="/run/secrets/honey-config.properties"
|
||||
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging function
|
||||
log() {
|
||||
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
# Check arguments
|
||||
if [ $# -eq 0 ]; then
|
||||
error "No backup file specified"
|
||||
echo "Usage: $0 <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 honey-backend"
|
||||
|
||||
629
scripts/rolling-update.sh
Normal file
629
scripts/rolling-update.sh
Normal file
@@ -0,0 +1,629 @@
|
||||
#!/bin/bash
|
||||
# Rolling Update Deployment Script (production)
|
||||
# Uses docker-compose.prod.yml. For staged (8GB VPS) use: scripts/rolling-update.staged.sh
|
||||
# This script performs zero-downtime deployment by:
|
||||
# 1. Building new backend image
|
||||
# 2. Starting new backend container on port 8082
|
||||
# 3. Health checking the new container
|
||||
# 4. Updating Nginx to point to new container
|
||||
# 5. Reloading Nginx (zero downtime)
|
||||
# 6. Stopping old container after grace period
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors (define early for use in config detection)
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Logging functions (define early)
|
||||
log() {
|
||||
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.prod.yml"
|
||||
|
||||
# Detect Nginx config file (try common locations)
|
||||
# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths
|
||||
NGINX_CONF="${NGINX_CONF:-}"
|
||||
if [ -z "$NGINX_CONF" ]; then
|
||||
if [ -f "/etc/nginx/sites-enabled/testforapp.website" ]; then
|
||||
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website"
|
||||
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
|
||||
elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
|
||||
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf"
|
||||
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
|
||||
elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then
|
||||
NGINX_CONF="/etc/nginx/conf.d/honey.conf"
|
||||
log "Using Nginx config: $NGINX_CONF (conf.d)"
|
||||
elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then
|
||||
warn "Found config at /opt/app/nginx/testforapp.website.conf"
|
||||
warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..."
|
||||
if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
|
||||
# Find the actual target
|
||||
local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.conf 2>/dev/null)
|
||||
if [ -n "$target" ]; then
|
||||
NGINX_CONF="$target"
|
||||
log "Using Nginx config: $NGINX_CONF (symlink target)"
|
||||
else
|
||||
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
|
||||
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
|
||||
fi
|
||||
else
|
||||
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
|
||||
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
|
||||
fi
|
||||
else
|
||||
error "Cannot find Nginx config file."
|
||||
error "Searched:"
|
||||
error " - /etc/nginx/sites-enabled/testforapp.website"
|
||||
error " - /etc/nginx/sites-enabled/testforapp.website.conf"
|
||||
error " - /etc/nginx/conf.d/honey.conf"
|
||||
error " - /opt/app/nginx/testforapp.website.conf"
|
||||
error ""
|
||||
error "Please set NGINX_CONF environment variable with the correct path."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)"
|
||||
fi
|
||||
|
||||
# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files)
|
||||
NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Ports for backends (will be swapped dynamically)
|
||||
PRIMARY_PORT=8080
|
||||
STANDBY_PORT=8082
|
||||
|
||||
# Detect which backend is currently active
|
||||
detect_active_backend() {
|
||||
# Check which port Nginx is currently using in upstream block
|
||||
# Look for server line that is NOT marked as backup
|
||||
local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1)
|
||||
|
||||
if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then
|
||||
# Port 8082 is active (not backup)
|
||||
ACTIVE_PORT=8082
|
||||
STANDBY_PORT=8080
|
||||
ACTIVE_CONTAINER="honey-backend-new"
|
||||
STANDBY_CONTAINER="honey-backend"
|
||||
log "Detected: Port 8082 is currently active"
|
||||
else
|
||||
# Port 8080 is active (default or only one present)
|
||||
ACTIVE_PORT=8080
|
||||
STANDBY_PORT=8082
|
||||
ACTIVE_CONTAINER="honey-backend"
|
||||
STANDBY_CONTAINER="honey-backend-new"
|
||||
log "Detected: Port 8080 is currently active"
|
||||
fi
|
||||
|
||||
PRIMARY_PORT=$ACTIVE_PORT
|
||||
HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness"
|
||||
}
|
||||
|
||||
HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max)
|
||||
HEALTH_CHECK_INTERVAL=2
|
||||
GRACE_PERIOD=10
|
||||
|
||||
# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback)
|
||||
# This allows keeping failed containers for debugging even when using sudo
|
||||
if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then
|
||||
SCRIPT_KEEP_FAILED_CONTAINER="true"
|
||||
export SCRIPT_KEEP_FAILED_CONTAINER
|
||||
log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging"
|
||||
fi
|
||||
|
||||
# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose')
|
||||
DOCKER_COMPOSE_CMD=""
|
||||
if docker compose version &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
error "Neither 'docker compose' nor 'docker-compose' is available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check prerequisites
|
||||
check_prerequisites() {
|
||||
log "Checking prerequisites..."
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "This script must be run as root (or with sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker compose is available (already detected above)
|
||||
log "Using Docker Compose command: $DOCKER_COMPOSE_CMD"
|
||||
|
||||
# Check if Nginx config exists
|
||||
if [ ! -f "$NGINX_CONF" ]; then
|
||||
error "Nginx config not found at $NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if DB_ROOT_PASSWORD is set
|
||||
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
|
||||
warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..."
|
||||
if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then
|
||||
source "${SCRIPT_DIR}/load-db-password.sh"
|
||||
else
|
||||
error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect which backend is currently active
|
||||
detect_active_backend
|
||||
|
||||
# Check if active backend is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
|
||||
error "Active backend container (${ACTIVE_CONTAINER}) is not running"
|
||||
error "Please start it first: docker-compose -f ${COMPOSE_FILE} up -d backend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "✅ Prerequisites check passed"
|
||||
log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}"
|
||||
log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}"
|
||||
}
|
||||
|
||||
# Build new backend image
|
||||
build_new_image() {
|
||||
log "Building new backend image..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Determine which service to build based on which container will be used
|
||||
# Both services use the same Dockerfile, but we need to build the correct one
|
||||
# to ensure the image cache is updated for the service that will be started
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
SERVICE_TO_BUILD="backend-new"
|
||||
else
|
||||
SERVICE_TO_BUILD="backend"
|
||||
fi
|
||||
|
||||
log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..."
|
||||
|
||||
# Build the image for the service that will be used
|
||||
# This ensures the correct service's image cache is updated with latest migrations
|
||||
if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
|
||||
log "✅ New backend image built successfully"
|
||||
else
|
||||
error "Failed to build new backend image"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
|
||||
log "✅ New backend image built successfully"
|
||||
else
|
||||
error "Failed to build new backend image"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Start new backend container
|
||||
start_new_container() {
|
||||
log "Starting new backend container on port ${STANDBY_PORT}..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Determine which service to start based on standby port
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
SERVICE_NAME="backend-new"
|
||||
CONTAINER_NAME="honey-backend-new"
|
||||
else
|
||||
SERVICE_NAME="backend"
|
||||
CONTAINER_NAME="honey-backend"
|
||||
fi
|
||||
|
||||
# Check if standby container exists (running or stopped)
|
||||
# We need to remove it to ensure a fresh start with migrations
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
warn "${CONTAINER_NAME} container is already running, stopping it first..."
|
||||
else
|
||||
warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..."
|
||||
fi
|
||||
if [ "$SERVICE_NAME" = "backend-new" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start the new container
|
||||
if [ "$SERVICE_NAME" = "backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then
|
||||
log "✅ New backend container started"
|
||||
else
|
||||
error "Failed to start new backend container"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then
|
||||
log "✅ New backend container started"
|
||||
else
|
||||
error "Failed to start new backend container"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for container to initialize (Spring Boot needs time to start)
|
||||
log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..."
|
||||
sleep 10
|
||||
|
||||
# Check if container is still running (might have crashed)
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:"
|
||||
error " docker logs ${CONTAINER_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Health check new container
|
||||
health_check_new_container() {
|
||||
log "Performing health check on new backend container (port ${STANDBY_PORT})..."
|
||||
|
||||
# First, check if container is still running
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
local container_name="honey-backend-new"
|
||||
else
|
||||
local container_name="honey-backend"
|
||||
fi
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||
error "Container ${container_name} is not running!"
|
||||
error "Check logs: docker logs ${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check container health status
|
||||
local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none")
|
||||
if [ "$health_status" != "none" ]; then
|
||||
info "Container health status: $health_status"
|
||||
fi
|
||||
|
||||
local retries=0
|
||||
while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do
|
||||
# Check if container is still running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||
error "Container ${container_name} stopped during health check!"
|
||||
error "Check logs: docker logs ${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Try health check
|
||||
if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then
|
||||
log "✅ New backend container is healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then
|
||||
# Show container status every 5 attempts
|
||||
if [ $((retries % 5)) -eq 0 ]; then
|
||||
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)"
|
||||
info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')"
|
||||
info "Last 5 log lines:"
|
||||
docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /'
|
||||
else
|
||||
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..."
|
||||
fi
|
||||
sleep $HEALTH_CHECK_INTERVAL
|
||||
fi
|
||||
done
|
||||
|
||||
error "Health check failed after $HEALTH_CHECK_RETRIES attempts"
|
||||
error "New backend container is not responding at $HEALTH_CHECK_URL"
|
||||
error ""
|
||||
error "Container status:"
|
||||
docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
|
||||
error ""
|
||||
error "Last 200 log lines:"
|
||||
docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /'
|
||||
error ""
|
||||
error "To debug, keep container running and check:"
|
||||
error " docker logs -f ${container_name}"
|
||||
error " docker logs --tail 500 ${container_name} # For even more logs"
|
||||
error " curl -v $HEALTH_CHECK_URL"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Update Nginx configuration
|
||||
update_nginx_config() {
|
||||
log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..."
|
||||
|
||||
# Backup current config
|
||||
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"
|
||||
log "Backed up Nginx config to: $NGINX_CONF_BACKUP"
|
||||
|
||||
# Use Python for reliable config manipulation
|
||||
# Pass variables directly to Python (not via sys.argv)
|
||||
python3 << PYTHON_SCRIPT
|
||||
import re
|
||||
import sys
|
||||
|
||||
config_file = "$NGINX_CONF"
|
||||
standby_port = "$STANDBY_PORT"
|
||||
active_port = "$ACTIVE_PORT"
|
||||
|
||||
try:
|
||||
# Read the entire file
|
||||
with open(config_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and update upstream block
|
||||
new_lines = []
|
||||
in_upstream = False
|
||||
upstream_start_idx = -1
|
||||
upstream_end_idx = -1
|
||||
keepalive_line = None
|
||||
keepalive_idx = -1
|
||||
|
||||
# First pass: find upstream block boundaries
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'^\s*upstream\s+backend\s*\{', line):
|
||||
upstream_start_idx = i
|
||||
in_upstream = True
|
||||
elif in_upstream and re.match(r'^\s*\}', line):
|
||||
upstream_end_idx = i
|
||||
break
|
||||
elif in_upstream and re.search(r'keepalive', line):
|
||||
keepalive_line = line
|
||||
keepalive_idx = i
|
||||
|
||||
if upstream_start_idx == -1 or upstream_end_idx == -1:
|
||||
raise Exception("Could not find upstream backend block")
|
||||
|
||||
# Build new lines
|
||||
for i, line in enumerate(lines):
|
||||
if i < upstream_start_idx:
|
||||
# Before upstream block - keep as is
|
||||
new_lines.append(line)
|
||||
elif i == upstream_start_idx:
|
||||
# Start of upstream block
|
||||
new_lines.append(line)
|
||||
elif i > upstream_start_idx and i < upstream_end_idx:
|
||||
# Inside upstream block
|
||||
# Skip old server lines
|
||||
if re.search(r'server\s+127\.0\.0\.1:808[02]', line):
|
||||
continue
|
||||
# Skip keepalive (we'll add it at the end)
|
||||
if re.search(r'keepalive', line):
|
||||
continue
|
||||
# Keep comments and other lines
|
||||
new_lines.append(line)
|
||||
elif i == upstream_end_idx:
|
||||
# Before closing brace - add server lines and keepalive
|
||||
new_lines.append(f" server 127.0.0.1:{standby_port};\n")
|
||||
new_lines.append(f" server 127.0.0.1:{active_port} backup;\n")
|
||||
if keepalive_line:
|
||||
new_lines.append(keepalive_line)
|
||||
else:
|
||||
new_lines.append(" keepalive 200;\n")
|
||||
new_lines.append(line)
|
||||
else:
|
||||
# After upstream block - keep as is
|
||||
new_lines.append(line)
|
||||
|
||||
# Write updated config
|
||||
with open(config_file, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
print("Nginx config updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating Nginx config: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
PYTHON_SCRIPT
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
error "Failed to update Nginx config"
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test Nginx configuration
|
||||
if nginx -t; then
|
||||
log "✅ Nginx configuration is valid"
|
||||
else
|
||||
error "Nginx configuration test failed, restoring backup..."
|
||||
error "Error details:"
|
||||
nginx -t 2>&1 | sed 's/^/ /'
|
||||
error ""
|
||||
error "Current config (first 50 lines):"
|
||||
head -50 "$NGINX_CONF" | sed 's/^/ /'
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reload Nginx (zero downtime)
|
||||
reload_nginx() {
|
||||
log "Reloading Nginx (zero downtime)..."
|
||||
|
||||
if systemctl reload nginx; then
|
||||
log "✅ Nginx reloaded successfully"
|
||||
log "✅ Traffic is now being served by new backend (port 8082)"
|
||||
else
|
||||
error "Failed to reload Nginx, restoring backup config..."
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
systemctl reload nginx
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop old container after grace period
|
||||
stop_old_container() {
|
||||
log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..."
|
||||
sleep $GRACE_PERIOD
|
||||
|
||||
log "Stopping old backend container (${ACTIVE_CONTAINER})..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then
|
||||
log "✅ Old backend container stopped"
|
||||
else
|
||||
warn "Failed to stop old backend container gracefully"
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then
|
||||
log "✅ Old backend container stopped"
|
||||
else
|
||||
warn "Failed to stop old backend container gracefully"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optionally remove the old container (comment out if you want to keep it for rollback)
|
||||
# if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
# docker-compose -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new
|
||||
# else
|
||||
# docker-compose -f "$COMPOSE_FILE" rm -f backend
|
||||
# fi
|
||||
}
|
||||
|
||||
# Rollback function
|
||||
rollback() {
|
||||
error "Rolling back to previous version..."
|
||||
|
||||
# Check KEEP_FAILED_CONTAINER (check both current env and script-level variable)
|
||||
local keep_container="${KEEP_FAILED_CONTAINER:-false}"
|
||||
if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then
|
||||
keep_container="true"
|
||||
fi
|
||||
|
||||
# Restore Nginx config
|
||||
if [ -f "$NGINX_CONF_BACKUP" ]; then
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
systemctl reload nginx
|
||||
log "✅ Nginx config restored"
|
||||
fi
|
||||
|
||||
# Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set)
|
||||
cd "$PROJECT_DIR"
|
||||
if [ "$keep_container" = "true" ]; then
|
||||
warn ""
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging"
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
|
||||
warn ""
|
||||
warn "Container 'honey-backend-new' is STOPPED but NOT REMOVED"
|
||||
warn ""
|
||||
warn "To check logs:"
|
||||
warn " docker logs honey-backend-new"
|
||||
warn " docker logs --tail 100 honey-backend-new"
|
||||
warn ""
|
||||
warn "To remove manually:"
|
||||
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new"
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
|
||||
warn ""
|
||||
warn "Container 'honey-backend' is STOPPED but NOT REMOVED"
|
||||
warn ""
|
||||
warn "To check logs:"
|
||||
warn " docker logs honey-backend"
|
||||
warn " docker logs --tail 100 honey-backend"
|
||||
warn ""
|
||||
warn "To remove manually:"
|
||||
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend"
|
||||
fi
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
else
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start old container if it was stopped
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend
|
||||
fi
|
||||
fi
|
||||
|
||||
error "Rollback completed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Main deployment flow
|
||||
main() {
|
||||
log "Starting rolling update deployment..."
|
||||
|
||||
# Trap errors for rollback
|
||||
trap rollback ERR
|
||||
|
||||
check_prerequisites
|
||||
build_new_image
|
||||
start_new_container
|
||||
|
||||
if ! health_check_new_container; then
|
||||
rollback
|
||||
fi
|
||||
|
||||
update_nginx_config
|
||||
reload_nginx
|
||||
|
||||
# Clear error trap after successful switch
|
||||
trap - ERR
|
||||
|
||||
stop_old_container
|
||||
|
||||
log "✅ Rolling update completed successfully!"
|
||||
log ""
|
||||
log "Summary:"
|
||||
log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})"
|
||||
log " - Nginx is serving traffic from new backend"
|
||||
log " - Old backend (${ACTIVE_CONTAINER}) has been stopped"
|
||||
log ""
|
||||
log "To rollback (if needed):"
|
||||
log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF"
|
||||
log " 2. Reload Nginx: systemctl reload nginx"
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE --profile rolling-update start backend-new"
|
||||
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE stop backend"
|
||||
else
|
||||
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE start backend"
|
||||
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE --profile rolling-update stop backend-new"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
|
||||
622
scripts/rolling-update.staged.sh
Normal file
622
scripts/rolling-update.staged.sh
Normal file
@@ -0,0 +1,622 @@
|
||||
#!/bin/bash
|
||||
# Rolling Update Deployment Script (staged / 8GB VPS)
|
||||
# Same as rolling-update.sh but uses docker-compose.staged.yml (lower memory limits).
|
||||
# This script performs zero-downtime deployment by:
|
||||
# 1. Building new backend image
|
||||
# 2. Starting new backend container on port 8082
|
||||
# 3. Health checking the new container
|
||||
# 4. Updating Nginx to point to new container
|
||||
# 5. Reloading Nginx (zero downtime)
|
||||
# 6. Stopping old container after grace period
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors (define early for use in config detection)
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Logging functions (define early)
|
||||
log() {
|
||||
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
# Configuration (staged: use docker-compose.staged.yml)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.staged.yml"
|
||||
|
||||
# Detect Nginx config file (try common locations)
|
||||
# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths
|
||||
NGINX_CONF="${NGINX_CONF:-}"
|
||||
if [ -z "$NGINX_CONF" ]; then
|
||||
if [ -f "/etc/nginx/sites-enabled/testforapp.website" ]; then
|
||||
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website"
|
||||
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
|
||||
elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
|
||||
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf"
|
||||
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
|
||||
elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then
|
||||
NGINX_CONF="/etc/nginx/conf.d/honey.conf"
|
||||
log "Using Nginx config: $NGINX_CONF (conf.d)"
|
||||
elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then
|
||||
warn "Found config at /opt/app/nginx/testforapp.website.conf"
|
||||
warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..."
|
||||
if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
|
||||
# Find the actual target
|
||||
local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.conf 2>/dev/null)
|
||||
if [ -n "$target" ]; then
|
||||
NGINX_CONF="$target"
|
||||
log "Using Nginx config: $NGINX_CONF (symlink target)"
|
||||
else
|
||||
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
|
||||
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
|
||||
fi
|
||||
else
|
||||
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
|
||||
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
|
||||
fi
|
||||
else
|
||||
error "Cannot find Nginx config file."
|
||||
error "Searched:"
|
||||
error " - /etc/nginx/sites-enabled/testforapp.website"
|
||||
error " - /etc/nginx/sites-enabled/testforapp.website.conf"
|
||||
error " - /etc/nginx/conf.d/honey.conf"
|
||||
error " - /opt/app/nginx/testforapp.website.conf"
|
||||
error ""
|
||||
error "Please set NGINX_CONF environment variable with the correct path."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)"
|
||||
fi
|
||||
|
||||
# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files)
|
||||
NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# Ports for backends (will be swapped dynamically)
|
||||
PRIMARY_PORT=8080
|
||||
STANDBY_PORT=8082
|
||||
|
||||
# Detect which backend is currently active
|
||||
detect_active_backend() {
|
||||
# Check which port Nginx is currently using in upstream block
|
||||
# Look for server line that is NOT marked as backup
|
||||
local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1)
|
||||
|
||||
if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then
|
||||
# Port 8082 is active (not backup)
|
||||
ACTIVE_PORT=8082
|
||||
STANDBY_PORT=8080
|
||||
ACTIVE_CONTAINER="honey-backend-new"
|
||||
STANDBY_CONTAINER="honey-backend"
|
||||
log "Detected: Port 8082 is currently active"
|
||||
else
|
||||
# Port 8080 is active (default or only one present)
|
||||
ACTIVE_PORT=8080
|
||||
STANDBY_PORT=8082
|
||||
ACTIVE_CONTAINER="honey-backend"
|
||||
STANDBY_CONTAINER="honey-backend-new"
|
||||
log "Detected: Port 8080 is currently active"
|
||||
fi
|
||||
|
||||
PRIMARY_PORT=$ACTIVE_PORT
|
||||
HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness"
|
||||
}
|
||||
|
||||
HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max)
|
||||
HEALTH_CHECK_INTERVAL=2
|
||||
GRACE_PERIOD=10
|
||||
|
||||
# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback)
|
||||
# This allows keeping failed containers for debugging even when using sudo
|
||||
if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then
|
||||
SCRIPT_KEEP_FAILED_CONTAINER="true"
|
||||
export SCRIPT_KEEP_FAILED_CONTAINER
|
||||
log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging"
|
||||
fi
|
||||
|
||||
# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose')
|
||||
DOCKER_COMPOSE_CMD=""
|
||||
if docker compose version &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
error "Neither 'docker compose' nor 'docker-compose' is available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check prerequisites
|
||||
check_prerequisites() {
|
||||
log "Checking prerequisites..."
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "This script must be run as root (or with sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker compose is available (already detected above)
|
||||
log "Using Docker Compose command: $DOCKER_COMPOSE_CMD"
|
||||
log "Using compose file: $COMPOSE_FILE (staged)"
|
||||
|
||||
# Check if Nginx config exists
|
||||
if [ ! -f "$NGINX_CONF" ]; then
|
||||
error "Nginx config not found at $NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if DB_ROOT_PASSWORD is set
|
||||
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
|
||||
warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..."
|
||||
if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then
|
||||
source "${SCRIPT_DIR}/load-db-password.sh"
|
||||
else
|
||||
error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect which backend is currently active
|
||||
detect_active_backend
|
||||
|
||||
# Check if active backend is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
|
||||
error "Active backend container (${ACTIVE_CONTAINER}) is not running"
|
||||
error "Please start it first: docker compose -f ${COMPOSE_FILE} up -d backend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "✅ Prerequisites check passed"
|
||||
log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}"
|
||||
log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}"
|
||||
}
|
||||
|
||||
# Build new backend image
|
||||
build_new_image() {
|
||||
log "Building new backend image..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Determine which service to build based on which container will be used
|
||||
# Both services use the same Dockerfile, but we need to build the correct one
|
||||
# to ensure the image cache is updated for the service that will be started
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
SERVICE_TO_BUILD="backend-new"
|
||||
else
|
||||
SERVICE_TO_BUILD="backend"
|
||||
fi
|
||||
|
||||
log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..."
|
||||
|
||||
# Build the image for the service that will be used
|
||||
# This ensures the correct service's image cache is updated with latest migrations
|
||||
if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
|
||||
log "✅ New backend image built successfully"
|
||||
else
|
||||
error "Failed to build new backend image"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
|
||||
log "✅ New backend image built successfully"
|
||||
else
|
||||
error "Failed to build new backend image"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Start new backend container
|
||||
start_new_container() {
|
||||
log "Starting new backend container on port ${STANDBY_PORT}..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Determine which service to start based on standby port
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
SERVICE_NAME="backend-new"
|
||||
CONTAINER_NAME="honey-backend-new"
|
||||
else
|
||||
SERVICE_NAME="backend"
|
||||
CONTAINER_NAME="honey-backend"
|
||||
fi
|
||||
|
||||
# Check if standby container exists (running or stopped)
|
||||
# We need to remove it to ensure a fresh start with migrations
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
warn "${CONTAINER_NAME} container is already running, stopping it first..."
|
||||
else
|
||||
warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..."
|
||||
fi
|
||||
if [ "$SERVICE_NAME" = "backend-new" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start the new container
|
||||
if [ "$SERVICE_NAME" = "backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then
|
||||
log "✅ New backend container started"
|
||||
else
|
||||
error "Failed to start new backend container"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then
|
||||
log "✅ New backend container started"
|
||||
else
|
||||
error "Failed to start new backend container"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for container to initialize (Spring Boot needs time to start)
|
||||
log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..."
|
||||
sleep 10
|
||||
|
||||
# Check if container is still running (might have crashed)
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:"
|
||||
error " docker logs ${CONTAINER_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Health check new container
|
||||
health_check_new_container() {
|
||||
log "Performing health check on new backend container (port ${STANDBY_PORT})..."
|
||||
|
||||
# First, check if container is still running
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
local container_name="honey-backend-new"
|
||||
else
|
||||
local container_name="honey-backend"
|
||||
fi
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||
error "Container ${container_name} is not running!"
|
||||
error "Check logs: docker logs ${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check container health status
|
||||
local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none")
|
||||
if [ "$health_status" != "none" ]; then
|
||||
info "Container health status: $health_status"
|
||||
fi
|
||||
|
||||
local retries=0
|
||||
while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do
|
||||
# Check if container is still running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||
error "Container ${container_name} stopped during health check!"
|
||||
error "Check logs: docker logs ${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Try health check
|
||||
if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then
|
||||
log "✅ New backend container is healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
retries=$((retries + 1))
|
||||
if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then
|
||||
# Show container status every 5 attempts
|
||||
if [ $((retries % 5)) -eq 0 ]; then
|
||||
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)"
|
||||
info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')"
|
||||
info "Last 5 log lines:"
|
||||
docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /'
|
||||
else
|
||||
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..."
|
||||
fi
|
||||
sleep $HEALTH_CHECK_INTERVAL
|
||||
fi
|
||||
done
|
||||
|
||||
error "Health check failed after $HEALTH_CHECK_RETRIES attempts"
|
||||
error "New backend container is not responding at $HEALTH_CHECK_URL"
|
||||
error ""
|
||||
error "Container status:"
|
||||
docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
|
||||
error ""
|
||||
error "Last 200 log lines:"
|
||||
docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /'
|
||||
error ""
|
||||
error "To debug, keep container running and check:"
|
||||
error " docker logs -f ${container_name}"
|
||||
error " docker logs --tail 500 ${container_name} # For even more logs"
|
||||
error " curl -v $HEALTH_CHECK_URL"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Update Nginx configuration
|
||||
update_nginx_config() {
|
||||
log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..."
|
||||
|
||||
# Backup current config
|
||||
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"
|
||||
log "Backed up Nginx config to: $NGINX_CONF_BACKUP"
|
||||
|
||||
# Use Python for reliable config manipulation
|
||||
# Pass variables directly to Python (not via sys.argv)
|
||||
python3 << PYTHON_SCRIPT
|
||||
import re
|
||||
import sys
|
||||
|
||||
config_file = "$NGINX_CONF"
|
||||
standby_port = "$STANDBY_PORT"
|
||||
active_port = "$ACTIVE_PORT"
|
||||
|
||||
try:
|
||||
# Read the entire file
|
||||
with open(config_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and update upstream block
|
||||
new_lines = []
|
||||
in_upstream = False
|
||||
upstream_start_idx = -1
|
||||
upstream_end_idx = -1
|
||||
keepalive_line = None
|
||||
keepalive_idx = -1
|
||||
|
||||
# First pass: find upstream block boundaries
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'^\s*upstream\s+backend\s*\{', line):
|
||||
upstream_start_idx = i
|
||||
in_upstream = True
|
||||
elif in_upstream and re.match(r'^\s*\}', line):
|
||||
upstream_end_idx = i
|
||||
break
|
||||
elif in_upstream and re.search(r'keepalive', line):
|
||||
keepalive_line = line
|
||||
keepalive_idx = i
|
||||
|
||||
if upstream_start_idx == -1 or upstream_end_idx == -1:
|
||||
raise Exception("Could not find upstream backend block")
|
||||
|
||||
# Build new lines
|
||||
for i, line in enumerate(lines):
|
||||
if i < upstream_start_idx:
|
||||
# Before upstream block - keep as is
|
||||
new_lines.append(line)
|
||||
elif i == upstream_start_idx:
|
||||
# Start of upstream block
|
||||
new_lines.append(line)
|
||||
elif i > upstream_start_idx and i < upstream_end_idx:
|
||||
# Inside upstream block
|
||||
# Skip old server lines
|
||||
if re.search(r'server\s+127\.0\.0\.1:808[02]', line):
|
||||
continue
|
||||
# Skip keepalive (we'll add it at the end)
|
||||
if re.search(r'keepalive', line):
|
||||
continue
|
||||
# Keep comments and other lines
|
||||
new_lines.append(line)
|
||||
elif i == upstream_end_idx:
|
||||
# Before closing brace - add server lines and keepalive
|
||||
new_lines.append(f" server 127.0.0.1:{standby_port};\n")
|
||||
new_lines.append(f" server 127.0.0.1:{active_port} backup;\n")
|
||||
if keepalive_line:
|
||||
new_lines.append(keepalive_line)
|
||||
else:
|
||||
new_lines.append(" keepalive 200;\n")
|
||||
new_lines.append(line)
|
||||
else:
|
||||
# After upstream block - keep as is
|
||||
new_lines.append(line)
|
||||
|
||||
# Write updated config
|
||||
with open(config_file, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
print("Nginx config updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating Nginx config: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
PYTHON_SCRIPT
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
error "Failed to update Nginx config"
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test Nginx configuration
|
||||
if nginx -t; then
|
||||
log "✅ Nginx configuration is valid"
|
||||
else
|
||||
error "Nginx configuration test failed, restoring backup..."
|
||||
error "Error details:"
|
||||
nginx -t 2>&1 | sed 's/^/ /'
|
||||
error ""
|
||||
error "Current config (first 50 lines):"
|
||||
head -50 "$NGINX_CONF" | sed 's/^/ /'
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reload Nginx (zero downtime)
|
||||
reload_nginx() {
|
||||
log "Reloading Nginx (zero downtime)..."
|
||||
|
||||
if systemctl reload nginx; then
|
||||
log "✅ Nginx reloaded successfully"
|
||||
log "✅ Traffic is now being served by new backend (port 8082)"
|
||||
else
|
||||
error "Failed to reload Nginx, restoring backup config..."
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
systemctl reload nginx
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop old container after grace period
|
||||
stop_old_container() {
|
||||
log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..."
|
||||
sleep $GRACE_PERIOD
|
||||
|
||||
log "Stopping old backend container (${ACTIVE_CONTAINER})..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then
|
||||
log "✅ Old backend container stopped"
|
||||
else
|
||||
warn "Failed to stop old backend container gracefully"
|
||||
fi
|
||||
else
|
||||
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then
|
||||
log "✅ Old backend container stopped"
|
||||
else
|
||||
warn "Failed to stop old backend container gracefully"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Rollback function
|
||||
rollback() {
|
||||
error "Rolling back to previous version..."
|
||||
|
||||
# Check KEEP_FAILED_CONTAINER (check both current env and script-level variable)
|
||||
local keep_container="${KEEP_FAILED_CONTAINER:-false}"
|
||||
if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then
|
||||
keep_container="true"
|
||||
fi
|
||||
|
||||
# Restore Nginx config
|
||||
if [ -f "$NGINX_CONF_BACKUP" ]; then
|
||||
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
|
||||
systemctl reload nginx
|
||||
log "✅ Nginx config restored"
|
||||
fi
|
||||
|
||||
# Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set)
|
||||
cd "$PROJECT_DIR"
|
||||
if [ "$keep_container" = "true" ]; then
|
||||
warn ""
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging"
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
|
||||
warn ""
|
||||
warn "Container 'honey-backend-new' is STOPPED but NOT REMOVED"
|
||||
warn ""
|
||||
warn "To check logs:"
|
||||
warn " docker logs honey-backend-new"
|
||||
warn " docker logs --tail 100 honey-backend-new"
|
||||
warn ""
|
||||
warn "To remove manually:"
|
||||
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new"
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
|
||||
warn ""
|
||||
warn "Container 'honey-backend' is STOPPED but NOT REMOVED"
|
||||
warn ""
|
||||
warn "To check logs:"
|
||||
warn " docker logs honey-backend"
|
||||
warn " docker logs --tail 100 honey-backend"
|
||||
warn ""
|
||||
warn "To remove manually:"
|
||||
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend"
|
||||
fi
|
||||
warn "═══════════════════════════════════════════════════════════════"
|
||||
else
|
||||
if [ "$STANDBY_PORT" = "8082" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start old container if it was stopped
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new
|
||||
else
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend
|
||||
fi
|
||||
fi
|
||||
|
||||
error "Rollback completed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Main deployment flow
|
||||
main() {
|
||||
log "Starting rolling update deployment (staged)..."
|
||||
|
||||
# Trap errors for rollback
|
||||
trap rollback ERR
|
||||
|
||||
check_prerequisites
|
||||
build_new_image
|
||||
start_new_container
|
||||
|
||||
if ! health_check_new_container; then
|
||||
rollback
|
||||
fi
|
||||
|
||||
update_nginx_config
|
||||
reload_nginx
|
||||
|
||||
# Clear error trap after successful switch
|
||||
trap - ERR
|
||||
|
||||
stop_old_container
|
||||
|
||||
log "✅ Rolling update completed successfully!"
|
||||
log ""
|
||||
log "Summary:"
|
||||
log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})"
|
||||
log " - Nginx is serving traffic from new backend"
|
||||
log " - Old backend (${ACTIVE_CONTAINER}) has been stopped"
|
||||
log ""
|
||||
log "To rollback (if needed):"
|
||||
log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF"
|
||||
log " 2. Reload Nginx: systemctl reload nginx"
|
||||
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
|
||||
log " 3. Start old backend: docker compose -f $COMPOSE_FILE --profile rolling-update start backend-new"
|
||||
log " 4. Stop new backend: docker compose -f $COMPOSE_FILE stop backend"
|
||||
else
|
||||
log " 3. Start old backend: docker compose -f $COMPOSE_FILE start backend"
|
||||
log " 4. Stop new backend: docker compose -f $COMPOSE_FILE --profile rolling-update stop backend-new"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
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/honey-be" ]; then
|
||||
CONFIG_DIR="/opt/app/backend/honey-be/config"
|
||||
LOG_DIR="/opt/app/logs"
|
||||
else
|
||||
# Try to find from current directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CONFIG_DIR="$BACKEND_DIR/config"
|
||||
LOG_DIR="/opt/app/logs"
|
||||
fi
|
||||
|
||||
echo "Setting up external logging configuration..."
|
||||
echo "Config directory: $CONFIG_DIR"
|
||||
echo "Log directory: $LOG_DIR"
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
|
||||
# Create log directory if it doesn't exist
|
||||
mkdir -p "$LOG_DIR"
|
||||
chmod 755 "$LOG_DIR"
|
||||
|
||||
# Extract logback-spring.xml from JAR if it doesn't exist
|
||||
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
|
||||
echo "Extracting logback-spring.xml from JAR..."
|
||||
|
||||
# Try multiple locations for JAR file
|
||||
JAR_PATH=""
|
||||
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")" "$(dirname "$(dirname "$CONFIG_DIR")")"; do
|
||||
if [ -d "$search_path" ]; then
|
||||
found_jar=$(find "$search_path" -name "honey-be-*.jar" -type f 2>/dev/null | head -n 1)
|
||||
if [ -n "$found_jar" ]; then
|
||||
JAR_PATH="$found_jar"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Try to find in target directory
|
||||
if [ -z "$JAR_PATH" ]; then
|
||||
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
|
||||
if [ -d "$search_path/target" ]; then
|
||||
found_jar=$(find "$search_path/target" -name "*.jar" -type f | head -n 1)
|
||||
if [ -n "$found_jar" ]; then
|
||||
JAR_PATH="$found_jar"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$JAR_PATH" ]; then
|
||||
echo "Warning: JAR file not found. Trying to copy from source..."
|
||||
# If JAR not found, copy from source (if available)
|
||||
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
|
||||
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
|
||||
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
|
||||
echo "Copied from source: $search_path/src/main/resources/logback-spring.xml"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
|
||||
echo "Error: Cannot find logback-spring.xml in JAR or source."
|
||||
echo "Please ensure the file exists or copy it manually to: $CONFIG_DIR/logback-spring.xml"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Found JAR: $JAR_PATH"
|
||||
# Extract from JAR
|
||||
unzip -p "$JAR_PATH" BOOT-INF/classes/logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || \
|
||||
unzip -p "$JAR_PATH" logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || {
|
||||
echo "Warning: Could not extract from JAR. Trying to copy from source..."
|
||||
# Try copying from source
|
||||
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
|
||||
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
|
||||
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
|
||||
echo "Error: Cannot extract or find logback-spring.xml."
|
||||
echo "Please copy it manually to: $CONFIG_DIR/logback-spring.xml"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
echo "Extracted from JAR: $JAR_PATH"
|
||||
fi
|
||||
|
||||
echo "logback-spring.xml created at $CONFIG_DIR/logback-spring.xml"
|
||||
else
|
||||
echo "logback-spring.xml already exists at $CONFIG_DIR/logback-spring.xml"
|
||||
fi
|
||||
|
||||
# Set proper permissions
|
||||
chmod 644 "$CONFIG_DIR/logback-spring.xml"
|
||||
chown $USER:$USER "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || true
|
||||
|
||||
echo "Logging configuration setup complete!"
|
||||
echo ""
|
||||
echo "Configuration file: $CONFIG_DIR/logback-spring.xml"
|
||||
echo "Log directory: $LOG_DIR"
|
||||
echo ""
|
||||
echo "You can now edit $CONFIG_DIR/logback-spring.xml to change log levels at runtime."
|
||||
echo "Changes will take effect within 30 seconds (no restart needed)."
|
||||
|
||||
22
src/main/java/com/honey/honey/HoneyBackendApplication.java
Normal file
22
src/main/java/com/honey/honey/HoneyBackendApplication.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.honey.honey;
|
||||
|
||||
import com.honey.honey.config.ConfigLoader;
|
||||
import com.honey.honey.config.TelegramProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableAsync
|
||||
@EnableConfigurationProperties({TelegramProperties.class})
|
||||
public class HoneyBackendApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication app = new SpringApplication(HoneyBackendApplication.class);
|
||||
app.addListeners(new ConfigLoader());
|
||||
app.run(args);
|
||||
}
|
||||
}
|
||||
|
||||
138
src/main/java/com/honey/honey/config/AdminSecurityConfig.java
Normal file
138
src/main/java/com/honey/honey/config/AdminSecurityConfig.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import com.honey.honey.security.admin.AdminDetailsService;
|
||||
import com.honey.honey.security.admin.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class AdminSecurityConfig {
|
||||
|
||||
@Value("${FRONTEND_URL:}")
|
||||
private String frontendUrl;
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final AdminDetailsService adminDetailsService;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DaoAuthenticationProvider adminAuthenticationProvider() {
|
||||
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
||||
authProvider.setUserDetailsService(adminDetailsService);
|
||||
authProvider.setPasswordEncoder(passwordEncoder());
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager adminAuthenticationManager() {
|
||||
return new ProviderManager(adminAuthenticationProvider());
|
||||
}
|
||||
|
||||
/**
|
||||
* Swagger/OpenAPI docs: permitAll with highest precedence so the default Spring Boot chain
|
||||
* (which requires auth for /**) never handles these paths. Includes webjars and resources
|
||||
* so the UI can load CSS/JS.
|
||||
*/
|
||||
@Bean
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
RequestMatcher swaggerMatcher = new OrRequestMatcher(
|
||||
new AntPathRequestMatcher("/swagger-ui/**"),
|
||||
new AntPathRequestMatcher("/swagger-ui.html"),
|
||||
new AntPathRequestMatcher("/v3/api-docs"),
|
||||
new AntPathRequestMatcher("/v3/api-docs/**"),
|
||||
new AntPathRequestMatcher("/webjars/**"),
|
||||
new AntPathRequestMatcher("/swagger-resources/**"),
|
||||
new AntPathRequestMatcher("/configuration/**")
|
||||
);
|
||||
http
|
||||
.securityMatcher(swaggerMatcher)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/api/admin/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/admin/login").permitAll()
|
||||
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
List<String> allowedOrigins = Stream.concat(
|
||||
Stream.of(
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000"
|
||||
),
|
||||
frontendUrl != null && !frontendUrl.isBlank()
|
||||
? Arrays.stream(frontendUrl.split("\\s*,\\s*")).filter(s -> !s.isBlank())
|
||||
: Stream.empty()
|
||||
).distinct().collect(Collectors.toList());
|
||||
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/admin/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
82
src/main/java/com/honey/honey/config/ConfigLoader.java
Normal file
82
src/main/java/com/honey/honey/config/ConfigLoader.java
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.MutablePropertySources;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Loads configuration from a mounted secret file (tmpfs) with fallback to environment variables.
|
||||
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno)
|
||||
* 2. Environment variables (Railway)
|
||||
*/
|
||||
@Slf4j
|
||||
public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
|
||||
|
||||
private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties";
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
|
||||
ConfigurableEnvironment environment = event.getEnvironment();
|
||||
MutablePropertySources propertySources = environment.getPropertySources();
|
||||
|
||||
Map<String, Object> configProperties = new HashMap<>();
|
||||
|
||||
// Try to load from mounted file first (Inferno deployment)
|
||||
File secretFile = new File(SECRET_FILE_PATH);
|
||||
if (secretFile.exists() && secretFile.isFile() && secretFile.canRead()) {
|
||||
log.info("📁 Loading configuration from mounted secret file: {}", SECRET_FILE_PATH);
|
||||
try {
|
||||
Properties props = new Properties();
|
||||
try (FileInputStream fis = new FileInputStream(secretFile)) {
|
||||
props.load(fis);
|
||||
}
|
||||
|
||||
for (String key : props.stringPropertyNames()) {
|
||||
String value = props.getProperty(key);
|
||||
configProperties.put(key, value);
|
||||
log.debug("Loaded from file: {} = {}", key, maskSensitiveValue(key, value));
|
||||
}
|
||||
log.info("✅ Successfully loaded {} properties from secret file", configProperties.size());
|
||||
} catch (IOException e) {
|
||||
log.warn("⚠️ Failed to read secret file, falling back to environment variables: {}", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
log.info("📝 Secret file not found at {}, using environment variables", SECRET_FILE_PATH);
|
||||
}
|
||||
|
||||
// Environment variables are already loaded by Spring Boot by default
|
||||
// We just add file-based config as a higher priority source if it exists
|
||||
if (!configProperties.isEmpty()) {
|
||||
propertySources.addFirst(new MapPropertySource("secretFileConfig", configProperties));
|
||||
log.info("✅ Configuration loaded: {} properties from file, environment variables as fallback",
|
||||
configProperties.size());
|
||||
} else {
|
||||
log.info("✅ Using environment variables for configuration");
|
||||
}
|
||||
}
|
||||
|
||||
private String maskSensitiveValue(String key, String value) {
|
||||
if (value == null) return "null";
|
||||
if (key.toLowerCase().contains("password") ||
|
||||
key.toLowerCase().contains("token") ||
|
||||
key.toLowerCase().contains("secret") ||
|
||||
key.toLowerCase().contains("key")) {
|
||||
return value.length() > 4 ? value.substring(0, 2) + "***" + value.substring(value.length() - 2) : "***";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/main/java/com/honey/honey/config/CorsConfig.java
Normal file
53
src/main/java/com/honey/honey/config/CorsConfig.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Value("${FRONTEND_URL:}")
|
||||
private String frontendUrl;
|
||||
|
||||
private static final List<String> ADDITIONAL_ORIGINS = Arrays.asList(
|
||||
"https://honey-test-fe-production.up.railway.app",
|
||||
"https://web.telegram.org",
|
||||
"https://webk.telegram.org",
|
||||
"https://t.me"
|
||||
);
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
List<String> origins = Stream.concat(
|
||||
Arrays.stream(frontendUrl != null && !frontendUrl.isBlank()
|
||||
? frontendUrl.split("\\s*,\\s*")
|
||||
: new String[]{}),
|
||||
ADDITIONAL_ORIGINS.stream()
|
||||
).filter(s -> s != null && !s.isBlank()).distinct().collect(Collectors.toList());
|
||||
|
||||
if (origins.isEmpty()) {
|
||||
origins = ADDITIONAL_ORIGINS;
|
||||
}
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(origins.toArray(new String[0]))
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
61
src/main/java/com/honey/honey/config/LocaleConfig.java
Normal file
61
src/main/java/com/honey/honey/config/LocaleConfig.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Configuration
|
||||
public class LocaleConfig {
|
||||
|
||||
// Supported languages
|
||||
public static final List<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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
src/main/java/com/honey/honey/config/OpenApiConfig.java
Normal file
33
src/main/java/com/honey/honey/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
|
||||
/**
|
||||
* OpenAPI / Swagger configuration for the public API only.
|
||||
* Admin endpoints (/api/admin/**) are excluded from the documentation.
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi publicApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("public")
|
||||
.pathsToMatch("/**")
|
||||
.pathsToExclude("/api/admin/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OpenAPI honeyOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("Honey Public API")
|
||||
.description("API for the Honey frontend. Admin panel endpoints are not included.")
|
||||
.version("1.0"));
|
||||
}
|
||||
}
|
||||
35
src/main/java/com/honey/honey/config/TelegramProperties.java
Normal file
35
src/main/java/com/honey/honey/config/TelegramProperties.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "telegram")
|
||||
@Data
|
||||
public class TelegramProperties {
|
||||
private String botToken;
|
||||
|
||||
/**
|
||||
* Bot token for checking channel membership.
|
||||
* Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN
|
||||
* or in mounted file at /run/secrets/honey-config.properties as telegram.channel-checker-bot-token
|
||||
*/
|
||||
private String channelCheckerBotToken;
|
||||
|
||||
/**
|
||||
* Channel ID for follow tasks (e.g., "@win_spin_news" or numeric ID).
|
||||
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID
|
||||
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id
|
||||
*/
|
||||
private String followTaskChannelId;
|
||||
|
||||
/**
|
||||
* Channel ID for follow withdrawals channel task (e.g., "@win_spin_withdrawals" or numeric ID).
|
||||
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2
|
||||
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id-2
|
||||
*/
|
||||
private String followTaskChannelId2;
|
||||
}
|
||||
|
||||
|
||||
51
src/main/java/com/honey/honey/config/WebConfig.java
Normal file
51
src/main/java/com/honey/honey/config/WebConfig.java
Normal file
@@ -0,0 +1,51 @@
|
||||
package com.honey.honey.config;
|
||||
|
||||
import com.honey.honey.security.AuthInterceptor;
|
||||
import com.honey.honey.security.RateLimitInterceptor;
|
||||
import com.honey.honey.security.UserRateLimitInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
private final RateLimitInterceptor rateLimitInterceptor;
|
||||
private final UserRateLimitInterceptor userRateLimitInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
|
||||
// NOTE: Rate limiting is NOT applied to Telegram webhook endpoint
|
||||
// Telegram sends webhooks from multiple IPs and we need to process all updates, especially payments
|
||||
// Rate limiting interceptor is only for bot registration endpoint (if needed elsewhere)
|
||||
|
||||
// User session interceptor for all other authenticated endpoints
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.excludePathPatterns(
|
||||
"/ping",
|
||||
"/actuator/**",
|
||||
"/api/auth/tma/session", // Session creation endpoint doesn't require auth
|
||||
"/api/telegram/webhook/**", // Telegram webhook (token in path, validated in controller)
|
||||
"/avatars/**", // Avatar static files don't require auth (served by Nginx in production)
|
||||
"/api/check_user/**", // User check endpoint for external applications (open endpoint)
|
||||
"/api/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth)
|
||||
"/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth)
|
||||
"/api/admin/**", // Admin endpoints are handled by Spring Security
|
||||
// Swagger / OpenAPI docs (no auth required for documentation)
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/v3/api-docs",
|
||||
"/v3/api-docs/**",
|
||||
"/webjars/**",
|
||||
"/swagger-resources/**",
|
||||
"/configuration/**"
|
||||
);
|
||||
|
||||
// User-based rate limiting for payment creation and payout creation (applied after auth interceptor)
|
||||
registry.addInterceptor(userRateLimitInterceptor)
|
||||
.addPathPatterns("/api/payments/create", "/api/payouts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.model.Payment;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.PayoutRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/analytics")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminAnalyticsController {
|
||||
|
||||
private final UserARepository userARepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final PayoutRepository payoutRepository;
|
||||
|
||||
/**
|
||||
* Get revenue and payout time series data for charts.
|
||||
* @param range Time range: 7d, 30d, 90d, 1y, all
|
||||
* @return Time series data with daily/weekly/monthly aggregation
|
||||
*/
|
||||
@GetMapping("/revenue")
|
||||
public ResponseEntity<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);
|
||||
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.getEpochSecond());
|
||||
point.put("newUsers", newUsers);
|
||||
point.put("activePlayers", activePlayers);
|
||||
point.put("rounds", 0L);
|
||||
|
||||
dataPoints.add(point);
|
||||
|
||||
current = periodEnd;
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("range", range);
|
||||
response.put("granularity", granularity);
|
||||
response.put("data", dataPoints);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.model.Payment;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.model.SupportTicket;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.PayoutRepository;
|
||||
import com.honey.honey.repository.SupportTicketRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/dashboard")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminDashboardController {
|
||||
|
||||
private final UserARepository userARepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final PayoutRepository payoutRepository;
|
||||
private final SupportTicketRepository supportTicketRepository;
|
||||
|
||||
@GetMapping("/stats")
|
||||
public ResponseEntity<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);
|
||||
|
||||
// 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", 0L,
|
||||
"today", 0L,
|
||||
"week", 0L,
|
||||
"month", 0L,
|
||||
"avgPool", 0
|
||||
));
|
||||
|
||||
stats.put("supportTickets", Map.of(
|
||||
"open", openTickets,
|
||||
"resolvedToday", ticketsResolvedToday
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.service.FeatureSwitchService;
|
||||
import com.honey.honey.service.FeatureSwitchService.FeatureSwitchDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/feature-switches")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminFeatureSwitchController {
|
||||
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.AdminLoginRequest;
|
||||
import com.honey.honey.dto.AdminLoginResponse;
|
||||
import com.honey.honey.service.AdminService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminLoginController {
|
||||
|
||||
private final AdminService adminService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
|
||||
if (request.getUsername() == null || request.getPassword() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body("Username and password are required");
|
||||
}
|
||||
|
||||
Optional<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.AdminMasterDto;
|
||||
import com.honey.honey.service.AdminMasterService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/masters")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminMasterController {
|
||||
|
||||
private final AdminMasterService adminMasterService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<AdminMasterDto>> getMasters() {
|
||||
return ResponseEntity.ok(adminMasterService.getMasters());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.NotifyBroadcastRequest;
|
||||
import com.honey.honey.service.NotificationBroadcastService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Admin API to trigger or stop notification broadcast (ADMIN only).
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/notifications")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminNotificationController {
|
||||
|
||||
private final NotificationBroadcastService notificationBroadcastService;
|
||||
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.AdminPaymentDto;
|
||||
import com.honey.honey.model.Payment;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payments")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
|
||||
public class AdminPaymentController {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final UserARepository userARepository;
|
||||
private final UserDRepository userDRepository;
|
||||
|
||||
private boolean isGameAdmin() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.AdminPayoutDto;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.model.UserB;
|
||||
import com.honey.honey.repository.PayoutRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserBRepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.service.PayoutService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payouts")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
|
||||
public class AdminPayoutController {
|
||||
|
||||
private final PayoutRepository payoutRepository;
|
||||
private final UserARepository userARepository;
|
||||
private final UserBRepository userBRepository;
|
||||
private final UserDRepository userDRepository;
|
||||
private final PayoutService payoutService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
private boolean isGameAdmin() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getAuthorities() == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.*;
|
||||
import com.honey.honey.service.AdminPromotionService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/promotions")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminPromotionController {
|
||||
|
||||
private final AdminPromotionService adminPromotionService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<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,316 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.*;
|
||||
import com.honey.honey.model.Admin;
|
||||
import com.honey.honey.model.SupportMessage;
|
||||
import com.honey.honey.model.SupportTicket;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.repository.AdminRepository;
|
||||
import com.honey.honey.repository.SupportMessageRepository;
|
||||
import com.honey.honey.repository.SupportTicketRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/tickets")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
|
||||
public class AdminSupportTicketController {
|
||||
|
||||
private final SupportTicketRepository supportTicketRepository;
|
||||
private final SupportMessageRepository supportMessageRepository;
|
||||
private final UserARepository userARepository;
|
||||
private final AdminRepository adminRepository;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<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,250 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.*;
|
||||
import com.honey.honey.service.AdminUserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
|
||||
/** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
|
||||
private static final Set<String> SORTABLE_FIELDS = Set.of(
|
||||
"id", "screenName", "telegramId", "telegramName", "isPremium",
|
||||
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
|
||||
"balanceA", "depositTotal", "withdrawTotal", "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 referralCountMin,
|
||||
@RequestParam(required = false) Integer referralCountMax,
|
||||
@RequestParam(required = false) Integer referrerId,
|
||||
@RequestParam(required = false) Integer referralLevel,
|
||||
@RequestParam(required = false) String ip) {
|
||||
|
||||
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query.
|
||||
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
|
||||
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
|
||||
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
|
||||
// Pass through; service will use custom ordered query
|
||||
} else if (effectiveSortBy != null && !SORTABLE_FIELDS.contains(effectiveSortBy)) {
|
||||
effectiveSortBy = null;
|
||||
}
|
||||
Sort sort = Sort.by("id").descending();
|
||||
if (effectiveSortBy != null && !sortRequiresJoin.contains(effectiveSortBy)) {
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
sort = Sort.by(direction, effectiveSortBy);
|
||||
}
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
|
||||
// Convert balance filters from tickets (divide by 1000000) to bigint format
|
||||
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null;
|
||||
Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null;
|
||||
|
||||
boolean excludeMasters = isGameAdmin();
|
||||
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
|
||||
pageable,
|
||||
search,
|
||||
banned,
|
||||
countryCode,
|
||||
languageCode,
|
||||
dateRegFrom,
|
||||
dateRegTo,
|
||||
balanceMinBigint,
|
||||
balanceMaxBigint,
|
||||
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}/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.honey.honey.dto.BalanceAdjustmentRequest request) {
|
||||
try {
|
||||
com.honey.honey.dto.BalanceAdjustmentResponse response = adminUserService.adjustBalance(id, request);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
src/main/java/com/honey/honey/controller/AuthController.java
Normal file
99
src/main/java/com/honey/honey/controller/AuthController.java
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.CreateSessionRequest;
|
||||
import com.honey.honey.dto.CreateSessionResponse;
|
||||
import com.honey.honey.exception.BannedUserException;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.service.SessionService;
|
||||
import com.honey.honey.service.TelegramAuthService;
|
||||
import com.honey.honey.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final SessionService sessionService;
|
||||
private final UserService userService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
/**
|
||||
* Creates a session by validating Telegram initData.
|
||||
* This is the only endpoint that accepts initData.
|
||||
* Handles user registration/login and referral system.
|
||||
*/
|
||||
@PostMapping("/tma/session")
|
||||
public CreateSessionResponse createSession(
|
||||
@RequestBody CreateSessionRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String initData = request.getInitData();
|
||||
|
||||
if (initData == null || initData.isBlank()) {
|
||||
throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired"));
|
||||
}
|
||||
|
||||
// Validate Telegram initData signature and parse data
|
||||
Map<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.debug("Session created: userId={}", user.getId());
|
||||
|
||||
return CreateSessionResponse.builder()
|
||||
.access_token(sessionId)
|
||||
.expires_in(sessionService.getSessionTtlSeconds())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out by invalidating the session.
|
||||
* This endpoint requires authentication (Bearer token).
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void logout(@RequestHeader(value = "Authorization", required = false) String authHeader) {
|
||||
if (authHeader == null) {
|
||||
log.warn("Logout called without Authorization header");
|
||||
return;
|
||||
}
|
||||
|
||||
String sessionId = extractBearerToken(authHeader);
|
||||
if (sessionId != null) {
|
||||
sessionService.invalidateSession(sessionId);
|
||||
log.debug("Session invalidated via logout");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Bearer token from Authorization header.
|
||||
*/
|
||||
private String extractBearerToken(String authHeader) {
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.ExternalDepositWebhookRequest;
|
||||
import com.honey.honey.service.PaymentService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Controller for 3rd party deposit completion webhook.
|
||||
* Path: POST /api/deposit_webhook/{token}. Token must match app.deposit-webhook.token (APP_DEPOSIT_WEBHOOK_TOKEN).
|
||||
* No session auth; token in path only. Set the token on VPS via environment variable.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/deposit_webhook")
|
||||
@RequiredArgsConstructor
|
||||
public class DepositWebhookController {
|
||||
|
||||
@Value("${app.deposit-webhook.token:}")
|
||||
private String expectedToken;
|
||||
|
||||
private final PaymentService paymentService;
|
||||
|
||||
/**
|
||||
* Called by 3rd party when a user's crypto deposit was successful.
|
||||
* Body: user_id (internal id from db_users_a), usd_amount (decimal, e.g. 1.45).
|
||||
*/
|
||||
@PostMapping("/{token}")
|
||||
public ResponseEntity<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,56 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.NotifyBroadcastRequest;
|
||||
import com.honey.honey.service.NotificationBroadcastService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Public API to trigger or stop notification broadcast. Token in path; no secrets in codebase.
|
||||
* Set APP_NOTIFY_BROADCAST_TOKEN on VPS.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/notify_broadcast")
|
||||
@RequiredArgsConstructor
|
||||
public class NotifyBroadcastController {
|
||||
|
||||
@Value("${app.notify-broadcast.token:}")
|
||||
private String expectedToken;
|
||||
|
||||
private final NotificationBroadcastService notificationBroadcastService;
|
||||
|
||||
@PostMapping("/{token}")
|
||||
public ResponseEntity<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();
|
||||
}
|
||||
}
|
||||
237
src/main/java/com/honey/honey/controller/PaymentController.java
Normal file
237
src/main/java/com/honey/honey/controller/PaymentController.java
Normal file
@@ -0,0 +1,237 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.CryptoWithdrawalResponse;
|
||||
import com.honey.honey.dto.CreateCryptoWithdrawalRequest;
|
||||
import com.honey.honey.dto.CreatePaymentRequest;
|
||||
import com.honey.honey.dto.DepositAddressRequest;
|
||||
import com.honey.honey.dto.DepositAddressResultDto;
|
||||
import com.honey.honey.dto.DepositMethodsDto;
|
||||
import com.honey.honey.dto.ErrorResponse;
|
||||
import com.honey.honey.dto.PaymentInvoiceResponse;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.dto.WithdrawalMethodDetailsDto;
|
||||
import com.honey.honey.dto.WithdrawalMethodsDto;
|
||||
import com.honey.honey.service.CryptoDepositService;
|
||||
import com.honey.honey.service.CryptoWithdrawalService;
|
||||
import com.honey.honey.service.FeatureSwitchService;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.service.PaymentService;
|
||||
import com.honey.honey.service.PayoutService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/payments")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentController {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
private final CryptoDepositService cryptoDepositService;
|
||||
private final CryptoWithdrawalService cryptoWithdrawalService;
|
||||
private final PayoutService payoutService;
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
/**
|
||||
* Returns minimum deposit from DB only (no sync). Used by Store screen for validation.
|
||||
*/
|
||||
@GetMapping("/minimum-deposit")
|
||||
public ResponseEntity<?> getMinimumDeposit() {
|
||||
if (!featureSwitchService.isPaymentEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
|
||||
}
|
||||
return ResponseEntity.ok(java.util.Map.of("minimumDeposit", cryptoDepositService.getMinimumDeposit()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns crypto deposit methods and minimum_deposit from DB only (sync is done every 10 min).
|
||||
* Called when user opens Payment Options screen.
|
||||
*/
|
||||
@GetMapping("/deposit-methods")
|
||||
public ResponseEntity<?> getDepositMethods() {
|
||||
if (!featureSwitchService.isPaymentEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
|
||||
}
|
||||
DepositMethodsDto dto = cryptoDepositService.getDepositMethodsFromDb();
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns crypto withdrawal methods from DB only (sync is done every 30 min).
|
||||
* Called when user opens Payout screen.
|
||||
*/
|
||||
@GetMapping("/withdrawal-methods")
|
||||
public ResponseEntity<?> getWithdrawalMethods() {
|
||||
if (!featureSwitchService.isPayoutEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
|
||||
}
|
||||
WithdrawalMethodsDto dto = cryptoWithdrawalService.getWithdrawalMethodsFromDb();
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns withdrawal method details (rate_usd, misha_fee_usd) from external API for the given pid.
|
||||
* Called when user opens Payout Confirmation screen to show network fee and compute "You will receive".
|
||||
*/
|
||||
@GetMapping("/withdrawal-method-details")
|
||||
public ResponseEntity<?> getWithdrawalMethodDetails(@RequestParam("pid") int pid) {
|
||||
if (!featureSwitchService.isPayoutEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
|
||||
}
|
||||
return cryptoWithdrawalService.getWithdrawalMethodDetails(pid)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a crypto withdrawal: calls external API, then on success creates payout and deducts balance.
|
||||
* Uses in-memory lock to prevent double-submit. Validates deposit total and maxWinAfterDeposit.
|
||||
*/
|
||||
@PostMapping("/crypto-withdrawal")
|
||||
public ResponseEntity<?> createCryptoWithdrawal(@RequestBody CreateCryptoWithdrawalRequest request) {
|
||||
if (!featureSwitchService.isPayoutEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
|
||||
}
|
||||
try {
|
||||
UserA user = UserContext.get();
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
|
||||
}
|
||||
Payout payout = payoutService.createCryptoPayout(user.getId(), request);
|
||||
return ResponseEntity.ok(CryptoWithdrawalResponse.builder()
|
||||
.id(payout.getId())
|
||||
.status(payout.getStatus().name())
|
||||
.build());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Crypto withdrawal validation failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
log.warn("Crypto withdrawal failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Crypto withdrawal error: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("Withdrawal failed. Please try again later."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment invoice for the current user.
|
||||
* Returns invoice data that frontend will use to open Telegram payment UI.
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<?> createPaymentInvoice(@RequestBody CreatePaymentRequest request) {
|
||||
if (!featureSwitchService.isPaymentEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
|
||||
}
|
||||
try {
|
||||
UserA user = UserContext.get();
|
||||
PaymentInvoiceResponse response = paymentService.createPaymentInvoice(user.getId(), request);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Payment invoice creation failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Payment invoice creation error: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("Failed to create payment invoice: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets a crypto deposit address from the external API (no payment record is created).
|
||||
* Call when user selects a payment method on Payment Options screen.
|
||||
* Returns address, amount_coins, name, network for the Payment Confirmation screen.
|
||||
*/
|
||||
@PostMapping("/deposit-address")
|
||||
public ResponseEntity<?> getDepositAddress(@RequestBody DepositAddressRequest request) {
|
||||
if (!featureSwitchService.isPaymentEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
|
||||
}
|
||||
try {
|
||||
UserA user = UserContext.get();
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
|
||||
}
|
||||
DepositAddressResultDto result = paymentService.requestCryptoDepositAddress(
|
||||
user.getId(), request.getPid(), request.getUsdAmount());
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Deposit address request failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Deposit address error: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse(e.getMessage() != null ? e.getMessage() : "Failed to get deposit address"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a payment (e.g., when user cancels in Telegram UI).
|
||||
*/
|
||||
@PostMapping("/cancel")
|
||||
public ResponseEntity<?> cancelPayment(@RequestBody CancelPaymentRequest request) {
|
||||
try {
|
||||
String orderId = request.getOrderId();
|
||||
UserA caller = UserContext.get();
|
||||
log.info("Payment cancel requested: orderId={}, callerUserId={}", orderId, caller != null ? caller.getId() : null);
|
||||
paymentService.cancelPayment(orderId);
|
||||
return ResponseEntity.ok().body(new PaymentWebhookResponse(true, "Payment cancelled"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Payment cancellation failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Payment cancellation error: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse("Failed to cancel payment: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// Response DTOs
|
||||
private static class PaymentWebhookResponse {
|
||||
private final boolean success;
|
||||
private final String message;
|
||||
|
||||
public PaymentWebhookResponse(boolean success, String message) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CancelPaymentRequest {
|
||||
private String orderId;
|
||||
|
||||
public String getOrderId() {
|
||||
return orderId;
|
||||
}
|
||||
|
||||
public void setOrderId(String orderId) {
|
||||
this.orderId = orderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.CreatePayoutRequest;
|
||||
import com.honey.honey.dto.ErrorResponse;
|
||||
import com.honey.honey.dto.PayoutHistoryEntryDto;
|
||||
import com.honey.honey.dto.PayoutResponse;
|
||||
import com.honey.honey.model.Payout;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.service.PayoutService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/payouts")
|
||||
@RequiredArgsConstructor
|
||||
public class PayoutController {
|
||||
|
||||
private final PayoutService payoutService;
|
||||
|
||||
/**
|
||||
* Creates a payout request for the current user.
|
||||
* Validates input and deducts balance if validation passes.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createPayout(@RequestBody CreatePayoutRequest request) {
|
||||
try {
|
||||
UserA user = UserContext.get();
|
||||
Payout payout = payoutService.createPayout(user.getId(), request);
|
||||
PayoutResponse response = payoutService.toResponse(payout);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Payout validation failed: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("Payout creation failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the last 20 payout history entries for the current user.
|
||||
*
|
||||
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
public List<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);
|
||||
}
|
||||
}
|
||||
|
||||
20
src/main/java/com/honey/honey/controller/PingController.java
Normal file
20
src/main/java/com/honey/honey/controller/PingController.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class PingController {
|
||||
|
||||
@GetMapping("/ping")
|
||||
public Map<String, String> ping() {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("status", "ok");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.PromotionDetailDto;
|
||||
import com.honey.honey.dto.PromotionListItemDto;
|
||||
import com.honey.honey.service.FeatureSwitchService;
|
||||
import com.honey.honey.service.PublicPromotionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public API: list and view promotion details (leaderboard, user progress).
|
||||
* Excludes INACTIVE promotions. Requires Bearer auth (app user).
|
||||
* When promotions feature switch is false, all endpoints return 404.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/promotions")
|
||||
@RequiredArgsConstructor
|
||||
public class PromotionController {
|
||||
|
||||
private final PublicPromotionService publicPromotionService;
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.QuickAnswerCreateRequest;
|
||||
import com.honey.honey.dto.QuickAnswerDto;
|
||||
import com.honey.honey.model.Admin;
|
||||
import com.honey.honey.model.QuickAnswer;
|
||||
import com.honey.honey.repository.AdminRepository;
|
||||
import com.honey.honey.repository.QuickAnswerRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/quick-answers")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
|
||||
public class QuickAnswerController {
|
||||
|
||||
private final QuickAnswerRepository quickAnswerRepository;
|
||||
private final AdminRepository adminRepository;
|
||||
|
||||
/**
|
||||
* Get current admin from authentication context
|
||||
*/
|
||||
private Admin getCurrentAdmin() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String username = authentication.getName();
|
||||
return adminRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new RuntimeException("Admin not found: " + username));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all quick answers for the current admin
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<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();
|
||||
}
|
||||
}
|
||||
|
||||
104
src/main/java/com/honey/honey/controller/SupportController.java
Normal file
104
src/main/java/com/honey/honey/controller/SupportController.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.*;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.service.SupportTicketService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/support")
|
||||
@RequiredArgsConstructor
|
||||
public class SupportController {
|
||||
|
||||
private final SupportTicketService supportTicketService;
|
||||
|
||||
/**
|
||||
* Creates a new support ticket with the first message.
|
||||
*/
|
||||
@PostMapping("/tickets")
|
||||
public ResponseEntity<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/honey/honey/controller/TaskController.java
Normal file
100
src/main/java/com/honey/honey/controller/TaskController.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.ClaimTaskResponse;
|
||||
import com.honey.honey.dto.DailyBonusStatusDto;
|
||||
import com.honey.honey.dto.RecentBonusClaimDto;
|
||||
import com.honey.honey.dto.TaskDto;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.service.TaskService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/tasks")
|
||||
@RequiredArgsConstructor
|
||||
public class TaskController {
|
||||
|
||||
private final TaskService taskService;
|
||||
private final LocalizationService localizationService;
|
||||
|
||||
/**
|
||||
* Gets all tasks for a specific type (referral, follow, other).
|
||||
* Includes user progress and claim status.
|
||||
*/
|
||||
@GetMapping
|
||||
public List<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,865 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.config.TelegramProperties;
|
||||
import com.honey.honey.dto.TelegramApiResponse;
|
||||
import com.honey.honey.dto.PaymentWebhookRequest;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.service.FeatureSwitchService;
|
||||
import com.honey.honey.service.PaymentService;
|
||||
import com.honey.honey.service.TelegramBotApiService;
|
||||
import com.honey.honey.service.UserService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||
import org.telegram.telegrambots.meta.api.objects.Message;
|
||||
import org.telegram.telegrambots.meta.api.objects.User;
|
||||
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
|
||||
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
|
||||
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
|
||||
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
|
||||
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
|
||||
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
|
||||
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton;
|
||||
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow;
|
||||
import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.config.LocaleConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
|
||||
/**
|
||||
* Webhook controller for receiving Telegram updates directly.
|
||||
* Path: POST /api/telegram/webhook/{token}. Token must match APP_TELEGRAM_WEBHOOK_TOKEN.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/telegram/webhook")
|
||||
@RequiredArgsConstructor
|
||||
public class TelegramWebhookController {
|
||||
|
||||
private static final String MINI_APP_URL = "https://testforapp.website/test/auth";
|
||||
|
||||
@Value("${app.telegram-webhook.token:}")
|
||||
private String expectedWebhookToken;
|
||||
|
||||
private final UserService userService;
|
||||
private final PaymentService paymentService;
|
||||
private final TelegramProperties telegramProperties;
|
||||
private final LocalizationService localizationService;
|
||||
private final TelegramBotApiService telegramBotApiService;
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Webhook endpoint for receiving updates from Telegram.
|
||||
* Path token must match app.telegram-webhook.token (APP_TELEGRAM_WEBHOOK_TOKEN).
|
||||
*/
|
||||
@PostMapping("/{token}")
|
||||
public ResponseEntity<?> handleWebhook(@PathVariable String token, @RequestBody Update update, HttpServletRequest httpRequest) {
|
||||
if (expectedWebhookToken.isEmpty() || !expectedWebhookToken.equals(token)) {
|
||||
log.warn("Webhook rejected: invalid or missing token (possible misconfiguration or wrong URL); update dropped");
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
try {
|
||||
// Handle callback queries (button clicks)
|
||||
if (update.hasCallbackQuery()) {
|
||||
handleCallbackQuery(update.getCallbackQuery());
|
||||
}
|
||||
|
||||
// Handle message updates (e.g., /start command or Reply Keyboard button clicks)
|
||||
if (update.hasMessage() && update.getMessage().hasText()) {
|
||||
handleMessage(update.getMessage(), httpRequest);
|
||||
}
|
||||
|
||||
// Handle pre-checkout query (before payment confirmation)
|
||||
if (update.hasPreCheckoutQuery()) {
|
||||
handlePreCheckoutQuery(update.getPreCheckoutQuery());
|
||||
}
|
||||
|
||||
// Handle successful payment
|
||||
if (update.hasMessage() && update.getMessage().hasSuccessfulPayment()) {
|
||||
handleSuccessfulPayment(update.getMessage().getSuccessfulPayment(), update.getMessage().getFrom().getId());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing Telegram webhook: {}", e.getMessage(), e);
|
||||
if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().startsWith("/start")) {
|
||||
Long telegramId = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getId() : null;
|
||||
log.warn("Registration attempt failed (webhook error), update dropped: telegramId={}", telegramId);
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
|
||||
* Format: /start or /start 123 (where 123 is the referral user ID)
|
||||
*/
|
||||
private void handleMessage(Message message, HttpServletRequest httpRequest) {
|
||||
String messageText = message.getText();
|
||||
if (messageText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
User telegramUser = message.getFrom();
|
||||
Long telegramId = telegramUser.getId();
|
||||
Long chatId = message.getChatId();
|
||||
|
||||
// Handle /start command
|
||||
if (messageText.startsWith("/start")) {
|
||||
handleStartCommand(message, httpRequest, telegramUser, telegramId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Reply Keyboard button clicks
|
||||
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
|
||||
String languageCode = "EN"; // Default
|
||||
try {
|
||||
var userOpt = userService.getUserByTelegramId(telegramId);
|
||||
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
|
||||
languageCode = userOpt.get().getLanguageCode();
|
||||
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
|
||||
languageCode = telegramUser.getLanguageCode();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not get user language, using default: {}", e.getMessage());
|
||||
// Fallback to Telegram user's language_code if available
|
||||
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
|
||||
languageCode = telegramUser.getLanguageCode();
|
||||
}
|
||||
}
|
||||
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
|
||||
|
||||
// Check if message matches Reply Keyboard button text
|
||||
String startSpinningText = "🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning");
|
||||
String usersPayoutsText = "💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts");
|
||||
String infoChannelText = "ℹ️ " + localizationService.getMessage(locale, "bot.button.infoChannel");
|
||||
|
||||
if (messageText.equals(startSpinningText)) {
|
||||
sendStartSpinningMessage(chatId, locale);
|
||||
} else if (messageText.equals(usersPayoutsText)) {
|
||||
sendUsersPayoutsMessage(chatId, locale);
|
||||
} else if (messageText.equals(infoChannelText)) {
|
||||
sendInfoChannelMessage(chatId, locale);
|
||||
} else {
|
||||
// Unknown message (e.g. old "Start Spinning" button or free text): reply and refresh keyboard
|
||||
sendUnrecognizedMessageAndUpdateKeyboard(chatId, locale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles /start command with optional referral parameter.
|
||||
* Format: /start or /start 123 (where 123 is the referral user ID)
|
||||
*/
|
||||
private void handleStartCommand(Message message, HttpServletRequest httpRequest, User telegramUser, Long telegramId) {
|
||||
String messageText = message.getText();
|
||||
|
||||
log.debug("Received /start command: telegramId={}", telegramId);
|
||||
|
||||
Integer referralUserId = null;
|
||||
|
||||
// Parse referral parameter from /start command
|
||||
// Format: /start or /start 123
|
||||
String[] parts = messageText.split("\\s+", 2);
|
||||
if (parts.length > 1 && !parts[1].trim().isEmpty()) {
|
||||
try {
|
||||
referralUserId = Integer.parseInt(parts[1].trim());
|
||||
log.debug("Parsed referral ID: {}", referralUserId);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid referral parameter format: '{}'", parts[1]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
boolean isNewUser = userService.getUserByTelegramId(telegramId).isEmpty();
|
||||
if (isNewUser) {
|
||||
log.info("New user registration via bot - telegramId={}, referralUserId={}",
|
||||
telegramId, referralUserId);
|
||||
}
|
||||
|
||||
// Build tgUserData map similar to what TelegramAuthService.parseInitData returns
|
||||
Map<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);
|
||||
String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage");
|
||||
sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard);
|
||||
|
||||
String welcomeText = localizationService.getMessage(locale, "bot.welcome.message");
|
||||
if (featureSwitchService.isStartGameButtonEnabled()) {
|
||||
InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup();
|
||||
List<List<InlineKeyboardButton>> inlineRows = new ArrayList<>();
|
||||
List<InlineKeyboardButton> inlineRow = new ArrayList<>();
|
||||
InlineKeyboardButton startInlineButton = new InlineKeyboardButton();
|
||||
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
|
||||
startInlineButton.setText(startSpinningButtonText);
|
||||
WebAppInfo webAppInfo = new WebAppInfo();
|
||||
webAppInfo.setUrl(MINI_APP_URL);
|
||||
startInlineButton.setWebApp(webAppInfo);
|
||||
inlineRow.add(startInlineButton);
|
||||
inlineRows.add(inlineRow);
|
||||
inlineKeyboard.setKeyboard(inlineRows);
|
||||
sendMessage(chatId, welcomeText, inlineKeyboard);
|
||||
} else {
|
||||
sendMessage(chatId, welcomeText, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends message with Start Spinning button.
|
||||
*/
|
||||
private void sendStartSpinningMessage(Long chatId, Locale locale) {
|
||||
String message = localizationService.getMessage(locale, "bot.message.startSpinning");
|
||||
if (!featureSwitchService.isStartGameButtonEnabled()) {
|
||||
sendMessage(chatId, message, null);
|
||||
return;
|
||||
}
|
||||
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
|
||||
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
|
||||
List<InlineKeyboardButton> row = new ArrayList<>();
|
||||
InlineKeyboardButton button = new InlineKeyboardButton();
|
||||
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
|
||||
button.setText(startSpinningButtonText);
|
||||
WebAppInfo webAppInfo = new WebAppInfo();
|
||||
webAppInfo.setUrl(MINI_APP_URL);
|
||||
button.setWebApp(webAppInfo);
|
||||
row.add(button);
|
||||
rows.add(row);
|
||||
keyboard.setKeyboard(rows);
|
||||
sendMessage(chatId, message, keyboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a friendly "unrecognized message" reply and updates the user's reply keyboard to the current one.
|
||||
* Used when the user sends unknown text (e.g. old "Start Spinning" button) so they get the new keyboard.
|
||||
*/
|
||||
private void sendUnrecognizedMessageAndUpdateKeyboard(Long chatId, Locale locale) {
|
||||
String message = localizationService.getMessage(locale, "bot.message.unrecognized");
|
||||
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
|
||||
sendMessageWithReplyKeyboard(chatId, message, replyKeyboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends message with Users payouts button.
|
||||
*/
|
||||
private void sendUsersPayoutsMessage(Long chatId, Locale locale) {
|
||||
String message = localizationService.getMessage(locale, "bot.message.usersPayouts");
|
||||
|
||||
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
|
||||
List<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.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.TransactionDto;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.service.TransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Controller for transaction history operations.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/transactions")
|
||||
@RequiredArgsConstructor
|
||||
public class TransactionController {
|
||||
|
||||
private final TransactionService transactionService;
|
||||
|
||||
/**
|
||||
* Gets transaction history for the current user.
|
||||
* Returns 50 transactions per page, ordered by creation time descending (newest first).
|
||||
*
|
||||
* @param page Page number (0-indexed, defaults to 0)
|
||||
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
|
||||
* @return Page of transactions
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<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,101 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.UserCheckDto;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.model.UserB;
|
||||
import com.honey.honey.model.UserD;
|
||||
import com.honey.honey.repository.PaymentRepository;
|
||||
import com.honey.honey.repository.UserARepository;
|
||||
import com.honey.honey.repository.UserBRepository;
|
||||
import com.honey.honey.repository.UserDRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Controller for user check endpoint (open endpoint for external applications).
|
||||
* Path token is validated against app.check-user.token (APP_CHECK_USER_TOKEN). No user auth.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/check_user")
|
||||
@RequiredArgsConstructor
|
||||
public class UserCheckController {
|
||||
|
||||
@Value("${app.check-user.token:}")
|
||||
private String expectedToken;
|
||||
|
||||
private final UserARepository userARepository;
|
||||
private final UserBRepository userBRepository;
|
||||
private final UserDRepository userDRepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
|
||||
/**
|
||||
* Gets user information by Telegram ID.
|
||||
* Path: /api/check_user/{token}/{telegramId}. Token must match APP_CHECK_USER_TOKEN.
|
||||
*
|
||||
* @param token Secret token from path (must match config)
|
||||
* @param telegramId The Telegram ID of the user
|
||||
* @return 200 with user info (found=true) or 200 with found=false when user not found; 403 if token invalid
|
||||
*/
|
||||
@GetMapping("/{token}/{telegramId}")
|
||||
public ResponseEntity<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 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)
|
||||
.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/honey/honey/controller/UserController.java
Normal file
146
src/main/java/com/honey/honey/controller/UserController.java
Normal file
@@ -0,0 +1,146 @@
|
||||
package com.honey.honey.controller;
|
||||
|
||||
import com.honey.honey.dto.ReferralDto;
|
||||
import com.honey.honey.dto.UserDto;
|
||||
import com.honey.honey.model.UserA;
|
||||
import com.honey.honey.model.UserB;
|
||||
import com.honey.honey.repository.UserBRepository;
|
||||
import com.honey.honey.security.UserContext;
|
||||
import com.honey.honey.service.AvatarService;
|
||||
import com.honey.honey.service.FeatureSwitchService;
|
||||
import com.honey.honey.service.LocalizationService;
|
||||
import com.honey.honey.service.UserService;
|
||||
import com.honey.honey.util.IpUtils;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final UserBRepository userBRepository;
|
||||
private final AvatarService avatarService;
|
||||
private final LocalizationService localizationService;
|
||||
private final FeatureSwitchService featureSwitchService;
|
||||
|
||||
@GetMapping("/current")
|
||||
public UserDto getCurrentUser() {
|
||||
UserA user = UserContext.get();
|
||||
|
||||
// Convert IP from byte[] to string for display
|
||||
String ipAddress = IpUtils.bytesToIp(user.getIp());
|
||||
|
||||
// Get balance
|
||||
Long balanceA = userBRepository.findById(user.getId())
|
||||
.map(UserB::getBalanceA)
|
||||
.orElse(0L);
|
||||
|
||||
// Generate avatar URL on-the-fly (deterministic from userId)
|
||||
String avatarUrl = avatarService.getAvatarUrl(user.getId());
|
||||
|
||||
return UserDto.builder()
|
||||
.id(user.getId())
|
||||
.telegram_id(user.getTelegramId())
|
||||
.username(user.getTelegramName())
|
||||
.screenName(user.getScreenName())
|
||||
.dateReg(user.getDateReg())
|
||||
.ip(ipAddress)
|
||||
.balanceA(balanceA)
|
||||
.avatarUrl(avatarUrl)
|
||||
.languageCode(user.getLanguageCode())
|
||||
.paymentEnabled(featureSwitchService.isPaymentEnabled())
|
||||
.payoutEnabled(featureSwitchService.isPayoutEnabled())
|
||||
.promotionsEnabled(featureSwitchService.isPromotionsEnabled())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user's language code.
|
||||
* Called when user changes language in app header.
|
||||
*/
|
||||
@PutMapping("/language")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
|
||||
UserA user = UserContext.get();
|
||||
userService.updateLanguageCode(user.getId(), request.getLanguageCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds deposit amount to user's balance_a.
|
||||
* For now, this is a mock implementation that directly adds to balance.
|
||||
* Will be replaced with payment integration later.
|
||||
*/
|
||||
@PostMapping("/deposit")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deposit(@RequestBody DepositRequest request) {
|
||||
UserA user = UserContext.get();
|
||||
|
||||
// Frontend sends amount already in bigint format (no conversion needed)
|
||||
Long depositAmount = request.getAmount();
|
||||
if (depositAmount == null || depositAmount <= 0) {
|
||||
throw new IllegalArgumentException(localizationService.getMessage("user.error.depositAmountInvalid"));
|
||||
}
|
||||
|
||||
UserB userB = userBRepository.findById(user.getId())
|
||||
.orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound")));
|
||||
|
||||
// Add to balance
|
||||
userB.setBalanceA(userB.getBalanceA() + depositAmount);
|
||||
|
||||
// Update deposit statistics
|
||||
userB.setDepositTotal(userB.getDepositTotal() + depositAmount);
|
||||
userB.setDepositCount(userB.getDepositCount() + 1);
|
||||
|
||||
userBRepository.save(userB);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UpdateLanguageRequest {
|
||||
private String languageCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets referrals for a specific level with pagination.
|
||||
* Always returns 50 results per page.
|
||||
*
|
||||
* @param level The referral level (1, 2, or 3)
|
||||
* @param page Page number (0-indexed, defaults to 0)
|
||||
* @return Page of referrals with name and commission
|
||||
*/
|
||||
@GetMapping("/referrals")
|
||||
public ReferralsResponse getReferrals(
|
||||
@RequestParam Integer level,
|
||||
@RequestParam(defaultValue = "0") Integer page) {
|
||||
UserA user = UserContext.get();
|
||||
|
||||
Page<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;
|
||||
}
|
||||
}
|
||||
10
src/main/java/com/honey/honey/dto/AdminLoginRequest.java
Normal file
10
src/main/java/com/honey/honey/dto/AdminLoginRequest.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminLoginRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
|
||||
13
src/main/java/com/honey/honey/dto/AdminLoginResponse.java
Normal file
13
src/main/java/com/honey/honey/dto/AdminLoginResponse.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class AdminLoginResponse {
|
||||
private String token;
|
||||
private String username;
|
||||
private String role;
|
||||
}
|
||||
|
||||
31
src/main/java/com/honey/honey/dto/AdminMasterDto.java
Normal file
31
src/main/java/com/honey/honey/dto/AdminMasterDto.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminMasterDto {
|
||||
private Integer id;
|
||||
private String screenName;
|
||||
/** Level 1 referrals count (from master's UserD row). */
|
||||
private Integer referals1;
|
||||
/** Level 2 referrals count. */
|
||||
private Integer referals2;
|
||||
/** Level 3 referrals count. */
|
||||
private Integer referals3;
|
||||
/** Total users with master_id = this master's id. */
|
||||
private Long totalReferrals;
|
||||
/** Sum of deposit_total of all referrals, in USD (divided by 1e9). */
|
||||
private BigDecimal depositTotalUsd;
|
||||
/** Sum of withdraw_total of all referrals, in USD (divided by 1e9). */
|
||||
private BigDecimal withdrawTotalUsd;
|
||||
/** depositTotalUsd - withdrawTotalUsd. */
|
||||
private BigDecimal profitUsd;
|
||||
}
|
||||
27
src/main/java/com/honey/honey/dto/AdminPaymentDto.java
Normal file
27
src/main/java/com/honey/honey/dto/AdminPaymentDto.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPaymentDto {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String orderId;
|
||||
private Integer starsAmount;
|
||||
private Long ticketsAmount;
|
||||
private String status;
|
||||
private String telegramPaymentChargeId;
|
||||
private String telegramProviderPaymentChargeId;
|
||||
private Instant createdAt;
|
||||
private Instant completedAt;
|
||||
}
|
||||
|
||||
28
src/main/java/com/honey/honey/dto/AdminPayoutDto.java
Normal file
28
src/main/java/com/honey/honey/dto/AdminPayoutDto.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPayoutDto {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String username;
|
||||
private String type;
|
||||
private String giftName;
|
||||
private Long total;
|
||||
private Integer starsAmount;
|
||||
private Integer quantity;
|
||||
private String status;
|
||||
private Instant createdAt;
|
||||
private Instant resolvedAt;
|
||||
}
|
||||
|
||||
23
src/main/java/com/honey/honey/dto/AdminPromotionDto.java
Normal file
23
src/main/java/com/honey/honey/dto/AdminPromotionDto.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionDto {
|
||||
private Integer id;
|
||||
private String type;
|
||||
private Instant startTime;
|
||||
private Instant endTime;
|
||||
private String status;
|
||||
private Long totalReward;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
25
src/main/java/com/honey/honey/dto/AdminPromotionRequest.java
Normal file
25
src/main/java/com/honey/honey/dto/AdminPromotionRequest.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionRequest {
|
||||
@NotNull
|
||||
private String type;
|
||||
@NotNull
|
||||
private Instant startTime;
|
||||
@NotNull
|
||||
private Instant endTime;
|
||||
@NotNull
|
||||
private String status;
|
||||
private Long totalReward;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionRewardDto {
|
||||
private Integer id;
|
||||
private Integer promoId;
|
||||
private Integer place;
|
||||
/** Reward in bigint (1 ticket = 1_000_000). */
|
||||
private Long reward;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionRewardRequest {
|
||||
@NotNull
|
||||
private Integer place;
|
||||
@NotNull
|
||||
private Long reward; // bigint, 1 ticket = 1_000_000
|
||||
}
|
||||
21
src/main/java/com/honey/honey/dto/AdminPromotionUserDto.java
Normal file
21
src/main/java/com/honey/honey/dto/AdminPromotionUserDto.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionUserDto {
|
||||
private Integer promoId;
|
||||
private Integer userId;
|
||||
/** Points as ticket count, 2 decimal places (e.g. 100.25). */
|
||||
private BigDecimal points;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminPromotionUserPointsRequest {
|
||||
@NotNull
|
||||
private BigDecimal points; // ticket count, 2 decimal places
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminSupportMessageDto {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String message;
|
||||
private Instant createdAt;
|
||||
private Boolean isAdmin; // true if sent by admin
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminSupportTicketDetailDto {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String subject;
|
||||
private String status;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private List<AdminSupportMessageDto> messages;
|
||||
}
|
||||
|
||||
26
src/main/java/com/honey/honey/dto/AdminSupportTicketDto.java
Normal file
26
src/main/java/com/honey/honey/dto/AdminSupportTicketDto.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminSupportTicketDto {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String subject;
|
||||
private String status;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private Long messageCount;
|
||||
private String lastMessagePreview;
|
||||
private Instant lastMessageAt;
|
||||
}
|
||||
|
||||
21
src/main/java/com/honey/honey/dto/AdminTaskClaimDto.java
Normal file
21
src/main/java/com/honey/honey/dto/AdminTaskClaimDto.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminTaskClaimDto {
|
||||
private Long id;
|
||||
private Integer taskId;
|
||||
private String taskTitle;
|
||||
private String taskType;
|
||||
private Instant claimedAt;
|
||||
}
|
||||
|
||||
21
src/main/java/com/honey/honey/dto/AdminTransactionDto.java
Normal file
21
src/main/java/com/honey/honey/dto/AdminTransactionDto.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminTransactionDto {
|
||||
private Long id;
|
||||
private Long amount; // In bigint format
|
||||
private String type;
|
||||
private Integer taskId;
|
||||
private Instant createdAt;
|
||||
}
|
||||
|
||||
52
src/main/java/com/honey/honey/dto/AdminUserDetailDto.java
Normal file
52
src/main/java/com/honey/honey/dto/AdminUserDetailDto.java
Normal file
@@ -0,0 +1,52 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminUserDetailDto {
|
||||
// Basic Info
|
||||
private Integer id;
|
||||
private String screenName;
|
||||
private Long telegramId;
|
||||
private String telegramName;
|
||||
private Integer isPremium;
|
||||
private String languageCode;
|
||||
private String countryCode;
|
||||
private String deviceCode;
|
||||
private String avatarUrl;
|
||||
private Integer dateReg;
|
||||
private Integer dateLogin;
|
||||
private Integer banned;
|
||||
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
|
||||
private String ipAddress;
|
||||
|
||||
// Balance Info
|
||||
private Long balanceA;
|
||||
private Long depositTotal;
|
||||
private Integer depositCount;
|
||||
private Long withdrawTotal;
|
||||
private Integer withdrawCount;
|
||||
/** Total deposits in USD (CRYPTO only). */
|
||||
private java.math.BigDecimal depositTotalUsd;
|
||||
/** Total withdrawals in USD (CRYPTO only). */
|
||||
private java.math.BigDecimal withdrawTotalUsd;
|
||||
/** When true, the user cannot create any payout request. */
|
||||
private Boolean withdrawalsDisabled;
|
||||
|
||||
// Referral Info
|
||||
private Integer referralCount;
|
||||
private Long totalCommissionsEarned;
|
||||
/** Total commissions earned in USD (converted from tickets). */
|
||||
private java.math.BigDecimal totalCommissionsEarnedUsd;
|
||||
private Integer masterId;
|
||||
private List<ReferralLevelDto> referralLevels;
|
||||
}
|
||||
|
||||
41
src/main/java/com/honey/honey/dto/AdminUserDto.java
Normal file
41
src/main/java/com/honey/honey/dto/AdminUserDto.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AdminUserDto {
|
||||
private Integer id;
|
||||
private String screenName;
|
||||
private Long telegramId;
|
||||
private String telegramName;
|
||||
private Long balanceA;
|
||||
private Long balanceB;
|
||||
private Long depositTotal;
|
||||
private Integer depositCount;
|
||||
private Long withdrawTotal;
|
||||
private Integer withdrawCount;
|
||||
private Integer dateReg;
|
||||
private Integer dateLogin;
|
||||
private Integer banned;
|
||||
private String countryCode;
|
||||
private String languageCode;
|
||||
private Integer referralCount; // Total referrals across all levels
|
||||
private Long totalCommissionsEarned; // Total commissions earned from referrals
|
||||
/** Profit in tickets (bigint): depositTotal - withdrawTotal */
|
||||
private Long profit;
|
||||
/** USD from db_users_b: depositTotal (tickets/1000) */
|
||||
private BigDecimal depositTotalUsd;
|
||||
/** USD from db_users_b: withdrawTotal (tickets/1000) */
|
||||
private BigDecimal withdrawTotalUsd;
|
||||
/** USD from db_users_b: profit (tickets/1000) */
|
||||
private BigDecimal profitUsd;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BalanceAdjustmentRequest {
|
||||
@NotNull(message = "Balance type is required")
|
||||
private BalanceType balanceType; // A or B
|
||||
|
||||
@NotNull(message = "Amount is required")
|
||||
private Long amount; // In bigint format (tickets * 1,000,000)
|
||||
|
||||
@NotNull(message = "Operation is required")
|
||||
private OperationType operation; // ADD or SUBTRACT
|
||||
|
||||
@NotBlank(message = "Reason is required")
|
||||
private String reason; // Reason for adjustment (for audit log)
|
||||
|
||||
public enum BalanceType {
|
||||
A, B
|
||||
}
|
||||
|
||||
public enum OperationType {
|
||||
ADD, SUBTRACT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BalanceAdjustmentResponse {
|
||||
private Long newBalanceA;
|
||||
private Long newBalanceB;
|
||||
private Long previousBalanceA;
|
||||
private Long previousBalanceB;
|
||||
private Long adjustmentAmount;
|
||||
private String message;
|
||||
}
|
||||
|
||||
19
src/main/java/com/honey/honey/dto/BalanceUpdateDto.java
Normal file
19
src/main/java/com/honey/honey/dto/BalanceUpdateDto.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BalanceUpdateDto {
|
||||
private Long balanceA; // Balance in bigint format (database format)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
37
src/main/java/com/honey/honey/dto/BotRegisterRequest.java
Normal file
37
src/main/java/com/honey/honey/dto/BotRegisterRequest.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BotRegisterRequest {
|
||||
@JsonProperty("telegram_id")
|
||||
private Long telegramId;
|
||||
|
||||
@JsonProperty("first_name")
|
||||
private String firstName;
|
||||
|
||||
@JsonProperty("last_name")
|
||||
private String lastName;
|
||||
|
||||
private String username;
|
||||
|
||||
@JsonProperty("is_premium")
|
||||
private Boolean isPremium;
|
||||
|
||||
@JsonProperty("language_code")
|
||||
private String languageCode;
|
||||
|
||||
@JsonProperty("photo_url")
|
||||
private String photoUrl;
|
||||
|
||||
@JsonProperty("referral_user_id")
|
||||
private Integer referralUserId;
|
||||
}
|
||||
|
||||
25
src/main/java/com/honey/honey/dto/BotRegisterResponse.java
Normal file
25
src/main/java/com/honey/honey/dto/BotRegisterResponse.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BotRegisterResponse {
|
||||
@JsonProperty("user_id")
|
||||
private Integer userId;
|
||||
|
||||
@JsonProperty("is_new_user")
|
||||
private Boolean isNewUser;
|
||||
|
||||
private String message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
19
src/main/java/com/honey/honey/dto/ClaimTaskResponse.java
Normal file
19
src/main/java/com/honey/honey/dto/ClaimTaskResponse.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ClaimTaskResponse {
|
||||
private boolean success;
|
||||
private String message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.honey.honey.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* Request body for POST /api/payments/crypto-withdrawal.
|
||||
*/
|
||||
@Data
|
||||
public class CreateCryptoWithdrawalRequest {
|
||||
|
||||
@NotNull(message = "pid is required")
|
||||
private Integer pid;
|
||||
|
||||
@NotBlank(message = "wallet is required")
|
||||
private String wallet;
|
||||
|
||||
/** Tickets amount in bigint format (tickets * 1_000_000). */
|
||||
@NotNull(message = "total is required")
|
||||
private Long total;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user