mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
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:
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey, Usage
|
||||
from src.services.user.bulk_cleanup import pre_clean_api_key
|
||||
|
||||
|
||||
class ApiKeyService:
|
||||
@@ -82,8 +83,7 @@ class ApiKeyService:
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(
|
||||
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, "
|
||||
f"独立Key={is_standalone}"
|
||||
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, " f"独立Key={is_standalone}"
|
||||
)
|
||||
return api_key, key # 返回密钥对象和明文密钥
|
||||
|
||||
@@ -245,7 +245,8 @@ class ApiKeyService:
|
||||
)
|
||||
|
||||
if should_delete:
|
||||
# 物理删除(Usage记录会保留,因为是 SET NULL)
|
||||
# 物理删除(Usage / RequestCandidate / VideoTask 等记录保留)
|
||||
pre_clean_api_key(db, api_key.id)
|
||||
db.delete(api_key)
|
||||
logger.info(
|
||||
f"删除过期API密钥: ID {api_key.id}, 名称 {api_key.name}, "
|
||||
|
||||
111
src/services/user/bulk_cleanup.py
Normal file
111
src/services/user/bulk_cleanup.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.models.database import RequestCandidate, Usage
|
||||
|
||||
_POSTGRES_BATCH_SIZE = 2000
|
||||
_SQLITE_BATCH_SIZE = 900
|
||||
|
||||
|
||||
def _resolve_batch_size(db: Session) -> int:
|
||||
try:
|
||||
bind = db.get_bind()
|
||||
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower()
|
||||
except Exception:
|
||||
dialect_name = ""
|
||||
|
||||
if dialect_name == "sqlite":
|
||||
return _SQLITE_BATCH_SIZE
|
||||
return _POSTGRES_BATCH_SIZE
|
||||
|
||||
|
||||
def batch_nullify_fk(
|
||||
db: Session,
|
||||
model: type[Any],
|
||||
column_name: str,
|
||||
entity_id: str | None,
|
||||
) -> int:
|
||||
"""分批将大表外键置空,避免单个长事务阻塞删除流程。"""
|
||||
if not entity_id:
|
||||
return 0
|
||||
|
||||
column = getattr(model, column_name)
|
||||
primary_key_column = next(iter(model.__table__.primary_key.columns))
|
||||
batch_size = _resolve_batch_size(db)
|
||||
total_updated = 0
|
||||
batch_index = 0
|
||||
started_at = time.monotonic()
|
||||
|
||||
while True:
|
||||
batch_ids = [
|
||||
row[0]
|
||||
for row in db.query(primary_key_column)
|
||||
.filter(column == entity_id)
|
||||
.limit(batch_size)
|
||||
.all()
|
||||
]
|
||||
if not batch_ids:
|
||||
break
|
||||
|
||||
batch_index += 1
|
||||
batch_started_at = time.monotonic()
|
||||
updated = int(
|
||||
db.query(model)
|
||||
.filter(primary_key_column.in_(batch_ids))
|
||||
.update({column: None}, synchronize_session=False)
|
||||
or 0
|
||||
)
|
||||
db.commit()
|
||||
|
||||
total_updated += updated
|
||||
elapsed_ms = int((time.monotonic() - batch_started_at) * 1000)
|
||||
logger.info(
|
||||
"批量清理 {}.{}: batch={}, updated={}, entity_id={}, elapsed_ms={}",
|
||||
model.__tablename__,
|
||||
column_name,
|
||||
batch_index,
|
||||
updated,
|
||||
entity_id,
|
||||
elapsed_ms,
|
||||
)
|
||||
|
||||
if len(batch_ids) < batch_size:
|
||||
break
|
||||
|
||||
if total_updated > 0:
|
||||
total_elapsed_ms = int((time.monotonic() - started_at) * 1000)
|
||||
logger.info(
|
||||
"批量清理完成 {}.{}: total_updated={}, entity_id={}, elapsed_ms={}",
|
||||
model.__tablename__,
|
||||
column_name,
|
||||
total_updated,
|
||||
entity_id,
|
||||
total_elapsed_ms,
|
||||
)
|
||||
|
||||
return total_updated
|
||||
|
||||
|
||||
def pre_clean_api_key(db: Session, api_key_id: str | None) -> int:
|
||||
"""预清理 API Key 在大表中的外键引用,减少后续删除锁竞争。"""
|
||||
if not api_key_id:
|
||||
return 0
|
||||
|
||||
usage_rows = batch_nullify_fk(db, Usage, "api_key_id", api_key_id)
|
||||
candidate_rows = batch_nullify_fk(db, RequestCandidate, "api_key_id", api_key_id)
|
||||
total_rows = usage_rows + candidate_rows
|
||||
|
||||
if total_rows > 0:
|
||||
logger.info(
|
||||
"API Key 预清理完成: api_key_id={}, usage={}, request_candidates={}",
|
||||
api_key_id,
|
||||
usage_rows,
|
||||
candidate_rows,
|
||||
)
|
||||
|
||||
return total_rows
|
||||
@@ -15,6 +15,7 @@ from src.core.logger import logger
|
||||
from src.core.validators import EmailValidator, PasswordValidator, UsernameValidator
|
||||
from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User, UserRole
|
||||
from src.services.cache.user_cache import UserCacheService
|
||||
from src.services.user.bulk_cleanup import batch_nullify_fk, pre_clean_api_key
|
||||
from src.utils.transaction_manager import retry_on_database_error, transactional
|
||||
|
||||
|
||||
@@ -252,15 +253,15 @@ class UserService:
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@transactional()
|
||||
def delete_user(db: Session, user_id: str) -> bool:
|
||||
"""删除用户(硬删除)
|
||||
|
||||
删除流程:
|
||||
1. 检查未完结账务,阻止删除
|
||||
2. 手动删除 ORM cascade 冲突的子记录
|
||||
3. 删除用户记录
|
||||
4. 财务记录(Wallet/PaymentOrder/RefundRequest/WalletTransaction)和
|
||||
2. 预清理 Usage / RequestCandidate 等大表外键
|
||||
3. 手动删除 ORM cascade 冲突的子记录
|
||||
4. 删除用户记录
|
||||
5. 财务记录(Wallet/PaymentOrder/RefundRequest/WalletTransaction)和
|
||||
Usage 记录保留,外键 SET NULL,由自动清理策略统一回收
|
||||
"""
|
||||
from src.models.database import (
|
||||
@@ -268,6 +269,7 @@ class UserService:
|
||||
ApiKey,
|
||||
PaymentOrder,
|
||||
RefundRequest,
|
||||
RequestCandidate,
|
||||
UserPreference,
|
||||
Wallet,
|
||||
)
|
||||
@@ -312,28 +314,39 @@ class UserService:
|
||||
if pending_order_count > 0:
|
||||
raise ValueError("用户存在未完结充值订单,禁止删除")
|
||||
|
||||
# 手动删除子记录,避免 SQLAlchemy 的 ORM cascade 与数据库 CASCADE 冲突
|
||||
# (UserPreference/AnnouncementRead 的数据库外键是 ON DELETE CASCADE,
|
||||
# 但 SQLAlchemy 会先尝试 UPDATE SET NULL 导致冲突)
|
||||
db.query(UserPreference).filter(UserPreference.user_id == user_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
api_key_ids = [
|
||||
api_key_id
|
||||
for (api_key_id,) in db.query(ApiKey.id).filter(ApiKey.user_id == user_id).all()
|
||||
]
|
||||
api_key_count = len(api_key_ids)
|
||||
|
||||
# 财务记录(Wallet/WalletTransaction/PaymentOrder/RefundRequest/PaymentCallback)
|
||||
# 和 Usage 记录全部保留,数据库外键 SET NULL 自动断开关联,
|
||||
# 由自动清理策略统一回收。
|
||||
# 注意:batch_nullify_fk 内部分批 commit,预清理部分不可回滚。
|
||||
# 这是预期行为:SET NULL 是幂等操作,即使后续步骤失败,
|
||||
# 已置空的外键不影响数据完整性,重新执行删除即可。
|
||||
try:
|
||||
for api_key_id in api_key_ids:
|
||||
pre_clean_api_key(db, api_key_id)
|
||||
|
||||
api_key_count = int(
|
||||
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user_id).scalar() or 0
|
||||
)
|
||||
db.query(ApiKey).filter(ApiKey.user_id == user_id).delete(synchronize_session=False)
|
||||
batch_nullify_fk(db, Usage, "user_id", user_id)
|
||||
batch_nullify_fk(db, RequestCandidate, "user_id", user_id)
|
||||
|
||||
# 现在删除用户(Usage, AuditLog, RequestAttempt 会通过数据库 SET NULL 保留)
|
||||
db.delete(user)
|
||||
db.commit() # 立即提交事务,释放数据库锁
|
||||
db.query(UserPreference).filter(UserPreference.user_id == user_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
# 财务记录(Wallet/WalletTransaction/PaymentOrder/RefundRequest/PaymentCallback)
|
||||
# 和 Usage / RequestCandidate / VideoTask 记录全部保留,数据库外键 SET NULL 自动断开关联。
|
||||
db.query(ApiKey).filter(ApiKey.user_id == user_id).delete(synchronize_session=False)
|
||||
|
||||
# 现在删除用户(Usage, AuditLog, RequestAttempt 等会通过数据库 SET NULL 保留)
|
||||
db.delete(user)
|
||||
db.commit() # 立即提交事务,释放数据库锁
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
# 清除用户缓存
|
||||
asyncio.create_task(UserCacheService.invalidate_user_cache(user_id, email))
|
||||
|
||||
Reference in New Issue
Block a user