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,166 @@
"""usage stats retention: SET NULL on delete and add name snapshots
Revision ID: 45b118150a78
Revises: 2d932114930d
Create Date: 2026-03-08 03:48:49.622091+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '45b118150a78'
down_revision = '2d932114930d'
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# Idempotent helpers
# ---------------------------------------------------------------------------
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE table_name = :table AND constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
return result.scalar() is not None
def _fk_ondelete(table_name: str, constraint_name: str) -> str | None:
"""Return the ON DELETE action for a foreign key, or None if it doesn't exist."""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = :table AND tc.constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
row = result.first()
return row[0] if row else None
def _replace_fk_if_needed(
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = _fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return # already correct
if current:
op.drop_constraint(constraint_name, table_name, type_='foreignkey')
op.create_foreign_key(
constraint_name, table_name, ref_table,
local_cols, remote_cols, ondelete=desired_ondelete,
)
def upgrade() -> None:
# --- Usage: add name snapshot columns ---
if not _column_exists('usage', 'username'):
op.add_column('usage', sa.Column('username', sa.String(100), nullable=True,
comment='用户名快照'))
if not _column_exists('usage', 'api_key_name'):
op.add_column('usage', sa.Column('api_key_name', sa.String(200), nullable=True,
comment='API Key 名称快照'))
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
_replace_fk_if_needed(
'stats_user_daily_user_id_fkey', 'stats_user_daily',
'users', ['user_id'], ['id'], 'SET NULL',
)
op.alter_column('stats_user_daily', 'user_id', existing_type=sa.String(36), nullable=True)
if not _column_exists('stats_user_daily', 'username'):
op.add_column('stats_user_daily', sa.Column('username', sa.String(100), nullable=True,
comment='用户名快照(删除用户后仍可追溯)'))
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
_replace_fk_if_needed(
'stats_daily_api_key_api_key_id_fkey', 'stats_daily_api_key',
'api_keys', ['api_key_id'], ['id'], 'SET NULL',
)
op.alter_column('stats_daily_api_key', 'api_key_id', existing_type=sa.String(36),
nullable=True)
if not _column_exists('stats_daily_api_key', 'api_key_name'):
op.add_column('stats_daily_api_key', sa.Column('api_key_name', sa.String(200),
nullable=True,
comment='API Key 名称快照(删除 Key 后仍可追溯)'))
# --- Backfill: populate snapshots from existing FK joins ---
# (WHERE ... IS NULL makes these inherently idempotent)
op.execute("""
UPDATE usage u
SET username = usr.username
FROM users usr
WHERE u.user_id = usr.id AND u.username IS NULL
""")
op.execute("""
UPDATE usage u
SET api_key_name = ak.name
FROM api_keys ak
WHERE u.api_key_id = ak.id AND u.api_key_name IS NULL
""")
op.execute("""
UPDATE stats_user_daily s
SET username = usr.username
FROM users usr
WHERE s.user_id = usr.id AND s.username IS NULL
""")
op.execute("""
UPDATE stats_daily_api_key s
SET api_key_name = ak.name
FROM api_keys ak
WHERE s.api_key_id = ak.id AND s.api_key_name IS NULL
""")
def downgrade() -> None:
# --- Remove snapshot columns ---
if _column_exists('stats_daily_api_key', 'api_key_name'):
op.drop_column('stats_daily_api_key', 'api_key_name')
if _column_exists('stats_user_daily', 'username'):
op.drop_column('stats_user_daily', 'username')
if _column_exists('usage', 'api_key_name'):
op.drop_column('usage', 'api_key_name')
if _column_exists('usage', 'username'):
op.drop_column('usage', 'username')
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
_replace_fk_if_needed(
'stats_daily_api_key_api_key_id_fkey', 'stats_daily_api_key',
'api_keys', ['api_key_id'], ['id'], 'CASCADE',
)
op.alter_column('stats_daily_api_key', 'api_key_id', existing_type=sa.String(36),
nullable=False)
# --- StatsUserDaily: SET NULL -> CASCADE ---
_replace_fk_if_needed(
'stats_user_daily_user_id_fkey', 'stats_user_daily',
'users', ['user_id'], ['id'], 'CASCADE',
)
op.alter_column('stats_user_daily', 'user_id', existing_type=sa.String(36), nullable=False)

View File

@@ -0,0 +1,208 @@
"""request_candidates/video_tasks retention: SET NULL and add snapshots
Revision ID: 13a4c8f6d9e0
Revises: 45b118150a78
Create Date: 2026-03-08 12:15:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "13a4c8f6d9e0"
down_revision = "45b118150a78"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# Idempotent helpers
# ---------------------------------------------------------------------------
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE table_name = :table AND constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
return result.scalar() is not None
def _fk_ondelete(table_name: str, constraint_name: str) -> str | None:
"""Return the ON DELETE action for a foreign key, or None if it doesn't exist."""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
"WHERE tc.table_name = :table AND tc.constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
row = result.first()
return row[0] if row else None
def _replace_fk_if_needed(
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = _fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return # already correct
if current:
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name, table_name, ref_table,
local_cols, remote_cols, ondelete=desired_ondelete,
)
def upgrade() -> None:
# --- request_candidates: add snapshot columns ---
if not _column_exists("request_candidates", "username"):
op.add_column(
"request_candidates",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
)
if not _column_exists("request_candidates", "api_key_name"):
op.add_column(
"request_candidates",
sa.Column(
"api_key_name",
sa.String(length=200),
nullable=True,
comment="API Key 名称快照",
),
)
# --- request_candidates: CASCADE -> SET NULL ---
_replace_fk_if_needed(
"request_candidates_user_id_fkey", "request_candidates",
"users", ["user_id"], ["id"], "SET NULL",
)
_replace_fk_if_needed(
"request_candidates_api_key_id_fkey", "request_candidates",
"api_keys", ["api_key_id"], ["id"], "SET NULL",
)
# --- video_tasks: add snapshot columns ---
if not _column_exists("video_tasks", "username"):
op.add_column(
"video_tasks",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
)
if not _column_exists("video_tasks", "api_key_name"):
op.add_column(
"video_tasks",
sa.Column(
"api_key_name",
sa.String(length=200),
nullable=True,
comment="API Key 名称快照",
),
)
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
_replace_fk_if_needed(
"video_tasks_user_id_fkey", "video_tasks",
"users", ["user_id"], ["id"], "SET NULL",
)
_replace_fk_if_needed(
"video_tasks_api_key_id_fkey", "video_tasks",
"api_keys", ["api_key_id"], ["id"], "SET NULL",
)
# --- Backfill: populate snapshots from existing FK joins ---
# (WHERE ... IS NULL makes these inherently idempotent)
op.execute(
"""
UPDATE request_candidates
SET username = (
SELECT users.username FROM users WHERE users.id = request_candidates.user_id
)
WHERE username IS NULL AND user_id IS NOT NULL
"""
)
op.execute(
"""
UPDATE request_candidates
SET api_key_name = (
SELECT api_keys.name FROM api_keys WHERE api_keys.id = request_candidates.api_key_id
)
WHERE api_key_name IS NULL AND api_key_id IS NOT NULL
"""
)
op.execute(
"""
UPDATE video_tasks
SET username = (
SELECT users.username FROM users WHERE users.id = video_tasks.user_id
)
WHERE username IS NULL AND user_id IS NOT NULL
"""
)
op.execute(
"""
UPDATE video_tasks
SET api_key_name = (
SELECT api_keys.name FROM api_keys WHERE api_keys.id = video_tasks.api_key_id
)
WHERE api_key_name IS NULL AND api_key_id IS NOT NULL
"""
)
def downgrade() -> None:
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
_replace_fk_if_needed(
"video_tasks_api_key_id_fkey", "video_tasks",
"api_keys", ["api_key_id"], ["id"], "NO ACTION",
)
_replace_fk_if_needed(
"video_tasks_user_id_fkey", "video_tasks",
"users", ["user_id"], ["id"], "NO ACTION",
)
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False)
if _column_exists("video_tasks", "api_key_name"):
op.drop_column("video_tasks", "api_key_name")
if _column_exists("video_tasks", "username"):
op.drop_column("video_tasks", "username")
# --- request_candidates: SET NULL -> CASCADE ---
_replace_fk_if_needed(
"request_candidates_api_key_id_fkey", "request_candidates",
"api_keys", ["api_key_id"], ["id"], "CASCADE",
)
_replace_fk_if_needed(
"request_candidates_user_id_fkey", "request_candidates",
"users", ["user_id"], ["id"], "CASCADE",
)
if _column_exists("request_candidates", "api_key_name"):
op.drop_column("request_candidates", "api_key_name")
if _column_exists("request_candidates", "username"):
op.drop_column("request_candidates", "username")

