create table if not exists public.community_orders ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, status text not null default 'PENDING' check (status in ('PENDING', 'PAID', 'CLOSED', 'REFUNDED', 'REVOKED')), amount_cents integer not null default 990 check (amount_cents = 990), currency text not null default 'CNY' check (currency = 'CNY'), subject text not null default 'GPT-Image2 付费交流群长期资格', terms_version text not null, alipay_app_id text, alipay_seller_id text, alipay_trade_no text, refund_request_no text, refund_status text not null default 'NONE' check (refund_status in ('NONE', 'PROCESSING', 'SUCCEEDED', 'FAILED')), refund_failure_code text, paid_amount_cents integer check (paid_amount_cents is null or paid_amount_cents = 990), paid_currency text check (paid_currency is null or paid_currency = 'CNY'), metadata jsonb not null default '{}'::jsonb, expires_at timestamptz not null default (now() + interval '30 minutes'), paid_at timestamptz, refund_requested_at timestamptz, refund_checked_at timestamptz, refunded_at timestamptz, revoked_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists community_orders_one_active_per_user_idx on public.community_orders (user_id) where status in ('PENDING', 'PAID'); create unique index if not exists community_orders_alipay_trade_no_idx on public.community_orders (alipay_trade_no) where alipay_trade_no is not null; create unique index if not exists community_orders_refund_request_no_idx on public.community_orders (refund_request_no) where refund_request_no is not null; create index if not exists community_orders_created_at_idx on public.community_orders (created_at desc); create index if not exists community_orders_status_idx on public.community_orders (status, created_at desc); create table if not exists public.community_alipay_notify_events ( id bigint generated by default as identity primary key, order_id uuid not null references public.community_orders(id) on delete cascade, notify_id text not null unique, trade_no text not null, trade_status text not null, payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create unique index if not exists community_alipay_notify_order_trade_event_idx on public.community_alipay_notify_events (order_id, trade_no, trade_status, notify_id); create table if not exists public.community_group_qr_assets ( id uuid primary key default gen_random_uuid(), media_type text not null check (media_type in ('image/png', 'image/jpeg', 'image/webp')), qr_bytes bytea not null, size_bytes integer not null check (size_bytes > 0 and size_bytes <= 2097152), is_current boolean not null default false, uploaded_by uuid not null references auth.users(id), created_at timestamptz not null default now(), retired_at timestamptz ); create unique index if not exists community_group_qr_one_current_idx on public.community_group_qr_assets ((true)) where is_current; drop trigger if exists community_orders_set_updated_at on public.community_orders; create trigger community_orders_set_updated_at before update on public.community_orders for each row execute function public.set_updated_at(); alter table public.community_orders enable row level security; alter table public.community_alipay_notify_events enable row level security; alter table public.community_group_qr_assets enable row level security; revoke all on table public.community_orders from public, anon, authenticated; revoke all on table public.community_alipay_notify_events from public, anon, authenticated; revoke all on table public.community_group_qr_assets from public, anon, authenticated; grant all on table public.community_orders to service_role; grant all on table public.community_alipay_notify_events to service_role; grant all on table public.community_group_qr_assets to service_role; grant usage, select on sequence public.community_alipay_notify_events_id_seq to service_role; create or replace function public.create_or_reuse_community_order( p_user_id uuid, p_terms_version text ) returns public.community_orders language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin if p_user_id is null then raise exception 'COMMUNITY_USER_REQUIRED' using errcode = 'P0001'; end if; if p_terms_version is null or btrim(p_terms_version) = '' then raise exception 'COMMUNITY_TERMS_REQUIRED' using errcode = 'P0001'; end if; perform pg_advisory_xact_lock(hashtext(p_user_id::text)); select co.* into v_order from public.community_orders as co where co.user_id = p_user_id and co.status in ('PENDING', 'PAID') order by co.created_at desc limit 1 for update; if found then return v_order; end if; insert into public.community_orders ( user_id, status, amount_cents, currency, subject, terms_version ) values ( p_user_id, 'PENDING', 990, 'CNY', 'GPT-Image2 付费交流群长期资格', p_terms_version ) returning * into v_order; return v_order; end; $$; create or replace function public.mark_community_order_paid( p_order_id uuid, p_trade_no text, p_paid_amount_cents integer, p_paid_currency text, p_notify_id text default null, p_notify_payload jsonb default '{}'::jsonb ) returns table ( current_status text, transitioned boolean ) language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin select co.* into v_order from public.community_orders as co where co.id = p_order_id for update; if not found then raise exception 'COMMUNITY_ORDER_NOT_FOUND' using errcode = 'P0001'; end if; if p_trade_no is null or btrim(p_trade_no) = '' then raise exception 'ALIPAY_TRADE_NO_REQUIRED' using errcode = 'P0001'; end if; if p_paid_amount_cents <> v_order.amount_cents or p_paid_amount_cents <> 990 then raise exception 'COMMUNITY_AMOUNT_MISMATCH' using errcode = 'P0001'; end if; if p_paid_currency <> v_order.currency or p_paid_currency <> 'CNY' then raise exception 'COMMUNITY_CURRENCY_MISMATCH' using errcode = 'P0001'; end if; if v_order.alipay_trade_no is not null and v_order.alipay_trade_no <> p_trade_no then raise exception 'ALIPAY_TRADE_NO_MISMATCH' using errcode = 'P0001'; end if; if p_notify_id is not null and btrim(p_notify_id) <> '' then insert into public.community_alipay_notify_events ( order_id, notify_id, trade_no, trade_status, payload ) values ( v_order.id, p_notify_id, p_trade_no, coalesce(nullif(p_notify_payload ->> 'trade_status', ''), 'TRADE_SUCCESS'), coalesce(p_notify_payload, '{}'::jsonb) ) on conflict (notify_id) do nothing; end if; if v_order.status in ('PAID', 'REFUNDED', 'REVOKED') then return query select v_order.status, false; return; end if; if v_order.status <> 'PENDING' then raise exception 'COMMUNITY_ORDER_NOT_PAYABLE' using errcode = 'P0001'; end if; update public.community_orders as co set status = 'PAID', alipay_trade_no = p_trade_no, paid_amount_cents = p_paid_amount_cents, paid_currency = p_paid_currency, paid_at = coalesce(co.paid_at, now()) where co.id = v_order.id; return query select 'PAID'::text, true; end; $$; create or replace function public.close_community_order(p_order_id uuid) returns table ( current_status text, transitioned boolean ) language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin select co.* into v_order from public.community_orders as co where co.id = p_order_id for update; if not found then raise exception 'COMMUNITY_ORDER_NOT_FOUND' using errcode = 'P0001'; end if; if v_order.status <> 'PENDING' then return query select v_order.status, false; return; end if; update public.community_orders as co set status = 'CLOSED' where co.id = v_order.id; return query select 'CLOSED'::text, true; end; $$; create or replace function public.prepare_community_refund( p_order_id uuid, p_refund_request_no text ) returns table ( refund_request_no text, refund_amount_cents integer, already_prepared boolean ) language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin select co.* into v_order from public.community_orders as co where co.id = p_order_id for update; if not found then raise exception 'COMMUNITY_ORDER_NOT_FOUND' using errcode = 'P0001'; end if; if v_order.status = 'REFUNDED' then return query select v_order.refund_request_no, v_order.amount_cents, true; return; end if; if v_order.status <> 'PAID' then raise exception 'COMMUNITY_ORDER_NOT_REFUNDABLE' using errcode = 'P0001'; end if; if p_refund_request_no is null or btrim(p_refund_request_no) = '' then raise exception 'REFUND_REQUEST_NO_REQUIRED' using errcode = 'P0001'; end if; if v_order.refund_request_no is not null and v_order.refund_request_no <> p_refund_request_no then raise exception 'REFUND_REQUEST_NO_MISMATCH' using errcode = 'P0001'; end if; update public.community_orders as co set refund_request_no = p_refund_request_no, refund_status = 'PROCESSING', refund_failure_code = null, refund_requested_at = coalesce(co.refund_requested_at, now()) where co.id = v_order.id; return query select p_refund_request_no, v_order.amount_cents, v_order.refund_status = 'PROCESSING'; end; $$; create or replace function public.set_community_refund_state( p_order_id uuid, p_refund_status text, p_failure_code text default null ) returns void language plpgsql security definer set search_path = public as $$ begin if p_refund_status not in ('PROCESSING', 'FAILED') then raise exception 'INVALID_REFUND_STATUS' using errcode = 'P0001'; end if; update public.community_orders set refund_status = p_refund_status, refund_failure_code = nullif(left(coalesce(p_failure_code, ''), 120), ''), refund_checked_at = now() where id = p_order_id and status = 'PAID'; if not found then raise exception 'COMMUNITY_ORDER_NOT_REFUNDABLE' using errcode = 'P0001'; end if; end; $$; create or replace function public.finalize_community_refund( p_order_id uuid, p_trade_no text, p_refund_request_no text, p_refund_amount_cents integer ) returns table ( current_status text, transitioned boolean ) language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin select co.* into v_order from public.community_orders as co where co.id = p_order_id for update; if not found then raise exception 'COMMUNITY_ORDER_NOT_FOUND' using errcode = 'P0001'; end if; if v_order.status = 'REFUNDED' then return query select 'REFUNDED'::text, false; return; end if; if v_order.status <> 'PAID' or v_order.refund_status <> 'PROCESSING' then raise exception 'COMMUNITY_REFUND_NOT_PROCESSING' using errcode = 'P0001'; end if; if v_order.alipay_trade_no <> p_trade_no or v_order.refund_request_no <> p_refund_request_no or p_refund_amount_cents <> v_order.amount_cents then raise exception 'COMMUNITY_REFUND_RESULT_MISMATCH' using errcode = 'P0001'; end if; update public.community_orders as co set status = 'REFUNDED', refund_status = 'SUCCEEDED', refund_failure_code = null, refund_checked_at = now(), refunded_at = coalesce(co.refunded_at, now()) where co.id = v_order.id; return query select 'REFUNDED'::text, true; end; $$; create or replace function public.revoke_community_access(p_order_id uuid) returns table ( current_status text, transitioned boolean ) language plpgsql security definer set search_path = public as $$ declare v_order public.community_orders%rowtype; begin select co.* into v_order from public.community_orders as co where co.id = p_order_id for update; if not found then raise exception 'COMMUNITY_ORDER_NOT_FOUND' using errcode = 'P0001'; end if; if v_order.status = 'REVOKED' then return query select 'REVOKED'::text, false; return; end if; if v_order.status <> 'PAID' or v_order.refund_status = 'PROCESSING' then raise exception 'COMMUNITY_ORDER_NOT_REVOKABLE' using errcode = 'P0001'; end if; update public.community_orders as co set status = 'REVOKED', revoked_at = coalesce(co.revoked_at, now()) where co.id = v_order.id; return query select 'REVOKED'::text, true; end; $$; create or replace function public.replace_community_group_qr_asset( p_media_type text, p_qr_bytes bytea, p_uploaded_by uuid ) returns table ( id uuid, media_type text, size_bytes integer, created_at timestamptz ) language plpgsql security definer set search_path = public as $$ declare v_asset public.community_group_qr_assets%rowtype; v_size integer; begin v_size := octet_length(p_qr_bytes); if p_media_type not in ('image/png', 'image/jpeg', 'image/webp') then raise exception 'COMMUNITY_QR_INVALID_TYPE' using errcode = 'P0001'; end if; if v_size is null or v_size < 1 or v_size > 2097152 then raise exception 'COMMUNITY_QR_INVALID_SIZE' using errcode = 'P0001'; end if; if p_uploaded_by is null then raise exception 'COMMUNITY_QR_UPLOADER_REQUIRED' using errcode = 'P0001'; end if; perform pg_advisory_xact_lock(hashtext('community_group_qr_current')); update public.community_group_qr_assets set is_current = false, retired_at = coalesce(retired_at, now()) where is_current; insert into public.community_group_qr_assets ( media_type, qr_bytes, size_bytes, is_current, uploaded_by ) values (p_media_type, p_qr_bytes, v_size, true, p_uploaded_by) returning * into v_asset; return query select v_asset.id, v_asset.media_type, v_asset.size_bytes, v_asset.created_at; end; $$; revoke execute on function public.create_or_reuse_community_order(uuid, text) from public, anon, authenticated; revoke execute on function public.mark_community_order_paid(uuid, text, integer, text, text, jsonb) from public, anon, authenticated; revoke execute on function public.close_community_order(uuid) from public, anon, authenticated; revoke execute on function public.prepare_community_refund(uuid, text) from public, anon, authenticated; revoke execute on function public.set_community_refund_state(uuid, text, text) from public, anon, authenticated; revoke execute on function public.finalize_community_refund(uuid, text, text, integer) from public, anon, authenticated; revoke execute on function public.revoke_community_access(uuid) from public, anon, authenticated; revoke execute on function public.replace_community_group_qr_asset(text, bytea, uuid) from public, anon, authenticated; grant execute on function public.create_or_reuse_community_order(uuid, text) to service_role; grant execute on function public.mark_community_order_paid(uuid, text, integer, text, text, jsonb) to service_role; grant execute on function public.close_community_order(uuid) to service_role; grant execute on function public.prepare_community_refund(uuid, text) to service_role; grant execute on function public.set_community_refund_state(uuid, text, text) to service_role; grant execute on function public.finalize_community_refund(uuid, text, text, integer) to service_role; grant execute on function public.revoke_community_access(uuid) to service_role; grant execute on function public.replace_community_group_qr_asset(text, bytea, uuid) to service_role;