forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
12
packages/config/package-lock.json
generated
Normal file
12
packages/config/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/config/package.json
Normal file
8
packages/config/package.json
Normal 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"
|
||||
}
|
||||
43
packages/config/src/index.js
Normal file
43
packages/config/src/index.js
Normal 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);
|
||||
}
|
||||
45
packages/config/src/index.ts
Normal file
45
packages/config/src/index.ts
Normal 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
194
packages/db/package-lock.json
generated
Normal 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
14
packages/db/package.json
Normal 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
18
packages/db/src/index.js
Normal 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
28
packages/db/src/index.ts
Normal 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
12
packages/domain/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/domain/package.json
Normal file
8
packages/domain/package.json
Normal 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"
|
||||
}
|
||||
37
packages/domain/src/index.js
Normal file
37
packages/domain/src/index.js
Normal 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';
|
||||
}
|
||||
39
packages/domain/src/index.ts
Normal file
39
packages/domain/src/index.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user