View File

@@ -2,6 +2,8 @@ import client from '../client'
import { dedupedRequest } from '@/utils/cache' import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, ProxyConfig } from './types/provider' import type { AllowedModels, ProxyConfig } from './types/provider'
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
export interface PoolKeyStatus { export interface PoolKeyStatus {
key_id: string key_id: string
key_name: string key_name: string
@@ -212,13 +214,21 @@ export async function batchActionPoolKeys(
providerId: string, providerId: string,
body: PoolBatchAction, body: PoolBatchAction,
): Promise<{ affected: number; message: string }> { ): Promise<{ affected: number; message: string }> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-action`, body) const response = await client.post(
`/api/admin/pool/${providerId}/keys/batch-action`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data return response.data
} }
export async function cleanupBannedPoolKeys( export async function cleanupBannedPoolKeys(
providerId: string, providerId: string,
): Promise<{ affected: number; message: string }> { ): Promise<{ affected: number; message: string }> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/cleanup-banned`) const response = await client.post(
`/api/admin/pool/${providerId}/keys/cleanup-banned`,
undefined,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data return response.data
} }

View File

@@ -23,6 +23,7 @@ from src.database import get_db
from src.models.api import CreateApiKeyRequest from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey, Wallet from src.models.database import ApiKey, Wallet
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key
from src.services.wallet import WalletService from src.services.wallet import WalletService
# 应用时区配置,默认为 Asia/Shanghai # 应用时区配置,默认为 Asia/Shanghai
@@ -349,10 +350,7 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
db = context.db db = context.db
# 独立Key支持无限制额度initial_balance_usd = null # 独立Key支持无限制额度initial_balance_usd = null
if ( if self.key_data.initial_balance_usd is not None and self.key_data.initial_balance_usd <= 0:
self.key_data.initial_balance_usd is not None
and self.key_data.initial_balance_usd <= 0
):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="创建独立余额Key时初始余额必须大于 0或设置为 null 表示无限制)", detail="创建独立余额Key时初始余额必须大于 0或设置为 null 表示无限制)",
@@ -568,6 +566,7 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
raise InvalidRequestException("仅支持删除独立密钥") raise InvalidRequestException("仅支持删除独立密钥")
user = api_key.user user = api_key.user
pre_clean_api_key(db, api_key.id)
db.delete(api_key) db.delete(api_key)
db.commit() db.commit()

