mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
@@ -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)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user