472 lines
19 KiB
PL/PgSQL
472 lines
19 KiB
PL/PgSQL
-- Compatibility snapshot for existing Supabase deployments.
|
|
-- New PostgreSQL/RDS deployments must use `npm run db:migrate`; do not use this
|
|
-- file as an unversioned migration source.
|
|
|
|
create table if not exists assets (
|
|
id text primary key,
|
|
owner_id text not null,
|
|
kind text not null,
|
|
name text not null,
|
|
url text not null,
|
|
storage_path text,
|
|
source text not null,
|
|
tags text[] not null default '{}',
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists generation_jobs (
|
|
id text primary key,
|
|
owner_id text not null,
|
|
external_client_id text,
|
|
capability text not null,
|
|
provider text not null,
|
|
req_key text not null,
|
|
status text not null,
|
|
prompt text,
|
|
input_asset_ids text[] not null default '{}',
|
|
input_urls text[] not null default '{}',
|
|
output_asset_ids text[] not null default '{}',
|
|
provider_task_id text,
|
|
request_payload jsonb not null default '{}'::jsonb,
|
|
response_payload jsonb,
|
|
error jsonb,
|
|
retry_of text,
|
|
idempotency_key text,
|
|
idempotency_fingerprint text,
|
|
priority integer not null default 0,
|
|
attempts integer not null default 0,
|
|
max_attempts integer not null default 3,
|
|
scheduled_at timestamptz not null default now(),
|
|
locked_at timestamptz,
|
|
locked_by text,
|
|
started_at timestamptz,
|
|
completed_at timestamptz,
|
|
webhook_url text,
|
|
webhook_attempts integer not null default 0,
|
|
webhook_last_status jsonb,
|
|
usage_context jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table generation_jobs add column if not exists external_client_id text;
|
|
alter table generation_jobs add column if not exists idempotency_key text;
|
|
alter table generation_jobs add column if not exists idempotency_fingerprint text;
|
|
alter table generation_jobs add column if not exists priority integer not null default 0;
|
|
alter table generation_jobs add column if not exists attempts integer not null default 0;
|
|
alter table generation_jobs add column if not exists max_attempts integer not null default 3;
|
|
alter table generation_jobs add column if not exists scheduled_at timestamptz not null default now();
|
|
alter table generation_jobs add column if not exists locked_at timestamptz;
|
|
alter table generation_jobs add column if not exists locked_by text;
|
|
alter table generation_jobs add column if not exists started_at timestamptz;
|
|
alter table generation_jobs add column if not exists completed_at timestamptz;
|
|
alter table generation_jobs add column if not exists webhook_url text;
|
|
alter table generation_jobs add column if not exists webhook_attempts integer not null default 0;
|
|
alter table generation_jobs add column if not exists webhook_last_status jsonb;
|
|
alter table generation_jobs add column if not exists usage_context jsonb;
|
|
|
|
create table if not exists usage_events (
|
|
id text primary key,
|
|
owner_id text not null,
|
|
job_id text not null,
|
|
source text not null default 'platform',
|
|
capability text not null,
|
|
provider text,
|
|
req_key text,
|
|
account_username text,
|
|
account_display_name text,
|
|
tenant_id text,
|
|
organization_id text,
|
|
organization_name text,
|
|
quantity integer not null default 1,
|
|
estimated_unit text not null default 'job',
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table usage_events add column if not exists source text;
|
|
alter table usage_events add column if not exists provider text;
|
|
alter table usage_events add column if not exists req_key text;
|
|
alter table usage_events add column if not exists account_username text;
|
|
alter table usage_events add column if not exists account_display_name text;
|
|
alter table usage_events add column if not exists tenant_id text;
|
|
alter table usage_events add column if not exists organization_id text;
|
|
alter table usage_events add column if not exists organization_name text;
|
|
alter table usage_events add column if not exists quantity integer not null default 1;
|
|
alter table usage_events add column if not exists estimated_unit text not null default 'job';
|
|
|
|
update usage_events as usage
|
|
set source = case
|
|
when jobs.external_client_id is not null or usage.owner_id like 'api:%' then 'api'
|
|
else 'platform'
|
|
end,
|
|
provider = coalesce(usage.provider, jobs.provider),
|
|
req_key = coalesce(usage.req_key, jobs.req_key),
|
|
quantity = 1,
|
|
estimated_unit = 'job'
|
|
from generation_jobs as jobs
|
|
where jobs.id = usage.job_id;
|
|
|
|
update usage_events
|
|
set source = case when owner_id like 'api:%' then 'api' else 'platform' end,
|
|
quantity = 1,
|
|
estimated_unit = 'job'
|
|
where source is null;
|
|
|
|
alter table usage_events alter column source set default 'platform';
|
|
alter table usage_events alter column source set not null;
|
|
alter table usage_events alter column estimated_unit set default 'job';
|
|
alter table usage_events drop constraint if exists usage_events_job_id_fkey;
|
|
|
|
create table if not exists projects (
|
|
id text primary key,
|
|
owner_id text not null,
|
|
name text not null,
|
|
brief text not null default '',
|
|
type text not null default 'custom',
|
|
asset_ids text[] not null default '{}',
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists image_templates (
|
|
id text primary key,
|
|
owner_id text not null,
|
|
name text not null,
|
|
description text,
|
|
prompt text not null,
|
|
preview_image_url text,
|
|
settings jsonb not null default '{}'::jsonb,
|
|
sort_order integer not null default 0,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists assets_owner_created_idx on assets(owner_id, created_at desc);
|
|
create index if not exists generation_jobs_owner_created_idx on generation_jobs(owner_id, created_at desc);
|
|
create index if not exists generation_jobs_status_idx on generation_jobs(status);
|
|
create index if not exists generation_jobs_claim_idx on generation_jobs(status, scheduled_at, locked_at, priority desc);
|
|
create index if not exists generation_jobs_external_client_idx on generation_jobs(owner_id, external_client_id, created_at desc);
|
|
create unique index if not exists generation_jobs_idempotency_idx
|
|
on generation_jobs(owner_id, external_client_id, idempotency_key)
|
|
where external_client_id is not null and idempotency_key is not null;
|
|
create index if not exists usage_events_owner_created_idx on usage_events(owner_id, created_at desc);
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1
|
|
from usage_events
|
|
group by job_id
|
|
having count(*) > 1
|
|
) then
|
|
raise exception using
|
|
errcode = '23505',
|
|
message = 'USAGE_EVENTS_DUPLICATE_JOB_ID: back up and clean duplicate usage_events.job_id rows before retrying this migration';
|
|
end if;
|
|
end;
|
|
$$;
|
|
create unique index if not exists usage_events_job_id_idx on usage_events(job_id);
|
|
create index if not exists usage_events_source_created_idx on usage_events(source, created_at desc);
|
|
create index if not exists usage_events_organization_created_idx on usage_events(organization_id, created_at desc);
|
|
create index if not exists image_templates_owner_sort_idx on image_templates(owner_id, sort_order asc, updated_at desc);
|
|
|
|
create or replace function claim_generation_jobs(
|
|
p_worker_id text,
|
|
p_limit integer default 1,
|
|
p_lock_timeout_seconds integer default 300
|
|
)
|
|
returns setof generation_jobs
|
|
language plpgsql
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
declare
|
|
v_now timestamptz := now();
|
|
begin
|
|
return query
|
|
with candidates as (
|
|
select id
|
|
from generation_jobs
|
|
where status in ('queued', 'running')
|
|
and coalesce(scheduled_at, created_at) <= v_now
|
|
and (
|
|
locked_at is null
|
|
or locked_at < v_now - make_interval(secs => p_lock_timeout_seconds)
|
|
)
|
|
order by coalesce(priority, 0) desc, coalesce(scheduled_at, created_at) asc, created_at asc
|
|
limit greatest(1, least(coalesce(p_limit, 1), 20))
|
|
for update skip locked
|
|
),
|
|
updated as (
|
|
update generation_jobs
|
|
set locked_at = v_now,
|
|
locked_by = p_worker_id,
|
|
started_at = coalesce(generation_jobs.started_at, v_now),
|
|
updated_at = v_now
|
|
where id in (select id from candidates)
|
|
returning generation_jobs.*
|
|
)
|
|
select * from updated;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function claim_generation_jobs(text, integer, integer) from public;
|
|
|
|
create table if not exists platform_organizations (
|
|
id text primary key,
|
|
name text not null unique,
|
|
status text not null default 'active' check (status in ('active', 'disabled')),
|
|
archive_owner_id text not null unique,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists platform_users (
|
|
id text primary key,
|
|
phone text not null unique,
|
|
display_name text not null,
|
|
role text not null default 'user' check (role in ('super_admin', 'organization_admin', 'user')),
|
|
organization_id text references platform_organizations(id) on delete set null,
|
|
status text not null default 'active' check (status in ('active', 'disabled')),
|
|
password_hash text not null,
|
|
password_salt text not null,
|
|
failed_login_count integer not null default 0,
|
|
locked_until timestamptz,
|
|
session_version integer not null default 1,
|
|
last_login_at timestamptz,
|
|
legacy_subject text,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists platform_account_migrations (
|
|
id text primary key,
|
|
legacy_owner_id text not null unique,
|
|
legacy_phone text,
|
|
platform_user_id text not null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists platform_users_organization_idx on platform_users(organization_id, created_at desc);
|
|
create index if not exists platform_users_status_idx on platform_users(status, role);
|
|
create index if not exists platform_account_migrations_user_idx on platform_account_migrations(platform_user_id);
|
|
|
|
alter table generation_jobs add column if not exists billing jsonb;
|
|
alter table usage_events add column if not exists charged_amount_fen bigint;
|
|
alter table usage_events add column if not exists currency text;
|
|
|
|
create table if not exists billing_price_rules (
|
|
id text primary key,
|
|
provider text not null check (provider in ('volcengine-visual', 'evolink', 'seedance', 'bailian', 'mock')),
|
|
capability text not null check (capability in ('image.generate', 'video.generate')),
|
|
req_key text,
|
|
variant_key text,
|
|
unit text not null check (unit in ('request', 'image', 'video_second')),
|
|
standard_unit_price_fen bigint not null check (standard_unit_price_fen >= 0),
|
|
markup_multiplier numeric(12, 4) not null default 1.0000 check (markup_multiplier >= 1),
|
|
enabled boolean not null default true,
|
|
conditions jsonb not null default '{}'::jsonb,
|
|
quantity_source text check (quantity_source in ('request', 'image_count', 'duration')),
|
|
priority integer not null default 0,
|
|
note text,
|
|
source jsonb,
|
|
parameter_dimensions jsonb not null default '[]'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table billing_price_rules add column if not exists variant_key text;
|
|
alter table billing_price_rules add column if not exists source jsonb;
|
|
alter table billing_price_rules add column if not exists conditions jsonb not null default '{}'::jsonb;
|
|
alter table billing_price_rules add column if not exists quantity_source text;
|
|
alter table billing_price_rules add column if not exists priority integer not null default 0;
|
|
alter table billing_price_rules add column if not exists parameter_dimensions jsonb not null default '[]'::jsonb;
|
|
alter table billing_price_rules drop constraint if exists billing_price_rules_quantity_source_check;
|
|
alter table billing_price_rules add constraint billing_price_rules_quantity_source_check check (quantity_source is null or quantity_source in ('request', 'image_count', 'duration'));
|
|
|
|
drop index if exists billing_price_rules_match_idx;
|
|
create unique index if not exists billing_price_rules_match_idx
|
|
on billing_price_rules(provider, capability, coalesce(req_key, ''), coalesce(variant_key, ''), coalesce(conditions, '{}'::jsonb));
|
|
|
|
create table if not exists billing_wallets (
|
|
organization_id text primary key references platform_organizations(id) on delete restrict,
|
|
balance_fen bigint not null default 0 check (balance_fen >= 0),
|
|
total_recharged_fen bigint not null default 0 check (total_recharged_fen >= 0),
|
|
total_charged_fen bigint not null default 0 check (total_charged_fen >= 0),
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists billing_ledger (
|
|
id text primary key,
|
|
organization_id text not null references platform_organizations(id) on delete restrict,
|
|
account_id text,
|
|
job_id text,
|
|
kind text not null check (kind in ('recharge', 'charge', 'refund', 'adjustment')),
|
|
delta_fen bigint not null check (delta_fen <> 0),
|
|
balance_after_fen bigint not null check (balance_after_fen >= 0),
|
|
currency text not null default 'CNY' check (currency = 'CNY'),
|
|
idempotency_key text not null,
|
|
description text not null,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
do $$
|
|
declare
|
|
v_constraint record;
|
|
v_index record;
|
|
begin
|
|
for v_constraint in
|
|
select constraint_schema, constraint_name
|
|
from information_schema.table_constraints
|
|
where table_schema = current_schema()
|
|
and table_name = 'billing_ledger'
|
|
and constraint_type = 'UNIQUE'
|
|
and array(
|
|
select key_column_usage.column_name::text
|
|
from information_schema.key_column_usage
|
|
where key_column_usage.constraint_schema = table_constraints.constraint_schema
|
|
and key_column_usage.constraint_name = table_constraints.constraint_name
|
|
and key_column_usage.table_name = table_constraints.table_name
|
|
order by key_column_usage.ordinal_position
|
|
) = array['idempotency_key']::text[]
|
|
loop
|
|
execute format('alter table %I.%I drop constraint %I', current_schema(), 'billing_ledger', v_constraint.constraint_name);
|
|
end loop;
|
|
|
|
for v_index in
|
|
select indexes.schemaname, indexes.indexname
|
|
from pg_indexes as indexes
|
|
join pg_class as index_class on index_class.relname = indexes.indexname
|
|
join pg_namespace as index_namespace
|
|
on index_namespace.oid = index_class.relnamespace
|
|
and index_namespace.nspname = indexes.schemaname
|
|
join pg_index as index_metadata on index_metadata.indexrelid = index_class.oid
|
|
join pg_attribute as indexed_column
|
|
on indexed_column.attrelid = index_metadata.indrelid
|
|
and indexed_column.attnum = index_metadata.indkey[0]
|
|
left join pg_constraint as backing_constraint on backing_constraint.conindid = index_class.oid
|
|
where indexes.schemaname = current_schema()
|
|
and indexes.tablename = 'billing_ledger'
|
|
and index_metadata.indisunique
|
|
and index_metadata.indnkeyatts = 1
|
|
and indexed_column.attname = 'idempotency_key'
|
|
and backing_constraint.oid is null
|
|
loop
|
|
execute format('drop index %I.%I', v_index.schemaname, v_index.indexname);
|
|
end loop;
|
|
end;
|
|
$$;
|
|
|
|
create unique index if not exists billing_ledger_organization_idempotency_idx
|
|
on billing_ledger(organization_id, idempotency_key);
|
|
create index if not exists billing_ledger_organization_created_idx on billing_ledger(organization_id, created_at desc);
|
|
create index if not exists billing_ledger_account_created_idx on billing_ledger(account_id, created_at desc);
|
|
create index if not exists billing_ledger_job_idx on billing_ledger(job_id);
|
|
|
|
create or replace function billing_post_wallet_entry(
|
|
p_ledger_id text,
|
|
p_organization_id text,
|
|
p_account_id text,
|
|
p_job_id text,
|
|
p_kind text,
|
|
p_delta_fen bigint,
|
|
p_currency text,
|
|
p_idempotency_key text,
|
|
p_description text,
|
|
p_metadata jsonb
|
|
)
|
|
returns table (
|
|
ledger_id text,
|
|
balance_after_fen bigint,
|
|
balance_fen bigint,
|
|
total_recharged_fen bigint,
|
|
total_charged_fen bigint,
|
|
created_at timestamptz,
|
|
updated_at timestamptz,
|
|
delta_fen bigint
|
|
)
|
|
language plpgsql
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
declare
|
|
v_existing billing_ledger%rowtype;
|
|
v_wallet billing_wallets%rowtype;
|
|
v_entry billing_ledger%rowtype;
|
|
v_account_id text;
|
|
begin
|
|
if p_currency <> 'CNY' then
|
|
raise exception 'BILLING_UNSUPPORTED_CURRENCY';
|
|
end if;
|
|
if p_delta_fen = 0 then
|
|
raise exception 'BILLING_ZERO_DELTA';
|
|
end if;
|
|
|
|
v_account_id := case when p_kind in ('recharge', 'adjustment') then null else p_account_id end;
|
|
|
|
perform pg_advisory_xact_lock(
|
|
hashtextextended(jsonb_build_array(p_organization_id, p_idempotency_key)::text, 0)
|
|
);
|
|
|
|
select * into v_existing
|
|
from billing_ledger
|
|
where organization_id = p_organization_id
|
|
and idempotency_key = p_idempotency_key;
|
|
if found then
|
|
if v_existing.account_id is distinct from v_account_id
|
|
or v_existing.job_id is distinct from p_job_id
|
|
or v_existing.kind is distinct from p_kind
|
|
or v_existing.delta_fen is distinct from p_delta_fen
|
|
or v_existing.currency is distinct from p_currency
|
|
then
|
|
raise exception using
|
|
errcode = 'P0001',
|
|
message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH';
|
|
end if;
|
|
|
|
select * into v_wallet from billing_wallets where organization_id = p_organization_id;
|
|
return query select v_existing.id, v_existing.balance_after_fen, v_wallet.balance_fen,
|
|
v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_existing.created_at,
|
|
v_wallet.updated_at, v_existing.delta_fen;
|
|
return;
|
|
end if;
|
|
|
|
insert into billing_wallets(organization_id)
|
|
values (p_organization_id)
|
|
on conflict (organization_id) do nothing;
|
|
|
|
select * into v_wallet from billing_wallets
|
|
where organization_id = p_organization_id
|
|
for update;
|
|
|
|
if p_delta_fen < 0 and v_wallet.balance_fen < abs(p_delta_fen) then
|
|
raise exception 'BILLING_INSUFFICIENT_BALANCE';
|
|
end if;
|
|
|
|
update billing_wallets
|
|
set balance_fen = v_wallet.balance_fen + p_delta_fen,
|
|
total_recharged_fen = v_wallet.total_recharged_fen + case when p_kind = 'recharge' and p_delta_fen > 0 then p_delta_fen else 0 end,
|
|
total_charged_fen = v_wallet.total_charged_fen + case when p_kind = 'charge' and p_delta_fen < 0 then abs(p_delta_fen) else 0 end,
|
|
updated_at = now()
|
|
where organization_id = p_organization_id
|
|
returning * into v_wallet;
|
|
|
|
insert into billing_ledger(
|
|
id, organization_id, account_id, job_id, kind, delta_fen,
|
|
balance_after_fen, currency, idempotency_key, description, metadata
|
|
) values (
|
|
p_ledger_id, p_organization_id,
|
|
v_account_id,
|
|
p_job_id, p_kind, p_delta_fen,
|
|
v_wallet.balance_fen, p_currency, p_idempotency_key, p_description, coalesce(p_metadata, '{}'::jsonb)
|
|
) returning * into v_entry;
|
|
|
|
return query select v_entry.id, v_entry.balance_after_fen, v_wallet.balance_fen,
|
|
v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_entry.created_at,
|
|
v_wallet.updated_at, v_entry.delta_fen;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) from public;
|