forked from wangziqi/gongxue-base
996 lines
38 KiB
PL/PgSQL
996 lines
38 KiB
PL/PgSQL
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 $$;
|