feat(retention): 删除用户/Key 时保留历史记录,外键改 SET NULL 并添加名称快照

- Usage/RequestCandidate/VideoTask/Stats 等表的 user_id/api_key_id 外键从
  CASCADE 改为 SET NULL,删除用户或 Key 后历史记录不再丢失
- 各表添加 username/api_key_name 快照字段,删除后仍可追溯归属
- 新增 bulk_cleanup 模块,分批置空大表外键避免长事务锁
- 删除用户/Key 流程集成预清理步骤,先置空再删除
- 精简 candidate_builder 冗余 debug 日志
- 修复 proxy_nodes 启动日志 format 占位符错误({} -> %s)
- 前端批量操作请求增加 5 分钟超时配置
This commit is contained in:
fawney19
2026-03-08 14:31:15 +08:00
parent 25c33846be
commit bd3f73c2fc
26 changed files with 799 additions and 96 deletions

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from src.services.user.bulk_cleanup import batch_nullify_fk
Base = declarative_base()
class DemoRow(Base):
__tablename__ = "demo_rows"
id = Column(Integer, primary_key=True, autoincrement=True)
ref_id = Column(String(36), nullable=True)
def test_batch_nullify_fk_handles_sqlite_batches() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine)
db: Session = session_factory()
try:
db.add_all([DemoRow(ref_id="entity-1") for _ in range(905)])
db.add_all([DemoRow(ref_id="entity-2") for _ in range(3)])
db.commit()
updated = batch_nullify_fk(db, DemoRow, "ref_id", "entity-1")
assert updated == 905
assert db.query(DemoRow).filter(DemoRow.ref_id.is_(None)).count() == 905
assert db.query(DemoRow).filter(DemoRow.ref_id == "entity-2").count() == 3
finally:
db.close()

View File

@@ -1,11 +1,19 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from src.models.database import PaymentOrder, RefundRequest, User, Wallet
from src.models.database import (
ApiKey,
PaymentOrder,
RefundRequest,
RequestCandidate,
Usage,
User,
Wallet,
)
from src.services.user.service import UserService
@@ -40,7 +48,7 @@ def test_delete_user_blocks_when_unfinished_refund_exists() -> None:
db.query.side_effect = _query
with pytest.raises(ValueError, match="未完结退款"):
UserService.delete_user.__wrapped__(db, "user-1")
UserService.delete_user(db, "user-1")
db.delete.assert_not_called()
@@ -82,6 +90,68 @@ def test_delete_user_blocks_when_unfinished_payment_order_exists() -> None:
db.query.side_effect = _query
with pytest.raises(ValueError, match="未完结充值订单"):
UserService.delete_user.__wrapped__(db, "user-2")
UserService.delete_user(db, "user-2")
db.delete.assert_not_called()
def test_delete_user_precleans_large_tables_before_final_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
user = SimpleNamespace(id="user-3", email="u3@example.com")
user_query = MagicMock()
user_query.filter.return_value = user_query
user_query.first.return_value = user
wallet_ids_query = MagicMock()
wallet_ids_query.outerjoin.return_value = wallet_ids_query
wallet_ids_query.filter.return_value = wallet_ids_query
wallet_ids_query.all.return_value = []
api_key_ids_query = MagicMock()
api_key_ids_query.filter.return_value = api_key_ids_query
api_key_ids_query.all.return_value = [("key-1",), ("key-2",)]
delete_query = MagicMock()
delete_query.filter.return_value = delete_query
delete_query.delete.return_value = 2
db = MagicMock()
def _query(model: object) -> MagicMock:
if model is User:
return user_query
if model is Wallet.id:
return wallet_ids_query
if model is ApiKey.id:
return api_key_ids_query
return delete_query
db.query.side_effect = _query
pre_clean_api_key = MagicMock()
batch_nullify_fk = MagicMock()
invalidate_user_cache = AsyncMock()
create_task = MagicMock()
monkeypatch.setattr("src.services.user.service.pre_clean_api_key", pre_clean_api_key)
monkeypatch.setattr("src.services.user.service.batch_nullify_fk", batch_nullify_fk)
monkeypatch.setattr(
"src.services.user.service.UserCacheService.invalidate_user_cache",
invalidate_user_cache,
)
monkeypatch.setattr("src.services.user.service.asyncio.create_task", create_task)
assert UserService.delete_user(db, "user-3") is True
assert pre_clean_api_key.call_args_list == [call(db, "key-1"), call(db, "key-2")]
assert batch_nullify_fk.call_args_list == [
call(db, Usage, "user_id", "user-3"),
call(db, RequestCandidate, "user_id", "user-3"),
]
db.delete.assert_called_once_with(user)
db.commit.assert_called_once()
db.rollback.assert_not_called()
invalidate_user_cache.assert_called_once_with("user-3", "u3@example.com")
create_task.assert_called_once()