feat: add tenant and user management with migrations, handlers, and tests

- Implemented tenant and user management features including:
  - Tenant creation and management with associated migrations.
  - User creation and management with associated migrations.
  - Tenant membership management with associated migrations.
  - Platform user roles management with associated migrations.
  - Quota management with associated migrations.
  - Article and template management with associated migrations.
- Added HTTP handlers for templates and workspaces.
- Created tests for protected and public routes.
- Introduced a script to check tenant scope in SQL queries.
- Documented task plan for backend completion and frontend foundation.
This commit is contained in:
2026-04-01 00:58:42 +08:00
commit de30497f59
210 changed files with 23733 additions and 0 deletions
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenants;
@@ -0,0 +1,10 @@
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
frozen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_tenant_name_active ON tenants(name) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
@@ -0,0 +1,13 @@
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
avatar_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenant_memberships;
@@ -0,0 +1,10 @@
CREATE TABLE tenant_memberships (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_role VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_tenant_user_active ON tenant_memberships(tenant_id, user_id) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS platform_user_roles;
@@ -0,0 +1,9 @@
CREATE TABLE platform_user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
platform_role VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_platform_user_role_active ON platform_user_roles(user_id, platform_role) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS plans;
@@ -0,0 +1,11 @@
CREATE TABLE plans (
id BIGSERIAL PRIMARY KEY,
plan_code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
quota_policy_json JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_plan_code_active ON plans(plan_code) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenant_plan_subscriptions;
@@ -0,0 +1,12 @@
CREATE TABLE tenant_plan_subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
plan_id BIGINT NOT NULL REFERENCES plans(id),
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_tenant_plan_active ON tenant_plan_subscriptions(tenant_id) WHERE status = 'active' AND deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tenant_quota_ledgers;
@@ -0,0 +1,12 @@
CREATE TABLE tenant_quota_ledgers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
quota_type VARCHAR(50) NOT NULL,
delta INT NOT NULL,
balance_after INT NOT NULL,
reason VARCHAR(255),
reference_type VARCHAR(50),
reference_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tenant_quota_tenant_type_created ON tenant_quota_ledgers(tenant_id, quota_type, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS quota_reservations;
@@ -0,0 +1,15 @@
CREATE TABLE quota_reservations (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
quota_type VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id BIGINT NOT NULL,
reserved_amount INT NOT NULL,
consumed_amount INT NOT NULL DEFAULT 0,
refunded_amount INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expire_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS article_templates;
@@ -0,0 +1,20 @@
CREATE TABLE article_templates (
id BIGSERIAL PRIMARY KEY,
scope VARCHAR(20) NOT NULL,
tenant_id BIGINT REFERENCES tenants(id),
origin_type VARCHAR(20) NOT NULL DEFAULT 'platform',
share_code_id BIGINT,
template_key VARCHAR(100) NOT NULL,
template_name VARCHAR(200) NOT NULL,
schema_json JSONB NOT NULL DEFAULT '{}',
prompt_template TEXT,
prompt_visibility VARCHAR(20) NOT NULL DEFAULT 'visible',
protected_prompt_asset_key TEXT,
card_config_json JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
version_no INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_template_scope_key_version_active ON article_templates(scope, tenant_id, template_key, version_no) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS articles;
@@ -0,0 +1,15 @@
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
source_type VARCHAR(30) NOT NULL,
template_id BIGINT REFERENCES article_templates(id),
prompt_rule_id BIGINT,
current_version_id BIGINT,
generate_status VARCHAR(20) NOT NULL DEFAULT 'pending',
publish_status VARCHAR(20) NOT NULL DEFAULT 'unpublished',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_article_tenant_created ON articles(tenant_id, created_at DESC);
CREATE INDEX idx_article_tenant_status ON articles(tenant_id, generate_status, publish_status, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS article_versions;
@@ -0,0 +1,12 @@
CREATE TABLE article_versions (
id BIGSERIAL PRIMARY KEY,
article_id BIGINT NOT NULL REFERENCES articles(id),
version_no INT NOT NULL,
title VARCHAR(500),
html_content TEXT,
markdown_content TEXT,
word_count INT NOT NULL DEFAULT 0,
source_label VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_article_version_no UNIQUE (article_id, version_no)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS generation_tasks;
@@ -0,0 +1,18 @@
CREATE TABLE generation_tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT REFERENCES articles(id),
task_batch_id VARCHAR(100),
quota_reservation_id BIGINT REFERENCES quota_reservations(id),
task_type VARCHAR(50) NOT NULL,
request_hash VARCHAR(64),
input_params_json JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'queued',
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_generation_task_status_created ON generation_tasks(status, created_at);
CREATE INDEX idx_generation_task_tenant ON generation_tasks(tenant_id, created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS brands;
@@ -0,0 +1,11 @@
CREATE TABLE brands (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
name VARCHAR(200) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_brand_tenant_name_active ON brands(tenant_id, name) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS brand_keywords;
@@ -0,0 +1,11 @@
CREATE TABLE brand_keywords (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_brand_keyword_name_active ON brand_keywords(brand_id, name) WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS brand_questions;
@@ -0,0 +1,12 @@
CREATE TABLE brand_questions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
keyword_id BIGINT NOT NULL REFERENCES brand_keywords(id),
current_version_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_brand_questions_keyword ON brand_questions(keyword_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS brand_question_versions;
@@ -0,0 +1,11 @@
CREATE TABLE brand_question_versions (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES brand_questions(id),
question_text TEXT NOT NULL,
question_hash VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_question_version_no UNIQUE (question_id, version_no)
);
CREATE INDEX idx_question_hash ON brand_question_versions(question_hash);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS competitors;
@@ -0,0 +1,13 @@
CREATE TABLE competitors (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
name VARCHAR(200) NOT NULL,
website TEXT,
description TEXT,
product_lines_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_competitor_brand ON competitors(brand_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS task_records;
@@ -0,0 +1,14 @@
CREATE TABLE task_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
task_type VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
task_batch_id VARCHAR(100),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_task_record_status_type ON task_records(status, task_type);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_logs;
@@ -0,0 +1,12 @@
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
operator_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT,
module VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
before_json JSONB,
after_json JSONB,
result VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_module_created ON audit_logs(module, created_at DESC);