上一版把可见性改成「记录里还有 CK」,账号不会再消失,但根因还在:批次启动 时就把复用的成功记录改成 pending(旧 CK 仍在),停止或失败后没人恢复状态, 记录一直显示成一次没完成的登录。 按「替换 CK 是最后一步」把两者拆开: - login_tasks 新增 attempt_status / attempt_message / attempt_batch_id / attempt_at,批次只写 attempt_*,status/cookie/message 只在登录成功时更新; - _begin_attempt 只登记尝试,_mark_attempt_failed 只记录失败(已有 CK 的记录 原样不动),_write_login_success 是唯一替换旧 CK 的入口; - 停止批次、代理不可用、登录异常、进程重启清理全部改为收敛 attempt_*, 因此停止一个批次不可能再让账号失去 CK; - 复用的记录改用「持有 CK 的记录」,不再创建第二条记录,失败记录在无 CK 时 才复用; - 登录任务列表/统计按 attempt_* 展示「这次登录怎么样了」,批次筛选同时匹配 attempt_batch_id;CK 操作页的重登状态也从 attempt_* 映射; - 迁移 20261001_0035 归一存量数据:持有 CK 的记录 status 统一为 success、 历史状态搬进 attempt_*,无 CK 且卡在 pending/running 的收敛为 failed。 新增测试:批次失败/停止不碰旧 CK、成功后替换 CK、列表展示尝试状态、 迁移存量数据归一。
153 lines
5.7 KiB
Python
153 lines
5.7 KiB
Python
"""迁移 20261001_0035 的存量数据归一验证。
|
||
|
||
线上升级时要保证:
|
||
- 持有 CK 的记录 status 归一到 success,历史批次状态搬进 attempt_*;
|
||
- 没有 CK 且卡在 pending/running 的记录收敛为 failed;
|
||
- 任何情况下都不丢 CK。
|
||
"""
|
||
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
from sqlalchemy import create_engine, text
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _migration_config(url: str) -> Config:
|
||
config = Config(str(PROJECT_ROOT / "alembic.ini"))
|
||
config.set_main_option(
|
||
"script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")
|
||
)
|
||
config.set_main_option("sqlalchemy.url", url)
|
||
return config
|
||
|
||
|
||
def _insert_task(conn, *, status: str, cookie: str, message: str = "") -> None:
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO login_tasks
|
||
(batch_id, account_id, status, cookie, message, created_by,
|
||
created_at, finished_at)
|
||
VALUES
|
||
('batch-1', 1, :status, :cookie, :message, 1,
|
||
'2026-09-30 00:00:00', '2026-09-30 01:00:00')
|
||
"""
|
||
),
|
||
{"status": status, "cookie": cookie, "message": message},
|
||
)
|
||
|
||
|
||
class TestLoginAttemptMigration:
|
||
def setup_method(self):
|
||
import web.backend.database as database_module
|
||
|
||
self.database_module = database_module
|
||
self.original_url = database_module.DATABASE_URL
|
||
self.tmpdir = tempfile.TemporaryDirectory()
|
||
self.url = f"sqlite:///{Path(self.tmpdir.name) / 'upgrade.db'}"
|
||
# env.py 用模块级 DATABASE_URL 覆盖配置里的连接串。
|
||
database_module.DATABASE_URL = self.url
|
||
self.config = _migration_config(self.url)
|
||
command.upgrade(self.config, "20260901_0034")
|
||
|
||
self.engine = create_engine(self.url)
|
||
with self.engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO accounts
|
||
(id, username, password, email, email_password,
|
||
email_imap_server, email_imap_port, email_imap_ssl,
|
||
tag, remark, uid, nickname, created_at, updated_at)
|
||
VALUES
|
||
(1, '远行者103', 'p', 'e@example.com', 'm',
|
||
'', 993, 1, '', '', '', '', '2026-09-30 00:00:00',
|
||
'2026-09-30 00:00:00')
|
||
"""
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO users (id, username, password_hash, role, is_active)
|
||
VALUES (1, 'admin', 'hash', 'super_admin', 1)
|
||
"""
|
||
)
|
||
)
|
||
_insert_task(conn, status="success", cookie="ck-success")
|
||
_insert_task(
|
||
conn,
|
||
status="relogin_failed",
|
||
cookie="ck-relogin",
|
||
message="重新登录失败: 密码错误(旧 Cookie 已保留)",
|
||
)
|
||
_insert_task(conn, status="pending", cookie="ck-pending")
|
||
_insert_task(conn, status="pending", cookie="")
|
||
_insert_task(conn, status="running", cookie="")
|
||
_insert_task(conn, status="failed", cookie="ck-failed", message="登录失败")
|
||
|
||
def teardown_method(self):
|
||
self.database_module.DATABASE_URL = self.original_url
|
||
self.engine.dispose()
|
||
self.tmpdir.cleanup()
|
||
|
||
def _rows(self) -> list[dict]:
|
||
with self.engine.connect() as conn:
|
||
rows = conn.execute(
|
||
text(
|
||
"""
|
||
SELECT status, cookie, message, attempt_status,
|
||
attempt_message, attempt_batch_id, attempt_at
|
||
FROM login_tasks
|
||
ORDER BY id
|
||
"""
|
||
)
|
||
).mappings()
|
||
return [dict(row) for row in rows]
|
||
|
||
def test_upgrade_normalizes_existing_rows(self):
|
||
command.upgrade(self.config, "head")
|
||
|
||
rows = self._rows()
|
||
(
|
||
success,
|
||
relogin_failed,
|
||
pending_with_ck,
|
||
pending_without_ck,
|
||
running_without_ck,
|
||
failed_with_ck,
|
||
) = rows
|
||
|
||
# 已经成功的记录:status 不变,也没有历史尝试需要搬迁
|
||
assert success["status"] == "success"
|
||
assert success["cookie"] == "ck-success"
|
||
assert success["attempt_status"] in (None, "")
|
||
|
||
# 重登失败:CK 保留,status 归一为 success,失败信息进 attempt_*
|
||
assert relogin_failed["cookie"] == "ck-relogin"
|
||
assert relogin_failed["status"] == "success"
|
||
assert relogin_failed["attempt_status"] == "failed"
|
||
assert "旧 Cookie 已保留" in relogin_failed["attempt_message"]
|
||
assert relogin_failed["attempt_batch_id"] == "batch-1"
|
||
|
||
# 卡在 pending 但持有 CK:同样归一为 success + 中断说明
|
||
assert pending_with_ck["cookie"] == "ck-pending"
|
||
assert pending_with_ck["status"] == "success"
|
||
assert pending_with_ck["attempt_status"] == "failed"
|
||
assert "旧 Cookie 已保留" in pending_with_ck["attempt_message"]
|
||
|
||
# 没有 CK 的在途记录:收敛为失败,不再永久排队
|
||
for row in (pending_without_ck, running_without_ck):
|
||
assert row["status"] == "failed"
|
||
assert row["attempt_status"] == "failed"
|
||
assert "服务重启" in row["message"]
|
||
|
||
# 复用记录上的旧 CK 不能被清掉
|
||
assert failed_with_ck["status"] == "success"
|
||
assert failed_with_ck["cookie"] == "ck-failed"
|
||
assert failed_with_ck["attempt_status"] == "failed"
|