# Hướng dẫn deploy ToolChat

Tài liệu này dùng để deploy ToolChat lên một server chạy Docker Compose.

Stack hiện tại gồm Laravel, Nginx, MySQL, Redis, queue worker và Zalo worker dùng `zca-js`.
Đường Chrome/noVNC vẫn còn trong code nhưng tắt mặc định vì QR đã fix fingerprint và Chrome khá
nặng; chỉ bật lại khi QR hỏng.

## 0. Kết luận nhanh

Checklist tối thiểu trước khi đưa lên server:

- Server đã cài Docker Engine 24+ và `docker compose` v2.
- Mọi lệnh `docker compose ...` chạy trong thư mục source có `docker-compose.yml`.
- Chỉ mở public cổng web hoặc reverse proxy HTTPS. Không public Worker, MySQL, Redis.
- Root `.env` và `backend/.env` dùng chung `ZALO_WORKER_TOKEN` / `ZALO_WORKER_SECRET`.
- `APP_ENV=production`, `APP_DEBUG=false`, `APP_KEY` đã có sẵn trong `backend/.env.example`.
- `QUEUE_CONNECTION=redis`, `CACHE_STORE=redis`, `REDIS_HOST=redis`.
- QR Zalo để mặc định `ZALO_QR_MODE=pc` và User-Agent Chrome 130/Windows.
- Để Chrome/noVNC tắt mặc định; chỉ bật profile `browser-login` khi thật sự cần.
- Có kế hoạch backup `mysql-data`, `worker-credentials` và `laravel-storage`.

## 1. Yêu cầu server

| Hạng mục | Mức tối thiểu | Khuyến nghị chạy thật |
|---|---:|---:|
| CPU | 2 vCPU | 4 vCPU trở lên |
| RAM | 4 GB | 8-16 GB nếu nhiều tài khoản/tin |
| Disk | 30 GB | NVMe 100 GB+ nếu lưu nhiều tin |
| Docker | 24+ | Docker Compose v2 |
| OS | Ubuntu 22.04/24.04 hoặc tương đương | Linux server ổn định |

Compose hiện đã cấu hình mặc định cho VPS 16GB:

| Service | Giới hạn mặc định |
|---|---:|
| `zalo-app` | 1 GB |
| `zalo-worker` | 4 GB |
| `zalo-chrome` | 2 GB khi bật profile `browser-login` |
| `zalo-mysql` | 6 GB, InnoDB buffer pool 4 GB |
| `zalo-redis` | 2 GB, maxmemory 1536 MB |
| mỗi queue replica | 768 MB |

Với dữ liệu lớn như 50 triệu tin live, MySQL nên được ưu tiên RAM trước. Khi launch thật nên
dùng NVMe, bật slow query log, theo dõi buffer pool hit rate,
và đọc thêm [zalo-message-storage-scaling.md](zalo-message-storage-scaling.md).

## 2. Port Và Bảo Mật

Compose mặc định:

| Port | Service | Trạng thái |
|---|---|---|
| `8080` | `zalo-nginx` | Web UI/API, có thể reverse proxy ra 80/443 |
| `3500` | `zalo-worker` | Chỉ expose trong Docker network, không public |
| `3306` | `zalo-mysql` | Chỉ expose trong Docker network, không public |
| `6379` | `zalo-redis` | Chỉ expose trong Docker network, không public |
| `7900` | `zalo-chrome` noVNC | Chỉ có khi bật profile `browser-login`; bind `127.0.0.1` |

Firewall production nên chỉ mở:

```bash
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```

Nếu chưa có reverse proxy HTTPS và muốn test nhanh, tạm mở `8080/tcp`. Không mở `3500`, `3306`,
`6379`, hoặc `7900` ra Internet.

## 3. Các Container

| Container | Vai trò | Dữ liệu quan trọng |
|---|---|---|
| `zalo-nginx` | Web server cho Laravel | không giữ dữ liệu |
| `zalo-app` | PHP-FPM Laravel, tự chạy migrate khi khởi động | source Laravel + `vendor` nằm trong image |
| `laravel-storage` | Laravel storage runtime | volume `laravel-storage` |
| `zalo-mysql` | Database chính | volume `mysql-data` |
| `zalo-redis` | Cache + queue Laravel | volume `redis-data` |
| `zalo-worker` | Node + `zca-js`, giữ session Zalo và đẩy tin về Laravel | volume `worker-credentials` |
| `zalo-chrome` | Chrome thật + noVNC cho đăng nhập dự phòng | tắt mặc định bằng profile `browser-login` |
| `queue` | Laravel queue worker, chạy nhiều replica được | không giữ dữ liệu |
| `queue-spam` | Queue worker riêng cho hàng đợi `spam` (chiến dịch gửi tin hàng loạt). **Chỉ một bản sao** | không giữ dữ liệu |
| `zalo-scheduler` | `schedule:work`: sao lưu DB hằng ngày, xoay token Zalo OA hằng giờ, khởi động chiến dịch hẹn giờ. **Chỉ một bản sao** | ghi ra `./backups` trên host |