View File

@@ -21,6 +21,7 @@ from src.models.api import CreateApiKeyRequest, CreateUserRequest
from src.models.database import ApiKey, User, UserRole from src.models.database import ApiKey, User, UserRole
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key
from src.services.user.service import UserService from src.services.user.service import UserService
from src.services.wallet import WalletService from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
@@ -584,6 +585,7 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
if not api_key: if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key") raise NotFoundException("API Key不存在或不属于该用户", "api_key")
pre_clean_api_key(db, api_key.id)
db.delete(api_key) db.delete(api_key)
db.commit() db.commit()

View File

@@ -634,6 +634,8 @@ class GeminiVeoHandler(VideoHandlerBase):
external_task_id=external_task_id, external_task_id=external_task_id,
user_id=self.user.id, user_id=self.user.id,
api_key_id=self.api_key.id, api_key_id=self.api_key.id,
username=self.user.username,
api_key_name=self.api_key.name,
provider_id=candidate.provider.id, provider_id=candidate.provider.id,
endpoint_id=candidate.endpoint.id, endpoint_id=candidate.endpoint.id,
key_id=candidate.key.id, key_id=candidate.key.id,

View File

@@ -887,6 +887,8 @@ class OpenAIVideoHandler(VideoHandlerBase):
external_task_id=external_task_id, external_task_id=external_task_id,
user_id=self.user.id, user_id=self.user.id,
api_key_id=self.api_key.id, api_key_id=self.api_key.id,
username=self.user.username,
api_key_name=self.api_key.name,
provider_id=candidate.provider.id, provider_id=candidate.provider.id,
endpoint_id=candidate.endpoint.id, endpoint_id=candidate.endpoint.id,
key_id=candidate.key.id, key_id=candidate.key.id,
@@ -1049,6 +1051,8 @@ class OpenAIVideoHandler(VideoHandlerBase):
external_task_id=None, external_task_id=None,
user_id=self.user.id, user_id=self.user.id,
api_key_id=self.api_key.id, api_key_id=self.api_key.id,
username=self.user.username,
api_key_name=self.api_key.name,
provider_id=None, provider_id=None,
endpoint_id=None, endpoint_id=None,
key_id=None, key_id=None,

View File

