feat: 添加 points_audit_ledger 及 JSON 字段 Pydantic Schema 约束
This commit is contained in:
@@ -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)
|
||||
+7
-7
@@ -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")
|
||||
+191
-35
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user