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")