@@ -46,6 +46,7 @@ from src.models.database import (
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key
from src.services.user.preference import PreferenceService from src.services.user.preference import PreferenceService
from src.services.wallet import WalletService from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
@@ -731,6 +732,7 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
raise NotFoundException("API密钥不存在", "api_key") raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked: if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法删除") raise ForbiddenException("该密钥已被管理员锁定,无法删除")
pre_clean_api_key(context.db, api_key.id)
context.db.delete(api_key) context.db.delete(api_key)
context.db.commit() context.db.commit()
return {"message": "API密钥已删除"} return {"message": "API密钥已删除"}

View File

@@ -32,7 +32,7 @@ from sqlalchemy import (
text, text,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import backref, declarative_base, relationship
from ..config import config from ..config import config
from ..core.enums import AuthSource, ProviderBillingType, UserRole from ..core.enums import AuthSource, ProviderBillingType, UserRole
@@ -235,7 +235,7 @@ class ApiKey(Base):
# 关系 # 关系
user = relationship("User", back_populates="api_keys") user = relationship("User", back_populates="api_keys")
usage_records = relationship("Usage", back_populates="api_key") usage_records = relationship("Usage", back_populates="api_key", passive_deletes=True)
wallet = relationship("Wallet", back_populates="api_key", uselist=False, passive_deletes=True) wallet = relationship("Wallet", back_populates="api_key", uselist=False, passive_deletes=True)
provider_mappings = relationship( provider_mappings = relationship(
"ApiKeyProviderMapping", back_populates="api_key", cascade="all, delete-orphan" "ApiKeyProviderMapping", back_populates="api_key", cascade="all, delete-orphan"
@@ -325,6 +325,10 @@ class Usage(Base):
String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True, index=True String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True, index=True
) )
# 归属快照(删除用户/Key 后仍可追溯)
username = Column(String(100), nullable=True, comment="用户名快照")
api_key_name = Column(String(200), nullable=True, comment="API Key 名称快照")
# 请求信息 # 请求信息
request_id = Column(String(100), unique=True, index=True, nullable=False) request_id = Column(String(100), unique=True, index=True, nullable=False)
provider_name = Column(String(100), nullable=False) # Provider 名称(非外键) provider_name = Column(String(100), nullable=False) # Provider 名称(非外键)
@@ -1932,8 +1936,14 @@ class VideoTask(Base):
external_task_id = Column(String(200)) external_task_id = Column(String(200))
# 关联 # 关联
user_id = Column(String(36), ForeignKey("users.id"), nullable=False) user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
api_key_id = Column(String(36), ForeignKey("api_keys.id"), index=True) api_key_id = Column(
String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True
)
# 归属快照(删除用户/Key 后仍可追溯)
username = Column(String(100), nullable=True, comment="用户名快照")
api_key_name = Column(String(200), nullable=True, comment="API Key 名称快照")
provider_id = Column(String(36), ForeignKey("providers.id"), index=True) provider_id = Column(String(36), ForeignKey("providers.id"), index=True)
endpoint_id = Column(String(36), ForeignKey("provider_endpoints.id"), index=True) endpoint_id = Column(String(36), ForeignKey("provider_endpoints.id"), index=True)
key_id = Column(String(36), ForeignKey("provider_api_keys.id"), index=True) key_id = Column(String(36), ForeignKey("provider_api_keys.id"), index=True)
@@ -2013,7 +2023,7 @@ class VideoTask(Base):
) )
# 关系 # 关系
user = relationship("User", backref="video_tasks") user = relationship("User", backref=backref("video_tasks", passive_deletes=True))
remixed_from = relationship("VideoTask", remote_side=[id], backref="remixes") remixed_from = relationship("VideoTask", remote_side=[id], backref="remixes")
# 复合索引和唯一约束 # 复合索引和唯一约束
@@ -2395,12 +2405,16 @@ class RequestCandidate(Base):
# 关联字段 # 关联字段
request_id = Column(String(100), nullable=False, index=True) request_id = Column(String(100), nullable=False, index=True)
user_id = Column( user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
) )
api_key_id = Column( api_key_id = Column(
String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=True, index=True String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True
) )
# 归属快照(删除用户/Key 后仍可追溯)
username = Column(String(100), nullable=True, comment="用户名快照")
api_key_name = Column(String(200), nullable=True, comment="API Key 名称快照")
# 候选信息 # 候选信息
candidate_index = Column(Integer, nullable=False) # 候选序号从0开始 candidate_index = Column(Integer, nullable=False) # 候选序号从0开始
retry_index = Column(Integer, nullable=False, default=0) # 重试序号从0开始 retry_index = Column(Integer, nullable=False, default=0) # 重试序号从0开始
@@ -2766,7 +2780,10 @@ class StatsDailyApiKey(Base):
__tablename__ = "stats_daily_api_key" __tablename__ = "stats_daily_api_key"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False) api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True)
api_key_name = Column(
String(200), nullable=True, comment="API Key 名称快照(删除 Key 后仍可追溯)"
)
date = Column(DateTime(timezone=True), nullable=False, index=True) date = Column(DateTime(timezone=True), nullable=False, index=True)
total_requests = Column(Integer, default=0, nullable=False) total_requests = Column(Integer, default=0, nullable=False)
@@ -2887,7 +2904,8 @@ class StatsUserDaily(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 用户关联 # 用户关联
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
username = Column(String(100), nullable=True, comment="用户名快照(删除用户后仍可追溯)")
# 统计日期 (UTC) # 统计日期 (UTC)
date = Column(DateTime(timezone=True), nullable=False, index=True) date = Column(DateTime(timezone=True), nullable=False, index=True)

View File

@@ -109,7 +109,7 @@ async def _on_startup() -> None:
logger.info("Hub worker channel initialized on startup") logger.info("Hub worker channel initialized on startup")
except Exception as e: except Exception as e:
# ensure_connected 失败时内部会启动重连循环,这里仅记录告警不阻塞启动 # ensure_connected 失败时内部会启动重连循环,这里仅记录告警不阻塞启动
logger.warning("Hub worker channel init failed, reconnecting in background: {}", e) logger.warning("Hub worker channel init failed, reconnecting in background: %s", e)
if active: if active:
logger.info("启动 ProxyNode 心跳检测调度器...") logger.info("启动 ProxyNode 心跳检测调度器...")

View File

@@ -332,6 +332,8 @@ class FailoverEngine:
request_id: str | None = None, request_id: str | None = None,
user_id: str | None = None, user_id: str | None = None,
api_key_id: str | None = None, api_key_id: str | None = None,
username: str | None = None,
api_key_name: str | None = None,
candidate_record_map: dict[tuple[int, int], str] | None = None, candidate_record_map: dict[tuple[int, int], str] | None = None,
max_candidates: int | None = None, max_candidates: int | None = None,
max_attempts: int | None = None, max_attempts: int | None = None,
@@ -405,6 +407,8 @@ class FailoverEngine:
candidate_index=candidate_index, candidate_index=candidate_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
skip_reason=skip_reason, skip_reason=skip_reason,
) )
candidate_keys_fallback.append( candidate_keys_fallback.append(
@@ -428,6 +432,8 @@ class FailoverEngine:
request_id=request_id, request_id=request_id,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidate_keys_fallback=candidate_keys_fallback, candidate_keys_fallback=candidate_keys_fallback,
candidates=candidates, candidates=candidates,
@@ -476,6 +482,8 @@ class FailoverEngine:
retry_index=retry_index, retry_index=retry_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
) )
self._attach_attempt_context( self._attach_attempt_context(
@@ -620,6 +628,8 @@ class FailoverEngine:
request_id: str | None, request_id: str | None,
user_id: str | None, user_id: str | None,
api_key_id: str | None, api_key_id: str | None,
username: str | None,
api_key_name: str | None,
candidate_record_map: dict[tuple[int, int], str] | None, candidate_record_map: dict[tuple[int, int], str] | None,
candidate_keys_fallback: list[CandidateKey], candidate_keys_fallback: list[CandidateKey],
candidates: list[ProviderCandidate], candidates: list[ProviderCandidate],
@@ -677,6 +687,8 @@ class FailoverEngine:
retry_index=base_retry_index, retry_index=base_retry_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
skip_reason=skip_reason, skip_reason=skip_reason,
) )
candidate_keys_fallback.append( candidate_keys_fallback.append(
@@ -724,6 +736,8 @@ class FailoverEngine:
retry_index=composite_retry_index, retry_index=composite_retry_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
) )
self._attach_attempt_context( self._attach_attempt_context(
@@ -1202,6 +1216,8 @@ class FailoverEngine:
retry_index: int, retry_index: int,
user_id: str | None, user_id: str | None,
api_key_id: str | None, api_key_id: str | None,
username: str | None,
api_key_name: str | None,
) -> str: ) -> str:
# Create "available" record, then caller will mark pending. # Create "available" record, then caller will mark pending.
extra = self._build_pool_extra_data(candidate) extra = self._build_pool_extra_data(candidate)
@@ -1212,6 +1228,8 @@ class FailoverEngine:
retry_index=retry_index, retry_index=retry_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
provider_id=str(candidate.provider.id), provider_id=str(candidate.provider.id),
endpoint_id=str(candidate.endpoint.id), endpoint_id=str(candidate.endpoint.id),
key_id=str(candidate.key.id), key_id=str(candidate.key.id),
@@ -1230,6 +1248,8 @@ class FailoverEngine:
retry_index: int = 0, retry_index: int = 0,
user_id: str | None, user_id: str | None,
api_key_id: str | None, api_key_id: str | None,
username: str | None,
api_key_name: str | None,
skip_reason: str | None, skip_reason: str | None,
) -> str: ) -> str:
extra = self._build_pool_extra_data(candidate) extra = self._build_pool_extra_data(candidate)
@@ -1240,6 +1260,8 @@ class FailoverEngine:
retry_index=retry_index, retry_index=retry_index,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
provider_id=str(candidate.provider.id), provider_id=str(candidate.provider.id),
endpoint_id=str(candidate.endpoint.id), endpoint_id=str(candidate.endpoint.id),
key_id=str(candidate.key.id), key_id=str(candidate.key.id),

