feat(ops): S1–S7 上线清单 + 回滚前置脚本(已在生产克隆库完整演练)
生产还停在 `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(删旧列)本次不做** —— 要等稳定运行 + 逻辑备份之后。
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user