From adb2b3bcc31852a1e880d4255fadfbbdb7231025 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Wed, 29 Apr 2026 00:37:45 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=95=B4=E5=90=88=20migration=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=B9=B6=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整合 18 个分散的 migration 文件为 5 个模块化文件 - settings.py 支持 .env.local 覆盖 .env - 移除 user schema 中未使用的 country 字段正则 - 更新 profile protocol 文档移除 country 字段 - pyproject.toml 添加 ruff 到 dev 依赖 - 简化 integration test conftest 邮箱 fixture --- .../versions/20260411_0001_core_llm_schema.py | 98 +++ .../20260411_0001_initial_llm_schema.py | 162 ----- ...20260411_0002_chat_points_invite_schema.py | 579 ------------------ ...20260411_0002_users_chat_points_invites.py | 392 ++++++++++++ .../versions/20260411_0003_notifications.py | 101 +++ ..._points_audit_and_register_bonus_claims.py | 190 ------ .../20260411_0004_add_notifications_tables.py | 170 ----- ...005_add_notification_static_sync_fields.py | 55 -- ...413_0004_register_bonus_claims_snapshot.py | 50 -- ...260415_0001_anonymous_session_snapshots.py | 111 ---- .../20260415_0001_compliance_and_feedback.py | 97 +++ ...415_0002_drop_points_ledger_biz_id_fkey.py | 40 -- ...20260416_0001_add_starter_pack_tracking.py | 32 - ...0260416_0002_drop_duplicate_llm_indexes.py | 25 - ...60416_0003_add_notification_target_mode.py | 37 -- .../20260417_0001_create_user_feedback.py | 118 ---- .../20260427_0001_apple_iap_transactions.py | 278 --------- .../20260427_0002_fix_apple_iap_rls_insert.py | 60 -- ...60428_0001_notification_title_body_i18n.py | 51 -- ...428_0002_update_profile_settings_schema.py | 201 ------ ...0428_0003_migrate_profile_settings_data.py | 52 -- .../20260428_0004_apple_iap_final_head.py | 81 +++ backend/src/core/config/settings.py | 17 +- backend/src/schemas/shared/user.py | 1 - backend/tests/integration/conftest.py | 14 +- docs/protocols/profile/profile-protocol.md | 6 +- pyproject.toml | 1 + 27 files changed, 787 insertions(+), 2232 deletions(-) create mode 100644 backend/alembic/versions/20260411_0001_core_llm_schema.py delete mode 100644 backend/alembic/versions/20260411_0001_initial_llm_schema.py delete mode 100644 backend/alembic/versions/20260411_0002_chat_points_invite_schema.py create mode 100644 backend/alembic/versions/20260411_0002_users_chat_points_invites.py create mode 100644 backend/alembic/versions/20260411_0003_notifications.py delete mode 100644 backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py delete mode 100644 backend/alembic/versions/20260411_0004_add_notifications_tables.py delete mode 100644 backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py delete mode 100644 backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py delete mode 100644 backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py create mode 100644 backend/alembic/versions/20260415_0001_compliance_and_feedback.py delete mode 100644 backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py delete mode 100644 backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py delete mode 100644 backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py delete mode 100644 backend/alembic/versions/20260416_0003_add_notification_target_mode.py delete mode 100644 backend/alembic/versions/20260417_0001_create_user_feedback.py delete mode 100644 backend/alembic/versions/20260427_0001_apple_iap_transactions.py delete mode 100644 backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py delete mode 100644 backend/alembic/versions/20260428_0001_notification_title_body_i18n.py delete mode 100644 backend/alembic/versions/20260428_0002_update_profile_settings_schema.py delete mode 100644 backend/alembic/versions/20260428_0003_migrate_profile_settings_data.py create mode 100644 backend/alembic/versions/20260428_0004_apple_iap_final_head.py diff --git a/backend/alembic/versions/20260411_0001_core_llm_schema.py b/backend/alembic/versions/20260411_0001_core_llm_schema.py new file mode 100644 index 0000000..67708a0 --- /dev/null +++ b/backend/alembic/versions/20260411_0001_core_llm_schema.py @@ -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") diff --git a/backend/alembic/versions/20260411_0001_initial_llm_schema.py b/backend/alembic/versions/20260411_0001_initial_llm_schema.py deleted file mode 100644 index e792c31..0000000 --- a/backend/alembic/versions/20260411_0001_initial_llm_schema.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py b/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py deleted file mode 100644 index daf3901..0000000 --- a/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260411_0002_users_chat_points_invites.py b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py new file mode 100644 index 0000000..4481707 --- /dev/null +++ b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py @@ -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") diff --git a/backend/alembic/versions/20260411_0003_notifications.py b/backend/alembic/versions/20260411_0003_notifications.py new file mode 100644 index 0000000..6e441a7 --- /dev/null +++ b/backend/alembic/versions/20260411_0003_notifications.py @@ -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") diff --git a/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py b/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py deleted file mode 100644 index 3481953..0000000 --- a/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260411_0004_add_notifications_tables.py b/backend/alembic/versions/20260411_0004_add_notifications_tables.py deleted file mode 100644 index 1bab5d4..0000000 --- a/backend/alembic/versions/20260411_0004_add_notifications_tables.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py b/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py deleted file mode 100644 index 60bef9b..0000000 --- a/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py b/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py deleted file mode 100644 index 14979d9..0000000 --- a/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py +++ /dev/null @@ -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", - ) diff --git a/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py b/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py deleted file mode 100644 index 54b5bef..0000000 --- a/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py +++ /dev/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") diff --git a/backend/alembic/versions/20260415_0001_compliance_and_feedback.py b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py new file mode 100644 index 0000000..382117b --- /dev/null +++ b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py @@ -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") diff --git a/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py b/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py deleted file mode 100644 index 7eaf3eb..0000000 --- a/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py +++ /dev/null @@ -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", - ) diff --git a/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py b/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py deleted file mode 100644 index 81ea199..0000000 --- a/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py +++ /dev/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") diff --git a/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py b/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py deleted file mode 100644 index 1e36ddc..0000000 --- a/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py +++ /dev/null @@ -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) diff --git a/backend/alembic/versions/20260416_0003_add_notification_target_mode.py b/backend/alembic/versions/20260416_0003_add_notification_target_mode.py deleted file mode 100644 index 6bf1711..0000000 --- a/backend/alembic/versions/20260416_0003_add_notification_target_mode.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260417_0001_create_user_feedback.py b/backend/alembic/versions/20260417_0001_create_user_feedback.py deleted file mode 100644 index 2b3fa7e..0000000 --- a/backend/alembic/versions/20260417_0001_create_user_feedback.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260427_0001_apple_iap_transactions.py b/backend/alembic/versions/20260427_0001_apple_iap_transactions.py deleted file mode 100644 index 86643ae..0000000 --- a/backend/alembic/versions/20260427_0001_apple_iap_transactions.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py b/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py deleted file mode 100644 index 131c949..0000000 --- a/backend/alembic/versions/20260427_0002_fix_apple_iap_rls_insert.py +++ /dev/null @@ -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)" - ) diff --git a/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py b/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py deleted file mode 100644 index 427e2e5..0000000 --- a/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py +++ /dev/null @@ -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', ''); - """ - ) diff --git a/backend/alembic/versions/20260428_0002_update_profile_settings_schema.py b/backend/alembic/versions/20260428_0002_update_profile_settings_schema.py deleted file mode 100644 index 5747c34..0000000 --- a/backend/alembic/versions/20260428_0002_update_profile_settings_schema.py +++ /dev/null @@ -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; - $$; - """ - ) diff --git a/backend/alembic/versions/20260428_0003_migrate_profile_settings_data.py b/backend/alembic/versions/20260428_0003_migrate_profile_settings_data.py deleted file mode 100644 index a55f0c4..0000000 --- a/backend/alembic/versions/20260428_0003_migrate_profile_settings_data.py +++ /dev/null @@ -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." - ) diff --git a/backend/alembic/versions/20260428_0004_apple_iap_final_head.py b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py new file mode 100644 index 0000000..1182ada --- /dev/null +++ b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py @@ -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") diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index edb9458..7c945c5 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -240,13 +240,18 @@ class AppleIapSettings(BaseModel): server_notifications_url: str | None = None -def _resolve_env_file() -> str: +def _resolve_env_files() -> list[str]: + """Resolve env files in order: .env.local overrides .env""" current = Path(__file__).resolve() for parent in [current, *current.parents]: - candidate = parent / ".env" - if candidate.is_file(): - return str(candidate) - return ".env" + env_file = parent / ".env" + if env_file.is_file(): + files = [str(env_file)] + env_local = parent / ".env.local" + if env_local.is_file(): + files.append(str(env_local)) + return files + return [".env"] PROJECT_ROOT = _resolve_project_root() @@ -305,7 +310,7 @@ class Settings(BaseSettings): return self.taskiq.result_backend_url or self.redis.url model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( - env_file=_resolve_env_file(), + env_file=_resolve_env_files(), env_prefix="ERYAO_", env_nested_delimiter="__", case_sensitive=False, diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index e764e35..bf7e5e0 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -7,7 +7,6 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel, ConfigDict, Field, field_validator _BCP47_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") -_COUNTRY_PATTERN = re.compile(r"^[A-Z]{2}$") class PreferenceSettings(BaseModel): diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index c3c6892..1b8dc93 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -4,7 +4,6 @@ from collections.abc import AsyncIterator import hashlib import hmac import os -import time import httpx import pytest @@ -37,18 +36,13 @@ def test_verify_code() -> str: @pytest.fixture -def unique_test_email() -> str: - base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower() - if "@" in base_email: - name, domain = base_email.split("@", 1) - else: - name, domain = base_email, "example.com" - return f"{name}+it{int(time.time() * 1000)}@{domain}" +def test_email() -> str: + return os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower() @pytest.fixture -def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]: - return {"email": unique_test_email, "code": test_verify_code} +def test_identity(test_email: str, test_verify_code: str) -> dict[str, str]: + return {"email": test_email, "code": test_verify_code} @pytest.fixture diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index c4c3932..80efea6 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -47,8 +47,7 @@ Response: "version": 1, "preferences": { "language": "zh-CN", - "timezone": "Asia/Shanghai", - "country": "CN" + "timezone": "Asia/Shanghai" }, "privacy": { "can_sell": false, @@ -111,8 +110,7 @@ Request: "version": 1, "preferences": { "language": "zh-CN", - "timezone": "Asia/Shanghai", - "country": "CN" + "timezone": "Asia/Shanghai" }, "privacy": { "can_sell": false, diff --git a/pyproject.toml b/pyproject.toml index fb099ce..26a4a06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ markers = ["integration: integration test requiring live backend and workers"] dev = [ "basedpyright==1.38.2", "pre-commit==4.5.1", + "ruff>=0.15.12", ] [tool.basedpyright]