chore: 整合 migration 文件并优化配置

- 整合 18 个分散的 migration 文件为 5 个模块化文件
- settings.py 支持 .env.local 覆盖 .env
- 移除 user schema 中未使用的 country 字段正则
- 更新 profile protocol 文档移除 country 字段
- pyproject.toml 添加 ruff 到 dev 依赖
- 简化 integration test conftest 邮箱 fixture
This commit is contained in:
ZL-Q
2026-04-29 00:37:45 +08:00
parent dab47f0cb3
commit adb2b3bcc3
27 changed files with 787 additions and 2232 deletions
@@ -0,0 +1,98 @@
"""Create core LLM configuration tables.
Revision ID: 20260428_squash_0001
Revises:
Create Date: 2026-04-11 00:01:00
Squashed history: replaces the original initial LLM migration plus the later
duplicate-index cleanup by creating only the final target schema.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260428_squash_0001"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"llm_factory",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("request_url", sa.String(length=255), nullable=False),
sa.Column("avatar", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
_enable_service_only_rls("llm_factory")
op.create_table(
"llms",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("factory_id", sa.UUID(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["factory_id"], ["llm_factory.id"], name="fk_llms_factory_id", ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("model_code"),
)
op.create_index("ix_llms_factory_id", "llms", ["factory_id"])
_enable_service_only_rls("llms")
op.create_table(
"system_agents",
sa.Column("agent_type", sa.String(length=20), nullable=False),
sa.Column("llm_id", sa.UUID(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("agent_type"),
)
_enable_service_only_rls("system_agents")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated")
def downgrade() -> None:
_drop_service_only_rls("system_agents")
op.drop_table("system_agents")
_drop_service_only_rls("llms")
op.drop_index("ix_llms_factory_id", table_name="llms")
op.drop_table("llms")
_drop_service_only_rls("llm_factory")
op.drop_table("llm_factory")
def _enable_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)")
op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)")
def _drop_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,162 +0,0 @@
"""initial llm/factory/system_agents schema
Revision ID: 20260411_0001
Revises:
Create Date: 2026-04-11 00:01:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260411_0001"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"llm_factory",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("request_url", sa.String(length=255), nullable=False),
sa.Column("avatar", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
_enable_rls("llm_factory")
op.create_table(
"llms",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("factory_id", sa.UUID(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("model_code"),
)
op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False)
op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True)
op.create_foreign_key(
"fk_llms_factory_id",
"llms",
"llm_factory",
["factory_id"],
["id"],
ondelete="RESTRICT",
)
_enable_rls("llms")
op.create_table(
"system_agents",
sa.Column("agent_type", sa.String(length=20), nullable=False),
sa.Column("llm_id", sa.UUID(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column(
"config",
postgresql.JSONB(astext_type=sa.Text()),
server_default="{}",
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("agent_type"),
)
op.create_foreign_key(
"fk_system_agents_llm_id",
"system_agents",
"llms",
["llm_id"],
["id"],
ondelete="RESTRICT",
)
_enable_rls("system_agents")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon")
op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated")
def downgrade() -> None:
_drop_rls("system_agents")
op.drop_constraint("fk_system_agents_llm_id", "system_agents", type_="foreignkey")
op.drop_table("system_agents")
_drop_rls("llms")
op.drop_constraint("fk_llms_factory_id", "llms", type_="foreignkey")
op.drop_index("ix_llms_model_code", table_name="llms")
op.drop_index("ix_llms_factory_id", table_name="llms")
op.drop_table("llms")
_drop_rls("llm_factory")
op.drop_index("ix_llm_factory_name", table_name="llm_factory")
op.drop_table("llm_factory")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,579 +0,0 @@
"""add chat, points, and invite schema
Revision ID: 20260411_0002
Revises: 20260411_0001
Create Date: 2026-04-11 00:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260411_0002"
down_revision: Union[str, Sequence[str], None] = "20260411_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"profiles",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("username", sa.String(length=30), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=True),
sa.Column("bio", sa.String(length=200), nullable=True),
sa.Column(
"settings",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column("referred_by", sa.UUID(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"char_length(username) >= 1", name="ck_profiles_username_non_empty"
),
sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_profiles_username", "profiles", ["username"], unique=False)
op.create_index(
"ix_profiles_settings_gin",
"profiles",
["settings"],
unique=False,
postgresql_using="gin",
)
op.create_index(
"ix_profiles_referred_by", "profiles", ["referred_by"], unique=False
)
_enable_rls("profiles")
op.create_table(
"sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("job_id", sa.UUID(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column(
"last_activity_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"message_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"total_tokens",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"total_cost",
sa.Numeric(12, 6),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"session_type in ('chat', 'automation')", name="ck_sessions_session_type"
),
sa.CheckConstraint(
"status in ('pending', 'running', 'completed', 'failed')",
name="ck_sessions_status",
),
sa.CheckConstraint(
"message_count >= 0", name="ck_sessions_message_count_non_negative"
),
sa.CheckConstraint(
"total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"
),
sa.CheckConstraint(
"total_cost >= 0", name="ck_sessions_total_cost_non_negative"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"], unique=False)
op.create_index(
"ix_sessions_user_activity",
"sessions",
["user_id", "last_activity_at"],
unique=False,
)
_enable_rls("sessions")
op.create_table(
"messages",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("session_id", sa.UUID(), nullable=False),
sa.Column("seq", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column(
"input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False
),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column(
"visibility_mask",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("seq > 0", name="ck_messages_seq_positive"),
sa.CheckConstraint(
"role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"
),
sa.CheckConstraint(
"input_tokens >= 0", name="ck_messages_input_tokens_non_negative"
),
sa.CheckConstraint(
"output_tokens >= 0", name="ck_messages_output_tokens_non_negative"
),
sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"),
sa.CheckConstraint(
"latency_ms is null or latency_ms >= 0",
name="ck_messages_latency_non_negative",
),
sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"),
)
op.create_index("ix_messages_session_id", "messages", ["session_id"], unique=False)
op.create_index(
"ix_messages_session_seq_visibility",
"messages",
["session_id", "seq", "visibility_mask"],
unique=False,
)
_enable_rls("messages")
op.create_table(
"user_points",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column(
"balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"frozen_balance",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"lifetime_earned",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"lifetime_spent",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column("version", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"),
sa.CheckConstraint(
"frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"
),
sa.CheckConstraint(
"lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"
),
sa.CheckConstraint(
"lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"
),
sa.CheckConstraint(
"frozen_balance <= balance", name="ck_user_points_frozen_le_balance"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
_enable_rls("user_points")
op.create_table(
"points_ledger",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("direction", sa.SmallInteger(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("change_type", sa.String(length=16), nullable=False),
sa.Column("biz_type", sa.String(length=16), nullable=True),
sa.Column("biz_id", sa.UUID(), nullable=True),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("operator_id", sa.UUID(), nullable=True),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"),
sa.CheckConstraint(
"direction in (1, -1)", name="ck_points_ledger_direction_valid"
),
sa.CheckConstraint(
"balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"
),
sa.CheckConstraint(
"change_type in ('register', 'consume', 'grant', 'adjust')",
name="ck_points_ledger_change_type",
),
sa.CheckConstraint(
"biz_type is null or biz_type = 'chat'", name="ck_points_ledger_biz_type"
),
sa.CheckConstraint(
"((change_type = 'register' and biz_type is null and biz_id is null) or "
"(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))",
name="ck_points_ledger_biz_binding",
),
sa.CheckConstraint(
"((change_type in ('register', 'grant') and direction = 1) or "
"(change_type = 'consume' and direction = -1) or "
"(change_type = 'adjust' and direction in (1, -1)))",
name="ck_points_ledger_direction_by_change_type",
),
sa.CheckConstraint(
"jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"
),
sa.CheckConstraint(
"metadata->>'schema_version' = '1' and "
"metadata->>'operator_type' in ('user', 'system', 'admin') and "
"coalesce(metadata->>'run_id', '') <> '' and "
"(not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')",
name="ck_points_ledger_metadata_common",
),
sa.CheckConstraint(
"(change_type <> 'register' or not (metadata ? 'charge'))",
name="ck_points_ledger_metadata_register_shape",
),
sa.CheckConstraint(
"(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and "
"(metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and "
"(metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and "
"(metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))",
name="ck_points_ledger_metadata_consume_shape",
),
sa.CheckConstraint(
"(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
name="ck_points_ledger_metadata_adjust_shape",
),
sa.ForeignKeyConstraint(["biz_id"], ["sessions.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(
["operator_id"], ["auth.users.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"),
)
op.create_index(
"ix_points_ledger_user_created_at",
"points_ledger",
["user_id", sa.text("created_at DESC")],
unique=False,
)
op.create_index(
"ix_points_ledger_biz_type_biz_id",
"points_ledger",
["biz_type", "biz_id"],
unique=False,
)
_enable_rls("points_ledger")
op.execute(
"""
CREATE TABLE invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'),
owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')),
used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0),
max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1),
expires_at TIMESTAMPTZ NULL,
reward_config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)")
op.execute(
"CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'"
)
_enable_rls("invite_codes")
op.execute(
"""
CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
result TEXT := '';
i INT;
BEGIN
FOR i IN 1..6 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
END;
$$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_invite_code text;
v_referrer_id uuid;
v_attempts int := 0;
invite_code_value text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object(
'allow_notifications', true,
'allow_vibration', true
)
)
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
v_invite_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
v_invite_code,
new.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
v_attempts := v_attempts + 1;
IF v_attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := new.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO v_referrer_id;
IF v_referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = v_referrer_id
WHERE id = new.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
"""
)
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute(
"DROP TRIGGER IF EXISTS trg_initialize_profile_and_points_on_signup ON auth.users"
)
op.execute(
"""
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute(
"DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()"
)
op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()")
_drop_rls("invite_codes")
op.execute("DROP INDEX IF EXISTS ix_invite_codes_code")
op.execute("DROP INDEX IF EXISTS ix_invite_codes_owner_id")
op.execute("DROP TABLE IF EXISTS invite_codes")
_drop_rls("points_ledger")
op.drop_index("ix_points_ledger_biz_type_biz_id", table_name="points_ledger")
op.drop_index("ix_points_ledger_user_created_at", table_name="points_ledger")
op.drop_table("points_ledger")
_drop_rls("user_points")
op.drop_table("user_points")
_drop_rls("messages")
op.drop_index("ix_messages_session_seq_visibility", table_name="messages")
op.drop_index("ix_messages_session_id", table_name="messages")
op.drop_table("messages")
_drop_rls("sessions")
op.drop_index("ix_sessions_user_activity", table_name="sessions")
op.drop_index("ix_sessions_user_id", table_name="sessions")
op.drop_table("sessions")
_drop_rls("profiles")
op.drop_index("ix_profiles_referred_by", table_name="profiles")
op.drop_index("ix_profiles_settings_gin", table_name="profiles")
op.drop_index("ix_profiles_username", table_name="profiles")
op.drop_table("profiles")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,392 @@
"""Create user, chat, points, and invite schema.
Revision ID: 20260428_squash_0002
Revises: 20260428_squash_0001
Create Date: 2026-04-11 00:10:00
Squashed history: builds the final profiles settings shape directly, removes
the obsolete points_ledger.biz_id FK, and creates the final signup trigger.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260428_squash_0002"
down_revision: Union[str, Sequence[str], None] = "20260428_squash_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
_create_profiles()
_create_chat_tables()
_create_points_tables()
_create_invite_codes()
_create_signup_helpers()
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute("DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()")
op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()")
for table_name in [
"invite_codes",
"register_bonus_claims",
"points_audit_ledger",
"points_ledger",
"user_points",
"messages",
"sessions",
"profiles",
]:
_drop_service_only_rls(table_name)
op.drop_table("invite_codes")
op.drop_table("register_bonus_claims")
op.drop_table("points_audit_ledger")
op.drop_table("points_ledger")
op.drop_table("user_points")
op.drop_table("messages")
op.drop_table("sessions")
op.drop_table("profiles")
def _create_profiles() -> None:
op.create_table(
"profiles",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("username", sa.String(length=30), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=True),
sa.Column("bio", sa.String(length=200), nullable=True),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("referred_by", sa.UUID(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("char_length(username) >= 1", name="ck_profiles_username_non_empty"),
sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_profiles_username", "profiles", ["username"])
op.create_index("ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin")
op.create_index("ix_profiles_referred_by", "profiles", ["referred_by"])
_enable_service_only_rls("profiles")
def _create_chat_tables() -> None:
op.create_table(
"sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("job_id", sa.UUID(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("message_count", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("total_cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column("state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("session_type in ('chat', 'automation')", name="ck_sessions_session_type"),
sa.CheckConstraint("status in ('pending', 'running', 'completed', 'failed')", name="ck_sessions_status"),
sa.CheckConstraint("message_count >= 0", name="ck_sessions_message_count_non_negative"),
sa.CheckConstraint("total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"),
sa.CheckConstraint("total_cost >= 0", name="ck_sessions_total_cost_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
op.create_index("ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"])
_enable_service_only_rls("sessions")
op.create_table(
"messages",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("session_id", sa.UUID(), nullable=False),
sa.Column("seq", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("visibility_mask", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("seq > 0", name="ck_messages_seq_positive"),
sa.CheckConstraint("role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"),
sa.CheckConstraint("input_tokens >= 0", name="ck_messages_input_tokens_non_negative"),
sa.CheckConstraint("output_tokens >= 0", name="ck_messages_output_tokens_non_negative"),
sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"),
sa.CheckConstraint("latency_ms is null or latency_ms >= 0", name="ck_messages_latency_non_negative"),
sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"),
)
op.create_index("ix_messages_session_id", "messages", ["session_id"])
op.create_index("ix_messages_session_seq_visibility", "messages", ["session_id", "seq", "visibility_mask"])
_enable_service_only_rls("messages")
def _create_points_tables() -> None:
op.create_table(
"user_points",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("frozen_balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("lifetime_spent", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"),
sa.CheckConstraint("frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"),
sa.CheckConstraint("lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"),
sa.CheckConstraint("frozen_balance <= balance", name="ck_user_points_frozen_le_balance"),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
_enable_service_only_rls("user_points")
op.create_table(
"points_ledger",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("direction", sa.SmallInteger(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("change_type", sa.String(length=16), nullable=False),
sa.Column("biz_type", sa.String(length=16), nullable=True),
sa.Column("biz_id", sa.UUID(), nullable=True),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("operator_id", sa.UUID(), nullable=True),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"),
sa.CheckConstraint("direction in (1, -1)", name="ck_points_ledger_direction_valid"),
sa.CheckConstraint("balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"),
sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type"),
sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type"),
sa.CheckConstraint("((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding"),
sa.CheckConstraint("((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type"),
sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"),
sa.CheckConstraint("metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", name="ck_points_ledger_metadata_common"),
sa.CheckConstraint("(change_type <> 'register' or not (metadata ? 'charge'))", name="ck_points_ledger_metadata_register_shape"),
sa.CheckConstraint("(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", name="ck_points_ledger_metadata_consume_shape"),
sa.CheckConstraint("(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape"),
sa.CheckConstraint("(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", name="ck_points_ledger_metadata_payment_shape"),
sa.CheckConstraint("(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", name="ck_points_ledger_metadata_refund_shape"),
sa.ForeignKeyConstraint(["operator_id"], ["auth.users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"),
)
op.create_index("ix_points_ledger_user_created_at", "points_ledger", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"])
_enable_service_only_rls("points_ledger")
op.create_table(
"points_audit_ledger",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("user_id_snapshot", sa.UUID(), nullable=True),
sa.Column("user_email_snapshot", sa.Text(), nullable=True),
sa.Column("change_type", sa.String(length=16), nullable=False),
sa.Column("biz_type", sa.String(length=16), nullable=True),
sa.Column("biz_id", sa.UUID(), nullable=True),
sa.Column("direction", sa.SmallInteger(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("billed_to", sa.String(length=16), nullable=False),
sa.Column("run_id", sa.String(length=128), nullable=True),
sa.Column("request_id", sa.String(length=128), nullable=True),
sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("amount >= 0", name="ck_points_audit_ledger_amount_non_negative"),
sa.CheckConstraint("direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"),
sa.CheckConstraint("balance_after >= 0", name="ck_points_audit_ledger_balance_after_non_negative"),
sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type"),
sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type"),
sa.CheckConstraint("billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"),
sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_audit_ledger_metadata_object"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"),
)
op.create_index("ix_points_audit_ledger_user_id_created_at", "points_audit_ledger", ["user_id_snapshot", sa.text("created_at DESC")])
op.create_index("ix_points_audit_ledger_change_type_created_at", "points_audit_ledger", ["change_type", sa.text("created_at DESC")])
_enable_service_only_rls("points_audit_ledger")
op.create_table(
"register_bonus_claims",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("email_hash", sa.String(length=64), nullable=False),
sa.Column("user_email_snapshot", sa.Text(), nullable=False),
sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True),
sa.Column("balance_snapshot", sa.BigInteger(), nullable=True),
sa.Column("grant_event_id", sa.String(length=64), nullable=False),
sa.Column("has_purchased_starter_pack", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"),
sa.UniqueConstraint("grant_event_id", name="uq_register_bonus_claims_grant_event_id"),
)
_enable_service_only_rls("register_bonus_claims")
def _create_invite_codes() -> None:
op.execute(
"""
CREATE TABLE invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'),
owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')),
used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0),
max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1),
expires_at TIMESTAMPTZ NULL,
reward_config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)")
op.execute("CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'")
_enable_service_only_rls("invite_codes")
def _create_signup_helpers() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
result TEXT := '';
i INT;
BEGIN
FOR i IN 1..6 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
END;
$$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_invite_code text;
v_referrer_id uuid;
v_attempts int := 0;
invite_code_value text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object('language', 'zh-CN', 'timezone', 'Asia/Shanghai'),
'privacy', jsonb_build_object('can_sell', false, 'profile_visibility', 'public'),
'notification', jsonb_build_object('allow_notifications', true, 'allow_vibration', true),
'divination_tutorial', jsonb_build_object(
'divination_entry_shown', false,
'auto_divination_shown', false,
'manual_divination_shown', false
)
)
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
v_invite_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (v_invite_code, new.id, 'active', 0, NULL, NULL, '{}'::jsonb);
EXIT;
EXCEPTION WHEN unique_violation THEN
v_attempts := v_attempts + 1;
IF v_attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := new.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO v_referrer_id;
IF v_referrer_id IS NOT NULL THEN
UPDATE public.profiles SET referred_by = v_referrer_id WHERE id = new.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
"""
)
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute("CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()")
def _enable_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)")
op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)")
def _drop_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,101 @@
"""Create notification inbox schema.
Revision ID: 20260428_squash_0003
Revises: 20260428_squash_0002
Create Date: 2026-04-11 12:00:00
Squashed history: creates notifications with static-sync fields, target_mode,
and final i18n jsonb title/body columns in one step.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260428_squash_0003"
down_revision: Union[str, Sequence[str], None] = "20260428_squash_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("type", sa.String(length=32), server_default=sa.text("'system'"), nullable=False),
sa.Column("source", sa.String(length=32), server_default=sa.text("'manual'"), nullable=False),
sa.Column("source_key", sa.String(length=128), nullable=True),
sa.Column("source_version", sa.Integer(), nullable=True),
sa.Column("content_hash", sa.String(length=64), nullable=True),
sa.Column("title", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("body", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("status", sa.String(length=16), server_default=sa.text("'published'"), nullable=False),
sa.Column("target_mode", sa.String(length=32), server_default=sa.text("'all_users'"), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('draft', 'published', 'revoked')", name="ck_notifications_status"),
sa.CheckConstraint("target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", name="ck_notifications_target_mode"),
sa.CheckConstraint("jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_notifications_status_created_at", "notifications", ["status", sa.text("created_at DESC")])
op.create_index("ix_notifications_published_at", "notifications", [sa.text("published_at DESC")])
op.create_index(
"uq_notifications_source_source_key",
"notifications",
["source", "source_key"],
unique=True,
postgresql_where=sa.text("source_key IS NOT NULL"),
)
_enable_service_only_rls("notifications")
op.create_table(
"user_notifications",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("notification_id", sa.UUID(), nullable=False),
sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["notification_id"], ["notifications.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "notification_id", name="uq_user_notifications_user_notification"),
)
op.create_index("ix_user_notifications_user_created_at", "user_notifications", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_user_notifications_user_unread", "user_notifications", ["user_id", "is_read"])
_enable_service_only_rls("user_notifications")
def downgrade() -> None:
_drop_service_only_rls("user_notifications")
op.drop_table("user_notifications")
_drop_service_only_rls("notifications")
op.drop_table("notifications")
def _enable_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)")
op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)")
def _drop_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,190 +0,0 @@
"""add points audit ledger and register bonus claims
Revision ID: 20260411_0003
Revises: 20260411_0002
Create Date: 2026-04-11 00:20:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260411_0003"
down_revision: Union[str, Sequence[str], None] = "20260411_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"DROP TRIGGER IF EXISTS trg_initialize_profile_and_points_on_signup ON auth.users"
)
op.execute(
"DROP FUNCTION IF EXISTS public.initialize_profile_and_points_on_signup()"
)
op.create_table(
"points_audit_ledger",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("user_id_snapshot", sa.UUID(), nullable=True),
sa.Column("user_email_snapshot", sa.Text(), nullable=True),
sa.Column("change_type", sa.String(length=16), nullable=False),
sa.Column("biz_type", sa.String(length=16), nullable=True),
sa.Column("biz_id", sa.UUID(), nullable=True),
sa.Column("direction", sa.SmallInteger(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("billed_to", sa.String(length=16), nullable=False),
sa.Column("run_id", sa.String(length=128), nullable=True),
sa.Column("request_id", sa.String(length=128), nullable=True),
sa.Column(
"input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"cost",
sa.Numeric(precision=12, scale=6),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"amount >= 0", name="ck_points_audit_ledger_amount_non_negative"
),
sa.CheckConstraint(
"direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"
),
sa.CheckConstraint(
"balance_after >= 0",
name="ck_points_audit_ledger_balance_after_non_negative",
),
sa.CheckConstraint(
"change_type in ('register', 'consume', 'grant', 'adjust')",
name="ck_points_audit_ledger_change_type",
),
sa.CheckConstraint(
"biz_type is null or biz_type = 'chat'",
name="ck_points_audit_ledger_biz_type",
),
sa.CheckConstraint(
"billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"
),
sa.CheckConstraint(
"jsonb_typeof(metadata) = 'object'",
name="ck_points_audit_ledger_metadata_object",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"),
)
op.create_index(
"ix_points_audit_ledger_user_id_created_at",
"points_audit_ledger",
["user_id_snapshot", sa.text("created_at DESC")],
unique=False,
)
op.create_index(
"ix_points_audit_ledger_change_type_created_at",
"points_audit_ledger",
["change_type", sa.text("created_at DESC")],
unique=False,
)
_enable_rls("points_audit_ledger")
op.create_table(
"register_bonus_claims",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("email_hash", sa.String(length=64), nullable=False),
sa.Column("user_email_snapshot", sa.Text(), nullable=False),
sa.Column("first_user_id", sa.UUID(), nullable=True),
sa.Column("grant_event_id", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["first_user_id"], ["auth.users.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"),
sa.UniqueConstraint(
"grant_event_id", name="uq_register_bonus_claims_grant_event_id"
),
)
_enable_rls("register_bonus_claims")
def downgrade() -> None:
_drop_rls("register_bonus_claims")
op.drop_table("register_bonus_claims")
_drop_rls("points_audit_ledger")
op.drop_index(
"ix_points_audit_ledger_change_type_created_at",
table_name="points_audit_ledger",
)
op.drop_index(
"ix_points_audit_ledger_user_id_created_at",
table_name="points_audit_ledger",
)
op.drop_table("points_audit_ledger")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,170 +0,0 @@
"""add notifications and user_notifications tables
Revision ID: 20260411_0004
Revises: 20260411_0003
Create Date: 2026-04-11 12:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260411_0004"
down_revision: Union[str, Sequence[str], None] = "20260411_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column(
"type",
sa.String(length=32),
server_default=sa.text("'system'"),
nullable=False,
),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"payload",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=16),
server_default=sa.text("'published'"),
nullable=False,
),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"status IN ('draft', 'published', 'revoked')",
name="ck_notifications_status",
),
sa.CheckConstraint(
"jsonb_typeof(payload) = 'object'",
name="ck_notifications_payload_object",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_notifications_status_created_at",
"notifications",
["status", sa.text("created_at DESC")],
)
op.create_index(
"ix_notifications_published_at",
"notifications",
[sa.text("published_at DESC")],
)
_enable_rls("notifications")
op.create_table(
"user_notifications",
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("notification_id", sa.UUID(), nullable=False),
sa.Column(
"is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["notification_id"], ["notifications.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id", "notification_id", name="uq_user_notifications_user_notification"
),
)
op.create_index(
"ix_user_notifications_user_created_at",
"user_notifications",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_user_notifications_user_unread",
"user_notifications",
["user_id", "is_read"],
)
_enable_rls("user_notifications")
def downgrade() -> None:
_drop_rls("user_notifications")
op.drop_index("ix_user_notifications_user_unread", table_name="user_notifications")
op.drop_index(
"ix_user_notifications_user_created_at", table_name="user_notifications"
)
op.drop_table("user_notifications")
_drop_rls("notifications")
op.drop_index("ix_notifications_published_at", table_name="notifications")
op.drop_index("ix_notifications_status_created_at", table_name="notifications")
op.drop_table("notifications")
def _enable_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
)
op.execute(
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
)
op.execute(
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,55 +0,0 @@
"""add notification static sync fields
Revision ID: 20260411_0005
Revises: 20260411_0004
Create Date: 2026-04-11 16:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260411_0005"
down_revision: Union[str, Sequence[str], None] = "20260411_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"notifications",
sa.Column(
"source",
sa.String(length=32),
server_default=sa.text("'manual'"),
nullable=False,
),
)
op.add_column(
"notifications",
sa.Column("source_key", sa.String(length=128), nullable=True),
)
op.add_column(
"notifications",
sa.Column("source_version", sa.Integer(), nullable=True),
)
op.add_column(
"notifications",
sa.Column("content_hash", sa.String(length=64), nullable=True),
)
op.create_index(
"uq_notifications_source_source_key",
"notifications",
["source", "source_key"],
unique=True,
postgresql_where=sa.text("source_key IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("uq_notifications_source_source_key", table_name="notifications")
op.drop_column("notifications", "content_hash")
op.drop_column("notifications", "source_version")
op.drop_column("notifications", "source_key")
op.drop_column("notifications", "source")
@@ -1,50 +0,0 @@
"""store register bonus balance snapshot and remove first_user_id fk
Revision ID: 20260413_0004
Revises: 20260411_0005
Create Date: 2026-04-13 00:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260413_0004"
down_revision: Union[str, Sequence[str], None] = "20260411_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE register_bonus_claims DROP CONSTRAINT IF EXISTS register_bonus_claims_first_user_id_fkey"
)
op.drop_column("register_bonus_claims", "first_user_id")
op.add_column(
"register_bonus_claims",
sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True),
)
op.add_column(
"register_bonus_claims",
sa.Column("balance_snapshot", sa.BigInteger(), nullable=True),
)
def downgrade() -> None:
op.drop_column("register_bonus_claims", "balance_snapshot")
op.drop_column("register_bonus_claims", "first_user_id_snapshot")
op.add_column(
"register_bonus_claims",
sa.Column("first_user_id", sa.UUID(), nullable=True),
)
op.create_foreign_key(
"register_bonus_claims_first_user_id_fkey",
"register_bonus_claims",
"users",
["first_user_id"],
["id"],
source_schema="public",
referent_schema="auth",
ondelete="SET NULL",
)
@@ -1,111 +0,0 @@
"""add anonymous_session_snapshots table for iOS compliance
Revision ID: 20260415_0001
Revises: 20260413_0004
Create Date: 2026-04-15 00:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260415_0001"
down_revision: Union[str, Sequence[str], None] = "20260413_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"anonymous_session_snapshots",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("anonymous_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=True),
sa.Column("question_type", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column("gua_name", sa.String(length=50), nullable=True),
sa.Column("gua_name_hant", sa.String(length=50), nullable=True),
sa.Column("target_gua_name", sa.String(length=50), nullable=True),
sa.Column("has_changing_yao", sa.Boolean(), nullable=True),
sa.Column("sign_level", sa.String(length=20), nullable=True),
sa.Column("keywords", postgresql.ARRAY(sa.Text()), nullable=True),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 6), nullable=True),
sa.Column("total_latency_ms", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"anonymized_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_anonymous_session_snapshots_anonymous_id",
"anonymous_session_snapshots",
["anonymous_id"],
unique=False,
)
op.create_index(
"ix_anonymous_session_snapshots_created_at",
"anonymous_session_snapshots",
["created_at"],
unique=False,
)
op.create_index(
"ix_anonymous_session_snapshots_question_type",
"anonymous_session_snapshots",
["question_type"],
unique=False,
)
_enable_service_role_only_rls("anonymous_session_snapshots")
def downgrade() -> None:
_drop_rls("anonymous_session_snapshots")
op.drop_index(
"ix_anonymous_session_snapshots_question_type",
table_name="anonymous_session_snapshots",
)
op.drop_index(
"ix_anonymous_session_snapshots_created_at",
table_name="anonymous_session_snapshots",
)
op.drop_index(
"ix_anonymous_session_snapshots_anonymous_id",
table_name="anonymous_session_snapshots",
)
op.drop_table("anonymous_session_snapshots")
def _enable_service_role_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
op.execute(
f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)"
)
def _drop_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"DROP POLICY IF EXISTS service_role_all_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,97 @@
"""Create compliance snapshot and feedback tables.
Revision ID: 20260428_squash_0004
Revises: 20260428_squash_0003
Create Date: 2026-04-15 00:10:00
Squashed history: keeps iOS anonymous-session snapshots and user feedback as
separate product surfaces while removing the intervening unrelated revisions.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260428_squash_0004"
down_revision: Union[str, Sequence[str], None] = "20260428_squash_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"anonymous_session_snapshots",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("anonymous_id", sa.UUID(), nullable=False),
sa.Column("session_type", sa.String(length=20), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=True),
sa.Column("question_type", sa.String(length=50), nullable=True),
sa.Column("tool_name", sa.String(length=100), nullable=True),
sa.Column("gua_name", sa.String(length=50), nullable=True),
sa.Column("gua_name_hant", sa.String(length=50), nullable=True),
sa.Column("target_gua_name", sa.String(length=50), nullable=True),
sa.Column("has_changing_yao", sa.Boolean(), nullable=True),
sa.Column("sign_level", sa.String(length=20), nullable=True),
sa.Column("keywords", postgresql.ARRAY(sa.Text()), nullable=True),
sa.Column("model_code", sa.String(length=50), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("total_cost", sa.Numeric(12, 6), nullable=True),
sa.Column("total_latency_ms", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("anonymized_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_anonymous_session_snapshots_anonymous_id", "anonymous_session_snapshots", ["anonymous_id"])
op.create_index("ix_anonymous_session_snapshots_created_at", "anonymous_session_snapshots", ["created_at"])
op.create_index("ix_anonymous_session_snapshots_question_type", "anonymous_session_snapshots", ["question_type"])
_enable_service_role_all_rls("anonymous_session_snapshots")
op.create_table(
"user_feedback",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("feedback_type", sa.String(length=20), server_default=sa.text("'other'"), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("images", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False),
sa.Column("device_info", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("app_version", sa.String(length=20), nullable=False),
sa.Column("os_version", sa.String(length=50), nullable=False),
sa.Column("status", sa.String(length=20), server_default=sa.text("'pending'"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"])
op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"])
op.create_index("ix_user_feedback_status", "user_feedback", ["status"])
op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'")
op.execute("COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'")
op.execute("COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'")
op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'")
op.execute("COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'")
op.execute("COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'")
op.execute("COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'")
_enable_service_role_all_rls("user_feedback")
def downgrade() -> None:
_drop_service_role_all_rls("user_feedback")
op.drop_table("user_feedback")
_drop_service_role_all_rls("anonymous_session_snapshots")
op.drop_table("anonymous_session_snapshots")
def _enable_service_role_all_rls(table_name: str) -> None:
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
op.execute(f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)")
def _drop_service_role_all_rls(table_name: str) -> None:
op.execute(f"DROP POLICY IF EXISTS service_role_all_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -1,40 +0,0 @@
"""drop points_ledger.biz_id foreign key for snapshot-style reference
Revision ID: 20260415_0002
Revises: 20260415_0001
Create Date: 2026-04-15 10:00:00
points_ledger.biz_id stores a snapshot reference to sessions.id for audit purposes.
This allows sessions to be deleted while preserving the biz_id value in points_ledger
for user-facing transaction history.
The FK constraint is removed because:
1. Users need to see their points transaction history even after session deletion
2. Session deletion (anonymization for iOS compliance) should not cascade delete
points_ledger records
3. biz_id becomes a "snapshot" reference - the value is kept but no FK enforcement
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260415_0002"
down_revision: Union[str, Sequence[str], None] = "20260415_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint("points_ledger_biz_id_fkey", "points_ledger", type_="foreignkey")
def downgrade() -> None:
op.create_foreign_key(
"points_ledger_biz_id_fkey",
"points_ledger",
"sessions",
["biz_id"],
["id"],
ondelete="SET NULL",
)
@@ -1,32 +0,0 @@
"""add has_purchased_starter_pack to register_bonus_claims
Revision ID: 20260416_0001
Revises: 20260413_0004
Create Date: 2026-04-16 12:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260416_0001"
down_revision: Union[str, Sequence[str], None] = "20260415_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"register_bonus_claims",
sa.Column(
"has_purchased_starter_pack",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("register_bonus_claims", "has_purchased_starter_pack")
@@ -1,25 +0,0 @@
"""drop duplicate indexes on llm_factory.name and llms.model_code
Revision ID: 20260416_0002
Revises: 20260416_0001
Create Date: 2026-04-16
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260416_0002"
down_revision: Union[str, Sequence[str], None] = "20260416_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index("ix_llm_factory_name", table_name="llm_factory")
op.drop_index("ix_llms_model_code", table_name="llms")
def downgrade() -> None:
op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True)
@@ -1,37 +0,0 @@
"""add target_mode to notifications
Revision ID: 20260416_0003
Revises: 20260416_0002
Create Date: 2026-04-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260416_0003"
down_revision: Union[str, Sequence[str], None] = "20260416_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"notifications",
sa.Column(
"target_mode",
sa.String(32),
nullable=False,
server_default="all_users",
),
)
op.execute(
"ALTER TABLE notifications ADD CONSTRAINT ck_notifications_target_mode "
"CHECK (target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids'))"
)
def downgrade() -> None:
op.execute("ALTER TABLE notifications DROP CONSTRAINT ck_notifications_target_mode")
op.drop_column("notifications", "target_mode")
@@ -1,118 +0,0 @@
"""create user_feedback table
Revision ID: 20260417_0001
Revises: 20260416_0003
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "20260417_0001"
down_revision: Union[str, Sequence[str], None] = "20260416_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_feedback",
sa.Column(
"id",
UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
primary_key=True,
),
sa.Column(
"user_id",
UUID(as_uuid=True),
sa.ForeignKey("auth.users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"feedback_type",
sa.String(20),
nullable=False,
server_default="other",
),
sa.Column("content", sa.Text, nullable=False),
sa.Column(
"images",
JSONB,
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
sa.Column(
"device_info",
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column("app_version", sa.String(20), nullable=False),
sa.Column("os_version", sa.String(50), nullable=False),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"])
op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"])
op.create_index("ix_user_feedback_status", "user_feedback", ["status"])
op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'")
op.execute(
"COMMENT ON COLUMN user_feedback.user_id IS "
"'用户ID,NULL表示匿名(勾选不上传我的个人信息)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.feedback_type IS "
"'反馈类型: bug/suggestion/other'"
)
op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'")
op.execute(
"COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.device_info IS "
"'设备信息JSON,匿名时照样采集(不涉及隐私)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'"
)
op.execute("ALTER TABLE public.user_feedback ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY "Service role full access on user_feedback"
ON public.user_feedback
FOR ALL
TO service_role
USING (true)
WITH CHECK (true)
""")
def downgrade() -> None:
op.execute(
'DROP POLICY IF EXISTS "Service role full access on user_feedback" ON public.user_feedback'
)
op.execute("ALTER TABLE public.user_feedback DISABLE ROW LEVEL SECURITY")
op.drop_table("user_feedback")
@@ -1,278 +0,0 @@
"""add apple_iap_transactions table and update points_ledger constraints
Revision ID: 20260427_0001
Revises: 20260417_0001
Create Date: 2026-04-27 12:00:00
Changes:
1. Create apple_iap_transactions table for Apple IAP payment tracking
2. Update points_ledger check constraints:
- Remove 'grant' from change_type (merged into 'adjust')
- Add 'purchase' and 'refund' to change_type
- Add 'payment' to biz_type
- Update biz_binding constraint for new types
- Update direction_by_change_type constraint
- Add metadata shape constraints for purchase/refund
- Update adjust metadata constraint (ticket_id -> reason)
3. Update points_audit_ledger check constraints similarly
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260427_0001"
down_revision: Union[str, Sequence[str], None] = "20260417_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"apple_iap_transactions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_code", sa.String(32), nullable=False),
sa.Column("app_store_product_id", sa.String(128), nullable=False),
sa.Column("transaction_id", sa.String(64), nullable=False),
sa.Column("original_transaction_id", sa.String(64), nullable=True),
sa.Column("web_order_line_item_id", sa.String(64), nullable=True),
sa.Column("environment", sa.String(16), nullable=False),
sa.Column("bundle_id", sa.String(128), nullable=False),
sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("purchase_date", sa.Text, nullable=False),
sa.Column("revocation_date", sa.Text, nullable=True),
sa.Column("status", sa.String(24), nullable=False),
sa.Column("credits", sa.BigInteger, nullable=False),
sa.Column("currency", sa.String(8), nullable=True),
sa.Column("price_milliunits", sa.BigInteger, nullable=True),
sa.Column("ledger_event_id", sa.String(64), nullable=True),
sa.Column("signed_transaction_info", sa.Text, nullable=False),
sa.Column(
"apple_payload",
postgresql.JSONB(),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column("failure_code", sa.String(64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"environment in ('Sandbox', 'Production')",
name="ck_apple_iap_transactions_environment",
),
sa.CheckConstraint(
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
name="ck_apple_iap_transactions_status",
),
sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"),
sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"),
)
op.create_index(
"ix_apple_iap_transactions_user_created_at",
"apple_iap_transactions",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_apple_iap_transactions_status_updated_at",
"apple_iap_transactions",
["status", sa.text("updated_at DESC")],
)
op.execute("ALTER TABLE apple_iap_transactions ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY anon_select_apple_iap_transactions ON apple_iap_transactions "
"FOR SELECT TO anon USING (false)"
)
op.execute(
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO anon WITH CHECK (true)"
)
op.execute(
"CREATE POLICY anon_update_apple_iap_transactions ON apple_iap_transactions "
"FOR UPDATE TO anon USING (false)"
)
op.execute(
"CREATE POLICY anon_delete_apple_iap_transactions ON apple_iap_transactions "
"FOR DELETE TO anon USING (false)"
)
op.execute(
"CREATE POLICY authenticated_select_apple_iap_transactions ON apple_iap_transactions "
"FOR SELECT TO authenticated USING (false)"
)
op.execute(
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO authenticated WITH CHECK (true)"
)
op.execute(
"CREATE POLICY authenticated_update_apple_iap_transactions ON apple_iap_transactions "
"FOR UPDATE TO authenticated USING (false)"
)
op.execute(
"CREATE POLICY authenticated_delete_apple_iap_transactions ON apple_iap_transactions "
"FOR DELETE TO authenticated USING (false)"
)
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_change_type",
"points_ledger",
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
)
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_biz_type",
"points_ledger",
"biz_type is null or biz_type in ('chat', 'payment')",
)
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_biz_binding",
"points_ledger",
"((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or "
"(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or "
"(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))",
)
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_direction_by_change_type",
"points_ledger",
"((change_type in ('register', 'purchase') and direction = 1) or "
"(change_type in ('consume', 'refund') and direction = -1) or "
"(change_type = 'adjust' and direction in (1, -1)))",
)
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_metadata_adjust_shape",
"points_ledger",
"(change_type <> 'adjust' or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'reason') and "
"coalesce(metadata #>> '{ext,reason}', '') <> ''))",
)
op.create_check_constraint(
"ck_points_ledger_metadata_payment_shape",
"points_ledger",
"(change_type not in ('purchase', 'refund') or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and "
"(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and "
"coalesce(metadata #>> '{ext,source}', '') <> '' and "
"coalesce(metadata #>> '{ext,platform}', '') <> '' and "
"coalesce(metadata #>> '{ext,product_code}', '') <> '' and "
"coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))",
)
op.create_check_constraint(
"ck_points_ledger_metadata_refund_shape",
"points_ledger",
"(change_type <> 'refund' or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and "
"coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
)
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
op.create_check_constraint(
"ck_points_audit_ledger_change_type",
"points_audit_ledger",
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
)
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
op.create_check_constraint(
"ck_points_audit_ledger_biz_type",
"points_audit_ledger",
"biz_type is null or biz_type in ('chat', 'payment')",
)
def downgrade() -> None:
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
op.create_check_constraint(
"ck_points_audit_ledger_biz_type",
"points_audit_ledger",
"biz_type is null or biz_type = 'chat'",
)
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
op.create_check_constraint(
"ck_points_audit_ledger_change_type",
"points_audit_ledger",
"change_type in ('register', 'consume', 'grant', 'adjust')",
)
op.drop_constraint("ck_points_ledger_metadata_refund_shape", "points_ledger", type_="check")
op.drop_constraint("ck_points_ledger_metadata_payment_shape", "points_ledger", type_="check")
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_metadata_adjust_shape",
"points_ledger",
"(change_type <> 'adjust' or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
)
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_direction_by_change_type",
"points_ledger",
"((change_type in ('register', 'grant') and direction = 1) or "
"(change_type = 'consume' and direction = -1) or "
"(change_type = 'adjust' and direction in (1, -1)))",
)
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_biz_binding",
"points_ledger",
"((change_type = 'register' and biz_type is null and biz_id is null) or "
"(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))",
)
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_biz_type",
"points_ledger",
"biz_type is null or biz_type = 'chat'",
)
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
op.create_check_constraint(
"ck_points_ledger_change_type",
"points_ledger",
"change_type in ('register', 'consume', 'grant', 'adjust')",
)
op.drop_index("ix_apple_iap_transactions_status_updated_at", table_name="apple_iap_transactions")
op.drop_index("ix_apple_iap_transactions_user_created_at", table_name="apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS authenticated_delete_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS authenticated_update_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS authenticated_select_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS anon_delete_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS anon_update_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions")
op.execute("DROP POLICY IF EXISTS anon_select_apple_iap_transactions ON apple_iap_transactions")
op.execute("ALTER TABLE apple_iap_transactions DISABLE ROW LEVEL SECURITY")
op.drop_table("apple_iap_transactions")
@@ -1,60 +0,0 @@
"""fix apple_iap_transactions RLS policies for INSERT
Revision ID: 20260427_0002
Revises: 20260427_0001
Create Date: 2026-04-27 18:00:00
Changes:
1. Fix anon_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
2. Fix authenticated_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
Rationale:
Apple IAP transactions should only be created by backend service_role,
not by client anon/authenticated users. The original policies allowed
unrestricted INSERT which bypasses RLS security.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260427_0002"
down_revision: Union[str, Sequence[str], None] = "20260427_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
)
op.execute(
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO anon WITH CHECK (false)"
)
op.execute(
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
)
op.execute(
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO authenticated WITH CHECK (false)"
)
def downgrade() -> None:
op.execute(
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
)
op.execute(
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO authenticated WITH CHECK (true)"
)
op.execute(
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
)
op.execute(
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
"FOR INSERT TO anon WITH CHECK (true)"
)
@@ -1,51 +0,0 @@
"""Convert notification title/body from text to jsonb (i18n dict).
title and body become jsonb objects keyed by locale code:
{"zh": "欢迎来到觅爻", "zh_Hant": "...", "en": "..."}
Existing data is wrapped under the "zh" key (simplified Chinese default).
Revision ID: 20260428_0001
"""
from alembic import op
import sqlalchemy as sa
revision = "20260428_0001"
down_revision = "20260427_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE notifications
ALTER COLUMN title TYPE jsonb USING jsonb_build_object('zh', title),
ALTER COLUMN body TYPE jsonb USING jsonb_build_object('zh', body);
"""
)
op.execute(
"""
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS ck_notifications_payload_object;
"""
)
op.execute(
"""
ALTER TABLE notifications
ADD CONSTRAINT ck_notifications_payload_object
CHECK (jsonb_typeof(payload) = 'object');
"""
)
def downgrade() -> None:
op.execute(
"""
ALTER TABLE notifications
ALTER COLUMN title TYPE text USING COALESCE(title ->> 'zh', ''),
ALTER COLUMN body TYPE text USING COALESCE(body ->> 'zh', '');
"""
)
@@ -1,201 +0,0 @@
"""update profile settings schema in trigger
Revision ID: 20260428_0002
Revises: 20260428_0001
Create Date: 2026-04-28
"""
from alembic import op
revision = "20260428_0002"
down_revision = "20260428_0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_invite_code text;
v_referrer_id uuid;
v_attempts int := 0;
invite_code_value text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'US'
),
'privacy', jsonb_build_object(
'can_sell', false,
'profile_visibility', 'public'
),
'notification', jsonb_build_object(
'allow_notifications', true,
'allow_vibration', true
),
'divination_tutorial', jsonb_build_object(
'divination_entry_shown', false,
'auto_divination_shown', false,
'manual_divination_shown', false
)
)
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
v_invite_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
v_invite_code,
new.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
v_attempts := v_attempts + 1;
IF v_attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := new.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO v_referrer_id;
IF v_referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = v_referrer_id
WHERE id = new.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
"""
)
def downgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_username text;
v_invite_code text;
v_referrer_id uuid;
v_attempts int := 0;
invite_code_value text;
BEGIN
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
INSERT INTO public.profiles (id, username, avatar_url, bio, settings)
VALUES (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object(
'allow_notifications', true,
'allow_vibration', true
)
)
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
v_invite_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
v_invite_code,
new.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
v_attempts := v_attempts + 1;
IF v_attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := new.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO v_referrer_id;
IF v_referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = v_referrer_id
WHERE id = new.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
"""
)
@@ -1,52 +0,0 @@
"""migrate existing profile settings to new schema
Revision ID: 20260428_0003
Revises: 20260428_0002
Create Date: 2026-04-28
"""
from alembic import op
revision = "20260428_0003"
down_revision = "20260428_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE profiles
SET settings = jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'language', COALESCE(settings->'preferences'->>'language', 'zh-CN'),
'timezone', COALESCE(settings->'preferences'->>'timezone', 'Asia/Shanghai'),
'country', COALESCE(settings->'preferences'->>'country', 'US')
),
'privacy', jsonb_build_object(
'can_sell', COALESCE((settings->'privacy'->>'can_sell')::boolean, false),
'profile_visibility', COALESCE(settings->'privacy'->>'profile_visibility', 'public')
),
'notification', jsonb_build_object(
'allow_notifications', COALESCE((settings->'notification'->>'allow_notifications')::boolean, true),
'allow_vibration', COALESCE((settings->'notification'->>'allow_vibration')::boolean, true)
),
'divination_tutorial', jsonb_build_object(
'divination_entry_shown', COALESCE((settings->'divination_tutorial'->>'divination_entry_shown')::boolean, false),
'auto_divination_shown', COALESCE((settings->'divination_tutorial'->>'auto_divination_shown')::boolean, false),
'manual_divination_shown', COALESCE((settings->'divination_tutorial'->>'manual_divination_shown')::boolean, false)
)
)
WHERE settings IS NOT NULL;
"""
)
def downgrade() -> None:
raise RuntimeError(
"20260428_0003 is a destructive JSON data-shape migration and cannot be "
"downgraded automatically. Restore profile settings from backup if rollback "
"to the previous schema is required."
)
@@ -0,0 +1,81 @@
"""Create Apple IAP schema and mark the squashed chain head.
Revision ID: 20260428_0004
Revises: 20260428_squash_0004
Create Date: 2026-04-28 00:04:00
Squashed history: creates the Apple IAP table with the corrected RLS policy
from the start. The revision id intentionally remains the previous head so
databases already stamped at 20260428_0004 stay recognized as current.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260428_0004"
down_revision: Union[str, Sequence[str], None] = "20260428_squash_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"apple_iap_transactions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("product_code", sa.String(length=32), nullable=False),
sa.Column("app_store_product_id", sa.String(length=128), nullable=False),
sa.Column("transaction_id", sa.String(length=64), nullable=False),
sa.Column("original_transaction_id", sa.String(length=64), nullable=True),
sa.Column("web_order_line_item_id", sa.String(length=64), nullable=True),
sa.Column("environment", sa.String(length=16), nullable=False),
sa.Column("bundle_id", sa.String(length=128), nullable=False),
sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("purchase_date", sa.Text(), nullable=False),
sa.Column("revocation_date", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("credits", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=True),
sa.Column("price_milliunits", sa.BigInteger(), nullable=True),
sa.Column("ledger_event_id", sa.String(length=64), nullable=True),
sa.Column("signed_transaction_info", sa.Text(), nullable=False),
sa.Column("apple_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("failure_code", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("environment in ('Sandbox', 'Production')", name="ck_apple_iap_transactions_environment"),
sa.CheckConstraint("status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", name="ck_apple_iap_transactions_status"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"),
sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"),
)
op.create_index("ix_apple_iap_transactions_user_created_at", "apple_iap_transactions", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_apple_iap_transactions_status_updated_at", "apple_iap_transactions", ["status", sa.text("updated_at DESC")])
_enable_service_only_rls("apple_iap_transactions")
def downgrade() -> None:
_drop_service_only_rls("apple_iap_transactions")
op.drop_table("apple_iap_transactions")
def _enable_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
for role in ["anon", "authenticated"]:
op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)")
op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)")
op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)")
def _drop_service_only_rls(table_name: str) -> None:
for role in ["anon", "authenticated"]:
for action in ["select", "insert", "update", "delete"]:
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")