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

12
packages/config/package-lock.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "@tiku-saas/config",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/config",
"version": "0.1.0"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "@tiku-saas/config",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}

View File

@@ -0,0 +1,43 @@
import fs from 'node:fs';
import path from 'node:path';
export const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
export const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
export const DEFAULT_TENANT_SLUG = 'master';
export const DEFAULT_TENANT_NAME = '升本刷题通主租户';
export function loadDotenv(cwd = process.cwd()) {
const envPath = path.resolve(cwd, '.env');
if (!fs.existsSync(envPath))
return;
const content = fs.readFileSync(envPath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#'))
continue;
const idx = trimmed.indexOf('=');
if (idx === -1)
continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim().replace(/^"|"$/g, '');
if (!process.env[key])
process.env[key] = value;
}
}
export function envString(key, fallback) {
return process.env[key] || fallback;
}
export function envNumber(key, fallback) {
const value = Number(process.env[key]);
return Number.isFinite(value) ? value : fallback;
}
export function envBoolean(key, fallback = false) {
const value = process.env[key];
if (value === undefined)
return fallback;
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
}
export function envList(key, fallback = '') {
return (process.env[key] || fallback)
.split(',')
.map(value => value.trim())
.filter(Boolean);
}

View File

@@ -0,0 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';
export const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
export const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
export const DEFAULT_TENANT_SLUG = 'master';
export const DEFAULT_TENANT_NAME = '升本刷题通主租户';
export function loadDotenv(cwd = process.cwd()) {
const envPath = path.resolve(cwd, '.env');
if (!fs.existsSync(envPath)) return;
const content = fs.readFileSync(envPath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim().replace(/^"|"$/g, '');
if (!process.env[key]) process.env[key] = value;
}
}
export function envString(key: string, fallback: string) {
return process.env[key] || fallback;
}
export function envNumber(key: string, fallback: number) {
const value = Number(process.env[key]);
return Number.isFinite(value) ? value : fallback;
}
export function envBoolean(key: string, fallback = false) {
const value = process.env[key];
if (value === undefined) return fallback;
return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase());
}
export function envList(key: string, fallback = '') {
return (process.env[key] || fallback)
.split(',')
.map(value => value.trim())
.filter(Boolean);
}

194
packages/db/package-lock.json generated Normal file
View File

@@ -0,0 +1,194 @@
{
"name": "@tiku-saas/db",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/db",
"version": "0.1.0",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/pg": "^8.15.4"
}
},
"node_modules/@types/node": {
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

14
packages/db/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@tiku-saas/db",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/pg": "^8.15.4"
}
}

18
packages/db/src/index.js Normal file
View File

@@ -0,0 +1,18 @@
import pg from 'pg';
import { DEFAULT_DATABASE_URL } from '../../config/src/index.js';
const { Pool } = pg;
export function createPool(options = {}) {
return new Pool({
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
max: options.max || 10,
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
});
}
export async function query(pool, sql, params = []) {
const result = await pool.query(sql, params);
return result.rows;
}
export async function queryOne(pool, sql, params = []) {
const rows = await query(pool, sql, params);
return rows[0] ?? null;
}

28
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import pg from 'pg';
import { DEFAULT_DATABASE_URL } from '../../config/src/index.js';
const { Pool } = pg;
export interface DbPoolOptions {
connectionString?: string;
max?: number;
idleTimeoutMillis?: number;
}
export function createPool(options: DbPoolOptions = {}) {
return new Pool({
connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
max: options.max || 10,
idleTimeoutMillis: options.idleTimeoutMillis || 30_000,
});
}
export async function query<T = unknown>(pool: pg.Pool, sql: string, params: unknown[] = []): Promise<T[]> {
const result = await pool.query(sql, params);
return result.rows as T[];
}
export async function queryOne<T = unknown>(pool: pg.Pool, sql: string, params: unknown[] = []): Promise<T | null> {
const rows = await query<T>(pool, sql, params);
return rows[0] ?? null;
}

12
packages/domain/package-lock.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "@tiku-saas/domain",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/domain",
"version": "0.1.0"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "@tiku-saas/domain",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}

View File

@@ -0,0 +1,37 @@
export const TENANT_ROLES = [
'platform_admin',
'tenant_owner',
'tenant_admin',
'tenant_operator',
'teacher',
'sales',
'agent',
'student',
];
export const QUESTION_STATUSES = ['draft', 'published', 'archived'];
export const ORDER_STATUSES = ['pending', 'paid', 'failed', 'closed', 'refunded'];
export const ENTITLEMENT_SCOPE_TYPES = ['tenant', 'region', 'module', 'subject', 'question_bank'];
export function normalizeTenantRole(role) {
const value = String(role || '').toLowerCase();
if (value === 'superadmin')
return 'platform_admin';
if (value === 'admin')
return 'tenant_admin';
if (value === 'operator')
return 'tenant_operator';
if (TENANT_ROLES.includes(value))
return value;
return 'student';
}
export function normalizeOrderStatus(status) {
const value = String(status || '').toLowerCase();
if (value === 'paid' || value === 'success')
return 'paid';
if (value === 'failed' || value === 'fail')
return 'failed';
if (value === 'refunded' || value === 'refund')
return 'refunded';
if (value === 'closed' || value === 'cancelled' || value === 'canceled')
return 'closed';
return 'pending';
}

View File

@@ -0,0 +1,39 @@
export const TENANT_ROLES = [
'platform_admin',
'tenant_owner',
'tenant_admin',
'tenant_operator',
'teacher',
'sales',
'agent',
'student',
] as const;
export type TenantRole = (typeof TENANT_ROLES)[number];
export const QUESTION_STATUSES = ['draft', 'published', 'archived'] as const;
export type QuestionStatus = (typeof QUESTION_STATUSES)[number];
export const ORDER_STATUSES = ['pending', 'paid', 'failed', 'closed', 'refunded'] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];
export const ENTITLEMENT_SCOPE_TYPES = ['tenant', 'region', 'module', 'subject', 'question_bank'] as const;
export type EntitlementScopeType = (typeof ENTITLEMENT_SCOPE_TYPES)[number];
export function normalizeTenantRole(role: unknown): TenantRole {
const value = String(role || '').toLowerCase();
if (value === 'superadmin') return 'platform_admin';
if (value === 'admin') return 'tenant_admin';
if (value === 'operator') return 'tenant_operator';
if (TENANT_ROLES.includes(value as TenantRole)) return value as TenantRole;
return 'student';
}
export function normalizeOrderStatus(status: unknown): OrderStatus {
const value = String(status || '').toLowerCase();
if (value === 'paid' || value === 'success') return 'paid';
if (value === 'failed' || value === 'fail') return 'failed';
if (value === 'refunded' || value === 'refund') return 'refunded';
if (value === 'closed' || value === 'cancelled' || value === 'canceled') return 'closed';
return 'pending';
}