Files
live-hub-py/docs/上线清单-S1-S7部署.md
T
yml2213 4732c02c09 feat(ops): 本地生产镜像同步脚本 + 上线清单补充说明
生产已部署完成(main@2f610a5,迁移 20261004_0046)。为了让后续测试能在本地跑,
把生产库同步成本地镜像,并把同步流程固化成脚本。

## 本次建立的本地镜像

`douyu_login_prod` —— 与生产逐项一致:15,182 账号 / 14,323 CK / 15,169 登录记录 /
**253,424 条 douyu_tasks(含 837MB JSON,可实测保留策略)** / 迁移 0046 / 907MB。

**已验证本地 `.env` 的 `APP_ENCRYPTION_KEY` 与生产一致** ——
抽样解密正常(CK 前缀 `dy_accounts_main`,长度 1659–1702),
所以镜像里的 CK 可用,CK 相关测试能跑。这一点不成立的话镜像基本没用。

## 脚本里规避的两个坑

1. **不要把 `mysqldump` 串进 SSH 管道**:实测 ~49KB/s(MySQL 读 + gzip + 网络互相拖),
   改成「服务器本地 dump 成文件 + rsync」后 ~8MB/s,**快两个数量级**。
   下载那步用 **rsync 而非 scp**:可断点续传,网断了不用重来。
2. 恢复前先 `DROP DATABASE`,避免旧数据残留造成「看起来同步了其实没有」。

## 文档

`docs/上线清单-S1-S7部署.md` 追加「把生产库同步到本地」一节:用法、
镜像内容、两个前提(密钥必须一致;**不要在镜像库上跑测试套件** ——
测试用独立的 `douyu_login_test`),以及上面两个坑。
2026-10-04 18:46:26 +08:00

11 KiB
Raw Blame History

上线清单:S1–S7 账号全生命周期(斗鱼)

已在本地的生产克隆库上完整演练(2026-10-04)。演练证据见文末。 演练环境:生产库 15,182 账号 / 14,324 持有 CK / 当前版本 20261003_0040。


0. 先明确现状(部署前必读)

生产迁移版本 20261003_0040 —— S1–S7(含 6 个迁移)一个都没上
account_events 表 不存在
CK 在哪 login_tasks.cookie(旧代码在跑,这是当前的唯一数据源)
账号数 / 持有 CK 15,182 / 14,324
整库 972 MB(其中 douyu_tasks 837 MB)

本次上线内容:迁移 0041 → 0046 + 对应代码。 不含 S8(删旧列) —— 那要等本次稳定运行之后再说。


1. 部署前检查(缺一不可)

# 1) 确认生产当前版本(应输出 20261003_0040)
docker exec live-hub-py-mysql-1 sh -c \
  'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B douyu_login -e "SELECT version_num FROM alembic_version;"'

# 2) 备份(务必确认文件非空、且能列出来)
docker exec live-hub-py-mysql-1 sh -c \
  'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick \
   --default-character-set=utf8mb4 --routines --triggers douyu_login' \
  | gzip > /root/backup_$(date +%Y%m%d_%H%M).sql.gz
ls -lh /root/backup_*.sql.gz          # 体积应接近 100MB 量级
gzip -t /root/backup_*.sql.gz && echo "备份完整性 OK"

# 3) 磁盘(迁移会给 accounts 加列、重建索引)
df -h /var/lib/docker               # 至少留 5GB

# 4) CK 可解密(部署前基线,部署后要对比)
docker exec -i live-hub-py-douyu-login-1 python - <<'PY'
from web.backend.database import SessionLocal
from web.backend.models import LoginTask
db = SessionLocal()
rows = (db.query(LoginTask).filter(LoginTask.cookie.isnot(None), LoginTask.cookie != "")
        .order_by(LoginTask.id.desc()).limit(5).all())
print([len(t.cookie or "") for t in rows])   # 应都是 ~1600,不是 0
db.close()
PY

⚠️ 第 4 步是本次演练唯一无法在本地做的检查(本地没有生产的加密密钥)。 已在生产上实测:抽样 5 条 5/5 解密正常,长度 1613–1660。


2. 部署执行

顺序:先停应用 → 再跑迁移 → 再起新代码。 不要反过来。

# 1) 停应用(只停 app,数据库别停)
docker compose stop douyu-login

# 2) 跑迁移(实测 9 秒)
docker compose run --rm douyu-login alembic upgrade head

# 3) 起新代码
docker compose up -d douyu-login

