From 2f610a53e3d6157301c19c729164f55191f1e29c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 18:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20S1=E2=80=93S7=20=E4=B8=8A=E7=BA=BF?= =?UTF-8?q?=E6=B8=85=E5=8D=95=20+=20=E5=9B=9E=E6=BB=9A=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=88=E5=B7=B2=E5=9C=A8=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E5=BA=93=E5=AE=8C=E6=95=B4=E6=BC=94=E7=BB=83?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产还停在 `20261003_0040`,S1–S7 一条都没上。本次在**生产克隆库**上把 「迁移 → 校验 → 回滚」完整走了一遍,并把可执行的清单落成文档。 ## 演练结果(克隆库,与生产逐项同构) | 步骤 | 结果 | | --- | --- | | 克隆生产库 | 15,182 账号 / 14,324 CK / 版本 `0040` / 27 张表,与生产一致 | | **迁移全链 0041→0046** | 6 个迁移,**9 秒**,无错误(含 S1 的 fail-closed 自校验) | | 账号数 | 15,182 ✓ 不变 | | **CK 搬运** | 账号侧 14,323 = 登录记录侧**去重账号数 14,323**(精确相等)| | 精英/电竞映射 | bound 410 / pending 75 / unbound 14;电竞 **unbound 26**(修复生效)| | `esports_opened` | **4** ✓(抢救生效)| | `source_ref` 可空 | `YES` ✓ | | **回滚降级** | 6 个迁移,**2 秒**;`bind_status` 原值完好、两列回到 `varchar(32)` | ## 🔴 演练查出的关键风险(不演练绝对发现不了) S2 之后 CK **只写 `accounts.cookie`、刻意不双写** `login_tasks.cookie`。 而降级会 **drop 掉 `accounts.cookie`** —— 也就是: > **部署期间新拿到的 CK,会在回滚时全部丢失。** CK 丢失是本项目唯一不能承受的损失(重登成功率≈0、全量重登≈2.7 天)。 于是新增 `scripts/backfill_login_tasks_from_accounts.py`(**回滚前置**): 把账号侧 CK / 健康 / 重登进度搬回旧列;没有历史行的账号补一条合成行 (否则旧代码靠 `login_tasks` 找不到它的 CK);没有 CK 的账号清掉旧行残留 (否则旧代码以为它还持有 CK)。自带 `--dry-run` 与**自检** (账号侧 ≤ 登录记录侧去重数,不通过就别降级)。 **同样在克隆库上验证过**:模拟「部署期间新拿到一个 CK」→ 跑回填 → 降级 → **该 CK 完整幸存**(task#100 / 账号 168)。不跑回填的话它就没了。 ## 补齐了之前做不到的那项验证 本地没有生产的加密密钥,所以之前只验证了 CK「搬过去了」、没验证「能解密」。 这次**在生产上只读实测**:抽样 5 条 `login_tasks.cookie`,**5/5 解密正常** (长度 1613–1660)。这是清单里第 1 节的部署前基线。 ## 交付物 - `docs/上线清单-S1-S7部署.md`:部署前检查 / 执行顺序(**先停应用→迁移→再起代码**)/ 七项逐条校验 / 回滚预案(含正确顺序)/ 已知限制 / 演练证据 - `scripts/backfill_login_tasks_from_accounts.py` + 2 条用例 (搬回已有行、无历史行时补合成行) ## 验证 `TZ=Asia/Shanghai`:**552 passed / 6 skipped**;ruff 全绿。 ⚠️ 清单里写明:**S8(删旧列)本次不做** —— 要等稳定运行 + 逻辑备份之后。 --- docs/上线清单-S1-S7部署.md | 201 +++++++++++++++++++++++++++++++++++++ tests/test_migrations.py | 128 +++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 docs/上线清单-S1-S7部署.md diff --git a/docs/上线清单-S1-S7部署.md b/docs/上线清单-S1-S7部署.md new file mode 100644 index 0000000..fbf59ba --- /dev/null +++ b/docs/上线清单-S1-S7部署.md @@ -0,0 +1,201 @@ +# 上线清单: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)✓ | diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 21575b8..29374c7 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -153,3 +153,131 @@ class TestMigrationSmoke: database_module.DATABASE_URL = original_url with admin_engine.begin() as conn: conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) + + +class TestRollbackBackfillScript: + """回滚前置脚本:把账号侧 CK 搬回 `login_tasks`。 + + 为什么必须有它:S2 之后 CK **只写 `accounts.cookie`、不双写** `login_tasks`, + 而降级会 drop 掉 `accounts.cookie` —— 不先搬回去, + **部署期间新拿到的 CK 会永久丢失**(本项目唯一不能承受的损失)。 + + 演练验证过:跑了它再降级,部署期间新拿到的 CK 完整幸存。 + """ + + def test_moves_account_cookie_back_to_login_tasks(self): + import importlib.util + from pathlib import Path + + import web.backend.database as db_module + from tests.support.db import make_test_session + from web.backend.models import Account, LoginTask, User + + path = Path(PROJECT_ROOT) / "scripts" / "backfill_login_tasks_from_accounts.py" + spec = importlib.util.spec_from_file_location("rollback_backfill", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + engine = get_test_engine() + db_module.SessionLocal.configure(bind=engine) + db = make_test_session(engine) + try: + db.query(LoginTask).filter(LoginTask.batch_id == "rb-test").delete() + db.commit() + user = User(username="rb-user", password_hash="h", role="super_admin") + db.add(user) + db.commit() + account = Account( + username="rb-acc", + password="p", + email="e@e.com", + email_password="ep", + ) + db.add(account) + db.commit() + # 账号侧有新 CK;登录记录侧还是旧值(模拟「部署期间新拿到」) + account.cookie = "PHPSESSID=NEW_DEPLOY_WINDOW_CK" + db.add( + LoginTask( + batch_id="rb-test", + account_id=account.id, + status="success", + message="旧行", + cookie="PHPSESSID=OLD", + created_by=user.id, + ) + ) + db.commit() + + result = module.run(db, apply=True) + assert result["updated"] >= 1 + + db.expire_all() + tasks = ( + db.query(LoginTask) + .filter( + LoginTask.account_id == account.id, + LoginTask.cookie.isnot(None), + LoginTask.cookie != "", + ) + .all() + ) + assert any(t.cookie == "PHPSESSID=NEW_DEPLOY_WINDOW_CK" for t in tasks), ( + "部署期间的新 CK 没有搬回登录记录 —— 降级会丢" + ) + finally: + db.close() + + def test_creates_synthetic_row_when_no_history(self): + """新代码跑期间新导入、没有任何登录记录的账号,也要能回填。 + + 旧代码靠 `login_tasks` 找 CK,没有行就等于「这账号没 CK」。 + """ + import importlib.util + from pathlib import Path + + import web.backend.database as db_module + from tests.support.db import make_test_session + from web.backend.models import Account, LoginTask, User + + path = Path(PROJECT_ROOT) / "scripts" / "backfill_login_tasks_from_accounts.py" + spec = importlib.util.spec_from_file_location("rollback_backfill2", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + engine = get_test_engine() + db_module.SessionLocal.configure(bind=engine) + db = make_test_session(engine) + try: + user = User(username="rb-user2", password_hash="h", role="super_admin") + db.add(user) + db.commit() + account = Account( + username="rb-orphan", + password="p", + email="e@e.com", + email_password="ep", + cookie="PHPSESSID=ORPHAN_CK", + ) + db.add(account) + db.commit() + assert ( + db.query(LoginTask).filter(LoginTask.account_id == account.id).count() + == 0 + ) + + module.run(db, apply=True) + db.expire_all() + row = ( + db.query(LoginTask) + .filter(LoginTask.account_id == account.id) + .order_by(LoginTask.id.desc()) + .first() + ) + assert row is not None, "没有历史行时必须补一条合成行" + assert row.cookie == "PHPSESSID=ORPHAN_CK" + assert row.batch_id == module.BACKFILL_BATCH + finally: + db.close()