# 上线清单: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. 部署前检查(缺一不可) ```bash # 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. 部署执行 **顺序:先停应用 → 再跑迁移 → 再起新代码。** 不要反过来。 ```bash # 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 节回滚) ```bash 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 能完整幸存。** ```bash # 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`)。 本地已建立一份**与生产逐项一致**的镜像库,方便后续测试在本地跑。 ```bash # 重新同步(默认全量,含 douyu_tasks 的 837MB) scripts/refresh_local_prod_mirror.sh # 精简版(跳过 douyu_tasks 行,43MB,够绝大多数测试) scripts/refresh_local_prod_mirror.sh --no-tasks ``` 然后用镜像库跑应用 / 脚本: ```bash DB_NAME=douyu_login_prod ./dev.sh # 或 DATABASE_URL="mysql+pymysql://douyu_login:@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 ...