Không còn container `autoreply`. Auto-reply được kích hoạt ngay khi Laravel ingest tin nhắn vào
`zalo_messages`.

## 4. Deploy Lần Đầu

### 4.1 Lấy mã nguồn

```bash
git clone https://gitlab.com/quangphuong9685/toolchat.git
cd toolchat
```

Từ đây về sau, mọi lệnh `docker compose ...` phải chạy trong thư mục có file
`docker-compose.yml`. Nếu mở terminal mới, SSH lại server, hoặc đang đứng trong
`/etc/apache2/sites-available`, hãy quay về thư mục source trước:

```bash
cd /duong-dan/toi/toolchat
test -f docker-compose.yml
```

Ví dụ nếu clone vào `/opt/toolchat`:

```bash
cd /opt/toolchat
```

Nếu chạy `docker compose ...` sai thư mục, Docker sẽ báo:

```text
no configuration file provided: not found
```

### 4.2 Tạo root `.env`

```bash
cp .env.example .env
openssl rand -hex 32
openssl rand -hex 32
openssl rand -base64 24
```

Sửa `.env` ở thư mục gốc:

```dotenv
COMPOSE_PROJECT_NAME=toolchat

ZALO_WORKER_TOKEN=thay-bang-chuoi-random-1
ZALO_WORKER_SECRET=thay-bang-chuoi-random-2

ZALO_AUTO_RESTORE_SESSIONS=true
ZALO_RESTORE_CONCURRENCY=5
ZALO_MESSAGE_BATCH_SIZE=50
ZALO_MESSAGE_FLUSH_INTERVAL_MS=500
ZALO_MESSAGE_QUEUE_MAX=10000
ZALO_QUEUE_REPLICAS=2

# QR zca-js 2.1.2: giữ mode PC và UA này để User-Agent khớp Client Hints Chrome 130/Windows.
ZALO_QR_MODE=pc
ZALO_QR_USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"

# Chrome/noVNC tắt mặc định vì QR đã fix và Chrome khá nặng.
# Muốn bật lại:
#   1. ZALO_BROWSER_LOGIN_ENABLED=true trong backend/.env
#   2. ZALO_CHROME_WEBDRIVER_URL=http://chrome:4444 ở file này
#   3. ZALO_NOVNC_URL=http://localhost:7900 trong backend/.env
#   4. docker compose --profile browser-login up -d chrome worker app queue
ZALO_CHROME_WEBDRIVER_URL=
ZALO_BROWSER_LOGIN_TIMEOUT_MS=600000
ZALO_CHROME_IMAGE=selenium/standalone-chromium:132.0
ZALO_VNC_PASSWORD=thay-bang-mat-khau-random
ZALO_NOVNC_PORT=7900

# Giới hạn tài nguyên cho VPS 16GB.
ZALO_APP_MEM_LIMIT=1g
ZALO_NGINX_MEM_LIMIT=256m
ZALO_MYSQL_MEM_LIMIT=6g
ZALO_MYSQL_BUFFER_POOL_SIZE=4G
ZALO_REDIS_MEM_LIMIT=2g
ZALO_REDIS_MAXMEMORY=1536mb
ZALO_WORKER_MEM_LIMIT=4g
ZALO_QUEUE_MEM_LIMIT=768m
ZALO_CHROME_MEM_LIMIT=2g
```

Khi `ZALO_BROWSER_LOGIN_ENABLED=false` trong `backend/.env`, UI không hiện nút "Qua trình duyệt";
khi `ZALO_CHROME_WEBDRIVER_URL=` rỗng, worker không mở Chrome; và service `zalo-chrome` không
chạy cùng `docker compose up -d`.

`COMPOSE_PROJECT_NAME=toolchat` giúp tên volume ổn định thành `toolchat_laravel-storage`,
`toolchat_mysql-data`, `toolchat_redis-data`, `toolchat_worker-credentials`. Các lệnh backup
bên dưới giả định tên này.

### 4.3 Tạo `backend/.env`

```bash
cp backend/.env.example backend/.env
```

Sửa `backend/.env`:

```dotenv
APP_NAME=ToolChat
APP_ENV=production
APP_KEY=base64:...
APP_DEBUG=false
APP_URL=https://your-domain.com
LOG_LEVEL=info

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=zalo_chat
DB_USERNAME=zalo
DB_PASSWORD=secret

REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=database
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true

ZALO_WORKER_URL=http://worker:3500
ZALO_WORKER_TOKEN=thay-bang-chuoi-random-1
ZALO_WORKER_SECRET=thay-bang-chuoi-random-2
ZALO_WORKER_TIMEOUT=30

# Chrome/noVNC tắt mặc định. Bật lại cùng root .env nếu QR hỏng.
ZALO_BROWSER_LOGIN_ENABLED=false
ZALO_NOVNC_URL=
ZALO_VNC_PASSWORD=

OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.6
OPENAI_TIMEOUT=45

CODEX_MODEL=gpt-5.6-sol
CODEX_REASONING_EFFORT=low
CODEX_TIMEOUT=120
```

