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
+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)
+3 -7
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")
raw_value: Any = metadata.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
+4 -16
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 []
resolved = extract_user_message_attachments(metadata)
signed_attachments: list[dict[str, str]] = []
for attachment in resolved:
@@ -94,20 +89,13 @@ def _convert_user_attachments(
def _extract_worker_agent_output(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
metadata: AgentChatMessageMetadata | None,
) -> dict[str, Any] | None:
"""提取 assistant 消息的结构化 agent_output。"""
if not metadata:
return None
if isinstance(metadata, AgentChatMessageMetadata):
agent_output = metadata.agent_output
else:
agent_output_data = metadata.get("agent_output")
if not agent_output_data:
return None
agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data)
agent_output = metadata.agent_output
if not agent_output:
return None
+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
+251 -10
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
usage_snapshot = await self._repository.get_run_usage_snapshot(
session_id=session_id,
run_id=run_id,
)
usage_missing = usage_snapshot is None
charge_snapshot = usage_snapshot or PointsChargeSnapshot(
message_id=uuid4(),
message_seq=1,
model_code="agent_run",
input_tokens=0,
output_tokens=0,
cost=Decimal("0"),
)
metadata = ConsumeLedgerMetadata(
operator_type=PointsOperatorType.USER,
run_id=run_id,
charge=PointsChargeSnapshot(
message_id=uuid4(),
message_seq=1,
model_code="agent_run",
input_tokens=0,
output_tokens=0,
cost=Decimal("0"),
),
ext={"source": "run_success"},
charge=charge_snapshot,
ext={
"source": "run_success",
"usage_missing": usage_missing,
},
)
command = ApplyPointsChangeCommand(
user_id=user_id,
@@ -150,9 +288,112 @@ class PointsService:
command=command,
balance_after=int(account.balance),
)
await self._repository.append_audit_ledger(
command=AppendAuditLedgerCommand(
event_id=event_id,
user_id_snapshot=user_id,
user_email_snapshot=(user_email or "").strip().lower() or None,
change_type=PointsChangeType.CONSUME,
biz_type=PointsBizType.CHAT,
biz_id=session_id,
direction=-1,
amount=RUN_POINTS_COST,
balance_after=int(account.balance),
billed_to="user",
run_id=run_id,
input_tokens=charge_snapshot.input_tokens,
output_tokens=charge_snapshot.output_tokens,
cost=charge_snapshot.cost,
metadata=AuditLedgerMetadata(
source="run_success",
usage_missing=usage_missing,
),
)
)
return RunChargeResult(
charged=True,
amount=RUN_POINTS_COST,
balance_after=int(account.balance),
event_id=event_id,
)
async def record_failed_run_platform_cost(
self,
*,
user_id: UUID,
session_id: UUID,
run_id: str,
operator_id: UUID | None,
user_email: str | None = None,
failure_kind: Literal["failed", "canceled"],
) -> PlatformCostAuditResult:
event_source = f"{session_id}:{run_id}:{failure_kind}".encode("utf-8")
event_hash = hashlib.sha1(event_source).hexdigest()
event_kind = "fail" if failure_kind == "failed" else "cancel"
event_id = f"chat.run.{event_kind}:{event_hash}"
if await self._repository.has_audit_event(event_id=event_id):
return PlatformCostAuditResult(
audited=False,
event_id=event_id,
cost=Decimal("0"),
)
usage_snapshot = await self._repository.get_run_usage_snapshot(
session_id=session_id,
run_id=run_id,
)
if usage_snapshot is None or usage_snapshot.cost <= Decimal("0"):
return PlatformCostAuditResult(
audited=False,
event_id=event_id,
cost=Decimal("0"),
)
account = await self._repository.get_or_create_user_points_for_update(
user_id=user_id
)
await self._repository.append_audit_ledger(
command=AppendAuditLedgerCommand(
event_id=event_id,
user_id_snapshot=user_id,
user_email_snapshot=(user_email or "").strip().lower() or None,
change_type=PointsChangeType.CONSUME,
biz_type=PointsBizType.CHAT,
biz_id=session_id,
direction=0,
amount=0,
balance_after=int(account.balance),
billed_to="platform",
run_id=run_id,
input_tokens=usage_snapshot.input_tokens,
output_tokens=usage_snapshot.output_tokens,
cost=usage_snapshot.cost,
metadata=AuditLedgerMetadata(
source=f"run_{failure_kind}",
failure_kind=failure_kind,
operator_id=str(operator_id) if operator_id is not None else None,
),
)
)
return PlatformCostAuditResult(
audited=True,
event_id=event_id,
cost=usage_snapshot.cost,
)
@staticmethod
def _normalize_email(email: str) -> str:
return email.strip().lower()
@staticmethod
def _build_register_bonus_email_hash(normalized_email: str) -> str:
key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
if not key:
raise RuntimeError("points_policy.register_bonus_hmac_key is required")
digest = hmac.new(
key=key.encode("utf-8"),
msg=normalized_email.encode("utf-8"),
digestmod=hashlib.sha256,
)
return digest.hexdigest()
+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