View File

@@ -216,6 +216,15 @@ class CandidateResolver:
candidate_records_to_insert: list[dict[str, Any]] = [] candidate_records_to_insert: list[dict[str, Any]] = []
candidate_record_map: dict[tuple[int, int], str] = {} candidate_record_map: dict[tuple[int, int], str] = {}
username = None
api_key_name = getattr(user_api_key, "name", None) if user_api_key else None
if user_api_key is not None:
try:
user = getattr(user_api_key, "user", None)
except Exception:
user = None
username = getattr(user, "username", None) if user is not None else None
# 只保存启用的能力(值为 True 的) # 只保存启用的能力(值为 True 的)
active_capabilities = None active_capabilities = None
@@ -290,6 +299,8 @@ class CandidateResolver:
"retry_index": retry_index, "retry_index": retry_index,
"user_id": user_id, "user_id": user_id,
"api_key_id": user_api_key.id if user_api_key else None, "api_key_id": user_api_key.id if user_api_key else None,
"username": username,
"api_key_name": api_key_name,
"provider_id": provider.id, "provider_id": provider.id,
"endpoint_id": endpoint.id, "endpoint_id": endpoint.id,
"key_id": key_id, "key_id": key_id,
@@ -314,6 +325,8 @@ class CandidateResolver:
"retry_index": 0, "retry_index": 0,
"user_id": user_id, "user_id": user_id,
"api_key_id": user_api_key.id if user_api_key else None, "api_key_id": user_api_key.id if user_api_key else None,
"username": username,
"api_key_name": api_key_name,
"provider_id": provider.id, "provider_id": provider.id,
"endpoint_id": endpoint.id, "endpoint_id": endpoint.id,
"key_id": key.id, "key_id": key.id,
@@ -339,6 +352,8 @@ class CandidateResolver:
"retry_index": retry_index, "retry_index": retry_index,
"user_id": user_id, "user_id": user_id,
"api_key_id": user_api_key.id if user_api_key else None, "api_key_id": user_api_key.id if user_api_key else None,
"username": username,
"api_key_name": api_key_name,
"provider_id": provider.id, "provider_id": provider.id,
"endpoint_id": endpoint.id, "endpoint_id": endpoint.id,
"key_id": key.id, "key_id": key.id,

View File

@@ -23,6 +23,8 @@ class RequestCandidateService:
retry_index: int = 0, # 新增:重试序号 retry_index: int = 0, # 新增:重试序号
user_id: str | None = None, user_id: str | None = None,
api_key_id: str | None = None, api_key_id: str | None = None,
username: str | None = None,
api_key_name: str | None = None,
provider_id: str | None = None, provider_id: str | None = None,
endpoint_id: str | None = None, endpoint_id: str | None = None,
key_id: str | None = None, key_id: str | None = None,
@@ -42,6 +44,8 @@ class RequestCandidateService:
retry_index: 重试序号从0开始 retry_index: 重试序号从0开始
user_id: 用户ID user_id: 用户ID
api_key_id: API Key ID api_key_id: API Key ID
username: 用户名快照
api_key_name: API Key 名称快照
provider_id: Provider ID provider_id: Provider ID
endpoint_id: Endpoint ID endpoint_id: Endpoint ID
key_id: API Key ID key_id: API Key ID
@@ -58,6 +62,8 @@ class RequestCandidateService:
retry_index=retry_index, # 新增 retry_index=retry_index, # 新增
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
username=username,
api_key_name=api_key_name,
provider_id=provider_id, provider_id=provider_id,
endpoint_id=endpoint_id, endpoint_id=endpoint_id,
key_id=key_id, key_id=key_id,

View File

@@ -476,18 +476,10 @@ class CacheAwareScheduler:
release_db_connection_before_await(db) release_db_connection_before_await(db)
logger.debug( logger.debug(
"[Scheduler] Found {} active providers", "[Scheduler] Found {} active providers: {}",
len(providers), len(providers),
", ".join(p.name for p in providers),
) )
for p in providers:
logger.debug(
"[Scheduler] Provider: id={}, name={}, is_active={}, endpoints={}, models={}",
p.id[:8] if p.id else "N/A",
p.name,
p.is_active,
len(p.endpoints) if p.endpoints else 0,
len(p.models) if p.models else 0,
)
if not providers: if not providers:
return [], global_model_id, queried_provider_count return [], global_model_id, queried_provider_count