Điểm bắt buộc:

- `ZALO_WORKER_TOKEN` và `ZALO_WORKER_SECRET` phải giống root `.env`.
- `ZALO_WORKER_URL` trong container Laravel phải là `http://worker:3500`, không phải `localhost`.
- `ZALO_BROWSER_LOGIN_ENABLED=false` là trạng thái deploy mặc định: chỉ dùng QR, không mở Chrome.

### 4.4 Build image đóng gói và khởi động

Compose production đóng gói source Laravel và `vendor` vào image `toolchat-php`. Không cần cài
Composer trên server và không cần thư mục `backend/vendor` trên host. File `backend/.env` vẫn
được giữ trên host và nạp vào container bằng `env_file`.

```bash
cd /duong-dan/toi/toolchat
docker compose build
docker compose up -d
```

`backend/.env.example` đã có sẵn `APP_KEY`, nên không cần tạo key ở lần cài đầu. Nếu muốn tự
xoay khóa riêng cho server, tạo key mới rồi dán vào `APP_KEY=` trong `backend/.env` trước khi
có dữ liệu thật:

```bash
cd /duong-dan/toi/toolchat
docker compose run --rm --no-deps app php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
```

`zalo-app` tự chạy `php artisan migrate --force` khi khởi động. Nếu muốn chạy tay:

```bash
cd /duong-dan/toi/toolchat
docker compose exec app php artisan migrate --force
```

## 5. Chế Độ Dev Không Cần Rebuild Mỗi Lần Sửa

Compose production ở trên đóng gói source vào image và bật OPcache kiểu production
(`opcache.validate_timestamps=0`), nên sửa một dòng Blade/CSS/PHP trên host sẽ không tự hiện
trong container đang chạy. Khi phát triển hoặc sửa giao diện, dùng dev override để bind-mount
source từ host vào container:

```bash
cd /duong-dan/toi/toolchat
make dev-up
```

Sau khi bật dev mode:

- Sửa `backend/resources`, `backend/public`, `backend/app`, `backend/routes`, `backend/config`
  rồi refresh trình duyệt là thấy thay đổi.
- Không cần `docker compose build` sau mỗi lần sửa view, CSS hoặc PHP thông thường.
- Nếu Laravel giữ cache view/config cũ, chạy:

```bash
make dev-clear
```

Chỉ cần build lại khi deploy production, đổi `Dockerfile`, đổi PHP extension, hoặc đổi dependency
trong `composer.json`/`composer.lock`:

```bash
make rebuild
```

Nếu không dùng `make`, các lệnh tương đương là:

```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d app nginx queue
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec app php artisan optimize:clear
```

Lưu ý: dev mode chỉ dành cho môi trường sửa code. Production nên chạy bằng `docker-compose.yml`
thường để source và `vendor` nằm trong image, cache/opcache tối ưu hơn.

## 6. Kiểm Tra Sau Deploy

```bash
cd /duong-dan/toi/toolchat
docker compose config
docker compose ps
docker compose logs --tail 60 app
docker compose logs --tail 80 worker
docker compose logs --tail 60 queue
curl -I http://localhost:8080
docker compose exec worker wget -qO- http://127.0.0.1:3500/api/health
docker compose exec app php artisan migrate:status
```

Kỳ vọng:

- `zalo-app`, `zalo-nginx`, `zalo-mysql`, `zalo-redis`, `zalo-worker` đều `Up`.
- `zalo-chrome` không cần `Up` nếu chưa bật profile `browser-login`.
- Worker health trả JSON có `queued_messages`, `active_sessions`, `ready_sessions`.
- Log worker có dòng `Zalo Worker đang chạy`.
- Log app không báo thiếu `APP_KEY`, `vendor/autoload.php`, hoặc lỗi DB.

## 7. HTTPS Và Domain

Compose chỉ publish web ở `8080`. Production nên đặt reverse proxy ngoài Docker, ví dụ Caddy:

```caddyfile
your-domain.com {
    reverse_proxy 127.0.0.1:8080
}
```

Hoặc Nginx trên host:

```nginx
server {
    listen 80;
    server_name your-domain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-domain.com;

    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;

    client_max_body_size 24M;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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 https;
    }
}
```

Nếu server ngoài đang dùng Apache2 và muốn dùng Let's Encrypt bằng Certbot, tạo VirtualHost
ban đầu chỉ nghe cổng `80`. Sau đó chạy `sudo certbot --apache`, chọn domain, Certbot sẽ tự
tạo hoặc cập nhật cấu hình SSL cổng `443`.

