diff --git a/.env.example b/.env.example index 8144ca4..b2a158f 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,12 @@ ERYAO_STORAGE__RETENTION_DAYS=30 ERYAO_LLM__PROVIDER_KEYS__DASHSCOPE= ERYAO_LLM__PROVIDER_KEYS__DEEPSEEK= +############ +# Points 策略配置 +############ +ERYAO_POINTS_POLICY__REGISTER_BONUS_POINTS=60 +ERYAO_POINTS_POLICY__REGISTER_BONUS_HMAC_KEY=replace-with-strong-random-key + ############ # 敏感词配置 ############ diff --git a/backend/AGENTS.md b/backend/AGENTS.md index eb0b94d..7b0ada7 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -81,3 +81,5 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli - Follow TDD for feature/bugfix work when practical. - Prioritize regression tests for changed logic/contracts. - Real DB tests must use `settings.test.*`; never hardcode test credentials. +- Integration tests that call live HTTP APIs must start backend via `./infra/scripts/app.sh` first. +- For integration suites under `backend/tests/integration`, use `./infra/scripts/app.sh restart` as precondition before `uv run pytest`. diff --git a/backend/alembic/versions/20260403_0003_points_register_init_trigger.py b/backend/alembic/versions/20260403_0003_points_register_init_trigger.py deleted file mode 100644 index f28dff3..0000000 --- a/backend/alembic/versions/20260403_0003_points_register_init_trigger.py +++ /dev/null @@ -1,163 +0,0 @@ -"""allow register ledger without chat and add user init trigger - -Revision ID: 202604030003 -Revises: 202604030002 -Create Date: 2026-04-03 23:20:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "202604030003" -down_revision: Union[str, Sequence[str], None] = "202604030002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.alter_column( - "points_ledger", "biz_type", existing_type=sa.String(length=16), nullable=True - ) - op.alter_column("points_ledger", "biz_id", existing_type=sa.UUID(), nullable=True) - - 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.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.execute( - """ - create or replace function public.initialize_profile_and_points_on_signup() - returns trigger - language plpgsql - security definer - set search_path = public - as $$ - declare - v_username text; - v_ledger_id uuid; - v_event_id text; - begin - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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('push_enabled', true) - ) - ) - on conflict (id) do nothing; - - insert into public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - values (new.id, 100, 0, 100, 0, 0) - on conflict (user_id) do nothing; - - insert into public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - values ( - v_ledger_id, - new.id, - 1, - 100, - 100, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - on conflict (user_id, event_id) do nothing; - - return new; - end; - $$; - """ - ) - - op.execute( - "drop trigger if exists trg_initialize_profile_and_points_on_signup on auth.users" - ) - op.execute( - """ - create trigger trg_initialize_profile_and_points_on_signup - after insert on auth.users - for each row - execute function public.initialize_profile_and_points_on_signup(); - """ - ) - - -def downgrade() -> 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.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check") - 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 = 'chat'", - ) - - op.execute( - "delete from points_ledger where change_type = 'register' and biz_id is null" - ) - - op.alter_column( - "points_ledger", "biz_type", existing_type=sa.String(length=16), nullable=False - ) - op.alter_column("points_ledger", "biz_id", existing_type=sa.UUID(), nullable=False) diff --git a/backend/alembic/versions/20260403_0004_remove_points_reason_code.py b/backend/alembic/versions/20260403_0004_remove_points_reason_code.py deleted file mode 100644 index 384dc86..0000000 --- a/backend/alembic/versions/20260403_0004_remove_points_reason_code.py +++ /dev/null @@ -1,316 +0,0 @@ -"""remove redundant reason_code from points ledger metadata - -Revision ID: 202604030004 -Revises: 202604030003 -Create Date: 2026-04-03 23:35:00 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "202604030004" -down_revision: Union[str, Sequence[str], None] = "202604030003" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.drop_constraint( - "ck_points_ledger_metadata_common", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_register_shape", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_consume_shape", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_grant_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_common", - "points_ledger", - "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')", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_register_shape", - "points_ledger", - "(change_type <> 'register' or not (metadata ? 'charge'))", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_consume_shape", - "points_ledger", - "(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')))", - ) - 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.execute( - "update points_ledger set metadata = metadata - 'reason_code' where metadata ? 'reason_code'" - ) - - op.execute( - """ - create or replace function public.initialize_profile_and_points_on_signup() - returns trigger - language plpgsql - security definer - set search_path = public - as $$ - declare - v_username text; - v_ledger_id uuid; - v_event_id text; - begin - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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('push_enabled', true) - ) - ) - on conflict (id) do nothing; - - insert into public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - values (new.id, 60, 0, 60, 0, 0) - on conflict (user_id) do nothing; - - insert into public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - values ( - v_ledger_id, - new.id, - 1, - 60, - 60, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - on conflict (user_id, event_id) do nothing; - - return new; - end; - $$; - """ - ) - - -def downgrade() -> None: - op.drop_constraint( - "ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_consume_shape", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_register_shape", "points_ledger", type_="check" - ) - op.drop_constraint( - "ck_points_ledger_metadata_common", "points_ledger", type_="check" - ) - - op.execute( - """ - update points_ledger - set metadata = jsonb_set( - metadata, - '{reason_code}', - to_jsonb( - case change_type - when 'register' then 'REGISTER_WELCOME' - when 'consume' then 'CHAT_CONSUME' - when 'grant' then 'CHAT_GRANT' - when 'adjust' then 'CHAT_ADJUST' - else 'CHAT_ADJUST' - end - ), - true - ) - where not (metadata ? 'reason_code'); - """ - ) - - op.create_check_constraint( - "ck_points_ledger_metadata_common", - "points_ledger", - "metadata->>'schema_version' = '1' and " - "metadata->>'reason_code' in ('REGISTER_WELCOME', 'CHAT_CONSUME', 'CHAT_GRANT', 'CHAT_ADJUST') and " - "metadata->>'operator_type' in ('user', 'system', 'admin') and " - "coalesce(metadata->>'run_id', '') <> '' and " - "(not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_register_shape", - "points_ledger", - "(change_type <> 'register' or (metadata->>'reason_code' = 'REGISTER_WELCOME' and not (metadata ? 'charge')))", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_consume_shape", - "points_ledger", - "(change_type <> 'consume' or (metadata->>'reason_code' = 'CHAT_CONSUME' and " - "(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')))", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_grant_shape", - "points_ledger", - "(change_type <> 'grant' or metadata->>'reason_code' = 'CHAT_GRANT')", - ) - op.create_check_constraint( - "ck_points_ledger_metadata_adjust_shape", - "points_ledger", - "(change_type <> 'adjust' or (metadata->>'reason_code' = 'CHAT_ADJUST' and " - "(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " - "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", - ) - - op.execute( - """ - create or replace function public.initialize_profile_and_points_on_signup() - returns trigger - language plpgsql - security definer - set search_path = public - as $$ - declare - v_username text; - v_ledger_id uuid; - v_event_id text; - begin - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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('push_enabled', true) - ) - ) - on conflict (id) do nothing; - - insert into public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - values (new.id, 60, 0, 60, 0, 0) - on conflict (user_id) do nothing; - - insert into public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - values ( - v_ledger_id, - new.id, - 1, - 60, - 60, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - on conflict (user_id, event_id) do nothing; - - return new; - end; - $$; - """ - ) diff --git a/backend/alembic/versions/20260407_0001_update_notification_settings.py b/backend/alembic/versions/20260407_0001_update_notification_settings.py deleted file mode 100644 index 4d6e69f..0000000 --- a/backend/alembic/versions/20260407_0001_update_notification_settings.py +++ /dev/null @@ -1,201 +0,0 @@ -"""update notification settings fields - -Revision ID: 20260407_0001 -Revises: 20260403_0004 -Create Date: 2026-04-07 00:00:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "20260407_0001" -down_revision: Union[str, Sequence[str], None] = "202604030004" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute( - """ - create or replace function public.initialize_profile_and_points_on_signup() - returns trigger - language plpgsql - security definer - set search_path = public - as $$ - declare - v_username text; - v_ledger_id uuid; - v_event_id text; - begin - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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; - - insert into public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - values (new.id, 100, 0, 100, 0, 0) - on conflict (user_id) do nothing; - - insert into public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - values ( - v_ledger_id, - new.id, - 1, - 100, - 100, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - on conflict (user_id, event_id) do nothing; - - return new; - end; - $$; - """ - ) - - -def downgrade() -> None: - op.execute( - """ - create or replace function public.initialize_profile_and_points_on_signup() - returns trigger - language plpgsql - security definer - set search_path = public - as $$ - declare - v_username text; - v_ledger_id uuid; - v_event_id text; - begin - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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('push_enabled', true) - ) - ) - on conflict (id) do nothing; - - insert into public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - values (new.id, 100, 0, 100, 0, 0) - on conflict (user_id) do nothing; - - insert into public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - values ( - v_ledger_id, - new.id, - 1, - 100, - 100, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - on conflict (user_id, event_id) do nothing; - - return new; - end; - $$; - """ - ) diff --git a/backend/alembic/versions/20260407_0002_add_invite_codes.py b/backend/alembic/versions/20260407_0002_add_invite_codes.py deleted file mode 100644 index 05c085d..0000000 --- a/backend/alembic/versions/20260407_0002_add_invite_codes.py +++ /dev/null @@ -1,329 +0,0 @@ -"""add invite_codes table and profiles referred_by - -Revision ID: 20260407_0002 -Revises: 20260407_0001 -Create Date: 2026-04-07 00:00:00 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "20260407_0002" -down_revision: Union[str, Sequence[str], None] = "20260407_0001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> 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'" - ) - - op.execute("ALTER TABLE invite_codes ENABLE ROW LEVEL SECURITY") - op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes") - op.execute( - "CREATE POLICY invite_codes_all_denied ON invite_codes FOR ALL USING (false)" - ) - - op.execute( - """ - ALTER TABLE profiles ADD COLUMN referred_by UUID REFERENCES profiles(id) ON DELETE SET NULL - """ - ) - op.execute("CREATE INDEX ix_profiles_referred_by ON profiles(referred_by)") - - 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_ledger_id uuid; - v_event_id 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); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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; - - INSERT INTO public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - VALUES (new.id, 60, 0, 60, 0, 0) - ON CONFLICT (user_id) DO NOTHING; - - INSERT INTO public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - VALUES ( - v_ledger_id, - new.id, - 1, - 60, - 60, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - ON CONFLICT (user_id, event_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 downgrade() -> None: - op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - - op.execute( - """ - CREATE OR REPLACE FUNCTION public.initialize_profile_and_points_on_signup() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - DECLARE - v_username text; - v_ledger_id uuid; - v_event_id text; - BEGIN - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; - v_event_id := 'register:' || new.id::text; - - 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('push_enabled', true) - ) - ) - ON CONFLICT (id) DO NOTHING; - - INSERT INTO public.user_points ( - user_id, - balance, - frozen_balance, - lifetime_earned, - lifetime_spent, - version - ) - VALUES (new.id, 60, 0, 60, 0, 0) - ON CONFLICT (user_id) DO NOTHING; - - INSERT INTO public.points_ledger ( - id, - user_id, - direction, - amount, - balance_after, - change_type, - biz_type, - biz_id, - event_id, - operator_id, - metadata - ) - VALUES ( - v_ledger_id, - new.id, - 1, - 60, - 60, - 'register', - null, - null, - v_event_id, - null, - jsonb_build_object( - 'schema_version', 1, - 'reason_code', 'REGISTER_WELCOME', - 'operator_type', 'system', - 'run_id', v_event_id, - 'request_id', null, - 'ext', jsonb_build_object('source', 'auth_signup') - ) - ) - ON CONFLICT (user_id, event_id) DO NOTHING; - - RETURN NEW; - END; - $$; - """ - ) - - op.execute( - "CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_points_on_signup()" - ) - - op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()") - - op.execute("DROP INDEX IF EXISTS ix_profiles_referred_by") - op.execute("ALTER TABLE profiles DROP COLUMN IF EXISTS referred_by") - - op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes") - op.execute("ALTER TABLE invite_codes DISABLE ROW LEVEL SECURITY") - 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") diff --git a/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py b/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py deleted file mode 100644 index 3323f5d..0000000 --- a/backend/alembic/versions/20260407_0003_drop_duplicate_indexes.py +++ /dev/null @@ -1,25 +0,0 @@ -"""drop duplicate indexes on llm_factory and llms - -Revision ID: 20260407_0003 -Revises: 20260407_0002 -Create Date: 2026-04-07 00:00:00 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "20260407_0003" -down_revision: Union[str, Sequence[str], None] = "20260407_0002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute("DROP INDEX IF EXISTS ix_llm_factory_name") - op.execute("DROP INDEX IF EXISTS ix_llms_model_code") - - -def downgrade() -> None: - op.execute("CREATE INDEX ix_llm_factory_name ON llm_factory(name)") - op.execute("CREATE INDEX ix_llms_model_code ON llms(model_code)") diff --git a/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py b/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py deleted file mode 100644 index a632a44..0000000 --- a/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py +++ /dev/null @@ -1,54 +0,0 @@ -"""update signup welcome points from 100 to 60 - -Revision ID: 20260409_0004 -Revises: 20260407_0003 -Create Date: 2026-04-09 00:00:00 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "20260409_0004" -down_revision: Union[str, Sequence[str], None] = "20260407_0003" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def _rewrite_signup_function(*, from_points: int, to_points: int) -> None: - op.execute( - f""" - DO $$ - DECLARE - v_def text; - v_from_points text := '{from_points}'; - v_to_points text := '{to_points}'; - BEGIN - SELECT pg_get_functiondef('public.initialize_profile_and_invite_code_on_signup()'::regprocedure) - INTO v_def; - - v_def := regexp_replace( - v_def, - 'VALUES \\(new\\.id,\\s*' || v_from_points || ',\\s*0,\\s*' || v_from_points || ',\\s*0,\\s*0\\)', - 'VALUES (new.id, ' || v_to_points || ', 0, ' || v_to_points || ', 0, 0)' - ); - - v_def := regexp_replace( - v_def, - E'\\n\\s*' || v_from_points || ',\\n\\s*' || v_from_points || ',\\n\\s*''register''', - E'\\n ' || v_to_points || ',\\n ' || v_to_points || ',\\n ''register''' - ); - - EXECUTE v_def; - END; - $$; - """ - ) - - -def upgrade() -> None: - _rewrite_signup_function(from_points=100, to_points=60) - - -def downgrade() -> None: - _rewrite_signup_function(from_points=60, to_points=100) diff --git a/backend/alembic/versions/20260402_0001_initial_llm_schema.py b/backend/alembic/versions/20260411_0001_initial_llm_schema.py similarity index 92% rename from backend/alembic/versions/20260402_0001_initial_llm_schema.py rename to backend/alembic/versions/20260411_0001_initial_llm_schema.py index 8bcc602..e792c31 100644 --- a/backend/alembic/versions/20260402_0001_initial_llm_schema.py +++ b/backend/alembic/versions/20260411_0001_initial_llm_schema.py @@ -1,8 +1,8 @@ """initial llm/factory/system_agents schema -Revision ID: 202604020001 +Revision ID: 20260411_0001 Revises: -Create Date: 2026-04-02 18:25:00 +Create Date: 2026-04-11 00:01:00 """ from typing import Sequence, Union @@ -11,7 +11,7 @@ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -revision: str = "202604020001" +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 @@ -155,8 +155,8 @@ def _enable_rls(table_name: str) -> None: def _drop_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: - op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + 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/20260403_0002_user_points_chat_schema.py b/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py similarity index 64% rename from backend/alembic/versions/20260403_0002_user_points_chat_schema.py rename to backend/alembic/versions/20260411_0002_chat_points_invite_schema.py index d2f51c8..daf3901 100644 --- a/backend/alembic/versions/20260403_0002_user_points_chat_schema.py +++ b/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py @@ -1,8 +1,8 @@ -"""add profiles points ledger sessions messages schema +"""add chat, points, and invite schema -Revision ID: 202604030002 -Revises: 202604020001 -Create Date: 2026-04-03 22:40:00 +Revision ID: 20260411_0002 +Revises: 20260411_0001 +Create Date: 2026-04-11 00:10:00 """ from typing import Sequence, Union @@ -11,8 +11,8 @@ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -revision: str = "202604030002" -down_revision: Union[str, Sequence[str], None] = "202604020001" +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 @@ -30,6 +30,7 @@ def upgrade() -> None: nullable=False, server_default=sa.text("'{}'::jsonb"), ), + sa.Column("referred_by", sa.UUID(), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), @@ -47,6 +48,7 @@ def upgrade() -> None: "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) @@ -57,6 +59,9 @@ def upgrade() -> None: unique=False, postgresql_using="gin", ) + op.create_index( + "ix_profiles_referred_by", "profiles", ["referred_by"], unique=False + ) _enable_rls("profiles") op.create_table( @@ -108,8 +113,7 @@ def upgrade() -> None: ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint( - "session_type in ('chat', 'automation')", - name="ck_sessions_session_type", + "session_type in ('chat', 'automation')", name="ck_sessions_session_type" ), sa.CheckConstraint( "status in ('pending', 'running', 'completed', 'failed')", @@ -177,8 +181,7 @@ def upgrade() -> None: 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", + "role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role" ), sa.CheckConstraint( "input_tokens >= 0", name="ck_messages_input_tokens_non_negative" @@ -267,8 +270,8 @@ def upgrade() -> None: 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=False), - sa.Column("biz_id", sa.UUID(), 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( @@ -300,47 +303,43 @@ def upgrade() -> None: "change_type in ('register', 'consume', 'grant', 'adjust')", name="ck_points_ledger_change_type", ), - sa.CheckConstraint("biz_type = 'chat'", name="ck_points_ledger_biz_type"), 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)))", + "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", + "jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object" ), sa.CheckConstraint( "metadata->>'schema_version' = '1' and " - "metadata->>'reason_code' in ('REGISTER_WELCOME', 'CHAT_CONSUME', 'CHAT_GRANT', 'CHAT_ADJUST') 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 " - "(metadata->>'reason_code' = 'REGISTER_WELCOME' and not (metadata ? 'charge')))", + "(change_type <> 'register' or not (metadata ? 'charge'))", name="ck_points_ledger_metadata_register_shape", ), sa.CheckConstraint( - "(change_type <> 'consume' or " - "(metadata->>'reason_code' = 'CHAT_CONSUME' and " - "(metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and " + "(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 <> 'grant' or metadata->>'reason_code' = 'CHAT_GRANT')", - name="ck_points_ledger_metadata_grant_shape", - ), - sa.CheckConstraint( - "(change_type <> 'adjust' or " - "(metadata->>'reason_code' = 'CHAT_ADJUST' and " - "(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " + "(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape", ), @@ -366,8 +365,164 @@ def upgrade() -> None: ) _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") @@ -387,6 +542,7 @@ def downgrade() -> None: 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") @@ -416,8 +572,8 @@ def _enable_rls(table_name: str) -> None: def _drop_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: - op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + 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 new file mode 100644 index 0000000..3481953 --- /dev/null +++ b/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py @@ -0,0 +1,190 @@ +"""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/src/core/agentscope/events/persistence.py b/backend/src/core/agentscope/events/persistence.py index 35c0eaa..4008338 100644 --- a/backend/src/core/agentscope/events/persistence.py +++ b/backend/src/core/agentscope/events/persistence.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.agent_chat_message import AgentChatMessage from models.agent_chat_session import AgentChatSession +from schemas.domain.chat_session import SessionStateSnapshot from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus @@ -80,13 +81,13 @@ class SessionRepository: *, chat_session: AgentChatSession, status: AgentChatSessionStatus, - state_snapshot: dict[str, object], + state_snapshot: SessionStateSnapshot, message_delta: int, token_delta: int = 0, cost_delta: Decimal = Decimal("0"), ) -> None: chat_session.status = status - chat_session.state_snapshot = state_snapshot + chat_session.state_snapshot = state_snapshot.model_dump(mode="json") chat_session.last_activity_at = datetime.now(timezone.utc) chat_session.message_count += message_delta chat_session.total_tokens += token_delta diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 321c894..80df05c 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -16,6 +16,7 @@ from schemas.agent.system_agent import AgentType from schemas.agent.runtime_models import AgentOutput, FollowUpOutput, ToolAgentOutput from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.domain.chat_message import AgentChatMessageMetadata +from schemas.domain.chat_session import SessionStateSnapshot class EventStore(Protocol): @@ -382,11 +383,12 @@ class SqlAlchemyEventStore: token_delta: int = 0, cost_delta: Decimal = Decimal("0"), ) -> None: - snapshot = ( + snapshot_raw = ( chat_session.state_snapshot if isinstance(chat_session.state_snapshot, dict) else {} ) + snapshot = SessionStateSnapshot.model_validate(snapshot_raw) await session_repo.update_runtime_state( chat_session=chat_session, status=status, diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index 2ca24ae..ad122f6 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import json from typing import Any, cast @@ -53,7 +54,7 @@ _RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput) def _serialize_tool_agent_output( *, - metadata: AgentChatMessageMetadata | dict[str, object] | None, + metadata: AgentChatMessageMetadata | None, ) -> str | None: if metadata is None: return None @@ -80,30 +81,13 @@ def _serialize_tool_agent_output( def _serialize_assistant_context_from_metadata( *, - metadata: AgentChatMessageMetadata | dict[str, object] | None, + metadata: AgentChatMessageMetadata | None, fallback_content: str, ) -> str: if metadata is None: return fallback_content - try: - resolved_metadata = ( - metadata - if isinstance(metadata, AgentChatMessageMetadata) - else AgentChatMessageMetadata.model_validate(metadata) - ) - except Exception: - return fallback_content - - agent_output = resolved_metadata.agent_output - if agent_output is None and isinstance(metadata, dict): - raw = metadata.get("agent_output") - if raw is not None: - try: - agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(raw) - except Exception: - return fallback_content - + agent_output = metadata.agent_output if agent_output is None: return fallback_content @@ -226,11 +210,11 @@ async def _build_recent_context_messages( content_raw = msg.get("content", "") content: str = content_raw if isinstance(content_raw, str) else "" metadata_raw = msg.get("metadata") - metadata: AgentChatMessageMetadata | dict[str, object] | None + metadata: AgentChatMessageMetadata | None if isinstance(metadata_raw, AgentChatMessageMetadata): metadata = metadata_raw elif isinstance(metadata_raw, dict): - metadata = metadata_raw + metadata = AgentChatMessageMetadata.model_validate(metadata_raw) else: metadata = None @@ -391,6 +375,7 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: context_config=runtime_config.context, ) + points_service = PointsService(repository=PointsRepository(session)) try: await runtime.run( run_input=run_input, @@ -399,15 +384,36 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: runtime_config=runtime_config, cancel_checker=_cancel_checker, ) - - points_service = PointsService(repository=PointsRepository(session)) await points_service.consume_successful_run_points( user_id=owner_id, session_id=UUID(thread_id), run_id=run_id, operator_id=owner_id, + user_email=owner_email, ) await session.commit() + except asyncio.CancelledError: + await points_service.record_failed_run_platform_cost( + user_id=owner_id, + session_id=UUID(thread_id), + run_id=run_id, + operator_id=owner_id, + user_email=owner_email, + failure_kind="canceled", + ) + await session.commit() + raise + except Exception: + await points_service.record_failed_run_platform_cost( + user_id=owner_id, + session_id=UUID(thread_id), + run_id=run_id, + operator_id=owner_id, + user_email=owner_email, + failure_kind="failed", + ) + await session.commit() + raise finally: delete_fn = getattr(redis_client, "delete", None) if callable(delete_fn): diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 62216db..1077eb4 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -207,6 +207,22 @@ class AgentRuntimeSettings(BaseModel): attachment_content_cache_max_base64_bytes: int = 262144 +class PointsPolicySettings(BaseModel): + register_bonus_points: int = Field(default=60, ge=0, le=1_000_000) + register_bonus_hmac_key: SecretStr = SecretStr("") + + @model_validator(mode="after") + def validate_hmac_key(self) -> "PointsPolicySettings": + key = self.register_bonus_hmac_key.get_secret_value().strip() + if not key: + raise ValueError("points_policy.register_bonus_hmac_key must not be empty") + if key.upper() == "CHANGE_ME": + raise ValueError( + "points_policy.register_bonus_hmac_key must not be CHANGE_ME" + ) + return self + + def _resolve_env_file() -> str: current = Path(__file__).resolve() for parent in [current, *current.parents]: @@ -233,6 +249,7 @@ class Settings(BaseSettings): test: TestSettings = Field(default_factory=TestSettings) taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) + points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) @computed_field @property diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index bf9ab4b..534bbb4 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -6,8 +6,10 @@ from .auth_user import AuthUser from .invite_code import InviteCode from .llm import Llm from .llm_factory import LlmFactory +from .points_audit_ledger import PointsAuditLedger from .points_ledger import PointsLedger from .profile import Profile +from .register_bonus_claims import RegisterBonusClaims from .system_agents import SystemAgents from .user_points import UserPoints @@ -18,8 +20,10 @@ __all__ = [ "InviteCode", "Llm", "LlmFactory", + "PointsAuditLedger", "PointsLedger", "Profile", + "RegisterBonusClaims", "SystemAgents", "UserPoints", ] diff --git a/backend/src/models/points_audit_ledger.py b/backend/src/models/points_audit_ledger.py new file mode 100644 index 0000000..c93e9f5 --- /dev/null +++ b/backend/src/models/points_audit_ledger.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import uuid +from decimal import Decimal + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Index, + Integer, + JSON, + Numeric, + SmallInteger, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class PointsAuditLedger(TimestampMixin, Base): + __tablename__ = "points_audit_ledger" + __table_args__ = ( + CheckConstraint( + "amount >= 0", name="ck_points_audit_ledger_amount_non_negative" + ), + CheckConstraint( + "direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid" + ), + CheckConstraint( + "balance_after >= 0", + name="ck_points_audit_ledger_balance_after_non_negative", + ), + CheckConstraint( + "change_type in ('register', 'consume', 'grant', 'adjust')", + name="ck_points_audit_ledger_change_type", + ), + CheckConstraint( + "biz_type is null or biz_type = 'chat'", + name="ck_points_audit_ledger_biz_type", + ), + CheckConstraint( + "billed_to in ('user', 'platform')", + name="ck_points_audit_ledger_billed_to", + ), + CheckConstraint( + "jsonb_typeof(metadata) = 'object'", + name="ck_points_audit_ledger_metadata_object", + ), + UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"), + Index( + "ix_points_audit_ledger_user_id_created_at", + "user_id_snapshot", + text("created_at DESC"), + ), + Index( + "ix_points_audit_ledger_change_type_created_at", + "change_type", + text("created_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + event_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id_snapshot: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + user_email_snapshot: Mapped[str | None] = mapped_column(Text, nullable=True) + change_type: Mapped[str] = mapped_column(String(16), nullable=False) + biz_type: Mapped[str | None] = mapped_column(String(16), nullable=True) + biz_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + direction: Mapped[int] = mapped_column(SmallInteger, nullable=False) + amount: Mapped[int] = mapped_column(BigInteger, nullable=False) + balance_after: Mapped[int] = mapped_column(BigInteger, nullable=False) + billed_to: Mapped[str] = mapped_column(String(16), nullable=False) + run_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + request_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cost: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0) + metadata_json: Mapped[dict[str, object]] = mapped_column( + "metadata", + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) diff --git a/backend/src/models/register_bonus_claims.py b/backend/src/models/register_bonus_claims.py new file mode 100644 index 0000000..585b2e1 --- /dev/null +++ b/backend/src/models/register_bonus_claims.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class RegisterBonusClaims(TimestampMixin, Base): + __tablename__ = "register_bonus_claims" + __table_args__ = ( + UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"), + UniqueConstraint( + "grant_event_id", name="uq_register_bonus_claims_grant_event_id" + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + email_hash: Mapped[str] = mapped_column(String(64), nullable=False) + user_email_snapshot: Mapped[str] = mapped_column(Text, nullable=False) + first_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="SET NULL"), + nullable=True, + ) + grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False) diff --git a/backend/src/schemas/domain/chat_message.py b/backend/src/schemas/domain/chat_message.py index f7687f0..234591a 100644 --- a/backend/src/schemas/domain/chat_message.py +++ b/backend/src/schemas/domain/chat_message.py @@ -44,21 +44,17 @@ class AgentChatMessage(BaseModel): output_tokens: int = Field(default=0, ge=0) cost: Decimal = Field(default=Decimal("0")) latency_ms: int | None = Field(default=None, ge=0) - metadata: AgentChatMessageMetadata | dict[str, object] | None = None + metadata: AgentChatMessageMetadata | None = None timestamp: datetime def extract_user_message_attachments( - metadata: AgentChatMessageMetadata | dict[str, object] | None, + metadata: AgentChatMessageMetadata | None, ) -> list[UserMessageAttachment]: if metadata is None: return [] - if isinstance(metadata, AgentChatMessageMetadata): - raw_value: Any = metadata.user_message_attachments - else: - raw_value = metadata.get("user_message_attachments") - + raw_value: Any = metadata.user_message_attachments if raw_value is None: return [] diff --git a/backend/src/schemas/domain/chat_session.py b/backend/src/schemas/domain/chat_session.py new file mode 100644 index 0000000..b2079fb --- /dev/null +++ b/backend/src/schemas/domain/chat_session.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class SessionStateSnapshot(BaseModel): + model_config = ConfigDict(extra="allow") + + pass diff --git a/backend/src/schemas/domain/points.py b/backend/src/schemas/domain/points.py index 86a8365..0c7f81f 100644 --- a/backend/src/schemas/domain/points.py +++ b/backend/src/schemas/domain/points.py @@ -126,3 +126,40 @@ class ApplyPointsChangeCommand(BaseModel): if self.biz_type != PointsBizType.CHAT or self.biz_id is None: raise ValueError("adjust must use chat binding") return self + + +class AuditLedgerMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: str = Field(min_length=1, max_length=64) + email_hash: str | None = Field(default=None, max_length=128) + usage_missing: bool | None = Field(default=None) + failure_kind: Literal["failed", "canceled"] | None = Field(default=None) + operator_id: str | None = Field(default=None, max_length=64) + + @model_validator(mode="after") + def validate_at_least_source(self) -> "AuditLedgerMetadata": + if not self.source: + raise ValueError("source is required") + return self + + +class AppendAuditLedgerCommand(BaseModel): + model_config = ConfigDict(extra="forbid") + + event_id: str = Field(min_length=1, max_length=64) + user_id_snapshot: UUID | None = None + user_email_snapshot: str | None = None + change_type: PointsChangeType + biz_type: PointsBizType | None = None + biz_id: UUID | None = None + direction: Literal[1, 0, -1] + amount: int = Field(ge=0) + balance_after: int = Field(ge=0) + billed_to: Literal["user", "platform"] + run_id: str | None = Field(default=None, max_length=128) + request_id: str | None = Field(default=None, max_length=128) + input_tokens: int = Field(ge=0) + output_tokens: int = Field(ge=0) + cost: Decimal = Field(ge=Decimal("0")) + metadata: AuditLedgerMetadata diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index b86e397..62c13a0 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -65,19 +65,14 @@ def convert_message_to_history( def _convert_user_attachments( - metadata: AgentChatMessageMetadata | dict[str, Any] | None, + metadata: AgentChatMessageMetadata | None, get_signed_url_fn: Callable[[dict[str, str]], str] | None, ) -> list[dict[str, str]]: """转换用户附件为临时访问 URL 列表""" if not metadata or not get_signed_url_fn: return [] - if isinstance(metadata, AgentChatMessageMetadata): - resolved = extract_user_message_attachments(metadata) - elif isinstance(metadata, dict): - resolved = extract_user_message_attachments(metadata) - else: - return [] + resolved = extract_user_message_attachments(metadata) signed_attachments: list[dict[str, str]] = [] for attachment in resolved: @@ -94,20 +89,13 @@ def _convert_user_attachments( def _extract_worker_agent_output( - metadata: AgentChatMessageMetadata | dict[str, Any] | None, + metadata: AgentChatMessageMetadata | None, ) -> dict[str, Any] | None: """提取 assistant 消息的结构化 agent_output。""" if not metadata: return None - if isinstance(metadata, AgentChatMessageMetadata): - agent_output = metadata.agent_output - else: - agent_output_data = metadata.get("agent_output") - if not agent_output_data: - return None - agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data) - + agent_output = metadata.agent_output if not agent_output: return None diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index e2d30c5..73d2f5f 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -1,8 +1,12 @@ from __future__ import annotations +from uuid import UUID + from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession from core.config.settings import config +from core.db import get_db from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service from v1.auth.schemas import ( @@ -13,6 +17,8 @@ from v1.auth.schemas import ( SessionResponse, ) from v1.auth.service import AuthService +from v1.points.repository import PointsRepository +from v1.points.service import PointsService router = APIRouter(prefix="/auth", tags=["auth"]) @@ -46,6 +52,7 @@ async def create_email_session( payload: EmailSessionCreateRequest, request: Request, service: AuthService = Depends(get_auth_service), + session: AsyncSession = Depends(get_db), ) -> SessionResponse: client_ip = _client_ip(request) await enforce_rate_limit( @@ -60,7 +67,14 @@ async def create_email_session( limit=20, window_seconds=300, ) - return await service.create_email_session(payload) + result = await service.create_email_session(payload) + points_service = PointsService(repository=PointsRepository(session)) + await points_service.grant_register_bonus_if_eligible( + user_id=UUID(result.user.id), + user_email=result.user.email, + ) + await session.commit() + return result @router.post("/sessions/refresh", response_model=SessionResponse) diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index fd9d514..e008007 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -1,14 +1,23 @@ from __future__ import annotations +from decimal import Decimal from uuid import UUID from sqlalchemy.dialects.postgresql import insert from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from models.agent_chat_message import AgentChatMessage +from models.points_audit_ledger import PointsAuditLedger from models.points_ledger import PointsLedger +from models.register_bonus_claims import RegisterBonusClaims from models.user_points import UserPoints -from schemas.domain.points import ApplyPointsChangeCommand +from schemas.domain.points import ( + AppendAuditLedgerCommand, + ApplyPointsChangeCommand, + PointsChargeSnapshot, +) +from schemas.enums import AgentChatMessageRole class PointsRepository: @@ -57,6 +66,72 @@ class PointsRepository: self._session.add(entry) await self._session.flush() + async def has_audit_event(self, *, event_id: str) -> bool: + stmt = select(PointsAuditLedger.id).where( + PointsAuditLedger.event_id == event_id + ) + row = (await self._session.execute(stmt)).scalar_one_or_none() + return row is not None + + async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None: + entry = PointsAuditLedger( + event_id=command.event_id, + user_id_snapshot=command.user_id_snapshot, + user_email_snapshot=command.user_email_snapshot, + change_type=command.change_type.value, + biz_type=command.biz_type.value if command.biz_type is not None else None, + biz_id=command.biz_id, + direction=command.direction, + amount=command.amount, + balance_after=command.balance_after, + billed_to=command.billed_to, + run_id=command.run_id, + request_id=command.request_id, + input_tokens=command.input_tokens, + output_tokens=command.output_tokens, + cost=command.cost, + metadata_json=command.metadata, + ) + self._session.add(entry) + await self._session.flush() + + async def get_run_usage_snapshot( + self, + *, + session_id: UUID, + run_id: str, + ) -> PointsChargeSnapshot | None: + stmt = ( + select(AgentChatMessage) + .where( + AgentChatMessage.session_id == session_id, + AgentChatMessage.role == AgentChatMessageRole.ASSISTANT, + AgentChatMessage.deleted_at.is_(None), + ) + .order_by(AgentChatMessage.seq.desc()) + .limit(20) + ) + messages = list((await self._session.execute(stmt)).scalars().all()) + message = None + for candidate in messages: + metadata = candidate.metadata_json or {} + if metadata.get("run_id") == run_id: + message = candidate + break + + if message is None: + return None + + cost_value = message.cost if message.cost is not None else Decimal("0") + return PointsChargeSnapshot( + message_id=message.id, + message_seq=max(int(message.seq), 1), + model_code=(message.model_code or "agent_run").strip() or "agent_run", + input_tokens=max(int(message.input_tokens), 0), + output_tokens=max(int(message.output_tokens), 0), + cost=Decimal(str(cost_value)), + ) + async def get_user_points(self, *, user_id: UUID) -> UserPoints: insert_stmt = ( insert(UserPoints) @@ -67,3 +142,25 @@ class PointsRepository: stmt = select(UserPoints).where(UserPoints.user_id == user_id) return (await self._session.execute(stmt)).scalar_one() + + async def claim_register_bonus( + self, + *, + email_hash: str, + user_email_snapshot: str, + first_user_id: UUID, + grant_event_id: str, + ) -> bool: + stmt = ( + insert(RegisterBonusClaims) + .values( + email_hash=email_hash, + user_email_snapshot=user_email_snapshot, + first_user_id=first_user_id, + grant_event_id=grant_event_id, + ) + .on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash]) + .returning(RegisterBonusClaims.id) + ) + inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() + return inserted_id is not None diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 8084111..ba8b0db 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -3,10 +3,19 @@ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal import hashlib +import hmac +from typing import Literal from uuid import UUID, uuid4 +from core.config.settings import config from core.http.errors import ApiProblemError, problem_payload -from schemas.domain.points import ConsumeLedgerMetadata, PointsChargeSnapshot +from schemas.domain.points import ( + AppendAuditLedgerCommand, + AuditLedgerMetadata, + ConsumeLedgerMetadata, + RegisterLedgerMetadata, + PointsChargeSnapshot, +) from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand from v1.points.repository import PointsRepository @@ -31,10 +40,128 @@ class PointsBalanceResult: can_run: bool +@dataclass(frozen=True) +class PlatformCostAuditResult: + audited: bool + event_id: str + cost: Decimal + + +@dataclass(frozen=True) +class RegisterBonusResult: + granted: bool + amount: int + balance_after: int + event_id: str + + class PointsService: def __init__(self, repository: PointsRepository) -> None: self._repository = repository + async def grant_register_bonus_if_eligible( + self, + *, + user_id: UUID, + user_email: str, + ) -> RegisterBonusResult: + normalized_email = self._normalize_email(user_email) + if not normalized_email: + return RegisterBonusResult( + granted=False, + amount=0, + balance_after=0, + event_id="", + ) + + bonus_points = int(config.points_policy.register_bonus_points) + if bonus_points <= 0: + account = await self._repository.get_or_create_user_points_for_update( + user_id=user_id + ) + return RegisterBonusResult( + granted=False, + amount=0, + balance_after=int(account.balance), + event_id="", + ) + + email_hash = self._build_register_bonus_email_hash(normalized_email) + event_hash = hashlib.sha1( + f"{normalized_email}:{email_hash}".encode("utf-8") + ).hexdigest() + event_id = f"register.bonus:{event_hash}" + + claimed = await self._repository.claim_register_bonus( + email_hash=email_hash, + user_email_snapshot=normalized_email, + first_user_id=user_id, + grant_event_id=event_id, + ) + account = await self._repository.get_or_create_user_points_for_update( + user_id=user_id + ) + if not claimed: + return RegisterBonusResult( + granted=False, + amount=0, + balance_after=int(account.balance), + event_id=event_id, + ) + + balance = int(account.balance) + account.balance = balance + bonus_points + account.lifetime_earned = int(account.lifetime_earned) + bonus_points + account.version = int(account.version) + 1 + + metadata = RegisterLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + "source": "register_bonus_policy", + "email_hash": email_hash, + }, + ) + command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.REGISTER, + event_id=event_id, + amount=bonus_points, + direction=1, + operator_id=None, + metadata=metadata, + ) + await self._repository.append_ledger( + command=command, + balance_after=int(account.balance), + ) + await self._repository.append_audit_ledger( + command=AppendAuditLedgerCommand( + event_id=event_id, + user_id_snapshot=user_id, + user_email_snapshot=normalized_email, + change_type=PointsChangeType.REGISTER, + direction=1, + amount=bonus_points, + balance_after=int(account.balance), + billed_to="user", + run_id=event_id, + input_tokens=0, + output_tokens=0, + cost=Decimal("0"), + metadata=AuditLedgerMetadata( + source="register_bonus_policy", + email_hash=email_hash, + ), + ) + ) + return RegisterBonusResult( + granted=True, + amount=bonus_points, + balance_after=int(account.balance), + event_id=event_id, + ) + async def ensure_run_points_available( self, *, @@ -84,6 +211,7 @@ class PointsService: session_id: UUID, run_id: str, operator_id: UUID | None, + user_email: str | None = None, ) -> RunChargeResult: event_source = f"{session_id}:{run_id}".encode("utf-8") event_hash = hashlib.sha1(event_source).hexdigest() @@ -122,18 +250,28 @@ class PointsService: account.lifetime_spent = int(account.lifetime_spent) + RUN_POINTS_COST account.version = int(account.version) + 1 + usage_snapshot = await self._repository.get_run_usage_snapshot( + session_id=session_id, + run_id=run_id, + ) + usage_missing = usage_snapshot is None + charge_snapshot = usage_snapshot or PointsChargeSnapshot( + message_id=uuid4(), + message_seq=1, + model_code="agent_run", + input_tokens=0, + output_tokens=0, + cost=Decimal("0"), + ) + metadata = ConsumeLedgerMetadata( operator_type=PointsOperatorType.USER, run_id=run_id, - charge=PointsChargeSnapshot( - message_id=uuid4(), - message_seq=1, - model_code="agent_run", - input_tokens=0, - output_tokens=0, - cost=Decimal("0"), - ), - ext={"source": "run_success"}, + charge=charge_snapshot, + ext={ + "source": "run_success", + "usage_missing": usage_missing, + }, ) command = ApplyPointsChangeCommand( user_id=user_id, @@ -150,9 +288,112 @@ class PointsService: command=command, balance_after=int(account.balance), ) + await self._repository.append_audit_ledger( + command=AppendAuditLedgerCommand( + event_id=event_id, + user_id_snapshot=user_id, + user_email_snapshot=(user_email or "").strip().lower() or None, + change_type=PointsChangeType.CONSUME, + biz_type=PointsBizType.CHAT, + biz_id=session_id, + direction=-1, + amount=RUN_POINTS_COST, + balance_after=int(account.balance), + billed_to="user", + run_id=run_id, + input_tokens=charge_snapshot.input_tokens, + output_tokens=charge_snapshot.output_tokens, + cost=charge_snapshot.cost, + metadata=AuditLedgerMetadata( + source="run_success", + usage_missing=usage_missing, + ), + ) + ) return RunChargeResult( charged=True, amount=RUN_POINTS_COST, balance_after=int(account.balance), event_id=event_id, ) + + async def record_failed_run_platform_cost( + self, + *, + user_id: UUID, + session_id: UUID, + run_id: str, + operator_id: UUID | None, + user_email: str | None = None, + failure_kind: Literal["failed", "canceled"], + ) -> PlatformCostAuditResult: + event_source = f"{session_id}:{run_id}:{failure_kind}".encode("utf-8") + event_hash = hashlib.sha1(event_source).hexdigest() + event_kind = "fail" if failure_kind == "failed" else "cancel" + event_id = f"chat.run.{event_kind}:{event_hash}" + + if await self._repository.has_audit_event(event_id=event_id): + return PlatformCostAuditResult( + audited=False, + event_id=event_id, + cost=Decimal("0"), + ) + + usage_snapshot = await self._repository.get_run_usage_snapshot( + session_id=session_id, + run_id=run_id, + ) + if usage_snapshot is None or usage_snapshot.cost <= Decimal("0"): + return PlatformCostAuditResult( + audited=False, + event_id=event_id, + cost=Decimal("0"), + ) + + account = await self._repository.get_or_create_user_points_for_update( + user_id=user_id + ) + await self._repository.append_audit_ledger( + command=AppendAuditLedgerCommand( + event_id=event_id, + user_id_snapshot=user_id, + user_email_snapshot=(user_email or "").strip().lower() or None, + change_type=PointsChangeType.CONSUME, + biz_type=PointsBizType.CHAT, + biz_id=session_id, + direction=0, + amount=0, + balance_after=int(account.balance), + billed_to="platform", + run_id=run_id, + input_tokens=usage_snapshot.input_tokens, + output_tokens=usage_snapshot.output_tokens, + cost=usage_snapshot.cost, + metadata=AuditLedgerMetadata( + source=f"run_{failure_kind}", + failure_kind=failure_kind, + operator_id=str(operator_id) if operator_id is not None else None, + ), + ) + ) + return PlatformCostAuditResult( + audited=True, + event_id=event_id, + cost=usage_snapshot.cost, + ) + + @staticmethod + def _normalize_email(email: str) -> str: + return email.strip().lower() + + @staticmethod + def _build_register_bonus_email_hash(normalized_email: str) -> str: + key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + if not key: + raise RuntimeError("points_policy.register_bonus_hmac_key is required") + digest = hmac.new( + key=key.encode("utf-8"), + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ) + return digest.hexdigest() diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md new file mode 100644 index 0000000..aa0659c --- /dev/null +++ b/backend/tests/AGENTS.md @@ -0,0 +1,27 @@ +# Backend Tests Rules + +This file governs `backend/tests/**` only. + +## Scope & Precedence + +- Inherits root `AGENTS.md` and `backend/AGENTS.md`. +- If rules conflict, apply the stricter one. + +## Test Execution + +- Use `uv run pytest ...` for all backend test commands. +- Unit tests should not depend on a running web process. +- Integration tests under `backend/tests/integration` are live API tests and must run against a started backend. + +## Integration Tests (Required Precondition) + +- Before running integration tests, start backend and workers with: + - `./infra/scripts/app.sh restart` +- Verify service readiness via `/health` before sending test requests. +- Integration tests may write test data to database, but must clean up created records after each test. + +## Data Safety + +- Test data must use isolated identifiers (unique email suffix, unique run_id/thread_id). +- Cleanup must include auth users and related bonus/audit records created by the test. +- Never hardcode production credentials or mutable shared user IDs in tests. diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..5b60d35 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +import hashlib +import hmac +import os +import time + +import httpx +import pytest +from sqlalchemy import text + +from core.config.settings import config +from core.db.session import AsyncSessionLocal + + +@pytest.fixture(scope="session") +def api_base_url() -> str: + return os.environ.get("ERYAO_TEST_BASE_URL", "http://localhost:5775") + + +@pytest.fixture(scope="session") +def test_verify_code() -> str: + return os.environ.get("ERYAO_TEST__CODE", "123456") + + +@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}" + + +@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} + + +@pytest.fixture +async def api_client(api_base_url: str) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(base_url=api_base_url, timeout=30.0) as client: + try: + health = await client.get("/health") + if health.status_code != 200: + pytest.skip(f"API not ready: /health={health.status_code}") + except Exception as exc: + pytest.skip(f"API unavailable: {exc}") + yield client + + +@pytest.fixture +async def db_cleanup() -> AsyncIterator[list[str]]: + emails: list[str] = [] + yield emails + + if not emails: + return + + hmac_key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + email_hashes = [ + hmac.new( + hmac_key.encode("utf-8"), email.encode("utf-8"), hashlib.sha256 + ).hexdigest() + for email in emails + ] + + async with AsyncSessionLocal() as session: + await session.execute( + text( + "DELETE FROM points_audit_ledger WHERE lower(coalesce(user_email_snapshot, '')) = ANY(:emails)" + ), + {"emails": emails}, + ) + await session.execute( + text( + "DELETE FROM register_bonus_claims WHERE email_hash = ANY(:email_hashes)" + ), + {"email_hashes": email_hashes}, + ) + await session.execute( + text("DELETE FROM auth.users WHERE lower(email) = ANY(:emails)"), + {"emails": emails}, + ) + await session.commit() diff --git a/backend/tests/integration/test_register_run_delete_reregister.py b/backend/tests/integration/test_register_run_delete_reregister.py new file mode 100644 index 0000000..95531fa --- /dev/null +++ b/backend/tests/integration/test_register_run_delete_reregister.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +import time +import uuid +from typing import TypedDict + +import httpx +import pytest +from sqlalchemy import select + +from core.config.settings import config +from core.db.session import AsyncSessionLocal +from models.points_audit_ledger import PointsAuditLedger +from models.points_ledger import PointsLedger +from models.register_bonus_claims import RegisterBonusClaims +from models.user_points import UserPoints + + +class IdentityData(TypedDict): + email: str + code: str + + +async def _create_email_session( + client: httpx.AsyncClient, + *, + email: str, + code: str, +) -> dict[str, object]: + resp = await client.post( + "/api/v1/auth/email-session", + json={"email": email, "token": code}, + ) + resp.raise_for_status() + return resp.json() + + +async def _wait_terminal_event( + client: httpx.AsyncClient, + *, + access_token: str, + thread_id: str, + run_id: str, + timeout_s: int = 180, +) -> str: + headers = {"Authorization": f"Bearer {access_token}"} + params = {"runId": run_id, "idle_limit": 120} + started = time.time() + + async with client.stream( + "GET", + f"/api/v1/agent/runs/{thread_id}/events", + headers=headers, + params=params, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if time.time() - started > timeout_s: + raise TimeoutError("SSE timed out") + if not line or not line.startswith("data: "): + continue + event = json.loads(line[6:]) + event_type = event.get("type") + if event_type in {"RUN_FINISHED", "RUN_ERROR"}: + return str(event_type) + + raise RuntimeError("No terminal SSE event") + + +def _build_run_payload(*, thread_id: str, run_id: str) -> dict[str, object]: + now = int(time.time() * 1000) + return { + "threadId": thread_id, + "runId": run_id, + "state": {}, + "messages": [ + { + "id": f"msg_{run_id}_user_0", + "role": "user", + "content": "今天适合做重要决策吗?", + } + ], + "tools": [], + "context": [], + "forwardedProps": { + "runtime_mode": "chat", + "client_time": { + "device_timezone": "Asia/Shanghai", + "client_now_iso": "2026-04-10T12:00:00Z", + "client_epoch_ms": now, + }, + "divinationPayload": { + "divinationMethod": "自动起卦", + "questionType": "运势", + "question": "今天适合做重要决策吗?", + "divinationTimeIso": "2026-04-10T12:00:00Z", + "yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"], + }, + }, + } + + +@pytest.mark.asyncio +async def test_register_run_delete_reregister_keeps_bonus_single_use( + api_client: httpx.AsyncClient, + test_identity: IdentityData, + db_cleanup: list[str], +) -> None: + email = str(test_identity["email"]).strip().lower() + db_cleanup.append(email) + bonus = int(config.points_policy.register_bonus_points) + + first = await _create_email_session( + api_client, + email=email, + code=str(test_identity["code"]), + ) + user1 = first.get("user") + assert isinstance(user1, dict) + user1_id = str(user1["id"]) + token1 = str(first["access_token"]) + headers1 = {"Authorization": f"Bearer {token1}"} + + before_run = await api_client.get("/api/v1/points/balance", headers=headers1) + before_run.raise_for_status() + before_data = before_run.json() + assert int(before_data["balance"]) == bonus + + thread_id = str(uuid.uuid4()) + run_id = f"run_{int(time.time() * 1000)}" + enqueue = await api_client.post( + "/api/v1/agent/runs", + headers=headers1, + json=_build_run_payload(thread_id=thread_id, run_id=run_id), + ) + enqueue.raise_for_status() + assert enqueue.status_code == 202 + + terminal = await _wait_terminal_event( + api_client, + access_token=token1, + thread_id=thread_id, + run_id=run_id, + ) + assert terminal in {"RUN_FINISHED", "RUN_ERROR"} + + after_run = await api_client.get("/api/v1/points/balance", headers=headers1) + after_run.raise_for_status() + after_data = after_run.json() + assert int(after_data["balance"]) == max(bonus - int(after_data["runCost"]), 0) + + delete_resp = await api_client.delete("/api/v1/users/me", headers=headers1) + assert delete_resp.status_code == 204 + + second = await _create_email_session( + api_client, + email=email, + code=str(test_identity["code"]), + ) + user2 = second.get("user") + assert isinstance(user2, dict) + user2_id = str(user2["id"]) + token2 = str(second["access_token"]) + assert user1_id != user2_id + + headers2 = {"Authorization": f"Bearer {token2}"} + reregister_balance = await api_client.get( + "/api/v1/points/balance", headers=headers2 + ) + reregister_balance.raise_for_status() + re_data = reregister_balance.json() + assert int(re_data["balance"]) == 0 + + async with AsyncSessionLocal() as session: + points2 = ( + await session.execute( + select(UserPoints).where(UserPoints.user_id == uuid.UUID(user2_id)) + ) + ).scalar_one() + assert int(points2.lifetime_earned) == 0 + + run_ledger_rows = list( + ( + await session.execute( + select(PointsLedger) + .where(PointsLedger.user_id == uuid.UUID(user1_id)) + .order_by(PointsLedger.created_at.desc()) + ) + ).scalars() + ) + assert run_ledger_rows == [] + + run_audit_rows = list( + ( + await session.execute( + select(PointsAuditLedger) + .where( + PointsAuditLedger.user_id_snapshot == uuid.UUID(user1_id), + PointsAuditLedger.run_id == run_id, + ) + .order_by(PointsAuditLedger.created_at.desc()) + ) + ).scalars() + ) + assert run_audit_rows + assert run_audit_rows[0].run_id == run_id + assert run_audit_rows[0].billed_to in {"user", "platform"} + + claim_rows = list( + ( + await session.execute( + select(RegisterBonusClaims).where( + RegisterBonusClaims.user_email_snapshot == email + ) + ) + ).scalars() + ) + assert len(claim_rows) == 1 diff --git a/backend/tests/unit/test_points_service_audit.py b/backend/tests/unit/test_points_service_audit.py new file mode 100644 index 0000000..03110f4 --- /dev/null +++ b/backend/tests/unit/test_points_service_audit.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from uuid import UUID, uuid4 + +import pytest + +from core.config.settings import config +from schemas.domain.points import AppendAuditLedgerCommand, ApplyPointsChangeCommand +from schemas.domain.points import PointsChargeSnapshot +from v1.points.service import PointsService + + +@dataclass +class _FakeAccount: + balance: int = 100 + frozen_balance: int = 0 + lifetime_earned: int = 0 + lifetime_spent: int = 0 + version: int = 0 + + +class _FakePointsRepository: + def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None: + self.account = _FakeAccount() + self.usage_snapshot = usage_snapshot + self.appended_ledger: list[ApplyPointsChangeCommand] = [] + self.appended_audit: list[AppendAuditLedgerCommand] = [] + self.claimed: bool = False + + async def get_or_create_user_points_for_update( + self, *, user_id: UUID + ) -> _FakeAccount: + del user_id + return self.account + + async def has_ledger_event(self, *, user_id: UUID, event_id: str) -> bool: + del user_id, event_id + return False + + async def append_ledger( + self, + *, + command: ApplyPointsChangeCommand, + balance_after: int, + ) -> None: + del balance_after + self.appended_ledger.append(command) + + async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None: + self.appended_audit.append(command) + + async def has_audit_event(self, *, event_id: str) -> bool: + del event_id + return False + + async def get_run_usage_snapshot( + self, + *, + session_id: UUID, + run_id: str, + ) -> PointsChargeSnapshot | None: + del session_id, run_id + return self.usage_snapshot + + async def claim_register_bonus( + self, + *, + email_hash: str, + user_email_snapshot: str, + first_user_id: UUID, + grant_event_id: str, + ) -> bool: + del email_hash, user_email_snapshot, first_user_id, grant_event_id + if self.claimed: + return False + self.claimed = True + return True + + +@pytest.mark.asyncio +async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None: + usage = PointsChargeSnapshot( + message_id=uuid4(), + message_seq=3, + model_code="doubao-1.5-pro", + input_tokens=123, + output_tokens=456, + cost=Decimal("0.023456"), + ) + repository = _FakePointsRepository(usage_snapshot=usage) + service = PointsService(repository=repository) # type: ignore[arg-type] + + user_id = uuid4() + session_id = uuid4() + run_id = "run_123" + result = await service.consume_successful_run_points( + user_id=user_id, + session_id=session_id, + run_id=run_id, + operator_id=user_id, + user_email="User@Example.com", + ) + + assert result.charged is True + assert result.amount == 20 + assert repository.account.balance == 80 + assert len(repository.appended_ledger) == 1 + assert len(repository.appended_audit) == 1 + + audit = repository.appended_audit[0] + assert audit.billed_to == "user" + assert audit.input_tokens == 123 + assert audit.output_tokens == 456 + assert audit.cost == Decimal("0.023456") + assert audit.direction == -1 + assert audit.amount == 20 + assert audit.user_email_snapshot == "user@example.com" + + +@pytest.mark.asyncio +async def test_record_failed_run_platform_cost_writes_platform_audit_only() -> None: + usage = PointsChargeSnapshot( + message_id=uuid4(), + message_seq=1, + model_code="doubao-1.5-pro", + input_tokens=100, + output_tokens=20, + cost=Decimal("0.012300"), + ) + repository = _FakePointsRepository(usage_snapshot=usage) + service = PointsService(repository=repository) # type: ignore[arg-type] + + result = await service.record_failed_run_platform_cost( + user_id=uuid4(), + session_id=uuid4(), + run_id="run_failed", + operator_id=None, + user_email="test@example.com", + failure_kind="failed", + ) + + assert result.audited is True + assert result.cost == Decimal("0.012300") + assert len(repository.appended_ledger) == 0 + assert len(repository.appended_audit) == 1 + + audit = repository.appended_audit[0] + assert audit.billed_to == "platform" + assert audit.direction == 0 + assert audit.amount == 0 + assert audit.cost == Decimal("0.012300") + + +@pytest.mark.asyncio +async def test_grant_register_bonus_if_eligible_first_time_grants() -> None: + repository = _FakePointsRepository(usage_snapshot=None) + service = PointsService(repository=repository) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), + user_email="NewUser@Example.com", + ) + + expected_bonus = int(config.points_policy.register_bonus_points) + assert result.granted is True + assert result.amount == expected_bonus + assert repository.account.balance == 100 + expected_bonus + assert len(repository.appended_ledger) == 1 + assert len(repository.appended_audit) == 1 + assert repository.appended_audit[0].billed_to == "user" + assert repository.appended_audit[0].change_type.value == "register" + + +@pytest.mark.asyncio +async def test_grant_register_bonus_if_eligible_second_time_skips() -> None: + repository = _FakePointsRepository(usage_snapshot=None) + repository.claimed = True + service = PointsService(repository=repository) # type: ignore[arg-type] + + result = await service.grant_register_bonus_if_eligible( + user_id=uuid4(), + user_email="dup@example.com", + ) + + assert result.granted is False + assert result.amount == 0 + assert len(repository.appended_ledger) == 0 + assert len(repository.appended_audit) == 0 diff --git a/docs/plans/ios-new-user-pack-payment-plan.md b/docs/plans/ios-new-user-pack-payment-plan.md new file mode 100644 index 0000000..7df96db --- /dev/null +++ b/docs/plans/ios-new-user-pack-payment-plan.md @@ -0,0 +1,247 @@ +# iOS 新人包支付接入与一次性权益计划 + +## 1. 背景与目标 + +当前前端充值页为静态套餐展示,购买按钮未接入真实支付链路。现需新增 iOS 新人包: + +- 价格:`$0.99` +- 积分:`60` +- 资格:同邮箱只能购买一次 +- 删除账号后同邮箱重新注册,不刷新新人包资格 + +同时补齐后端真实支付路由与订单审计能力,前端不再硬编码套餐。 + +## 2. 本次范围 + +### 2.1 In Scope + +1. 后端新增 iOS 支付相关路由(下单/验单/查询/回调)。 +2. 新建支付订单主表与支付事件审计表。 +3. 改造 `register_bonus_claims` 为可承载“权益唯一占用”能力。 +4. 前端套餐由后端接口驱动,不再硬编码三档固定套餐。 +5. 新人包资格前后端联动(展示、购买、验单、入账)。 + +### 2.2 Out of Scope + +1. Android 支付渠道接入。 +2. Apple 开发者账号正式联调(当前账号未就绪)。 +3. 财务对账后台页面。 + +## 3. 数据模型设计 + +## 3.1 新建表:`payment_orders` + +用途:订单当前态,支持幂等验单与退款状态跟踪。 + +建议字段: + +- `id` UUID PK +- `order_no` VARCHAR(64) UNIQUE +- `user_id` UUID NOT NULL (`auth.users.id`) +- `channel` VARCHAR(16) NOT NULL (`ios_iap`) +- `product_code` VARCHAR(64) NOT NULL(例:`new_user_pack_099_60`) +- `price_usd` NUMERIC(12,6) NOT NULL +- `credits` BIGINT NOT NULL +- `currency` VARCHAR(8) NOT NULL DEFAULT `USD` +- `status` VARCHAR(24) NOT NULL + - `created|receipt_submitted|verified|credited|refund_pending|refunded|revoked|failed` +- `apple_transaction_id` VARCHAR(128) NULL UNIQUE +- `apple_original_transaction_id` VARCHAR(128) NULL +- `app_account_token` UUID NULL +- `idempotency_key` VARCHAR(128) NULL UNIQUE +- `error_code` VARCHAR(64) NULL +- `error_message` TEXT NULL +- `created_at` / `updated_at` + +关键约束: + +- `credits > 0` +- `price_usd >= 0` +- `status` check +- `channel='ios_iap'`(本期) + +## 3.2 新建表:`payment_order_events` + +用途:支付事件不可变审计流水(验单结果、回调、退款、冲正)。 + +建议字段: + +- `id` UUID PK +- `order_id` UUID NOT NULL FK `payment_orders.id` +- `event_type` VARCHAR(32) NOT NULL + - `order_created|receipt_submitted|verify_success|verify_failed|credited|refund_notified|refunded|revoke_notified|reversed` +- `event_source` VARCHAR(24) NOT NULL + - `api|apple_server_notification|job` +- `event_idempotency_key` VARCHAR(128) NULL UNIQUE +- `payload` JSONB NOT NULL +- `operator_id` UUID NULL +- `created_at` + +## 3.3 改造表:`register_bonus_claims` + +目标:从“注册送分去重”升级为“权益唯一占用”。 + +新增字段建议: + +- `offer_code` VARCHAR(64) NOT NULL(例:`register_bonus_20`、`new_user_pack_099_60`) +- `claim_source` VARCHAR(24) NOT NULL(`register_bonus|ios_purchase`) +- `claim_order_id` UUID NULL FK `payment_orders.id` + +新增唯一约束: + +- `UNIQUE(offer_code, email_hash)` + +保留行为: + +- `first_user_id` 允许 `ON DELETE SET NULL`,保证删号后资格仍占用。 + +## 4. 路由与服务边界 + +## 4.1 后端新增路由(v1) + +1. `GET /api/v1/payments/packages` + - 返回可购买套餐列表与用户资格(是否可买新人包)。 +2. `POST /api/v1/payments/orders` + - 创建订单,返回 `orderNo` 与客户端支付所需参数。 +3. `POST /api/v1/payments/orders/{orderNo}/verify-ios-receipt` + - 提交 iOS 收据,后端调用 Apple 校验。 +4. `GET /api/v1/payments/orders/{orderNo}` + - 查询订单状态与入账结果。 +5. `POST /api/v1/payments/webhooks/apple` + - 接收 App Store Server Notifications V2,处理退款/撤销。 + +## 4.2 分层职责 + +- Router:鉴权、请求校验、RFC7807 错误映射。 +- Service: + - 资格判断(新人包是否可买) + - 下单与验单业务编排 + - 入账积分与冲正 + - 幂等控制 +- Repository: + - `payment_orders`/`payment_order_events`/`register_bonus_claims` 读写 + - 订单状态流转条件更新 + +## 5. 核心流程 + +## 5.1 下单与资格检查 + +```text +客户端请求套餐 -> GET /payments/packages + -> 后端按 email_hash 检查 offer_code='new_user_pack_099_60' 是否已占用 + -> 返回 eligible=true/false + +客户端创建订单 -> POST /payments/orders + -> 再次做资格校验(防并发) + -> 创建 payment_orders(status=created) + -> 写 payment_order_events(order_created) +``` + +## 5.2 iOS 验单与积分入账 + +```text +客户端支付后提交 receipt -> POST /orders/{orderNo}/verify-ios-receipt + -> 后端调用 Apple 验单(可切 sandbox) + -> 验证 transaction_id 幂等 + -> 状态 verified + -> 原子事务: + 1) 占用权益 register_bonus_claims(offer_code,email_hash) + 2) 写 points_ledger(grant) + 3) 写 points_audit_ledger(direction=1,billed_to='user') + 4) 订单置 credited + 5) 写 payment_order_events(credited) +``` + +## 5.3 退款与冲正 + +```text +Apple 回调退款 -> POST /payments/webhooks/apple + -> 定位 order(transaction_id / original_transaction_id) + -> 幂等处理通知 + -> 状态 refunded/revoked + -> 原子事务: + 1) 写 points_ledger(adjust/consume reverse) + 2) 写 points_audit_ledger(direction=-1,billed_to='platform',metadata.reason='refund') + 3) 写 payment_order_events(refunded/reversed) +``` + +## 6. 信任边界与风控 + +1. 客户端价格、积分、product_code 全部不可信,按后端配置为准。 +2. 不信任客户端“支付成功”标记,必须后端验单通过才入账。 +3. Apple 回调需验签(JWS)并做 `notificationUUID` 幂等。 +4. 订单与入账使用数据库事务,失败不允许半成功。 +5. `offer_code + email_hash` 唯一约束是最终防线。 + +## 7. 前端改造 + +当前 `CoinCenterScreen` 中套餐硬编码,需改为 API 驱动: + +- 页面加载调用 `GET /api/v1/payments/packages` +- 渲染返回的套餐列表 +- 新人包 `eligible=false` 时展示“已购买/不可购买”态 +- 点击购买后走真实支付流(创建订单 -> 拉起 IAP -> 提交 receipt) + +## 8. 无 Apple 账号阶段的交付策略 + +在无开发者账号前,先做可替换的验单适配层: + +- `IOSReceiptVerifier` 接口(生产实现 + mock 实现) +- 通过配置开关使用 mock 结果跑通后端链路与前端状态 +- 后续只替换 verifier 实现,不改订单主流程 + +## 9. 测试计划 + +## 9.1 后端单元测试 + +1. 新人包资格判定(首次可买、重复不可买、删号重注册不可买) +2. 验单幂等(同 transaction_id 不重复入账) +3. 退款冲正幂等(同通知不重复冲正) + +## 9.2 后端集成测试 + +1. 首次注册 -> 下单 -> 验单 -> 入账 60 +2. 删除账号 -> 同邮箱重注册 -> 新人包不可买 +3. 退款通知 -> 积分冲正 -> 订单状态更新 + +## 9.3 前端集成测试 + +1. 套餐接口渲染(替代硬编码) +2. 新人包可买/不可买状态切换 +3. 支付中/成功/失败/退款状态展示 + +## 10. 里程碑拆分 + +### PR1(数据层) + +- 迁移:新建 `payment_orders`、`payment_order_events` +- 迁移:改造 `register_bonus_claims` +- 模型与 repository + +### PR2(后端业务) + +- 支付路由 + service +- iOS 验单适配层(先 mock) +- 订单与积分入账/冲正 + +### PR3(前端) + +- 套餐改 API 驱动 +- 新人包购买态与禁用态 +- 下单/验单交互链路 + +### PR4(联调与验证) + +- 使用集成测试回归全流程 +- Apple 账号就绪后切换真实 verifier + +## 11. 变更类型判定 + +这是 **新 Feature**,不是现有功能的小修补。 + +理由: + +1. 引入了新的支付域模型和事件审计。 +2. 引入了新的后端支付路由与验单流程。 +3. 前端从静态展示升级为可交易流程。 +4. 增加了退款冲正与 iOS 回调处理能力。 diff --git a/docs/plans/notification-system-plan.md b/docs/plans/notification-system-plan.md new file mode 100644 index 0000000..9142c1a --- /dev/null +++ b/docs/plans/notification-system-plan.md @@ -0,0 +1,784 @@ +# 通知系统实现方案 + +> 创建时间:2026-04-10 +> 状态:已评审 + +## 1. 需求理解 + +### 1.1 核心问题 +当前项目(觅爻签问 App)没有通知系统,需要从零建设。核心需求包括: +- 通知中心(通知列表/收件箱) +- 用户通知存储与读取 +- 已读/已看状态追踪 +- 系统推送触达(APNs/FCM) +- 前台实时通知同步 +- 新版本通知、活动通知等业务通知类型 + +### 1.2 通知类型区分 + +| 类型 | 说明 | 当前状态 | +|------|------|----------| +| 应用内通知记录 | 存储在 DB 的通知数据 | **不存在** | +| 系统推送通知 | 通过 APNs/FCM 发送 | **不存在** | +| 前台实时同步 | App 打开时实时拉取 | **不存在** | +| 本地通知 | App 本地触发的通知 | **不存在** | +| 已看状态 | 用户是否打开过通知详情 | **未设计** | +| 已读状态 | 用户是否标记为已读 | **未设计** | +| 推送触达 | 消息是否成功送达设备 | **未设计** | + +### 1.3 系统边界 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Flutter App │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ 本地通知 │ │ 推送接收 │ │ 通知中心 │ │ Badge │ │ +│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ +└───────────────┬─────────────────────┬───────────────────┘ + │ │ + │ REST API │ Supabase Realtime + ▼ ▼ +┌───────────────────────────────────────────────────────────┐ +│ Backend (FastAPI) │ +│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ +│ │ 通知写入 │ │ Fanout │ │ 推送发送 (APNs/FCM)│ │ +│ └────────────┘ └────────────┘ └────────────────────┘ │ +└───────────────┬─────────────────────┬───────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────┐ +│ PostgreSQL (Supabase) │ │ Redis / Supabase │ +│ ┌──────────────────┐ │ │ Realtime │ +│ │ notifications │ │ │ ┌─────────────────────┐ │ +│ │ notification_ │ │ │ │ user:{id}:notif:new │ │ +│ │ receipts │ │ │ └─────────────────────┘ │ +│ │ user_push_ │ │ └─────────────────────────────┘ +│ │ devices │ │ +│ └──────────────────┘ │ +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Push Provider │ +│ (APNs / FCM / 备用) │ +└─────────────────────────┘ +``` + +--- + +## 2. 现有代码调研结果 + +### 2.1 已发现的相关模块 + +#### Flutter 端 + +| 路径 | 说明 | +|------|------| +| `apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart` | 通知设置页面(占位 UI) | +| `apps/lib/features/settings/data/models/profile_settings.dart` | `NotificationSettings`(`allowNotifications`, `allowVibration`) | +| `apps/lib/features/home/presentation/screens/home_screen.dart:204` | 通知图标(点击显示 `featurePending`) | + +#### 后端 + +| 路径 | 说明 | +|------|------| +| `backend/src/schemas/shared/user.py:44` | `NotificationSettings` schema | +| `backend/src/v1/users/service.py` | 用户服务(含 settings 更新) | +| `backend/src/v1/users/router.py` | 用户路由 | +| `backend/src/services/base/supabase.py` | Supabase 服务封装 | +| `backend/src/core/config/settings.py` | 配置管理 | + +#### 数据库迁移 + +| 文件 | 说明 | +|------|------| +| `backend/alembic/versions/20260403_0002_user_points_chat_schema.py` | profiles, sessions, messages 表 | +| `backend/alembic/versions/20260407_0001_update_notification_settings.py` | 通知设置默认值 | + +### 2.2 已有能力 + +1. **Profile + Settings 系统**:`profiles.settings` 是 JSONB,可扩展存储通知偏好 +2. **用户认证**:Supabase Auth 完整 +3. **数据库迁移框架**:Alembic 已建立 +4. **后台任务**:Taskiq (基于 Redis) +5. **日志系统**:已集成 structlog +6. **API 模式**:Pydantic schemas, RFC7807 错误格式 + +### 2.3 当前缺口 + +| 缺口 | 说明 | +|------|------| +| 通知数据模型 | 无 `notifications` 表 | +| 推送设备管理 | 无 `user_push_devices` 表 | +| 通知状态追踪 | 无 `user_notifications` 状态字段体系 | +| 推送服务集成 | 无 APNs/FCM/备用推送集成 | +| Flutter 通知中心 | 无通知列表页面 | +| Flutter 推送接收 | 无 firebase_messaging 等依赖 | +| Supabase Realtime | 未用于通知同步 | +| 未读数 Badge | 未实现 | + +### 2.4 潜在冲突或风险 + +1. **AGENTS.md 约束**:`apps/AGENTS.md` 明确要求通知相关代码放在 `core/notification/` 和 `shared/widgets/notification/`,需要遵循 +2. **现有 Settings 结构**:`NotificationSettings` 只有两个布尔字段,未来需要扩展 +3. **数据库 JSONB 查询**:`profiles.settings` 使用 GIN 索引,但通知列表不适合放 JSONB + +--- + +## 3. 当前架构判断 + +### 3.1 推荐架构:混合推送 + 实时同步(Broadcast 主通道) + +**推荐方案:Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)** + +**原因**: +1. App 打开时(前台):使用 Broadcast 推送状态变化,避免直接依赖 `postgres_changes` 在高并发下的 RLS 扫描压力 +2. App 关闭时(后台/离线):通过 APNs/FCM 触达设备 +3. 两条链路统一写入 `user_notifications`,状态由服务端收敛,客户端只上报回执 + +**不适合当前项目的方案**: + +| 方案 | 不适合原因 | +|------|------------| +| 纯轮询 | 电量/服务器压力大,不优雅 | +| WebSocket 直连 | 需要自己维护连接,复杂度高 | +| 仅本地通知 | 无法触达离线用户 | +| 仅 APNs/FCM | 无法查看历史通知列表 | + +### 3.2 职责划分 + +| 组件 | 职责 | +|------|------| +| `notifications` 表 | 存储通知内容,永久记录 | +| `user_notifications` 表 | 追踪每个用户对每条通知的状态 | +| `notification_push_attempts` 表 | 追踪推送每次尝试和失败原因 | +| `user_push_devices` 表 | 存储设备 token | +| Supabase Realtime | 前台实时通知同步 | +| APNs/FCM | 后台/离线推送 | + +--- + +## 4. 推荐实现方案 + +### 4.1 数据模型设计 + +#### 4.1.1 `notifications` 表 + +```sql +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(32) NOT NULL, -- 'system'|'activity'|'version'|'social' + priority SMALLINT DEFAULT 0, -- 0=normal, 1=high + title TEXT NOT NULL, + body TEXT NOT NULL, + data JSONB, -- 透传数据(deeplink 等) + action_url TEXT, + expires_at TIMESTAMPTZ, -- 过期时间,NULL=永不过期 + created_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- 索引 +CREATE INDEX ix_notifications_type ON notifications(type); +CREATE INDEX ix_notifications_created_at ON notifications(created_at DESC); +``` + +#### 4.1.2 `user_notifications` 表(用户通知记录 + 状态) + +```sql +CREATE TABLE user_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, + dedupe_key TEXT NOT NULL, -- 幂等键(建议: campaign_id:user_id 或业务事件ID) + + -- 状态字段 + is_read BOOLEAN DEFAULT FALSE, -- 已读(点击进入详情) + is_seen BOOLEAN DEFAULT FALSE, -- 已看(列表中曝光) + is_opened BOOLEAN DEFAULT FALSE, -- 从推送打开(客户端回执) + read_at TIMESTAMPTZ, + seen_at TIMESTAMPTZ, + opened_at TIMESTAMPTZ, + + -- 推送状态(服务端可验证状态,不直接使用 delivered 语义) + push_state SMALLINT DEFAULT 0, -- 0=queued, 1=sent, 2=provider_ack, 3=failed + push_provider VARCHAR(16), -- apns|fcm + push_error_code TEXT, + push_sent_at TIMESTAMPTZ, + push_provider_ack_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ, + + UNIQUE(user_id, dedupe_key) +); + +-- 索引 +CREATE INDEX ix_user_notifications_user_id ON user_notifications(user_id); +CREATE INDEX ix_user_notifications_user_unread ON user_notifications(user_id, is_read) WHERE deleted_at IS NULL; +CREATE INDEX ix_user_notifications_created_at ON user_notifications(created_at DESC); +CREATE INDEX ix_user_notifications_user_seen ON user_notifications(user_id, is_seen) WHERE deleted_at IS NULL; +CREATE INDEX ix_user_notifications_push_state ON user_notifications(push_state) WHERE deleted_at IS NULL; +``` + +#### 4.1.3 `user_push_devices` 表 + +```sql +CREATE TABLE user_push_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_token TEXT NOT NULL, -- APNs/FCM token + device_type VARCHAR(16) NOT NULL, -- 'ios'|'android' + push_provider VARCHAR(32) NOT NULL, -- 'apns'|'fcm'|'huawei'|'xiaomi' + app_version TEXT, + locale VARCHAR(16), + + is_active BOOLEAN DEFAULT TRUE, + last_used_at TIMESTAMPTZ DEFAULT now(), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ, + + UNIQUE(user_id, device_token, push_provider) +); + +-- 索引 +CREATE INDEX ix_user_push_devices_user_id ON user_push_devices(user_id); +CREATE INDEX ix_user_push_devices_token ON user_push_devices(device_token); +CREATE INDEX ix_user_push_devices_active ON user_push_devices(user_id, is_active) WHERE deleted_at IS NULL; +``` + +#### 4.1.4 `notification_push_attempts` 表(推送尝试日志) + +```sql +CREATE TABLE notification_push_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_notification_id UUID NOT NULL REFERENCES user_notifications(id) ON DELETE CASCADE, + provider VARCHAR(16) NOT NULL, -- apns|fcm + attempt_no SMALLINT NOT NULL, + request_id TEXT, + result SMALLINT NOT NULL, -- 0=sent, 1=provider_ack, 2=failed, 3=timeout + error_code TEXT, + error_detail TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX ix_notification_push_attempts_un_id ON notification_push_attempts(user_notification_id, created_at DESC); +``` + +#### 4.1.5 状态字段说明 + +| 字段 | 含义 | 触发条件 | +|------|------|----------| +| `is_seen` | 用户在列表中看到该通知 | 列表滚动曝光(停留 > 1秒)或拉取 | +| `is_read` | 用户点击进入详情 | 点击通知项 | +| `is_opened` | 用户从系统通知打开 App | 客户端上报 open 回执 | +| `push_state=sent` | 推送请求已发出 | 服务端调用 APNs/FCM 成功返回 | +| `push_state=provider_ack` | 平台确认接收 | APNs/FCM Provider 级确认 | +| `push_state=failed` | 推送失败 | 服务端重试耗尽或不可恢复错误 | + +> 注意:`provider_ack` 不等价于“用户已看到通知”。用户可见性必须以 `is_opened`/`is_seen`/`is_read` 为准。 + +### 4.2 后端 API 设计 + +#### 4.2.1 通知路由 (`backend/src/v1/notifications/`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/notifications` | 获取当前用户通知列表 | +| GET | `/api/v1/notifications/unread-count` | 获取未读数 | +| PATCH | `/api/v1/notifications/{id}/seen` | 标记为已看 | +| PATCH | `/api/v1/notifications/{id}/read` | 标记为已读 | +| PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | +| DELETE | `/api/v1/notifications/{id}` | 删除通知 | + +#### 4.2.2 设备路由 (`backend/src/v1/push/`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/push/devices` | 注册/更新设备 token | +| DELETE | `/api/v1/push/devices/{id}` | 删除设备 | +| GET | `/api/v1/push/devices` | 获取用户设备列表 | + +### 4.3 Flutter 端设计 + +#### 4.3.1 目录结构(遵循 AGENTS.md) + +``` +apps/lib/ +├── core/ +│ └── notification/ # NEW +│ ├── models/ +│ │ ├── notification.dart +│ │ └── push_device.dart +│ ├── services/ +│ │ ├── notification_service.dart +│ │ └── push_service.dart +│ └── repositories/ +│ └── notification_repository.dart +├── features/ +│ └── notifications/ # NEW +│ ├── data/ +│ │ ├── apis/notification_api.dart +│ │ └── repositories/notification_repository_impl.dart +│ ├── presentation/ +│ │ ├── screens/notification_center_screen.dart +│ │ ├── widgets/notification_item.dart +│ │ └── bloc/notification_bloc.dart +├── shared/ +│ └── widgets/ +│ └── notification/ # NEW +│ ├── notification_badge.dart +│ └── notification_toast.dart +``` + +#### 4.3.2 通知中心流程 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ App 打开 / HomeScreen │ +│ 1. 初始化时连接 Supabase Realtime 私有频道 │ +│ 2. 监听 Broadcast 通知事件 │ +│ 3. 实时更新本地列表和未读数 Badge │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ NotificationCenterScreen │ +│ 1. 首次进入 → 调用 GET /api/v1/notifications │ +│ 2. 滚动曝光 → 调用 PATCH /api/v1/notifications/{id}/seen │ +│ 3. 点击通知 → 调用 PATCH /api/v1/notifications/{id}/read │ +│ 4. 下拉刷新 → 重新拉取列表 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 4.4 实时同步方案 + +**Supabase Realtime 职责**: +- 当用户在另一设备读取通知时,当前设备实时更新 `is_read` / `is_seen` / `is_opened` 状态 +- 仅做前台状态分发,不承担离线触达 + +**推荐通道:Broadcast(私有频道)** + +```text +DB update(user_notifications) + -> trigger 调用 realtime.broadcast_changes(...) + -> channel: user:{user_id}:notifications + -> Flutter 收到事件并更新本地缓存 +``` + +**为什么不用 postgres_changes 作为主通道**: +- 在订阅规模扩大时,`postgres_changes` 会放大 RLS 评估开销 +- Broadcast 在通知场景下更可控,可按用户私有频道精准下发 + +```dart +final channel = supabase.channel( + 'user:${userId}:notifications', + opts: const RealtimeChannelConfig(private: true), +); + +channel.onBroadcast( + event: 'notification_changed', + callback: (payload) { + // 更新本地状态和 Badge + }, +).subscribe(); +``` + +### 4.5 推送发送流程(Outbox + Worker) + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ 业务触发 │────▶│ DB 事务写入 │────▶│ push_outbox │ +│ (后台任务) │ │ notifications + │ │ pending 事件 │ +└─────────────┘ │ user_notifications│ └────────┬────────┘ + └──────────────────┘ │ + ▼ + ┌─────────────────┐ + │ Fanout Worker │ + │ 读取 outbox │ + └────────┬────────┘ + │ + ┌─────────────────────────────────┼─────────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ Broadcast │ │ APNs / FCM │ │ 重试 / DLQ │ + │ 前台增量同步 │ │ 离线触达 │ │ 指数退避 │ + └──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 4.6 幂等、重试与失败处理 + +#### 4.6.1 幂等策略 + +- 每次业务发通知必须携带 `dedupe_key` +- `UNIQUE(user_id, dedupe_key)` 保证重复投递不会生成多条记录 +- `PATCH /read`、`PATCH /seen` 必须幂等,重复调用返回 200 且不重复更新统计 + +#### 4.6.2 重试策略 + +- 可重试错误:超时、5xx、429 +- 不可重试错误:token invalid、payload invalid、认证失败 +- 退避策略:`30s -> 2m -> 10m -> 1h`,最多 4 次 +- 超过上限进入 DLQ,由运维任务定时重放或人工处理 + +#### 4.6.3 失败模式清单 + +| 场景 | 风险 | 方案 | +|------|------|------| +| 推送平台短时不可用 | 批量失败 | Outbox + 指数退避 + DLQ | +| 客户端重复上报 read | 状态抖动/统计偏差 | 幂等更新 + 仅首次写时间戳 | +| 多设备并发 read/seen | 最终状态不一致 | 单条记录原子更新 + `updated_at` 冲突检测 | +| Realtime 断连 | UI 未及时同步 | 前台重连后触发增量拉取(按 `updated_at`) | + +### 4.7 版本通知与活动通知 + +#### 4.7.1 版本通知 + +- 触发条件:用户登录时检测到 App 版本低于最新版本 +- 写入方式:后台任务扫描所有用户,批量写入 `user_notifications` +- `notification.type = 'version'` + +#### 4.7.2 活动通知 + +- 触发条件:运营后台或定时任务触发 +- 目标用户:可按标签/行为筛选 +- `notification.type = 'activity'` + +### 4.8 权限与安全策略 + +#### RLS 设计 + +```sql +-- user_notifications +ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY; + +CREATE POLICY auth_select ON user_notifications FOR SELECT + USING (auth.uid() = user_id AND deleted_at IS NULL); + +CREATE POLICY auth_update ON user_notifications FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- user_push_devices +ALTER TABLE user_push_devices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY auth_all ON user_push_devices FOR ALL + USING (auth.uid() = user_id AND deleted_at IS NULL) + WITH CHECK (auth.uid() = user_id); +``` + +#### 信任边界 + +- 客户端不能创建通知主记录,通知内容仅由服务端写入 +- 客户端只能操作自己的 `seen/read/open` 回执和设备 token +- `owner_id/user_id` 必须来自 JWT `sub`,禁止客户端传入 +- 管理后台发通知接口必须走服务角色鉴权,不暴露给普通用户 token + +### 4.9 可观测性与运行指标 + +| 指标 | 目标/SLO | 备注 | +|------|----------|------| +| 通知写入成功率 | >= 99.9% | 5 分钟窗口 | +| 推送发送成功率(sent) | >= 99.5% | 按 provider 分维度 | +| 推送 provider_ack 延迟 P95 | < 3s | 仅平台确认链路 | +| 前台实时同步延迟 P95 | < 1s | Broadcast 收包到 UI 更新 | +| read API P95 | < 150ms | 排除网络抖动 | + +日志要求:每次通知发送链路打通 `trace_id`(创建 -> outbox -> provider -> 回执)。 + +### 4.10 NOT in scope(本阶段不做) + +- 厂商通道(小米/华为)深度集成 +- 通知模板多语言管理后台 +- 通知 A/B 实验平台 +- 全量历史归档/冷热分层 + +--- + +## 5. 分阶段落地计划 + +### 第一阶段:最小可用通知中心(MVP 收敛) + +**目标**:在 App 内显示通知列表,支持标记已读 + +**改动范围**: +- Backend: `notifications` 表 + API +- Flutter: 通知中心页面 + Repository + +**主要任务**: +1. 创建 `notifications` 和 `user_notifications` 表 +2. 实现 `GET /api/v1/notifications` API +3. 实现 `PATCH /api/v1/notifications/{id}/read` API +4. Flutter 通知中心页面(列表 + 标记已读) +5. 未读数 Badge(在 HomeScreen 通知图标上显示红点) +6. 完成协议文档:`docs/protocols/notification/notification-protocol.md` +7. 完成基础测试(见第 7 节) + +**依赖项**:无 + +**风险点**: +- 无推送,通知需要后台手动写入 DB +- 不支持实时同步,多设备体验差 + +**验收标准**: +- [ ] 能看到通知列表 +- [ ] 点击通知能标记为已读 +- [ ] 未读通知有 Badge 提示 +- [ ] 已读/未读状态在下拉刷新后正确 +- [ ] 越权访问被拒绝(用户 A 无法读写用户 B 通知) +- [ ] read 接口重复调用幂等 + +--- + +### 第二阶段:接入系统推送 + +**目标**:支持离线推送通知 + +**改动范围**: +- Backend: `user_push_devices` 表 + Push Service +- Flutter: 推送接收 + 设备注册 + +**主要任务**: +1. 创建 `user_push_devices` 表 +2. 实现 `POST /api/v1/push/devices` API(注册 token) +3. 集成 firebase_messaging(Android)/ apns(iOS) +4. 实现 Push Sending Service(APNs/FCM SDK)+ Outbox Worker +5. Flutter 端:获取 FCM/APNs token 并注册 +6. 服务端发送后更新 `push_state`,客户端仅上报 open/read/seen 回执 +7. 落地重试与 DLQ + +**依赖项**: +- 第一阶段完成 +- Firebase 项目配置(Android) +- APNs 证书/Key(iOS) + +**风险点**: +- APNs/FCM 配置复杂 +- 推送送达率不稳定(特别是国内 Android) + +**验收标准**: +- [ ] 离线设备能收到推送 +- [ ] 点击推送能打开对应通知详情 +- [ ] 推送状态(sent/provider_ack/failed)正确回写 +- [ ] provider 短时故障时消息可自动重试并可追踪 + +--- + +### 第三阶段:实时同步与统计 + +**目标**:多设备实时同步 + 完整状态追踪 + +**改动范围**: +- Backend: Supabase Realtime + 状态统计 +- Flutter: Realtime 订阅 + 曝光追踪 + +**主要任务**: +1. Supabase Realtime Broadcast 私有频道接入 +2. 实现 `seen` 状态(曝光追踪) +3. 添加统计接口(送达率、点击率) +4. Flutter 端:列表滚动时标记 `seen` +5. 版本通知、活动通知的后台写入逻辑 +6. 断线重连后的增量拉取机制(按 `updated_at`) + +**依赖项**: +- 第二阶段完成 +- Supabase Realtime 已启用 + +**风险点**: +- Realtime 连接数限制(根据 plan) +- 曝光追踪可能影响性能 + +**验收标准**: +- [ ] 一设备读取通知,另一设备实时更新 +- [ ] 列表曝光能正确标记 `seen` +- [ ] 能查看送达/点击统计 +- [ ] Realtime 断连恢复后不丢状态更新 + +--- + +## 6. 建议改动清单 + +### 6.1 新增/修改的表 + +| 表名 | 操作 | 说明 | +|------|------|------| +| `notifications` | 新增 | 通知模板表 | +| `user_notifications` | 新增 | 用户通知记录+状态 | +| `user_push_devices` | 新增 | 设备 token 存储 | +| `notification_push_attempts` | 新增 | 推送尝试日志 | +| `profiles` | 修改 | 暂不改,优先在线计算 unread_count | + +### 6.2 新增后端模块 + +| 路径 | 说明 | +|------|------| +| `backend/src/v1/notifications/` | 通知路由+服务+仓库 | +| `backend/src/v1/push/` | 推送设备路由+服务 | +| `backend/src/services/push/` | APNs/FCM 发送服务 | +| `backend/src/services/push/outbox_worker.py` | 推送 outbox 消费与重试 | +| `backend/src/models/notification.py` | 通知 ORM 模型 | +| `backend/src/models/notification_push_attempt.py` | 推送尝试日志模型 | +| `backend/src/schemas/notification.py` | Pydantic schemas | + +### 6.3 新增 Flutter 模块 + +| 路径 | 说明 | +|------|------| +| `apps/lib/core/notification/` | 通知核心逻辑 | +| `apps/lib/features/notifications/` | 通知功能模块 | +| `apps/lib/shared/widgets/notification/` | 通知 UI 组件 | + +### 6.4 新增接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/notifications` | 通知列表 | +| GET | `/api/v1/notifications/unread-count` | 未读数 | +| PATCH | `/api/v1/notifications/{id}/seen` | 标记已看 | +| PATCH | `/api/v1/notifications/{id}/read` | 标记已读 | +| PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | +| DELETE | `/api/v1/notifications/{id}` | 删除通知 | +| POST | `/api/v1/notifications/{id}/opened` | 上报从推送打开 | +| POST | `/api/v1/push/devices` | 注册设备 | +| DELETE | `/api/v1/push/devices/{id}` | 删除设备 | + +### 6.5 新增配置项 + +```env +# .env +ERYAO_PUSH__APNS_KEY_ID=xxx +ERYAO_PUSH__APNS_TEAM_ID=xxx +ERYAO_PUSH__APNS_KEY_PATH=/path/to/key.p8 +ERYAO_PUSH__FCM_SERVER_KEY=xxx +ERYAO_PUSH__ENABLED=true +``` + +### 6.6 Flutter 依赖(pubspec.yaml) + +```yaml +dependencies: + firebase_messaging: ^15.0.0 # FCM + flutter_local_notifications: ^18.0.0 # 本地通知 + supabase_flutter: ^2.5.0 # Supabase 客户端(含 Realtime) +``` + +### 6.7 协议文档(先于实现) + +```text +docs/protocols/notification/ +├── notification-protocol.md # 数据模型、字段语义、兼容策略 +├── notification-api-protocol.md # API 请求/响应、错误码 +└── notification-realtime-protocol.md # Broadcast payload 与版本约束 +``` + +--- + +## 7. 测试策略(必须随阶段落地) + +### 7.1 测试框架 + +- 后端:`pytest` + `pytest-asyncio`(见 `pyproject.toml`) +- Flutter:单测 + Widget 测试 + 关键链路集成测试 + +### 7.2 覆盖图(关键分支) + +```text +Create Notification + -> validate payload + -> invalid type [unit] + -> expires_at in past [unit] + -> fanout target users + -> empty target set [unit] + -> partial user write failed [integration] + -> write user_notifications (dedupe) + -> first write success [unit] + -> duplicate dedupe_key [unit] + -> enqueue outbox + -> enqueue success [integration] + -> enqueue failed rollback [integration] + +Push Worker + -> send provider + -> sent/provider_ack [integration] + -> timeout retry [integration] + -> permanent failure to DLQ [integration] + +User Callback + -> PATCH seen/read/opened + -> own record success [api] + -> cross-user forbidden [api/security] + -> repeat call idempotent [api] + +Realtime + -> broadcast receive update [integration] + -> reconnect + incremental pull [e2e] +``` + +### 7.3 最低覆盖要求 + +- 新增后端核心分支(通知创建、状态更新、重试)语句覆盖率 >= 90% +- 安全相关路径(越权、伪造 user_id、非法 token)必须 100% 有测试 +- 每个阶段必须包含至少 1 条回归测试,防止旧行为被破坏 + +### 7.4 回归测试强制项 + +1. 同一通知重复发送不应生成重复 `user_notifications` +2. `mark-all-read` 与单条 `read` 并发时,最终状态一致 +3. Realtime 中断后恢复,未读数与服务端一致 +4. 无效/过期 token 不应导致消息丢失(进入失败态并可重试) + +--- + +## 8. 最终推荐 + +### 8.1 推荐总体方案 + +**采用 Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)的混合方案** + +### 8.2 推荐原因 + +1. **用户体验最优**:前台实时同步,多设备状态一致 +2. **离线可达**:通过 APNs/FCM 触达离线用户 +3. **扩展性更稳**:Broadcast 规避高并发下 `postgres_changes` 的权限扫描放大 +4. **状态语义更准确**:`provider_ack` 与用户可见状态分离,统计更可信 +5. **项目适配**:Supabase 已在用,Realtime 只是扩展使用 + +### 8.3 不确定点 + +| 问题 | 推断依据 | +|------|----------| +| 是否已有 Firebase 项目 | 未在代码库中找到 `google-services.json` 或 Firebase 配置 | +| 是否已有 APNs 证书 | 未在代码库中找到证书文件 | +| 国内 Android 推送需求 | 国内 Android ROM 需要厂商通道(如小米、华为),建议预留 | + +### 8.4 实施优先级排序 + +| 优先级 | 阶段 | 说明 | +|--------|------|------| +| **P0** | 第一阶段 | MVP:通知列表 + 标记已读 | +| **P1** | 第一阶段 | 未读数 Badge | +| **P2** | 第二阶段 | 推送接入(Android FCM 先,iOS APNs 后) | +| **P3** | 第三阶段 | Realtime 同步 | +| **P4** | 第三阶段 | 曝光追踪 + 统计 | + +### 8.5 关键约束提醒 + +1. **遵循 AGENTS.md**:通知代码放在 `core/notification/` 和 `shared/widgets/notification/` +2. **Error Swallowing**:所有异常必须传播,禁止静默捕获 +3. **Protocol 先行**:新建通知相关协议文档在 `docs/protocols/notification/` +4. **渐进演进**:不废弃现有 `NotificationSettings`,而是扩展它 + +--- + +## 附录:推断依据 + +| 信息点 | 推断依据 | +|--------|----------| +| 无通知表 | 所有 migration 文件中均未发现 `notifications` 相关表 | +| 无推送集成 | `pubspec.yaml` 无 firebase_messaging / apns 相关依赖 | +| 无 Realtime 通知 | Supabase Service 仅用于 Storage,未配置 Realtime | +| AGENTS.md 约束 | `apps/AGENTS.md` 明确提到 "Reminder/Notification Rewrite Boundary" | +| 推送服务配置 | `settings.py` 无任何 APNs/FCM 相关配置 | diff --git a/docs/plans/notification.md b/docs/plans/notification.md deleted file mode 100644 index 865cc5e..0000000 --- a/docs/plans/notification.md +++ /dev/null @@ -1,131 +0,0 @@ -你是一个资深系统设计与代码分析助手。你的任务不是立刻编写代码,而是先深入理解当前项目中与“通知系统”相关的现有实现,然后基于现有代码结构输出一份可靠、可落地的实现方案。 - -本次任务聚焦于 App 通知系统设计,重点包括但不限于: -- 通知中心(notification list / inbox) -- 用户通知存储 -- 已读 / 已看状态 -- 推送触达状态 -- 前台实时通知 -- 后台/离线系统推送 -- 新版本通知、活动通知等业务通知类型 -- Flutter 客户端、后端服务、Supabase 之间的协作方式 - -你的首要目标是“理解现状并制定方案”,而不是直接进入编码。 - -请严格遵循以下工作方式: - -1. 先理解现有代码,再做设计 -- 主动查找项目中与通知相关的现有代码、模块、接口、表结构、状态管理、服务封装和配置文件。 -- 特别关注以下内容: - - Flutter 端是否已有本地通知、消息中心、badge、页面入口、深链跳转 - - 后端是否已有 notification / message / event / push / reminder 等相关模型、接口或 service - - Supabase 中是否已有相关表、RLS、Realtime、设备 token 存储 - - 是否已有 APNs / FCM / flutter_local_notifications / Firebase Messaging / Supabase Realtime 等集成痕迹 - - 是否已有版本检查机制、活动通知机制、用户收件箱机制 -- 不要假设项目是空白的。必须优先复用现有架构与已有能力。 - -2. 基于现有架构设计,而不是脱离项目另起炉灶 -- 方案必须尽量贴合当前项目的技术栈、目录结构、分层方式、命名风格和已有约束。 -- 优先考虑如何在现有模块上扩展,而不是重新设计一整套无关架构。 -- 如果当前实现存在明显缺陷或冲突,可以指出问题,但仍要给出“在现有基础上渐进演进”的方案。 - -3. 明确区分几个概念 -你在分析和设计时,必须区分以下概念,避免混淆: -- 应用内通知记录 -- 系统推送通知 -- 前台实时同步 -- 本地通知 -- 已看状态 -- 已读状态 -- 推送是否成功 -- 用户是否真正查看 - -4. 方案输出要覆盖的核心问题 -在最终方案中,至少要回答以下问题: -- 现有代码里已经有什么,缺什么 -- 通知数据应该如何建模 -- 是否需要 notifications / notification_receipts / user_push_devices 等表 -- 已读、已看、点击、删除等状态如何设计 -- Flutter 端如何读取通知列表、显示未读数、更新已读状态 -- Supabase Realtime 在这个项目里适合承担什么职责 -- APNs / FCM 或其他推送通道应该如何接入 -- 后端应该如何组织通知写入、fanout、推送发送、状态回写 -- 新版本通知与活动通知如何落地 -- 如何保证权限安全,例如 RLS、用户只能访问自己的通知 -- 如何分阶段实施,避免一次性改动过大 - -5. 输出必须先分析,后给建议 -不要一上来直接写“建议这样做”。 -你必须先给出: -- 当前代码现状梳理 -- 已有能力 -- 缺失点 -- 架构约束 -然后再给出推荐方案。 - -6. 不直接修改代码 -- 本轮目标是产出实现方案,而不是直接提交代码。 -- 除非我明确要求,否则不要直接创建文件、修改代码或生成迁移。 -- 可以提出建议的文件改动点,但不要直接实现。 - -请按以下结构输出: - -# 1. 需求理解 -- 这次通知系统要解决的核心问题 -- 涉及的通知类型 -- 系统边界(Flutter / Backend / Supabase / Push Provider) - -# 2. 现有代码调研结果 -- 已发现的相关模块 -- 已有能力 -- 可复用部分 -- 当前缺口 -- 潜在冲突或风险 - -# 3. 当前架构判断 -- 当前项目更适合采用什么通知架构 -- 为什么 -- 哪些方案不适合当前项目 - -# 4. 推荐实现方案 -至少包括: -- 数据模型设计 -- 状态字段设计 -- 客户端交互流程 -- 服务端处理流程 -- 实时通知与系统推送的职责划分 -- 已读/已看/触达状态方案 -- 版本通知与活动通知方案 -- 权限与安全策略 - -# 5. 分阶段落地计划 -请拆分为多个阶段,例如: -- 第一阶段:最小可用通知中心 -- 第二阶段:接入系统推送 -- 第三阶段:完善版本通知/活动通知/统计能力 -每个阶段说明: -- 目标 -- 改动范围 -- 主要任务 -- 依赖项 -- 风险点 -- 验收标准 - -# 6. 建议改动清单 -- 建议新增或修改的表 -- 建议新增或修改的后端模块 -- 建议新增或修改的 Flutter 模块 -- 建议新增的接口 / RPC / service -- 建议新增的配置项 - -# 7. 最终推荐 -- 推荐采用的总体方案 -- 推荐原因 -- 不确定点 -- 实施优先级排序 - -额外要求: -- 如果代码库中已经存在通知、提醒、消息、推送等相近实现,优先尝试整合,而不是重复建设。 -- 如果某些信息无法从当前代码中确认,要明确写出“不确定项”和“推断依据”。 -- 方案必须可执行、可渐进落地,避免空泛。 -- 优先给出最贴合当前代码库的设计,不要输出与项目现状脱节的理想化架构。 diff --git a/docs/plans/points-audit-and-register-bonus-plan.md b/docs/plans/points-audit-and-register-bonus-plan.md deleted file mode 100644 index 0f8f2ca..0000000 --- a/docs/plans/points-audit-and-register-bonus-plan.md +++ /dev/null @@ -1,419 +0,0 @@ -# 积分审计与注册赠分策略改造计划(gstack / plan-eng-review) - -## 1. 目标与结论 - -本计划解决三个问题: - -1. 用户删除账号后,积分与成本审计数据不能随业务数据一起丢失。 -2. 同邮箱重复注册时,不应再次拿到注册赠分。 -3. 积分消耗审计必须记录真实 `input_tokens` / `output_tokens` / `cost`,不能再写占位值。 -4. LLM 失败/取消时若平台已产生真实成本,该成本不转嫁用户积分,但必须进入审计账本。 - -结论:采用 **双账本 + 资格账本**。 - -- 保留业务账本:`user_points`、`points_ledger`(在线业务能力) -- 新增审计账本:`points_audit_ledger`(不可变审计) -- 新增资格账本:`register_bonus_claims`(注册奖励去重) -- 注册赠分策略从 DB trigger 移出,改为应用层策略(配置驱动) - ---- - -## 2. 系统边界 - -### 2.1 业务域(可删除) - -- `user_points`:余额视图 -- `points_ledger`:业务流水 -- `messages` / `sessions`:会话与消息 - -### 2.2 审计域(不可级联删除) - -- `points_audit_ledger`:审计流水,保留用户快照和成本快照(含用户承担/平台承担归属) -- `register_bonus_claims`:注册奖励领取资格记录 - -### 2.3 策略域(应用层) - -- `register_bonus_points` 配置项(默认 60) -- `register_bonus_hmac_key` 配置项(环境变量注入) -- 首登赠分是否发放由服务层决定,不写死在数据库 trigger - ---- - -## 3. 现状问题(基于当前代码) - -1. 注册赠分写死在 DB trigger。 - 当前函数:`public.initialize_profile_and_invite_code_on_signup()`,历史上出现过 100/60 改动漂移。 - -2. 积分消费审计写占位值。 - 在 `backend/src/v1/points/service.py` 中,`consume_successful_run_points` 写入 `input_tokens=0`、`output_tokens=0`、`cost=0`。 - -3. 删号会丢审计线索。 - 当前业务删除路径会清理业务数据,缺少独立审计账本保留策略。 - ---- - -## 4. 数据模型(按项目风格精简命名) - -说明:不引入陌生“模板字段”,沿用当前 `points_ledger` 命名风格。 - -### 4.1 新表:`points_audit_ledger` - -- `id` UUID PK -- `event_id` VARCHAR(64) UNIQUE NOT NULL -- `user_id_snapshot` UUID NULL -- `user_email_snapshot` TEXT NULL -- `change_type` VARCHAR(16) NOT NULL -- `biz_type` VARCHAR(16) NULL -- `biz_id` UUID NULL -- `direction` SMALLINT NOT NULL -- `amount` BIGINT NOT NULL -- `balance_after` BIGINT NOT NULL -- `billed_to` VARCHAR(16) NOT NULL -- `user` | `platform` -- `run_id` VARCHAR(128) NULL -- `request_id` VARCHAR(128) NULL -- `input_tokens` INTEGER NOT NULL DEFAULT 0 -- `output_tokens` INTEGER NOT NULL DEFAULT 0 -- `cost` NUMERIC(12,6) NOT NULL DEFAULT 0 -- `metadata` JSONB NOT NULL DEFAULT '{}' -- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now() -- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now() - -索引建议: - -- `uq_points_audit_ledger_event_id` -- `ix_points_audit_ledger_user_id_created_at` (`user_id_snapshot`, `created_at DESC`) -- `ix_points_audit_ledger_change_type_created_at` (`change_type`, `created_at DESC`) - -### 4.2 新表:`register_bonus_claims` - -- `id` UUID PK -- `email_hash` VARCHAR(64) UNIQUE NOT NULL -- `user_email_snapshot` TEXT NOT NULL -- `first_user_id` UUID NULL -- `grant_event_id` VARCHAR(64) UNIQUE NOT NULL -- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now() -- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now() - -注:`email_hash` 由标准化邮箱(trim + lower)计算(HMAC-SHA256,key 来自 `register_bonus_hmac_key`)。 - ---- - -## 5. 数据流设计 - -### 5.1 注册赠分流程(应用层,非 trigger) - -```text -[用户首登/注册完成] - -> PointsPolicyService.load(register_bonus_points) - -> normalize(email) -> email_hash - -> INSERT register_bonus_claims(email_hash, ...) - - 成功: 继续发放积分 - - 唯一冲突: 说明历史已领取,跳过发放 - -> 更新 user_points - -> 写 points_ledger - -> 写 points_audit_ledger - -> commit -``` - -### 5.2 运行消耗积分流程(写真实成本) - -```text -[run completed] - -> 从持久化消息/会话聚合真实 usage - (input_tokens, output_tokens, cost) - -> PointsService.consume_successful_run_points(...) - -> 更新 user_points - -> 写 points_ledger - -> 写 points_audit_ledger(真实 usage) - -> commit -``` - -### 5.3 运行失败/取消但平台发生成本流程(不扣用户,记平台账) - -```text -[run failed/canceled] - -> 从持久化消息/事件聚合真实 usage - -> 若 cost > 0: - - 不调用用户扣分 - - 写 points_audit_ledger( - direction=0, - amount=0, - billed_to='platform', - input_tokens/output_tokens/cost=真实值, - metadata.reason='run_failed_or_canceled_platform_billed' - ) - -> commit -``` - -### 5.4 删除账号流程 - -```text -[delete account] - -> 删除 user_points / points_ledger / sessions / messages / profile / auth - -> 保留 points_audit_ledger / register_bonus_claims -``` - ---- - -## 6. 失败模式与处理 - -### 6.1 双写不一致(P0) - -- 场景:`points_ledger` 写成功,`points_audit_ledger` 写失败。 -- 策略:同事务写入,任一失败全部回滚。 - -### 6.2 并发重复注册(P0) - -- 场景:同邮箱并发首登,发放多次。 -- 策略:`register_bonus_claims.email_hash` 唯一约束 + 冲突即跳过。 - -### 6.3 邮箱规范化不一致(P1) - -- 场景:`User@A.com` 与 `user@a.com` 被当成不同人。 -- 策略:统一 normalize(trim + lower)后再 hash。 - -### 6.4 成本快照缺失(P1) - -- 场景:run 成功但 usage 聚合取不到,写入 0。 -- 策略: - - 业务是否扣分与成本写入解耦:允许扣分,但审计需标记 `metadata.usage_missing=true` - - 记录 warning 日志并纳入告警指标 - -### 6.5 失败/取消真实成本归属(P0) - -- 场景:LLM 回调失败或用户取消,但上游已计费。 -- 策略: - - 不扣用户积分(`user_points`、`points_ledger`不变) - - 审计账本强制落一条平台承担记录(`billed_to='platform'`) - - 该记录必须包含真实 `input_tokens` / `output_tokens` / `cost` - ---- - -## 7. 信任边界与安全 - -1. `user_email_snapshot` 必须来自服务端认证上下文,不接受客户端传入。 -2. `input_tokens/output_tokens/cost` 必须来自服务端持久化记录,不接受客户端上报。 -3. 审计表只允许后端 service-role 写入,不暴露客户端写接口。 -4. `register_bonus_claims` 不应被普通业务接口更新/删除。 -5. `register_bonus_hmac_key` 仅后端可读,不下发客户端,不写日志。 - ---- - -## 8. 实施步骤(最小改动优先) - -### Phase 1: 协议与配置 - -- 更新协议文档: - - `docs/protocols/common/user-points-chat-data-protocol.md` - - 新增“审计留存与注册奖励策略”章节 -- 新增配置:`register_bonus_points`(默认 60) - -### Phase 2: 数据库迁移 - -- 新增表:`points_audit_ledger` -- 新增表:`register_bonus_claims` -- 不改现有 `points_ledger`、`user_points` 结构 - -### Phase 3: 服务层改造 - -- 移除 trigger 中注册送分逻辑(trigger 只保留 profile/invite 初始化) -- 在应用层增加注册奖励发放逻辑(带资格检查) -- 在积分消费路径改造为真实 usage 写审计 -- 在失败/取消路径增加平台承担成本审计(不扣用户) - -### Phase 4: 删除链路校验 - -- 删除账号后验证业务表清理 -- 验证审计表与资格表仍可查 - ---- - -## 9. 测试覆盖计划 - -### 9.0 P0 测试门槛(实现前锁定) - -以下测试为上线前阻断项,任一缺失不得合并: - -1. **幂等回放**:同一 `event_id` 重放不重复写 `points_audit_ledger`。 -2. **注册送分去重**:同邮箱(normalize 后)重复注册不重复发放积分。 -3. **事务一致性**:业务账本写入成功但审计写入失败时,整体回滚。 -4. **删除后重注册**:删号后同邮箱重注册仍不再发放首登奖励。 -5. **失败/取消审计**:run 失败与取消场景写审计但不扣积分。 -6. **成本归属**:失败/取消且 `cost>0` 的记录必须为 `billed_to='platform'`。 - -### 9.1 单元测试 - -- 邮箱 normalize/hash 一致性 -- 注册奖励配置读取与默认值 -- usage 聚合函数(含空值和异常值) - -### 9.2 集成测试 - -- 首次注册发放奖励成功 -- 同邮箱重复注册不再发放 -- 并发注册仅一次成功发放 -- 消费积分写入真实 tokens/cost 审计 -- 失败/取消且平台发生成本时,写平台承担审计且不扣用户积分 -- 删号后审计数据保留 - -### 9.3 回归测试 - -- 现有积分余额查询和扣分逻辑不回归 -- 邀请码流程不回归 - ---- - -## 10. 文件级改造清单 - -### 数据库 / 模型 - -- `backend/alembic/versions/*` 新增迁移:创建两张新表 -- `backend/src/models/points_audit_ledger.py` 新增 -- `backend/src/models/register_bonus_claims.py` 新增 - -### 积分服务与仓储 - -- `backend/src/v1/points/repository.py`:新增审计写入、资格检查方法 -- `backend/src/v1/points/service.py`: - - 新增注册奖励发放入口(配置驱动) - - 消费路径写真实 usage 审计 - - 失败/取消路径写平台承担成本审计 - -### 运行时调用链 - -- `backend/src/core/agentscope/runtime/tasks.py`: - - 在扣分点传入真实 usage(或可计算上下文) - - 在 run 异常/取消路径传入 usage 并落平台承担审计 - -### 协议文档 - -- `docs/protocols/common/user-points-chat-data-protocol.md` 更新 - ---- - -## 11. 取舍说明 - -### 为什么不直接改 `points_ledger` 为审计表 - -- 会把在线业务与审计诉求耦合在一张表,后续权限和迁移风险高。 -- 当前最小改动方案是新增审计表,保持业务链路稳定。 - -### 为什么保留 `event_id` - -- `id` 是技术主键,只保证行唯一。 -- `event_id` 是业务幂等键,防重放、防重试重复记账、支持跨表对账。 - ---- - -## 12. 未决事项 - -1. `user_email_snapshot` 是否明文存储,还是仅内部可解密存储。 -2. 审计数据保留时长(默认建议至少 1 年)。 -3. 成本单位与精度是否统一沿用 `NUMERIC(12,6)`。 - ---- - -## 13. PR 拆分与执行顺序(可直接实现) - -### PR1:数据库与协议落地(不改业务行为) - -目标:先建立新数据边界,不改变线上积分逻辑。 - -改动范围: - -- `backend/alembic/versions/*`:新增迁移,创建 `points_audit_ledger`、`register_bonus_claims` -- `backend/src/models/points_audit_ledger.py`:新增模型 -- `backend/src/models/register_bonus_claims.py`:新增模型 -- `docs/protocols/common/user-points-chat-data-protocol.md`:补审计与注册送分策略契约 - -验收标准: - -- 迁移可执行、可回滚 -- 新表索引与唯一约束生效 -- 协议文档与表结构一致 - -测试要求: - -- 迁移 smoke test -- 约束与索引存在性校验 - -### PR2:注册送分策略迁移到应用层(去 trigger 固化) - -目标:把注册送分从 DB trigger 移到应用层唯一触发点(注册回调)。 - -改动范围: - -- `backend/src/v1/points/service.py`:新增注册奖励发放入口与资格校验 -- `backend/src/v1/points/repository.py`:新增 `register_bonus_claims` 检查/写入 -- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_points` -- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_hmac_key` -- 相关注册回调调用链文件:接入 `grant_register_bonus_if_eligible(...)` -- 迁移调整:更新 trigger,移除注册送分写入逻辑,仅保留 profile/invite 初始化 - -验收标准: - -- 新注册触发一次赠分 -- 同邮箱重复注册不再赠分 -- 配置变更可控制赠分值(默认 60) -- 邮箱哈希稳定且不可逆(同邮箱同哈希,不暴露明文) - -测试要求: - -- 并发注册去重测试(唯一约束 + 冲突路径) -- 删除账号后同邮箱重注册不赠分 -- event_id 幂等回放不重复发放 -- 缺失 `register_bonus_hmac_key` 时服务启动失败(fail fast) - -### PR3:真实成本审计与删除链路联调 - -目标:将 run 真实 usage 写入审计,并覆盖成功/失败/取消三种对话轮次;失败/取消场景发生真实成本时记平台承担。 - -改动范围: - -- `backend/src/v1/points/service.py`:消费路径审计写入(真实 tokens/cost) -- `backend/src/v1/points/repository.py`:新增 `append_audit_ledger(...)` -- `backend/src/core/agentscope/runtime/tasks.py`:传递该轮次必要上下文 -- 账号删除服务链路:确认保留 `points_audit_ledger/register_bonus_claims` - -验收标准: - -- 成功对话:扣分 + 审计 -- 失败/取消对话:不扣分 + 审计(若有成本则 `billed_to='platform'`) -- 审计中的 `input_tokens/output_tokens/cost` 为真实值,不再占位 0 - -测试要求: - -- 成功/失败/取消三路径集成测试 -- 事务一致性测试(业务写成功 + 审计写失败 -> 回滚) -- 删除后审计保留验证 -- 失败/取消 + `cost>0` 平台承担场景回归测试 - -### PR4:观测与运维保障(建议同迭代完成) - -目标:避免审计静默失真。 - -改动范围: - -- 指标与日志: - - `points_audit_write_failed_total` - - `points_usage_missing_total` -- 告警阈值:连续失败或短时突增告警 -- 运维文档:异常重放与人工核对流程 - -验收标准: - -- 审计写入失败可被监控发现 -- usage 缺失可被监控发现并可追溯到事件 - ---- - -## 14. 实施完成定义(DoD) - -满足以下全部条件才算完成: - -1. 计划中的 P0 测试门槛全部通过。 -2. 注册赠分不再依赖 DB trigger 写死值。 -3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消。 -3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消;失败/取消有真实成本时归属 `platform`。 -4. 删除账号后业务数据清理,审计与资格数据保留。 -5. 关键失败有指标与告警,不允许静默失败。 diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index d00796e..797ff75 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -4,15 +4,17 @@ This protocol defines the canonical data contract for user profile, points accou Protocol verification status: -- Last audited migration: `backend/alembic/versions/20260403_0004_remove_points_reason_code.py` -- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` -- Current status: aligned +- Last audited migration: `backend/alembic/versions/20260410_0005_add_points_audit_and_register_bonus_claims.py` +- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` +- Current status: partially aligned (register bonus still runs in DB trigger, audit ledger tables are additive and ready) ## Scope - `profiles` - `user_points` - `points_ledger` +- `points_audit_ledger` +- `register_bonus_claims` - `sessions` - `messages` @@ -29,7 +31,8 @@ Protocol verification status: - Failure behavior: failed/canceled runs do not deduct points. - Precheck: before accepting a run, backend must verify `available = balance - frozen_balance >= 20`. - Session follow-up cap: one session allows at most 2 user runs total (initial divination + 1 follow-up). -- Billing idempotency key for per-run consume: `chat.run.success:{session_id}:{run_id}`. +- Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`. +- Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`. ## Table contract @@ -74,6 +77,32 @@ Protocol verification status: - `adjust => direction in (1, -1)` - idempotency: `unique (user_id, event_id)` +### points_audit_ledger + +- PK: `id` +- No FK to `auth.users` for `user_id_snapshot` to avoid cascade delete and preserve audit retention +- Core fields: `event_id`, `user_id_snapshot`, `user_email_snapshot`, `change_type`, `biz_type`, `biz_id`, `direction`, `amount`, `balance_after`, `billed_to`, `run_id`, `request_id`, `input_tokens`, `output_tokens`, `cost`, `metadata`, `created_at`, `updated_at` +- Constraints: + - `amount >= 0` + - `direction in (1, 0, -1)` + - `balance_after >= 0` + - `change_type in ('register', 'consume', 'grant', 'adjust')` + - `biz_type is null or biz_type='chat'` + - `billed_to in ('user', 'platform')` + - metadata must be object + - idempotency: `unique (event_id)` + +### register_bonus_claims + +- PK: `id` +- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id`, `grant_event_id`, `created_at`, `updated_at` +- Constraints: + - `email_hash` unique + - `grant_event_id` unique +- Notes: + - `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`) + - key source: backend config `points_policy.register_bonus_hmac_key` + #### points_ledger.metadata (schema_version=1) Canonical shape: @@ -110,18 +139,16 @@ JSON constraints: - `grant`: no extra metadata shape requirement - `adjust`: requires `ext.ticket_id` non-empty -## Signup initialization contract +## Signup initialization contract (current + target) -- Trigger: `auth.users` after insert -- Function: `public.initialize_profile_and_points_on_signup()` -- Side effects: - - create `profiles` row with default settings - - username format: `user_xxxxxx` (`x` = 6 chars from `[a-z0-9]`) - - create `user_points` row with initial `balance=100`, `lifetime_earned=100` - - create `points_ledger` register row: - - `change_type='register'` - - `biz_type=null`, `biz_id=null` - - `amount=100`, `direction=1`, `balance_after=100` +- Current trigger: + - Trigger: `auth.users` after insert + - Function: `public.initialize_profile_and_invite_code_on_signup()` + - Side effects include profile init + invite code init + register points (currently fixed to 60) +- Target migration: + - remove register points grant from DB trigger + - grant register bonus in application service with eligibility ledger `register_bonus_claims` + - keep trigger focused on profile/invite initialization only ### sessions