Files
Jyotisha/frontend/supabase/migrations/20260806050000_operations_feature_flags.sql
T
2026-08-06 20:15:08 +08:00

132 lines
9.1 KiB
PL/PgSQL

begin;
create table if not exists public.feature_flags (
id uuid primary key default gen_random_uuid(),
flag_key text not null check (flag_key ~ '^[a-z][a-z0-9_.-]{2,79}$'),
version integer not null check (version > 0),
enabled boolean not null default false,
rollout_percentage integer not null default 100 check (rollout_percentage between 0 and 100),
config jsonb not null default '{}'::jsonb check (jsonb_typeof(config)='object'),
status text not null default 'draft' check (status in ('draft','published','retired')),
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
published_at timestamptz,
unique(flag_key,version)
);
create unique index if not exists feature_flags_one_draft_idx on public.feature_flags(flag_key) where status='draft';
create unique index if not exists feature_flags_one_published_idx on public.feature_flags(flag_key) where status='published';
create table if not exists public.notification_templates (
id uuid primary key default gen_random_uuid(),
template_key text not null check (template_key in ('subscription.expiring','payment.grant_failed','fair_use.blocked')),
version integer not null check (version>0),
channel text not null default 'in_app' check (channel in ('in_app','email')),
subject text not null default '' check (char_length(subject)<=160),
body text not null check (char_length(body) between 1 and 4000),
status text not null default 'draft' check (status in ('draft','published','retired')),
created_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
published_at timestamptz,
unique(template_key,channel,version)
);
create unique index if not exists notification_templates_published_idx
on public.notification_templates(template_key,channel) where status='published';
create table if not exists public.pricing_experiment_events (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete set null,
experiment_key text not null,
variant text not null,
event_name text not null check (event_name in ('view','checkout','paid','cancelled')),
product_code text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default clock_timestamp()
);
create index if not exists pricing_experiment_events_created_idx on public.pricing_experiment_events(experiment_key,created_at desc);
insert into public.feature_flags(flag_key,version,enabled,rollout_percentage,config,status,created_at,published_at) values
('billing.subscriptions',1,true,100,'{}','published',now(),now()),
('models.database_catalog',1,true,100,'{}','published',now(),now()),
('models.circuit_breaker',1,false,0,'{"failureThreshold":5,"cooldownSeconds":300}','published',now(),now()),
('billing.pro_products',1,false,0,'{}','published',now(),now())
on conflict(flag_key,version) do nothing;
insert into public.notification_templates(template_key,version,channel,subject,body,status,published_at) values
('subscription.expiring',1,'in_app','会员即将到期','你的会员权益即将到期,可在账户页查看结束时间。','published',now()),
('payment.grant_failed',1,'in_app','支付权益待处理','支付已确认,但权益发放仍在重试。请勿重复付款。','published',now()),
('fair_use.blocked',1,'in_app','已达到合理使用上限','当前使用频率已达到会员合理使用上限,请稍后再试。','published',now())
on conflict(template_key,channel,version) do nothing;
create or replace function public.admin_save_feature_flag(
p_actor_user_id uuid,p_flag_id uuid,p_flag_key text,p_enabled boolean,p_rollout_percentage integer,
p_config jsonb,p_expected_version integer,p_reason text,p_request_id text
)
returns uuid language plpgsql security definer set search_path=''
as $$ declare v_id uuid; v_version integer;
begin
if not public.admin_has_permission(p_actor_user_id,'ops.flags.write') then raise exception 'admin_permission_denied' using errcode='42501'; end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then raise exception 'admin_reason_required' using errcode='22023'; end if;
if p_flag_id is null then
select coalesce(max(version),0)+1 into v_version from public.feature_flags where flag_key=p_flag_key;
insert into public.feature_flags(flag_key,version,enabled,rollout_percentage,config,created_by)
values(p_flag_key,v_version,p_enabled,p_rollout_percentage,coalesce(p_config,'{}'::jsonb),p_actor_user_id) returning id into v_id;
else
update public.feature_flags set enabled=p_enabled,rollout_percentage=p_rollout_percentage,config=coalesce(p_config,'{}'::jsonb)
where id=p_flag_id and status='draft' and version=p_expected_version returning id into v_id;
if v_id is null then raise exception 'feature_flag_version_conflict' using errcode='40001'; end if;
end if;
insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,after_value,
request_id,permission_used,reason)
select p_actor_user_id,lower(btrim(u.email)),'admin','ops.flag.draft.save','feature_flag',v_id,
jsonb_build_object('flagKey',p_flag_key,'enabled',p_enabled,'rolloutPercentage',p_rollout_percentage),
p_request_id,'ops.flags.write',btrim(p_reason) from identity.users u where u.id=p_actor_user_id on conflict do nothing;
return v_id;
end $$;
create or replace function public.admin_publish_feature_flag(
p_actor_user_id uuid,p_flag_id uuid,p_expected_version integer,p_reason text,p_request_id text
)
returns uuid language plpgsql security definer set search_path=''
as $$ declare v_flag public.feature_flags%rowtype;
begin
if not public.admin_has_permission(p_actor_user_id,'ops.flags.write') then raise exception 'admin_permission_denied' using errcode='42501'; end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then raise exception 'admin_reason_required' using errcode='22023'; end if;
select * into v_flag from public.feature_flags where id=p_flag_id and status='draft' and version=p_expected_version for update;
if not found then raise exception 'feature_flag_version_conflict' using errcode='40001'; end if;
update public.feature_flags set status='retired' where flag_key=v_flag.flag_key and status='published';
update public.feature_flags set status='published',published_at=clock_timestamp() where id=p_flag_id;
insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,after_value,
request_id,permission_used,reason)
select p_actor_user_id,lower(btrim(u.email)),'admin','ops.flag.publish','feature_flag',p_flag_id,
jsonb_build_object('flagKey',v_flag.flag_key,'version',v_flag.version,'enabled',v_flag.enabled),
p_request_id,'ops.flags.write',btrim(p_reason) from identity.users u where u.id=p_actor_user_id on conflict do nothing;
return p_flag_id;
end $$;
create or replace view public.admin_billing_overview as
select
(select count(*) from public.user_subscriptions where status='active' and starts_at<=now() and ends_at>now())::bigint as active_subscriptions,
(select count(*) from public.payment_orders where status='paid' and paid_at>=date_trunc('day',now()))::bigint as paid_orders_today,
(select coalesce(sum(money_cents),0) from public.payment_orders where status='paid' and paid_at>=date_trunc('month',now()))::bigint as revenue_cents_month,
(select count(*) from public.payment_orders where status='grant_pending' or grant_status='failed')::bigint as grant_failures,
(select count(*) from public.usage_reservations where status='completed' and reserved_at>=date_trunc('day',now()))::bigint as completed_usage_today,
(select coalesce(sum(cost_microusd),0) from public.usage_ledger where created_at>=date_trunc('month',now()))::bigint as model_cost_microusd_month;
alter table public.feature_flags enable row level security;
alter table public.notification_templates enable row level security;
alter table public.pricing_experiment_events enable row level security;
revoke all on table public.feature_flags,public.notification_templates,public.pricing_experiment_events from public,anon,authenticated;
grant select on table public.feature_flags,public.notification_templates,public.pricing_experiment_events to service_role;
revoke all on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text),
public.admin_publish_feature_flag(uuid,uuid,integer,text,text) from public,anon,authenticated;
grant execute on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text),
public.admin_publish_feature_flag(uuid,uuid,integer,text,text) to service_role;
do $$ begin if exists(select 1 from pg_roles where rolname='admin_runtime') then
grant select on table public.feature_flags,public.notification_templates,public.pricing_experiment_events to admin_runtime;
grant select on public.admin_billing_overview to admin_runtime;
grant execute on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text),
public.admin_publish_feature_flag(uuid,uuid,integer,text,text) to admin_runtime;
end if; end $$;
commit;