feat: scaffold supabase multi-tenant backend

This commit is contained in:
Codex
2026-06-21 21:54:43 +08:00
commit c9c767c7bd
99 changed files with 36660 additions and 0 deletions

View File

@@ -0,0 +1,995 @@
create extension if not exists pgcrypto;
create extension if not exists citext;
create schema if not exists app;
create schema if not exists app_private;
revoke all on schema app_private from public;
revoke all on schema app_private from anon;
revoke all on schema app_private from authenticated;
create or replace function app.touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create or replace function app.jwt_text(claim_name text)
returns text
language sql
stable
as $$
select nullif(coalesce(auth.jwt() ->> claim_name, current_setting('request.jwt.claim.' || claim_name, true)), '')
$$;
create or replace function app.current_tenant_id()
returns uuid
language sql
stable
as $$
select app.jwt_text('tenant_id')::uuid
$$;
create or replace function app.current_role()
returns text
language sql
stable
as $$
select coalesce(app.jwt_text('app_role'), app.jwt_text('role'), '')
$$;
create or replace function app.is_platform_admin()
returns boolean
language sql
stable
as $$
select app.current_role() in ('platform_admin', 'service_role')
$$;
create table if not exists public.tenants (
id uuid primary key default gen_random_uuid(),
slug citext not null unique,
name text not null,
legal_name text,
status text not null default 'active' check (status in ('draft', 'active', 'suspended', 'archived')),
mode text not null default 'saas' check (mode in ('platform_owned', 'saas', 'dedicated')),
billing_status text not null default 'trial' check (billing_status in ('trial', 'active', 'past_due', 'cancelled')),
owner_user_id uuid,
legacy_id text,
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 public.platform_users (
id uuid primary key default gen_random_uuid(),
auth_user_id uuid unique references auth.users(id) on delete set null,
legacy_id text unique,
username text,
email citext,
phone text,
name text,
avatar_url text,
primary_role text not null default 'student',
score integer not null default 0,
last_seen_at timestamptz,
legacy_password_hash text,
password_migration_required boolean not null default false,
raw_profile jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.tenants
drop constraint if exists tenants_owner_user_id_fkey,
add constraint tenants_owner_user_id_fkey
foreign key (owner_user_id) references public.platform_users(id) on delete set null;
create table if not exists public.user_identities (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.platform_users(id) on delete cascade,
provider text not null,
provider_subject text not null,
union_id text,
open_id text,
phone text,
email citext,
secret_payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (provider, provider_subject)
);
create table if not exists public.tenant_memberships (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
role text not null check (role in ('platform_admin', 'tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student')),
status text not null default 'active' check (status in ('active', 'invited', 'disabled')),
permissions jsonb not null default '{}'::jsonb,
legacy_role text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, user_id, role)
);
create table if not exists public.tenant_domains (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
host citext not null unique,
domain_type text not null default 'custom' check (domain_type in ('system', 'custom', 'miniapp')),
status text not null default 'pending' check (status in ('pending', 'active', 'failed', 'disabled')),
is_primary boolean not null default false,
verification_token text,
verified_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.tenant_branding (
tenant_id uuid primary key references public.tenants(id) on delete cascade,
brand_name text not null,
short_name text,
slogan text,
org_name text,
logo_url text,
favicon_url text,
service_wechat text,
service_account_name text,
theme jsonb not null default '{}'::jsonb,
public_assets jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.tenant_settings (
tenant_id uuid primary key references public.tenants(id) on delete cascade,
feature_flags jsonb not null default '{}'::jsonb,
admin_feature_flags jsonb not null default '{}'::jsonb,
public_config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.tenant_payment_accounts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null,
mode text not null default 'platform_collect' check (mode in ('platform_collect', 'tenant_collect', 'service_provider')),
display_name text,
status text not null default 'disabled' check (status in ('active', 'disabled', 'pending')),
config_public jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider)
);
create table if not exists app_private.tenant_secrets (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
secret_scope text not null check (secret_scope in ('payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system')),
secret_key text not null,
secret_value text,
secret_json jsonb not null default '{}'::jsonb,
provider text,
last_rotated_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, secret_scope, secret_key)
);
comment on table app_private.tenant_secrets is
'Private tenant secrets. Do not expose through PostgREST/anon/authenticated roles. Prefer external vault or encrypted values in production.';
create table if not exists public.tenant_subscriptions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
plan_code text not null,
status text not null default 'trial' check (status in ('trial', 'active', 'past_due', 'cancelled')),
starts_at timestamptz,
expires_at timestamptz,
billing_cycle text default 'yearly',
amount_cents integer not null default 0,
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 public.tenant_usage_records (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
metric_key text not null,
metric_value numeric not null default 0,
period_start date not null,
period_end date not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists public.regions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
name text not null,
code text,
short_name text,
full_name text,
icon text,
pinyin text,
sort_order integer not null default 0,
is_hot boolean not null default false,
is_active boolean not null default true,
config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.region_modules (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
name text not null,
type text,
icon text,
color text,
text_color text,
description text,
route text,
sort_order integer not null default 0,
is_primary_school_module boolean not null default false,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.module_nodes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
module_id uuid references public.region_modules(id) on delete set null,
parent_id uuid references public.module_nodes(id) on delete cascade,
legacy_id text,
legacy_parent_id text,
legacy_module_id text,
type text not null check (type in ('category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom')),
name text not null,
path text,
sort_order integer not null default 0,
is_active boolean not null default true,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.schools (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
module_id uuid references public.region_modules(id) on delete set null,
legacy_id text,
name text not null,
professional_exam_date text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.majors (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
school_id uuid references public.schools(id) on delete cascade,
legacy_id text,
name text not null,
description text,
study_tips text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.subjects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
module_id uuid references public.region_modules(id) on delete set null,
school_id uuid references public.schools(id) on delete set null,
major_id uuid references public.majors(id) on delete set null,
node_id uuid references public.module_nodes(id) on delete set null,
legacy_id text,
name text not null,
type text check (type in ('cultural', 'professional')),
major_legacy_ids jsonb not null default '[]'::jsonb,
icon text,
description text,
stats jsonb not null default '{}'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.categories (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
subject_id uuid references public.subjects(id) on delete cascade,
node_id uuid references public.module_nodes(id) on delete set null,
legacy_id text,
name text not null,
category_type text check (category_type in ('chapter', 'paper')),
sort_order integer not null default 0,
svip_question_limit integer,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.question_banks (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
name text not null,
source_scope text not null default 'tenant' check (source_scope in ('platform', 'tenant')),
status text not null default 'active' check (status in ('active', 'archived')),
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 public.questions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
question_bank_id uuid references public.question_banks(id) on delete set null,
subject_id uuid references public.subjects(id) on delete set null,
category_id uuid references public.categories(id) on delete set null,
node_id uuid references public.module_nodes(id) on delete set null,
legacy_id text,
legacy_subject_id text,
legacy_category_id text,
legacy_node_id text,
type text not null default 'choice',
type_label text,
difficulty integer,
tags jsonb not null default '[]'::jsonb,
media_url text,
has_video_explanation boolean not null default false,
status text not null default 'published' check (status in ('draft', 'published', 'archived')),
current_version_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.question_versions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
question_id uuid not null references public.questions(id) on delete cascade,
version_no integer not null default 1,
content text,
options jsonb not null default '[]'::jsonb,
correct_option_index integer,
correct_option_indices jsonb not null default '[]'::jsonb,
answer_text text,
explanation text,
sub_questions jsonb not null default '[]'::jsonb,
code_lang text,
code_template text,
source_hash text,
created_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
unique (question_id, version_no)
);
alter table public.questions
drop constraint if exists questions_current_version_id_fkey,
add constraint questions_current_version_id_fkey
foreign key (current_version_id) references public.question_versions(id) on delete set null;
create table if not exists public.student_profiles (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
legacy_user_id text,
region_id uuid references public.regions(id) on delete set null,
selected_school_id uuid references public.schools(id) on delete set null,
selected_major_id uuid references public.majors(id) on delete set null,
questions_answered_today integer not null default 0,
mastered_words_count integer not null default 0,
last_check_in_date date,
stats jsonb not null default '{}'::jsonb,
progress jsonb not null default '{}'::jsonb,
module_selections jsonb not null default '{}'::jsonb,
recent_activities jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, user_id)
);
create table if not exists public.practice_sessions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
mode text not null default 'chapter',
target_type text,
target_id uuid,
started_at timestamptz not null default now(),
finished_at timestamptz,
metadata jsonb not null default '{}'::jsonb
);
create table if not exists public.answer_records (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
question_id uuid references public.questions(id) on delete set null,
question_version_id uuid references public.question_versions(id) on delete set null,
practice_session_id uuid references public.practice_sessions(id) on delete set null,
legacy_id text,
legacy_question_id text,
legacy_category_id text,
selected_options jsonb not null default '[]'::jsonb,
answer_text text,
is_correct boolean,
answered_at timestamptz not null default now(),
created_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.favorite_questions (
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
question_id uuid not null references public.questions(id) on delete cascade,
source text not null default 'imported',
created_at timestamptz not null default now(),
primary key (tenant_id, user_id, question_id)
);
create table if not exists public.wrong_questions (
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
question_id uuid not null references public.questions(id) on delete cascade,
wrong_count integer not null default 1,
last_wrong_at timestamptz not null default now(),
resolved_at timestamptz,
primary key (tenant_id, user_id, question_id)
);
create table if not exists public.svip_plans (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
name text not null,
price_cents integer not null default 0,
original_price_cents integer,
days integer not null default 0,
description text,
per_day_label text,
badge text,
recommended boolean not null default false,
coupon_only boolean not null default false,
vp_product_id text,
vp_enabled boolean not null default false,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.orders (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete set null,
legacy_id text,
legacy_user_id text,
order_no text not null,
status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'closed', 'refunded')),
product_type text,
product_name text,
amount_cents integer not null default 0,
pay_method text,
pay_provider text,
trade_no text,
plan_id uuid references public.svip_plans(id) on delete set null,
legacy_plan_id text,
days integer,
region_id uuid references public.regions(id) on delete set null,
legacy_region_id text,
paid_at timestamptz,
raw_payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, order_no),
unique (tenant_id, legacy_id)
);
create table if not exists public.order_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
order_id uuid not null references public.orders(id) on delete cascade,
legacy_id text,
item_type text not null,
item_id uuid,
name text not null,
quantity integer not null default 1,
unit_amount_cents integer not null default 0,
total_amount_cents integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
unique (tenant_id, legacy_id)
);
create table if not exists public.payments (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
order_id uuid not null references public.orders(id) on delete cascade,
legacy_id text,
legacy_order_id text,
provider text not null,
method text,
status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'cancelled', 'refunded')),
amount_cents integer not null,
provider_trade_no text,
paid_at timestamptz,
raw_payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.payment_events (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
payment_id uuid references public.payments(id) on delete set null,
provider text not null,
event_type text not null,
event_id text,
signature_valid boolean,
payload jsonb not null,
processed_at timestamptz,
error text,
created_at timestamptz not null default now(),
unique (provider, event_id)
);
create table if not exists public.entitlements (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
entitlement_type text not null default 'svip',
scope_type text not null default 'tenant' check (scope_type in ('tenant', 'region', 'module', 'subject', 'question_bank')),
scope_id uuid,
source_type text not null default 'migration',
source_id uuid,
legacy_source_id text,
starts_at timestamptz not null default now(),
expires_at timestamptz,
status text not null default 'active' check (status in ('active', 'revoked', 'expired')),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists public.code_batches (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
name text not null,
sale_type text,
channel text,
campaign_name text,
default_unit_price_cents integer not null default 0,
cost_price_cents integer not null default 0,
total_count integer not null default 0,
days integer,
region_id uuid references public.regions(id) on delete set null,
legacy_region_id text,
issued_at timestamptz,
created_by uuid references public.platform_users(id) on delete set null,
remark text,
commission_rate numeric(6,4),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.activation_codes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
code citext not null,
days integer not null default 0,
is_used boolean not null default false,
used_by uuid references public.platform_users(id) on delete set null,
used_at timestamptz,
agent_user_id uuid references public.platform_users(id) on delete set null,
batch_id uuid references public.code_batches(id) on delete set null,
sale_type text,
unit_price_cents integer,
sold_to text,
used_region_id uuid references public.regions(id) on delete set null,
coupon_code text,
coupon_redemption_id uuid,
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, code),
unique (tenant_id, legacy_id)
);
create table if not exists public.coupons (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
code citext not null,
plan_id uuid references public.svip_plans(id) on delete set null,
discount_type text check (discount_type in ('percent', 'fixed')),
discount_value numeric,
valid_from timestamptz,
valid_to timestamptz,
max_uses integer,
used_count integer not null default 0,
source text,
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, code),
unique (tenant_id, legacy_id)
);
create table if not exists public.coupon_redemptions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
coupon_id uuid references public.coupons(id) on delete set null,
coupon_code text,
user_id uuid references public.platform_users(id) on delete set null,
plan_id uuid references public.svip_plans(id) on delete set null,
order_id uuid references public.orders(id) on delete set null,
status text not null default 'claimed',
discount_applied_cents integer,
region_id uuid references public.regions(id) on delete set null,
source text,
remark text,
claimed_at timestamptz,
used_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.vocabulary_units (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
name text not null,
description text,
word_count integer,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.vocabulary_words (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
unit_id uuid references public.vocabulary_units(id) on delete set null,
legacy_id text,
word text not null,
phonetic text,
meaning text,
example text,
example_translation text,
difficulty integer,
tags jsonb not null default '[]'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.handbook_subjects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
name text not null,
type text,
icon text,
color text,
description text,
sort_order integer not null default 0,
is_active boolean not null default true,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.handbook_chapters (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
subject_id uuid references public.handbook_subjects(id) on delete cascade,
legacy_id text,
name text not null,
description text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.handbook_entries (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
chapter_id uuid references public.handbook_chapters(id) on delete cascade,
legacy_id text,
title text not null,
summary text,
content text,
tags jsonb not null default '[]'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.banners (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
title text,
subtitle text,
content text,
button_text text,
button_link text,
bg_color text,
border_color text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.faqs (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
question text,
answer text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.announcements (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
content text,
link text,
bg_color text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.reports (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
question_id uuid references public.questions(id) on delete set null,
user_id uuid references public.platform_users(id) on delete set null,
type text,
description text,
status text not null default 'pending',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.audit_logs (
id uuid primary key default gen_random_uuid(),
tenant_id uuid references public.tenants(id) on delete cascade,
actor_user_id uuid references public.platform_users(id) on delete set null,
action text not null,
target_type text,
target_id text,
details jsonb not null default '{}'::jsonb,
ip_address text,
user_agent text,
created_at timestamptz not null default now()
);
create table if not exists public.crm_config (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
enabled boolean not null default false,
url text,
secret_ref text,
form_name text,
exam_type text,
timeout_sec integer,
delay_sec integer,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id)
);
create table if not exists public.pb_import_runs (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
source_name text not null,
source_kind text not null default 'json',
status text not null default 'running' check (status in ('running', 'completed', 'failed')),
stats jsonb not null default '{}'::jsonb,
started_at timestamptz not null default now(),
finished_at timestamptz
);
create table if not exists public.pb_raw_records (
run_id uuid not null references public.pb_import_runs(id) on delete cascade,
tenant_id uuid not null references public.tenants(id) on delete cascade,
collection_name text not null,
legacy_id text not null,
record jsonb not null,
normalized boolean not null default false,
errors jsonb not null default '[]'::jsonb,
imported_at timestamptz not null default now(),
primary key (run_id, collection_name, legacy_id)
);
create table if not exists public.pb_import_issues (
id uuid primary key default gen_random_uuid(),
run_id uuid references public.pb_import_runs(id) on delete cascade,
tenant_id uuid not null references public.tenants(id) on delete cascade,
collection_name text not null,
legacy_id text,
severity text not null check (severity in ('info', 'warning', 'error', 'critical')),
issue_code text not null,
message text not null,
field_path text,
raw_value_sample text,
created_at timestamptz not null default now()
);
create index if not exists idx_tenant_domains_host on public.tenant_domains(host);
create index if not exists idx_memberships_tenant_user on public.tenant_memberships(tenant_id, user_id);
create index if not exists idx_regions_tenant_active on public.regions(tenant_id, is_active, sort_order);
create index if not exists idx_module_nodes_tenant_parent on public.module_nodes(tenant_id, parent_id, sort_order);
create index if not exists idx_questions_tenant_subject on public.questions(tenant_id, subject_id);
create index if not exists idx_questions_tenant_node on public.questions(tenant_id, node_id);
create index if not exists idx_question_versions_question on public.question_versions(question_id, version_no desc);
create index if not exists idx_orders_tenant_status on public.orders(tenant_id, status, created_at desc);
create index if not exists idx_orders_tenant_user on public.orders(tenant_id, user_id, created_at desc);
create index if not exists idx_entitlements_tenant_user on public.entitlements(tenant_id, user_id, status, expires_at);
create index if not exists idx_raw_records_lookup on public.pb_raw_records(tenant_id, collection_name, legacy_id);
create index if not exists idx_import_issues_run on public.pb_import_issues(run_id, severity, collection_name);
create index if not exists idx_tenant_secrets_lookup on app_private.tenant_secrets(tenant_id, secret_scope, secret_key);
alter table public.tenants enable row level security;
alter table public.platform_users enable row level security;
alter table public.user_identities enable row level security;
alter table app_private.tenant_secrets enable row level security;
drop policy if exists platform_admin_tenants on public.tenants;
create policy platform_admin_tenants on public.tenants
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop policy if exists tenant_member_can_read_own_tenants on public.tenants;
create policy tenant_member_can_read_own_tenants on public.tenants
for select
using (
exists (
select 1
from public.tenant_memberships tm
join public.platform_users pu on pu.id = tm.user_id
where tm.tenant_id = tenants.id
and pu.auth_user_id = auth.uid()
and tm.status = 'active'
)
);
drop policy if exists platform_admin_platform_users on public.platform_users;
create policy platform_admin_platform_users on public.platform_users
for all
using (app.is_platform_admin() or auth_user_id = auth.uid())
with check (app.is_platform_admin() or auth_user_id = auth.uid());
drop policy if exists platform_admin_user_identities on public.user_identities;
create policy platform_admin_user_identities on public.user_identities
for all
using (
app.is_platform_admin()
or exists (
select 1 from public.platform_users pu
where pu.id = user_id and pu.auth_user_id = auth.uid()
)
)
with check (
app.is_platform_admin()
or exists (
select 1 from public.platform_users pu
where pu.id = user_id and pu.auth_user_id = auth.uid()
)
);
drop policy if exists platform_admin_tenant_secrets on app_private.tenant_secrets;
create policy platform_admin_tenant_secrets on app_private.tenant_secrets
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
do $$
declare
table_name text;
begin
foreach table_name in array array[
'tenant_domains', 'tenant_branding', 'tenant_settings', 'tenant_payment_accounts',
'tenant_subscriptions', 'tenant_usage_records', 'tenant_memberships',
'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects',
'categories', 'question_banks', 'questions', 'question_versions',
'student_profiles', 'practice_sessions', 'answer_records', 'favorite_questions',
'wrong_questions', 'svip_plans', 'orders', 'order_items', 'payments',
'payment_events', 'entitlements', 'code_batches', 'activation_codes',
'coupons', 'coupon_redemptions', 'vocabulary_units', 'vocabulary_words',
'handbook_subjects', 'handbook_chapters', 'handbook_entries', 'banners',
'faqs', 'announcements', 'reports', 'audit_logs', 'crm_config',
'pb_import_runs', 'pb_raw_records', 'pb_import_issues'
]
loop
execute format('alter table public.%I enable row level security', table_name);
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
end loop;
end $$;
do $$
declare
table_name text;
begin
foreach table_name in array array[
'tenants', 'platform_users', 'user_identities', 'tenant_domains', 'tenant_branding',
'tenant_settings', 'tenant_payment_accounts', 'tenant_subscriptions',
'regions', 'region_modules', 'module_nodes', 'schools', 'majors', 'subjects',
'categories', 'question_banks', 'questions', 'student_profiles', 'svip_plans',
'orders', 'payments', 'code_batches', 'activation_codes', 'coupons',
'coupon_redemptions', 'vocabulary_units', 'vocabulary_words', 'handbook_subjects',
'handbook_chapters', 'handbook_entries', 'banners', 'faqs', 'announcements',
'reports', 'crm_config'
]
loop
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;

View File

@@ -0,0 +1,257 @@
create table if not exists public.products (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
title text not null,
price_label text,
link text,
type text,
tags jsonb not null default '[]'::jsonb,
cover text,
preview_iframe text,
detail_images jsonb not null default '[]'::jsonb,
sort_order integer not null default 0,
status text not null default 'active' check (status in ('active', 'inactive', 'archived')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.timelines (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
school_id uuid references public.schools(id) on delete set null,
legacy_id text,
type text,
title text not null,
description text,
event_date date,
link text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.video_explanations (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
title text not null,
description text,
video_url text,
thumbnail_url text,
duration_seconds integer,
knowledge_tags jsonb not null default '[]'::jsonb,
is_general boolean not null default false,
subject_id uuid references public.subjects(id) on delete set null,
legacy_subject_id text,
difficulty integer,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.question_videos (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
question_id uuid references public.questions(id) on delete cascade,
video_id uuid references public.video_explanations(id) on delete cascade,
legacy_id text,
legacy_question_id text,
legacy_video_id text,
video_type text not null default 'specific',
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.scoreline_schools (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
name text not null,
short_name text,
type text,
is_hot boolean not null default false,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.scoreline_majors (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
school_id uuid references public.scoreline_schools(id) on delete cascade,
legacy_id text,
name text not null,
sort_order integer not null default 0,
has_restriction boolean not null default false,
restriction_desc text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.scoreline_fields (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
field_key text not null,
field_name text not null,
field_type text,
unit text,
is_filter boolean not null default false,
is_required boolean not null default false,
is_visible boolean not null default true,
is_trend boolean not null default false,
options jsonb not null default '[]'::jsonb,
placeholder text,
description text,
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id),
unique (tenant_id, region_id, field_key)
);
create table if not exists public.scoreline_records (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
school_id uuid references public.scoreline_schools(id) on delete set null,
major_id uuid references public.scoreline_majors(id) on delete set null,
legacy_id text,
year integer not null,
school_name text,
major_name text,
field_values jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.referral_tracks (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
event_type text not null,
ref_code text,
ref_user_id uuid references public.platform_users(id) on delete set null,
target_user_id uuid references public.platform_users(id) on delete set null,
source text,
ip_address text,
user_agent text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.badges (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
name text not null,
description text,
category text,
icon_url text,
level integer,
unlock_type text,
condition_field text,
condition_operator text,
condition_value numeric,
condition_extra jsonb not null default '{}'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.user_badges (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete cascade,
badge_id uuid references public.badges(id) on delete cascade,
granted_by uuid references public.platform_users(id) on delete set null,
legacy_id text,
note text,
granted_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.crm_webhook_queue (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
record_id text,
status text not null default 'pending',
scheduled_at timestamptz,
attempts integer not null default 0,
next_attempt_at timestamptz,
last_error text,
last_http_code integer,
lead_id text,
sent_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.crm_webhook_log (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
record_id text,
http_code integer,
outcome text,
error_message text,
lead_id text,
request_body text,
response_summary text,
signed_at timestamptz,
attempt integer,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create index if not exists idx_products_tenant_region on public.products(tenant_id, region_id, sort_order);
create index if not exists idx_timelines_tenant_region on public.timelines(tenant_id, region_id, event_date);
create index if not exists idx_video_explanations_tenant_subject on public.video_explanations(tenant_id, subject_id);
create index if not exists idx_scoreline_records_tenant_year on public.scoreline_records(tenant_id, region_id, year desc);
create index if not exists idx_referral_tracks_tenant_event on public.referral_tracks(tenant_id, event_type, created_at desc);
create index if not exists idx_crm_queue_tenant_status on public.crm_webhook_queue(tenant_id, status, next_attempt_at);
do $$
declare
table_name text;
begin
foreach table_name in array array[
'products', 'timelines', 'video_explanations', 'question_videos',
'scoreline_schools', 'scoreline_majors', 'scoreline_fields', 'scoreline_records',
'referral_tracks', 'badges', 'user_badges', 'crm_webhook_queue', 'crm_webhook_log'
]
loop
execute format('alter table public.%I enable row level security', table_name);
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;

View File

@@ -0,0 +1,180 @@
create table if not exists public.exam_dates (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
school_id uuid references public.schools(id) on delete set null,
legacy_id text,
exam_name text not null,
exam_date date,
exam_type text,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.question_type_groups (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
subject_id uuid references public.subjects(id) on delete cascade,
legacy_id text,
legacy_subject_id text,
display_name text not null,
types jsonb not null default '[]'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.subject_shares (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
source_subject_id uuid references public.subjects(id) on delete cascade,
target_subject_id uuid references public.subjects(id) on delete cascade,
legacy_id text,
legacy_source_subject_id text,
legacy_target_subject_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.recent_practices (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete cascade,
legacy_id text,
practice_type text,
target_legacy_id text,
target_name text,
progress integer not null default 0,
color text,
last_access_at timestamptz,
last_practice_at timestamptz,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.user_word_progress (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete cascade,
word_id uuid references public.vocabulary_words(id) on delete cascade,
legacy_id text,
legacy_user_id text,
legacy_word_id text,
status text not null default 'new' check (status in ('new', 'learning', 'mastered', 'reviewing')),
correct_count integer not null default 0,
wrong_count integer not null default 0,
last_review_date timestamptz,
next_review_date timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id),
unique (tenant_id, user_id, word_id)
);
create table if not exists public.user_word_favorites (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete cascade,
word_id uuid references public.vocabulary_words(id) on delete cascade,
legacy_id text,
legacy_user_id text,
legacy_word_id text,
note text,
favorited_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id),
unique (tenant_id, user_id, word_id)
);
create table if not exists public.content_assets (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
asset_key text,
title text,
category text,
description text,
file_name text,
cdn_url text,
is_public boolean not null default false,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.dashboard_daily_stats (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
stat_date date not null,
region_id uuid references public.regions(id) on delete set null,
legacy_region_id text,
new_users integer not null default 0,
new_questions integer not null default 0,
new_orders integer not null default 0,
new_revenue_cents integer not null default 0,
active_users integer not null default 0,
rebuilt_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, stat_date, legacy_region_id)
);
create table if not exists public.revenue_daily_stats (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
stat_date date not null,
region_id uuid references public.regions(id) on delete set null,
legacy_region_id text,
sale_type text,
real_revenue_cents integer not null default 0,
order_count integer not null default 0,
code_count integer not null default 0,
code_used integer not null default 0,
code_estimated_cents integer not null default 0,
estimated_revenue_cents integer not null default 0,
rebuilt_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, stat_date, legacy_region_id, sale_type)
);
create index if not exists idx_exam_dates_tenant_region on public.exam_dates(tenant_id, region_id, exam_date);
create index if not exists idx_question_type_groups_subject on public.question_type_groups(tenant_id, subject_id, sort_order);
create index if not exists idx_recent_practices_user on public.recent_practices(tenant_id, user_id, last_practice_at desc);
create index if not exists idx_user_word_progress_user on public.user_word_progress(tenant_id, user_id, status);
create index if not exists idx_content_assets_tenant_key on public.content_assets(tenant_id, asset_key);
create index if not exists idx_dashboard_daily_stats_date on public.dashboard_daily_stats(tenant_id, stat_date desc);
create index if not exists idx_revenue_daily_stats_date on public.revenue_daily_stats(tenant_id, stat_date desc);
do $$
declare
table_name text;
begin
foreach table_name in array array[
'exam_dates', 'question_type_groups', 'subject_shares', 'recent_practices',
'user_word_progress', 'user_word_favorites', 'content_assets',
'dashboard_daily_stats', 'revenue_daily_stats'
]
loop
execute format('alter table public.%I enable row level security', table_name);
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;

View File

@@ -0,0 +1,122 @@
create table if not exists public.tenant_auth_providers (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null,
status text not null default 'disabled' check (status in ('active', 'disabled', 'testing')),
display_name text,
config_public jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider)
);
comment on table public.tenant_auth_providers is
'Public, non-secret auth provider settings for each tenant. Secrets stay in app_private.tenant_secrets or an external vault.';
create table if not exists public.sms_verification_codes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
phone text not null,
purpose text not null default 'login' check (purpose in ('login', 'bind_phone', 'reset_password')),
code_hash text not null,
provider text not null default 'mock',
status text not null default 'pending' check (status in ('pending', 'sent', 'verified', 'expired', 'blocked')),
attempts integer not null default 0,
expires_at timestamptz not null,
consumed_at timestamptz,
ip_address text,
user_agent text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
comment on table public.sms_verification_codes is
'SMS verification records. Plain verification codes are never stored; only one-way hashes are kept.';
create table if not exists public.auth_login_events (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete set null,
provider text not null,
identifier text,
result text not null check (result in ('sent', 'success', 'failed', 'blocked')),
failure_code text,
ip_address text,
user_agent text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists app_private.auth_sessions (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
token_hash text not null unique,
provider text not null,
expires_at timestamptz not null,
revoked_at timestamptz,
ip_address text,
user_agent text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table app_private.auth_sessions is
'API-issued session token hashes for the migration period before full Supabase Auth JWT adoption.';
create index if not exists idx_auth_providers_tenant_status
on public.tenant_auth_providers(tenant_id, provider, status);
create index if not exists idx_sms_codes_tenant_phone_purpose
on public.sms_verification_codes(tenant_id, phone, purpose, expires_at desc);
create index if not exists idx_sms_codes_pending_lookup
on public.sms_verification_codes(tenant_id, phone, purpose, created_at desc)
where consumed_at is null and status in ('pending', 'sent');
create index if not exists idx_auth_login_events_tenant_user
on public.auth_login_events(tenant_id, user_id, created_at desc);
create index if not exists idx_auth_sessions_user
on app_private.auth_sessions(tenant_id, user_id, expires_at desc)
where revoked_at is null;
alter table public.tenant_auth_providers enable row level security;
alter table public.sms_verification_codes enable row level security;
alter table public.auth_login_events enable row level security;
alter table app_private.auth_sessions enable row level security;
drop policy if exists tenant_isolation on public.tenant_auth_providers;
create policy tenant_isolation on public.tenant_auth_providers
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.sms_verification_codes;
create policy tenant_isolation on public.sms_verification_codes
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.auth_login_events;
create policy tenant_isolation on public.auth_login_events
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists platform_admin_auth_sessions on app_private.auth_sessions;
create policy platform_admin_auth_sessions on app_private.auth_sessions
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop trigger if exists set_updated_at on public.tenant_auth_providers;
create trigger set_updated_at
before update on public.tenant_auth_providers
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on app_private.auth_sessions;
create trigger set_updated_at
before update on app_private.auth_sessions
for each row execute function app.touch_updated_at();

View File

@@ -0,0 +1,181 @@
create table if not exists public.platform_saas_plans (
id uuid primary key default gen_random_uuid(),
code text not null unique,
name text not null,
description text,
billing_cycle text not null default 'yearly' check (billing_cycle in ('monthly', 'quarterly', 'yearly', 'one_time')),
base_amount_cents integer not null default 0,
currency text not null default 'CNY',
included_quotas jsonb not null default '{}'::jsonb,
overage_prices jsonb not null default '{}'::jsonb,
feature_flags jsonb not null default '{}'::jsonb,
status text not null default 'active' check (status in ('active', 'archived')),
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.tenant_billing_profiles (
tenant_id uuid primary key references public.tenants(id) on delete cascade,
billing_name text,
tax_id text,
contact_name text,
contact_phone text,
contact_email citext,
billing_address text,
invoice_title text,
invoice_type text check (invoice_type in ('none', 'normal_vat', 'special_vat')),
bank_name text,
bank_account_masked text,
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 public.tenant_invoices (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
invoice_no text not null unique,
invoice_type text not null default 'subscription' check (invoice_type in ('subscription', 'service_fee', 'usage_overage', 'manual_adjustment')),
status text not null default 'draft' check (status in ('draft', 'issued', 'paid', 'void', 'overdue')),
currency text not null default 'CNY',
subtotal_cents integer not null default 0,
discount_cents integer not null default 0,
tax_cents integer not null default 0,
total_cents integer not null default 0,
paid_cents integer not null default 0,
balance_cents integer not null default 0,
billing_period_start date,
billing_period_end date,
due_date date,
issued_at timestamptz,
paid_at timestamptz,
note text,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.tenant_invoice_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
item_type text not null default 'subscription',
item_ref_id uuid,
description text not null,
quantity numeric(12,2) not null default 1,
unit_amount_cents integer not null default 0,
amount_cents integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists public.tenant_invoice_payments (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
payment_no text not null unique,
provider text not null default 'manual',
method text,
status text not null default 'paid' check (status in ('pending', 'paid', 'failed', 'refunded')),
amount_cents integer not null default 0,
paid_at timestamptz,
provider_trade_no text,
received_by uuid references public.platform_users(id) on delete set null,
raw_payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_saas_plans_status on public.platform_saas_plans(status, sort_order);
create index if not exists idx_tenant_subscriptions_tenant_status on public.tenant_subscriptions(tenant_id, status, expires_at desc);
create index if not exists idx_tenant_usage_records_lookup on public.tenant_usage_records(tenant_id, metric_key, period_start, period_end);
create index if not exists idx_tenant_invoices_tenant_status on public.tenant_invoices(tenant_id, status, due_date desc);
create index if not exists idx_tenant_invoice_items_invoice on public.tenant_invoice_items(invoice_id);
create index if not exists idx_tenant_invoice_payments_invoice on public.tenant_invoice_payments(invoice_id, status);
alter table public.platform_saas_plans enable row level security;
alter table public.tenant_billing_profiles enable row level security;
alter table public.tenant_invoices enable row level security;
alter table public.tenant_invoice_items enable row level security;
alter table public.tenant_invoice_payments enable row level security;
drop policy if exists platform_admin_saas_plans on public.platform_saas_plans;
create policy platform_admin_saas_plans on public.platform_saas_plans
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
do $$
declare
table_name text;
begin
foreach table_name in array array[
'tenant_billing_profiles',
'tenant_invoices',
'tenant_invoice_items',
'tenant_invoice_payments'
]
loop
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
end loop;
end $$;
do $$
declare
table_name text;
begin
foreach table_name in array array[
'platform_saas_plans',
'tenant_billing_profiles',
'tenant_invoices',
'tenant_invoice_payments'
]
loop
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;
insert into public.platform_saas_plans (
code, name, description, billing_cycle, base_amount_cents,
included_quotas, overage_prices, feature_flags, sort_order
)
values
(
'starter_yearly',
'合作商基础版',
'适合单地区题库合作商,含基础品牌与域名能力。',
'yearly',
980000,
'{"students":1000,"questions":5000,"storageGb":20}'::jsonb,
'{"studentsExtraPerYearCents":500,"storageGbPerYearCents":12000}'::jsonb,
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":false}'::jsonb,
10
),
(
'pro_yearly',
'合作商专业版',
'适合多地区、多课程运营,支持更多运营工具。',
'yearly',
1980000,
'{"students":5000,"questions":30000,"storageGb":100}'::jsonb,
'{"studentsExtraPerYearCents":300,"storageGbPerYearCents":9000}'::jsonb,
'{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":true,"crmWebhook":true}'::jsonb,
20
)
on conflict (code)
do update set name = excluded.name,
description = excluded.description,
billing_cycle = excluded.billing_cycle,
base_amount_cents = excluded.base_amount_cents,
included_quotas = excluded.included_quotas,
overage_prices = excluded.overage_prices,
feature_flags = excluded.feature_flags,
sort_order = excluded.sort_order,
updated_at = now();

View File

@@ -0,0 +1,150 @@
create table if not exists public.referral_codes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
code citext not null,
status text not null default 'active' check (status in ('active', 'disabled')),
channel text,
landing_path text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, user_id),
unique (tenant_id, code)
);
comment on table public.referral_codes is
'Tenant-scoped referral codes for sales, agents, teachers, operators, and other permitted referrers.';
create table if not exists public.referral_leads (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
student_user_id uuid not null references public.platform_users(id) on delete cascade,
referrer_user_id uuid references public.platform_users(id) on delete set null,
ref_code citext,
source text,
first_track_id uuid references public.referral_tracks(id) on delete set null,
bind_type text not null default 'first_touch' check (bind_type in ('first_touch', 'manual', 'imported')),
status text not null default 'protected' check (status in ('protected', 'invalid', 'released')),
protected_until timestamptz,
metadata jsonb not null default '{}'::jsonb,
bound_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, student_user_id)
);
comment on table public.referral_leads is
'First-binding lead ownership. Once a student is bound to a referrer, normal scan/share events cannot rebind the lead.';
create table if not exists public.referral_team_edges (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
member_user_id uuid not null references public.platform_users(id) on delete cascade,
leader_user_id uuid references public.platform_users(id) on delete set null,
relation_type text not null default 'sales_team' check (relation_type in ('sales_team', 'agent_network', 'teacher_class')),
status text not null default 'active' check (status in ('active', 'disabled')),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, member_user_id, relation_type)
);
create table if not exists public.referral_qrcodes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid references public.platform_users(id) on delete set null,
ref_code citext not null,
scene text not null,
page text not null default 'pages/index/index',
provider text not null default 'wechat-miniapp',
qrcode_url text,
status text not null default 'pending' check (status in ('pending', 'ready', 'failed', 'disabled')),
error_message text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider, scene, page)
);
alter table public.referral_tracks
add column if not exists metadata jsonb not null default '{}'::jsonb,
add column if not exists lead_id uuid references public.referral_leads(id) on delete set null;
alter table public.crm_webhook_queue
add column if not exists source text,
add column if not exists payload jsonb not null default '{}'::jsonb,
add column if not exists idempotency_key text,
add column if not exists target_url text;
alter table public.crm_webhook_log
add column if not exists request_payload jsonb not null default '{}'::jsonb;
create unique index if not exists idx_crm_queue_tenant_record_id
on public.crm_webhook_queue(tenant_id, record_id)
where record_id is not null;
create unique index if not exists idx_crm_queue_tenant_idempotency
on public.crm_webhook_queue(tenant_id, idempotency_key)
where idempotency_key is not null;
create index if not exists idx_referral_codes_tenant_code
on public.referral_codes(tenant_id, code, status);
create index if not exists idx_referral_leads_referrer
on public.referral_leads(tenant_id, referrer_user_id, bound_at desc);
create index if not exists idx_referral_team_leader
on public.referral_team_edges(tenant_id, leader_user_id, status);
create index if not exists idx_referral_tracks_lead
on public.referral_tracks(tenant_id, lead_id, created_at desc);
alter table public.referral_codes enable row level security;
alter table public.referral_leads enable row level security;
alter table public.referral_team_edges enable row level security;
alter table public.referral_qrcodes enable row level security;
drop policy if exists tenant_isolation on public.referral_codes;
create policy tenant_isolation on public.referral_codes
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.referral_leads;
create policy tenant_isolation on public.referral_leads
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.referral_team_edges;
create policy tenant_isolation on public.referral_team_edges
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.referral_qrcodes;
create policy tenant_isolation on public.referral_qrcodes
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.referral_codes;
create trigger set_updated_at
before update on public.referral_codes
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.referral_leads;
create trigger set_updated_at
before update on public.referral_leads
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.referral_team_edges;
create trigger set_updated_at
before update on public.referral_team_edges
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.referral_qrcodes;
create trigger set_updated_at
before update on public.referral_qrcodes
for each row execute function app.touch_updated_at();

View File

@@ -0,0 +1,164 @@
alter table public.content_assets
add column if not exists asset_type text not null default 'document',
add column if not exists storage_provider text not null default 'external_url',
add column if not exists bucket text,
add column if not exists object_key text,
add column if not exists mime_type text,
add column if not exists file_size_bytes bigint,
add column if not exists checksum_sha256 text,
add column if not exists visibility text not null default 'tenant',
add column if not exists region_id uuid references public.regions(id) on delete set null,
add column if not exists subject_id uuid references public.subjects(id) on delete set null,
add column if not exists category_id uuid references public.categories(id) on delete set null,
add column if not exists node_id uuid references public.module_nodes(id) on delete set null,
add column if not exists preview_url text,
add column if not exists status text not null default 'active',
add column if not exists sort_order integer not null default 0,
add column if not exists access_rules jsonb not null default '{}'::jsonb,
add column if not exists created_by uuid references public.platform_users(id) on delete set null,
add column if not exists updated_by uuid references public.platform_users(id) on delete set null,
add column if not exists source text not null default 'manual',
add column if not exists download_count integer not null default 0;
update public.content_assets
set visibility = case when is_public then 'public' else visibility end,
storage_provider = case when cdn_url is not null and cdn_url <> '' then 'external_url' else storage_provider end
where visibility = 'tenant' or storage_provider = 'external_url';
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'content_assets_asset_type_check') then
alter table public.content_assets
add constraint content_assets_asset_type_check
check (asset_type in ('pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'));
end if;
if not exists (select 1 from pg_constraint where conname = 'content_assets_storage_provider_check') then
alter table public.content_assets
add constraint content_assets_storage_provider_check
check (storage_provider in ('external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'));
end if;
if not exists (select 1 from pg_constraint where conname = 'content_assets_visibility_check') then
alter table public.content_assets
add constraint content_assets_visibility_check
check (visibility in ('public', 'tenant', 'members', 'svip', 'private'));
end if;
if not exists (select 1 from pg_constraint where conname = 'content_assets_status_check') then
alter table public.content_assets
add constraint content_assets_status_check
check (status in ('draft', 'active', 'archived'));
end if;
if not exists (select 1 from pg_constraint where conname = 'content_assets_file_size_check') then
alter table public.content_assets
add constraint content_assets_file_size_check
check (file_size_bytes is null or file_size_bytes >= 0);
end if;
if not exists (select 1 from pg_constraint where conname = 'content_assets_download_count_check') then
alter table public.content_assets
add constraint content_assets_download_count_check
check (download_count >= 0);
end if;
end $$;
create table if not exists public.content_import_jobs (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
created_by uuid references public.platform_users(id) on delete set null,
import_type text not null check (import_type in ('questions', 'vocabulary', 'handbook', 'scoreline', 'assets', 'videos')),
source_format text not null default 'json' check (source_format in ('json', 'excel', 'csv', 'pocketbase', 'api')),
status text not null default 'preview' check (status in ('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')),
source_name text,
source_hash text,
target_region_id uuid references public.regions(id) on delete set null,
target_subject_id uuid references public.subjects(id) on delete set null,
target_category_id uuid references public.categories(id) on delete set null,
target_node_id uuid references public.module_nodes(id) on delete set null,
target_question_bank_id uuid references public.question_banks(id) on delete set null,
dry_run boolean not null default true,
total_count integer not null default 0,
valid_count integer not null default 0,
error_count integer not null default 0,
warning_count integer not null default 0,
inserted_count integer not null default 0,
updated_count integer not null default 0,
skipped_count integer not null default 0,
summary jsonb not null default '{}'::jsonb,
raw_payload jsonb not null default '[]'::jsonb,
normalized_payload jsonb not null default '[]'::jsonb,
error_message text,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.content_import_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
row_no integer not null,
external_id text,
status text not null default 'valid' check (status in ('valid', 'invalid', 'inserted', 'updated', 'skipped', 'failed')),
target_type text,
target_id uuid,
source_payload jsonb not null default '{}'::jsonb,
normalized_payload jsonb not null default '{}'::jsonb,
content_hash text,
issues_count integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (job_id, row_no)
);
create table if not exists public.content_import_issues (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
job_id uuid not null references public.content_import_jobs(id) on delete cascade,
item_id uuid references public.content_import_items(id) on delete cascade,
row_no integer,
severity text not null default 'error' check (severity in ('error', 'warning')),
code text not null,
field_path text,
message text not null,
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_content_assets_filters
on public.content_assets(tenant_id, status, visibility, asset_type, region_id, subject_id, category_id, sort_order);
create index if not exists idx_content_assets_object
on public.content_assets(tenant_id, storage_provider, bucket, object_key);
create index if not exists idx_content_assets_checksum
on public.content_assets(tenant_id, checksum_sha256)
where checksum_sha256 is not null;
create index if not exists idx_content_import_jobs_tenant_status
on public.content_import_jobs(tenant_id, import_type, status, created_at desc);
create index if not exists idx_content_import_items_job_status
on public.content_import_items(tenant_id, job_id, status, row_no);
create index if not exists idx_content_import_issues_job
on public.content_import_issues(tenant_id, job_id, severity, row_no);
do $$
declare
table_name text;
begin
foreach table_name in array array[
'content_import_jobs', 'content_import_items', 'content_import_issues'
]
loop
execute format('alter table public.%I enable row level security', table_name);
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;