Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
153 lines
7.0 KiB
PL/PgSQL
153 lines
7.0 KiB
PL/PgSQL
-- Content moderation hits (2026-09-30, compliance round). Add-only.
|
|
-- One row per blocked or flagged input/output. No user or model text is
|
|
-- stored: only categories, rule ids / provider labels, route, side, model and
|
|
-- request ids. Service role only; operators read it through /admin.
|
|
create table if not exists public.moderation_events (
|
|
id bigint generated always as identity primary key,
|
|
created_at timestamptz not null default now(),
|
|
user_id uuid references auth.users(id) on delete set null,
|
|
side text not null check (side in ('input', 'output')),
|
|
verdict text not null check (verdict in ('block', 'review')),
|
|
route text not null check (char_length(route) between 1 and 64),
|
|
categories text[] not null default '{}',
|
|
rule_ids text[] not null default '{}',
|
|
provider text not null check (provider in ('local', 'aliyun')),
|
|
model_id text,
|
|
request_id text
|
|
);
|
|
|
|
create index if not exists moderation_events_created_idx on public.moderation_events (created_at desc);
|
|
create index if not exists moderation_events_user_idx on public.moderation_events (user_id, created_at desc);
|
|
|
|
alter table public.moderation_events enable row level security;
|
|
revoke all on public.moderation_events from public, anon, authenticated;
|
|
grant select, insert on public.moderation_events to service_role;
|
|
|
|
-- Output moderation (same round): an answer whose text failed moderation is
|
|
-- replaced by a short notice and the reservation is released, exactly like
|
|
-- the small-talk free completion above it in history, but for the notice
|
|
-- (responseKind 'moderated', ≤ 200 chars). Service role only.
|
|
begin;
|
|
create or replace function public.complete_consultation_moderated(
|
|
p_user_id uuid,
|
|
p_request_id text,
|
|
p_session_id uuid,
|
|
p_response_message jsonb,
|
|
p_actual_usage jsonb
|
|
)
|
|
returns table(success boolean, credits integer, error_code text)
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_request public.consultation_requests%rowtype;
|
|
v_res public.usage_reservations%rowtype;
|
|
v_settlement record;
|
|
v_balance integer;
|
|
begin
|
|
if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then
|
|
return query select false, null::integer, 'invalid_request'::text;
|
|
return;
|
|
end if;
|
|
if jsonb_typeof(p_response_message) is distinct from 'object'
|
|
or p_response_message->>'role' is distinct from 'assistant'
|
|
or p_response_message->>'responseKind' is distinct from 'moderated'
|
|
or jsonb_typeof(p_response_message->'text') is distinct from 'string'
|
|
or btrim(coalesce(p_response_message->>'text', '')) = ''
|
|
or length(p_response_message->>'text') > 200
|
|
or (p_response_message - array['role', 'text', 'responseKind']) <> '{}'::jsonb then
|
|
return query select false, null::integer, 'invalid_response_message'::text;
|
|
return;
|
|
end if;
|
|
if jsonb_typeof(p_actual_usage) is distinct from 'object' then
|
|
return query select false, null::integer, 'invalid_actual_usage'::text;
|
|
return;
|
|
end if;
|
|
|
|
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0));
|
|
select request.* into v_request
|
|
from public.consultation_requests as request
|
|
where request.user_id = p_user_id
|
|
and request.request_id = btrim(p_request_id)
|
|
and request.session_id = p_session_id
|
|
for update;
|
|
if not found then
|
|
return query select false, null::integer, 'request_missing'::text;
|
|
return;
|
|
end if;
|
|
if v_request.status = 'cancelled' then
|
|
return query select false, null::integer, 'request_cancelled'::text;
|
|
return;
|
|
end if;
|
|
if v_request.status = 'completed' then
|
|
select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id;
|
|
return query select coalesce(v_request.response_message = p_response_message, false), v_balance,
|
|
case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end;
|
|
return; -- Never refund a previously completed paid consultation.
|
|
end if;
|
|
if v_request.status <> 'reserved' then
|
|
return query select false, null::integer, 'invalid_request_status'::text;
|
|
return;
|
|
end if;
|
|
|
|
select reservation.* into v_res from public.usage_reservations as reservation
|
|
where reservation.user_id = p_user_id and reservation.request_id = btrim(p_request_id)
|
|
for update;
|
|
if not found or v_res.status <> 'reserved' or v_res.feature_key <> 'chat.standard' then
|
|
return query select false, null::integer, 'invalid_reservation'::text;
|
|
return;
|
|
end if;
|
|
select profile.credits into v_balance from public.profiles as profile
|
|
where profile.id = p_user_id for update;
|
|
if not found then
|
|
return query select false, null::integer, 'profile_missing'::text;
|
|
return;
|
|
end if;
|
|
|
|
update public.chat_sessions as session
|
|
set messages = session.messages || jsonb_build_array(p_response_message), updated_at = clock_timestamp()
|
|
where session.id = p_session_id and session.user_id = p_user_id and session.session_type = 'consultation';
|
|
if not found then
|
|
return query select false, null::integer, 'session_missing'::text;
|
|
return;
|
|
end if;
|
|
|
|
-- complete_usage is the sole cost ledger writer; it does not debit credits.
|
|
-- Keep cost even though the reservation is released below (also frees subscription quota).
|
|
select * into v_settlement from public.complete_usage(
|
|
p_user_id, btrim(p_request_id),
|
|
p_actual_usage || jsonb_build_object('metadata', coalesce(p_actual_usage->'metadata', '{}'::jsonb)
|
|
|| jsonb_build_object('responseKind', 'moderated', 'freeCompletion', true))
|
|
);
|
|
if not coalesce(v_settlement.success, false) then
|
|
raise exception 'consultation_moderated_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown');
|
|
end if;
|
|
|
|
-- Same refund amount, balance lock and transaction identity as release_usage.
|
|
-- Only reachable from a reserved request and reserved usage row under the shared lock.
|
|
if v_res.source = 'credits' and v_res.credit_amount > 0 then
|
|
update public.profiles as profile
|
|
set credits = profile.credits + v_res.credit_amount, updated_at = clock_timestamp()
|
|
where profile.id = p_user_id returning profile.credits into v_balance;
|
|
insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id, model)
|
|
values(p_user_id, 'refund', v_res.credit_amount, v_balance, v_res.request_id, v_res.requested_model_id);
|
|
-- No ON CONFLICT: an inconsistent prior refund must roll back everything, never double-credit.
|
|
end if;
|
|
update public.usage_reservations
|
|
set status = 'released', released_at = clock_timestamp(), release_reason = 'consultation_output_moderated'
|
|
where id = v_res.id;
|
|
update public.consultation_requests
|
|
set status = 'completed', response_message = p_response_message, updated_at = clock_timestamp()
|
|
where user_id = p_user_id and request_id = btrim(p_request_id);
|
|
|
|
return query select true, v_balance, null::text;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.complete_consultation_moderated(uuid, text, uuid, jsonb, jsonb)
|
|
from public, anon, authenticated;
|
|
grant execute on function public.complete_consultation_moderated(uuid, text, uuid, jsonb, jsonb)
|
|
to service_role;
|
|
commit;
|