View File

@@ -425,11 +425,6 @@ class CandidateBuilder:
allowed_kinds = {client_kind} allowed_kinds = {client_kind}
for provider in providers: for provider in providers:
logger.debug(
"[Scheduler] Checking provider: {}, endpoints={}",
provider.name,
len(provider.endpoints) if provider.endpoints else 0,
)
# 按端点格式分别判断兼容性与模型/Key 可用性: # 按端点格式分别判断兼容性与模型/Key 可用性:
# - 同格式端点优先needs_conversion=False # - 同格式端点优先needs_conversion=False
# - 跨格式端点次之needs_conversion=True # - 跨格式端点次之needs_conversion=True
@@ -487,15 +482,7 @@ class CandidateBuilder:
) )
for endpoint in endpoints: for endpoint in endpoints:
logger.debug(
"[Scheduler] Checking endpoint: family={}, kind={}, is_active={}, base_url={}",
getattr(endpoint, "api_family", None),
getattr(endpoint, "endpoint_kind", None),
getattr(endpoint, "is_active", None),
(endpoint.base_url[:50] if endpoint.base_url else "N/A"),
)
if not endpoint.is_active: if not endpoint.is_active:
logger.debug("[Scheduler] Endpoint skipped: not active")
continue continue
endpoint_format_str = make_signature_key( endpoint_format_str = make_signature_key(
@@ -520,17 +507,6 @@ class CandidateBuilder:
global_conversion_enabled, global_conversion_enabled,
skip_endpoint_check=skip_endpoint_check, skip_endpoint_check=skip_endpoint_check,
) )
logger.debug(
"[Scheduler] Format compatibility: client={}, endpoint={}, compatible={}, "
"global={}, provider={}, skip_endpoint={}, reason={}",
client_format_str,
endpoint_format_str,
is_compatible,
global_conversion_enabled,
provider_conversion_enabled,
skip_endpoint_check,
_compat_reason,
)
if not is_compatible: if not is_compatible:
continue continue
@@ -547,21 +523,7 @@ class CandidateBuilder:
supports_model, skip_reason, _model_caps, provider_model_names = ( supports_model, skip_reason, _model_caps, provider_model_names = (
model_support_cache[endpoint_format_str] model_support_cache[endpoint_format_str]
) )
logger.debug(
"[Scheduler] Model support: provider={}, model={}, supports={}, reason={}",
provider.name,
model_name,
supports_model,
skip_reason,
)
if not supports_model: if not supports_model:
logger.debug(
"Provider {} 端点 {} 不支持模型 {}: {}",
provider.name,
endpoint_format_str,
model_name,
skip_reason,
)
continue continue
# Key 直属 Provider通过 api_formats 按端点格式筛选 # Key 直属 Provider通过 api_formats 按端点格式筛选

View File

