Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
299 lines
12 KiB
PL/PgSQL
299 lines
12 KiB
PL/PgSQL
-- In-app feedback / complaints and persisted reply ratings
|
|
-- (docs/tasks/PROGRESS-feedback-complaints-20260930.md, compliance round 2026-09-30).
|
|
--
|
|
-- user_feedback: one row per submission from the account menu's 「反馈与投诉」.
|
|
-- Holds the user's own words, an optional contact, and at most a session id
|
|
-- when the user ticks 「附上当前对话」 — never message text. Admins handle rows
|
|
-- through permission-checked functions; there is no direct table privilege.
|
|
-- reply_ratings: the 👍 / 👎 on one assistant reply (「回复评价」 in CONTEXT.md),
|
|
-- keyed by session + message position, with a short hash of the reply text
|
|
-- so a rating is not carried over onto a regenerated answer.
|
|
--
|
|
-- Access:
|
|
-- * both tables enable RLS and grant no table privilege to any runtime role;
|
|
-- * the web server writes/reads through SECURITY DEFINER functions executable
|
|
-- by service_role only; each checks that the session belongs to the user;
|
|
-- * the admin console reads and updates feedback through SECURITY DEFINER
|
|
-- functions executable by admin_runtime, gated by new permissions
|
|
-- support.feedback.read / support.feedback.write, and every update writes
|
|
-- audit.admin_audit_logs.
|
|
-- Rows cascade with the account (auth.users) and with the session.
|
|
--
|
|
-- Backward compatibility: additive only (two tables, functions, permission
|
|
-- rows and role grants). Nothing existing changes.
|
|
|
|
begin;
|
|
|
|
create table if not exists public.user_feedback (
|
|
id uuid primary key default gen_random_uuid(),
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
feedback_type text not null
|
|
check (feedback_type in ('problem', 'content_report', 'refund', 'other')),
|
|
body text not null check (char_length(btrim(body)) between 10 and 2000),
|
|
contact text check (contact is null or char_length(contact) between 1 and 200),
|
|
session_id uuid references public.chat_sessions(id) on delete set null,
|
|
status text not null default 'new'
|
|
check (status in ('new', 'in_progress', 'resolved', 'rejected')),
|
|
admin_note text check (admin_note is null or char_length(admin_note) <= 2000),
|
|
handled_by uuid,
|
|
created_at timestamptz not null default clock_timestamp(),
|
|
updated_at timestamptz not null default clock_timestamp()
|
|
);
|
|
|
|
create index if not exists user_feedback_user_created_idx
|
|
on public.user_feedback (user_id, created_at desc);
|
|
create index if not exists user_feedback_status_created_idx
|
|
on public.user_feedback (status, created_at desc);
|
|
|
|
create table if not exists public.reply_ratings (
|
|
user_id uuid not null references auth.users(id) on delete cascade,
|
|
session_id uuid not null references public.chat_sessions(id) on delete cascade,
|
|
message_index integer not null check (message_index between 0 and 100000),
|
|
rating text not null check (rating in ('up', 'down')),
|
|
answer_sha256 text not null check (answer_sha256 ~ '^[a-f0-9]{16,64}$'),
|
|
created_at timestamptz not null default clock_timestamp(),
|
|
updated_at timestamptz not null default clock_timestamp(),
|
|
primary key (user_id, session_id, message_index)
|
|
);
|
|
|
|
alter table public.user_feedback enable row level security;
|
|
alter table public.reply_ratings enable row level security;
|
|
|
|
revoke all on table public.user_feedback from public, anon, authenticated, service_role;
|
|
revoke all on table public.reply_ratings from public, anon, authenticated, service_role;
|
|
|
|
do $$
|
|
begin
|
|
if exists (select 1 from pg_roles where rolname = 'app_runtime') then
|
|
revoke all on table public.user_feedback from app_runtime;
|
|
revoke all on table public.reply_ratings from app_runtime;
|
|
end if;
|
|
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
|
|
revoke all on table public.user_feedback from admin_runtime;
|
|
revoke all on table public.reply_ratings from admin_runtime;
|
|
end if;
|
|
end;
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- User side (service_role only)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
create or replace function public.submit_user_feedback(
|
|
p_user_id uuid,
|
|
p_type text,
|
|
p_body text,
|
|
p_contact text,
|
|
p_session_id uuid
|
|
)
|
|
returns uuid
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_id uuid;
|
|
v_recent integer;
|
|
begin
|
|
if p_user_id is null then
|
|
raise exception 'feedback_user_required' using errcode = '22023';
|
|
end if;
|
|
-- Five submissions per rolling hour per account.
|
|
select count(*) into v_recent
|
|
from public.user_feedback
|
|
where user_id = p_user_id
|
|
and created_at > clock_timestamp() - interval '1 hour';
|
|
if v_recent >= 5 then
|
|
raise exception 'feedback_rate_limited' using errcode = 'P0001';
|
|
end if;
|
|
if p_session_id is not null and not exists (
|
|
select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id
|
|
) then
|
|
raise exception 'feedback_session_not_owned' using errcode = '42501';
|
|
end if;
|
|
insert into public.user_feedback (user_id, feedback_type, body, contact, session_id)
|
|
values (p_user_id, p_type, btrim(p_body), nullif(btrim(coalesce(p_contact, '')), ''), p_session_id)
|
|
returning id into v_id;
|
|
return v_id;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.set_reply_rating(
|
|
p_user_id uuid,
|
|
p_session_id uuid,
|
|
p_message_index integer,
|
|
p_rating text,
|
|
p_answer_sha256 text
|
|
)
|
|
returns boolean
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
if not exists (
|
|
select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id
|
|
) then
|
|
raise exception 'rating_session_not_owned' using errcode = '42501';
|
|
end if;
|
|
if p_rating is null then
|
|
delete from public.reply_ratings
|
|
where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index;
|
|
return true;
|
|
end if;
|
|
insert into public.reply_ratings (user_id, session_id, message_index, rating, answer_sha256)
|
|
values (p_user_id, p_session_id, p_message_index, p_rating, p_answer_sha256)
|
|
on conflict (user_id, session_id, message_index) do update
|
|
set rating = excluded.rating,
|
|
answer_sha256 = excluded.answer_sha256,
|
|
updated_at = clock_timestamp();
|
|
return true;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.list_reply_ratings(p_user_id uuid, p_session_id uuid)
|
|
returns table (message_index integer, rating text, answer_sha256 text)
|
|
language sql
|
|
stable
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
select r.message_index, r.rating, r.answer_sha256
|
|
from public.reply_ratings r
|
|
where r.user_id = p_user_id and r.session_id = p_session_id
|
|
order by r.message_index;
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Admin side (admin_runtime only, permission-checked, audited)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
insert into public.admin_permissions (permission_key, description) values
|
|
('support.feedback.read', '查看用户反馈与投诉'),
|
|
('support.feedback.write', '处理用户反馈与投诉')
|
|
on conflict (permission_key) do update set description = excluded.description;
|
|
|
|
with role_grants(role_code, permission_key) as (values
|
|
('owner', 'support.feedback.read'), ('owner', 'support.feedback.write'),
|
|
('operations', 'support.feedback.read'), ('operations', 'support.feedback.write'),
|
|
('support', 'support.feedback.read'), ('support', 'support.feedback.write'),
|
|
('auditor', 'support.feedback.read')
|
|
)
|
|
insert into public.admin_role_permissions (role_id, permission_id)
|
|
select r.id, p.id
|
|
from role_grants g
|
|
join public.admin_roles r on r.code = g.role_code
|
|
join public.admin_permissions p on p.permission_key = g.permission_key
|
|
on conflict do nothing;
|
|
|
|
create or replace function public.admin_list_user_feedback(
|
|
p_actor_user_id uuid,
|
|
p_type text,
|
|
p_status text,
|
|
p_query text,
|
|
p_limit integer,
|
|
p_offset integer
|
|
)
|
|
returns table (
|
|
id uuid, user_id uuid, email text, feedback_type text, body text, contact text,
|
|
session_id uuid, status text, admin_note text, handled_by uuid,
|
|
created_at timestamptz, updated_at timestamptz, total_count bigint
|
|
)
|
|
language plpgsql
|
|
stable
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
if not public.admin_has_permission(p_actor_user_id, 'support.feedback.read') then
|
|
raise exception 'admin_permission_denied' using errcode = '42501';
|
|
end if;
|
|
return query
|
|
select f.id, f.user_id, u.email, f.feedback_type, f.body, f.contact,
|
|
f.session_id, f.status, f.admin_note, f.handled_by,
|
|
f.created_at, f.updated_at, count(*) over() as total_count
|
|
from public.user_feedback f
|
|
left join identity.users u on u.id = f.user_id
|
|
where (p_type is null or f.feedback_type = p_type)
|
|
and (p_status is null or f.status = p_status)
|
|
and (p_query is null or f.body ilike p_query or u.email ilike p_query or f.user_id::text ilike p_query)
|
|
order by (f.feedback_type = 'content_report' and f.status = 'new') desc, f.created_at desc
|
|
limit greatest(1, least(coalesce(p_limit, 20), 100))
|
|
offset greatest(0, coalesce(p_offset, 0));
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.admin_update_user_feedback(
|
|
p_actor_user_id uuid,
|
|
p_feedback_id uuid,
|
|
p_status text,
|
|
p_admin_note text,
|
|
p_request_id text
|
|
)
|
|
returns table (id uuid, status text, admin_note text, handled_by uuid, updated_at timestamptz)
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_actor_email text;
|
|
v_before jsonb;
|
|
begin
|
|
if not public.admin_has_permission(p_actor_user_id, 'support.feedback.write') then
|
|
raise exception 'admin_permission_denied' using errcode = '42501';
|
|
end if;
|
|
if p_status not in ('new', 'in_progress', 'resolved', 'rejected') then
|
|
raise exception 'feedback_status_invalid' using errcode = '22023';
|
|
end if;
|
|
if p_admin_note is not null and char_length(p_admin_note) > 2000 then
|
|
raise exception 'feedback_note_too_long' using errcode = '22023';
|
|
end if;
|
|
select jsonb_build_object('status', f.status, 'admin_note', f.admin_note)
|
|
into v_before
|
|
from public.user_feedback f where f.id = p_feedback_id for update;
|
|
if v_before is null then
|
|
raise exception 'feedback_not_found' using errcode = '22023';
|
|
end if;
|
|
select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id;
|
|
update public.user_feedback f
|
|
set status = p_status,
|
|
admin_note = nullif(btrim(coalesce(p_admin_note, '')), ''),
|
|
handled_by = p_actor_user_id,
|
|
updated_at = clock_timestamp()
|
|
where f.id = p_feedback_id;
|
|
insert into audit.admin_audit_logs (
|
|
actor_user_id, actor_email, actor_role, action, target_type, target_id,
|
|
before_value, after_value, request_id, permission_used, reason
|
|
) values (
|
|
p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin',
|
|
'user_feedback.update', 'user_feedback', p_feedback_id,
|
|
v_before, jsonb_build_object('status', p_status),
|
|
p_request_id, 'support.feedback.write', 'feedback handling'
|
|
) on conflict do nothing;
|
|
return query
|
|
select f.id, f.status, f.admin_note, f.handled_by, f.updated_at
|
|
from public.user_feedback f where f.id = p_feedback_id;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.submit_user_feedback(uuid, text, text, text, uuid) from public, anon, authenticated;
|
|
revoke all on function public.set_reply_rating(uuid, uuid, integer, text, text) from public, anon, authenticated;
|
|
revoke all on function public.list_reply_ratings(uuid, uuid) from public, anon, authenticated;
|
|
revoke all on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) from public, anon, authenticated;
|
|
revoke all on function public.admin_update_user_feedback(uuid, uuid, text, text, text) from public, anon, authenticated;
|
|
|
|
grant execute on function public.submit_user_feedback(uuid, text, text, text, uuid) to service_role;
|
|
grant execute on function public.set_reply_rating(uuid, uuid, integer, text, text) to service_role;
|
|
grant execute on function public.list_reply_ratings(uuid, uuid) to service_role;
|
|
|
|
do $$
|
|
begin
|
|
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
|
|
grant execute on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) to admin_runtime;
|
|
grant execute on function public.admin_update_user_feedback(uuid, uuid, text, text, text) to admin_runtime;
|
|
end if;
|
|
end;
|
|
$$;
|
|
|
|
commit;
|