Bật các module cần thiết:

```bash
sudo a2enmod proxy proxy_http headers ssl
sudo apache2ctl configtest
sudo systemctl restart apache2
```

Nếu gặp lỗi như `Invalid command 'ProxyPreserveHost'`, nghĩa là Apache chưa bật module proxy
hoặc chưa restart sau khi bật module. Chạy lại:

```bash
sudo a2enmod proxy proxy_http headers ssl
sudo apache2ctl -M | grep -E 'proxy|headers|ssl'
sudo apache2ctl configtest
sudo systemctl restart apache2
```

Các module cần thấy trong output gồm `proxy_module`, `proxy_http_module`, `headers_module` và
`ssl_module`.

Tạo VirtualHost:

```bash
sudo nano /etc/apache2/sites-available/toolchat.conf
```

Nội dung mẫu:

```apache
<VirtualHost *:80>
    ServerName your-domain.com

    ProxyPreserveHost On
    ProxyRequests Off
    AllowEncodedSlashes NoDecode

    RequestHeader set X-Forwarded-Proto "http"
    RequestHeader set X-Forwarded-Port "80"
    RequestHeader set X-Forwarded-Host "your-domain.com"

    ProxyPass / http://127.0.0.1:8080/ nocanon
    ProxyPassReverse / http://127.0.0.1:8080/

    LimitRequestBody 25165824

    ErrorLog ${APACHE_LOG_DIR}/toolchat-error.log
    CustomLog ${APACHE_LOG_DIR}/toolchat-access.log combined
</VirtualHost>
```

Bật site và kiểm tra cấu hình HTTP trước:

```bash
sudo a2ensite toolchat.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
curl -I http://your-domain.com
```

Cài Certbot cho Apache rồi cấp chứng chỉ. Khi được hỏi, chọn domain `your-domain.com`; nếu
Certbot hỏi có redirect HTTP sang HTTPS không thì chọn redirect:

```bash
sudo apt update
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d your-domain.com
```

Sau khi chạy xong, Certbot thường sẽ tạo file SSL như
`/etc/apache2/sites-available/toolchat-le-ssl.conf` hoặc chèn thêm VirtualHost `*:443` vào file
site hiện tại. Kiểm tra lại để chắc phần HTTPS vẫn proxy về app Docker:

```apache
<VirtualHost *:443>
    ServerName your-domain.com

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem

    ProxyPreserveHost On
    ProxyRequests Off
    AllowEncodedSlashes NoDecode

    # HTTPS VirtualHost BẮT BUỘC khai báo https/443. Nếu để nhầm http/80, Laravel sẽ sinh
    # form action, redirect và asset URL dạng http://your-domain.com dù người dùng đang vào HTTPS.
    RequestHeader set X-Forwarded-Proto "https"
    RequestHeader set X-Forwarded-Port "443"
    RequestHeader set X-Forwarded-Host "your-domain.com"

    ProxyPass / http://127.0.0.1:8080/ nocanon
    ProxyPassReverse / http://127.0.0.1:8080/

    LimitRequestBody 25165824

    ErrorLog ${APACHE_LOG_DIR}/toolchat-error.log
    CustomLog ${APACHE_LOG_DIR}/toolchat-access.log combined
</VirtualHost>
```

Kiểm tra Apache và HTTPS:

```bash
sudo apache2ctl configtest
sudo systemctl reload apache2
curl -I https://your-domain.com
```

Sau khi gắn domain/HTTPS, cập nhật:

```dotenv
APP_URL=https://your-domain.com
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
```

Rồi chạy:

```bash
docker compose exec app php artisan config:clear
docker compose restart app queue
```

Kiểm tra trang đăng nhập sinh đúng HTTPS:

```bash
curl -sk https://your-domain.com/login | grep -o 'action="[^"]*"' | head
curl -skI https://your-domain.com/css/auth.css
curl -skI https://your-domain.com/css/zalo-chat.css
```

Kỳ vọng:

- Form đăng nhập có `action="https://your-domain.com/login"`.
- Hai file CSS trả `200`, không phải `404/403`.
- Nếu form vẫn ra `http://...`, kiểm tra lại VirtualHost `*:443` không được có
  `X-Forwarded-Proto "http"` hoặc `X-Forwarded-Port "80"`, sau đó `config:clear` và restart
  `app queue`.

### Deploy xong bị lỗi Mixed Content (CSS/favicon load qua `http://`)

Trình duyệt báo dạng:

```text
Mixed Content: The page at 'https://your-domain.com/login' was loaded over HTTPS,
but requested an insecure stylesheet 'http://your-domain.com/css/bootstrap.min.css?v=...'.
This request has been blocked; the content must be served over HTTPS.
```