为什么先停应用:迁移期间 accounts.cookie 还没回填完,此时旧代码在跑会 继续往 login_tasks 写而新表接到不完整的状态。停机窗口实测 <1 分钟。

迁移做了什么(0041 → 0046)

迁移 内容 破坏性
0041 建 account_events、audit_logs.account_id 无(纯新增)
0042 ★ accounts 加 15 列 + 3 索引;CK/健康/重登进度从 login_tasks 回填;失败即中止的自校验 无(只读旧表)
0043 删掉废弃的收集器作业行 无
0044 ★ bind_status 三列拆解(新增 role_bind_status/esports_opened,归一化 电竞/小店两列并收窄成 varchar(16)) ⚠️ 唯一破坏性的一个
0045 放宽 account_events.source_ref 为可空 无(放宽)
0046 accounts.points_expire_at(积分到期用) 无(纯新增)

0044 是唯一需要留意的:它会覆盖 esports_bind_status / xpd_bind_status 的旧自由文本取值。accounts.bind_status 原值未动(保留到 S8)。 回滚要回到旧代码,必须先跑第 3 节的反向回填。


3. 部署后校验(逐条核对,任一条不过就按第 4 节回滚)

docker exec live-hub-py-mysql-1 sh -c \
  'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -t douyu_login' <<'SQL'
-- ① 账号数不变(应为 15182)
SELECT COUNT(*) AS accounts FROM accounts;

-- ② CK 已搬到账号侧(应为 14323,等于旧侧去重账号数)
SELECT COUNT(*) AS account_side FROM accounts WHERE cookie IS NOT NULL AND cookie <> '';
SELECT COUNT(DISTINCT account_id) AS login_task_side FROM login_tasks
 WHERE cookie IS NOT NULL AND cookie <> '';

-- ③ 精英绑定(预期 bound≈410 / pending≈75 / unbound 14 / 其余 unknown)
SELECT role_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC;

-- ④ 电竞(预期 bound 360 / pending 66 / unbound 26;unbound 就是本次修的映射)
SELECT esports_bind_status, COUNT(*) FROM accounts GROUP BY 1 ORDER BY 2 DESC;

-- ⑤ esports_opened 救回(应为 4)
SELECT COUNT(*) FROM accounts WHERE esports_opened = 1;

-- ⑥ source_ref 可空(应为 YES)
SELECT is_nullable FROM information_schema.columns
 WHERE table_schema='douyu_login' AND table_name='account_events' AND column_name='source_ref';

-- ⑦ 版本(应为 20261004_0046)
SELECT version_num FROM alembic_version;
SQL

功能性验证(新代码起来后):

  1. 打开账号列表 —— 状态卡有分布数字,账号行有 CK / 绑定 / 积分鱼翅 / 标签列
  2. 打开任一账号详情 —— 生命周期状态卡正常、时间线能加载
  3. CK 仍可解密(与部署前基线对比,长度量级一致)
  4. 手动跑一次「账号检测」(选一个账号)—— 应能完成,且该账号详情页时间线出现一条检测事件

4. 回滚预案(顺序不能错)

🔴 关键:accounts.cookie 在降级时会被 drop。S2 之后 CK 只写账号、不双写 login_tasks。所以「部署期间新拿到的 CK」如果不先搬回去,会永久丢失。

CK 丢失是本项目唯一不能承受的损失(重登成功率接近 0、全量重登 ≈ 2.7 天)。 演练验证过:按下面的顺序,部署期间新拿到的 CK 能完整幸存。

# 1) 先反向回填(把账号侧 CK/健康/重登进度搬回 login_tasks)
docker compose run --rm douyu-login \
  python scripts/backfill_login_tasks_from_accounts.py --dry-run   # 先看会改什么

docker compose run --rm douyu-login \
  python scripts/backfill_login_tasks_from_accounts.py --apply

#    脚本自带自检:「账号侧 CK 数」必须 ≤「登录记录侧去重账号数」。
#    输出 «不通过 ❌» 就**不要**继续降级。

# 2) 再降级迁移
docker compose run --rm douyu-login alembic downgrade 20261003_0040

# 3) 最后切回旧代码镜像
docker compose up -d douyu-login      # 用旧 tag

回滚后应确认:

检查 期望
alembic_version 20261003_0040
accounts 行数 不变(15182)
login_tasks 里持有 CK 的去重账号数 ≥ 14323,且包含部署期间新增的
accounts.esports_bind_status 类型 回到 varchar(32)(旧代码要写自由文本)
accounts.bind_status 原值完好(S1–S7 从未改它)
role_bind_status / esports_opened / points_expire_at / account_events 已 drop(预期)

