diff --git a/backend/migrations/004_add_redeem_code_notes.sql b/backend/migrations/004_add_redeem_code_notes.sql
index 7fed6ec0..eeb37b10 100644
--- a/backend/migrations/004_add_redeem_code_notes.sql
+++ b/backend/migrations/004_add_redeem_code_notes.sql
@@ -1,6 +1,6 @@
--- 为 redeem_codes 表添加备注字段
-
-ALTER TABLE redeem_codes
-ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT NULL;
-
-COMMENT ON COLUMN redeem_codes.notes IS '备注说明(管理员调整时的原因说明)';
+-- 为 redeem_codes 表添加备注字段
+
+ALTER TABLE redeem_codes
+ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT NULL;
+
+COMMENT ON COLUMN redeem_codes.notes IS '备注说明(管理员调整时的原因说明)';
diff --git a/backend/migrations/005_schema_parity.sql b/backend/migrations/005_schema_parity.sql
index 9b065fd2..0ee3f121 100644
--- a/backend/migrations/005_schema_parity.sql
+++ b/backend/migrations/005_schema_parity.sql
@@ -1,42 +1,42 @@
--- Align SQL migrations with current GORM persistence models.
--- This file is designed to be safe on both fresh installs and existing databases.
-
--- users: add fields added after initial migration
-ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(100) NOT NULL DEFAULT '';
-ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) NOT NULL DEFAULT '';
-ALTER TABLE users ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-
--- api_keys: allow longer keys (GORM model uses size:128)
-ALTER TABLE api_keys ALTER COLUMN key TYPE VARCHAR(128);
-
--- accounts: scheduling and rate-limit fields used by repository queries
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS schedulable BOOLEAN NOT NULL DEFAULT TRUE;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS rate_limited_at TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS rate_limit_reset_at TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS overload_until TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_start TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_end TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_status VARCHAR(20);
-
-CREATE INDEX IF NOT EXISTS idx_accounts_schedulable ON accounts(schedulable);
-CREATE INDEX IF NOT EXISTS idx_accounts_rate_limited_at ON accounts(rate_limited_at);
-CREATE INDEX IF NOT EXISTS idx_accounts_rate_limit_reset_at ON accounts(rate_limit_reset_at);
-CREATE INDEX IF NOT EXISTS idx_accounts_overload_until ON accounts(overload_until);
-
--- redeem_codes: subscription redeem fields
-ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL;
-ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS validity_days INT NOT NULL DEFAULT 30;
-CREATE INDEX IF NOT EXISTS idx_redeem_codes_group_id ON redeem_codes(group_id);
-
--- usage_logs: billing type used by filters and stats
-ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS billing_type SMALLINT NOT NULL DEFAULT 0;
-CREATE INDEX IF NOT EXISTS idx_usage_logs_billing_type ON usage_logs(billing_type);
-
--- settings: key-value store
-CREATE TABLE IF NOT EXISTS settings (
- id BIGSERIAL PRIMARY KEY,
- key VARCHAR(100) NOT NULL UNIQUE,
- value TEXT NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
+-- Align SQL migrations with current GORM persistence models.
+-- This file is designed to be safe on both fresh installs and existing databases.
+
+-- users: add fields added after initial migration
+ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(100) NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
+
+-- api_keys: allow longer keys (GORM model uses size:128)
+ALTER TABLE api_keys ALTER COLUMN key TYPE VARCHAR(128);
+
+-- accounts: scheduling and rate-limit fields used by repository queries
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS schedulable BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS rate_limited_at TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS rate_limit_reset_at TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS overload_until TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_start TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_end TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS session_window_status VARCHAR(20);
+
+CREATE INDEX IF NOT EXISTS idx_accounts_schedulable ON accounts(schedulable);
+CREATE INDEX IF NOT EXISTS idx_accounts_rate_limited_at ON accounts(rate_limited_at);
+CREATE INDEX IF NOT EXISTS idx_accounts_rate_limit_reset_at ON accounts(rate_limit_reset_at);
+CREATE INDEX IF NOT EXISTS idx_accounts_overload_until ON accounts(overload_until);
+
+-- redeem_codes: subscription redeem fields
+ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL;
+ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS validity_days INT NOT NULL DEFAULT 30;
+CREATE INDEX IF NOT EXISTS idx_redeem_codes_group_id ON redeem_codes(group_id);
+
+-- usage_logs: billing type used by filters and stats
+ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS billing_type SMALLINT NOT NULL DEFAULT 0;
+CREATE INDEX IF NOT EXISTS idx_usage_logs_billing_type ON usage_logs(billing_type);
+
+-- settings: key-value store
+CREATE TABLE IF NOT EXISTS settings (
+ id BIGSERIAL PRIMARY KEY,
+ key VARCHAR(100) NOT NULL UNIQUE,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
diff --git a/backend/migrations/006_fix_invalid_subscription_expires_at.sql b/backend/migrations/006_fix_invalid_subscription_expires_at.sql
index 66cf0af3..7a0c2642 100644
--- a/backend/migrations/006_fix_invalid_subscription_expires_at.sql
+++ b/backend/migrations/006_fix_invalid_subscription_expires_at.sql
@@ -1,10 +1,10 @@
--- Fix legacy subscription records with invalid expires_at (year > 2099).
-DO $$
-BEGIN
- IF to_regclass('public.user_subscriptions') IS NOT NULL THEN
- UPDATE user_subscriptions
- SET expires_at = TIMESTAMPTZ '2099-12-31 23:59:59+00'
- WHERE expires_at > TIMESTAMPTZ '2099-12-31 23:59:59+00';
- END IF;
-END $$;
-
+-- Fix legacy subscription records with invalid expires_at (year > 2099).
+DO $$
+BEGIN
+ IF to_regclass('public.user_subscriptions') IS NOT NULL THEN
+ UPDATE user_subscriptions
+ SET expires_at = TIMESTAMPTZ '2099-12-31 23:59:59+00'
+ WHERE expires_at > TIMESTAMPTZ '2099-12-31 23:59:59+00';
+ END IF;
+END $$;
+
diff --git a/backend/migrations/007_add_user_allowed_groups.sql b/backend/migrations/007_add_user_allowed_groups.sql
index 78aa1240..a61400d2 100644
--- a/backend/migrations/007_add_user_allowed_groups.sql
+++ b/backend/migrations/007_add_user_allowed_groups.sql
@@ -1,20 +1,20 @@
--- Add user_allowed_groups join table to replace users.allowed_groups (BIGINT[]).
--- Phase 1: create table + backfill from the legacy array column.
-
-CREATE TABLE IF NOT EXISTS user_allowed_groups (
- user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- PRIMARY KEY (user_id, group_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_user_allowed_groups_group_id ON user_allowed_groups(group_id);
-
--- Backfill from the legacy users.allowed_groups array.
-INSERT INTO user_allowed_groups (user_id, group_id)
-SELECT u.id, x.group_id
-FROM users u
-CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
-JOIN groups g ON g.id = x.group_id
-WHERE u.allowed_groups IS NOT NULL
-ON CONFLICT DO NOTHING;
+-- Add user_allowed_groups join table to replace users.allowed_groups (BIGINT[]).
+-- Phase 1: create table + backfill from the legacy array column.
+
+CREATE TABLE IF NOT EXISTS user_allowed_groups (
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (user_id, group_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_allowed_groups_group_id ON user_allowed_groups(group_id);
+
+-- Backfill from the legacy users.allowed_groups array.
+INSERT INTO user_allowed_groups (user_id, group_id)
+SELECT u.id, x.group_id
+FROM users u
+CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
+JOIN groups g ON g.id = x.group_id
+WHERE u.allowed_groups IS NOT NULL
+ON CONFLICT DO NOTHING;
diff --git a/backend/migrations/008_seed_default_group.sql b/backend/migrations/008_seed_default_group.sql
index a05917c0..cfe2640f 100644
--- a/backend/migrations/008_seed_default_group.sql
+++ b/backend/migrations/008_seed_default_group.sql
@@ -1,4 +1,4 @@
--- Seed a default group for fresh installs.
-INSERT INTO groups (name, description, created_at, updated_at)
-SELECT 'default', 'Default group', NOW(), NOW()
-WHERE NOT EXISTS (SELECT 1 FROM groups);
+-- Seed a default group for fresh installs.
+INSERT INTO groups (name, description, created_at, updated_at)
+SELECT 'default', 'Default group', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM groups);
diff --git a/backend/migrations/009_fix_usage_logs_cache_columns.sql b/backend/migrations/009_fix_usage_logs_cache_columns.sql
index 07aba064..979405af 100644
--- a/backend/migrations/009_fix_usage_logs_cache_columns.sql
+++ b/backend/migrations/009_fix_usage_logs_cache_columns.sql
@@ -1,37 +1,37 @@
--- Ensure usage_logs cache token columns use the underscored names expected by code.
--- Backfill from legacy column names if they exist.
-
-ALTER TABLE usage_logs
- ADD COLUMN IF NOT EXISTS cache_creation_5m_tokens INT NOT NULL DEFAULT 0;
-
-ALTER TABLE usage_logs
- ADD COLUMN IF NOT EXISTS cache_creation_1h_tokens INT NOT NULL DEFAULT 0;
-
-DO $$
-BEGIN
- IF EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'usage_logs'
- AND column_name = 'cache_creation5m_tokens'
- ) THEN
- UPDATE usage_logs
- SET cache_creation_5m_tokens = cache_creation5m_tokens
- WHERE cache_creation_5m_tokens = 0
- AND cache_creation5m_tokens <> 0;
- END IF;
-
- IF EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'usage_logs'
- AND column_name = 'cache_creation1h_tokens'
- ) THEN
- UPDATE usage_logs
- SET cache_creation_1h_tokens = cache_creation1h_tokens
- WHERE cache_creation_1h_tokens = 0
- AND cache_creation1h_tokens <> 0;
- END IF;
-END $$;
+-- Ensure usage_logs cache token columns use the underscored names expected by code.
+-- Backfill from legacy column names if they exist.
+
+ALTER TABLE usage_logs
+ ADD COLUMN IF NOT EXISTS cache_creation_5m_tokens INT NOT NULL DEFAULT 0;
+
+ALTER TABLE usage_logs
+ ADD COLUMN IF NOT EXISTS cache_creation_1h_tokens INT NOT NULL DEFAULT 0;
+
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'usage_logs'
+ AND column_name = 'cache_creation5m_tokens'
+ ) THEN
+ UPDATE usage_logs
+ SET cache_creation_5m_tokens = cache_creation5m_tokens
+ WHERE cache_creation_5m_tokens = 0
+ AND cache_creation5m_tokens <> 0;
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'usage_logs'
+ AND column_name = 'cache_creation1h_tokens'
+ ) THEN
+ UPDATE usage_logs
+ SET cache_creation_1h_tokens = cache_creation1h_tokens
+ WHERE cache_creation_1h_tokens = 0
+ AND cache_creation1h_tokens <> 0;
+ END IF;
+END $$;
diff --git a/backend/migrations/010_add_usage_logs_aggregated_indexes.sql b/backend/migrations/010_add_usage_logs_aggregated_indexes.sql
index 55850c8b..ab2dbbc1 100644
--- a/backend/migrations/010_add_usage_logs_aggregated_indexes.sql
+++ b/backend/migrations/010_add_usage_logs_aggregated_indexes.sql
@@ -1,4 +1,4 @@
--- 为聚合查询补充复合索引
-CREATE INDEX IF NOT EXISTS idx_usage_logs_account_created_at ON usage_logs(account_id, created_at);
-CREATE INDEX IF NOT EXISTS idx_usage_logs_api_key_created_at ON usage_logs(api_key_id, created_at);
-CREATE INDEX IF NOT EXISTS idx_usage_logs_model_created_at ON usage_logs(model, created_at);
+-- 为聚合查询补充复合索引
+CREATE INDEX IF NOT EXISTS idx_usage_logs_account_created_at ON usage_logs(account_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_usage_logs_api_key_created_at ON usage_logs(api_key_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_usage_logs_model_created_at ON usage_logs(model, created_at);
diff --git a/backend/migrations/011_remove_duplicate_unique_indexes.sql b/backend/migrations/011_remove_duplicate_unique_indexes.sql
index e6fa1813..8fd62710 100644
--- a/backend/migrations/011_remove_duplicate_unique_indexes.sql
+++ b/backend/migrations/011_remove_duplicate_unique_indexes.sql
@@ -1,39 +1,39 @@
--- 011_remove_duplicate_unique_indexes.sql
--- 移除重复的唯一索引
--- 这些字段在 ent schema 的 Fields() 中已声明 .Unique(),
--- 因此在 Indexes() 中再次声明 index.Fields("x").Unique() 会创建重复索引。
--- 本迁移脚本清理这些冗余索引。
-
--- 重复索引命名约定(由 Ent 自动生成/历史迁移遗留):
--- - 字段级 Unique() 创建的索引名:
__key
--- - Indexes() 中的 Unique() 创建的索引名: _
--- - 初始化迁移中的非唯一索引: idx__
-
--- 仅当索引存在时才删除(幂等操作)
-
--- api_keys 表: key 字段
-DROP INDEX IF EXISTS apikey_key;
-DROP INDEX IF EXISTS api_keys_key;
-DROP INDEX IF EXISTS idx_api_keys_key;
-
--- users 表: email 字段
-DROP INDEX IF EXISTS user_email;
-DROP INDEX IF EXISTS users_email;
-DROP INDEX IF EXISTS idx_users_email;
-
--- settings 表: key 字段
-DROP INDEX IF EXISTS settings_key;
-DROP INDEX IF EXISTS idx_settings_key;
-
--- redeem_codes 表: code 字段
-DROP INDEX IF EXISTS redeemcode_code;
-DROP INDEX IF EXISTS redeem_codes_code;
-DROP INDEX IF EXISTS idx_redeem_codes_code;
-
--- groups 表: name 字段
-DROP INDEX IF EXISTS group_name;
-DROP INDEX IF EXISTS groups_name;
-DROP INDEX IF EXISTS idx_groups_name;
-
--- 注意: 每个字段的唯一约束仍由字段级 Unique() 创建的约束保留,
--- 如 api_keys_key_key、users_email_key 等。
+-- 011_remove_duplicate_unique_indexes.sql
+-- 移除重复的唯一索引
+-- 这些字段在 ent schema 的 Fields() 中已声明 .Unique(),
+-- 因此在 Indexes() 中再次声明 index.Fields("x").Unique() 会创建重复索引。
+-- 本迁移脚本清理这些冗余索引。
+
+-- 重复索引命名约定(由 Ent 自动生成/历史迁移遗留):
+-- - 字段级 Unique() 创建的索引名: __key
+-- - Indexes() 中的 Unique() 创建的索引名: _
+-- - 初始化迁移中的非唯一索引: idx__
+
+-- 仅当索引存在时才删除(幂等操作)
+
+-- api_keys 表: key 字段
+DROP INDEX IF EXISTS apikey_key;
+DROP INDEX IF EXISTS api_keys_key;
+DROP INDEX IF EXISTS idx_api_keys_key;
+
+-- users 表: email 字段
+DROP INDEX IF EXISTS user_email;
+DROP INDEX IF EXISTS users_email;
+DROP INDEX IF EXISTS idx_users_email;
+
+-- settings 表: key 字段
+DROP INDEX IF EXISTS settings_key;
+DROP INDEX IF EXISTS idx_settings_key;
+
+-- redeem_codes 表: code 字段
+DROP INDEX IF EXISTS redeemcode_code;
+DROP INDEX IF EXISTS redeem_codes_code;
+DROP INDEX IF EXISTS idx_redeem_codes_code;
+
+-- groups 表: name 字段
+DROP INDEX IF EXISTS group_name;
+DROP INDEX IF EXISTS groups_name;
+DROP INDEX IF EXISTS idx_groups_name;
+
+-- 注意: 每个字段的唯一约束仍由字段级 Unique() 创建的约束保留,
+-- 如 api_keys_key_key、users_email_key 等。
diff --git a/backend/migrations/012_add_user_subscription_soft_delete.sql b/backend/migrations/012_add_user_subscription_soft_delete.sql
index a3204523..b6cb7366 100644
--- a/backend/migrations/012_add_user_subscription_soft_delete.sql
+++ b/backend/migrations/012_add_user_subscription_soft_delete.sql
@@ -1,13 +1,13 @@
--- 012: 为 user_subscriptions 表添加软删除支持
--- 任务:fix-medium-data-hygiene 1.1
-
--- 添加 deleted_at 字段
-ALTER TABLE user_subscriptions
-ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
-
--- 添加 deleted_at 索引以优化软删除查询
-CREATE INDEX IF NOT EXISTS usersubscription_deleted_at
-ON user_subscriptions (deleted_at);
-
--- 注释:与其他使用软删除的实体保持一致
-COMMENT ON COLUMN user_subscriptions.deleted_at IS '软删除时间戳,NULL 表示未删除';
+-- 012: 为 user_subscriptions 表添加软删除支持
+-- 任务:fix-medium-data-hygiene 1.1
+
+-- 添加 deleted_at 字段
+ALTER TABLE user_subscriptions
+ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
+
+-- 添加 deleted_at 索引以优化软删除查询
+CREATE INDEX IF NOT EXISTS usersubscription_deleted_at
+ON user_subscriptions (deleted_at);
+
+-- 注释:与其他使用软删除的实体保持一致
+COMMENT ON COLUMN user_subscriptions.deleted_at IS '软删除时间戳,NULL 表示未删除';
diff --git a/backend/migrations/013_log_orphan_allowed_groups.sql b/backend/migrations/013_log_orphan_allowed_groups.sql
index 80db30d8..976c0aca 100644
--- a/backend/migrations/013_log_orphan_allowed_groups.sql
+++ b/backend/migrations/013_log_orphan_allowed_groups.sql
@@ -1,32 +1,32 @@
--- 013: 记录 users.allowed_groups 中的孤立 group_id
--- 任务:fix-medium-data-hygiene 3.1
---
--- 目的:在删除 legacy allowed_groups 列前,记录所有引用了不存在 group 的孤立记录
--- 这些记录可用于审计或后续数据修复
-
--- 创建审计表存储孤立的 allowed_groups 记录
-CREATE TABLE IF NOT EXISTS orphan_allowed_groups_audit (
- id BIGSERIAL PRIMARY KEY,
- user_id BIGINT NOT NULL,
- group_id BIGINT NOT NULL,
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- UNIQUE (user_id, group_id)
-);
-
--- 记录孤立的 group_id(存在于 users.allowed_groups 但不存在于 groups 表)
-INSERT INTO orphan_allowed_groups_audit (user_id, group_id)
-SELECT u.id, x.group_id
-FROM users u
-CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
-LEFT JOIN groups g ON g.id = x.group_id
-WHERE u.allowed_groups IS NOT NULL
- AND g.id IS NULL
-ON CONFLICT (user_id, group_id) DO NOTHING;
-
--- 添加索引便于查询
-CREATE INDEX IF NOT EXISTS idx_orphan_allowed_groups_audit_user_id
-ON orphan_allowed_groups_audit(user_id);
-
--- 记录迁移完成信息
-COMMENT ON TABLE orphan_allowed_groups_audit IS
-'审计表:记录 users.allowed_groups 中引用的不存在的 group_id,用于数据清理前的审计';
+-- 013: 记录 users.allowed_groups 中的孤立 group_id
+-- 任务:fix-medium-data-hygiene 3.1
+--
+-- 目的:在删除 legacy allowed_groups 列前,记录所有引用了不存在 group 的孤立记录
+-- 这些记录可用于审计或后续数据修复
+
+-- 创建审计表存储孤立的 allowed_groups 记录
+CREATE TABLE IF NOT EXISTS orphan_allowed_groups_audit (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ group_id BIGINT NOT NULL,
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (user_id, group_id)
+);
+
+-- 记录孤立的 group_id(存在于 users.allowed_groups 但不存在于 groups 表)
+INSERT INTO orphan_allowed_groups_audit (user_id, group_id)
+SELECT u.id, x.group_id
+FROM users u
+CROSS JOIN LATERAL unnest(u.allowed_groups) AS x(group_id)
+LEFT JOIN groups g ON g.id = x.group_id
+WHERE u.allowed_groups IS NOT NULL
+ AND g.id IS NULL
+ON CONFLICT (user_id, group_id) DO NOTHING;
+
+-- 添加索引便于查询
+CREATE INDEX IF NOT EXISTS idx_orphan_allowed_groups_audit_user_id
+ON orphan_allowed_groups_audit(user_id);
+
+-- 记录迁移完成信息
+COMMENT ON TABLE orphan_allowed_groups_audit IS
+'审计表:记录 users.allowed_groups 中引用的不存在的 group_id,用于数据清理前的审计';
diff --git a/backend/migrations/014_drop_legacy_allowed_groups.sql b/backend/migrations/014_drop_legacy_allowed_groups.sql
index 7dc6ea9e..2c2a3d45 100644
--- a/backend/migrations/014_drop_legacy_allowed_groups.sql
+++ b/backend/migrations/014_drop_legacy_allowed_groups.sql
@@ -1,15 +1,15 @@
--- 014: 删除 legacy users.allowed_groups 列
--- 任务:fix-medium-data-hygiene 3.3
---
--- 前置条件:
--- - 迁移 007 已将数据回填到 user_allowed_groups 联接表
--- - 迁移 013 已记录所有孤立的 group_id 到审计表
--- - 应用代码已停止写入该列(3.2 完成)
---
--- 该列现已废弃,所有读写操作均使用 user_allowed_groups 联接表。
-
--- 删除 allowed_groups 列
-ALTER TABLE users DROP COLUMN IF EXISTS allowed_groups;
-
--- 添加注释记录删除原因
-COMMENT ON TABLE users IS '用户表。注:原 allowed_groups BIGINT[] 列已迁移至 user_allowed_groups 联接表';
+-- 014: 删除 legacy users.allowed_groups 列
+-- 任务:fix-medium-data-hygiene 3.3
+--
+-- 前置条件:
+-- - 迁移 007 已将数据回填到 user_allowed_groups 联接表
+-- - 迁移 013 已记录所有孤立的 group_id 到审计表
+-- - 应用代码已停止写入该列(3.2 完成)
+--
+-- 该列现已废弃,所有读写操作均使用 user_allowed_groups 联接表。
+
+-- 删除 allowed_groups 列
+ALTER TABLE users DROP COLUMN IF EXISTS allowed_groups;
+
+-- 添加注释记录删除原因
+COMMENT ON TABLE users IS '用户表。注:原 allowed_groups BIGINT[] 列已迁移至 user_allowed_groups 联接表';
diff --git a/backend/migrations/015_fix_settings_unique_constraint.sql b/backend/migrations/015_fix_settings_unique_constraint.sql
index e675ee18..60f8fcad 100644
--- a/backend/migrations/015_fix_settings_unique_constraint.sql
+++ b/backend/migrations/015_fix_settings_unique_constraint.sql
@@ -1,19 +1,19 @@
--- 015_fix_settings_unique_constraint.sql
--- 修复 settings 表 key 字段缺失的唯一约束
--- 此约束是 ON CONFLICT ("key") DO UPDATE 语句所必需的
-
--- 检查并添加唯一约束(如果不存在)
-DO $$
-BEGIN
- -- 检查是否已存在唯一约束
- IF NOT EXISTS (
- SELECT 1 FROM pg_constraint
- WHERE conrelid = 'settings'::regclass
- AND contype = 'u'
- AND conname = 'settings_key_key'
- ) THEN
- -- 添加唯一约束
- ALTER TABLE settings ADD CONSTRAINT settings_key_key UNIQUE (key);
- END IF;
-END
-$$;
+-- 015_fix_settings_unique_constraint.sql
+-- 修复 settings 表 key 字段缺失的唯一约束
+-- 此约束是 ON CONFLICT ("key") DO UPDATE 语句所必需的
+
+-- 检查并添加唯一约束(如果不存在)
+DO $$
+BEGIN
+ -- 检查是否已存在唯一约束
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'settings'::regclass
+ AND contype = 'u'
+ AND conname = 'settings_key_key'
+ ) THEN
+ -- 添加唯一约束
+ ALTER TABLE settings ADD CONSTRAINT settings_key_key UNIQUE (key);
+ END IF;
+END
+$$;
diff --git a/backend/migrations/016_soft_delete_partial_unique_indexes.sql b/backend/migrations/016_soft_delete_partial_unique_indexes.sql
index e3a1ea6b..b006b775 100644
--- a/backend/migrations/016_soft_delete_partial_unique_indexes.sql
+++ b/backend/migrations/016_soft_delete_partial_unique_indexes.sql
@@ -1,51 +1,51 @@
--- 016_soft_delete_partial_unique_indexes.sql
--- 修复软删除 + 唯一约束冲突问题
--- 将普通唯一约束替换为部分唯一索引(WHERE deleted_at IS NULL)
--- 这样软删除的记录不会占用唯一约束位置,允许删后重建同名/同邮箱/同订阅关系
-
--- ============================================================================
--- 1. users 表: email 字段
--- ============================================================================
-
--- 删除旧的唯一约束(可能的命名方式)
-ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
-DROP INDEX IF EXISTS users_email_key;
-DROP INDEX IF EXISTS user_email_key;
-
--- 创建部分唯一索引:只对未删除的记录建立唯一约束
-CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active
- ON users(email)
- WHERE deleted_at IS NULL;
-
--- ============================================================================
--- 2. groups 表: name 字段
--- ============================================================================
-
--- 删除旧的唯一约束
-ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_name_key;
-DROP INDEX IF EXISTS groups_name_key;
-DROP INDEX IF EXISTS group_name_key;
-
--- 创建部分唯一索引
-CREATE UNIQUE INDEX IF NOT EXISTS groups_name_unique_active
- ON groups(name)
- WHERE deleted_at IS NULL;
-
--- ============================================================================
--- 3. user_subscriptions 表: (user_id, group_id) 组合字段
--- ============================================================================
-
--- 删除旧的唯一约束/索引
-ALTER TABLE user_subscriptions DROP CONSTRAINT IF EXISTS user_subscriptions_user_id_group_id_key;
-DROP INDEX IF EXISTS user_subscriptions_user_id_group_id_key;
-DROP INDEX IF EXISTS usersubscription_user_id_group_id;
-
--- 创建部分唯一索引
-CREATE UNIQUE INDEX IF NOT EXISTS user_subscriptions_user_group_unique_active
- ON user_subscriptions(user_id, group_id)
- WHERE deleted_at IS NULL;
-
--- ============================================================================
--- 注意: api_keys 表的 key 字段保留普通唯一约束
--- API Key 即使软删除后也不应该重复使用(安全考虑)
--- ============================================================================
+-- 016_soft_delete_partial_unique_indexes.sql
+-- 修复软删除 + 唯一约束冲突问题
+-- 将普通唯一约束替换为部分唯一索引(WHERE deleted_at IS NULL)
+-- 这样软删除的记录不会占用唯一约束位置,允许删后重建同名/同邮箱/同订阅关系
+
+-- ============================================================================
+-- 1. users 表: email 字段
+-- ============================================================================
+
+-- 删除旧的唯一约束(可能的命名方式)
+ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
+DROP INDEX IF EXISTS users_email_key;
+DROP INDEX IF EXISTS user_email_key;
+
+-- 创建部分唯一索引:只对未删除的记录建立唯一约束
+CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active
+ ON users(email)
+ WHERE deleted_at IS NULL;
+
+-- ============================================================================
+-- 2. groups 表: name 字段
+-- ============================================================================
+
+-- 删除旧的唯一约束
+ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_name_key;
+DROP INDEX IF EXISTS groups_name_key;
+DROP INDEX IF EXISTS group_name_key;
+
+-- 创建部分唯一索引
+CREATE UNIQUE INDEX IF NOT EXISTS groups_name_unique_active
+ ON groups(name)
+ WHERE deleted_at IS NULL;
+
+-- ============================================================================
+-- 3. user_subscriptions 表: (user_id, group_id) 组合字段
+-- ============================================================================
+
+-- 删除旧的唯一约束/索引
+ALTER TABLE user_subscriptions DROP CONSTRAINT IF EXISTS user_subscriptions_user_id_group_id_key;
+DROP INDEX IF EXISTS user_subscriptions_user_id_group_id_key;
+DROP INDEX IF EXISTS usersubscription_user_id_group_id;
+
+-- 创建部分唯一索引
+CREATE UNIQUE INDEX IF NOT EXISTS user_subscriptions_user_group_unique_active
+ ON user_subscriptions(user_id, group_id)
+ WHERE deleted_at IS NULL;
+
+-- ============================================================================
+-- 注意: api_keys 表的 key 字段保留普通唯一约束
+-- API Key 即使软删除后也不应该重复使用(安全考虑)
+-- ============================================================================
diff --git a/backend/migrations/018_user_attributes.sql b/backend/migrations/018_user_attributes.sql
index 8290c9d5..d2dad80d 100644
--- a/backend/migrations/018_user_attributes.sql
+++ b/backend/migrations/018_user_attributes.sql
@@ -1,48 +1,48 @@
--- Add user attribute definitions and values tables for custom user attributes.
-
--- User Attribute Definitions table (with soft delete support)
-CREATE TABLE IF NOT EXISTS user_attribute_definitions (
- id BIGSERIAL PRIMARY KEY,
- key VARCHAR(100) NOT NULL,
- name VARCHAR(255) NOT NULL,
- description TEXT DEFAULT '',
- type VARCHAR(20) NOT NULL,
- options JSONB DEFAULT '[]'::jsonb,
- required BOOLEAN NOT NULL DEFAULT FALSE,
- validation JSONB DEFAULT '{}'::jsonb,
- placeholder VARCHAR(255) DEFAULT '',
- display_order INT NOT NULL DEFAULT 0,
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- deleted_at TIMESTAMPTZ
-);
-
--- Partial unique index for key (only for non-deleted records)
--- Allows reusing keys after soft delete
-CREATE UNIQUE INDEX IF NOT EXISTS idx_user_attribute_definitions_key_unique
- ON user_attribute_definitions(key) WHERE deleted_at IS NULL;
-
-CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_enabled
- ON user_attribute_definitions(enabled);
-CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_display_order
- ON user_attribute_definitions(display_order);
-CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_deleted_at
- ON user_attribute_definitions(deleted_at);
-
--- User Attribute Values table (hard delete only, no deleted_at)
-CREATE TABLE IF NOT EXISTS user_attribute_values (
- id BIGSERIAL PRIMARY KEY,
- user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- attribute_id BIGINT NOT NULL REFERENCES user_attribute_definitions(id) ON DELETE CASCADE,
- value TEXT DEFAULT '',
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
- UNIQUE(user_id, attribute_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_user_attribute_values_user_id
- ON user_attribute_values(user_id);
-CREATE INDEX IF NOT EXISTS idx_user_attribute_values_attribute_id
- ON user_attribute_values(attribute_id);
+-- Add user attribute definitions and values tables for custom user attributes.
+
+-- User Attribute Definitions table (with soft delete support)
+CREATE TABLE IF NOT EXISTS user_attribute_definitions (
+ id BIGSERIAL PRIMARY KEY,
+ key VARCHAR(100) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT '',
+ type VARCHAR(20) NOT NULL,
+ options JSONB DEFAULT '[]'::jsonb,
+ required BOOLEAN NOT NULL DEFAULT FALSE,
+ validation JSONB DEFAULT '{}'::jsonb,
+ placeholder VARCHAR(255) DEFAULT '',
+ display_order INT NOT NULL DEFAULT 0,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+);
+
+-- Partial unique index for key (only for non-deleted records)
+-- Allows reusing keys after soft delete
+CREATE UNIQUE INDEX IF NOT EXISTS idx_user_attribute_definitions_key_unique
+ ON user_attribute_definitions(key) WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_enabled
+ ON user_attribute_definitions(enabled);
+CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_display_order
+ ON user_attribute_definitions(display_order);
+CREATE INDEX IF NOT EXISTS idx_user_attribute_definitions_deleted_at
+ ON user_attribute_definitions(deleted_at);
+
+-- User Attribute Values table (hard delete only, no deleted_at)
+CREATE TABLE IF NOT EXISTS user_attribute_values (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ attribute_id BIGINT NOT NULL REFERENCES user_attribute_definitions(id) ON DELETE CASCADE,
+ value TEXT DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE(user_id, attribute_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_attribute_values_user_id
+ ON user_attribute_values(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_attribute_values_attribute_id
+ ON user_attribute_values(attribute_id);
diff --git a/backend/migrations/019_migrate_wechat_to_attributes.sql b/backend/migrations/019_migrate_wechat_to_attributes.sql
index ac98f99f..765ca498 100644
--- a/backend/migrations/019_migrate_wechat_to_attributes.sql
+++ b/backend/migrations/019_migrate_wechat_to_attributes.sql
@@ -1,83 +1,83 @@
--- Migration: Move wechat field from users table to user_attribute_values
--- This migration:
--- 1. Creates a "wechat" attribute definition
--- 2. Migrates existing wechat data to user_attribute_values
--- 3. Does NOT drop the wechat column (for rollback safety, can be done in a later migration)
-
--- +goose Up
--- +goose StatementBegin
-
--- Step 1: Insert wechat attribute definition if not exists
-INSERT INTO user_attribute_definitions (key, name, description, type, options, required, validation, placeholder, display_order, enabled, created_at, updated_at)
-SELECT 'wechat', '微信', '用户微信号', 'text', '[]'::jsonb, false, '{}'::jsonb, '请输入微信号', 0, true, NOW(), NOW()
-WHERE NOT EXISTS (
- SELECT 1 FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
-);
-
--- Step 2: Migrate existing wechat values to user_attribute_values
--- Only migrate non-empty values
-INSERT INTO user_attribute_values (user_id, attribute_id, value, created_at, updated_at)
-SELECT
- u.id,
- (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1),
- u.wechat,
- NOW(),
- NOW()
-FROM users u
-WHERE u.wechat IS NOT NULL
- AND u.wechat != ''
- AND u.deleted_at IS NULL
- AND NOT EXISTS (
- SELECT 1 FROM user_attribute_values uav
- WHERE uav.user_id = u.id
- AND uav.attribute_id = (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1)
- );
-
--- Step 3: Update display_order to ensure wechat appears first
-UPDATE user_attribute_definitions
-SET display_order = -1
-WHERE key = 'wechat' AND deleted_at IS NULL;
-
--- Reorder all attributes starting from 0
-WITH ordered AS (
- SELECT id, ROW_NUMBER() OVER (ORDER BY display_order, id) - 1 as new_order
- FROM user_attribute_definitions
- WHERE deleted_at IS NULL
-)
-UPDATE user_attribute_definitions
-SET display_order = ordered.new_order
-FROM ordered
-WHERE user_attribute_definitions.id = ordered.id;
-
--- Step 4: Drop the redundant wechat column from users table
-ALTER TABLE users DROP COLUMN IF EXISTS wechat;
-
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-
--- Restore wechat column
-ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) DEFAULT '';
-
--- Copy attribute values back to users.wechat column
-UPDATE users u
-SET wechat = uav.value
-FROM user_attribute_values uav
-JOIN user_attribute_definitions uad ON uav.attribute_id = uad.id
-WHERE uav.user_id = u.id
- AND uad.key = 'wechat'
- AND uad.deleted_at IS NULL;
-
--- Delete migrated attribute values
-DELETE FROM user_attribute_values
-WHERE attribute_id IN (
- SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
-);
-
--- Soft-delete the wechat attribute definition
-UPDATE user_attribute_definitions
-SET deleted_at = NOW()
-WHERE key = 'wechat' AND deleted_at IS NULL;
-
--- +goose StatementEnd
+-- Migration: Move wechat field from users table to user_attribute_values
+-- This migration:
+-- 1. Creates a "wechat" attribute definition
+-- 2. Migrates existing wechat data to user_attribute_values
+-- 3. Does NOT drop the wechat column (for rollback safety, can be done in a later migration)
+
+-- +goose Up
+-- +goose StatementBegin
+
+-- Step 1: Insert wechat attribute definition if not exists
+INSERT INTO user_attribute_definitions (key, name, description, type, options, required, validation, placeholder, display_order, enabled, created_at, updated_at)
+SELECT 'wechat', '微信', '用户微信号', 'text', '[]'::jsonb, false, '{}'::jsonb, '请输入微信号', 0, true, NOW(), NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
+);
+
+-- Step 2: Migrate existing wechat values to user_attribute_values
+-- Only migrate non-empty values
+INSERT INTO user_attribute_values (user_id, attribute_id, value, created_at, updated_at)
+SELECT
+ u.id,
+ (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1),
+ u.wechat,
+ NOW(),
+ NOW()
+FROM users u
+WHERE u.wechat IS NOT NULL
+ AND u.wechat != ''
+ AND u.deleted_at IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM user_attribute_values uav
+ WHERE uav.user_id = u.id
+ AND uav.attribute_id = (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1)
+ );
+
+-- Step 3: Update display_order to ensure wechat appears first
+UPDATE user_attribute_definitions
+SET display_order = -1
+WHERE key = 'wechat' AND deleted_at IS NULL;
+
+-- Reorder all attributes starting from 0
+WITH ordered AS (
+ SELECT id, ROW_NUMBER() OVER (ORDER BY display_order, id) - 1 as new_order
+ FROM user_attribute_definitions
+ WHERE deleted_at IS NULL
+)
+UPDATE user_attribute_definitions
+SET display_order = ordered.new_order
+FROM ordered
+WHERE user_attribute_definitions.id = ordered.id;
+
+-- Step 4: Drop the redundant wechat column from users table
+ALTER TABLE users DROP COLUMN IF EXISTS wechat;
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+-- Restore wechat column
+ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) DEFAULT '';
+
+-- Copy attribute values back to users.wechat column
+UPDATE users u
+SET wechat = uav.value
+FROM user_attribute_values uav
+JOIN user_attribute_definitions uad ON uav.attribute_id = uad.id
+WHERE uav.user_id = u.id
+ AND uad.key = 'wechat'
+ AND uad.deleted_at IS NULL;
+
+-- Delete migrated attribute values
+DELETE FROM user_attribute_values
+WHERE attribute_id IN (
+ SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL
+);
+
+-- Soft-delete the wechat attribute definition
+UPDATE user_attribute_definitions
+SET deleted_at = NOW()
+WHERE key = 'wechat' AND deleted_at IS NULL;
+
+-- +goose StatementEnd
diff --git a/backend/migrations/024_add_gemini_tier_id.sql b/backend/migrations/024_add_gemini_tier_id.sql
index 1629238a..d9ac7afe 100644
--- a/backend/migrations/024_add_gemini_tier_id.sql
+++ b/backend/migrations/024_add_gemini_tier_id.sql
@@ -1,30 +1,30 @@
--- +goose Up
--- +goose StatementBegin
--- 为 Gemini Code Assist OAuth 账号添加默认 tier_id
--- 包括显式标记为 code_assist 的账号,以及 legacy 账号(oauth_type 为空但 project_id 存在)
-UPDATE accounts
-SET credentials = jsonb_set(
- credentials,
- '{tier_id}',
- '"LEGACY"',
- true
-)
-WHERE platform = 'gemini'
- AND type = 'oauth'
- AND jsonb_typeof(credentials) = 'object'
- AND credentials->>'tier_id' IS NULL
- AND (
- credentials->>'oauth_type' = 'code_assist'
- OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
- );
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
--- 回滚:删除 tier_id 字段
-UPDATE accounts
-SET credentials = credentials - 'tier_id'
-WHERE platform = 'gemini'
- AND type = 'oauth'
- AND credentials ? 'tier_id';
--- +goose StatementEnd
+-- +goose Up
+-- +goose StatementBegin
+-- 为 Gemini Code Assist OAuth 账号添加默认 tier_id
+-- 包括显式标记为 code_assist 的账号,以及 legacy 账号(oauth_type 为空但 project_id 存在)
+UPDATE accounts
+SET credentials = jsonb_set(
+ credentials,
+ '{tier_id}',
+ '"LEGACY"',
+ true
+)
+WHERE platform = 'gemini'
+ AND type = 'oauth'
+ AND jsonb_typeof(credentials) = 'object'
+ AND credentials->>'tier_id' IS NULL
+ AND (
+ credentials->>'oauth_type' = 'code_assist'
+ OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
+ );
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+-- 回滚:删除 tier_id 字段
+UPDATE accounts
+SET credentials = credentials - 'tier_id'
+WHERE platform = 'gemini'
+ AND type = 'oauth'
+ AND credentials ? 'tier_id';
+-- +goose StatementEnd