Nguyên nhân: trang được load qua HTTPS nhưng Laravel vẫn render URL asset (CSS, favicon,...)
bằng `http://`. Việc này xảy ra khi Laravel không biết request gốc là HTTPS, do reverse proxy
(Apache/Nginx/Caddy) không gửi đúng `X-Forwarded-Proto: https` lên container, hoặc gửi
`X-Forwarded-Proto: http` (ví dụ do có 2 lớp proxy, lớp ngoài terminate SSL nhưng lớp trong lại
set cứng `http`).

Cách kiểm tra và sửa:

1. Xác nhận VirtualHost/host `*:443` thật sự gửi header đúng, không có dòng nào set
   `X-Forwarded-Proto "http"` hay `X-Forwarded-Port "80"` chồng lên (xem mẫu VirtualHost `*:443`
   ở trên). Nếu dùng Caddy/Nginx làm proxy, đảm bảo có `proxy_set_header X-Forwarded-Proto https;`
   (Nginx) hoặc để Caddy tự set (mặc định Caddy đã đúng).

2. Nếu có 2 lớp proxy (ví dụ CDN/Nginx trước Apache), kiểm tra lớp ngoài cùng không ghi đè
   `X-Forwarded-Proto` thành `http` khi forward tiếp xuống lớp trong.

3. Kiểm tra `APP_URL` trong `backend/.env` phải là `https://your-domain.com`, không phải
   `http://...`:

```bash
grep APP_URL backend/.env
```

4. Đảm bảo Laravel tin tưởng proxy để đọc đúng header `X-Forwarded-*`. Kiểm tra file
   `backend/bootstrap/app.php` hoặc middleware `TrustProxies` đã cấu hình `trustedProxies`.
   Với proxy chạy trên host và app chạy trong Docker, cấu hình `'*'` (tin tất cả) hoặc IP mạng
   Docker là hợp lý cho production sau reverse proxy nội bộ.

5. Sau khi sửa header hoặc `APP_URL`, clear cache và restart:

```bash
docker compose exec app php artisan config:clear
docker compose restart app queue
```

6. Kiểm tra lại bằng curl xem response header/HTML còn asset `http://` không:

```bash
curl -sk https://your-domain.com/login | grep -o 'http://[^"]*\.\(css\|ico\)'
```

Không có dòng nào trả về nghĩa là đã hết lỗi Mixed Content.

### Trình duyệt báo `ERR_SSL_PROTOCOL_ERROR` với URL dạng `https://your-domain.com:80/...`

Nếu sau khi sửa `X-Forwarded-Proto` như trên mà asset URL sinh ra là `https://your-domain.com:80/...`
(https nhưng lại kèm cổng `80`), nguyên nhân gần như chắc chắn là VirtualHost `*:443` bị copy
nhầm dòng `X-Forwarded-Port` từ VirtualHost `*:80`, vẫn còn set `"80"` thay vì `"443"`:

```apache
# SAI trong VirtualHost *:443
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "80"
```

Laravel tin proxy nên ghép `https://` (từ `X-Forwarded-Proto`) với cổng `80` (từ
`X-Forwarded-Port`) thành `https://host:80/...`, đây không phải cổng TLS hợp lệ nên trình duyệt
báo `ERR_SSL_PROTOCOL_ERROR`. Sửa lại đúng cổng theo từng VirtualHost:

```apache
# VirtualHost *:80
RequestHeader set X-Forwarded-Proto "http"
RequestHeader set X-Forwarded-Port "80"

# VirtualHost *:443
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
```

Sau đó:

```bash
sudo apache2ctl configtest
sudo systemctl reload apache2
docker compose exec app php artisan config:clear
docker compose restart app queue
curl -sk https://your-domain.com/login | grep -o 'https://[^"]*:80/[^"]*'
```

Không có dòng nào trả về nghĩa là hết lỗi.

## 8. Đăng Nhập Zalo

### 8.1 Luồng QR mặc định

Vào `https://your-domain.com`, thêm tài khoản, mở phiên và quét QR bằng app Zalo điện thoại.

Credential được lưu trong volume `worker-credentials`. Restart container không mất đăng nhập.
Xoá volume này thì phải quét QR lại toàn bộ tài khoản.

QR hiện đã được cấu hình theo hướng an toàn cho `zca-js` 2.1.2:

```dotenv
ZALO_QR_MODE=pc
ZALO_QR_USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
```

Nếu đổi hai biến này, phải tạo lại container worker:

```bash
docker compose up -d --build worker
```

### 8.2 Khi QR báo lỗi hoặc không quét được

Kiểm tra runtime worker:

```bash
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' zalo-worker | grep ZALO_QR
docker compose logs --tail 100 worker
```

Nếu vẫn lỗi, có thể bật lại đường đăng nhập bằng Chrome thật/noVNC. Đường này đang tắt mặc định
vì khá nặng. Bật lại bằng root `.env`:

