Files
cmhub/docs/deployment.md
T

552 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 部署 / 运行文档(T-403)
> 面向 VPS + 宝塔面板 + Nginx + MySQL 8.4 的 MVP 部署说明。本文只写可执行步骤和上线前检查,不保存真实密钥、数据库密码、支付凭证或上游 API Key。
## 一、部署目标
生产形态:
```text
公网 HTTPS
-> 宝塔 / Nginx
/static/ -> STATIC_ROOT
/media/ -> MEDIA_ROOT(MVP 本地媒体;后续可换对象存储)
/api/v1/generate/image/tasks* -> Gunicorn 普通请求池(异步提交/轮询,短请求)
/api/v1/generate/* -> Gunicorn 长请求池(旧同步生成,超时更长)
/api/v1/analyze/images -> Gunicorn 长请求池(同步多图理解)
其他路径 -> Gunicorn 普通请求池(用户端 / admin / 余额 / 充值)
-> cmhub Django 单体
-> cmhub image-task worker(management command,后台处理异步生图)
-> MySQL 8.4
-> Django shared cache(MVP 可用 MySQL DatabaseCache;高并发换 Redis/Memcached)
```
当前约束:
- 使用系统 Python 3.12,不使用虚拟环境。
- 图片生成已提供旧同步接口和新异步提交 / 轮询接口;真实图片生成耗时仍需在生产链路记录,并用于校准旧同步超时和异步 worker 容量。
- T-619 多图理解和 T-620 多图图生图均有 Base64 请求体;请求体上限需覆盖 Base64 膨胀,默认 32 MiB 解码后总图片上限建议配置至少 50 MiB 的 Nginx `client_max_body_size`。图生图提交多个 `image_url` 时,Web 请求会在预扣前等待每张图片下载完成。
- 真实支付需微信 / 支付宝商户密钥、证书、生产 SDK 与公网回调地址;未配置前只能用 `PAYMENT_CALLBACK_MODE=mock`。
## 二、系统准备
1. 安装系统依赖:
```bash
python3.12 --version
python3.12 -m pip --version
```
2. 准备 MySQL 8.4:
- 使用 cmhub 专用 MySQL 8.4 实例,不复用已有 MySQL 5.7。
- 默认端口为 `3306`;如果 VPS 上已有 MySQL 占用 3306,则为 cmhub 的 MySQL 8.4 配专用端口,并同步写入 `.env` 的 `MYSQL_PORT`。
- 数据库字符集使用 `utf8mb4`,表引擎使用 InnoDB。
示例 SQL(密码仅占位):
```sql
CREATE DATABASE cmhub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'cmhub'@'%' IDENTIFIED BY 'change-me-strong-password';
GRANT ALL PRIVILEGES ON cmhub.* TO 'cmhub'@'%';
FLUSH PRIVILEGES;
```
3. 拉取代码到生产目录,例如:
```bash
cd /www/wwwroot
git clone <your-remote-url> cmhub
cd /www/wwwroot/cmhub
```
## 三、安装依赖
生产 Linux 安装运行依赖:
```bash
python3.12 -m pip install -r requirements-production.txt
```
如果启用真实支付 SDK,还需按支付 SDK 文档在生产机安装:
```bash
python3.12 -m pip install wechatpayv3 python-alipay-sdk
```
如果暂未提供真实商户配置,不安装支付 SDK 也能以 `PAYMENT_CALLBACK_MODE=mock` 运行本地/测试流程。
## 四、配置 `.env`
从样例复制后只在部署机填写真实值:
```bash
cp .env.example .env
```
生产关键配置:
```dotenv
DJANGO_SECRET_KEY=change-me-to-a-long-random-secret
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=cmhub.example.com,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=https://cmhub.example.com
DJANGO_TIME_ZONE=Asia/Shanghai
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DJANGO_DEFAULT_FROM_EMAIL=noreply@cmhub.example.com
DJANGO_SESSION_COOKIE_SECURE=true
DJANGO_CSRF_COOKIE_SECURE=true
DJANGO_SECURE_PROXY_SSL_HEADER=true
DJANGO_SECURE_SSL_REDIRECT=false
DJANGO_SECURE_HSTS_SECONDS=0
ACCOUNT_SIGNUP_RATE_LIMIT=20/m/ip
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=cmhub
MYSQL_USER=cmhub
MYSQL_PASSWORD=change-me
MYSQL_CHARSET=utf8mb4
MYSQL_CONNECT_TIMEOUT=30
MYSQL_READ_TIMEOUT=120
MYSQL_WRITE_TIMEOUT=120
STATIC_URL=/static/
STATIC_ROOT=/www/wwwroot/cmhub/staticfiles
MEDIA_ROOT=/www/wwwroot/cmhub/media
MEDIA_URL=/media/
PUBLIC_BASE_URL=https://cmhub.example.com
MEDIA_PUBLIC_BASE_URL=https://cmhub.example.com
IMAGE_TASK_RETENTION_HOURS=24
GENERATED_IMAGE_RETENTION_HOURS=72
IMAGE_TASK_REAPER_INTERVAL_SECONDS=60
IMAGE_TASK_LEASE_SECONDS=600
IMAGE_TASK_MAX_RETRIES=2
IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30
CMSHOPEE_SUBSCRIPTION_MODE=shadow
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off
CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY_UPDATED_AT=2026-07-28T00:00:00+08:00
IMAGE_MAX_INPUT_IMAGES=8
IMAGE_MAX_INPUT_IMAGE_BYTES=10485760
IMAGE_MAX_INPUT_TOTAL_BYTES=33554432
DJANGO_CACHE_BACKEND=django.core.cache.backends.db.DatabaseCache
DJANGO_CACHE_LOCATION=cmhub_cache
AI_KEY_ENCRYPTION_KEY=base64-fernet-key
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PAYMENT_CALLBACK_MODE=sdk
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
ALIPAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/alipay
```
生成 Fernet 主密钥:
```bash
python3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
注意:
- `AI_KEY_ENCRYPTION_KEY` 生产不可随意更换;更换会导致已加密的 `AiModel.api_key_encrypted` 无法解密。
- 当前注册策略为免邮箱验证,注册登录不依赖邮件服务;新注册用户成功后会赠送 10 点,生产必须保留或收紧 `ACCOUNT_SIGNUP_RATE_LIMIT`,并使用共享 Django cache 承载限流计数;若后续启用密码找回、通知或恢复邮箱验证,再把 `DJANGO_EMAIL_BACKEND` 改为真实 SMTP / 邮件服务并配置 `DJANGO_DEFAULT_FROM_EMAIL`。
- `PAYMENT_CALLBACK_MODE=sdk` 必须配齐微信 / 支付宝商户配置;未配齐时先保持 `mock`。
- 宝塔 / Nginx 已强制 HTTPS 时,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;全站 HTTPS 稳定后再把 `DJANGO_SECURE_HSTS_SECONDS` 调大,避免 HSTS 误锁域名。
- T-614 异步生图 worker 没有 request 对象,生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为公开 HTTPS 域名,否则异步轮询成功时可能返回相对 `/media/...` URL。
- T-635/T-636 客户端启动订阅策略与服务端订阅模式独立:先部署迁移但不要预置 `ClientSubscriptionPolicy`,此时保留 `.env` 兜底 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off`。日常由超级管理员在 `/admin/licensing/clientsubscriptionpolicy/` 创建 / 编辑唯一策略记录,填写原因后按 `off -> observe -> enforce` 灰度;保存后下一次版本检查立即生效,无需重启。仅当该记录不存在时,才修改带时区的 `.env` 更新时间并重启 `cmhub-web` 与 `cmhub-generate`。不得把客户端 `off` 误当作服务端取消授权。
## 五、初始化数据库与静态文件
```bash
python3.12 manage.py check
python3.12 manage.py migrate
python3.12 manage.py createcachetable cmhub_cache
python3.12 manage.py collectstatic --noinput
python3.12 manage.py createsuperuser
```
如果后续改用 Redis / Memcached,则把 `DJANGO_CACHE_BACKEND` / `DJANGO_CACHE_LOCATION` 换成对应共享后端配置,并安装对应 Python backend 依赖。多 Gunicorn worker 下不要使用默认 `LocMemCache`,否则生成接口和认证失败限流会按 worker 各算一份。
## 六、导入 AI 模型配置
真实上游调用前:
```bash
python3.12 manage.py import_ai_models /secure/cmhub/ai_models.json --create-default-aliases
```
要求:
- `/secure/cmhub/ai_models.json` 不提交到 git。
- `AiModel.timeout_seconds` 不能继续依赖默认 `0` 口径上线;图片模型必须按真实耗时设置读取超时,并受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护。
- 运营后台需配置 `PricingRule` 和 `ExchangeRate`,否则生成和充值会因缺规则被拒绝。
### AI 上游配置故障处理
已知线上故障形态:
1. `GET /api/v1/balance` 成功,说明 API Key 与 base URL 可用。
2. `GET /api/v1/models` 中 `title-standard` 存在,`operation_type="title"`,`pricing_status="priced"`。
3. `POST /api/v1/generate/title` 返回:
```json
{"error":{"code":"upstream_error","message":"上游模型配置不可用"}}
```
该形态优先判定为**服务端上游模型运行配置不可用**,不是桌面端 payload 问题;生成失败后应确认余额未减少。
修复步骤:
1. 确认线上 `.env` 中 `AI_KEY_ENCRYPTION_KEY` 存在且是有效 Fernet key;不要更换已用于加密入库的 key。
2. 在 django-admin 检查 `/admin/ai/modelalias/`:`title-standard` 必须 `is_active=True`、`operation_type=title`,且指向 active 的 `AiModel`。
3. 在 `/admin/ai/aimodel/` 检查该模型:`url`、`model`、`api_type`、`capabilities`、API Key 均已配置;标题模型至少包含 `text` 能力,如需看图标题还应包含 `vision`。
4. 若怀疑解密失败,在后台重新保存该 `AiModel` 的 API Key,或使用当前 `AI_KEY_ENCRYPTION_KEY` 重新执行 `import_ai_models` 导入模型配置。
5. 环境变量改动后重启 `cmhub-web.service` 与 `cmhub-generate.service`;数据库模型配置改动通常热生效,但生产排障时可统一重启减少不确定性。
命令行诊断时不要打印真实 API Key,可只打印是否存在和密文前缀:
```bash
python3.12 manage.py shell
```
```python
from apps.ai.aliases import resolve_model_alias
alias = resolve_model_alias("title", "title-standard")
model = alias.ai_model
print({
"alias": alias.alias,
"alias_active": alias.is_active,
"operation_type": alias.operation_type,
"model_name": model.name,
"model_active": model.is_active,
"api_type": model.api_type,
"url_set": bool(model.url),
"model_set": bool(model.model),
"capabilities": sorted(model.capabilities_set()),
"has_api_key": model.has_api_key,
"encrypted_prefix": model.api_key_encrypted[:7] if model.api_key_encrypted else "",
})
resolved = model.to_resolved_model()
print({
"resolved_api_type": resolved.api_type,
"resolved_url_set": bool(resolved.url),
"resolved_model_set": bool(resolved.model),
"resolved_has_api_key": bool(resolved.api_key),
})
```
修复后验收:
```bash
python3.12 manage.py check
python3.12 manage.py smoke_ai_generation title
```
再用真实 API Key 调 `GET /api/v1/balance`、`GET /api/v1/models`、`POST /api/v1/generate/title`。期望标题接口返回 `titles`、`points_cost`、`points_balance`、`call_id`;若上游失败,确认错误不再是「上游模型配置不可用」。
## 七、Gunicorn 运行
建议把同步图片生成与同步多图理解分流到独立 Gunicorn 池,避免长请求占满普通页面和 admin 的 worker。
普通请求池示例:
```bash
gunicorn config.wsgi:application \
--bind 127.0.0.1:8001 \
--workers 2 \
--worker-class gthread \
--threads 4 \
--timeout 120 \
--access-logfile - \
--error-logfile -
```
生成长请求池示例:
```bash
gunicorn config.wsgi:application \
--bind 127.0.0.1:8002 \
--workers 1 \
--worker-class gthread \
--threads 4 \
--timeout 360 \
--access-logfile - \
--error-logfile -
```
`--timeout` 必须按真实生成耗时校准:生图 Provider 实际读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;多图理解读取超时取视觉模型的文本请求超时。`Gunicorn timeout` 应大于内层上游超时,Nginx `proxy_read_timeout` 应大于 Gunicorn timeout,调用方 read timeout 应不小于 Nginx。
systemd 单元可分别命名为 `cmhub-web.service` 和 `cmhub-generate.service`。服务的 `WorkingDirectory` 指向 `/www/wwwroot/cmhub`,`ExecStart` 使用上面的两条 Gunicorn 命令,环境变量由项目根目录 `.env` 在 Django settings 中读取。
异步生图 worker 示例:
```bash
python3.12 manage.py run_image_tasks \
--worker-id cmhub-image-worker-1 \
--sleep-seconds 1
```
建议单独托管为 `cmhub-image-worker.service`。该 worker 从数据库 `image_generation_task` 表抢 `queued` 且 `next_attempt_at` 已到达的任务,使用 MySQL `select_for_update(skip_locked)` 标记 `running`,执行成功后写 `succeeded` 和稳定 `result_url`。T-616 起,`upstream_timeout` / `upstream_error` 这类临时性上游失败会先回到 `queued` 并写 `next_attempt_at`,默认最多重试 2 次;重试期间 `CallRecord` 仍为 `pending`,不退点。不可重试错误或最终失败才调用计费层幂等退点并写 `failed`。worker 循环会按 `IMAGE_TASK_REAPER_INTERVAL_SECONDS` 扫描租约或心跳过期的 `running` 任务,默认判失败并幂等退点,不默认重排队。
worker 每处理一个任务会向 stdout 输出一行结构化日志,形如 `event=image_task_processed task_id=... status=queued alias=image-hd attempt=1 max_attempts=3 retrying=true next_attempt_at=2026-07-09T12:00:10+08:00 duration_ms=220015 error_code=upstream_timeout`。失败日志必须用于区分「还在 queued 未提交给上游」「queued 等待重试」和「已最终 failed」;日志不得包含 prompt、`image_base64`、provider raw 或密钥。
生产默认 `IMAGE_TASK_MAX_RETRIES=2`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30`,表示第 1 次正常执行失败后最多再重试 2 次。若 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=220`,单任务最坏耗时约 `220 * 3 + 10 + 30 = 700` 秒;Nginx / Gunicorn 的异步提交与轮询仍是短请求,但桌面端轮询总超时和任务保留窗口必须覆盖这个最坏耗时。
多 worker 可以并行运行同一命令,只要 `--worker-id` 不同即可;MySQL 8.4 会通过 `select_for_update(skip_locked)` 避免重复抢同一任务。生产扩容应按 2、4、8、16 逐级观察 `queued` 长度、`duration_ms`、`error_code`、MySQL 连接数、VPS CPU/内存/磁盘写入和上游失败率。不要直接扩到 100 个 worker:这会同时放大 MySQL 连接、上游请求、图片下载和本地写文件压力;如果上游已经频繁 `upstream_timeout`,100 并发通常只会把失败更快放大。
当前 worker 执行前会基于任务快照复跑一次生成准备逻辑,包括 prompt 复审、别名 / Provider 解析和定价检查;账务仍使用 submit 阶段已预扣的 `CallRecord.points_cost`,不会重复扣点。这是短队列下偏安全的取舍:敏感词库变更后,排队任务仍可在执行前被拦截并退款。若生产出现明显排队或频繁切换别名 / 模型,应单独开发“提交时模型配置快照”,让 worker 使用提交时确认的模型执行。
输入方式对 submit 耗时有直接影响:桌面端批量生图应优先传 `image_base64`,submit 阶段只解码并写入输入文件;`image_url` 会在 submit 阶段完成 SSRF 校验、远程下载和大小限制,再保存为输入文件引用,因此可能阻塞提交请求。`image_url` 的好处是任务进入队列后自包含,worker 不再访问调用方外部 URL;生产排查 submit 慢时,应先确认是否有客户端批量使用 `image_url`。
systemd 单元示例:
```ini
[Unit]
Description=cmhub image task worker
After=network.target mysql.service
[Service]
Type=simple
WorkingDirectory=/www/wwwroot/cmhub
ExecStart=/usr/bin/python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-seconds 1
Restart=always
RestartSec=5
User=www
Group=www
[Install]
WantedBy=multi-user.target
```
部署异步生图时至少需要同时运行:
- `cmhub-web.service`:用户端、admin、余额、模型目录、异步提交 / 轮询等短请求。
- `cmhub-generate.service`:旧同步生成接口,保留给老客户端。
- `cmhub-image-worker.service`:新异步任务真正调上游生成图片。
### 生图接口用量遥测与旧同步弃用
T-615 起,旧同步 `POST /api/v1/generate/image` 和新异步提交 `POST /api/v1/generate/image/tasks` 会向 `cmhub.api.generation_usage` 写结构化日志,日志消息以 `generation_route_usage` 开头,JSON 字段包含:
```json
{
"event": "generation_route_usage",
"route_type": "sync",
"api_key_id": 12,
"api_key_prefix": "sk_cmhub_xxxxxx",
"user_id": 34,
"client_version": "0.1.1",
"alias": "image-hd",
"status": "success",
"latency_ms": 123456,
"error_code": "",
"http_status": 200
}
```
安全边界:日志不得包含 API Key 明文、prompt 全文、`image_base64`、provider raw、上游密钥或图片内容。调用方建议在两个生图提交接口都带 `X-Client-Version`,便于按客户端版本观察迁移进度。
systemd 日志查询示例:
```bash
journalctl -u cmhub-generate.service --since "24 hours ago" | grep generation_route_usage
journalctl -u cmhub-web.service --since "24 hours ago" | grep generation_route_usage
```
常用观察维度:
- 按 `route_type` 看旧同步 `sync` 与新异步 `async` 的调用占比。
- 按 `client_version` 找仍在调用旧同步接口的客户端版本。
- 按 `api_key_id` / `api_key_prefix` 找未迁移的接入账号。
- 按 `status` / `error_code` / `latency_ms` 比较旧路和新路错误率、超时率和耗时。
旧同步接口退出条件:
1. 新版桌面端默认走异步提交 / 轮询接口。
2. 连续观察一段生产窗口后,旧同步 `route_type=sync` 调用归零,或低于运营确认的阈值,且没有关键客户仍依赖旧路。
3. 先在发布说明和接口文档中标记旧同步接口 deprecated。
4. 单独立任务下线旧同步接口;下线前必须继续保持旧同步成功响应字段兼容。
## 八、宝塔 / Nginx 配置
宝塔中新建站点并绑定域名和 SSL 后,在站点 Nginx 配置中加入或调整:
```nginx
server {
listen 443 ssl http2;
server_name cmhub.example.com;
client_max_body_size 64m;
location /static/ {
alias /www/wwwroot/cmhub/staticfiles/;
expires 30d;
add_header Cache-Control "public";
}
location /media/ {
alias /www/wwwroot/cmhub/media/;
expires 7d;
add_header Cache-Control "public";
}
location = /api/v1/generate/image/tasks {
proxy_pass http://127.0.0.1:8001;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location ^~ /api/v1/generate/image/tasks/ {
proxy_pass http://127.0.0.1:8001;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location = /api/v1/analyze/images {
proxy_pass http://127.0.0.1:8002;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 360s;
proxy_read_timeout 360s;
}
location /api/v1/generate/ {
proxy_pass http://127.0.0.1:8002;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 360s;
proxy_read_timeout 360s;
}
location / {
proxy_pass http://127.0.0.1:8001;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
}
```
说明:
- `/static/` 必须指向 `STATIC_ROOT`,否则 Bootstrap/qrcode 本地资源在 `DEBUG=False` 下 404。
- `/media/` 是 MVP 本地图片结果访问路径;如果改对象存储,应同步更新 `MEDIA_URL` 和存储配置。
- `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}` 必须写在 `/api/v1/generate/` 前面,确保异步提交 / 轮询走普通 web 池;旧同步生成接口继续走长请求池。
- `/api/v1/analyze/images` 不在 `/api/v1/generate/` 前缀下,必须单独写精确 location 并转发到长请求池;否则会落入普通 web 池并可能拖慢登录、余额和 admin。
- 宝塔若已在外层配置 HTTP 到 HTTPS 跳转,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;如果由 Django 负责跳转,再设为 `true`。
## 九、上线前验证
基础验证:
```bash
python3.12 manage.py check
python3.12 manage.py check --deploy
python3.12 manage.py makemigrations --check --dry-run
python3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1
python3.12 manage.py collectstatic --dry-run --noinput
```
账务与用户端测试:
```bash
python3.12 manage.py test apps.billing apps.users --noinput --keepdb --verbosity 2
python3.12 manage.py test apps.api --noinput --keepdb --verbosity 2
python3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2
```
异步生图 worker 单次验证:
```bash
python3.12 manage.py run_image_tasks --once --worker-id smoke-worker
python3.12 manage.py run_image_tasks --reap-only
```
AI smoke:
```bash
python3.12 manage.py smoke_ai_generation title --recorded
python3.12 manage.py smoke_ai_generation image --recorded
python3.12 manage.py smoke_ai_generation title
python3.12 manage.py smoke_ai_generation image
```
上线前必须记录真实 `image` smoke 的 `elapsed_ms`,并据此回填:
- 图片 `AiModel.timeout_seconds` 与 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`
- Gunicorn 长请求池 `--timeout`
- Nginx `/api/v1/generate/` 的 `proxy_read_timeout`
- 接入方客户端 read timeout
如果真实图片 smoke 仍未跑通,只能按保守值(例如 300s~360s)上线内测,并在发布记录中明确“图片同步真实耗时风险未退”,不能声称已验证生产图片链路。
## 十、发布检查清单
- `DJANGO_DEBUG=false`。
- `DJANGO_SECRET_KEY`、`AI_KEY_ENCRYPTION_KEY`、MySQL 密码、支付密钥均为生产值且未提交。
- `DJANGO_ALLOWED_HOSTS`、`DJANGO_CSRF_TRUSTED_ORIGINS`、微信 / 支付宝 `notify_url` 使用同一 HTTPS 域名。
- 如启用密码找回、通知或邮箱验证等邮件能力,邮件后端可发送邮件;当前免邮箱验证策略不把邮件服务作为注册登录上线前置条件。
- HTTPS 已由宝塔 / Nginx 强制跳转;如由 Django 强制跳转则设置 `DJANGO_SECURE_SSL_REDIRECT=true`。HSTS 只在确认全站 HTTPS 后启用。
- `STATIC_ROOT` 已 `collectstatic`,Nginx 可访问 `/static/portal/vendor/bootstrap/bootstrap.min.css` 和 `/static/portal/vendor/qrcode/qrcode.js`。
- `MEDIA_ROOT` 或对象存储可访问生成图片 URL。
- `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 已配置为客户端可访问的 HTTPS 域名;异步生图成功返回的 `result.image_url` 可公网下载。
- `cmhub-image-worker.service` 已启动并设置开机自启;`journalctl -u cmhub-image-worker.service` 无持续异常。
- `DJANGO_CACHE_BACKEND` 为共享 cache,不是 `LocMemCache`。
- MySQL 是 8.4 / InnoDB / `utf8mb4`。
- 真实支付 SDK 与商户配置齐全后,`PAYMENT_CALLBACK_MODE=sdk`。
- 真实图片生成耗时已记录并用于设置超时链路;若未记录,发布说明必须标注风险。
- `manage.py check`、迁移、静态文件、关键测试和 smoke 均有记录。
## 十一、存量用户批量授予过渡套餐
仅在运营已创建并确认目标套餐后使用。先用套餐数据库 ID 预演,默认不会写数据:
```bash
python3.12 manage.py grant_existing_users_plan \
--plan-id 2 \
--reason "存量用户测试套餐过渡"
```
确认输出中的 `grant_count`、套餐 ID 和产品代码,并完成数据库备份后,再把预演人数原样传给执行命令:
```bash
python3.12 manage.py grant_existing_users_plan \
--plan-id 2 \
--reason "存量用户测试套餐过渡" \
--execute \
--expected-grant-count 30
```
安全规则:
- 默认只处理启用的非后台、非超级用户账号。
- 同产品已有有效或宽限期权益的用户会跳过,不用测试套餐覆盖正式会员。
- `--expected-grant-count` 与执行时重新计算的人数不一致时整批拒绝。
- 实际授予在一个事务中执行,任一失败整批回滚,并为每个新权益写带原因的授权事件。
- 执行后再次运行预演,正常应得到 `grant_count=0`;再核对权益、授权事件、后台账号和订阅状态接口。
- 示例中的套餐 ID 和人数只表示一次部署记录,每次操作都必须重新从 admin 和预演结果确认,不能照抄。