@@ -426,6 +426,7 @@ class StatsAggregatorService:
stats = ( stats = (
db.query( db.query(
Usage.api_key_id, Usage.api_key_id,
func.max(Usage.api_key_name).label("api_key_name"),
func.count(Usage.id).label("total_requests"), func.count(Usage.id).label("total_requests"),
func.sum(case((error_cond, 1), else_=0)).label("error_requests"), func.sum(case((error_cond, 1), else_=0)).label("error_requests"),
func.sum(Usage.input_tokens).label("input_tokens"), func.sum(Usage.input_tokens).label("input_tokens"),
@@ -460,6 +461,10 @@ class StatsAggregatorService:
id=str(uuid.uuid4()), date=day_start, api_key_id=stat.api_key_id id=str(uuid.uuid4()), date=day_start, api_key_id=stat.api_key_id
) )
# 填充 api_key_name 快照(优先用已有值,新数据从 usage 聚合获取)
if not record.api_key_name and stat.api_key_name:
record.api_key_name = stat.api_key_name
error_requests = int(stat.error_requests or 0) error_requests = int(stat.error_requests or 0)
total_requests = int(stat.total_requests or 0) total_requests = int(stat.total_requests or 0)
record.total_requests = total_requests record.total_requests = total_requests
@@ -598,6 +603,7 @@ class StatsAggregatorService:
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"), func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"), func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost"), func.sum(Usage.total_cost_usd).label("total_cost"),
func.max(Usage.username).label("username"),
) )
.filter( .filter(
and_( and_(
@@ -609,6 +615,12 @@ class StatsAggregatorService:
.first() .first()
) )
# 填充 username 快照:从 Usage 聚合获取,用户删除后仍可追溯
if not stats.username:
username = getattr(aggregated, "username", None)
if username:
stats.username = username
total_requests = int(getattr(aggregated, "total_requests", 0) or 0) total_requests = int(getattr(aggregated, "total_requests", 0) or 0)
if total_requests == 0: if total_requests == 0:
stats.total_requests = 0 stats.total_requests = 0

View File

@@ -128,10 +128,13 @@ class SyncTaskExecutionService:
affinity_key = str(user_api_key.id) affinity_key = str(user_api_key.id)
user_id = str(user_api_key.user_id) user_id = str(user_api_key.user_id)
api_format_norm = normalize_endpoint_signature(api_format) api_format_norm = normalize_endpoint_signature(api_format)
username_snapshot = None
api_key_name_snapshot = getattr(user_api_key, "name", None)
# Keep pending usage creation behavior consistent with previous behavior # Keep pending usage creation behavior consistent with previous behavior
try: try:
user = self.db.query(User).filter(User.id == user_api_key.user_id).first() user = self.db.query(User).filter(User.id == user_api_key.user_id).first()
username_snapshot = getattr(user, "username", None) if user else None
UsageService.create_pending_usage( UsageService.create_pending_usage(
db=self.db, db=self.db,
request_id=request_id, request_id=request_id,
@@ -216,6 +219,8 @@ class SyncTaskExecutionService:
retry_index=retry_index, retry_index=retry_index,
user_id=user_id, user_id=user_id,
api_key_id=str(user_api_key.id), api_key_id=str(user_api_key.id),
username=username_snapshot,
api_key_name=api_key_name_snapshot,
provider_id=str(candidate.provider.id), provider_id=str(candidate.provider.id),
endpoint_id=str(candidate.endpoint.id), endpoint_id=str(candidate.endpoint.id),
key_id=str(candidate.key.id), key_id=str(candidate.key.id),
@@ -253,7 +258,14 @@ class SyncTaskExecutionService:
max_attempts=max_attempts_local, max_attempts=max_attempts_local,
is_stream=is_stream, is_stream=is_stream,
) )
_ = (attempt_id, _provider_name, _provider_id, _endpoint_id, _key_id, _first_byte_time_ms) _ = (
attempt_id,
_provider_name,
_provider_id,
_endpoint_id,
_key_id,
_first_byte_time_ms,
)
# Account Pool: on success, update sticky binding + LRU. # Account Pool: on success, update sticky binding + LRU.
await self._pool_ops.pool_on_success(candidate, request_body) await self._pool_ops.pool_on_success(candidate, request_body)
@@ -335,6 +347,8 @@ class SyncTaskExecutionService:
request_id=request_id, request_id=request_id,
user_id=user_id, user_id=user_id,
api_key_id=str(user_api_key.id), api_key_id=str(user_api_key.id),
username=username_snapshot,
api_key_name=api_key_name_snapshot,
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
max_attempts=max_attempts, max_attempts=max_attempts,
execution_error_handler=_handle_exec_err, execution_error_handler=_handle_exec_err,

View File

@@ -363,6 +363,9 @@ class TaskService:
elif user_api_key is not None and getattr(user_api_key, "user_id", None): elif user_api_key is not None and getattr(user_api_key, "user_id", None):
user_id = str(user_api_key.user_id) user_id = str(user_api_key.user_id)
username_snapshot = getattr(resolved_user, "username", None) if resolved_user else None
api_key_name_snapshot = getattr(user_api_key, "name", None) if user_api_key else None
resolved_affinity_key = affinity_key resolved_affinity_key = affinity_key
if not resolved_affinity_key: if not resolved_affinity_key:
api_key_id = getattr(user_api_key, "id", None) if user_api_key is not None else None api_key_id = getattr(user_api_key, "id", None) if user_api_key is not None else None
@@ -443,6 +446,8 @@ class TaskService:
retry_index=retry_index, retry_index=retry_index,
user_id=user_id, user_id=user_id,
api_key_id=(getattr(user_api_key, "id", None) if user_api_key else None), api_key_id=(getattr(user_api_key, "id", None) if user_api_key else None),
username=username_snapshot,
api_key_name=api_key_name_snapshot,
provider_id=str(candidate.provider.id), provider_id=str(candidate.provider.id),
endpoint_id=str(candidate.endpoint.id), endpoint_id=str(candidate.endpoint.id),
key_id=str(candidate.key.id), key_id=str(candidate.key.id),
@@ -564,6 +569,8 @@ class TaskService:
api_key_id=( api_key_id=(
str(user_api_key.id) if user_api_key and getattr(user_api_key, "id", None) else None str(user_api_key.id) if user_api_key and getattr(user_api_key, "id", None) else None
), ),
username=username_snapshot,
api_key_name=api_key_name_snapshot,
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
max_attempts=max_attempts, max_attempts=max_attempts,
execution_error_handler=_handle_exec_err, execution_error_handler=_handle_exec_err,
@@ -605,7 +612,6 @@ class TaskService:
execution_state.last_error, execution_state.last_error,
) )
async def submit_with_failover( async def submit_with_failover(
self, self,
*, *,

View File

@@ -193,6 +193,8 @@ def build_usage_params(
return { return {
"user_id": user.id if user else None, "user_id": user.id if user else None,
"api_key_id": api_key.id if api_key else None, "api_key_id": api_key.id if api_key else None,
"username": user.username if user else None,
"api_key_name": api_key.name if api_key else None,
"request_id": request_id, "request_id": request_id,
"provider_name": provider, "provider_name": provider,
"model": model, "model": model,

View File

@@ -65,6 +65,8 @@ class UsageLifecycleMixin:
usage = Usage( usage = Usage(
user_id=user.id if user else None, user_id=user.id if user else None,
api_key_id=api_key.id if api_key else None, api_key_id=api_key.id if api_key else None,
username=user.username if user else None,
api_key_name=api_key.name if api_key else None,
request_id=request_id, request_id=request_id,
provider_name="pending", # 尚未确定 provider provider_name="pending", # 尚未确定 provider
model=model, model=model,

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ApiKey, Usage from src.models.database import ApiKey, Usage
from src.services.user.bulk_cleanup import pre_clean_api_key
class ApiKeyService: class ApiKeyService:
@@ -82,8 +83,7 @@ class ApiKeyService:
db.refresh(api_key) db.refresh(api_key)
logger.info( logger.info(
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, " f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, " f"独立Key={is_standalone}"
f"独立Key={is_standalone}"
) )
return api_key, key # 返回密钥对象和明文密钥 return api_key, key # 返回密钥对象和明文密钥
@@ -245,7 +245,8 @@ class ApiKeyService:
) )
if should_delete: if should_delete:
# 物理删除Usage记录保留,因为是 SET NULL # 物理删除Usage / RequestCandidate / VideoTask 等记录保留)
pre_clean_api_key(db, api_key.id)
db.delete(api_key) db.delete(api_key)
logger.info( logger.info(
f"删除过期API密钥: ID {api_key.id}, 名称 {api_key.name}, " f"删除过期API密钥: ID {api_key.id}, 名称 {api_key.name}, "

View 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

View File

@@ -15,6 +15,7 @@ from src.core.logger import logger
from src.core.validators import EmailValidator, PasswordValidator, UsernameValidator from src.core.validators import EmailValidator, PasswordValidator, UsernameValidator
from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User, UserRole from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User, UserRole
from src.services.cache.user_cache import UserCacheService 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 from src.utils.transaction_manager import retry_on_database_error, transactional
@@ -252,15 +253,15 @@ class UserService:
return user return user
@staticmethod @staticmethod
@transactional()
def delete_user(db: Session, user_id: str) -> bool: def delete_user(db: Session, user_id: str) -> bool:
"""删除用户(硬删除) """删除用户(硬删除)
删除流程: 删除流程:
1. 检查未完结账务,阻止删除 1. 检查未完结账务,阻止删除
2. 手动删除 ORM cascade 冲突的子记录 2. 预清理 Usage / RequestCandidate 等大表外键
3. 删除用户记录 3. 手动删除 ORM cascade 冲突的子记录
4. 财务记录Wallet/PaymentOrder/RefundRequest/WalletTransaction 4. 删除用户记录
5. 财务记录Wallet/PaymentOrder/RefundRequest/WalletTransaction
Usage 记录保留,外键 SET NULL由自动清理策略统一回收 Usage 记录保留,外键 SET NULL由自动清理策略统一回收
""" """
from src.models.database import ( from src.models.database import (
@@ -268,6 +269,7 @@ class UserService:
ApiKey, ApiKey,
PaymentOrder, PaymentOrder,
RefundRequest, RefundRequest,
RequestCandidate,
UserPreference, UserPreference,
Wallet, Wallet,
) )
@@ -312,28 +314,39 @@ class UserService:
if pending_order_count > 0: if pending_order_count > 0:
raise ValueError("用户存在未完结充值订单,禁止删除") raise ValueError("用户存在未完结充值订单,禁止删除")
# 手动删除子记录,避免 SQLAlchemy 的 ORM cascade 与数据库 CASCADE 冲突 api_key_ids = [
# UserPreference/AnnouncementRead 的数据库外键是 ON DELETE CASCADE api_key_id
# 但 SQLAlchemy 会先尝试 UPDATE SET NULL 导致冲突) for (api_key_id,) in db.query(ApiKey.id).filter(ApiKey.user_id == user_id).all()
db.query(UserPreference).filter(UserPreference.user_id == user_id).delete( ]
synchronize_session=False api_key_count = len(api_key_ids)
)
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
synchronize_session=False
)
# 财务记录Wallet/WalletTransaction/PaymentOrder/RefundRequest/PaymentCallback # 注意batch_nullify_fk 内部分批 commit预清理部分不可回滚。
# 和 Usage 记录全部保留,数据库外键 SET NULL 自动断开关联 # 这是预期行为SET NULL 是幂等操作,即使后续步骤失败
# 由自动清理策略统一回收 # 已置空的外键不影响数据完整性,重新执行删除即可
try:
for api_key_id in api_key_ids:
pre_clean_api_key(db, api_key_id)
api_key_count = int( batch_nullify_fk(db, Usage, "user_id", user_id)
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user_id).scalar() or 0 batch_nullify_fk(db, RequestCandidate, "user_id", user_id)
)
db.query(ApiKey).filter(ApiKey.user_id == user_id).delete(synchronize_session=False)
# 现在删除用户Usage, AuditLog, RequestAttempt 会通过数据库 SET NULL 保留) db.query(UserPreference).filter(UserPreference.user_id == user_id).delete(
db.delete(user) synchronize_session=False
db.commit() # 立即提交事务,释放数据库锁 )
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)) asyncio.create_task(UserCacheService.invalidate_user_cache(user_id, email))

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 __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock, call
import pytest 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 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 db.query.side_effect = _query
with pytest.raises(ValueError, match="未完结退款"): with pytest.raises(ValueError, match="未完结退款"):
UserService.delete_user.__wrapped__(db, "user-1") UserService.delete_user(db, "user-1")
db.delete.assert_not_called() 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 db.query.side_effect = _query
with pytest.raises(ValueError, match="未完结充值订单"): with pytest.raises(ValueError, match="未完结充值订单"):
UserService.delete_user.__wrapped__(db, "user-2") UserService.delete_user(db, "user-2")
db.delete.assert_not_called() 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()

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from unittest.mock import MagicMock
from src.services.request.candidate import RequestCandidateService
def test_create_candidate_persists_snapshot_fields() -> None:
db = MagicMock()
candidate = RequestCandidateService.create_candidate(
db=db,
request_id="req-1",
candidate_index=0,
retry_index=1,
user_id="user-1",
api_key_id="key-1",
username="alice",
api_key_name="Primary Key",
provider_id="provider-1",
endpoint_id="endpoint-1",
key_id="pool-key-1",
status="available",
)
assert candidate.username == "alice"
assert candidate.api_key_name == "Primary Key"
db.add.assert_called_once_with(candidate)
db.flush.assert_called_once()