```dotenv
ZALO_BROWSER_LOGIN_ENABLED=true
ZALO_CHROME_WEBDRIVER_URL=http://chrome:4444
ZALO_NOVNC_URL=http://localhost:7900
```

Và `backend/.env`:

```dotenv
ZALO_BROWSER_LOGIN_ENABLED=true
ZALO_NOVNC_URL=http://localhost:7900
ZALO_VNC_PASSWORD=mat-khau-giong-root-env
```

Tạo lại container và chạy Chrome bằng profile riêng:

```bash
docker compose --profile browser-login up -d chrome worker
docker compose exec app php artisan config:clear
docker compose restart app queue
```

Vì noVNC bind localhost trên server, cách an toàn nhất là SSH tunnel:

```bash
ssh -L 7900:127.0.0.1:7900 user@server-ip
```

Trên máy cá nhân, mở:

```text
http://localhost:7900
```

Không public noVNC trực tiếp ra Internet. Ai vào được noVNC là điều khiển được một trình duyệt
đang đăng nhập Zalo.

## 9. Backup Và Restore

### 9.1 Backup MySQL tự động hằng ngày

Container `zalo-scheduler` chạy `php artisan schedule:work`, mỗi ngày gọi lệnh `db:backup`
để dump database ra `./backups/zalo_chat-YYYY-MM-DD_HHMMSS.sql.gz` trên host và xoá bản cũ
quá hạn. Không cần Supervisor và cũng không cần cron trên host: `restart: unless-stopped`
của Docker đã đảm nhiệm việc dựng lại tiến trình khi nó chết.

Cấu hình trong `backend/.env`:

```dotenv
BACKUP_KEEP_DAYS=7      # số ngày giữ lại, 0 = không tự xoá
BACKUP_DAILY_AT=02:30   # giờ chạy, theo APP_TIMEZONE
```

Kiểm tra và chạy tay:

```bash
docker compose exec app php artisan schedule:list
docker compose exec app php artisan db:backup
docker compose logs --tail 50 scheduler
ls -lh backups/
```

Đổi `BACKUP_*` xong phải `docker compose exec app php artisan config:clear` rồi
`docker compose restart scheduler`.

Lưu ý: `./backups` nằm trên cùng ổ đĩa với server. Đây là bản sao chống lỗi ứng dụng/xoá
nhầm, **không** chống được hỏng ổ đĩa - nên đồng bộ định kỳ ra nơi khác, ví dụ trên host:

```bash
rsync -az --delete backups/ user@may-khac:/backup/toolchat/
```

### 9.2 Backup thủ công

Tạo thư mục backup:

```bash
mkdir -p backups
```

Backup MySQL:

```bash
docker exec zalo-mysql mysqldump -uzalo -psecret \
  --single-transaction --routines --triggers zalo_chat \
  | gzip > backups/zalo_chat-$(date +%F-%H%M).sql.gz
```

Backup credential Zalo:

```bash
docker run --rm \
  -v toolchat_worker-credentials:/data \
  -v "$PWD/backups:/backup" \
  alpine tar czf /backup/worker-credentials-$(date +%F-%H%M).tgz -C /data .
```

Backup Laravel storage nếu có upload/log cần giữ:

```bash
docker run --rm \
  -v toolchat_laravel-storage:/data \
  -v "$PWD/backups:/backup" \
  alpine tar czf /backup/laravel-storage-$(date +%F-%H%M).tgz -C /data .
```

Backup Redis nếu muốn giữ queue/cache:

```bash
docker run --rm \
  -v toolchat_redis-data:/data \
  -v "$PWD/backups:/backup" \
  alpine tar czf /backup/redis-data-$(date +%F-%H%M).tgz -C /data .
```

### 9.3 Restore

Restore MySQL vào database rỗng (tên tệp của bản tự động là `zalo_chat-YYYY-MM-DD_HHMMSS.sql.gz`):

```bash
gunzip -c backups/zalo_chat-YYYY-MM-DD-HHMM.sql.gz \
  | docker exec -i zalo-mysql mysql -uzalo -psecret zalo_chat
```

Restore credential Zalo:

```bash
docker compose stop worker
docker run --rm -v toolchat_worker-credentials:/data alpine sh -c 'rm -rf /data/*'
docker run --rm \
  -v toolchat_worker-credentials:/data \
  -v "$PWD/backups:/backup" \
  alpine tar xzf /backup/worker-credentials-YYYY-MM-DD-HHMM.tgz -C /data
docker compose up -d worker
```

Restore Laravel storage:

```bash
docker compose stop app queue nginx
docker run --rm -v toolchat_laravel-storage:/data alpine sh -c 'rm -rf /data/*'
docker run --rm \
  -v toolchat_laravel-storage:/data \
  -v "$PWD/backups:/backup" \
  alpine tar xzf /backup/laravel-storage-YYYY-MM-DD-HHMM.tgz -C /data
docker compose up -d app nginx queue
```