降级实测 2 秒。


5. 已知限制与后续

项 说明
0044 的旧值不可恢复 归一化覆盖了 esports_bind_status/xpd_bind_status 的旧自由文本取值。bind_status 原值保留、可人工比对。回退旧代码不影响(旧代码读 bind_status)。
积分到期金额是「预计」 到期时刻由作业补记,但实际扣了多少没亲眼看到,事件标 confirmed=false。
任务级失败不进时间线 设计上只记成功的事实;失败只在 douyu_tasks.message(保留 90 天)。
应用宝充值未并入 它按游戏角色记账、当前未启用(按决定先放着)。
S8(删旧列) 本次不做。要等稳定运行、且做了逻辑备份 + 反向回填演练之后。
douyu_tasks 837MB 保留策略作业(douyu_tasks_retention)随本次一起上线,但默认关闭,需要时手动打开。首次开启预计清掉 82,185 行(噪声档超 7 天的)。

6. 演练证据(本地生产克隆库)

步骤 结果
克隆生产库 15,182 账号 / 14,324 CK / 版本 0040 / 27 张表 —— 与生产逐项一致
迁移全链 6 个迁移,9 秒,无错误(含 S1 的 fail-closed 自校验)
① 账号数 15,182 ✓ 不变
② CK 搬运 账号侧 14,323 = 登录记录侧去重账号数 14,323(精确相等)✓
③ 精英映射 bound 410 / pending 75 / unbound 14 ✓(与迁移前基线一致)
④ 电竞映射 bound 360 / pending 66 / unbound 26 ✓ ← 修复生效
⑤ esports_opened 4 ✓ ← 抢救生效
⑥ source_ref YES ✓
回滚 6 个迁移降级,2 秒,bind_status 原值完好、两列回到 varchar(32)
回滚前置脚本 模拟「部署期间新拿到的 CK」→ 回填 → 降级 → 该 CK 完整幸存 ✓
生产 CK 解密 抽样 5 条,5/5 正常(长度 1613–1660)✓

附:把生产库同步到本地(测试用)

生产已于 2026-10-04 部署完成(main@2f610a5,迁移 20261004_0046)。 本地已建立一份与生产逐项一致的镜像库,方便后续测试在本地跑。

# 重新同步(默认全量,含 douyu_tasks 的 837MB)
scripts/refresh_local_prod_mirror.sh
# 精简版(跳过 douyu_tasks 行,43MB,够绝大多数测试)
scripts/refresh_local_prod_mirror.sh --no-tasks

然后用镜像库跑应用 / 脚本:

DB_NAME=douyu_login_prod ./dev.sh
# 或
DATABASE_URL="mysql+pymysql://douyu_login:<pw>@127.0.0.1:3307/douyu_login_prod?charset=utf8mb4" ...

当前本地镜像的内容(2026-10-04 建立)

本地 douyu_login_prod
迁移版本 20261004_0046(与生产一致)
账号 / 持有 CK 15,182 / 14,323
登录记录 15,169
douyu_tasks 253,424(含 837MB JSON —— 可用来实测保留策略)
account_events 34(部署后产生的真实事件)
库大小 907 MB

两个关键前提

  1. APP_ENCRYPTION_KEY 必须与生产一致,否则镜像里的 CK 解不开、CK 相关测试全废。 已验证:当前本地 .env 的密钥与生产一致(抽样解密正常,CK 前缀 dy_accounts_main,长度 1659–1702)。
  2. 不要在镜像库上跑测试套件。测试用的是独立库 douyu_login_test (由 TEST_MYSQL_DATABASE 指定,init_test_schema 会 create_all)。 镜像库只用于手工/联调。

踩过的坑(脚本里已规避)

  • 不要把 mysqldump 串进 SSH 管道:实测只有 ~49KB/s(MySQL 读 + gzip + 网络互相拖); 改成"服务器本地 dump 成文件 + rsync"后 ~8MB/s,快两个数量级。
  • 用 rsync 而非 scp:可断点续传。
  • docker compose run 默认读 stdin —— 脚本如果通过 ssh 'bash -s' < 脚本 喂进去, 它会把脚本剩下的行当 stdin 吃掉(这就是本次部署 90 秒停机的根因)。 正确写法:docker compose run -T --rm ... </dev/null。