Files
video/docs/04-部署运行文档.md
2026-10-18 21:08:54 +08:00

350 lines
17 KiB
Markdown
Raw Permalink 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.
# 04-部署运行文档
> 本文记录**真实生产环境**的部署架构与运维操作手册(截至 2026-10-18)。
> 服务器:腾讯云 CVM(14G 内存),已装宝塔面板;系统 Linux。
> 项目根目录:`/root/project/video`,三个业务域名均启用 HTTPS。
## 1. 总体架构
```
┌─────────────────────────────────────────┐
用户浏览器/App │ 腾讯云服务器 │
│ │
h5.256kb.top ───────► │ nginx(宝塔) ──► /root/project/video/h5 │
(下载页/邀请落地页) │ ├─ /downloads /videos /uploads │
│ │ → /root/project/video/runtime/* │
│ └─ /r/ /api/v1/invite/ ──┐ │
vm.256kb.top ───────► │ root:/root/project/video/admin/dist │
(管理后台 SPA) │ /api/ /static/ /r/ ──────┤ │
│ ▼ │
vapi.256kb.top ──────► │ 全量反代 ──────────► uvicorn 127.0.0.1:8000
(App API 入口) │ (video-api.service,│
│ 2 workers) │
│ │ │ │
│ ┌───────────┘ └────┐ │
│ ▼ ▼ │
│ docker: mysql8 docker: my-redis
│ 127.0.0.1:3307 127.0.0.1:6379
│ 数据卷 /opt/mysql8-data 配置/数据 /home/redis
└─────────────────────────────────────────┘
视频 ts 分片流量:App → 直连源站 CDN(不经过本服务器,见 02-技术文档)
```
| 域名 | 用途 | nginx 要点 |
|---|---|---|
| `h5.256kb.top` | App 下载页 + 邀请落地页 + 静态媒体 | root=`/root/project/video/h5`;`/downloads/` `/videos/` `/uploads/` alias 到 `runtime/`;`/r/`、`/api/v1/invite/` 反代 8000 |
| `vm.256kb.top` | 管理后台 | root=`/root/project/video/admin/dist`(SPA 回退 index.html);`/api/` `/static/` `/r/` 反代 8000 |
| `vapi.256kb.top` | App 业务 API | 全量反代 127.0.0.1:8000 |
| `codeserver.256kb.top` | code-server(运维用,默认容器未自启) | 反代 code-server 容器 |
证书:Let's Encrypt ECC,由 acme.sh/宝塔申请到 `/www/server/panel/vhost/cert/<域名>/fullchain.pem`,crontab 有续期任务(`5 4,10,16,22 * * * acme.sh --cron` + 宝塔 acme_v2.py),80 端口仅用于 acme 挑战与跳转 HTTPS。
## 2. 目录规划
| 路径 | 用途 |
|---|---|
| `/root/project/video/server` | FastAPI 后端源码 + `.venv` 虚拟环境 + `.env` + `config.yaml` |
| `/root/project/video/admin` | Vue3 后台源码;构建产物 `admin/dist/` 即 vm 站点根目录 |
| `/root/project/video/app` | Flutter App 源码(编译机即本服务器) |
| `/root/project/video/h5` | H5 下载页(`index.html`) |
| `/root/project/video/runtime` | 运行期产出:`downloads/`(APK)、`videos/`(转码视频)、`uploads/`(上传文件) |
| `/root/project/video/docs` | 项目文档(本目录) |
| `/root/project/video/deploy.sh` | App 一键发版脚本(见 §7) |
| `/root/project/video/project-ctl.sh` | 项目一键启停脚本(见 §8) |
| `/opt/mysql8-data` | MySQL 8 数据卷(Docker 挂载) |
| `/home/redis/conf`、`/home/redis/data` | Redis 配置与数据(Docker 挂载) |
| `/root/backups/video-app-signing` | App 签名 keystore 备份(**极其重要**,见 §7.3) |
## 3. Docker 组件(MySQL / Redis)
两个容器均为 `--restart unless-stopped`:服务器重启自动拉起;**手动 `docker stop` 后重启不会恢复**(project-ctl.sh stop 利用此语义实现"暂停")。
### 3.1 MySQL 8(容器名 mysql8)
- 镜像 `mysql:8`(当前 8.4.11),端口映射 **宿主机 3307 → 容器 3306**
- 数据卷 `/opt/mysql8-data:/var/lib/mysql`,时区 Asia/Shanghai
- 业务库 `video`,账号 `root` / 密码 `256kb.cn`
- **连接必须加 `-h 127.0.0.1`**:不加时 mysql 客户端走 unix socket 会连到宿主机旧版 MySQL 5.7,曾导致 deploy.sh 把版本号写错库
```bash
# 日常连接
mysql -h 127.0.0.1 -uroot -p256kb.cn -P 3307 video
# 或容器内
docker exec -it mysql8 mysql -uroot -p256kb.cn video
# 如需重建容器(数据在 /opt/mysql8-data,不丢)
docker run -d --name mysql8 --restart unless-stopped \
-p 3307:3306 -v /opt/mysql8-data:/var/lib/mysql \
-e TZ=Asia/Shanghai -e MYSQL_ROOT_PASSWORD=256kb.cn mysql:8
```
### 3.2 Redis(容器名 my-redis)
- 镜像 `redis:latest`(7.x),端口 **6379**,挂载 `/home/redis/conf` 与 `/home/redis/data`
- 启动命令 `redis-server /usr/local/etc/redis/redis.conf`
- **注意:宿主机曾装过 Redis 5.0.3 并已禁用自启**。若它抢占了 6379,新版 redis-py 客户端默认 RESP3 的 `HELLO` 握手会失败 → 后端全接口 500。排查时先确认 `docker ps` 里 my-redis 在跑、且 6379 被 docker-proxy 占用
- 后端 redis-py 客户端显式指定 `protocol=2`(RESP2),新旧服务端都兼容
- 清业务缓存一律容器内执行:`docker exec my-redis redis-cli DEL config:system`
```bash
# 如需重建容器
docker run -d --name my-redis --restart unless-stopped \
-p 6379:6379 -v /home/redis/conf:/usr/local/etc/redis -v /home/redis/data:/data \
redis:latest redis-server /usr/local/etc/redis/redis.conf
```
## 4. 后端服务(video-api.service)
### 4.1 systemd 单元(/etc/systemd/system/video-api.service)
```ini
[Unit]
Description=Video App FastAPI Backend (uvicorn)
After=network.target docker.service redis.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/project/video/server
ExecStart=/root/project/video/server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
- 只监听 **127.0.0.1:8000**,对外全靠 nginx 反代
- `--workers 2`:⚠️ 结算周期任务(推荐激活/创作收益/超时关单)随每个 worker 的 lifespan 各启动一份(`app/services/settle_service.py`)。当前任务均为条件更新、重复执行无副作用;**修改周期任务逻辑时必须保证幂等**
- 日志走 journald:`journalctl -u video-api -f`
常用命令:
```bash
systemctl restart video-api # 改后端代码后重启
systemctl enable --now video-api # 启动并恢复开机自启
journalctl -u video-api -n 100 --no-pager
```
### 4.2 环境配置(server/.env)
键清单(敏感值不入文档;`.env` 本身不入 git):
```
APP_ENV=prod APP_HOST APP_PORT
DATABASE_URL=mysql+aiomysql://root:256kb.cn@127.0.0.1:3307/video?charset=utf8mb4
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL CELERY_RESULT_BACKEND # 预留,当前未启用
JWT_SECRET ACCESS_TOKEN_TTL REFRESH_TOKEN_TTL ADMIN_TOKEN_TTL
OSS_ENDPOINT OSS_BUCKET OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET # 未启用则为占位
ALIPAY_APP_ID ALIPAY_PRIVATE_KEY ALIPAY_PUBLIC_KEY # 支付占位→dev_mock
WECHAT_MCHID WECHAT_APP_ID WECHAT_PRIVATE_KEY
TRON_API_KEY # USDT 查账
SMSBAO_USERNAME SMSBAO_API_KEY SMS_SIGN_NAME # 短信宝验证码
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD # 邮箱验证码(已启用)
```
业务参数在 `server/config.yaml`(顶层键:`points` / `membership` / `recharge` / `referral` / `asset` / `withdraw` / `upload`),其中白名单项可在后台「系统配置」热改。
### 4.3 数据库初始化(新环境一次性)
```bash
cd /root/project/video/server
python3.11 -m venv .venv && .venv/bin/pip install .
docker exec mysql8 mysql -uroot -p256kb.cn -e \
"CREATE DATABASE IF NOT EXISTS video CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
.venv/bin/alembic upgrade head # 4 个迁移,建 30 张业务表(含 series 补漏迁移 e7a1f3c58d02)
```
创建首个超管(代码不自动播种):
```bash
.venv/bin/python - <<'EOF'
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.config import settings
from app.core.security import hash_password
from app.models.admin import AdminUser
async def main():
engine = create_async_engine(settings.database_url)
async with async_sessionmaker(engine)() as s:
s.add(AdminUser(username="admin",
password_hash=hash_password("请设置强密码"),
name="超管", role="super"))
await s.commit()
await engine.dispose()
asyncio.run(main())
EOF
```
后台角色:`super`(全部权限)、`operator`(仪表盘/套餐/赠送)、`reviewer`(视频审核)。
### 4.4 服务暴露路径(反代依据)
| 路径 | 说明 |
|---|---|
| `/api/v1/*` | App 业务接口(9 个路由文件 + admin,共 100+ 端点) |
| `/api/v1/admin/*` | 管理后台接口 |
| `/r/{code}` | 邀请落地页 |
| `/static/*` | 本地媒体文件 |
| `/health` | 健康检查 |
## 5. 管理后台发布(admin/)
```bash
cd /root/project/video/admin
npm ci
npm run build # 产物直接覆盖 admin/dist/,即 vm 站点根目录
```
- 纯静态站点,**构建即发布,无需重启任何服务**;浏览器强刷(Ctrl+F5)生效
- 前端 API baseURL 为 `/api/v1`,由 vm 站点的 `/api/` 反代到后端
- 菜单按角色显示:仪表盘/套餐/赠送=operator,视频审核=reviewer,其余=super
## 6. nginx 配置(宝塔管理)
配置文件:`/www/server/panel/vhost/nginx/{h5,vm,vapi,codeserver}.256kb.top.conf`。
改配置在宝塔面板「网站 → 设置 → 配置文件」编辑,或改文件后 `nginx -t && nginx -s reload`。
关键规则摘要:
**h5.256kb.top**
```nginx
root /root/project/video/h5; # index.html 下载页
location /downloads/ { alias /root/project/video/runtime/downloads/; } # APK
location /videos/ { alias /root/project/video/runtime/videos/; }
location /uploads/ { alias /root/project/video/runtime/uploads/; }
location /r/ { proxy_pass http://127.0.0.1:8000; } # 邀请落地
location /api/v1/invite/ { proxy_pass http://127.0.0.1:8000; }
```
**vm.256kb.top**
```nginx
root /root/project/video/admin/dist;
location / { try_files $uri $uri/ /index.html; } # SPA 回退
location /api/ { proxy_pass http://127.0.0.1:8000; }
location /static/ { proxy_pass http://127.0.0.1:8000; }
location /r/ { proxy_pass http://127.0.0.1:8000; }
```
**vapi.256kb.top**:`location / { proxy_pass http://127.0.0.1:8000; }` 全量反代。
站点日志在 `/www/wwwlogs/<域名>.log`(排查 App 接口 401/500 时非常管用)。
## 7. App 编译与发版
### 7.1 域名发现(不硬编码 API 地址)
启动时优先级:`--dart-define=API_BASE` > 本地缓存 > DNS TXT(`txt.256kb.top`) > 兜底域名(md5(yyyyMM)+后缀)。
正式包**不带 dart-define 直接构建**,走 TXT 发现(当前指向 `vapi.256kb.top/api/v1`)。
### 7.2 一键发版 deploy.sh
```bash
# 1. 改 app/pubspec.yaml 的 version(如 1.3.7+23,+后为 versionCode)
# 2. 执行
bash /root/project/video/deploy.sh
```
脚本自动完成:`flutter analyze`(warning 不阻断)→ `flutter build apk --release` → `gradlew --stop` 清理 Gradle Daemon(防内存泄漏)→ 覆盖 `runtime/downloads/video-app.apk` → 同步 DB `system_configs.app.version.android`(latest_version/code 更新、**min_version_code 保留**、连接指定 `-h 127.0.0.1`)→ `docker exec my-redis` 清配置缓存。
老用户打开 App 触发 `/api/v1/app/version` 检查 → 弹更新提示 → 跳 h5 下载页。强制更新:把 `min_version_code` 调大于用户版本。
### 7.3 正式签名(2026-10-17 起,v1.3.7+23 为首个正式签名版)
| 项 | 值 |
|---|---|
| keystore | `app/android/app/upload-keystore.jks`(PKCS12,RSA 2048,有效期 30 年) |
| 密码配置 | `app/android/key.properties`(alias=`video_release`) |
| 证书指纹 SHA-256 | `8fb75f6a:3a9f:bc39:...:aef4`(完整值见 `apksigner verify --print-certs`) |
| git | 两者均已 gitignore,**不入库** |
| 备份 | `/root/backups/video-app-signing/`(keystore + key.properties) |
`build.gradle.kts` 逻辑:key.properties 存在 → release 签名;否则回退 debug(保证新环境可编译)。
> ⚠️ **keystore 丢失 = 永远无法给老用户推更新**。除服务器备份外,务必另存一份到本地电脑/网盘。
> ⚠️ 首个正式签名版与旧 debug 签名不一致,**老用户必须先卸载再安装**(版本 notice 已注明)。
> 侧载安装时小米等厂商会提示"未备案",勾选"已了解风险"即可继续安装;消除该提示需办理 App ICP 备案。
### 7.4 iOS
`flutter build ipa --release`(需 macOS + Xcode + 开发者证书,当前仅交付 Android)。上架前确认后台 `ios_payment.enabled` 开关规避虚拟支付审核。
## 8. 项目启停 project-ctl.sh
```bash
bash /root/project/video/project-ctl.sh start # docker mysql8/my-redis → 等待就绪 → video-api(enable+start) → 健康检查
bash /root/project/video/project-ctl.sh stop # video-api 停止并 disable + 停两个容器(重启服务器也不会拉起)
bash /root/project/video/project-ctl.sh restart # 停 → 启
bash /root/project/video/project-ctl.sh status # 服务/容器/接口探测
```
- 管理范围:`video-api.service` + `mysql8` + `my-redis`;**不含 nginx**(系统组件)
- 服务器重启后如项目处于"运行态",docker 与 systemd 会自动拉起全部组件,无需人工干预
- 项目当前(2026-10-18 起)处于**暂停态**,恢复运行执行 `start` 即可
## 9. 备份策略
| 对象 | 方式 | 频率建议 |
|---|---|---|
| MySQL 数据 | `docker exec mysql8 mysqldump -uroot -p256kb.cn video \| gzip > backup-$(date +%F).sql.gz`(或宝塔计划任务) | 每日 |
| keystore + key.properties | 已备份 `/root/backups/video-app-signing/`,另离线保存 | 变更即备 |
| `runtime/`(视频/APK/上传) | 文件级备份(rsync/对象存储) | 每周 |
| `server/.env`、`config.yaml` | 纳入文件备份 | 变更即备 |
恢复:导入 sql → `alembic upgrade head` 校验版本 → 启动服务。
## 10. 日常运维与故障排查
### 10.1 常用命令
```bash
journalctl -u video-api -f # 后端日志
docker logs -f mysql8 / my-redis # 容器日志
tail -f /www/wwwlogs/vapi.256kb.top.log # App 接口访问日志
docker exec my-redis redis-cli DEL config:system # 清系统配置缓存
docker exec my-redis redis-cli KEYS 'auth:*' # 查登录态键
free -h && docker stats --no-stream # 资源占用
```
### 10.2 历史故障速查表
| 症状 | 根因 | 处置 |
|---|---|---|
| 后端全接口 500,Redis 报错 | 服务器重启后 my-redis 未自启,宿主机 Redis 5.0.3 抢 6379,RESP3 握手失败 | 停本机 redis 自启;docker start my-redis;客户端已固定 protocol=2 |
| 重启后服务缺失/端口错乱 | Docker 容器未配 restart 策略 | 两容器均已 `unless-stopped` |
| App 更新提示反复弹 | 硬编码版本号与 APK 不一致 | 已改用 package_info_plus 读真实版本号 |
| 发版后 DB 版本没更新 | deploy.sh mysql 未加 `-h 127.0.0.1` 写错宿主机库 | 已修复;注意检查 |
| 编译后内存爆满 | Gradle Daemon 常驻 | deploy.sh 已自动 `gradlew --stop` |
| IDE 里 OpenJDK 吃内存 | Trae 的 fwcd.kotlin 扩展语言服务器 | 禁用该扩展并重载窗口 |
| 编译卡死无输出 | Gradle 进程僵死 | 杀掉重跑即可 |
| 会员/钱包页加载失败 | 僵尸登录态(access 过期+无 refresh) | App 端已修复(401 清态+启动检测);临时:退出重登 |
| 切"我的"仍听到视频声 | 播放器池 open 竞态 | 已修复(suspend/resume) |
| 进度条不走/拖动无效 | 本地代理未支持 Range | 已修复(Accept-Ranges + 206) |
### 10.3 端到端验证清单(发版/重启后)
```bash
curl http://127.0.0.1:8000/health # {"status":"ok"}
curl "http://127.0.0.1:8000/api/v1/videos/feed?type=hot&limit=1" # code:0
curl -I https://h5.256kb.top/downloads/video-app.apk # 200
# 浏览器开 https://vm.256kb.top → 登录 → 仪表盘有数据
# App:启动过版本检查 → Feed 出视频 → 登录 → 播放/计费正常
```
## 11. 新环境从零部署速查
1. 装 Docker / Python3.11 / Node18+ / Flutter / nginx(或宝塔)
2. 建 mysql8、my-redis 容器(§3 命令)
3. `server/` 建 venv、装依赖、写 `.env`、`alembic upgrade head`、建超管(§4.3)
4. 写 systemd unit(§4.1)→ `systemctl enable --now video-api`
5. `admin/` 构建 dist;配三个 nginx 站点 + 证书(§6)
6. App:放入 keystore + key.properties → `deploy.sh` 发版(§7)
7. 后台「系统配置」:`app.invite_base_url`、`app.version.android/ios` 等(见 03 接口文档配置键)
8. 跑 §10.3 验证清单