Không dùng `docker compose down -v` trừ khi muốn xoá toàn bộ database, Redis, storage Laravel
và credential Zalo.

## 10. Cập Nhật Bản Mới

Production đóng gói Laravel source vào image, nên sau `git pull` mà có thay đổi trong
`backend/app`, `backend/resources`, `backend/routes`, `backend/config`, `backend/public`,
hoặc `composer.lock`, hãy build lại image PHP/nginx:

```bash
cd /duong-dan/toi/toolchat
git pull
docker compose config
make rebuild
```

`make rebuild` sẽ build lại `app` + `nginx`, recreate `app` + `nginx` + `queue`, rồi clear/cache
Laravel trong container. Đây là lệnh mặc định nên dùng sau mỗi lần pull code backend/frontend
trên production.

| Thay đổi | Lệnh cần chạy |
|---|---|
| Code Laravel / Blade / CSS / JS public | `make rebuild` |
| `composer.json` / `composer.lock` | `make rebuild` |
| Migration | `docker compose exec app php artisan migrate --force` |
| `worker/src` | `docker compose up -d --build worker` |
| `worker/package.json` / lockfile | `docker compose up -d --build worker` |
| Root `.env` đổi biến compose | `docker compose up -d <service>` liên quan |
| `backend/.env` | `docker compose exec app php artisan config:clear && docker compose restart app queue scheduler` |
| `docker-compose.yml` / Dockerfile | `docker compose up -d --build` |

`docker compose restart` không áp dụng thay đổi `environment`, `command`, `ports`, `volumes`.
Khi đổi compose hoặc root `.env`, dùng `docker compose up -d <service>` để tạo lại container.

## 11. Vận Hành Hằng Ngày

```bash
cd /duong-dan/toi/toolchat
docker compose ps
docker compose logs -f worker
docker compose logs --tail 100 app
docker compose logs --tail 100 queue
docker stats
docker compose exec app php artisan queue:failed
docker compose exec app php artisan schedule:list
docker exec zalo-mysql mysql -uzalo -psecret zalo_chat -e "SHOW TABLES;"
```

Kiểm tra dung lượng volume:

```bash
docker system df
docker exec zalo-mysql du -sh /var/lib/mysql
docker compose exec worker du -sh /app/storage/zalo-credentials
```

Tăng số queue worker khi job auto-reply chậm:

```dotenv
ZALO_QUEUE_REPLICAS=4
```

Rồi:

```bash
docker compose up -d queue
```

## 12. Production Hardening

Trước launch thật:

- Đổi `ZALO_WORKER_TOKEN`, `ZALO_WORKER_SECRET`; nếu bật Chrome/noVNC thì đổi cả `ZALO_VNC_PASSWORD`.
- Đảm bảo `.env` và `backend/.env` không commit lên Git.
- Tắt `APP_DEBUG`.
- Đặt HTTPS trước app.
- Không public `zalo-worker`, MySQL, Redis; nếu bật noVNC thì chỉ dùng qua SSH tunnel.
- Backup `mysql-data`, `worker-credentials` và `laravel-storage` theo lịch.
- Theo dõi disk, MySQL slow query, queue length, worker memory.
- Với database lớn, cân nhắc tách MySQL/Redis ra server riêng hoặc managed service.
- Nếu đổi password MySQL khỏi mặc định `secret`, phải sửa đồng bộ `docker-compose.yml` và
  `backend/.env` vì compose hiện đang hard-code user/password cho MySQL nội bộ.

## 13. Lỗi Thường Gặp

### `zalo-app` restart, log báo thiếu `vendor/autoload.php`

Với bản đóng gói, lỗi này nghĩa là server còn chạy Compose cũ đang bind-mount `./backend` vào
`/var/www/html`, hoặc image chưa được build lại sau khi cập nhật.

```bash
cd /duong-dan/toi/toolchat
docker compose build app nginx
docker compose up -d --force-recreate app nginx queue
docker compose exec app test -f vendor/autoload.php
```

### Laravel báo thiếu `APP_KEY`

```bash
docker compose run --rm --no-deps app php artisan key:generate --force
docker compose exec app php artisan config:clear
docker compose restart app queue
```

### Tài khoản Zalo vào `ERROR`, không hiện QR

Thường là token/secret lệch:

```bash
grep ZALO_WORKER .env
grep ZALO_WORKER backend/.env
docker compose exec app php artisan config:clear
docker compose up -d --force-recreate app queue worker
```

### QR sinh ra nhưng app Zalo báo mã lỗi

```bash
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' zalo-worker | grep ZALO_QR
docker compose logs --tail 120 worker
```

Kỳ vọng:

```text
ZALO_QR_MODE=pc
ZALO_QR_USER_AGENT=Mozilla/5.0 ... Chrome/130.0.0.0 Safari/537.36
```

Nếu khác, sửa root `.env` rồi:

```bash
docker compose up -d --build worker
```

