feat: add configurable payments and Gitea staging delivery
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / validate (push) Failing after 5m10s
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / publish-and-deploy (push) Has been skipped

Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest.
This commit is contained in:
linmeng
2026-07-27 20:19:05 +08:00
parent 9dc115509e
commit 1b8d6fcce6
37 changed files with 1257 additions and 28 deletions
@@ -0,0 +1,11 @@
create table public.admin_users (
user_id uuid primary key references auth.users(id) on delete cascade,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id),
revoked_at timestamptz,
revoked_by uuid references auth.users(id)
);
alter table public.admin_users enable row level security;
revoke all on table public.admin_users from anon, authenticated;
grant select, insert, update on table public.admin_users to service_role;
@@ -0,0 +1,69 @@
begin;
alter table public.credit_transactions drop constraint if exists credit_transactions_transaction_type_check;
alter table public.credit_transactions add constraint credit_transactions_transaction_type_check
check (transaction_type in ('redeem', 'reserve', 'refund', 'payment'));
alter table public.credit_transactions drop constraint if exists credit_transactions_amount_check;
alter table public.credit_transactions add constraint credit_transactions_amount_check
check ((transaction_type = 'reserve' and amount < 0) or (transaction_type in ('redeem', 'refund', 'payment') and amount > 0));
create table public.payment_packages (
id uuid primary key default gen_random_uuid(),
name text not null check (char_length(name) between 1 and 80),
description text not null default '' check (char_length(description) <= 500),
price_cents integer not null check (price_cents > 0),
credits integer not null check (credits > 0),
sort_order integer not null default 0,
enabled boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_by uuid references auth.users(id) on delete set null
);
create table public.payment_orders (
id uuid primary key default gen_random_uuid(),
order_no text not null unique check (char_length(order_no) between 16 and 100),
user_id uuid not null references auth.users(id) on delete cascade,
package_id uuid not null references public.payment_packages(id) on delete restrict,
money_cents integer not null check (money_cents > 0),
credits integer not null check (credits > 0),
status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'expired')),
epay_trade_no text,
raw_notify_payload_hash text,
paid_at timestamptz,
created_at timestamptz not null default now()
);
create unique index payment_orders_trade_no_idx on public.payment_orders(epay_trade_no) where epay_trade_no is not null;
create index payment_orders_user_created_idx on public.payment_orders(user_id, created_at desc);
alter table public.payment_packages enable row level security;
alter table public.payment_orders enable row level security;
revoke all on public.payment_packages, public.payment_orders from anon, authenticated;
grant select on public.payment_orders to authenticated;
create policy payment_orders_select_own on public.payment_orders for select to authenticated using ((select auth.uid()) = user_id);
grant all on public.payment_packages, public.payment_orders to service_role;
create or replace function public.settle_epay_order(p_order_no text, p_trade_no text, p_money_cents integer, p_payload_hash text)
returns table (success boolean, status text, credits integer)
language plpgsql security definer set search_path = public, pg_temp
as $$
declare v_order public.payment_orders%rowtype; v_balance integer;
begin
select * into v_order from public.payment_orders where order_no = btrim(p_order_no) for update;
if not found or v_order.money_cents <> p_money_cents then return query select false, 'invalid'::text, null::integer; return; end if;
if v_order.status = 'paid' then return query select true, 'paid'::text, v_order.credits; return; end if;
select credits into v_balance from public.profiles where id = v_order.user_id for update;
if not found then return query select false, 'profile_missing'::text, null::integer; return; end if;
update public.profiles set credits = credits + v_order.credits, updated_at = now() where id = v_order.user_id returning credits into v_balance;
insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id)
values (v_order.user_id, 'payment', v_order.credits, v_balance, v_order.order_no)
on conflict (user_id, transaction_type, request_id) do nothing;
update public.payment_orders set status='paid', epay_trade_no=p_trade_no, raw_notify_payload_hash=p_payload_hash, paid_at=now() where id=v_order.id;
return query select true, 'paid'::text, v_order.credits;
end;
$$;
revoke all on function public.settle_epay_order(text, text, integer, text) from public, anon, authenticated;
grant execute on function public.settle_epay_order(text, text, integer, text) to service_role;
commit;
@@ -0,0 +1,23 @@
begin;
create index if not exists payment_orders_created_status_idx on public.payment_orders(created_at desc, status);
create or replace function public.get_payment_order_stats(p_from timestamptz default null, p_to timestamptz default null)
returns table (total_orders bigint, paid_orders bigint, pending_orders bigint, failed_expired_orders bigint, paid_amount_cents bigint, granted_credits bigint)
language sql security definer set search_path = public, pg_temp
as $$
select
count(*)::bigint,
count(*) filter (where status = 'paid')::bigint,
count(*) filter (where status = 'pending')::bigint,
count(*) filter (where status in ('failed', 'expired'))::bigint,
coalesce(sum(money_cents) filter (where status = 'paid'), 0)::bigint,
coalesce(sum(credits) filter (where status = 'paid'), 0)::bigint
from public.payment_orders
where (p_from is null or created_at >= p_from)
and (p_to is null or created_at <= p_to);
$$;
revoke all on function public.get_payment_order_stats(timestamptz, timestamptz) from public, anon, authenticated;
grant execute on function public.get_payment_order_stats(timestamptz, timestamptz) to service_role;
commit;