feat: 添加 points_audit_ledger 及 JSON 字段 Pydantic Schema 约束

This commit is contained in:
qzl
2026-04-10 12:28:18 +08:00
parent 46513829cd
commit 0ac8b81a66
34 changed files with 2595 additions and 1757 deletions
+6
View File
@@ -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
############
# 敏感词配置
############
+2
View File
@@ -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`.
@@ -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)
@@ -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;
$$;
"""
)
@@ -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;
$$;
"""
)
@@ -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")
@@ -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)")
@@ -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)
@@ -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")
@@ -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")
@@ -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")
@@ -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
+3 -1
View File
@@ -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,
+30 -24
View File
@@ -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):
+17
View File
@@ -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
+4
View File
@@ -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",
]
+96
View File
@@ -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,
)
@@ -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)
+2 -6
View File
@@ -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")
if raw_value is None:
return []
@@ -0,0 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class SessionStateSnapshot(BaseModel):
model_config = ConfigDict(extra="allow")
pass
+37
View File
@@ -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
+2 -14
View File
@@ -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 []
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)
if not agent_output:
return None
+15 -1
View File
@@ -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)
+98 -1
View File
@@ -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
+247 -6
View File
@@ -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
metadata = ConsumeLedgerMetadata(
operator_type=PointsOperatorType.USER,
usage_snapshot = await self._repository.get_run_usage_snapshot(
session_id=session_id,
run_id=run_id,
charge=PointsChargeSnapshot(
)
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"),
),
ext={"source": "run_success"},
)
metadata = ConsumeLedgerMetadata(
operator_type=PointsOperatorType.USER,
run_id=run_id,
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()
+27
View File
@@ -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.
+87
View File
@@ -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()
@@ -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
@@ -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
@@ -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 回调处理能力。
+784
View File
@@ -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_messagingAndroid/ apnsiOS
4. 实现 Push Sending ServiceAPNs/FCM SDK+ Outbox Worker
5. Flutter 端:获取 FCM/APNs token 并注册
6. 服务端发送后更新 `push_state`,客户端仅上报 open/read/seen 回执
7. 落地重试与 DLQ
**依赖项**
- 第一阶段完成
- Firebase 项目配置(Android
- APNs 证书/KeyiOS
**风险点**
- 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 相关配置 |
-131
View File
@@ -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. 最终推荐
- 推荐采用的总体方案
- 推荐原因
- 不确定点
- 实施优先级排序
额外要求:
- 如果代码库中已经存在通知、提醒、消息、推送等相近实现,优先尝试整合,而不是重复建设。
- 如果某些信息无法从当前代码中确认,要明确写出“不确定项”和“推断依据”。
- 方案必须可执行、可渐进落地,避免空泛。
- 优先给出最贴合当前代码库的设计,不要输出与项目现状脱节的理想化架构。
@@ -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-SHA256key 来自 `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` 被当成不同人。
- 策略:统一 normalizetrim + 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. 关键失败有指标与告警,不允许静默失败。
@@ -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)
- Current trigger:
- 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`
- 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