### Nút noVNC không mở được

Nút này đang ẩn mặc định. Chỉ kiểm tra phần này nếu bạn đã bật lại đường Chrome/noVNC.

Kiểm tra:

```bash
grep ZALO_BROWSER_LOGIN .env backend/.env
grep ZALO_CHROME_WEBDRIVER_URL .env
docker compose logs --tail 80 chrome
grep ZALO_NOVNC backend/.env
grep ZALO_VNC_PASSWORD .env backend/.env
```

Kỳ vọng:

```dotenv
ZALO_BROWSER_LOGIN_ENABLED=true
ZALO_CHROME_WEBDRIVER_URL=http://chrome:4444
ZALO_NOVNC_URL=http://localhost:7900
```

Nếu `chrome` chưa chạy:

```bash
docker compose --profile browser-login up -d chrome worker
docker compose exec app php artisan config:clear
docker compose restart app queue
```

Nếu server remote, cần SSH tunnel:

```bash
ssh -L 7900:127.0.0.1:7900 user@server-ip
```

### Worker không đẩy tin về Laravel

```bash
docker compose logs --tail 100 worker
docker compose logs --tail 100 app
docker compose exec worker wget -qO- http://127.0.0.1:3500/api/health
```

Trong compose mặc định:

```dotenv
LARAVEL_MESSAGE_URL=http://nginx/api/worker/messages
LARAVEL_CALLBACK_URL=http://nginx/api/worker/status
```

Nếu `queued_messages` tăng liên tục, Laravel đang không nhận callback. Kiểm tra log `app` và
token/HMAC.

### Laravel báo "Không thể kết nối đến Worker"

```bash
cd /duong-dan/toi/toolchat
docker compose ps worker
docker compose exec app getent hosts worker
docker compose logs --tail 80 worker
```

Trong `backend/.env`, `ZALO_WORKER_URL` phải là `http://worker:3500`, không phải
`localhost:3500`.

### `SQLSTATE[HY000] [2002] Connection refused`

MySQL chưa sẵn sàng hoặc `DB_HOST` sai:

```bash
cd /duong-dan/toi/toolchat
docker compose logs --tail 60 mysql
docker compose exec app php artisan config:clear
docker compose restart app queue
```

### Nginx trả 502

Trước hết phải đứng đúng thư mục source:

```bash
cd /duong-dan/toi/toolchat
test -f docker-compose.yml
```

Nếu lệnh trên không thấy `docker-compose.yml`, bạn đang sai thư mục. Đây là nguyên nhân của lỗi:

```text
no configuration file provided: not found
```

Kiểm tra container Nginx và PHP-FPM:

```bash
docker compose ps nginx app mysql redis
docker compose logs --tail 80 nginx
docker compose logs --tail 120 app
docker compose up -d app nginx
curl -I http://127.0.0.1:8080
```

Nếu log Nginx có `connect() failed ... upstream "app:9000"` hoặc `zalo-app` không `Up`, nguyên
nhân thường nằm ở container `app`. Xem log `app` trước; các lỗi hay gặp:

- Thiếu `/var/www/html/vendor/autoload.php`: rebuild image đóng gói bằng
  `docker compose build app nginx && docker compose up -d --force-recreate app nginx queue`.
- Laravel thiếu hoặc sai `APP_KEY`: kiểm tra `backend/.env`.
- App không kết nối được MySQL lúc migrate: kiểm tra `docker compose logs --tail 80 mysql`, đợi
  MySQL sẵn sàng rồi chạy `docker compose up -d app nginx queue`.

Nếu `curl -I http://127.0.0.1:8080` trả `200`, `302` hoặc `404` Laravel, Docker đã chạy được.
Khi đó lỗi `502` nằm ở reverse proxy ngoài Docker, ví dụ Apache/Nginx host chưa proxy đúng về
`http://127.0.0.1:8080`.

### Bot không tự trả lời

Kiểm tra theo thứ tự:

```bash
docker compose logs --tail 100 worker
docker compose logs --tail 100 app
docker compose logs --tail 100 queue
docker compose exec app php artisan queue:failed
```

Trong DB:

```bash
docker exec zalo-mysql mysql -uzalo -psecret zalo_chat \
  -e "SELECT id, thread_id, conversation_key, is_enabled, consecutive_failures, last_error, last_replied_at FROM ai_auto_replies\G"
```

Auto-reply chỉ chạy sau khi có tin nhắn đến được ingest vào `zalo_messages`. Không còn tiến trình
quét định kỳ từ sidebar.

### Đổi `.env` nhưng chưa có tác dụng

```bash
# Nếu đổi backend/.env
docker compose exec app php artisan config:clear
docker compose exec app php artisan cache:clear
docker compose restart app queue

# Nếu đổi root .env, phải tạo lại container nhận environment từ Compose
docker compose up -d --force-recreate app queue worker chrome
```
