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

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
node_modules
apps/*/node_modules
packages/*/node_modules
scripts/import-pocketbase/node_modules
dist
dist-admin
dist-public
apps/*/dist
packages/*/dist
scripts/import-pocketbase/dist
.env
.env.*
!.env.example
pb_data
pb_public
pb_export
.git
.qoder
.codex-backups
logs
*.log

42
.env.example Normal file
View File

@@ -0,0 +1,42 @@
# 阿里云短信服务配置(脚本/服务端使用,前端不读取此文件)
# 复制此文件为 .env 并填写实际值
#
# 生产环境说明:
# - 学生端 https://tiku.tjszsb.com
# - 超管后台 https://tikuguanli.tjszsb.com
# - 服务器 39.107.64.207PocketBase 运行于 127.0.0.1:8090由 Nginx 反代)
#
# 前端Vite运行时 PocketBase 地址会根据 window.location 自动判断:
# - 本地开发 → http://127.0.0.1:8090
# - 线上部署 → 与页面同源Nginx 代理到后端)
# 因此前端代码无需配置服务器 IP本文件仅供 Node 脚本使用。
# PocketBase 地址(本地脚本连接用)
POCKETBASE_URL=http://127.0.0.1:8090
# 阿里云 AccessKey
ALIYUN_ACCESS_KEY_ID=your_access_key_id_here
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret_here
# 阿里云短信配置
ALIYUN_SMS_SIGN=天津专升本
ALIYUN_SMS_TEMPLATE=SMS_123456789
# 服务端口
PORT=3000
# 新 Supabase/PostgreSQL 重构 API
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
DEFAULT_TENANT_SLUG=master
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
# 认证迁移期配置:生产环境必须替换为高强度随机值
AUTH_SMS_PROVIDER=mock
AUTH_CODE_PEPPER=replace_with_a_long_random_secret
AUTH_SESSION_SECRET=replace_with_another_long_random_secret
AUTH_CODE_TTL_SECONDS=300
AUTH_SMS_COOLDOWN_SECONDS=60
AUTH_SESSION_TTL_SECONDS=604800
# 迁移期平台管理 API Key。生产环境应替换为平台管理员 JWT/服务端会话。
PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key

77
.gitignore vendored Normal file
View File

@@ -0,0 +1,77 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# ✅ 环境变量文件(包含敏感信息)
.env
.env.local
.env.production
# ✅ 租户配置(每个部署实例独立维护)
src/tenant.config.ts
# ✅ 卫星站同步配置(含管理员密码)
scripts/satellite/sync-config.json
# ✅ 构建产物
/dist-admin/
/dist-public/
# ✅ IDE / AI 工具配置
.qoder/
.codex-backups/
# ✅ 补丁与压缩包
*.patch
*.tar.gz
# ✅ 临时/对比文件
stash_comparison.txt
stash_diff.txt
# ✅ 备份文件
*.backup
# ✅ 卫星站安装包
卫星站安装包/
# ✅ 参考资料(非源码)
参考/
新UI参考/
# ✅ Electron 打包产物
/release/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# ✅ 部署脚本本地缓存(记录 package-lock 哈希)
.deploy-cache/
# ✅ PocketBase 运行期产出(不入 git
pb_data/
pb_public/
pocketbase
pocketbase.exe
# Supabase local CLI state
supabase/.branches/
supabase/.temp/

154
README.md Normal file
View File

@@ -0,0 +1,154 @@
# tiku-supabase
PocketBase/SQLite question-bank product rebuilt as a Supabase/PostgreSQL multi-tenant SaaS backend.
This repository is the new reconstruction workspace for the commercial SaaS version. The old PocketBase/React project remains as the migration reference in the original workspace, but this Git repository should focus on the new backend, database schema, importer, and delivery documentation.
## Current Status
Updated: 2026-06-21
Implemented and locally verified:
- Supabase/PostgreSQL multi-tenant schema, RLS policies, indexes, and triggers.
- Node.js business API under `apps/api`, shared by future H5, Taro mini program, and admin clients.
- Tenant admin APIs for branding, domain, settings, payment accounts, login providers, secrets, marketing content, activation codes, coupons, members, permissions, and audit logs.
- Tenant content APIs for questions, videos, scorelines, vocabulary, handbook content, content assets, and question JSON import.
- Student APIs for catalog, practice sessions, answers, wrong questions, favorites, vocabulary progress, profile, scorelines, question videos, orders, entitlements, activation-code redemption, and content asset download.
- Platform admin APIs for tenants, SaaS plans, subscriptions, invoices, payments, usage records, and billing profiles.
- Referral/CRM growth APIs for invite codes, first-binding lead protection, sales/agent teams, CRM config, and CRM queue.
- PocketBase schema/data importer scaffold with validation.
- Local Supabase reset, smoke seed, API integration tests, and refactor check command.
Still not production complete:
- Supabase Auth/JWT and full production RLS validation must replace migration-period headers.
- Real SMS/OAuth/payment provider adapters are still pending.
- Real OSS/COS/Supabase Storage signed upload/download adapters are still pending.
- Excel/CSV import, vocabulary/handbook/scoreline/video import, and async import worker are pending.
- Taro frontend scaffold is pending.
See:
- `docs/refactor/implementation-status.md`
- `docs/refactor/backend-progress.md`
- `docs/refactor/blueprint-coverage.md`
- `docs/refactor/api-structure.md`
## Repository Layout
```text
apps/api/ Node.js business API
packages/config/ Shared config defaults
packages/db/ PostgreSQL pool/query helpers
packages/domain/ Domain constants and shared types
supabase/migrations/ PostgreSQL schema, RLS, indexes, triggers
supabase/seed.sql Minimal tenant seed
scripts/import-pocketbase/ PocketBase schema/data importer and validator
scripts/smoke-seed.js Local integration-test seed data
scripts/api-integration-test.js
docs/refactor/ Reconstruction architecture and progress docs
docker-compose.api.yml API container compose file
```
## Local Development
Prerequisites:
- Node.js 20+
- Docker Desktop
- Supabase CLI
```bash
npm install
npm run supabase:start
npm run supabase:reset
npm run db:smoke-seed
npm run dev:api
```
Default local database:
```text
postgresql://postgres:postgres@127.0.0.1:54322/postgres
```
API defaults to:
```text
http://127.0.0.1:8787
```
## Verification
Run the full backend reconstruction check:
```bash
npm run check:refactor
```
This runs:
- API TypeScript check
- PocketBase importer TypeScript check
- PocketBase import validation
- smoke seed
- API build
- local API integration test
Useful individual commands:
```bash
npm run check:api
npm run check:importer
npm run pb:import:validate
npm run test:api
```
## API Modules
Current API feature folders:
```text
apps/api/src/features/
auth/
catalog/
commerce/
health/
learning/
platform-admin/
profile/
referral/
scoreline/
tenant/
tenant-admin/
tenant-content/
video/
```
Migration-period API context:
- `x-tenant-id`
- `x-user-id`
- `x-platform-admin-key`
Production must replace these with Supabase Auth/JWT/server-side sessions.
## Security Notes
- Tenant public payment/login config must not contain secrets.
- Secrets go to `app_private.tenant_secrets` or future production KMS/Vault.
- Asset download must go through API authorization and signed URL generation.
- Content import must write job/item/issue records before final import.
- Payment webhooks must be idempotent before production use.
## Latest Verified Check
Last local verification:
```text
npm run supabase:reset
npm run check:refactor
```
Result: passed.

4
apps/api/.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=8787
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
DEFAULT_TENANT_SLUG=master
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173

34
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
RUN npm ci --workspaces --include-workspace-root
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY apps/api ./apps/api
COPY packages ./packages
RUN npm run build:api
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/domain/package.json packages/domain/package.json
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
EXPOSE 8787
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]

729
apps/api/package-lock.json generated Normal file
View File

@@ -0,0 +1,729 @@
{
"name": "@tiku-saas/api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/api",
"version": "0.1.0",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.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/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.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/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"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"
}
}
}
}

21
apps/api/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@tiku-saas/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/apps/api/src/server.js",
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,37 @@
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
export interface ApiConfig {
nodeEnv: string;
port: number;
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
platformAdminApiKey: string;
isProduction: boolean;
}
loadDotenv();
const isProduction = envString('NODE_ENV', 'development') === 'production';
export const config: ApiConfig = {
nodeEnv: envString('NODE_ENV', 'development'),
port: envNumber('PORT', 8787),
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
corsOrigins: envList('CORS_ORIGIN', '*'),
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
isProduction,
};

35
apps/api/src/core/db.ts Normal file
View File

@@ -0,0 +1,35 @@
import type pg from 'pg';
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../../packages/db/src/index.js';
import { config } from './config.js';
export const pool = createPool({
connectionString: config.databaseUrl,
max: 10,
});
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
return runQuery<T>(pool, sql, params);
}
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
return runQueryOne<T>(pool, sql, params);
}
export async function closePool() {
await pool.end();
}
export async function transaction<T>(callback: (client: pg.PoolClient) => Promise<T>) {
const client = await pool.connect();
try {
await client.query('begin');
const result = await callback(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}

68
apps/api/src/core/http.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { URL } from 'node:url';
import { config } from './config.js';
export interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
url: URL;
}
export type Handler = (ctx: RequestContext) => Promise<unknown>;
export class HttpError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code = 'HTTP_ERROR',
) {
super(message);
}
}
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify(body));
}
export function getHeader(req: IncomingMessage, name: string): string {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value[0] || '';
return value || '';
}
export function applyCors(req: IncomingMessage, res: ServerResponse) {
const origin = getHeader(req, 'origin');
const allowAll = config.corsOrigins.includes('*');
if (allowAll) {
res.setHeader('access-control-allow-origin', '*');
} else if (origin && config.corsOrigins.includes(origin)) {
res.setHeader('access-control-allow-origin', origin);
res.setHeader('vary', 'origin');
}
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
}
export function routeKey(method: string | undefined, pathname: string) {
return `${method || 'GET'} ${pathname}`;
}
export function publicErrorBody(error: unknown) {
if (error instanceof HttpError) {
return {
statusCode: error.statusCode,
body: { error: error.message, code: error.code },
};
}
const message = error instanceof Error ? error.message : 'Unknown error';
return {
statusCode: 500,
body: {
error: config.isProduction ? 'Internal server error' : message,
code: 'INTERNAL_ERROR',
},
};
}

View File

@@ -0,0 +1,87 @@
import { config } from './config.js';
import { getHeader, HttpError, type RequestContext } from './http.js';
export type JsonObject = Record<string, unknown>;
export function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
}
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
const userId =
getHeader(ctx.req, 'x-user-id') ||
ctx.url.searchParams.get('userId') ||
(typeof body?.userId === 'string' ? body.userId : '');
if (!userId) {
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
}
return userId;
}
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
export function stringParam(ctx: RequestContext, name: string) {
return ctx.url.searchParams.get(name)?.trim() || '';
}
export async function readJsonBody(ctx: RequestContext): Promise<JsonObject> {
const chunks: Buffer[] = [];
for await (const chunk of ctx.req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
}
return parsed as JsonObject;
} catch (error) {
if (error instanceof HttpError) throw error;
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON_BODY');
}
}
export function requiredString(body: JsonObject, key: string) {
const value = body[key];
if (typeof value !== 'string' || !value.trim()) {
throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
}
return value.trim();
}
export function optionalString(body: JsonObject, key: string) {
const value = body[key];
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
export function optionalInteger(body: JsonObject, key: string, fallback: number) {
const value = Number(body[key] ?? fallback);
return Number.isFinite(value) ? Math.trunc(value) : fallback;
}
export function optionalStringArray(body: JsonObject, key: string): string[] {
const value = body[key];
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(Boolean);
}
export function requirePlatformAdmin(ctx: RequestContext) {
const provided = getHeader(ctx.req, 'x-platform-admin-key');
if (!provided || provided !== config.platformAdminApiKey) {
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}
}

View File

@@ -0,0 +1,44 @@
import type { Handler } from './http.js';
import { routeKey } from './http.js';
import { authRoutes } from '../features/auth/index.js';
import { catalogRoutes } from '../features/catalog/index.js';
import { commerceRoutes } from '../features/commerce/index.js';
import { healthRoutes } from '../features/health/index.js';
import { learningRoutes } from '../features/learning/index.js';
import { platformAdminRoutes } from '../features/platform-admin/index.js';
import { profileRoutes } from '../features/profile/index.js';
import { referralRoutes } from '../features/referral/index.js';
import { scorelineRoutes } from '../features/scoreline/index.js';
import { tenantAdminRoutes } from '../features/tenant-admin/index.js';
import { tenantContentRoutes } from '../features/tenant-content/index.js';
import { tenantRoutes } from '../features/tenant/index.js';
import { videoRoutes } from '../features/video/index.js';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export type RouteDefinition = [method: HttpMethod, path: string, handler: Handler];
export function createRouter(definitions: RouteDefinition[] = allRoutes) {
const routes = new Map<string, Handler>();
for (const [method, path, handler] of definitions) {
routes.set(routeKey(method, path), handler);
}
return routes;
}
const allRoutes: RouteDefinition[] = [
...healthRoutes,
...authRoutes,
...tenantRoutes,
...catalogRoutes,
...learningRoutes,
...profileRoutes,
...scorelineRoutes,
...videoRoutes,
...commerceRoutes,
...referralRoutes,
...platformAdminRoutes,
...tenantAdminRoutes,
...tenantContentRoutes,
];

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
sendSmsCodeRoute,
verifySmsCodeRoute,
} from './routes.js';
export const authRoutes: RouteDefinition[] = [
['POST', '/api/auth/sms/send', sendSmsCodeRoute],
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
['GET', '/api/auth/me', meRoute],
['POST', '/api/auth/logout', logoutRoute],
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
];

View File

@@ -0,0 +1,52 @@
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq';
export interface SmsSendInput {
tenantId: string;
phone: string;
code: string;
purpose: string;
metadata: Record<string, unknown>;
}
export interface SmsSendResult {
provider: SmsProviderName;
status: 'sent' | 'mocked';
providerMessageId?: string;
raw?: Record<string, unknown>;
}
export interface SmsProvider {
name: SmsProviderName;
send(input: SmsSendInput): Promise<SmsSendResult>;
}
class MockSmsProvider implements SmsProvider {
readonly name = 'mock' as const;
async send(): Promise<SmsSendResult> {
return {
provider: this.name,
status: 'mocked',
raw: { localOnly: true },
};
}
}
class NotConfiguredSmsProvider implements SmsProvider {
constructor(readonly name: SmsProviderName) {}
async send(): Promise<SmsSendResult> {
throw new Error(`${this.name} SMS provider is not configured yet`);
}
}
export function createSmsProvider(name: string): SmsProvider {
if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun');
if (name === 'tencent') return new NotConfiguredSmsProvider('tencent');
return new MockSmsProvider();
}
export function supportedOAuthProviders(): OAuthProviderName[] {
return ['wechat_web', 'wechat_miniapp', 'qq'];
}

View File

@@ -0,0 +1,377 @@
import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { query, transaction } from '../../core/db.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
import { createSmsProvider } from './providers.js';
import {
assertChinaPhone,
bearerTokenFrom,
clientIpFrom,
createLoginSession,
findUserBySessionToken,
generateSmsCode,
hashSmsCode,
normalizePurpose,
upsertPhoneUser,
userAgentFrom,
writeLoginEvent,
} from './service.js';
interface SmsCodeRow {
id: string;
codeHash: string;
attempts: number;
expiresAt: string;
status: string;
}
interface CooldownRow {
createdAt: string;
}
type SmsVerifyResult =
| {
ok: false;
statusCode: number;
message: string;
code: string;
}
| {
ok: true;
verified: true;
purpose: string;
phone: string;
user?: unknown;
isNewUser?: boolean;
session?: unknown;
};
function hashEquals(left: string, right: string) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function jsonObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
async function activeSmsProviderName(tenantId: string) {
if (config.authSmsProvider !== 'mock') return config.authSmsProvider;
const rows = await query<{ provider: string }>(
`
select provider
from public.tenant_auth_providers
where tenant_id = $1
and provider in ('aliyun', 'tencent', 'mock')
and status in ('active', 'testing')
order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end
limit 1
`,
[tenantId],
);
return rows[0]?.provider || config.authSmsProvider;
}
export async function sendSmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const metadata = jsonObject(body.metadata);
const cooldownRows = await query<CooldownRow>(
`
select created_at as "createdAt"
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
and created_at > now() - ($4::text || ' seconds')::interval
order by created_at desc
limit 1
`,
[tenantId, phone, purpose, config.authSmsCooldownSeconds],
);
const cooldownRow = cooldownRows[0];
if (cooldownRow) {
const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
}
const providerName = await activeSmsProviderName(tenantId);
const provider = createSmsProvider(providerName);
if (config.isProduction && provider.name === 'mock') {
throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
}
const code = generateSmsCode();
const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata });
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
const item = await transaction(async client => {
const insertResult = await client.query(
`
insert into public.sms_verification_codes (
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
`,
[
tenantId,
phone,
purpose,
codeHash,
providerResult.provider,
expiresAt,
ipAddress || null,
userAgent || null,
JSON.stringify({
...metadata,
providerStatus: providerResult.status,
providerMessageId: providerResult.providerMessageId || null,
}),
],
);
await writeLoginEvent(client, {
tenantId,
provider: `sms:${providerResult.provider}`,
identifier: phone,
result: 'sent',
ipAddress,
userAgent,
metadata: { purpose },
});
return insertResult.rows[0];
});
return {
item,
expireIn: config.authCodeTtlSeconds,
cooldown: config.authSmsCooldownSeconds,
debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
};
}
export async function verifySmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const code = requiredString(body, 'code');
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
const result = await transaction<SmsVerifyResult>(async client => {
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[tenantId, phone, purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress,
userAgent,
metadata: { purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
if (purpose !== 'login') {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: { purpose, sessionIssued: false },
});
return { ok: true, verified: true, purpose, phone };
}
const { user, isNewUser } = await upsertPhoneUser(client, { tenantId, phone });
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider: 'sms',
ipAddress,
userAgent,
metadata: { purpose },
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: { purpose, isNewUser },
});
return { ok: true, verified: true, purpose, phone, user, isNewUser, session };
});
if (!result.ok) {
throw new HttpError(result.statusCode, result.message, result.code);
}
return result;
}
export async function meRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) {
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
}
const session = await findUserBySessionToken(token);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
if (session.tenantId !== tenantId) {
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
}
return {
user: {
id: session.id,
username: session.username,
phone: session.phone,
name: session.name,
avatarUrl: session.avatarUrl,
primaryRole: session.primaryRole,
createdAt: session.createdAt,
},
session: {
id: session.sessionId,
expiresAt: session.sessionExpiresAt,
},
};
}
export async function logoutRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) return { ok: true };
const session = await findUserBySessionToken(token);
if (session && session.tenantId === tenantId) {
await query(
`
update app_private.auth_sessions
set revoked_at = now(), updated_at = now()
where id = $1
`,
[session.sessionId],
);
}
return { ok: true };
}
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
throw new HttpError(
501,
`${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
'PROVIDER_NOT_CONFIGURED',
);
}

View File

@@ -0,0 +1,281 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { config } from '../../core/config.js';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
export interface PlatformUserSummary {
id: string;
username: string | null;
phone: string | null;
name: string | null;
avatarUrl: string | null;
primaryRole: string;
createdAt: string;
}
export interface LoginSessionSummary {
token: string;
expiresAt: string;
}
export function clientIpFrom(ctx: RequestContext) {
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
}
export function userAgentFrom(ctx: RequestContext) {
return getHeader(ctx.req, 'user-agent');
}
export function normalizeChinaPhone(phone: string) {
return phone.replace(/\s+/g, '').replace(/^\+?86/, '');
}
export function assertChinaPhone(phone: string) {
const normalized = normalizeChinaPhone(phone);
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new HttpError(400, 'Invalid China mainland phone number', 'INVALID_PHONE');
}
return normalized;
}
export function normalizePurpose(value: string) {
if (['login', 'bind_phone', 'reset_password'].includes(value)) return value;
throw new HttpError(400, 'Unsupported SMS purpose', 'UNSUPPORTED_SMS_PURPOSE');
}
export function generateSmsCode() {
return crypto.randomInt(100000, 1000000).toString();
}
export function hashSmsCode(tenantId: string, phone: string, purpose: string, code: string) {
return crypto
.createHmac('sha256', config.authCodePepper)
.update([tenantId, phone, purpose, code].join(':'))
.digest('hex');
}
export function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
export async function findUserBySessionToken(token: string) {
const tokenHash = hashSessionToken(token);
return queryOne<
PlatformUserSummary & {
tenantId: string;
sessionId: string;
sessionExpiresAt: string;
}
>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
and s.revoked_at is null
and s.expires_at > now()
limit 1
`,
[tokenHash],
);
}
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}
export async function createLoginSession(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
provider: string;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
},
): Promise<LoginSessionSummary> {
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
await client.query(
`
insert into app_private.auth_sessions (
tenant_id, user_id, token_hash, provider, expires_at, ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5::timestamptz, $6, $7, $8::jsonb)
`,
[
input.tenantId,
input.userId,
tokenHash,
input.provider,
expiresAt,
input.ipAddress || null,
input.userAgent || null,
JSON.stringify(input.metadata || {}),
],
);
return { token, expiresAt };
}
export async function upsertPhoneUser(
client: pg.PoolClient,
input: {
tenantId: string;
phone: string;
},
) {
const existing = await client.query<PlatformUserSummary>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt"
from public.user_identities i
join public.platform_users u on u.id = i.user_id
where i.provider = 'phone'
and i.provider_subject = $1
limit 1
`,
[input.phone],
);
if (existing.rows[0]) {
const user = existing.rows[0];
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: false };
}
const legacyPhoneUser = await client.query<PlatformUserSummary>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt"
from public.platform_users u
where u.phone = $1
order by u.created_at asc
limit 1
`,
[input.phone],
);
if (legacyPhoneUser.rows[0]) {
const user = legacyPhoneUser.rows[0];
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set user_id = excluded.user_id,
phone = excluded.phone,
updated_at = now()
`,
[user.id, input.phone],
);
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: false };
}
const userResult = await client.query<PlatformUserSummary>(
`
insert into public.platform_users (username, phone, primary_role, raw_profile)
values ($1, $2, 'student', $3::jsonb)
returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
`,
[`u_${input.phone.slice(-4)}_${Date.now().toString(36)}`, input.phone, JSON.stringify({ source: 'sms_login' })],
);
const user = userResult.rows[0];
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set user_id = excluded.user_id,
phone = excluded.phone,
updated_at = now()
`,
[user.id, input.phone],
);
await ensureStudentTenantRecords(client, input.tenantId, user.id);
return { user, isNewUser: true };
}
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status)
values ($1, $2, 'student', 'active')
on conflict (tenant_id, user_id, role)
do update set status = 'active', updated_at = now()
`,
[tenantId, userId],
);
await client.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values ($1, $2, $3::jsonb, '{}'::jsonb)
on conflict (tenant_id, user_id) do nothing
`,
[
tenantId,
userId,
JSON.stringify({
totalAnswered: 0,
correctCount: 0,
wrongCount: 0,
studyDays: 1,
}),
],
);
}
export async function writeLoginEvent(
client: pg.PoolClient,
input: {
tenantId: string;
userId?: string | null;
provider: string;
identifier?: string;
result: 'sent' | 'success' | 'failed' | 'blocked';
failureCode?: string | null;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.auth_login_events (
tenant_id, user_id, provider, identifier, result, failure_code,
ip_address, user_agent, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`,
[
input.tenantId,
input.userId || null,
input.provider,
input.identifier || null,
input.result,
input.failureCode || null,
input.ipAddress || null,
input.userAgent || null,
JSON.stringify(input.metadata || {}),
],
);
}

View File

@@ -0,0 +1,202 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
interface CatalogAssetRow {
id: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
visibility: string;
regionId: string | null;
subjectId: string | null;
}
function optionalUserId(ctx: RequestContext) {
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
}
function hasUserContext(ctx: RequestContext) {
return !!optionalUserId(ctx);
}
function signedDownload(asset: CatalogAssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function hasActiveMembership(tenantId: string, userId: string) {
const row = await queryOne<{ id: string }>(
`
select id
from public.tenant_memberships
where tenant_id = $1 and user_id = $2 and status = 'active'
limit 1
`,
[tenantId, userId],
);
return !!row;
}
async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAssetRow) {
const now = new Date().toISOString();
const row = await queryOne<{ id: string }>(
`
select id
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
and (
scope_type = 'tenant'
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid)
)
limit 1
`,
[tenantId, userId, now, asset.regionId, asset.subjectId],
);
return !!row;
}
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
if (asset.visibility === 'private') {
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
}
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const member = await hasActiveMembership(tenantId, userId);
if (!member) {
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
}
if (asset.visibility === 'members') return { userId, svip: false };
const svip = await hasSvipAccess(tenantId, userId, asset);
if (!svip) {
throw new HttpError(403, 'SVIP entitlement is required for this asset', 'ASSET_SVIP_REQUIRED');
}
return { userId, svip: true };
}
export async function assetsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
const userPresent = hasUserContext(ctx);
const params: unknown[] = [tenantId];
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
if (!includeLocked || !userPresent) {
filters.push(`visibility in ('public', 'tenant')`);
}
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`(subject_id = $${params.length} or subject_id is null)`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`(category_id = $${params.length} or category_id is null)`);
}
params.push(limit);
const items = await query(
`
select id, asset_key as "assetKey", asset_type as "assetType",
title, category as "categoryLabel", description,
file_name as "fileName", preview_url as "previewUrl",
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
visibility, region_id as "regionId", subject_id as "subjectId",
category_id as "categoryId", node_id as "nodeId",
sort_order as "order", metadata, created_at as "createdAt",
updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function assetDownloadRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
if (!assetId) {
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
}
const asset = await queryOne<CatalogAssetRow>(
`
select id, asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
region_id as "regionId", subject_id as "subjectId"
from public.content_assets
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
const access = await assertAssetAccess(ctx, asset);
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, assetId],
);
return {
item: {
id: asset.id,
assetType: asset.assetType,
title: asset.title,
fileName: asset.fileName,
previewUrl: asset.previewUrl,
visibility: asset.visibility,
},
access,
download: signedDownload(asset, 900),
};
}

View File

@@ -0,0 +1,47 @@
import type { RouteDefinition } from '../../core/router.js';
import { assetDownloadRoute, assetsRoute } from './assets.js';
import {
announcementsRoute,
bannersRoute,
categoriesRoute,
faqsRoute,
handbookChaptersRoute,
handbookEntriesRoute,
handbookSubjectsRoute,
majorsRoute,
moduleNodesRoute,
productsRoute,
questionsRoute,
regionModulesRoute,
regionsRoute,
schoolsRoute,
subjectsRoute,
svipPlansRoute,
timelinesRoute,
vocabularyUnitsRoute,
vocabularyWordsRoute,
} from './routes.js';
export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/regions', regionsRoute],
['GET', '/api/catalog/region-modules', regionModulesRoute],
['GET', '/api/catalog/module-nodes', moduleNodesRoute],
['GET', '/api/catalog/schools', schoolsRoute],
['GET', '/api/catalog/majors', majorsRoute],
['GET', '/api/catalog/subjects', subjectsRoute],
['GET', '/api/catalog/categories', categoriesRoute],
['GET', '/api/catalog/questions', questionsRoute],
['GET', '/api/catalog/assets', assetsRoute],
['GET', '/api/catalog/assets/download', assetDownloadRoute],
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
['GET', '/api/catalog/handbook-chapters', handbookChaptersRoute],
['GET', '/api/catalog/handbook-entries', handbookEntriesRoute],
['GET', '/api/catalog/banners', bannersRoute],
['GET', '/api/catalog/faqs', faqsRoute],
['GET', '/api/catalog/announcements', announcementsRoute],
['GET', '/api/catalog/products', productsRoute],
['GET', '/api/catalog/timelines', timelinesRoute],
['GET', '/api/catalog/svip-plans', svipPlansRoute],
];

View File

@@ -0,0 +1,580 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
}
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
export async function regionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const items = await query(
`
select id, legacy_id as "legacyId", name, code, short_name as "shortName",
full_name as "fullName", icon, pinyin, is_hot as "isHot",
is_active as "isActive", sort_order as "order"
from public.regions
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
`,
[tenantId],
);
return { items };
}
export async function regionModulesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId", name, type,
icon, color, text_color as "textColor", description, route,
is_primary_school_module as "isPrimarySchoolModule",
is_active as "isActive", sort_order as "order"
from public.region_modules
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function moduleNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const moduleId = ctx.url.searchParams.get('moduleId');
const parentId = ctx.url.searchParams.get('parentId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (moduleId) {
params.push(moduleId);
filters.push(`module_id = $${params.length}`);
}
if (parentId) {
params.push(parentId === 'root' ? null : parentId);
filters.push(parentId === 'root' ? 'parent_id is null' : `parent_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", module_id as "moduleId",
parent_id as "parentId", legacy_parent_id as "legacyParentId",
region_id as "regionId", type, name, path, sort_order as "order",
is_active as "isActive", metadata
from public.module_nodes
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function schoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
module_id as "moduleId", name,
professional_exam_date as "professionalExamDate",
metadata, created_at as "createdAt", updated_at as "updatedAt"
from public.schools
where ${filters.join(' and ')}
order by name asc, created_at asc
`,
params,
);
return { items };
}
export async function majorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", name, description, study_tips as "studyTips",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.majors
where ${filters.join(' and ')}
order by sort_order asc, name asc
`,
params,
);
return { items };
}
export async function subjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const majorId = ctx.url.searchParams.get('majorId');
const moduleId = ctx.url.searchParams.get('moduleId');
const type = ctx.url.searchParams.get('type');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
if (majorId) {
params.push(majorId);
filters.push(`major_id = $${params.length}`);
}
if (moduleId) {
params.push(moduleId);
filters.push(`module_id = $${params.length}`);
}
if (type) {
params.push(type);
filters.push(`type = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
module_id as "moduleId", school_id as "schoolId",
major_id as "majorId", node_id as "nodeId", name, type,
major_legacy_ids as "majorLegacyIds", icon, description, stats,
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.subjects
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function categoriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const nodeId = ctx.url.searchParams.get('nodeId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", subject_id as "subjectId",
node_id as "nodeId", name, category_type as "categoryType",
sort_order as "order", svip_question_limit as "svipQuestionLimit",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.categories
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function questionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const categoryId = ctx.url.searchParams.get('categoryId');
const nodeId = ctx.url.searchParams.get('nodeId');
const limit = intParam(ctx, 'limit', 200, 500);
const params: unknown[] = [tenantId];
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
if (subjectId) {
params.push(subjectId);
filters.push(`q.subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`q.category_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`q.node_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
q.category_id as "categoryId", q.node_id as "nodeId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
where ${filters.join(' and ')}
order by q.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function vocabularyUnitsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function vocabularyWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const unitId = ctx.url.searchParams.get('unitId');
const limit = intParam(ctx, 'limit', 1000, 2000);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (unitId) {
params.push(unitId);
filters.push(`unit_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", unit_id as "unitId",
word, phonetic, meaning, example,
example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order",
is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words
where ${filters.join(' and ')}
order by sort_order asc, word asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function handbookSubjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, type, icon, color, description, sort_order as "order",
is_active as "isActive", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_subjects
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function handbookChaptersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", subject_id as "subjectId",
name, description, sort_order as "order",
is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_chapters
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function handbookEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const chapterId = ctx.url.searchParams.get('chapterId');
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (chapterId) {
params.push(chapterId);
filters.push(`chapter_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", chapter_id as "chapterId",
title, summary, ${includeContent ? 'content' : 'null::text as content'},
tags, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.handbook_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function bannersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
title, subtitle, content, button_text as "buttonText",
button_link as "buttonLink", bg_color as "bgColor",
border_color as "borderColor", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.banners
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function faqsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
question, answer, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.faqs
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function announcementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const items = await query(
`
select id, legacy_id as "legacyId", content, link,
bg_color as "bgColor", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.announcements
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
`,
[tenantId],
);
return { items };
}
export async function productsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
title, price_label as "price", link, type, tags, cover,
preview_iframe as "previewIframe", detail_images as "detailImages",
sort_order as "order", status, created_at as "createdAt",
updated_at as "updatedAt"
from public.products
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
`,
params,
);
return { items };
}
export async function timelinesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`school_id = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", type, title, description,
event_date as "eventDate", link, sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.timelines
where ${filters.join(' and ')}
order by event_date asc nulls last, sort_order asc
`,
params,
);
return { items };
}
export async function svipPlansRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, price_cents as "priceCents",
(price_cents::numeric / 100.0) as price,
original_price_cents as "originalPriceCents",
case when original_price_cents is null then null else (original_price_cents::numeric / 100.0) end as "originalPrice",
days, description as "desc", per_day_label as "perDay",
badge, recommended, coupon_only as "couponOnly",
vp_product_id as "vpProductId", vp_enabled as "vpEnabled",
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.svip_plans
where ${filters.join(' and ')}
order by sort_order asc, price_cents asc
`,
params,
);
return { items };
}

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
confirmManualPaymentRoute,
createOrderRoute,
entitlementCheckRoute,
entitlementsRoute,
ordersRoute,
redeemActivationCodeRoute,
} from './routes.js';
export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/orders', createOrderRoute],
['GET', '/api/commerce/orders', ordersRoute],
['GET', '/api/commerce/entitlements', entitlementsRoute],
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
];

View File

@@ -0,0 +1,385 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalInteger,
optionalString,
readJsonBody,
requiredString,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { createOrderNo, grantSvipEntitlement } from './service.js';
interface PlanRow {
id: string;
name: string;
price_cents: number;
days: number;
region_id: string | null;
}
interface OrderRow {
id: string;
order_no: string;
status: string;
amount_cents: number;
days: number | null;
user_id: string | null;
region_id: string | null;
}
interface EntitlementRow {
id: string;
entitlementType: string;
scopeType: string;
scopeId: string | null;
sourceType: string;
sourceId: string | null;
startsAt: string;
expiresAt: string | null;
status: string;
metadata: Record<string, unknown>;
createdAt: string;
}
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const planId = requiredString(body, 'planId');
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
const payMethod = optionalString(body, 'payMethod') || 'manual';
const payProvider = optionalString(body, 'payProvider') || 'manual';
const regionId = optionalString(body, 'regionId') || null;
const plan = await queryOne<PlanRow>(
`
select id, name, price_cents, days, region_id
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, planId],
);
if (!plan) {
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
}
const finalRegionId = regionId || plan.region_id;
const amountCents = Math.max(0, plan.price_cents * quantity);
const days = Math.max(0, plan.days * quantity);
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
const item = await transaction(async client => {
const orderResult = await client.query(
`
insert into public.orders (
tenant_id, user_id, order_no, status, product_type, product_name,
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
)
values ($1, $2, $3, 'pending', 'svip', $4, $5, $6, $7, $8, $9, $10, $11::jsonb)
returning id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
plan_id as "planId", days, region_id as "regionId",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
orderNo,
`${plan.name}${quantity > 1 ? ` x${quantity}` : ''}`,
amountCents,
payMethod,
payProvider,
planId,
days,
finalRegionId,
JSON.stringify({ quantity, request: body }),
],
);
await client.query(
`
insert into public.order_items (tenant_id, order_id, item_type, item_id, name, quantity, unit_amount_cents, total_amount_cents, metadata)
values ($1, $2, 'svip_plan', $3, $4, $5, $6, $7, $8::jsonb)
`,
[
tenantId,
orderResult.rows[0].id,
planId,
plan.name,
quantity,
plan.price_cents,
amountCents,
JSON.stringify({ days: plan.days, totalDays: days }),
],
);
await client.query(
`
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
`,
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
);
return orderResult.rows[0];
});
return { item };
}
export async function ordersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query<EntitlementRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days,
region_id as "regionId", paid_at as "paidAt",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2
order by created_at desc
limit $3
`,
[tenantId, userId, limit],
);
return { items };
}
export async function entitlementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const now = new Date().toISOString();
const items: EntitlementRow[] = await query<EntitlementRow>(
`
select id, entitlement_type as "entitlementType",
scope_type as "scopeType", scope_id as "scopeId",
source_type as "sourceType", source_id as "sourceId",
starts_at as "startsAt", expires_at as "expiresAt",
status, metadata, created_at as "createdAt"
from public.entitlements
where tenant_id = $1 and user_id = $2
order by expires_at desc nulls first, created_at desc
`,
[tenantId, userId],
);
const activeSvip = await queryOne<{
expiresAt: string | null;
scopeType: string;
scopeId: string | null;
}>(
`
select expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
order by expires_at desc nulls first
limit 1
`,
[tenantId, userId, now],
);
const svipExpiresAt =
items.find(item => item.entitlementType === 'svip' && item.status === 'active' && !item.expiresAt)?.expiresAt ||
items
.filter(item => item.entitlementType === 'svip' && item.status === 'active' && item.expiresAt)
.map(item => item.expiresAt)
.sort()
.at(-1) ||
activeSvip?.expiresAt ||
null;
return {
items,
summary: {
isSvip: !!activeSvip,
svipExpiresAt,
svipScopeType: activeSvip?.scopeType || null,
svipScopeId: activeSvip?.scopeId || null,
},
};
}
export async function entitlementCheckRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId') || '';
const now = new Date().toISOString();
const row = await queryOne<{
id: string;
expiresAt: string | null;
scopeType: string;
scopeId: string | null;
}>(
`
select id, expires_at as "expiresAt", scope_type as "scopeType", scope_id as "scopeId"
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
and (
scope_type = 'tenant'
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
)
order by case when scope_type = 'region' then 0 else 1 end, expires_at desc nulls first
limit 1
`,
[tenantId, userId, now, regionId || null],
);
return {
allowed: !!row,
entitlement: row || null,
};
}
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const orderNo = requiredString(body, 'orderNo');
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
const amountCents = optionalInteger(body, 'amountCents', -1);
const item = await transaction(async client => {
const orderResult = await client.query<OrderRow>(
`
select id, order_no, status, amount_cents, days, user_id, region_id
from public.orders
where tenant_id = $1 and order_no = $2
limit 1
for update
`,
[tenantId, orderNo],
);
const order = orderResult.rows[0];
if (!order) {
throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
}
if (!order.user_id) {
throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
}
if (amountCents >= 0 && amountCents !== order.amount_cents) {
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
}
if (order.status === 'paid') {
return { orderNo, status: 'paid', idempotent: true };
}
await client.query(
`
update public.orders
set status = 'paid', trade_no = $3, paid_at = now(), updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, order.id, providerTradeNo],
);
await client.query(
`
update public.payments
set status = 'paid', provider_trade_no = $3, paid_at = now(), updated_at = now(),
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb
where tenant_id = $1 and order_id = $2
`,
[tenantId, order.id, providerTradeNo, JSON.stringify({ manualConfirm: body })],
);
const entitlement = await grantSvipEntitlement(client, {
tenantId,
userId: order.user_id,
days: order.days || 0,
regionId: order.region_id,
sourceType: 'order',
sourceId: order.id,
metadata: { orderNo },
});
return { orderNo, status: 'paid', entitlement };
});
return { item };
}
export async function redeemActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const code = requiredString(body, 'code');
const regionId = optionalString(body, 'regionId') || null;
const item = await transaction(async client => {
const codeResult = await client.query<{
id: string;
code: string;
days: number;
is_used: boolean;
used_region_id: string | null;
}>(
`
select id, code, days, is_used, used_region_id
from public.activation_codes
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
for update
`,
[tenantId, code],
);
const activationCode = codeResult.rows[0];
if (!activationCode) {
throw new HttpError(404, 'Activation code not found', 'ACTIVATION_CODE_NOT_FOUND');
}
if (activationCode.is_used) {
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
}
const finalRegionId = regionId || activationCode.used_region_id;
await client.query(
`
update public.activation_codes
set is_used = true, used_by = $3, used_at = now(),
used_region_id = coalesce($4::uuid, used_region_id),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, activationCode.id, userId, finalRegionId],
);
const entitlement = await grantSvipEntitlement(client, {
tenantId,
userId,
days: activationCode.days,
regionId: finalRegionId,
sourceType: 'activation_code',
sourceId: activationCode.id,
legacySourceId: activationCode.code,
metadata: { code: activationCode.code },
});
return { code: activationCode.code, entitlement };
});
return { item };
}

View File

@@ -0,0 +1,80 @@
import type pg from 'pg';
interface EntitlementInput {
tenantId: string;
userId: string;
days: number;
regionId?: string | null;
sourceType: string;
sourceId?: string | null;
legacySourceId?: string | null;
metadata?: Record<string, unknown>;
}
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
const days = Math.max(0, Math.trunc(input.days || 0));
const scopeType = input.regionId ? 'region' : 'tenant';
const startsAtResult = await client.query<{ starts_at: string }>(
`
select greatest(
now(),
coalesce(max(expires_at), now())
) as starts_at
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and scope_type = $3
and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid)
and status = 'active'
`,
[input.tenantId, input.userId, scopeType, input.regionId || null],
);
const startsAt = startsAtResult.rows[0]?.starts_at;
const result = await client.query(
`
insert into public.entitlements (
tenant_id, user_id, entitlement_type, scope_type, scope_id,
source_type, source_id, legacy_source_id, starts_at, expires_at, status, metadata
)
values (
$1, $2, 'svip', $3, $4,
$5, $6, $7, $8::timestamptz,
$8::timestamptz + ($9::text || ' days')::interval,
'active', $10::jsonb
)
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
status, metadata
`,
[
input.tenantId,
input.userId,
scopeType,
input.regionId || null,
input.sourceType,
input.sourceId || null,
input.legacySourceId || null,
startsAt,
days,
JSON.stringify(input.metadata || {}),
],
);
return result.rows[0];
}
export function createOrderNo(prefix = 'SVIP') {
const now = new Date();
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}

View File

@@ -0,0 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import { healthRoute } from './routes.js';
export const healthRoutes: RouteDefinition[] = [
['GET', '/health', async () => healthRoute()],
];

View File

@@ -0,0 +1,11 @@
import { queryOne } from '../../core/db.js';
export async function healthRoute() {
const db = await queryOne<{ ok: number }>('select 1 as ok');
return {
ok: true,
service: 'tiku-saas-api',
db: db?.ok === 1 ? 'ok' : 'unknown',
time: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,28 @@
import type { RouteDefinition } from '../../core/router.js';
import {
createPracticeSessionRoute,
favoriteQuestionsRoute,
favoriteWordsRoute,
resolveWrongQuestionRoute,
submitAnswerRoute,
toggleFavoriteQuestionRoute,
toggleFavoriteWordRoute,
updateWordProgressRoute,
wordProgressRoute,
wordStatsRoute,
wrongQuestionsRoute,
} from './routes.js';
export const learningRoutes: RouteDefinition[] = [
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
['POST', '/api/learning/answers', submitAnswerRoute],
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],
];

View File

@@ -0,0 +1,453 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalInteger,
optionalString,
optionalStringArray,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
interface QuestionAnswerRow {
question_id: string;
question_version_id: string | null;
correct_option_index: number | null;
correct_option_indices: unknown;
answer_text: string | null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(item => item !== '');
}
function normalizeNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value
.map(item => Number(item))
.filter(item => Number.isFinite(item))
.map(item => Math.trunc(item))
.sort((a, b) => a - b);
}
function normalizeAnswerText(value: unknown) {
return String(value ?? '').trim().replace(/\s+/g, ' ');
}
function arraysEqual<T>(left: T[], right: T[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
const correctIndices = normalizeNumberArray(row.correct_option_indices);
if (correctIndices.length) {
return arraysEqual(
selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b),
correctIndices,
);
}
if (row.correct_option_index !== null && row.correct_option_index !== undefined) {
return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index;
}
if (row.answer_text) {
return normalizeAnswerText(answerText) === normalizeAnswerText(row.answer_text);
}
return null;
}
export async function createPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const mode = optionalString(body, 'mode') || 'chapter';
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const item = await queryOne(
`
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
values ($1, $2, $3, $4, $5, $6::jsonb)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
started_at as "startedAt", finished_at as "finishedAt", metadata
`,
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
);
return { item };
}
export async function submitAnswerRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const selectedOptions = optionalStringArray(body, 'selectedOptions');
const answerText = optionalString(body, 'answerText');
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
const question = await queryOne<QuestionAnswerRow>(
`
select q.id as question_id, q.current_version_id as question_version_id,
v.correct_option_index, v.correct_option_indices, v.answer_text
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
limit 1
`,
[tenantId, questionId],
);
if (!question) {
throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
}
const judged = judgeAnswer(question, selectedOptions, answerText);
const result = await transaction(async client => {
const answerResult = await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id, practice_session_id,
selected_options, answer_text, is_correct
)
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
returning id, question_id as "questionId", question_version_id as "questionVersionId",
selected_options as "selectedOptions", answer_text as "answerText",
is_correct as "isCorrect", answered_at as "answeredAt"
`,
[tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged],
);
if (judged === false) {
await client.query(
`
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
values ($1, $2, $3, 1, now(), null)
on conflict (tenant_id, user_id, question_id)
do update set wrong_count = public.wrong_questions.wrong_count + 1,
last_wrong_at = now(),
resolved_at = null
`,
[tenantId, userId, questionId],
);
} else if (judged === true) {
await client.query(
`
update public.wrong_questions
set resolved_at = coalesce(resolved_at, now())
where tenant_id = $1 and user_id = $2 and question_id = $3 and resolved_at is null
`,
[tenantId, userId, questionId],
);
}
await client.query(
`
update public.student_profiles
set questions_answered_today = questions_answered_today + 1,
stats = coalesce(stats, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(
'totalAnswered', coalesce((stats->>'totalAnswered')::integer, 0) + 1,
'correctCount', case when $3::boolean is true then coalesce((stats->>'correctCount')::integer, 0) + 1 else null end,
'wrongCount', case when $3::boolean is false then coalesce((stats->>'wrongCount')::integer, 0) + 1 else null end
)),
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId, judged],
);
return answerResult.rows[0];
});
return { item: result };
}
export async function favoriteQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select fq.question_id as "questionId", fq.source, fq.created_at as "createdAt",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.type, q.type_label as "typeLabel", v.content
from public.favorite_questions fq
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where fq.tenant_id = $1 and fq.user_id = $2
order by fq.created_at desc
limit $3
`,
[tenantId, userId, limit],
);
return { items };
}
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const favorite = body.favorite !== false;
if (favorite) {
await query(
`
insert into public.favorite_questions (tenant_id, user_id, question_id, source)
values ($1, $2, $3, 'api')
on conflict (tenant_id, user_id, question_id) do nothing
`,
[tenantId, userId, questionId],
);
} else {
await query(
`
delete from public.favorite_questions
where tenant_id = $1 and user_id = $2 and question_id = $3
`,
[tenantId, userId, questionId],
);
}
return { ok: true, favorite };
}
export async function wrongQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.type, q.type_label as "typeLabel", v.content
from public.wrong_questions wq
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where wq.tenant_id = $1 and wq.user_id = $2
and ($3::boolean = false or wq.resolved_at is null)
order by wq.last_wrong_at desc
limit $4
`,
[tenantId, userId, unresolvedOnly, limit],
);
return { items };
}
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
await query(
`
update public.wrong_questions
set resolved_at = now()
where tenant_id = $1 and user_id = $2 and question_id = $3
`,
[tenantId, userId, questionId],
);
return { ok: true };
}
function normalizeWordStatus(value: string) {
if (['new', 'learning', 'mastered', 'reviewing'].includes(value)) return value;
return 'learning';
}
export async function wordProgressRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const status = stringParam(ctx, 'status');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select p.id, p.word_id as "wordId", p.status,
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
p.created_at as "createdAt", p.updated_at as "updatedAt",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
from public.user_word_progress p
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
where p.tenant_id = $1 and p.user_id = $2
and ($3::uuid is null or w.unit_id = $3::uuid)
and ($4::text = '' or p.status = $4::text)
order by p.updated_at desc
limit $5
`,
[tenantId, userId, unitId || null, status, limit],
);
return { items };
}
export async function updateWordProgressRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
const item = await transaction(async client => {
const word = await client.query<{ id: string }>(
`
select id from public.vocabulary_words
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, wordId],
);
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
const progress = await client.query(
`
insert into public.user_word_progress (
tenant_id, user_id, word_id, status, correct_count, wrong_count,
last_review_date, next_review_date
)
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
on conflict (tenant_id, user_id, word_id)
do update set status = excluded.status,
correct_count = public.user_word_progress.correct_count + $5,
wrong_count = public.user_word_progress.wrong_count + $6,
last_review_date = now(),
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
updated_at = now()
returning id, word_id as "wordId", status,
correct_count as "correctCount", wrong_count as "wrongCount",
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
created_at as "createdAt", updated_at as "updatedAt"
`,
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
);
await client.query(
`
update public.student_profiles
set mastered_words_count = (
select count(*) from public.user_word_progress
where tenant_id = $1 and user_id = $2 and status = 'mastered'
),
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return progress.rows[0];
});
return { item };
}
export async function favoriteWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select f.id, f.word_id as "wordId", f.note,
coalesce(f.favorited_at, f.created_at) as "favoritedAt",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
w.example, w.example_translation as "exampleTranslation",
w.difficulty, w.tags
from public.user_word_favorites f
join public.vocabulary_words w on w.id = f.word_id and w.tenant_id = f.tenant_id
where f.tenant_id = $1 and f.user_id = $2
and ($3::uuid is null or w.unit_id = $3::uuid)
order by coalesce(f.favorited_at, f.created_at) desc
limit $4
`,
[tenantId, userId, unitId || null, limit],
);
return { items };
}
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const favorite = body.favorite !== false;
const note = optionalString(body, 'note') || null;
if (favorite) {
await query(
`
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
values ($1, $2, $3, $4, now())
on conflict (tenant_id, user_id, word_id)
do update set note = coalesce(excluded.note, public.user_word_favorites.note),
favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
updated_at = now()
`,
[tenantId, userId, wordId, note],
);
} else {
await query(
`
delete from public.user_word_favorites
where tenant_id = $1 and user_id = $2 and word_id = $3
`,
[tenantId, userId, wordId],
);
}
return { ok: true, favorite };
}
export async function wordStatsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const item = await queryOne(
`
select
count(w.id)::integer as "totalWords",
count(p.word_id)::integer as "progressedWords",
count(p.word_id) filter (where p.status = 'mastered')::integer as "masteredWords",
count(p.word_id) filter (where p.status in ('learning', 'reviewing'))::integer as "learningWords",
count(p.word_id) filter (where p.last_review_date::date = current_date)::integer as "todayReviewed",
(
select count(*)
from public.user_word_favorites f
join public.vocabulary_words fw on fw.id = f.word_id and fw.tenant_id = f.tenant_id
where f.tenant_id = $1 and f.user_id = $2
and ($3::uuid is null or fw.unit_id = $3::uuid)
)::integer as "favoriteWords"
from public.vocabulary_words w
left join public.user_word_progress p
on p.word_id = w.id and p.tenant_id = w.tenant_id and p.user_id = $2
where w.tenant_id = $1 and w.is_active = true
and ($3::uuid is null or w.unit_id = $3::uuid)
`,
[tenantId, userId, unitId || null],
);
return { item };
}

View File

@@ -0,0 +1,34 @@
import type { RouteDefinition } from '../../core/router.js';
import {
confirmInvoicePaymentRoute,
createInvoiceRoute,
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantRoute,
platformOverviewRoute,
platformPlansRoute,
recordUsageRoute,
tenantDetailRoute,
tenantInvoicesRoute,
tenantsRoute,
tenantUsageRoute,
updateTenantStatusRoute,
upsertBillingProfileRoute,
} from './routes.js';
export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/overview', platformOverviewRoute],
['GET', '/api/platform-admin/plans', platformPlansRoute],
['GET', '/api/platform-admin/tenants', tenantsRoute],
['POST', '/api/platform-admin/tenants', createTenantRoute],
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
['GET', '/api/platform-admin/usage', tenantUsageRoute],
['POST', '/api/platform-admin/usage', recordUsageRoute],
];

View File

@@ -0,0 +1,871 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalString,
readJsonBody,
requiredString,
requirePlatformAdmin,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import {
centsFrom,
createInvoiceNo,
invoiceSubtotal,
normalizeHost,
normalizeInvoiceItems,
normalizeSlug,
quantityFrom,
recalculateInvoiceTotals,
} from './service.js';
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
function listQuery(ctx: RequestContext, name: string) {
return ctx.url.searchParams.get(name)?.trim() || '';
}
function toDateText(value: unknown) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
return String(value).slice(0, 10);
}
export async function platformOverviewRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
queryOne<{
totalTenants: string;
activeTenants: string;
suspendedTenants: string;
trialTenants: string;
pastDueTenants: string;
}>(
`
select count(*)::text as "totalTenants",
count(*) filter (where status = 'active')::text as "activeTenants",
count(*) filter (where status = 'suspended')::text as "suspendedTenants",
count(*) filter (where billing_status = 'trial')::text as "trialTenants",
count(*) filter (where billing_status = 'past_due')::text as "pastDueTenants"
from public.tenants
`,
),
queryOne<{
unpaidAmountCents: string;
paidAmountCents: string;
overdueInvoices: string;
}>(
`
select coalesce(sum(balance_cents) filter (where status in ('issued', 'overdue')), 0)::text as "unpaidAmountCents",
coalesce(sum(paid_cents), 0)::text as "paidAmountCents",
count(*) filter (where status = 'overdue' or (status = 'issued' and due_date < current_date))::text as "overdueInvoices"
from public.tenant_invoices
`,
),
queryOne<{ activeSubscriptions: string; expiringSoon: string }>(
`
select count(*) filter (where status in ('trial', 'active'))::text as "activeSubscriptions",
count(*) filter (where status in ('trial', 'active') and expires_at <= now() + interval '30 days')::text as "expiringSoon"
from public.tenant_subscriptions
`,
),
queryOne<{ students: string; questions: string; storageGb: string }>(
`
select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students,
coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions,
coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb"
from public.tenant_usage_records
where period_end >= current_date - interval '31 days'
`,
),
]);
return {
item: {
tenants: {
total: Number(tenantStats?.totalTenants || 0),
active: Number(tenantStats?.activeTenants || 0),
suspended: Number(tenantStats?.suspendedTenants || 0),
trial: Number(tenantStats?.trialTenants || 0),
pastDue: Number(tenantStats?.pastDueTenants || 0),
},
billing: {
unpaidAmountCents: Number(invoiceStats?.unpaidAmountCents || 0),
paidAmountCents: Number(invoiceStats?.paidAmountCents || 0),
overdueInvoices: Number(invoiceStats?.overdueInvoices || 0),
},
subscriptions: {
active: Number(subscriptionStats?.activeSubscriptions || 0),
expiringSoon: Number(subscriptionStats?.expiringSoon || 0),
},
usage: {
students: Number(usageStats?.students || 0),
questions: Number(usageStats?.questions || 0),
storageGb: Number(usageStats?.storageGb || 0),
},
},
};
}
export async function platformPlansRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
const items = await query(
`
select id, code, name, description, billing_cycle as "billingCycle",
base_amount_cents as "baseAmountCents", currency, included_quotas as "includedQuotas",
overage_prices as "overagePrices", feature_flags as "featureFlags",
status, sort_order as "sortOrder", created_at as "createdAt", updated_at as "updatedAt"
from public.platform_saas_plans
where ($1::boolean = true or status = 'active')
order by sort_order asc, created_at asc
`,
[includeArchived],
);
return { items };
}
export async function tenantsRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const status = listQuery(ctx, 'status');
const billingStatus = listQuery(ctx, 'billingStatus');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query(
`
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
b.brand_name as "brandName", b.logo_url as "logoUrl",
s.plan_code as "planCode", s.status as "subscriptionStatus",
s.expires_at as "subscriptionExpiresAt",
coalesce(i.open_balance_cents, 0) as "openBalanceCents"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join lateral (
select plan_code, status, expires_at
from public.tenant_subscriptions
where tenant_id = t.id
order by created_at desc
limit 1
) s on true
left join lateral (
select sum(balance_cents)::integer as open_balance_cents
from public.tenant_invoices
where tenant_id = t.id and status in ('issued', 'overdue')
) i on true
where ($1::text = '' or t.status = $1)
and ($2::text = '' or t.billing_status = $2)
and (
$3::text = ''
or t.slug::text ilike '%' || $3 || '%'
or t.name ilike '%' || $3 || '%'
or coalesce(t.legal_name, '') ilike '%' || $3 || '%'
)
order by t.created_at desc
limit $4
`,
[status, billingStatus, q, limit],
);
return { items };
}
export async function tenantDetailRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
const tenant = await queryOne(
`
select t.id, t.slug::text, t.name, t.legal_name as "legalName", t.status, t.mode,
t.billing_status as "billingStatus", t.owner_user_id as "ownerUserId",
t.metadata, t.created_at as "createdAt", t.updated_at as "updatedAt",
b.brand_name as "brandName", b.short_name as "shortName", b.logo_url as "logoUrl",
b.service_wechat as "serviceWechat",
bp.billing_name as "billingName", bp.tax_id as "taxId",
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
bp.invoice_type as "invoiceType"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
where t.id = $1
limit 1
`,
[tenantId],
);
if (!tenant) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
const [domains, subscriptions, invoices, usage] = await Promise.all([
query(
`
select id, host::text, domain_type as "domainType", status, is_primary as "isPrimary",
verified_at as "verifiedAt", created_at as "createdAt"
from public.tenant_domains
where tenant_id = $1
order by is_primary desc, created_at asc
`,
[tenantId],
),
query(
`
select id, plan_code as "planCode", status, starts_at as "startsAt",
expires_at as "expiresAt", billing_cycle as "billingCycle",
amount_cents as "amountCents", metadata, created_at as "createdAt"
from public.tenant_subscriptions
where tenant_id = $1
order by created_at desc
limit 10
`,
[tenantId],
),
query(
`
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", due_date as "dueDate",
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
from public.tenant_invoices
where tenant_id = $1
order by created_at desc
limit 20
`,
[tenantId],
),
query(
`
select metric_key as "metricKey", metric_value as "metricValue",
period_start as "periodStart", period_end as "periodEnd", metadata,
created_at as "createdAt"
from public.tenant_usage_records
where tenant_id = $1
order by period_end desc, metric_key asc
limit 50
`,
[tenantId],
),
]);
return { item: { tenant, domains, subscriptions, invoices, usage } };
}
export async function createTenantRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const slug = normalizeSlug(requiredString(body, 'slug'));
const name = requiredString(body, 'name');
if (!slug) throw new HttpError(400, 'slug is invalid', 'INVALID_SLUG');
const planCode = optionalString(body, 'planCode') || 'starter_yearly';
const mode = optionalString(body, 'mode') || 'saas';
const billingStatus = optionalString(body, 'billingStatus') || 'trial';
const status = optionalString(body, 'status') || 'active';
const legalName = optionalString(body, 'legalName') || null;
const primaryHost = optionalString(body, 'primaryHost');
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
const item = await transaction(async client => {
const planResult = await client.query<{ code: string; billing_cycle: string; base_amount_cents: number }>(
`
select code, billing_cycle, base_amount_cents
from public.platform_saas_plans
where code = $1 and status = 'active'
limit 1
`,
[planCode],
);
const plan = planResult.rows[0];
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
const tenantResult = await client.query(
`
insert into public.tenants (slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
returning id, slug::text, name, legal_name as "legalName", status, mode,
billing_status as "billingStatus", metadata, created_at as "createdAt"
`,
[slug, name, legalName, status, mode, billingStatus, JSON.stringify(metadata)],
);
const tenant = tenantResult.rows[0];
await client.query(
`
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
values ($1, $2, $3, $4)
`,
[tenant.id, optionalString(body, 'brandName') || name, optionalString(body, 'shortName') || name, optionalString(body, 'slogan') || null],
);
await client.query(
`
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
values ($1, $2::jsonb, $3::jsonb, $4::jsonb)
`,
[
tenant.id,
jsonBodyValue(body.featureFlags || {}),
jsonBodyValue(body.adminFeatureFlags || {}),
jsonBodyValue(body.publicConfig || {}),
],
);
await client.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone,
contact_email, billing_address, invoice_title, invoice_type, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
`,
[
tenant.id,
typeof billing.billingName === 'string' ? billing.billingName : legalName || name,
typeof billing.taxId === 'string' ? billing.taxId : null,
typeof billing.contactName === 'string' ? billing.contactName : null,
typeof billing.contactPhone === 'string' ? billing.contactPhone : null,
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
jsonBodyValue(billing.metadata),
],
);
if (primaryHost) {
const host = normalizeHost(primaryHost);
if (host) {
await client.query(
`
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary, verification_token)
values ($1, $2, 'custom', 'pending', true, $3)
`,
[tenant.id, host, `tenant-${tenant.id.slice(0, 8)}-${Math.random().toString(36).slice(2, 10)}`],
);
}
}
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
const expiresAt =
optionalString(body, 'expiresAt') ||
(plan.billing_cycle === 'yearly'
? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString()
: null);
await client.query(
`
insert into public.tenant_subscriptions (
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
)
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
`,
[
tenant.id,
plan.code,
billingStatus === 'trial' ? 'trial' : 'active',
startsAt,
expiresAt,
plan.billing_cycle,
centsFrom(body.amountCents, plan.base_amount_cents),
JSON.stringify({ source: 'platform-admin:createTenant' }),
],
);
return tenant;
});
return { item };
}
export async function updateTenantStatusRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const status = optionalString(body, 'status');
const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
const item = await queryOne(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
return { item };
}
export async function upsertBillingProfileRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const item = await queryOne(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
optionalString(body, 'invoiceType') || 'none',
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
return { item };
}
export async function createSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const planCode = requiredString(body, 'planCode');
const plan = await queryOne<{ code: string; billingCycle: string; baseAmountCents: number }>(
`
select code, billing_cycle as "billingCycle", base_amount_cents as "baseAmountCents"
from public.platform_saas_plans
where code = $1 and status = 'active'
limit 1
`,
[planCode],
);
if (!plan) throw new HttpError(404, 'SaaS plan not found', 'SAAS_PLAN_NOT_FOUND');
const startsAt = optionalString(body, 'startsAt') || new Date().toISOString();
const expiresAt =
optionalString(body, 'expiresAt') ||
(plan.billingCycle === 'yearly' ? new Date(new Date(startsAt).getTime() + 365 * 24 * 60 * 60 * 1000).toISOString() : null);
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_subscriptions (
tenant_id, plan_code, status, starts_at, expires_at, billing_cycle, amount_cents, metadata
)
values ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6, $7, $8::jsonb)
returning id, tenant_id as "tenantId", plan_code as "planCode", status,
starts_at as "startsAt", expires_at as "expiresAt",
billing_cycle as "billingCycle", amount_cents as "amountCents",
metadata, created_at as "createdAt"
`,
[
tenantId,
plan.code,
optionalString(body, 'status') || 'active',
startsAt,
expiresAt,
optionalString(body, 'billingCycle') || plan.billingCycle,
centsFrom(body.amountCents, plan.baseAmountCents),
jsonBodyValue(body.metadata),
],
);
await client.query(
`
update public.tenants
set billing_status = case when $2 = 'active' then 'active' else billing_status end,
updated_at = now()
where id = $1
`,
[tenantId, optionalString(body, 'status') || 'active'],
);
return result.rows[0];
});
return { item };
}
export async function tenantInvoicesRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const status = listQuery(ctx, 'status');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query(
`
select i.id, i.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType", i.status,
i.currency, i.total_cents as "totalCents", i.paid_cents as "paidCents",
i.balance_cents as "balanceCents", i.billing_period_start as "billingPeriodStart",
i.billing_period_end as "billingPeriodEnd", i.due_date as "dueDate",
i.issued_at as "issuedAt", i.paid_at as "paidAt", i.note,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.tenant_invoices i
join public.tenants t on t.id = i.tenant_id
where ($1::uuid is null or i.tenant_id = $1::uuid)
and ($2::text = '' or i.status = $2)
order by i.created_at desc
limit $3
`,
[tenantId || null, status, limit],
);
return { items };
}
interface CreateInvoiceInput {
tenantId: string;
invoiceNo?: string;
invoiceType?: string;
status?: string;
currency?: string;
discountCents?: number;
taxCents?: number;
billingPeriodStart?: string | null;
billingPeriodEnd?: string | null;
dueDate?: string | null;
note?: string | null;
metadata?: Record<string, unknown>;
items: ReturnType<typeof normalizeInvoiceItems>;
}
async function createInvoiceRecord(input: CreateInvoiceInput) {
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
const discountCents = centsFrom(input.discountCents, 0);
const taxCents = centsFrom(input.taxCents, 0);
const subtotalCents = invoiceSubtotal(input.items);
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
const invoiceNo = input.invoiceNo || createInvoiceNo();
return transaction(async client => {
const invoiceResult = await client.query(
`
insert into public.tenant_invoices (
tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
)
values (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, 0, $9,
$10::date, $11::date, $12::date,
case when $4 = 'draft' then null else now() end,
$13, $14::jsonb
)
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
invoice_type as "invoiceType", status, total_cents as "totalCents",
balance_cents as "balanceCents", due_date as "dueDate",
created_at as "createdAt"
`,
[
input.tenantId,
invoiceNo,
input.invoiceType || 'subscription',
input.status || 'issued',
input.currency || 'CNY',
subtotalCents,
discountCents,
taxCents,
totalCents,
input.billingPeriodStart || null,
input.billingPeriodEnd || null,
input.dueDate || null,
input.note || null,
JSON.stringify(input.metadata || {}),
],
);
const invoice = invoiceResult.rows[0];
for (const itemInput of input.items) {
await client.query(
`
insert into public.tenant_invoice_items (
tenant_id, invoice_id, item_type, description,
quantity, unit_amount_cents, amount_cents, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
input.tenantId,
invoice.id,
itemInput.itemType,
itemInput.description,
itemInput.quantity,
itemInput.unitAmountCents,
Math.round(itemInput.quantity * itemInput.unitAmountCents),
JSON.stringify(itemInput.metadata || {}),
],
);
}
await recalculateInvoiceTotals(client, invoice.id);
return invoice;
});
}
export async function createInvoiceRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const items = normalizeInvoiceItems(body.items);
const item = await createInvoiceRecord({
tenantId,
items,
invoiceNo: optionalString(body, 'invoiceNo') || undefined,
invoiceType: optionalString(body, 'invoiceType') || 'subscription',
status: optionalString(body, 'status') || 'issued',
currency: optionalString(body, 'currency') || 'CNY',
discountCents: centsFrom(body.discountCents, 0),
taxCents: centsFrom(body.taxCents, 0),
billingPeriodStart: optionalString(body, 'billingPeriodStart') || null,
billingPeriodEnd: optionalString(body, 'billingPeriodEnd') || null,
dueDate: optionalString(body, 'dueDate') || null,
note: optionalString(body, 'note') || null,
metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? (body.metadata as Record<string, unknown>) : {},
});
return { item };
}
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const invoiceId = requiredString(body, 'invoiceId');
const amountCents = centsFrom(body.amountCents, -1);
if (amountCents <= 0) throw new HttpError(400, 'amountCents must be positive', 'INVALID_AMOUNT');
const item = await transaction(async client => {
const invoiceResult = await client.query<{ id: string; balance_cents: number }>(
`
select id, balance_cents
from public.tenant_invoices
where tenant_id = $1 and id = $2
limit 1
for update
`,
[tenantId, invoiceId],
);
const invoice = invoiceResult.rows[0];
if (!invoice) throw new HttpError(404, 'Invoice not found', 'INVOICE_NOT_FOUND');
if (amountCents > invoice.balance_cents && invoice.balance_cents > 0) {
throw new HttpError(409, 'Payment amount exceeds invoice balance', 'PAYMENT_AMOUNT_EXCEEDS_BALANCE');
}
const paymentNo = optionalString(body, 'paymentNo') || createInvoiceNo('PAY');
await client.query(
`
insert into public.tenant_invoice_payments (
tenant_id, invoice_id, payment_no, provider, method, status,
amount_cents, paid_at, provider_trade_no, raw_payload
)
values ($1, $2, $3, $4, $5, 'paid', $6, coalesce($7::timestamptz, now()), $8, $9::jsonb)
`,
[
tenantId,
invoiceId,
paymentNo,
optionalString(body, 'provider') || 'manual',
optionalString(body, 'method') || 'manual',
amountCents,
optionalString(body, 'paidAt') || null,
optionalString(body, 'providerTradeNo') || null,
jsonBodyValue(body.rawPayload || body),
],
);
const updatedInvoice = await recalculateInvoiceTotals(client, invoiceId);
if (updatedInvoice?.status === 'paid') {
await client.query(
`
update public.tenants
set billing_status = 'active', updated_at = now()
where id = $1
`,
[tenantId],
);
await client.query(
`
update public.tenant_subscriptions
set status = 'active', updated_at = now()
where tenant_id = $1
and status in ('trial', 'past_due')
and id = (
select id
from public.tenant_subscriptions
where tenant_id = $1
order by created_at desc
limit 1
)
`,
[tenantId],
);
}
return updatedInvoice;
});
return { item };
}
export async function recordUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const metricKey = requiredString(body, 'metricKey');
const metricValue = quantityFrom(body.metricValue, 0);
const periodStart = optionalString(body, 'periodStart');
const periodEnd = optionalString(body, 'periodEnd');
if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD');
const item = await queryOne(
`
insert into public.tenant_usage_records (
tenant_id, metric_key, metric_value, period_start, period_end, metadata
)
values ($1, $2, $3, $4::date, $5::date, $6::jsonb)
returning id, tenant_id as "tenantId", metric_key as "metricKey",
metric_value as "metricValue", period_start as "periodStart",
period_end as "periodEnd", metadata, created_at as "createdAt"
`,
[tenantId, metricKey, metricValue, periodStart, periodEnd, jsonBodyValue(body.metadata)],
);
return { item };
}
export async function tenantUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select u.id, u.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
u.metric_key as "metricKey", u.metric_value as "metricValue",
u.period_start as "periodStart", u.period_end as "periodEnd",
u.metadata, u.created_at as "createdAt"
from public.tenant_usage_records u
join public.tenants t on t.id = u.tenant_id
where ($1::uuid is null or u.tenant_id = $1::uuid)
order by u.period_end desc, u.created_at desc
limit $2
`,
[tenantId || null, limit],
);
return { items };
}
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const subscriptionId = optionalString(body, 'subscriptionId');
const subscription = await queryOne<{
id: string;
planCode: string;
amountCents: number;
startsAt: string | null;
expiresAt: string | null;
}>(
`
select id, plan_code as "planCode", amount_cents as "amountCents",
starts_at as "startsAt", expires_at as "expiresAt"
from public.tenant_subscriptions
where tenant_id = $1
and ($2::uuid is null or id = $2::uuid)
order by created_at desc
limit 1
`,
[tenantId, subscriptionId || null],
);
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
const plan = await queryOne<{ name: string }>(
`
select name from public.platform_saas_plans
where code = $1
limit 1
`,
[subscription.planCode],
);
const item = await createInvoiceRecord({
tenantId,
invoiceType: 'subscription',
status: optionalString(body, 'status') || 'issued',
dueDate: optionalString(body, 'dueDate') || null,
billingPeriodStart: toDateText(subscription.startsAt),
billingPeriodEnd: toDateText(subscription.expiresAt),
note: optionalString(body, 'note') || null,
metadata: { source: 'subscription', subscriptionId: subscription.id },
items: [
{
itemType: 'subscription',
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
quantity: 1,
unitAmountCents: subscription.amountCents,
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
},
],
});
return { item };
}

View File

@@ -0,0 +1,118 @@
import type pg from 'pg';
export function createInvoiceNo(prefix = 'BILL') {
const now = new Date();
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}
export function normalizeSlug(slug: string) {
return slug
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function normalizeHost(host: string) {
return host.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || '';
}
export function centsFrom(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
}
export function quantityFrom(value: unknown, fallback = 1) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export interface InvoiceItemInput {
itemType: string;
description: string;
quantity: number;
unitAmountCents: number;
metadata?: Record<string, unknown>;
}
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
if (!Array.isArray(items)) return [];
return items
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => !!item)
.map(item => ({
itemType: typeof item.itemType === 'string' && item.itemType.trim() ? item.itemType.trim() : 'service_fee',
description: typeof item.description === 'string' && item.description.trim() ? item.description.trim() : '服务费',
quantity: quantityFrom(item.quantity, 1),
unitAmountCents: centsFrom(item.unitAmountCents, 0),
metadata: item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata) ? (item.metadata as Record<string, unknown>) : {},
}))
.filter(item => item.unitAmountCents > 0);
}
export function invoiceSubtotal(items: InvoiceItemInput[]) {
return items.reduce((sum, item) => sum + Math.round(item.quantity * item.unitAmountCents), 0);
}
export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: string) {
const itemResult = await client.query<{ subtotal: string }>(
`
select coalesce(sum(amount_cents), 0)::text as subtotal
from public.tenant_invoice_items
where invoice_id = $1
`,
[invoiceId],
);
const paymentResult = await client.query<{ paid: string }>(
`
select coalesce(sum(amount_cents), 0)::text as paid
from public.tenant_invoice_payments
where invoice_id = $1 and status = 'paid'
`,
[invoiceId],
);
const subtotal = Number(itemResult.rows[0]?.subtotal || 0);
const paid = Number(paymentResult.rows[0]?.paid || 0);
const updateResult = await client.query(
`
update public.tenant_invoices
set subtotal_cents = $2,
total_cents = greatest(0, $2 - discount_cents + tax_cents),
paid_cents = $3,
balance_cents = greatest(0, greatest(0, $2 - discount_cents + tax_cents) - $3),
status = case
when status = 'void' then status
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid'
when status = 'draft' then 'issued'
else status
end,
paid_at = case
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then coalesce(paid_at, now())
else paid_at
end,
updated_at = now()
where id = $1
returning id, invoice_no as "invoiceNo", status, subtotal_cents as "subtotalCents",
discount_cents as "discountCents", tax_cents as "taxCents",
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", paid_at as "paidAt"
`,
[invoiceId, subtotal, paid],
);
return updateResult.rows[0];
}

View File

@@ -0,0 +1,7 @@
import type { RouteDefinition } from '../../core/router.js';
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
export const profileRoutes: RouteDefinition[] = [
['GET', '/api/profile/me', profileMeRoute],
['PATCH', '/api/profile/me', updateProfileMeRoute],
];

View File

@@ -0,0 +1,259 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
type JsonMap = Record<string, unknown>;
interface ProfileRow {
id: string;
userId: string;
username: string | null;
phone: string | null;
email: string | null;
name: string | null;
avatarUrl: string | null;
primaryRole: string;
score: number;
regionId: string | null;
regionName: string | null;
selectedSchoolId: string | null;
selectedSchoolName: string | null;
selectedMajorId: string | null;
selectedMajorName: string | null;
questionsAnsweredToday: number;
masteredWordsCount: number;
lastCheckInDate: string | null;
stats: JsonMap;
progress: JsonMap;
moduleSelections: JsonMap;
recentActivities: unknown[];
createdAt: string;
updatedAt: string;
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' ? value : {});
}
function jsonArrayBodyValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const limit = intParam(ctx, 'recentLimit', 8, 50);
const profile = await queryOne<ProfileRow>(
`
select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name,
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score,
sp.region_id as "regionId", r.name as "regionName",
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
sp.questions_answered_today as "questionsAnsweredToday",
sp.mastered_words_count as "masteredWordsCount",
sp.last_check_in_date as "lastCheckInDate",
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
sp.recent_activities as "recentActivities",
sp.created_at as "createdAt", sp.updated_at as "updatedAt"
from public.student_profiles sp
join public.platform_users u on u.id = sp.user_id
left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id
left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id
left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id
where sp.tenant_id = $1 and sp.user_id = $2
limit 1
`,
[tenantId, userId],
);
if (!profile) {
throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
}
const recentPractices = await query(
`
select id, practice_type as "practiceType", target_legacy_id as "targetLegacyId",
target_name as "targetName", progress, color,
last_access_at as "lastAccessAt", last_practice_at as "lastPracticeAt",
metadata, created_at as "createdAt", updated_at as "updatedAt"
from public.recent_practices
where tenant_id = $1 and user_id = $2
order by last_practice_at desc nulls last, last_access_at desc nulls last, updated_at desc
limit $3
`,
[tenantId, userId, limit],
);
const answerStats = await queryOne<{
totalAnswered: string;
correctCount: string;
wrongCount: string;
latestAnsweredAt: string | null;
}>(
`
select count(*)::text as "totalAnswered",
count(*) filter (where is_correct is true)::text as "correctCount",
count(*) filter (where is_correct is false)::text as "wrongCount",
max(answered_at) as "latestAnsweredAt"
from public.answer_records
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
const wordStats = await queryOne<{
totalWords: string;
progressedWords: string;
masteredWords: string;
learningWords: string;
favoriteWords: string;
}>(
`
select
(select count(*) from public.vocabulary_words where tenant_id = $1 and is_active = true)::text as "totalWords",
count(*)::text as "progressedWords",
count(*) filter (where status = 'mastered')::text as "masteredWords",
count(*) filter (where status in ('learning', 'reviewing'))::text as "learningWords",
(select count(*) from public.user_word_favorites where tenant_id = $1 and user_id = $2)::text as "favoriteWords"
from public.user_word_progress
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
const entitlement = await queryOne(
`
select id, entitlement_type as "entitlementType", scope_type as "scopeType",
scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt",
status, metadata
from public.entitlements
where tenant_id = $1 and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= now()
and (expires_at is null or expires_at > now())
order by expires_at desc nulls first, created_at desc
limit 1
`,
[tenantId, userId],
);
const orderSummary = await queryOne<{
totalOrders: string;
paidOrders: string;
paidAmountCents: string;
}>(
`
select count(*)::text as "totalOrders",
count(*) filter (where status = 'paid')::text as "paidOrders",
coalesce(sum(amount_cents) filter (where status = 'paid'), 0)::text as "paidAmountCents"
from public.orders
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return {
item: {
...profile,
target: {
regionId: profile.regionId,
regionName: profile.regionName,
schoolId: profile.selectedSchoolId,
schoolName: profile.selectedSchoolName,
majorId: profile.selectedMajorId,
majorName: profile.selectedMajorName,
},
membership: {
isSvip: !!entitlement,
entitlement,
},
stats: {
...profile.stats,
answers: {
totalAnswered: Number(answerStats?.totalAnswered || 0),
correctCount: Number(answerStats?.correctCount || 0),
wrongCount: Number(answerStats?.wrongCount || 0),
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
},
vocabulary: {
totalWords: Number(wordStats?.totalWords || 0),
progressedWords: Number(wordStats?.progressedWords || 0),
masteredWords: Number(wordStats?.masteredWords || 0),
learningWords: Number(wordStats?.learningWords || 0),
favoriteWords: Number(wordStats?.favoriteWords || 0),
},
orders: {
totalOrders: Number(orderSummary?.totalOrders || 0),
paidOrders: Number(orderSummary?.paidOrders || 0),
paidAmountCents: Number(orderSummary?.paidAmountCents || 0),
},
},
recentPractices,
},
};
}
export async function updateProfileMeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const name = optionalString(body, 'name') || null;
const avatarUrl = optionalString(body, 'avatarUrl') || null;
const regionId = optionalString(body, 'regionId') || null;
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
const item = await queryOne(
`
with updated_user as (
update public.platform_users
set name = coalesce($3, name),
avatar_url = coalesce($4, avatar_url),
updated_at = now()
where id = $2
returning id
)
insert into public.student_profiles (
tenant_id, user_id, region_id, selected_school_id, selected_major_id,
stats, progress, module_selections, recent_activities
)
values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
on conflict (tenant_id, user_id)
do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id),
selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id),
stats = case when $12::boolean then excluded.stats else public.student_profiles.stats end,
progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end,
module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end,
recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
updated_at = now()
returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId",
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId",
stats, progress, module_selections as "moduleSelections",
recent_activities as "recentActivities", updated_at as "updatedAt"
`,
[
tenantId,
userId,
name,
avatarUrl,
regionId,
selectedSchoolId,
selectedMajorId,
jsonBodyValue(body.stats),
jsonBodyValue(body.progress),
jsonBodyValue(body.moduleSelections),
jsonArrayBodyValue(body.recentActivities),
Object.hasOwn(body, 'stats'),
Object.hasOwn(body, 'progress'),
Object.hasOwn(body, 'moduleSelections'),
Object.hasOwn(body, 'recentActivities'),
],
);
return { item };
}

View File

@@ -0,0 +1,34 @@
import type { RouteDefinition } from '../../core/router.js';
import {
crmConfigRoute,
crmQueueRoute,
referralBindRoute,
referralInviteCodeRoute,
referralManualBindRoute,
referralQrcodeRoute,
referralResolveRoute,
referralSalesClientsRoute,
referralSalesStatsRoute,
referralStatsRoute,
referralTeamRoute,
referralTrackEventRoute,
upsertCrmConfigRoute,
upsertReferralTeamRoute,
} from './routes.js';
export const referralRoutes: RouteDefinition[] = [
['POST', '/api/referral/invite-code', referralInviteCodeRoute],
['POST', '/api/referral/resolve', referralResolveRoute],
['POST', '/api/referral/track-event', referralTrackEventRoute],
['POST', '/api/referral/bind', referralBindRoute],
['GET', '/api/referral/stats', referralStatsRoute],
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
['POST', '/api/referral/manual-bind', referralManualBindRoute],
['GET', '/api/referral/team', referralTeamRoute],
['PUT', '/api/referral/team', upsertReferralTeamRoute],
['POST', '/api/referral/qrcode', referralQrcodeRoute],
['GET', '/api/crm/config', crmConfigRoute],
['PUT', '/api/crm/config', upsertCrmConfigRoute],
['GET', '/api/crm/queue', crmQueueRoute],
];

View File

@@ -0,0 +1,920 @@
import { randomBytes } from 'node:crypto';
import type pg from 'pg';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
type JsonBody = Record<string, unknown>;
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown'];
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function normalizeCode(code: string) {
return code.replace(/\s+/g, '').toUpperCase();
}
function generateReferralCode() {
let code = '';
for (let index = 0; index < 6; index += 1) {
code += REFERRAL_CODE_ALPHABET[randomBytes(1)[0] % REFERRAL_CODE_ALPHABET.length];
}
return code;
}
function centsToAmount(cents: number) {
return Math.round(cents) / 100;
}
function canViewAllReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:read');
}
function canViewSelfReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
}
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query(
`
select 1
from public.tenant_memberships
where tenant_id = $1 and user_id = $2 and status = 'active'
limit 1
`,
[tenantId, userId],
);
return Boolean(result.rows[0]);
}
async function resolveReferralCode(tenantId: string, code: string) {
return queryOne<{
code: string;
userId: string;
status: string;
username: string | null;
name: string | null;
phone: string | null;
role: string | null;
}>(
`
select rc.code::text as code, rc.user_id as "userId", rc.status,
u.username, u.name, u.phone, tm.role
from public.referral_codes rc
join public.platform_users u on u.id = rc.user_id
left join lateral (
select role
from public.tenant_memberships
where tenant_id = rc.tenant_id and user_id = rc.user_id and status = 'active'
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'sales' then 3
when 'agent' then 4
when 'teacher' then 5
else 9
end
limit 1
) tm on true
where rc.tenant_id = $1
and rc.code = $2::citext
and rc.status = 'active'
limit 1
`,
[tenantId, normalizeCode(code)],
);
}
async function createOrGetReferralCode(client: pg.PoolClient, tenantId: string, userId: string, metadata: Record<string, unknown> = {}) {
const existing = await client.query<{ code: string }>(
`
select code::text
from public.referral_codes
where tenant_id = $1 and user_id = $2
limit 1
`,
[tenantId, userId],
);
if (existing.rows[0]?.code) return existing.rows[0].code;
for (let attempt = 0; attempt < 30; attempt += 1) {
const candidate = generateReferralCode();
const inserted = await client.query<{ code: string }>(
`
insert into public.referral_codes (tenant_id, user_id, code, metadata)
values ($1, $2, $3::citext, $4::jsonb)
on conflict do nothing
returning code::text
`,
[tenantId, userId, candidate, JSON.stringify(metadata)],
);
if (inserted.rows[0]?.code) return inserted.rows[0].code;
}
throw new HttpError(500, 'Failed to generate referral code', 'REFERRAL_CODE_GENERATION_FAILED');
}
async function enqueueCrmLead(
client: pg.PoolClient,
input: {
tenantId: string;
leadId: string;
studentUserId: string;
source: string;
metadata?: Record<string, unknown>;
},
) {
const cfg = await client.query<{
enabled: boolean;
url: string | null;
form_name: string | null;
exam_type: string | null;
delay_sec: number | null;
timeout_sec: number | null;
}>(
`
select enabled, url, form_name, exam_type, delay_sec, timeout_sec
from public.crm_config
where tenant_id = $1
limit 1
`,
[input.tenantId],
);
const config = cfg.rows[0];
if (!config?.enabled || !config.url) return null;
const student = await client.query<{
id: string;
username: string | null;
name: string | null;
phone: string | null;
email: string | null;
raw_profile: Record<string, unknown>;
}>(
`
select id, username, name, phone, email::text, raw_profile
from public.platform_users
where id = $1
limit 1
`,
[input.studentUserId],
);
const lead = await client.query<{
ref_code: string | null;
referrer_user_id: string | null;
bound_at: string;
}>(
`
select ref_code::text, referrer_user_id, bound_at
from public.referral_leads
where tenant_id = $1 and id = $2
limit 1
`,
[input.tenantId, input.leadId],
);
const payload = {
formName: config.form_name || '刷题题库',
examType: config.exam_type || '成人本科',
source: input.source,
leadId: input.leadId,
student: {
id: input.studentUserId,
username: student.rows[0]?.username || null,
name: student.rows[0]?.name || null,
phone: student.rows[0]?.phone || null,
email: student.rows[0]?.email || null,
},
referral: {
refCode: lead.rows[0]?.ref_code || null,
referrerUserId: lead.rows[0]?.referrer_user_id || null,
boundAt: lead.rows[0]?.bound_at || null,
},
metadata: input.metadata || {},
};
const delaySeconds = Math.max(0, Number(config.delay_sec || 60));
const idempotencyKey = `lead:${input.leadId}`;
const queued = await client.query(
`
insert into public.crm_webhook_queue (
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
source, payload, idempotency_key, target_url
)
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, $4, $5, $6::jsonb, $7, $8)
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
do update set status = case
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
else 'pending'
end,
scheduled_at = excluded.scheduled_at,
next_attempt_at = null,
payload = excluded.payload,
target_url = excluded.target_url,
updated_at = now()
returning id, status, scheduled_at as "scheduledAt", lead_id as "leadId"
`,
[
input.tenantId,
input.studentUserId,
delaySeconds,
input.leadId,
input.source,
JSON.stringify(payload),
idempotencyKey,
config.url,
],
);
return queued.rows[0];
}
async function bindReferralLead(
client: pg.PoolClient,
input: {
tenantId: string;
studentUserId: string;
refCode: string;
referrerUserId: string | null;
source: string;
trackId: string | null;
bindType?: 'first_touch' | 'manual' | 'imported';
force?: boolean;
metadata?: Record<string, unknown>;
},
) {
if (input.referrerUserId && input.referrerUserId === input.studentUserId) {
throw new HttpError(400, 'Cannot bind a lead to itself', 'SELF_REFERRAL_NOT_ALLOWED');
}
const existing = await client.query<{
id: string;
referrerUserId: string | null;
refCode: string | null;
status: string;
boundAt: string;
}>(
`
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode",
status, bound_at as "boundAt"
from public.referral_leads
where tenant_id = $1 and student_user_id = $2
limit 1
`,
[input.tenantId, input.studentUserId],
);
if (existing.rows[0] && !input.force) {
return { item: existing.rows[0], bound: false, protected: true };
}
const result = await client.query(
`
insert into public.referral_leads (
tenant_id, student_user_id, referrer_user_id, ref_code, source,
first_track_id, bind_type, status, metadata
)
values ($1, $2, $3, $4::citext, $5, $6::uuid, $7, 'protected', $8::jsonb)
on conflict (tenant_id, student_user_id)
do update set referrer_user_id = excluded.referrer_user_id,
ref_code = excluded.ref_code,
source = excluded.source,
first_track_id = coalesce(public.referral_leads.first_track_id, excluded.first_track_id),
bind_type = excluded.bind_type,
status = 'protected',
metadata = public.referral_leads.metadata || excluded.metadata,
updated_at = now()
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
ref_code::text as "refCode", source, bind_type as "bindType", status,
bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt"
`,
[
input.tenantId,
input.studentUserId,
input.referrerUserId,
normalizeCode(input.refCode),
input.source,
input.trackId,
input.bindType || 'first_touch',
JSON.stringify(input.metadata || {}),
],
);
if (input.trackId) {
await client.query(
'update public.referral_tracks set lead_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
[input.tenantId, input.trackId, result.rows[0].id],
);
}
return { item: result.rows[0], bound: true, protected: false };
}
function crmPermission(auth: TenantAdminAuth) {
requireTenantPermission(auth, 'crm:read');
}
export async function referralInviteCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const item = await transaction(async client => {
const activeMember = await userHasTenantMembership(client, tenantId, userId);
if (!activeMember) {
throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
}
const code = await createOrGetReferralCode(client, tenantId, userId, { source: 'api' });
return { inviteCode: code };
});
return item;
}
export async function referralResolveRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = optionalString(body, 'code');
if (!code) return { valid: false };
const item = await resolveReferralCode(tenantId, code);
if (!item) return { valid: false };
return {
valid: true,
inviterId: item.userId,
inviteCode: item.code,
role: item.role,
name: item.name || item.username || '推广员',
};
}
export async function referralTrackEventRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
const refCode = normalizeCode(requiredString(body, 'refCode'));
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
const referrer = await resolveReferralCode(tenantId, refCode);
const result = await transaction(async client => {
const track = await client.query(
`
insert into public.referral_tracks (
tenant_id, event_type, ref_code, ref_user_id, target_user_id, source,
ip_address, user_agent, metadata
)
values ($1, $2, $3::citext, $4, $5, $6, $7, $8, $9::jsonb)
returning id, event_type as "eventType", ref_code::text as "refCode",
ref_user_id as "refUserId", target_user_id as "targetUserId",
source, created_at as "createdAt"
`,
[
tenantId,
eventType,
refCode,
referrer?.userId || null,
targetUserId,
source,
clientIpFrom(ctx),
userAgentFrom(ctx),
JSON.stringify(objectValue(body.metadata)),
],
);
let lead = null;
let crmQueue = null;
if (targetUserId && referrer?.userId && (eventType === 'register' || eventType === 'enter' || eventType === 'scan')) {
lead = await bindReferralLead(client, {
tenantId,
studentUserId: targetUserId,
refCode,
referrerUserId: referrer.userId,
source,
trackId: track.rows[0].id,
metadata: { eventType },
});
if (lead.bound) {
crmQueue = await enqueueCrmLead(client, {
tenantId,
leadId: lead.item.id,
studentUserId: targetUserId,
source,
metadata: { trigger: 'referral.track_event', eventType },
});
}
}
return { track: track.rows[0], lead, crmQueue };
});
return { item: result.track, lead: result.lead, crmQueue: result.crmQueue };
}
export async function referralBindRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const studentUserId = userIdFrom(ctx, body);
const refCode = normalizeCode(requiredString(body, 'refCode'));
const referrer = await resolveReferralCode(tenantId, refCode);
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
const result = await transaction(async client => {
const lead = await bindReferralLead(client, {
tenantId,
studentUserId,
refCode,
referrerUserId: referrer.userId,
source: optionalString(body, 'source') || 'manual',
trackId: null,
metadata: objectValue(body.metadata),
});
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
tenantId,
leadId: lead.item.id,
studentUserId,
source: optionalString(body, 'source') || 'manual',
metadata: { trigger: 'referral.bind' },
}) : null;
return { lead, crmQueue };
});
return result;
}
export async function referralStatsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
const item = await queryOne<{
referrerUserId: string;
leadCount: string;
paidLeadCount: string;
paidAmountCents: string;
trackCount: string;
}>(
`
with leads as (
select rl.student_user_id
from public.referral_leads rl
where rl.tenant_id = $1 and rl.referrer_user_id = $2 and rl.status = 'protected'
),
paid as (
select o.user_id, sum(o.amount_cents)::bigint as amount_cents
from public.orders o
join leads l on l.student_user_id = o.user_id
where o.tenant_id = $1 and o.status = 'paid'
group by o.user_id
)
select $2::uuid as "referrerUserId",
(select count(*) from leads)::text as "leadCount",
(select count(*) from paid)::text as "paidLeadCount",
coalesce((select sum(amount_cents) from paid), 0)::text as "paidAmountCents",
(
select count(*)
from public.referral_tracks rt
where rt.tenant_id = $1 and rt.ref_user_id = $2
)::text as "trackCount"
`,
[auth.tenantId, referrerUserId],
);
const paidAmountCents = Number(item?.paidAmountCents || 0);
return {
item: {
referrerUserId,
leadCount: Number(item?.leadCount || 0),
paidLeadCount: Number(item?.paidLeadCount || 0),
paidAmountCents,
paidAmount: centsToAmount(paidAmountCents),
trackCount: Number(item?.trackCount || 0),
conversionRate: Number(item?.leadCount || 0) > 0
? Math.round((Number(item?.paidLeadCount || 0) / Number(item?.leadCount || 0)) * 100)
: 0,
},
};
}
export async function referralSalesStatsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
let scopeSql = '';
if (!canViewAllReferral(auth)) {
params.push(auth.userId);
scopeSql = `and tm.user_id = $${params.length}`;
}
params.push(limit);
const items = await query<{
referrerUserId: string;
role: string;
username: string | null;
name: string | null;
phone: string | null;
inviteCode: string | null;
leadCount: number;
paidLeadCount: number;
paidAmountCents: string;
trackCount: number;
}>(
`
with referrers as (
select tm.user_id, tm.role, u.username, u.name, u.phone,
rc.code::text as code
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
where tm.tenant_id = $1
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
${scopeSql}
),
lead_stats as (
select referrer_user_id,
count(*)::int as lead_count,
count(*) filter (where exists (
select 1 from public.orders o
where o.tenant_id = rl.tenant_id
and o.user_id = rl.student_user_id
and o.status = 'paid'
))::int as paid_lead_count
from public.referral_leads rl
where rl.tenant_id = $1 and rl.status = 'protected'
group by referrer_user_id
),
order_stats as (
select rl.referrer_user_id, coalesce(sum(o.amount_cents), 0)::bigint as paid_amount_cents
from public.referral_leads rl
join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id and o.status = 'paid'
where rl.tenant_id = $1 and rl.status = 'protected'
group by rl.referrer_user_id
),
track_stats as (
select ref_user_id, count(*)::int as track_count
from public.referral_tracks
where tenant_id = $1
group by ref_user_id
)
select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
r.code as "inviteCode",
coalesce(ls.lead_count, 0) as "leadCount",
coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
coalesce(ts.track_count, 0) as "trackCount"
from referrers r
left join lead_stats ls on ls.referrer_user_id = r.user_id
left join order_stats os on os.referrer_user_id = r.user_id
left join track_stats ts on ts.ref_user_id = r.user_id
order by coalesce(ls.lead_count, 0) desc, r.name asc nulls last
limit $${params.length}
`,
params,
);
return {
items: items.map((item: Record<string, unknown>) => ({
...item,
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
conversionRate: Number(item.leadCount || 0) > 0
? Math.round((Number(item.paidLeadCount || 0) / Number(item.leadCount || 0)) * 100)
: 0,
})),
};
}
export async function referralSalesClientsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedReferrerId = stringParam(ctx, 'referrerUserId');
const referrerUserId = canViewAllReferral(auth) && requestedReferrerId ? requestedReferrerId : auth.userId;
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query<{
id: string;
studentUserId: string;
username: string | null;
name: string | null;
phone: string | null;
email: string | null;
referrerUserId: string;
refCode: string | null;
source: string | null;
bindType: string;
status: string;
boundAt: string;
paidAmountCents: string;
lastPaidAt: string | null;
}>(
`
select rl.id, rl.student_user_id as "studentUserId",
u.username, u.name, u.phone, u.email::text as email,
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode",
rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt",
coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents",
max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
from public.referral_leads rl
join public.platform_users u on u.id = rl.student_user_id
left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id
where rl.tenant_id = $1
and rl.referrer_user_id = $2
and rl.status = 'protected'
group by rl.id, u.id
order by rl.bound_at desc
limit $3
`,
[auth.tenantId, referrerUserId, limit],
);
return {
items: items.map((item: Record<string, unknown>) => ({
...item,
paidAmount: centsToAmount(Number(item.paidAmountCents || 0)),
})),
};
}
export async function referralManualBindRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'referral:write');
const body = await readJsonBody(ctx);
const studentUserId = requiredString(body, 'studentUserId');
const referrerUserId = requiredString(body, 'referrerUserId');
const code = await transaction(async client => {
const member = await userHasTenantMembership(client, auth.tenantId, referrerUserId);
if (!member) throw new HttpError(400, 'Referrer is not an active tenant member', 'REFERRER_NOT_TENANT_MEMBER');
return createOrGetReferralCode(client, auth.tenantId, referrerUserId, { source: 'manual_bind' });
});
const result = await transaction(async client => {
const lead = await bindReferralLead(client, {
tenantId: auth.tenantId,
studentUserId,
refCode: code,
referrerUserId,
source: optionalString(body, 'source') || 'manual',
trackId: null,
bindType: 'manual',
force: body.force === true,
metadata: { operatorUserId: auth.userId, ...objectValue(body.metadata) },
});
const crmQueue = lead.bound ? await enqueueCrmLead(client, {
tenantId: auth.tenantId,
leadId: lead.item.id,
studentUserId,
source: optionalString(body, 'source') || 'manual',
metadata: { trigger: 'referral.manual_bind', operatorUserId: auth.userId },
}) : null;
return { lead, crmQueue };
});
return result;
}
export async function referralTeamRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const requestedLeaderId = stringParam(ctx, 'leaderUserId');
const leaderUserId = canViewAllReferral(auth) && requestedLeaderId ? requestedLeaderId : auth.userId;
const items = await query(
`
select rte.id, rte.member_user_id as "memberUserId", rte.leader_user_id as "leaderUserId",
rte.relation_type as "relationType", rte.status, rte.metadata,
u.username, u.name, u.phone,
tm.role
from public.referral_team_edges rte
join public.platform_users u on u.id = rte.member_user_id
left join public.tenant_memberships tm on tm.tenant_id = rte.tenant_id
and tm.user_id = rte.member_user_id
and tm.status = 'active'
where rte.tenant_id = $1
and rte.leader_user_id = $2
and rte.status = 'active'
order by rte.created_at asc
`,
[auth.tenantId, leaderUserId],
);
return { items };
}
export async function upsertReferralTeamRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'referral:write');
const body = await readJsonBody(ctx);
const memberUserId = requiredString(body, 'memberUserId');
const leaderUserId = nullableString(body.leaderUserId);
const relationType = optionalChoice(body.relationType, ['sales_team', 'agent_network', 'teacher_class'], 'sales_team');
const status = optionalChoice(body.status, ['active', 'disabled'], 'active');
const item = await transaction(async client => {
const member = await userHasTenantMembership(client, auth.tenantId, memberUserId);
if (!member) throw new HttpError(400, 'Member is not an active tenant member', 'MEMBER_NOT_TENANT_MEMBER');
if (leaderUserId) {
const leader = await userHasTenantMembership(client, auth.tenantId, leaderUserId);
if (!leader) throw new HttpError(400, 'Leader is not an active tenant member', 'LEADER_NOT_TENANT_MEMBER');
if (leaderUserId === memberUserId) throw new HttpError(400, 'Leader cannot be the member itself', 'SELF_LEADER_NOT_ALLOWED');
}
const result = await client.query(
`
insert into public.referral_team_edges (
tenant_id, member_user_id, leader_user_id, relation_type, status, metadata
)
values ($1, $2, $3, $4, $5, $6::jsonb)
on conflict (tenant_id, member_user_id, relation_type)
do update set leader_user_id = excluded.leader_user_id,
status = excluded.status,
metadata = excluded.metadata,
updated_at = now()
returning id, member_user_id as "memberUserId", leader_user_id as "leaderUserId",
relation_type as "relationType", status, metadata, updated_at as "updatedAt"
`,
[auth.tenantId, memberUserId, leaderUserId, relationType, status, JSON.stringify(objectValue(body.metadata))],
);
return result.rows[0];
});
return { item };
}
export async function referralQrcodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const body = await readJsonBody(ctx);
const page = optionalString(body, 'page') || 'pages/index/index';
const provider = optionalString(body, 'provider') || 'wechat-miniapp';
const item = await transaction(async client => {
const activeMember = await userHasTenantMembership(client, tenantId, userId);
if (!activeMember) throw new HttpError(403, 'Active tenant membership is required', 'TENANT_MEMBER_REQUIRED');
const refCode = await createOrGetReferralCode(client, tenantId, userId, { source: 'qrcode' });
const scene = optionalString(body, 'scene') || `ref_${refCode}`;
const result = await client.query(
`
insert into public.referral_qrcodes (tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status, metadata)
values ($1, $2, $3::citext, $4, $5, $6, $7, 'ready', $8::jsonb)
on conflict (tenant_id, provider, scene, page)
do update set user_id = excluded.user_id,
ref_code = excluded.ref_code,
qrcode_url = coalesce(public.referral_qrcodes.qrcode_url, excluded.qrcode_url),
status = 'ready',
metadata = public.referral_qrcodes.metadata || excluded.metadata,
updated_at = now()
returning id, ref_code::text as "refCode", scene, page, provider,
qrcode_url as "qrcodeUrl", status, created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
refCode,
scene,
page,
provider,
optionalString(body, 'qrcodeUrl') || `miniapp://${page}?scene=${encodeURIComponent(scene)}`,
JSON.stringify({ generatedBy: 'local-placeholder', ...objectValue(body.metadata) }),
],
);
return result.rows[0];
});
return { item };
}
export async function crmConfigRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
crmPermission(auth);
const item = await queryOne(
`
select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
created_at as "createdAt", updated_at as "updatedAt"
from public.crm_config
where tenant_id = $1
limit 1
`,
[auth.tenantId],
);
return { item };
}
export async function upsertCrmConfigRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'crm:write');
const body = await readJsonBody(ctx);
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null);
const item = await transaction(async client => {
if (body.secret && typeof body.secret === 'string') {
await client.query(
`
insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ($1, 'crm', 'webhook', $2, 'webhook', now())
on conflict (tenant_id, secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[auth.tenantId, body.secret],
);
}
const result = await client.query(
`
insert into public.crm_config (
tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec
)
values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (tenant_id)
do update set enabled = excluded.enabled,
url = excluded.url,
secret_ref = excluded.secret_ref,
form_name = excluded.form_name,
exam_type = excluded.exam_type,
timeout_sec = excluded.timeout_sec,
delay_sec = excluded.delay_sec,
updated_at = now()
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec",
updated_at as "updatedAt"
`,
[
auth.tenantId,
body.enabled === true,
nullableString(body.url),
secretRef,
optionalString(body, 'formName') || '刷题题库',
optionalString(body, 'examType') || '成人本科',
Number(body.timeoutSec || 10),
Number(body.delaySec || 60),
],
);
return result.rows[0];
});
return { item };
}
export async function crmQueueRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
crmPermission(auth);
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, record_id as "recordId", status, scheduled_at as "scheduledAt",
attempts, next_attempt_at as "nextAttemptAt", last_error as "lastError",
last_http_code as "lastHttpCode", lead_id as "leadId", sent_at as "sentAt",
source, idempotency_key as "idempotencyKey", target_url as "targetUrl",
payload, created_at as "createdAt", updated_at as "updatedAt"
from public.crm_webhook_queue
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}

View File

@@ -0,0 +1,18 @@
import type { RouteDefinition } from '../../core/router.js';
import {
scorelineFieldsRoute,
scorelineMajorsRoute,
scorelineRecordsRoute,
scorelineSchoolsRoute,
scorelineTrendRoute,
scorelineYearsRoute,
} from './routes.js';
export const scorelineRoutes: RouteDefinition[] = [
['GET', '/api/scoreline/fields', scorelineFieldsRoute],
['GET', '/api/scoreline/schools', scorelineSchoolsRoute],
['GET', '/api/scoreline/majors', scorelineMajorsRoute],
['GET', '/api/scoreline/records', scorelineRecordsRoute],
['GET', '/api/scoreline/trend', scorelineTrendRoute],
['GET', '/api/scoreline/years', scorelineYearsRoute],
];

View File

@@ -0,0 +1,164 @@
import type { RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, field_name asc
`,
[tenantId, regionId || null],
);
return { items };
}
export async function scorelineSchoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const q = stringParam(ctx, 'q');
const limit = intParam(ctx, 'limit', 200, 1000);
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
name, short_name as "shortName", type, is_hot as "isHot",
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_schools
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::text = '' or name ilike '%' || $3 || '%' or short_name ilike '%' || $3 || '%')
order by is_hot desc, sort_order asc, name asc, type asc nulls last
limit $4
`,
[tenantId, regionId || null, q, limit],
);
return { items };
}
export async function scorelineMajorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", name, sort_order as "order",
has_restriction as "hasRestriction", restriction_desc as "restrictionDesc",
created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_majors
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
order by sort_order asc, name asc
limit $4
`,
[tenantId, regionId || null, schoolId || null, limit],
);
return { items };
}
export async function scorelineRecordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
const year = Number(ctx.url.searchParams.get('year') || 0);
const page = intParam(ctx, 'page', 1, 10_000);
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
const offset = (page - 1) * pageSize;
const where = `
tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
and ($5::integer = 0 or year = $5::integer)
`;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const [countRow, items] = await Promise.all([
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
school_id as "schoolId", major_id as "majorId", year,
school_name as "schoolName", major_name as "majorName",
field_values as "fieldValues", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_records
where ${where}
order by year desc, school_name asc nulls last, major_name asc nulls last
limit $6 offset $7
`,
[...params, pageSize, offset],
),
]);
return {
page,
pageSize,
total: Number(countRow?.total || 0),
items,
};
}
export async function scorelineTrendRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
const limit = intParam(ctx, 'limit', 20, 100);
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues"
from public.scoreline_records
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
order by year asc, major_name asc nulls last
limit $5
`,
[tenantId, regionId || null, schoolId || null, majorId || null, limit],
);
return { items };
}
export async function scorelineYearsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query<{ year: number }>(
`
select distinct year
from public.scoreline_records
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by year desc
`,
[tenantId, regionId || null],
);
return { items: items.map(item => item.year) };
}

View File

@@ -0,0 +1,130 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
const TENANT_ADMIN_ROLES = new Set([
'tenant_owner',
'tenant_admin',
'tenant_operator',
'teacher',
'sales',
'agent',
]);
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
tenant_owner: ['*'],
tenant_admin: ['*'],
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
teacher: ['content:*'],
sales: ['codes:*', 'coupons:*', 'referral:*'],
agent: ['codes:read', 'coupons:read', 'referral:self'],
student: [],
};
export interface TenantAdminAuth {
tenantId: string;
userId: string;
role: string;
permissions: Record<string, unknown>;
}
function permissionKeys(permission: string) {
const parts = permission.split(':').filter(Boolean);
const keys = [permission];
for (let i = parts.length - 1; i >= 1; i -= 1) {
keys.push(`${parts.slice(0, i).join(':')}:*`);
}
keys.push('*');
return keys;
}
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
for (const key of permissionKeys(permission)) {
const value = permissions[key];
if (typeof value === 'boolean') return value;
}
return null;
}
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
const explicit = explicitPermission(auth.permissions, permission);
if (explicit !== null) return explicit;
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
return defaults.some(defaultPermission => {
if (defaultPermission === '*') return true;
if (defaultPermission === permission) return true;
if (defaultPermission.endsWith(':*')) {
return permission.startsWith(defaultPermission.slice(0, -1));
}
return false;
});
}
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
if (!hasTenantPermission(auth, permission)) {
throw new HttpError(403, `Tenant permission is required: ${permission}`, 'TENANT_PERMISSION_REQUIRED');
}
}
export function tenantPermissionCatalog() {
return {
permissions: [
{ key: 'tenant:overview:read', label: '租户概览' },
{ key: 'tenant:branding:write', label: '品牌配置' },
{ key: 'tenant:settings:write', label: '公开设置' },
{ key: 'tenant:domains:read', label: '域名查看' },
{ key: 'tenant:domains:write', label: '域名管理' },
{ key: 'tenant:payment:read', label: '商户配置查看' },
{ key: 'tenant:payment:write', label: '商户配置管理' },
{ key: 'tenant:auth:read', label: '登录配置查看' },
{ key: 'tenant:auth:write', label: '登录配置管理' },
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
{ key: 'tenant:secrets:write', label: '密钥轮换' },
{ key: 'marketing:read', label: '活动内容查看' },
{ key: 'marketing:write', label: '活动内容管理' },
{ key: 'codes:read', label: '激活码查看' },
{ key: 'codes:write', label: '激活码管理' },
{ key: 'coupons:read', label: '优惠券查看' },
{ key: 'coupons:write', label: '优惠券管理' },
{ key: 'referral:read', label: '客资全局查看' },
{ key: 'referral:self', label: '本人客资查看' },
{ key: 'referral:write', label: '客资归属管理' },
{ key: 'crm:read', label: 'CRM 队列查看' },
{ key: 'crm:write', label: 'CRM 入队和重试' },
{ key: 'members:read', label: '成员查看' },
{ key: 'members:write', label: '成员管理' },
{ key: 'audit:read', label: '审计日志查看' },
{ key: 'content:*', label: '内容维护' },
],
roleDefaults: ROLE_PERMISSION_DEFAULTS,
};
}
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
else 9
end
limit 1
`,
[tenantId, userId, Array.from(TENANT_ADMIN_ROLES)],
);
if (!membership) {
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
}

View File

@@ -0,0 +1,64 @@
import type { RouteDefinition } from '../../core/router.js';
import {
activationCodesRoute,
announcementsAdminRoute,
auditLogsRoute,
authProvidersRoute,
bannersAdminRoute,
codeBatchesRoute,
couponsRoute,
createTenantDomainRoute,
disableTenantMemberRoute,
faqsAdminRoute,
generateActivationCodesRoute,
paymentAccountsRoute,
tenantDomainsRoute,
tenantMembersRoute,
tenantOverviewRoute,
tenantPermissionsRoute,
tenantSecretsRoute,
upsertTenantMemberRoute,
upsertActivationCodeRoute,
upsertAnnouncementRoute,
upsertAuthProviderRoute,
upsertBannerRoute,
upsertCodeBatchRoute,
upsertCouponRoute,
upsertFaqRoute,
updateTenantBrandingRoute,
updateTenantSettingsRoute,
upsertPaymentAccountRoute,
upsertTenantSecretRoute,
} from './routes.js';
export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
['PUT', '/api/tenant-admin/payment-accounts', upsertPaymentAccountRoute],
['GET', '/api/tenant-admin/auth-providers', authProvidersRoute],
['PUT', '/api/tenant-admin/auth-providers', upsertAuthProviderRoute],
['GET', '/api/tenant-admin/secrets', tenantSecretsRoute],
['PUT', '/api/tenant-admin/secrets', upsertTenantSecretRoute],
['GET', '/api/tenant-admin/banners', bannersAdminRoute],
['PUT', '/api/tenant-admin/banners', upsertBannerRoute],
['GET', '/api/tenant-admin/faqs', faqsAdminRoute],
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
['PUT', '/api/tenant-admin/activation-codes', upsertActivationCodeRoute],
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
['GET', '/api/tenant-admin/coupons', couponsRoute],
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
['GET', '/api/tenant-admin/members', tenantMembersRoute],
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],
['GET', '/api/tenant-admin/audit-logs', auditLogsRoute],
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
import { randomUUID } from 'node:crypto';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
const ASSET_STATUSES = ['draft', 'active', 'archived'];
interface AssetRow {
id: string;
tenantId: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
visibility: string;
status: string;
}
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function nullableUuid(value: unknown) {
return nullableString(value);
}
function safeFileName(fileName: string) {
return fileName
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.slice(0, 160) || 'asset';
}
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
if (!id) return;
const row = await queryOne<{ id: string }>(
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!row) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
`,
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
);
}
export async function assetsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const status = stringParam(ctx, 'status');
const visibility = stringParam(ctx, 'visibility');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
if (visibility) {
params.push(visibility);
filters.push(`visibility = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertAssetRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
const bucket = nullableString(body.bucket);
const objectKey = nullableString(body.objectKey);
const cdnUrl = nullableString(body.cdnUrl);
const title = requiredString(body, 'title');
const regionId = nullableUuid(body.regionId);
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
}
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
const item = await queryOne(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
is_public, region_id, subject_id, category_id, node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
$25, $26::jsonb, $27::jsonb, $28, $28, $29
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
asset_key = excluded.asset_key,
asset_type = excluded.asset_type,
storage_provider = excluded.storage_provider,
bucket = excluded.bucket,
object_key = excluded.object_key,
title = excluded.title,
category = excluded.category,
description = excluded.description,
file_name = excluded.file_name,
cdn_url = excluded.cdn_url,
preview_url = excluded.preview_url,
mime_type = excluded.mime_type,
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
visibility = excluded.visibility,
is_public = excluded.is_public,
region_id = excluded.region_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
source = excluded.source,
updated_at = now()
where public.content_assets.tenant_id = excluded.tenant_id
returning id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.legacyId),
nullableString(body.assetKey),
assetType,
storageProvider,
bucket,
objectKey,
title,
nullableString(body.categoryLabel) || nullableString(body.category),
nullableString(body.description),
nullableString(body.fileName),
cdnUrl,
nullableString(body.previewUrl),
nullableString(body.mimeType),
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
nullableString(body.checksumSha256),
visibility,
visibility === 'public',
regionId,
subjectId,
categoryId,
nodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
nullableString(body.source) || 'manual',
],
);
if (!item) {
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
}
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
title,
assetType,
visibility,
status,
});
return { item };
}
export async function signAssetUploadRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const fileName = requiredString(body, 'fileName');
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
const bucket = nullableString(body.bucket) || 'tenant-assets';
const objectKey =
nullableString(body.objectKey) ||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
return {
upload: {
provider: storageProvider,
bucket,
objectKey,
method: 'PUT',
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
headers: {
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
},
expiresAt,
signatureMode: 'local-placeholder',
},
assetDraft: {
assetType,
storageProvider,
bucket,
objectKey,
fileName,
mimeType: nullableString(body.mimeType),
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
checksumSha256: nullableString(body.checksumSha256),
},
};
}
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
title, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", visibility, status
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, assetId],
);
return {
item: asset,
download: placeholderSignedUrl(asset, expiresInSec),
};
}

View File

@@ -0,0 +1,43 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
export interface TenantContentAuth {
tenantId: string;
userId: string;
role: string;
permissions: Record<string, unknown>;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
limit 1
`,
[tenantId, userId, Array.from(CONTENT_ROLES)],
);
if (!membership) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
}

View File

@@ -0,0 +1,951 @@
import { createHash } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, nullableString } from './utils.js';
type JsonObject = Record<string, unknown>;
interface ImportIssue {
rowNo: number;
severity: 'error' | 'warning';
code: string;
fieldPath: string | null;
message: string;
details?: JsonObject;
}
interface NormalizedQuestion {
legacyId: string | null;
type: string;
typeLabel: string | null;
content: string;
options: unknown[];
correctOptionIndex: number | null;
correctOptionIndices: number[];
answerText: string | null;
explanation: string | null;
difficulty: number;
tags: string[];
mediaUrl: string | null;
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
sourceHash: string;
}
interface PreviewResult {
job: {
id: string;
status: string;
totalCount: number;
validCount: number;
errorCount: number;
warningCount: number;
};
items: Array<{
rowNo: number;
status: 'valid' | 'invalid';
externalId: string | null;
normalized: NormalizedQuestion | null;
issues: ImportIssue[];
}>;
issues: ImportIssue[];
}
const OBJECTIVE_TYPES = new Set(['choice', 'multi', 'judge', 'image']);
const READING_TYPE = 'reading';
const KNOWN_TYPES = new Set([
...OBJECTIVE_TYPES,
READING_TYPE,
'text',
'terms',
'short_answer',
'composition',
'discuss',
'translation',
'case_analysis',
'brief_analysis',
'calculation',
'analysis_design',
'combination',
'solution',
]);
function objectValue(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function stringArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value.map(item => String(item).trim()).filter(Boolean);
}
function numberArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map(item => Number(item))
.filter(Number.isFinite)
.map(item => Math.trunc(item));
}
function parseQuestionItems(body: JsonObject) {
const source = body.items ?? body.questions ?? body.payload;
let parsed: unknown = source;
if (typeof source === 'string') {
try {
parsed = JSON.parse(source);
} catch {
throw new HttpError(400, 'payload must be a valid JSON array string', 'INVALID_IMPORT_PAYLOAD');
}
}
if (!Array.isArray(parsed)) {
throw new HttpError(400, 'items/questions/payload must be a JSON array', 'INVALID_IMPORT_PAYLOAD');
}
if (parsed.length === 0) {
throw new HttpError(400, 'Import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD');
}
if (parsed.length > 2000) {
throw new HttpError(400, 'A single import job can contain at most 2000 items', 'IMPORT_TOO_LARGE');
}
return parsed;
}
function contentHash(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function normalizeDifficulty(value: unknown, issues: ImportIssue[], rowNo: number) {
if (value === undefined || value === null || value === '') return 1;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
issues.push({
rowNo,
severity: 'error',
code: 'INVALID_DIFFICULTY',
fieldPath: 'difficulty',
message: 'difficulty must be a number from 1 to 5',
});
return 1;
}
const difficulty = Math.trunc(parsed);
if (difficulty < 1 || difficulty > 5) {
issues.push({
rowNo,
severity: 'warning',
code: 'DIFFICULTY_OUT_OF_RANGE',
fieldPath: 'difficulty',
message: 'difficulty is outside the recommended 1-5 range and was clamped',
details: { original: value },
});
}
return Math.min(5, Math.max(1, difficulty));
}
function normalizeOptions(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath = 'options') {
if (!Array.isArray(value)) return [];
const normalized = value.filter(item => {
if (typeof item === 'string') return item.trim() !== '';
return item && typeof item === 'object';
});
if (normalized.length !== value.length) {
issues.push({
rowNo,
severity: 'warning',
code: 'EMPTY_OPTIONS_REMOVED',
fieldPath,
message: 'empty options were removed during normalization',
});
}
return normalized;
}
function normalizeSubQuestions(value: unknown, issues: ImportIssue[], rowNo: number) {
if (!Array.isArray(value)) return [];
return value.map((raw, index) => {
const sub = objectValue(raw);
const fieldPrefix = `sub_questions[${index}]`;
const type = stringValue(sub.type) || 'choice';
const content = stringValue(sub.content);
const options = normalizeOptions(sub.options, issues, rowNo, `${fieldPrefix}.options`);
const correctOptionIndices = numberArrayValue(sub.correctOptionIndices ?? sub.correct_option_indices);
const answerText = stringValue(sub.answerText ?? sub.answer_text);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_CONTENT_REQUIRED',
fieldPath: `${fieldPrefix}.content`,
message: 'sub question content is required',
});
}
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo, fieldPrefix);
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_ANSWER_REQUIRED',
fieldPath: `${fieldPrefix}.answerText`,
message: 'subjective sub question requires answerText',
});
}
return {
type,
typeLabel: stringValue(sub.typeLabel ?? sub.type_label) || null,
content,
options,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(sub.explanation) || null,
};
});
}
function validateObjectiveAnswer(
options: unknown[],
correctOptionIndices: number[],
issues: ImportIssue[],
rowNo: number,
fieldPrefix = '',
) {
const prefix = fieldPrefix ? `${fieldPrefix}.` : '';
if (options.length < 2) {
issues.push({
rowNo,
severity: 'error',
code: 'OPTIONS_REQUIRED',
fieldPath: `${prefix}options`,
message: 'objective question requires at least two options',
});
}
if (correctOptionIndices.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_REQUIRED',
fieldPath: `${prefix}correctOptionIndices`,
message: 'objective question requires correctOptionIndices',
});
}
for (const index of correctOptionIndices) {
if (index < 0 || index >= options.length) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_OUT_OF_RANGE',
fieldPath: `${prefix}correctOptionIndices`,
message: 'correct option index is outside the options range',
details: { index, optionsCount: options.length },
});
}
}
}
function normalizeQuestion(raw: unknown, rowNo: number) {
const issues: ImportIssue[] = [];
const source = objectValue(raw);
if (!source || Object.keys(source).length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'ROW_OBJECT_REQUIRED',
fieldPath: null,
message: 'each import row must be an object',
});
return { normalized: null, issues };
}
const subQuestions = normalizeSubQuestions(source.sub_questions ?? source.subQuestions, issues, rowNo);
let type = stringValue(source.type) || (subQuestions.length ? READING_TYPE : 'choice');
if (subQuestions.length) type = READING_TYPE;
if (!KNOWN_TYPES.has(type)) {
issues.push({
rowNo,
severity: 'warning',
code: 'UNKNOWN_QUESTION_TYPE',
fieldPath: 'type',
message: 'unknown question type was kept for compatibility',
details: { type },
});
}
const content = stringValue(source.content);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'CONTENT_REQUIRED',
fieldPath: 'content',
message: 'question content is required',
});
}
const options = normalizeOptions(source.options, issues, rowNo);
const legacyCorrectIndex = source.correctOptionIndex ?? source.correct_option_index;
const correctOptionIndices = numberArrayValue(source.correctOptionIndices ?? source.correct_option_indices);
if (correctOptionIndices.length === 0 && legacyCorrectIndex !== undefined && legacyCorrectIndex !== null && legacyCorrectIndex !== '') {
const parsed = Number(legacyCorrectIndex);
if (Number.isFinite(parsed)) correctOptionIndices.push(Math.trunc(parsed));
}
const answerText = stringValue(source.answerText ?? source.answer_text);
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo);
} else if (type === READING_TYPE) {
if (subQuestions.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTIONS_REQUIRED',
fieldPath: 'sub_questions',
message: 'reading question requires sub_questions',
});
}
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'ANSWER_TEXT_REQUIRED',
fieldPath: 'answerText',
message: 'subjective question requires answerText',
});
}
const normalizedWithoutHash = {
legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null,
type,
typeLabel: stringValue(source.typeLabel ?? source.type_label) || null,
content,
options,
correctOptionIndex: correctOptionIndices.length === 1 ? correctOptionIndices[0] : null,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(source.explanation) || null,
difficulty: normalizeDifficulty(source.difficulty, issues, rowNo),
tags: stringArrayValue(source.tags),
mediaUrl: stringValue(source.mediaUrl ?? source.media_url) || null,
subQuestions,
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
};
const normalized: NormalizedQuestion = {
...normalizedWithoutHash,
sourceHash: contentHash(normalizedWithoutHash),
};
return { normalized, issues };
}
async function assertTargetReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
const subjectId = requiredString(body, 'subjectId');
const categoryId = requiredString(body, 'categoryId');
const nodeId = nullableString(body.nodeId);
const questionBankId = nullableString(body.questionBankId);
const regionId = nullableString(body.regionId);
const subject = await client.query(
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, subjectId],
);
if (!subject.rows[0]) {
throw new HttpError(400, 'subjectId is not in this tenant', 'SUBJECT_NOT_FOUND');
}
const category = await client.query(
'select id from public.categories where tenant_id = $1 and id = $2 and subject_id = $3 limit 1',
[auth.tenantId, categoryId, subjectId],
);
if (!category.rows[0]) {
throw new HttpError(400, 'categoryId is not in this tenant or subject', 'CATEGORY_NOT_FOUND');
}
if (nodeId) {
const node = await client.query(
'select id from public.module_nodes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, nodeId],
);
if (!node.rows[0]) {
throw new HttpError(400, 'nodeId is not in this tenant', 'NODE_NOT_FOUND');
}
}
if (questionBankId) {
const questionBank = await client.query(
'select id from public.question_banks where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, questionBankId],
);
if (!questionBank.rows[0]) {
throw new HttpError(400, 'questionBankId is not in this tenant', 'QUESTION_BANK_NOT_FOUND');
}
}
if (regionId) {
const region = await client.query(
'select id from public.regions where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, regionId],
);
if (!region.rows[0]) {
throw new HttpError(400, 'regionId is not in this tenant', 'REGION_NOT_FOUND');
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId };
}
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
const rawItems = parseQuestionItems(body);
const sourceFormat = stringValue(body.sourceFormat) || 'json';
const sourceName = stringValue(body.sourceName) || null;
if (sourceFormat !== 'json') {
throw new HttpError(400, 'Only json sourceFormat is supported by the synchronous API for now', 'IMPORT_FORMAT_NOT_SUPPORTED');
}
return transaction(async client => {
const target = await assertTargetReferences(client, auth, body);
const normalizedItems = rawItems.map((raw, index) => {
const rowNo = index + 1;
const { normalized, issues } = normalizeQuestion(raw, rowNo);
return {
rowNo,
status: issues.some(issue => issue.severity === 'error') ? 'invalid' as const : 'valid' as const,
externalId: normalized?.legacyId || null,
source: raw,
normalized,
issues,
};
});
const issues = normalizedItems.flatMap(item => item.issues);
const errorCount = issues.filter(issue => issue.severity === 'error').length;
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
const validCount = normalizedItems.filter(item => item.status === 'valid').length;
const rawPayload = JSON.stringify(rawItems);
const normalizedPayload = JSON.stringify(normalizedItems.map(item => item.normalized).filter(Boolean));
const jobResult = await client.query(
`
insert into public.content_import_jobs (
tenant_id, created_by, import_type, source_format, status,
source_name, source_hash, target_region_id, target_subject_id,
target_category_id, target_node_id, target_question_bank_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload
)
values (
$1, $2, 'questions', $3, 'preview',
$4, $5, $6::uuid, $7::uuid,
$8::uuid, $9::uuid, $10::uuid,
true, $11, $12, $13, $14,
$15::jsonb, $16::jsonb, $17::jsonb
)
returning id, status, total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount"
`,
[
auth.tenantId,
auth.userId,
sourceFormat,
sourceName,
contentHash(rawItems),
target.regionId,
target.subjectId,
target.categoryId,
target.nodeId,
target.questionBankId,
rawItems.length,
validCount,
errorCount,
warningCount,
JSON.stringify({ target, generatedAt: new Date().toISOString() }),
rawPayload,
normalizedPayload,
],
);
const job = jobResult.rows[0];
const responseItems: PreviewResult['items'] = [];
for (const item of normalizedItems) {
const itemResult = await client.query(
`
insert into public.content_import_items (
tenant_id, job_id, row_no, external_id, status, target_type,
source_payload, normalized_payload, content_hash, issues_count
)
values ($1, $2, $3, $4, $5, 'question', $6::jsonb, $7::jsonb, $8, $9)
returning id
`,
[
auth.tenantId,
job.id,
item.rowNo,
item.externalId,
item.status,
JSON.stringify(item.source),
JSON.stringify(item.normalized || {}),
item.normalized?.sourceHash || contentHash(item.source),
item.issues.length,
],
);
const itemId = itemResult.rows[0].id;
for (const issue of item.issues) {
await client.query(
`
insert into public.content_import_issues (
tenant_id, job_id, item_id, row_no, severity, code,
field_path, message, details
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`,
[
auth.tenantId,
job.id,
itemId,
issue.rowNo,
issue.severity,
issue.code,
issue.fieldPath,
issue.message,
JSON.stringify(issue.details || {}),
],
);
}
responseItems.push({
rowNo: item.rowNo,
status: item.status,
externalId: item.externalId,
normalized: item.normalized,
issues: item.issues,
});
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.previewed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ importType: 'questions', total: rawItems.length, errorCount, warningCount })],
);
return {
job,
items: responseItems,
issues,
};
});
}
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
const result = await client.query<{
id: string;
status: string;
total_count: number;
valid_count: number;
error_count: number;
warning_count: number;
target_region_id: string | null;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
}>(
`
select id, status, total_count, valid_count, error_count, warning_count,
target_region_id, target_subject_id, target_category_id,
target_node_id, target_question_bank_id
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
for update
`,
[auth.tenantId, jobId],
);
const job = result.rows[0];
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
if (['importing', 'failed'].includes(job.status)) {
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
}
return job;
}
async function currentVersionHash(client: pg.PoolClient, questionId: string) {
const result = await client.query<{ source_hash: string | null }>(
`
select v.source_hash
from public.questions q
join public.question_versions v on v.id = q.current_version_id
where q.id = $1
limit 1
`,
[questionId],
);
return result.rows[0]?.source_hash || null;
}
async function importOneQuestion(
client: pg.PoolClient,
auth: TenantContentAuth,
job: {
id: string;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
},
item: {
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
},
) {
const normalized = item.normalized_payload;
const legacyId = normalized.legacyId || `content-import:${job.id}:${item.row_no}`;
const existing = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1 for update',
[auth.tenantId, legacyId],
);
const existingQuestion = existing.rows[0];
let questionId = existingQuestion?.id || '';
if (!existingQuestion) {
const inserted = await client.query<{ id: string }>(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
returning id
`,
[
auth.tenantId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
legacyId,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
questionId = inserted.rows[0].id;
} else {
await client.query(
`
update public.questions
set question_bank_id = $3::uuid,
subject_id = $4::uuid,
category_id = $5::uuid,
node_id = $6::uuid,
type = $7,
type_label = $8,
difficulty = $9,
tags = $10::jsonb,
media_url = $11,
status = 'published',
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
questionId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
}
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
if (previousHash && previousHash === normalized.sourceHash) {
await client.query(
`
update public.content_import_items
set status = 'skipped', target_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, questionId],
);
return 'skipped' as const;
}
const latest = await client.query<{ version_no: number }>(
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
[questionId],
);
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const version = await client.query<{ id: string }>(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
returning id
`,
[
auth.tenantId,
questionId,
nextVersionNo,
normalized.content,
JSON.stringify(normalized.options),
normalized.correctOptionIndex,
JSON.stringify(normalized.correctOptionIndices),
normalized.answerText,
normalized.explanation,
JSON.stringify(normalized.subQuestions),
normalized.codeLang,
normalized.codeTemplate,
normalized.sourceHash,
auth.userId,
],
);
await client.query(
'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, questionId, version.rows[0].id],
);
const status = existingQuestion ? 'updated' : 'inserted';
await client.query(
`
update public.content_import_items
set status = $3, target_id = $4, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, status, questionId],
);
return status as 'inserted' | 'updated';
}
export async function previewQuestionsImportRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
return createQuestionPreviewJob(auth, body);
}
export async function importQuestionsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const allowPartial = boolValue(body.allowPartial, false);
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
const createdPreview = jobId ? null : await createQuestionPreviewJob(auth, body);
const finalJobId = jobId || createdPreview?.job.id || '';
const result = await transaction(async client => {
const job = await loadPreviewJob(client, auth, finalJobId);
if (job.status === 'completed' || job.status === 'completed_with_errors') {
return {
jobId: job.id,
status: job.status,
idempotent: true,
insertedCount: 0,
updatedCount: 0,
skippedCount: 0,
};
}
if (job.error_count > 0 && !allowPartial) {
await client.query(
`
update public.content_import_jobs
set status = 'rejected', error_message = 'Preview contains validation errors', updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
}
await client.query(
`
update public.content_import_jobs
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
const itemResult = await client.query<{
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
}>(
`
select id, row_no, normalized_payload
from public.content_import_items
where tenant_id = $1 and job_id = $2 and status = 'valid'
order by row_no asc
for update
`,
[auth.tenantId, job.id],
);
let insertedCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const item of itemResult.rows) {
const status = await importOneQuestion(client, auth, job, item);
if (status === 'inserted') insertedCount += 1;
if (status === 'updated') updatedCount += 1;
if (status === 'skipped') skippedCount += 1;
}
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
await client.query(
`
update public.content_import_jobs
set status = $3,
inserted_count = $4,
updated_count = $5,
skipped_count = $6,
summary = coalesce(summary, '{}'::jsonb) || $7::jsonb,
finished_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
job.id,
finalStatus,
insertedCount,
updatedCount,
skippedCount,
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
],
);
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.questions.completed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ insertedCount, updatedCount, skippedCount, allowPartial })],
);
return {
jobId: job.id,
status: finalStatus,
insertedCount,
updatedCount,
skippedCount,
errorCount: job.error_count,
warningCount: job.warning_count,
};
});
return { item: result, preview: createdPreview };
}
export async function importJobsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const importType = stringParam(ctx, 'importType');
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (importType) {
params.push(importType);
filters.push(`import_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, import_type as "importType", source_format as "sourceFormat",
status, source_name as "sourceName", source_hash as "sourceHash",
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
target_question_bank_id as "targetQuestionBankId",
dry_run as "dryRun", total_count as "totalCount",
valid_count as "validCount", error_count as "errorCount",
warning_count as "warningCount", inserted_count as "insertedCount",
updated_count as "updatedCount", skipped_count as "skippedCount",
summary, error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
from public.content_import_jobs
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function importIssuesRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
const limit = intParam(ctx, 'limit', 500, 2000);
const job = await queryOne<{ id: string }>(
'select id from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, jobId],
);
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
const items = await query(
`
select i.id, i.row_no as "rowNo", i.severity, i.code,
i.field_path as "fieldPath", i.message, i.details,
item.external_id as "externalId", item.status as "itemStatus",
i.created_at as "createdAt"
from public.content_import_issues i
left join public.content_import_items item on item.id = i.item_id
where i.tenant_id = $1 and i.job_id = $2
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
limit $3
`,
[auth.tenantId, jobId, limit],
);
return { items };
}

View File

@@ -0,0 +1,72 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetsAdminRoute,
signAssetDownloadAdminRoute,
signAssetUploadRoute,
upsertAssetRoute,
} from './assets.js';
import {
importIssuesRoute,
importJobsRoute,
importQuestionsRoute,
previewQuestionsImportRoute,
} from './imports.js';
import {
bindQuestionVideoRoute,
createQuestionRoute,
handbookChaptersAdminRoute,
handbookEntriesAdminRoute,
handbookSubjectsAdminRoute,
scorelineFieldsAdminRoute,
scorelineMajorsAdminRoute,
scorelineRecordsAdminRoute,
scorelineSchoolsAdminRoute,
updateQuestionRoute,
upsertHandbookChapterRoute,
upsertHandbookEntryRoute,
upsertHandbookSubjectRoute,
upsertScorelineFieldRoute,
upsertScorelineMajorRoute,
upsertScorelineRecordRoute,
upsertScorelineSchoolRoute,
upsertVideoRoute,
upsertVocabularyUnitRoute,
upsertVocabularyWordRoute,
videosAdminRoute,
vocabularyUnitsAdminRoute,
vocabularyWordsAdminRoute,
} from './routes.js';
export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute],
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
['GET', '/api/tenant-content/scoreline/schools', scorelineSchoolsAdminRoute],
['PUT', '/api/tenant-content/scoreline/schools', upsertScorelineSchoolRoute],
['GET', '/api/tenant-content/scoreline/majors', scorelineMajorsAdminRoute],
['PUT', '/api/tenant-content/scoreline/majors', upsertScorelineMajorRoute],
['GET', '/api/tenant-content/scoreline/fields', scorelineFieldsAdminRoute],
['PUT', '/api/tenant-content/scoreline/fields', upsertScorelineFieldRoute],
['GET', '/api/tenant-content/scoreline/records', scorelineRecordsAdminRoute],
['PUT', '/api/tenant-content/scoreline/records', upsertScorelineRecordRoute],
['GET', '/api/tenant-content/vocabulary-units', vocabularyUnitsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-units', upsertVocabularyUnitRoute],
['GET', '/api/tenant-content/vocabulary-words', vocabularyWordsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-words', upsertVocabularyWordRoute],
['GET', '/api/tenant-content/handbook-subjects', handbookSubjectsAdminRoute],
['PUT', '/api/tenant-content/handbook-subjects', upsertHandbookSubjectRoute],
['GET', '/api/tenant-content/handbook-chapters', handbookChaptersAdminRoute],
['PUT', '/api/tenant-content/handbook-chapters', upsertHandbookChapterRoute],
['GET', '/api/tenant-content/handbook-entries', handbookEntriesAdminRoute],
['PUT', '/api/tenant-content/handbook-entries', upsertHandbookEntryRoute],
];

View File

@@ -0,0 +1,820 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor } from './auth.js';
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js';
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
export async function createQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const questionResult = await client.query(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
nullableString(body.legacyId),
optionalString(body, 'type') || 'choice',
optionalString(body, 'typeLabel') || null,
intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
],
);
const question = questionResult.rows[0];
const versionResult = await client.query(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13)
returning id, version_no as "versionNo", content, options,
correct_option_index as "correctOptionIndex",
correct_option_indices as "correctOptionIndices",
answer_text as "answerText", explanation, sub_questions as "subQuestions",
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
`,
[
auth.tenantId,
question.id,
nullableString(body.content),
jsonArrayValue(body.options),
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
jsonArrayValue(body.correctOptionIndices),
nullableString(body.answerText),
nullableString(body.explanation),
jsonArrayValue(body.subQuestions),
nullableString(body.codeLang),
nullableString(body.codeTemplate),
nullableString(body.sourceHash),
auth.userId,
],
);
await client.query(
`
update public.questions
set current_version_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, question.id, versionResult.rows[0].id],
);
return { ...question, currentVersion: versionResult.rows[0] };
});
return { item };
}
export async function updateQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const questionId = requiredString(body, 'questionId');
const createVersion = body.createVersion === true;
const item = await transaction(async client => {
const questionResult = await client.query(
`
update public.questions
set question_bank_id = coalesce($3::uuid, question_bank_id),
subject_id = coalesce($4::uuid, subject_id),
category_id = coalesce($5::uuid, category_id),
node_id = coalesce($6::uuid, node_id),
type = coalesce(nullif($7, ''), type),
type_label = coalesce(nullif($8, ''), type_label),
difficulty = coalesce($9, difficulty),
tags = case when $10::boolean then $11::jsonb else tags end,
media_url = coalesce(nullif($12, ''), media_url),
status = coalesce(nullif($13, ''), status),
updated_at = now()
where tenant_id = $1 and id = $2
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
`,
[
auth.tenantId,
questionId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
optionalString(body, 'type'),
optionalString(body, 'typeLabel'),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
Object.hasOwn(body, 'tags'),
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
],
);
const question = questionResult.rows[0];
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
if (!createVersion) return question;
const latest = await client.query<{ version_no: number }>(
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
[questionId],
);
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const versionResult = await client.query(
`
insert into public.question_versions (
tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text,
explanation, sub_questions, code_lang, code_template, source_hash, created_by
)
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
returning id, version_no as "versionNo", content, options,
correct_option_index as "correctOptionIndex",
correct_option_indices as "correctOptionIndices",
answer_text as "answerText", explanation, sub_questions as "subQuestions",
code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
`,
[
auth.tenantId,
questionId,
nextVersionNo,
nullableString(body.content),
jsonArrayValue(body.options),
body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
jsonArrayValue(body.correctOptionIndices),
nullableString(body.answerText),
nullableString(body.explanation),
jsonArrayValue(body.subQuestions),
nullableString(body.codeLang),
nullableString(body.codeTemplate),
nullableString(body.sourceHash),
auth.userId,
],
);
await client.query(
`
update public.questions
set current_version_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, questionId, versionResult.rows[0].id],
);
return { ...question, currentVersion: versionResult.rows[0] };
});
return { item };
}
export async function videosAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select id, legacy_id as "legacyId", title, description, video_url as "videoUrl",
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
subject_id as "subjectId", difficulty, sort_order as "order",
is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.video_explanations
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
order by sort_order asc, created_at desc
limit $3
`,
[auth.tenantId, subjectId || null, limit],
);
return { items };
}
export async function upsertVideoRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const id = nullableString(body.id);
const title = requiredString(body, 'title');
const item = await queryOne(
`
insert into public.video_explanations (
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
sort_order, is_active
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7,
$8, $9::jsonb, $10, $11::uuid, $12, $13, $14
)
on conflict (id)
do update set title = excluded.title,
description = excluded.description,
video_url = excluded.video_url,
thumbnail_url = excluded.thumbnail_url,
duration_seconds = excluded.duration_seconds,
knowledge_tags = excluded.knowledge_tags,
is_general = excluded.is_general,
subject_id = excluded.subject_id,
difficulty = excluded.difficulty,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, title, description, video_url as "videoUrl",
thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
knowledge_tags as "knowledgeTags", is_general as "isGeneral",
subject_id as "subjectId", difficulty, sort_order as "order",
is_active as "isActive", updated_at as "updatedAt"
`,
[
id,
auth.tenantId,
nullableString(body.legacyId),
title,
nullableString(body.description),
nullableString(body.videoUrl),
nullableString(body.thumbnailUrl),
body.duration === undefined ? null : intValue(body.duration, 0),
jsonArrayValue(body.knowledgeTags),
boolValue(body.isGeneral, false),
nullableString(body.subjectId),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function bindQuestionVideoRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const questionId = requiredString(body, 'questionId');
const videoId = requiredString(body, 'videoId');
const item = await transaction(async client => {
const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]);
if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]);
if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');
const result = await client.query(
`
insert into public.question_videos (
tenant_id, question_id, video_id, legacy_id, video_type, sort_order
)
values ($1, $2, $3, $4, $5, $6)
returning id, question_id as "questionId", video_id as "videoId",
video_type as "videoType", sort_order as "order", created_at as "createdAt"
`,
[
auth.tenantId,
questionId,
videoId,
nullableString(body.legacyId),
optionalString(body, 'videoType') || 'specific',
intValue(body.order, 0),
],
);
await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [
auth.tenantId,
questionId,
]);
return result.rows[0];
});
return { item };
}
export async function scorelineSchoolsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, region_id as "regionId", name, short_name as "shortName", type,
is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_schools
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, name asc
`,
[auth.tenantId, regionId || null],
);
return { items };
}
export async function upsertScorelineSchoolRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
short_name = excluded.short_name,
type = excluded.type,
is_hot = excluded.is_hot,
sort_order = excluded.sort_order,
updated_at = now()
returning id, region_id as "regionId", name, short_name as "shortName",
type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.shortName),
nullableString(body.type),
boolValue(body.isHot, false),
intValue(body.order, 0),
],
);
return { item };
}
export async function scorelineMajorsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const schoolId = stringParam(ctx, 'schoolId');
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId", name,
sort_order as "order", has_restriction as "hasRestriction",
restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_majors
where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid)
order by sort_order asc, name asc
`,
[auth.tenantId, schoolId || null],
);
return { items };
}
export async function upsertScorelineMajorRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_majors (
id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
school_id = excluded.school_id,
name = excluded.name,
sort_order = excluded.sort_order,
has_restriction = excluded.has_restriction,
restriction_desc = excluded.restriction_desc,
updated_at = now()
returning id, region_id as "regionId", school_id as "schoolId", name,
sort_order as "order", has_restriction as "hasRestriction",
restriction_desc as "restrictionDesc", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
requiredString(body, 'schoolId'),
nullableString(body.legacyId),
requiredString(body, 'name'),
intValue(body.order, 0),
boolValue(body.hasRestriction, false),
nullableString(body.restrictionDesc),
],
);
return { item };
}
export async function scorelineFieldsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, region_id as "regionId", field_key as "fieldKey",
field_name as "fieldName", field_type as "fieldType", unit,
is_filter as "isFilter", is_required as "isRequired",
is_visible as "isVisible", is_trend as "isTrend",
options, placeholder, description, sort_order as "sortOrder"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc
`,
[auth.tenantId, regionId || null],
);
return { items };
}
export async function upsertScorelineFieldRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_fields (
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
unit, is_filter, is_required, is_visible, is_trend, options,
placeholder, description, sort_order
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16
)
on conflict (tenant_id, region_id, field_key)
do update set field_name = excluded.field_name,
field_type = excluded.field_type,
unit = excluded.unit,
is_filter = excluded.is_filter,
is_required = excluded.is_required,
is_visible = excluded.is_visible,
is_trend = excluded.is_trend,
options = excluded.options,
placeholder = excluded.placeholder,
description = excluded.description,
sort_order = excluded.sort_order,
updated_at = now()
returning id, region_id as "regionId", field_key as "fieldKey",
field_name as "fieldName", field_type as "fieldType",
unit, is_filter as "isFilter", is_required as "isRequired",
is_visible as "isVisible", is_trend as "isTrend",
options, placeholder, description, sort_order as "sortOrder",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'fieldKey'),
requiredString(body, 'fieldName'),
optionalString(body, 'fieldType') || 'number',
nullableString(body.unit),
boolValue(body.isFilter, false),
boolValue(body.isRequired, false),
boolValue(body.isVisible, true),
boolValue(body.isTrend, false),
jsonArrayValue(body.options),
nullableString(body.placeholder),
nullableString(body.description),
intValue(body.sortOrder, 0),
],
);
return { item };
}
export async function scorelineRecordsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
`
select id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues",
created_at as "createdAt", updated_at as "updatedAt"
from public.scoreline_records
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by year desc, school_name asc, major_name asc
limit $3
`,
[auth.tenantId, regionId || null, limit],
);
return { items };
}
export async function upsertScorelineRecordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.scoreline_records (
id, tenant_id, region_id, school_id, major_id, legacy_id,
year, school_name, major_name, field_values
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
school_id = excluded.school_id,
major_id = excluded.major_id,
year = excluded.year,
school_name = excluded.school_name,
major_name = excluded.major_name,
field_values = excluded.field_values,
updated_at = now()
returning id, region_id as "regionId", school_id as "schoolId",
major_id as "majorId", year, school_name as "schoolName",
major_name as "majorName", field_values as "fieldValues",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.schoolId),
nullableString(body.majorId),
nullableString(body.legacyId),
intValue(body.year, new Date().getFullYear()),
nullableString(body.schoolName),
nullableString(body.majorName),
jsonObjectValue(body.fieldValues),
],
);
return { item };
}
export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units
where tenant_id = $1
order by sort_order asc, created_at asc
`,
[auth.tenantId],
);
return { items };
}
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_units (
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const unitId = stringParam(ctx, 'unitId');
const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query(
`
select id, unit_id as "unitId", word, phonetic, meaning, example,
example_translation as "exampleTranslation", difficulty, tags,
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words
where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid)
order by sort_order asc, word asc
limit $3
`,
[auth.tenantId, unitId || null, limit],
);
return { items };
}
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.vocabulary_words (
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
example, example_translation, difficulty, tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
on conflict (id)
do update set unit_id = excluded.unit_id,
word = excluded.word,
phonetic = excluded.phonetic,
meaning = excluded.meaning,
example = excluded.example,
example_translation = excluded.example_translation,
difficulty = excluded.difficulty,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, unit_id as "unitId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.unitId),
nullableString(body.legacyId),
requiredString(body, 'word'),
nullableString(body.phonetic),
nullableString(body.meaning),
nullableString(body.example),
nullableString(body.exampleTranslation),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const items = await query(
`
select id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive", metadata
from public.handbook_subjects
where tenant_id = $1
order by sort_order asc, created_at asc
`,
[auth.tenantId],
);
return { items };
}
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_subjects (
id, tenant_id, region_id, legacy_id, name, type, icon, color,
description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.type),
nullableString(body.icon),
nullableString(body.color),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return { item };
}
export async function handbookChaptersAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const items = await query(
`
select id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive"
from public.handbook_chapters
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
order by sort_order asc, created_at asc
`,
[auth.tenantId, subjectId || null],
);
return { items };
}
export async function upsertHandbookChapterRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_chapters (
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
on conflict (id)
do update set subject_id = excluded.subject_id,
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
requiredString(body, 'subjectId'),
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}
export async function handbookEntriesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const chapterId = stringParam(ctx, 'chapterId');
const items = await query(
`
select id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive"
from public.handbook_entries
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
order by sort_order asc, created_at asc
`,
[auth.tenantId, chapterId || null],
);
return { items };
}
export async function upsertHandbookEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await queryOne(
`
insert into public.handbook_entries (
id, tenant_id, chapter_id, legacy_id, title, summary, content,
tags, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
on conflict (id)
do update set chapter_id = excluded.chapter_id,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
requiredString(body, 'chapterId'),
nullableString(body.legacyId),
requiredString(body, 'title'),
nullableString(body.summary),
nullableString(body.content),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
return { item };
}

View File

@@ -0,0 +1,32 @@
import { HttpError } from '../../core/http.js';
export type JsonObject = Record<string, unknown>;
export function jsonObjectValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
export function jsonArrayValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
export function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
export function intValue(value: unknown, fallback: number) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
export function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function optionalStatus(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid status: ${candidate}`, 'INVALID_STATUS');
}
return candidate;
}

View File

@@ -0,0 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import { resolveTenantRoute } from './routes.js';
export const tenantRoutes: RouteDefinition[] = [
['GET', '/api/tenant/resolve', resolveTenantRoute],
];

View File

@@ -0,0 +1,108 @@
import { config } from '../../core/config.js';
import { queryOne } from '../../core/db.js';
import { getHeader, type RequestContext } from '../../core/http.js';
interface TenantResolveRow {
id: string;
slug: string;
name: string;
status: string;
mode: string;
host: string | null;
brand_name: string | null;
short_name: string | null;
slogan: string | null;
logo_url: string | null;
favicon_url: string | null;
service_wechat: string | null;
service_account_name: string | null;
theme: Record<string, unknown>;
feature_flags: Record<string, unknown>;
admin_feature_flags: Record<string, unknown>;
public_config: Record<string, unknown>;
}
function normalizeHost(host: string) {
return host.split(':')[0]?.trim().toLowerCase() || '';
}
export async function resolveTenantRoute(ctx: RequestContext) {
const hostParam = ctx.url.searchParams.get('host') || '';
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
const row = tenantCode
? await queryOne<TenantResolveRow>(
`
select t.id, t.slug, t.name, t.status, t.mode,
null::text as host,
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
coalesce(s.public_config, '{}'::jsonb) as public_config
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_settings s on s.tenant_id = t.id
where t.slug = $1 and t.status = 'active'
limit 1
`,
[tenantCode],
)
: await queryOne<TenantResolveRow>(
`
select t.id, t.slug, t.name, t.status, t.mode,
d.host::text,
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
coalesce(s.public_config, '{}'::jsonb) as public_config
from public.tenant_domains d
join public.tenants t on t.id = d.tenant_id
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_settings s on s.tenant_id = t.id
where d.host = $1 and d.status = 'active' and t.status = 'active'
limit 1
`,
[requestHost || 'localhost'],
);
if (!row && requestHost !== 'localhost') {
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
return resolveTenantRoute(ctx);
}
if (!row) {
return {
found: false,
message: 'Tenant not found',
lookup: { host: requestHost, tenantCode: tenantCode || null },
};
}
return {
found: true,
tenant: {
id: row.id,
slug: row.slug,
name: row.name,
status: row.status,
mode: row.mode,
host: row.host,
},
branding: {
brandName: row.brand_name || row.name,
shortName: row.short_name || row.name,
slogan: row.slogan || '',
logoUrl: row.logo_url || '',
faviconUrl: row.favicon_url || '',
serviceWechat: row.service_wechat || '',
serviceAccountName: row.service_account_name || '',
theme: row.theme || {},
},
features: row.feature_flags || {},
adminFeatures: row.admin_feature_flags || {},
publicConfig: row.public_config || {},
};
}

View File

@@ -0,0 +1,8 @@
import type { RouteDefinition } from '../../core/router.js';
import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js';
export const videoRoutes: RouteDefinition[] = [
['GET', '/api/questions/videos', questionVideosRoute],
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
['GET', '/api/videos/search', videoSearchRoute],
];

View File

@@ -0,0 +1,111 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
import { query } from '../../core/db.js';
interface QuestionVideoRow {
questionId: string;
videoType: string;
order: number;
id: string;
legacyId: string | null;
title: string;
description: string | null;
videoUrl: string | null;
thumbnailUrl: string | null;
duration: number | null;
knowledgeTags: unknown[];
isGeneral: boolean;
subjectId: string | null;
difficulty: number | null;
}
function videoSelectSql() {
return `
select qv.question_id as "questionId", qv.video_type as "videoType",
qv.sort_order as "order",
v.id, v.legacy_id as "legacyId", v.title, v.description,
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
v.is_general as "isGeneral", v.subject_id as "subjectId",
v.difficulty
from public.question_videos qv
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
`;
}
export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId');
if (!questionId) {
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
}
const videos = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
`,
[tenantId, questionId],
);
return { videos, total: videos.length };
}
export async function questionVideosBatchRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
if (!questionIds.length) {
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
}
const rows = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
order by qv.question_id, qv.sort_order asc, v.sort_order asc
`,
[tenantId, questionIds],
);
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
for (const row of rows) {
data[row.questionId] ||= { hasVideo: true, videos: [] };
data[row.questionId].videos.push(row);
}
return { data };
}
export async function videoSearchRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const tags = stringParam(ctx, 'tags')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
const limit = intParam(ctx, 'limit', 50, 200);
const videos = await query(
`
select id, legacy_id as "legacyId", title, description,
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
is_general as "isGeneral", subject_id as "subjectId",
difficulty, sort_order as "order", created_at as "createdAt"
from public.video_explanations
where tenant_id = $1
and is_active = true
and is_general = true
and ($2::uuid is null or subject_id = $2::uuid)
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
order by sort_order asc, created_at desc
limit $4
`,
[tenantId, subjectId || null, tags, limit],
);
return { videos, total: videos.length };
}

48
apps/api/src/server.ts Normal file
View File

@@ -0,0 +1,48 @@
import http from 'node:http';
import { config } from './core/config.js';
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
import { createRouter } from './core/router.js';
const routes = createRouter();
function resolveHandler(method: string | undefined, url: URL) {
const exact = routes.get(routeKey(method, url.pathname));
if (exact) return exact;
const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/);
if (method === 'GET' && questionVideosMatch?.[1]) {
url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1]));
return routes.get(routeKey(method, '/api/questions/videos'));
}
return null;
}
const server = http.createServer(async (req, res) => {
applyCors(req, res);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const handler = resolveHandler(req.method, url);
if (!handler) {
sendJson(res, 404, { error: 'Not found', path: url.pathname });
return;
}
try {
const result = await handler({ req, res, url });
sendJson(res, 200, result);
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
sendJson(res, statusCode, body);
}
});
server.listen(config.port, () => {
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
});

14
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "../..",
"types": ["node"]
},
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
}

25
docker-compose.api.yml Normal file
View File

@@ -0,0 +1,25 @@
name: tiku-saas-dev
services:
api:
build:
context: .
dockerfile: apps/api/Dockerfile
environment:
NODE_ENV: production
PORT: 8787
DATABASE_URL: postgresql://postgres:postgres@host.docker.internal:54322/postgres
DEFAULT_TENANT_SLUG: master
CORS_ORIGIN: http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174
extra_hosts:
- host.docker.internal:host-gateway
ports:
- "8787:8787"
healthcheck:
test:
- CMD-SHELL
- wget -qO- http://127.0.0.1:8787/health >/dev/null 2>&1 || exit 1
interval: 10s
timeout: 3s
retries: 6
start_period: 15s

10917
docs/pb_schema.json Normal file

File diff suppressed because it is too large Load Diff

28
docs/refactor/README.md Normal file
View File

@@ -0,0 +1,28 @@
# SaaS 重构工作区
这个目录记录从 PocketBase 单体项目迁移到 Supabase/PostgreSQL + 新 API + Taro 学生端的重构过程。
当前第一阶段目标:
- 本地 Supabase 能启动并创建多租户 PostgreSQL schema。
- 新 API 能连接数据库并解析租户。
- PocketBase 的 `docs/pb_schema.json` 能被解析,后续真实数据导出后可导入到新库。
- 先保留旧 React Web 项目,逐步把学生端和后台接到新 API。
关键文件:
- `supabase/config.toml`:本地 Supabase 配置。
- `supabase/migrations/202606210001_core_multitenant_schema.sql`:第一版商用级多租户 schema。
- `apps/api`:新业务 API 服务,内部按 `src/core``src/features` 分层。
- `packages/config``packages/db``packages/domain`:新系统共享基础包。
- `scripts/import-pocketbase`PocketBase schema/数据导入工具。
- `docker-compose.api.yml``apps/api/Dockerfile`:本地 Docker API 运行入口。
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
下一步优先级:
1. 导出 PocketBase 真实数据到 `pb_export/*.json`
2. 执行 `npm run pb:import:json``npm run pb:import:validate`
3. 按学生端页面逐步从 PocketBase SDK 切换到 `src/services/supabaseApi.ts`
4. 为订单、支付、权益开通补齐 API 写入流程和 webhook 幂等处理。
5. 新建 Taro 学生端时复用同一套租户解析和业务 API不另起一套后端。

View File

@@ -0,0 +1,70 @@
# API 目录规范
`apps/api` 是 Web、Taro 小程序、管理后台共用的业务 API。所有复杂业务写入都进入这里前端不直接写 Supabase 表。
## 当前结构
```text
apps/api/src/
server.ts HTTP 服务入口,只负责请求生命周期
core/
config.ts 环境变量和运行配置
db.ts PostgreSQL 连接池和查询封装
http.ts CORS、JSON 响应、统一错误
router.ts 汇总注册各业务域路由
features/
auth/ 短信验证码、迁移期 session、OAuth provider 预留
health/ 健康检查
tenant/ 租户解析、品牌配置、域名识别
catalog/ 公开题库、科目、手册、商城、资料资源只读接口
learning/ 答题、错题、收藏、练习 session
commerce/ 订单、支付确认、激活码、权益
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
tenant-content/ 租户后台内容维护:题目、视频、分数线、单词、知识手册、资料资源、批量导入
```
## 新业务域落位
后续按下面方式增加目录:
```text
features/
auth/ 登录、绑定手机、OAuth 回调、会话换取
learning/ 答题记录、错题、收藏、学习进度
commerce/ 商品、订单、支付、退款、权益开通
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
platform-admin/ 平台租户管理、年费、服务费、账务审计
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券
tenant-content/ 合作商内容维护、批量导入、资源绑定、内容审计
```
每个 feature 默认包含:
```text
index.ts 导出 RouteDefinition[]
routes.ts HTTP handler
service.ts 业务编排和事务
repository.ts SQL 查询和写入
types.ts 仅本领域使用的类型
```
## 规则
- `server.ts` 不直接 import 业务 handler只 import `createRouter()`
- `features/*/index.ts` 只注册路由,不写 SQL。
- `routes.ts` 做参数解析、鉴权上下文、HTTP 错误,不写复杂事务。
- `service.ts` 承接订单、支付、权益、答题判定等业务规则。
- `repository.ts` 才写 SQL所有 SQL 必须带明确租户边界。
- 可预期错误用 `HttpError`,生产环境不向前端暴露内部异常。
- 写接口必须考虑幂等、审计和租户隔离;支付 webhook 必须先设计幂等键。
- 迁移期接口可用 `x-user-id` 标识学生用户;接 Supabase Auth 后统一替换为 JWT 解析。
- 登录类接口先使用 `Authorization: Bearer tk_*` 迁移期 sessionsession 明文只返回客户端,数据库只保存 hash。
- 平台运营接口使用 `x-platform-admin-key` 作为临时保护;正式上线前要迁到平台管理员 JWT 和审计日志。
- `platform-admin` 管平台与合作商之间的 SaaS 账务,`tenant-admin` 管合作商自己的品牌、域名、公开配置、登录/商户配置、活动和兑换码,`tenant-content` 管合作商自己的题库和学习内容维护。
- `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault对前端只返回 `secretRef` 和掩码状态。
- `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。
- `referral` 是增长/客资业务域,负责邀请码、扫码事件、首绑保护、销售/代理团队归属和 CRM 入队;真实 CRM webhook 发送应由 worker 处理API 只负责幂等入队。
- 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 地址。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录同步 API 当前支持题目 JSONExcel/CSV 和其它内容类型应接入同一管线。

View File

@@ -0,0 +1,97 @@
# 重构工程结构
新系统按 SaaS 商用架构组织,不再把 PocketBase 旧项目作为长期主结构。旧 React/PocketBase 代码先保留为兼容层,新的后端、数据库、导入器和共享包独立放置。
## 目录边界
```text
apps/
api/ 新业务 API前端和小程序都通过它访问业务数据
src/core/ 配置、HTTP、错误响应、路由注册、数据库访问等基础层
src/features/ 领域模块,按 catalog、tenant、health 等拆分
src/features/platform-admin/
平台方管理合作商租户、订阅、账单和使用量
packages/
config/ 环境变量、默认租户、默认数据库连接等共享配置
db/ PostgreSQL 连接池和 query/queryOne
domain/ 租户角色、订单状态、权益范围等领域常量
supabase/
config.toml 本地 Supabase 配置
migrations/ PostgreSQL schema、RLS、触发器、索引
seed.sql 本地主租户 seed
scripts/
import-pocketbase/ PocketBase schema 风险分析、JSON 导入、导入后校验
smoke-seed.js 本地 reset 后的最小业务烟测数据
src/
services/supabaseApi.ts 旧 Web 前端迁向新 API 的兼容客户端
tenant.config.ts 租户解析配置,优先读新 API
```
## 设计原则
- `apps/api` 是业务 API 层,复杂交易、支付、权益、租户解析都应该在这里做,不让前端直接操作表。
- 平台方与合作商之间的 SaaS 收费,使用 `platform_saas_plans``tenant_subscriptions``tenant_invoices``tenant_invoice_payments`;学生 C 端会员订单仍使用 `orders/payments/entitlements`
- `apps/api/src/server.ts` 只负责 HTTP 生命周期;业务路由统一放在 `features/*`,由 `core/router.ts` 汇总注册。
- API 对外错误必须走 `HttpError` 或统一错误响应,生产环境不向前端泄露数据库异常和内部栈信息。
- `packages/*` 放可复用基础能力,后续 Taro 小程序、管理后台 API、异步 worker 都复用这里。
- `supabase/migrations` 是数据库事实来源,旧 PB 字段不能绕过迁移规范直接进正式表。
- `scripts/import-pocketbase` 是一次性和可重复迁移工具,必须保持幂等,导入后必须跑验证。
- `src/services/pocketbase.ts``src/services/mockBackend.ts` 属于旧兼容层,后续按页面逐步替换到 `src/services/supabaseApi.ts`
## 本地开发顺序
```bash
npm run supabase:start
npm run supabase:reset
npm run db:smoke-seed
npm run dev:api
```
也可以只把 API 放进 Docker 容器运行。Supabase 仍由 Supabase CLI 管理API 容器通过宿主机端口连接本地 PostgreSQL
```bash
npm run supabase:start
npm run docker:api:build
npm run docker:api:up
```
如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`
验证 API
```bash
curl http://127.0.0.1:8787/health
curl "http://127.0.0.1:8787/api/tenant/resolve?host=localhost"
curl "http://127.0.0.1:8787/api/catalog/regions?tenantId=00000000-0000-0000-0000-000000000001"
```
导入旧数据:
```bash
npm run pb:import:json
npm run pb:import:validate
```
## 当前已验证
- Docker Desktop 可用。
- Supabase 本地容器可启动。
- `supabase db reset` 可完整执行三份 migration 和 seed。
- `supabase db reset` 可完整执行全部 migration 和 seed。
- `npm run db:smoke-seed` 可恢复最小业务烟测数据。
- `platform-admin` 可完成平台概览、租户创建、订阅、账单生成、人工收款确认、使用量记录。
- API `/health` 可连 PostgreSQL 并返回 `db: ok`
- API `/api/tenant/resolve?host=localhost` 可解析主租户。
- API 构建产物入口 `apps/api/dist/apps/api/src/server.js` 已验证可启动。
- 空库执行 `pb:import:validate` 为 0 failures、0 warnings。
## 下一阶段拆分
- `apps/api/src/features` 继续按业务域扩展:真实支付 provider、真实 OAuth provider、平台审计和 worker。
- `src/services/supabaseApi.ts` 逐页替换旧 PB 只读接口,优先学生端和小程序共用页面。
- 新增 `apps/worker` 承接 CRM webhook、支付补偿、日报统计、导入后异步检查。
- 新增 `apps/taro` 后,所有租户解析和公开业务读取都复用 API不单独维护另一套后端逻辑。

View File

@@ -0,0 +1,94 @@
# 国内认证与支付接入方案
## Supabase 边界
Supabase 适合承担 PostgreSQL、RLS、Auth、Edge Functions、Webhook/Hooks 等底座能力,但它不是中国大陆支付网关,也不会内置微信支付、支付宝、阿里云短信、腾讯云短信这一整套商用配置。
对本项目更稳妥的落位是:
- Supabase/PostgreSQL保存多租户、订单、支付事件、权益、审计、登录事件。
- `apps/api`:实现业务 API、短信 provider、OAuth provider、支付 provider、回调验签和幂等。
- `app_private.tenant_secrets` 或生产 Vault/KMS保存租户级密钥。
- `tenant_auth_providers``tenant_payment_accounts`:只保存非敏感公开配置。
Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先用 `app_private.auth_sessions` 签发 `tk_` session保证 Web/Taro 能跑通端到端流程。
## 当前已实现
- `POST /api/auth/sms/send`:手机号验证码发送,验证码只保存 HMAC hash。
- `POST /api/auth/sms/verify`:验证码登录,自动创建或复用 `platform_users`
- `GET /api/auth/me`:通过 Bearer token 获取当前用户。
- `POST /api/auth/logout`:吊销迁移期 session。
- `POST /api/auth/oauth/wechat``/wechat-miniapp``/qq`provider 占位,已固定错误码 `PROVIDER_NOT_CONFIGURED`
- `tenant_auth_providers`:租户级公开认证配置。
- `sms_verification_codes`:验证码审计表,不保存明文 code。
- `auth_login_events`:登录事件审计。
- `app_private.auth_sessions`:迁移期 session token hash。
## 短信 Provider
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境如果仍为 mock会直接拒绝发送。
后续真实 provider
- `aliyun`:接阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。
- `tencent`:接腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。
密钥策略:
- AccessKey/SecretKey 不进入 `tenant_settings.public_config`
- 租户级密钥写 `app_private.tenant_secrets(secret_scope='sms')` 或生产 Vault。
- 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。
## 微信/QQ 登录
微信小程序登录应由前端传 `wx.login` code 到 `/api/auth/oauth/wechat-miniapp`,后端调用微信 `code2Session` 换取 openid/session_key/unionid再落 `user_identities`
微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature不继续堆在 auth 模块里。
## 支付 Provider
支付不走 Supabase 内置能力。推荐继续扩展 `commerce`
- `POST /api/commerce/orders` 只负责创建订单,金额以后端套餐为准。
- `POST /api/commerce/payments/:provider/create` 后续按 provider 创建支付参数或收银台地址。
- `POST /api/commerce/payments/:provider/notify` 统一落 `payment_events`,先验签、再幂等、再更新订单和权益。
- 支付成功继续复用 `grantSvipEntitlement`,避免微信/支付宝/XPay 各写一套开通逻辑。
B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务:
- `platform_saas_plans`:平台售卖给合作商的 SaaS 套餐。
- `tenant_subscriptions`:合作商当前订阅状态。
- `tenant_invoices``tenant_invoice_items`:合作商账单与明细。
- `tenant_invoice_payments`:合作商账单收款记录。
- `tenant_usage_records`:学生数、题量、存储等用量指标。
支持策略:
- 平台代收:平台商户号收款,再给租户结算。
- 租户自收:每个租户配置自己的商户号和密钥。
- 服务商模式:平台服务商统一管理子商户。
真实接入前需要先明确微信支付、支付宝或聚合支付是否允许你们销售的题库会员形态,以及小程序端是否涉及虚拟支付限制。
## 需要准备的资料
- 阿里云或腾讯云短信:签名、模板 ID、AccessKey/SecretKey、短信用途文案。
- 微信小程序AppID、AppSecret、主体信息、合法域名、用户手机号授权能力。
- 微信网页/公众号AppID、AppSecret、授权回调域名。
- QQ 互联AppID、AppKey、回调域名。
- 微信支付商户号、API v3 key、商户证书/平台证书、回调域名、AppID 绑定关系。
- 支付宝AppID、应用私钥、支付宝公钥、回调地址、网页/手机网站/当面付产品开通情况。
- 每个合作商租户的收款模式:平台代收、租户自收或服务商子商户。
## 参考资料
- Supabase Phone Login: https://supabase.com/docs/guides/auth/phone-login
- Supabase Auth Hooks: https://supabase.com/docs/guides/auth/auth-hooks
- Supabase Social Login: https://supabase.com/docs/guides/auth/social-login
- 阿里云短信 SendSms: https://help.aliyun.com/zh/sms/developer-reference/api-dysmsapi-2017-05-25-sendsms
- 腾讯云短信 SendSms: https://cloud.tencent.com/document/api/382/55981
- 微信小程序登录 code2Session: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
- QQ 互联 OAuth: https://wiki.connect.qq.com/oauth2-0简介
- 微信支付 API v3: https://pay.weixin.qq.com/doc/v3/merchant/4012791855
- 支付宝开放平台: https://opendocs.alipay.com/

View File

@@ -0,0 +1,177 @@
# 后端重构进度
## 已完成
- Docker Desktop + Supabase local 已可用。
- API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。
- API 已按 `core/features` 分层:
- `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。
- `catalog`公开题库、地区、科目、手册、商品、SVIP 套餐、资料资源只读/下载接口。
- `learning`:练习 session、答题记录、错题、收藏、背单词进度/收藏/统计。
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习。
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
- `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单、支付确认、激活码兑换、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
- `tenant-content`:租户后台题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。
- `tenant`:域名/租户解析。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API并断言核心学生端接口、租户隔离、资源权限和题目导入。
## 已验证接口
```text
GET /health
POST /api/auth/sms/send
POST /api/auth/sms/verify
GET /api/auth/me
POST /api/auth/logout
POST /api/auth/oauth/wechat
POST /api/auth/oauth/wechat-miniapp
POST /api/auth/oauth/qq
GET /api/tenant/resolve
GET /api/platform-admin/overview
GET /api/platform-admin/plans
GET /api/platform-admin/tenants
POST /api/platform-admin/tenants
GET /api/platform-admin/tenants/detail
PATCH /api/platform-admin/tenants/status
PUT /api/platform-admin/tenants/billing-profile
POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices
POST /api/platform-admin/invoices
POST /api/platform-admin/invoices/from-subscription
POST /api/platform-admin/invoices/payments/manual-confirm
GET /api/platform-admin/usage
POST /api/platform-admin/usage
GET /api/catalog/*
GET /api/catalog/assets
GET /api/catalog/assets/download
POST /api/learning/answers
GET /api/learning/favorites/questions
POST /api/learning/favorites/questions
GET /api/learning/wrong-questions
GET /api/learning/vocabulary/progress
POST /api/learning/vocabulary/progress
GET /api/learning/vocabulary/favorites
POST /api/learning/vocabulary/favorites
GET /api/learning/vocabulary/stats
GET /api/profile/me
PATCH /api/profile/me
GET /api/scoreline/fields
GET /api/scoreline/schools
GET /api/scoreline/majors
GET /api/scoreline/records
GET /api/scoreline/trend
GET /api/scoreline/years
GET /api/questions/{questionId}/videos
POST /api/questions/videos/batch
GET /api/videos/search
POST /api/tenant-content/questions
PATCH /api/tenant-content/questions
GET /api/tenant-content/assets
PUT /api/tenant-content/assets
POST /api/tenant-content/assets/sign-upload
POST /api/tenant-content/assets/sign-download
POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions
GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues
PUT /api/tenant-content/videos
POST /api/tenant-content/question-videos
PUT /api/tenant-content/scoreline/schools
PUT /api/tenant-content/scoreline/majors
PUT /api/tenant-content/scoreline/fields
PUT /api/tenant-content/scoreline/records
PUT /api/tenant-content/vocabulary-units
PUT /api/tenant-content/vocabulary-words
PUT /api/tenant-content/handbook-subjects
PUT /api/tenant-content/handbook-chapters
PUT /api/tenant-content/handbook-entries
POST /api/commerce/orders
GET /api/commerce/orders
POST /api/commerce/payments/manual-confirm
POST /api/commerce/activation-codes/redeem
GET /api/commerce/entitlements
GET /api/commerce/entitlements/check
POST /api/referral/invite-code
POST /api/referral/resolve
POST /api/referral/track-event
POST /api/referral/bind
GET /api/referral/stats
GET /api/referral/sales-stats
GET /api/referral/sales-clients
POST /api/referral/manual-bind
GET /api/referral/team
PUT /api/referral/team
POST /api/referral/qrcode
GET /api/crm/config
PUT /api/crm/config
GET /api/crm/queue
GET /api/tenant-admin/permissions
GET /api/tenant-admin/overview
PUT /api/tenant-admin/branding
PUT /api/tenant-admin/settings
GET /api/tenant-admin/domains
POST /api/tenant-admin/domains
GET /api/tenant-admin/payment-accounts
PUT /api/tenant-admin/payment-accounts
GET /api/tenant-admin/auth-providers
PUT /api/tenant-admin/auth-providers
GET /api/tenant-admin/secrets
PUT /api/tenant-admin/secrets
GET /api/tenant-admin/banners
PUT /api/tenant-admin/banners
GET /api/tenant-admin/faqs
PUT /api/tenant-admin/faqs
GET /api/tenant-admin/announcements
PUT /api/tenant-admin/announcements
GET /api/tenant-admin/code-batches
PUT /api/tenant-admin/code-batches
GET /api/tenant-admin/activation-codes
PUT /api/tenant-admin/activation-codes
POST /api/tenant-admin/activation-codes/generate
GET /api/tenant-admin/coupons
PUT /api/tenant-admin/coupons
GET /api/tenant-admin/members
PUT /api/tenant-admin/members
POST /api/tenant-admin/members/disable
GET /api/tenant-admin/audit-logs
```
## 迁移期约定
- 当前写接口用 `x-tenant-id``x-user-id` 做迁移期上下文。
- `auth` 当前签发迁移期 `tk_` sessiontoken hash 存在 `app_private.auth_sessions`;后续接 Supabase Auth 后,`x-user-id` 要替换为 JWT 用户身份解析。
- 短信验证码只保存 HMAC hash不保存明文本地 `mock` provider 才会返回 `debugCode`
- `platform-admin` 当前用 `x-platform-admin-key` 做迁移期保护,生产后必须替换为平台管理员 JWT/服务端会话。
- B 端合作商年费/服务费使用 `tenant_invoices``tenant_invoice_items``tenant_invoice_payments`,不与 C 端学生订单混表。
- 订单金额以后端套餐价格为准,不信任前端传价。
- 激活码兑换和支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/VaultAPI 只返回 `secretRef` 和掩码状态。
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
- 题目批量导入当前支持 JSON 数组预览、逐行 issue、job/item 台账、执行导入和幂等跳过Excel/CSV、单词/手册/分数线导入会复用同一套 `content_import_jobs` 管线。
## 下一步
1. 完善内容导入和文件上传Excel/CSV、单词、手册、分数线、视频导入真实 OSS/COS/Supabase Storage 签名。
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
4. 增加真实支付 providerXPay、微信支付、支付宝并完善 webhook 幂等。
5. 增加 `apps/worker`支付补偿、CRM webhook、日报统计、导入后检查。
6. 开始 Taro scaffold`supabaseApi` 抽到跨端包或适配层。
## 测试命令
```text
npm run test:api
npm run check:refactor
```

View File

@@ -0,0 +1,43 @@
# SaaS 蓝图覆盖矩阵
更新时间2026-06-21 21:42
## 目标定位
新项目要覆盖旧 PocketBase 项目全部功能,同时升级为多租户 SaaS
- 平台超级管理员管理所有租户、SaaS 套餐、年费/服务费、公共/地区题库披露。
- 租户公司:拥有自己的品牌、域名、支付/登录/CRM 配置、成员角色、题库内容、销售/代理体系。
- 学生端:刷题、错题、收藏、背单词、知识手册、分数线、视频解析、会员权益。
- 跨端前端:后续 Taro 一套代码输出 H5 和小程序,统一调用 `apps/api`
## 当前覆盖情况
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
| --- | --- | --- | --- |
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 |
| 题库内容维护 | 基础完成 | 题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分类/节点完整管理、公题库采纳/复制/授权 |
| 学生刷题 | 基础完成 | 题目列表、练习 session、答题、错题本、收藏夹 | 模考、专项练习策略、错题复习计划、题型统计深度分析 |
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计 | 复习算法、每日计划、排行榜 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护 | 富文本资源、版本管理、附件/PDF 关联 |
| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 |
| 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 |
| 资料下载/PDF | 基础完成 | `content_assets` 资源台账、后台资源管理、上传/下载签名占位、学生端列表、SVIP 下载权限 | 真实 OSS/COS/Supabase Storage 签名、PDF 预览渲染、防盗链、资料前端管理页 |
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告 | 勋章自动发放、复杂活动规则、核销报表 |
| 销售/代理客资 | 基础完成 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、小程序码占位 | 真实微信小程序码、分佣结算单、销售团队看板、代理费结算 |
| CRM 系统 | 基础完成 | CRM 配置、密钥私密存储、客资入队、队列查询 | worker 发送、钉钉/飞书/企微 adapter、重试签名、定向/轮询分配 |
| 数据看板 | 数据表部分具备 | `dashboard_daily_stats``revenue_daily_stats` 表、旧 backfill 脚本 | API 聚合、24h 活跃、收入趋势、题型/科目/套餐销售看板 |
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信/QQ 登录真实 adapter、Supabase Auth/JWT |
| 支付 | 迁移期可用 | 订单、支付记录、手动确认、权益发放、租户商户配置 | 微信支付/支付宝/XPay adapter、webhook 幂等、退款 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |
## 接下来优先级
1. 完善内容导入和对象存储Excel/CSV、单词/手册/分数线/视频导入,真实 OSS/COS/Supabase Storage 签名。
2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。
3. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。
4. 数据看板 API把旧 dashboard/revenue 统计迁到新 API。
5. 真实 provider短信、微信/QQ 登录、微信支付/支付宝、CRM worker。

View File

@@ -0,0 +1,65 @@
# 数据治理与安全规范
这次重构不按 PocketBase 旧字段原样搬迁。旧集合只作为历史输入源,正式业务表按商用 SaaS 规范重新建模。
## 默认原则
- 旧数据先进入 `pb_raw_records`,且默认脱敏。
- 密钥、token、session、private key、AppSecret、支付密钥不得进入 `public` schema。
- 私密配置进入 `app_private.tenant_secrets`,生产环境再接云厂商 KMS/Vault。
- `settings` 不再作为业务表使用必须拆成公开配置、支付账户、短信配置、OAuth 配置、存储配置。
- `users` 不再作为万能表,拆成用户、身份、租户成员、学生资料、权益、学习记录。
- `users.isSvip``svipExpiry``svipRegions` 不作为新系统权限源,统一迁移为 `entitlements`
- `users.stats.favorites/wrongBook` 不继续留在 JSON 中,统一迁移为收藏表和错题表。
- `crm_config`、支付、短信、OAuth、对象存储等配置不得在 `public` schema 中保存明文密钥;公共表只保留可展示配置或 secret 引用。
- 导入后的上线闸门是 `npm run pb:import:validate`:有 `FAIL` 不上线,`WARN` 必须由业务确认并记录。
## 禁止直接复制的字段类型
字段名包含以下关键词时默认视为敏感:
```text
password
token
secret
privateKey
sessionKey
accessKey
appKey
apiKey
openid
unionid
aesKey
notifyToken
```
这些字段默认在导入原始区时写入 `[REDACTED]`。如果确实需要迁移到私有表,必须显式设置:
```bash
IMPORT_SECRET_VALUES=true npm run import:json
```
并且只能进入 `app_private.tenant_secrets`
## 导入质量报告
导入时会写入 `pb_import_issues`,用于记录:
- 敏感字段来源
- 旧 JSON 字段需要拆表
- `settings` 大杂烩配置风险
- `users` 会员状态需要转权益
查看旧 schema 风险:
```bash
npm run pb:schema:risk
```
## 后续硬性验收
- RLS 覆盖所有带 `tenant_id` 的表。
- 租户间数据隔离测试必须自动化。
- 支付 webhook 必须幂等。
- 订单金额、支付流水、权益开通必须可审计。
- 管理员操作必须写审计日志。

View File

@@ -0,0 +1,240 @@
# Supabase 重构功能进度矩阵
更新时间2026-06-21 21:42
## 当前结论
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、部分学生端 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目 JSON 批量导入基础闭环,以及本地 Docker/API 构建验证。
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。
## 新旧项目位置
| 范围 | 路径 | 状态 |
| --- | --- | --- |
| 旧 PocketBase/React 项目 | `F:\project\src``F:\project\pb_hooks``F:\project\pb_migrations` | 保留作为功能参照和迁移来源 |
| 新 Node API | `F:\project\apps\api` | 已按 `core/features` 分层重构 |
| Supabase/PostgreSQL 迁移 | `F:\project\supabase\migrations` | 已建立多租户和业务域表 |
| PocketBase 数据导入 | `F:\project\scripts\import-pocketbase` | 已支持多类旧数据归一化导入与校验 |
| 共享包 | `F:\project\packages\config``F:\project\packages\db``F:\project\packages\domain` | 已建立基础共享层 |
| 旧 Web 到新 API 适配 | `F:\project\src\services\supabaseApi.ts` | 已开始抽象,后续应迁到 Taro 共享 API 包 |
## 功能完成度
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
| --- | --- | --- | --- | --- | --- |
| 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
| 刷题题库 | 已建题库、题目、题目版本、分类、地区、科目、导入任务台账 | 已支持核心映射 | 题目列表、练习 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导入断言 | 基础刷题链路、后台题目录入和 JSON 批量导入可跑,专项练习/模考/Excel 导入仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
| 背单词 | 已建单词单元、单词、进度、收藏表 | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API 已实现 | 核心 API 集成测试 | 学生端基础学习状态和后台单词维护已实现,复习算法和后台统计待完善 |
| 知识手册 | 已建手册科目、章节、条目 | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API 已实现 | 核心 API 集成测试 | 学生端阅读和后台维护基础可用,富文本资源/版本管理待补 |
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
| 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 |
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、上传/下载签名占位、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载 | 资料资源基础闭环可跑,真实 OSS/COS 签名、PDF 预览渲染、资料下载前端待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用真实微信小程序码、分佣结算单、CRM worker 推送待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录已实现 | 仅烟测 | 平台收费链路骨架可用,正式鉴权/审计/自动计费未完成 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、OAuth 占位已实现 | 仅烟测 | 本地可测,真实短信/微信/QQ 登录未完成 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
## 已实现 API 范围
```text
auth:
POST /api/auth/sms/send
POST /api/auth/sms/verify
GET /api/auth/me
POST /api/auth/logout
POST /api/auth/oauth/wechat
POST /api/auth/oauth/wechat-miniapp
POST /api/auth/oauth/qq
tenant:
GET /api/tenant/resolve
catalog:
GET /api/catalog/regions
GET /api/catalog/region-modules
GET /api/catalog/module-nodes
GET /api/catalog/schools
GET /api/catalog/majors
GET /api/catalog/subjects
GET /api/catalog/categories
GET /api/catalog/questions
GET /api/catalog/assets
GET /api/catalog/assets/download
GET /api/catalog/vocabulary-units
GET /api/catalog/vocabulary-words
GET /api/catalog/handbook-subjects
GET /api/catalog/handbook-chapters
GET /api/catalog/handbook-entries
GET /api/catalog/banners
GET /api/catalog/faqs
GET /api/catalog/announcements
GET /api/catalog/products
GET /api/catalog/timelines
GET /api/catalog/svip-plans
learning:
POST /api/learning/practice-sessions
POST /api/learning/answers
GET /api/learning/favorites/questions
POST /api/learning/favorites/questions
GET /api/learning/wrong-questions
POST /api/learning/wrong-questions/resolve
GET /api/learning/vocabulary/progress
POST /api/learning/vocabulary/progress
GET /api/learning/vocabulary/favorites
POST /api/learning/vocabulary/favorites
GET /api/learning/vocabulary/stats
profile:
GET /api/profile/me
PATCH /api/profile/me
scoreline:
GET /api/scoreline/fields
GET /api/scoreline/schools
GET /api/scoreline/majors
GET /api/scoreline/records
GET /api/scoreline/trend
GET /api/scoreline/years
video:
GET /api/questions/{questionId}/videos
GET /api/questions/videos?questionId=...
POST /api/questions/videos/batch
GET /api/videos/search
tenant-content:
POST /api/tenant-content/questions
PATCH /api/tenant-content/questions
GET /api/tenant-content/assets
PUT /api/tenant-content/assets
POST /api/tenant-content/assets/sign-upload
POST /api/tenant-content/assets/sign-download
POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions
GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues
GET /api/tenant-content/videos
PUT /api/tenant-content/videos
POST /api/tenant-content/question-videos
GET /api/tenant-content/scoreline/schools
PUT /api/tenant-content/scoreline/schools
GET /api/tenant-content/scoreline/majors
PUT /api/tenant-content/scoreline/majors
GET /api/tenant-content/scoreline/fields
PUT /api/tenant-content/scoreline/fields
GET /api/tenant-content/scoreline/records
PUT /api/tenant-content/scoreline/records
GET /api/tenant-content/vocabulary-units
PUT /api/tenant-content/vocabulary-units
GET /api/tenant-content/vocabulary-words
PUT /api/tenant-content/vocabulary-words
GET /api/tenant-content/handbook-subjects
PUT /api/tenant-content/handbook-subjects
GET /api/tenant-content/handbook-chapters
PUT /api/tenant-content/handbook-chapters
GET /api/tenant-content/handbook-entries
PUT /api/tenant-content/handbook-entries
commerce:
POST /api/commerce/orders
GET /api/commerce/orders
GET /api/commerce/entitlements
GET /api/commerce/entitlements/check
POST /api/commerce/payments/manual-confirm
POST /api/commerce/activation-codes/redeem
referral/crm:
POST /api/referral/invite-code
POST /api/referral/resolve
POST /api/referral/track-event
POST /api/referral/bind
GET /api/referral/stats
GET /api/referral/sales-stats
GET /api/referral/sales-clients
POST /api/referral/manual-bind
GET /api/referral/team
PUT /api/referral/team
POST /api/referral/qrcode
GET /api/crm/config
PUT /api/crm/config
GET /api/crm/queue
tenant-admin:
GET /api/tenant-admin/permissions
GET /api/tenant-admin/overview
PUT /api/tenant-admin/branding
PUT /api/tenant-admin/settings
GET /api/tenant-admin/domains
POST /api/tenant-admin/domains
GET /api/tenant-admin/payment-accounts
PUT /api/tenant-admin/payment-accounts
GET /api/tenant-admin/auth-providers
PUT /api/tenant-admin/auth-providers
GET /api/tenant-admin/secrets
PUT /api/tenant-admin/secrets
GET /api/tenant-admin/banners
PUT /api/tenant-admin/banners
GET /api/tenant-admin/faqs
PUT /api/tenant-admin/faqs
GET /api/tenant-admin/announcements
PUT /api/tenant-admin/announcements
GET /api/tenant-admin/code-batches
PUT /api/tenant-admin/code-batches
GET /api/tenant-admin/activation-codes
PUT /api/tenant-admin/activation-codes
POST /api/tenant-admin/activation-codes/generate
GET /api/tenant-admin/coupons
PUT /api/tenant-admin/coupons
GET /api/tenant-admin/members
PUT /api/tenant-admin/members
POST /api/tenant-admin/members/disable
GET /api/tenant-admin/audit-logs
platform-admin:
GET /api/platform-admin/overview
GET /api/platform-admin/plans
GET /api/platform-admin/tenants
POST /api/platform-admin/tenants
GET /api/platform-admin/tenants/detail
PATCH /api/platform-admin/tenants/status
PUT /api/platform-admin/tenants/billing-profile
POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices
POST /api/platform-admin/invoices
POST /api/platform-admin/invoices/from-subscription
POST /api/platform-admin/invoices/payments/manual-confirm
GET /api/platform-admin/usage
POST /api/platform-admin/usage
```
## 商用交付缺口
上线前至少还需要完成:
1. 正式鉴权:迁移期 `x-tenant-id``x-user-id``x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session并逐表验证 RLS。
2. 国内能力接入短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API下一步重点是后台维护、权限、统计和真实业务验收。
4. 后台能力题库录入、JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 APIExcel 导入、真实对象存储签名和前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
## 下一步优先级
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
1. 完善内容导入和文件上传Excel/CSV、单词、手册、分数线、视频导入接真实 OSS/COS/Supabase Storage 签名。
2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。
3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。
4. 补视频商用控制SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
5. 补 AI 择校推荐报告、排行榜、勋章自动发放。
6. 接真实支付、短信、微信/QQ 登录 provider adapter并开始 Taro scaffold。

View File

@@ -0,0 +1,127 @@
# 本地 Supabase 开发环境
## 前置依赖
Supabase 本地开发需要:
- Docker Desktop
- Supabase CLI
- Node.js 20+
当前机器如果出现下面错误,说明 Docker Desktop 未启动或 Docker daemon 不可访问:
```text
failed to inspect container health
open //./pipe/docker_engine: The system cannot find the file specified
```
先启动 Docker Desktop再执行 Supabase 命令。
当前仓库已经加入 `supabase/config.toml` 和第一版 migration。安装依赖后执行
```bash
npm run supabase:start
npm run supabase:status
```
本地默认端口:
```text
API: http://127.0.0.1:54321
DB: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio: http://127.0.0.1:54323
Inbucket: http://127.0.0.1:54324
```
重置数据库:
```bash
npm run supabase:reset
npm run db:smoke-seed
```
## API 服务
```bash
npm run dev:api
```
健康检查:
```bash
curl http://127.0.0.1:8787/health
```
租户解析:
```bash
curl "http://127.0.0.1:8787/api/tenant/resolve?host=localhost"
curl "http://127.0.0.1:8787/api/tenant/resolve?tenantCode=master"
```
公开题库数据接口示例:
```bash
curl "http://127.0.0.1:8787/api/catalog/regions?tenantId=00000000-0000-0000-0000-000000000001"
curl "http://127.0.0.1:8787/api/catalog/questions?tenantId=00000000-0000-0000-0000-000000000001&limit=20"
```
本地短信登录 smoke 可用 mock provider。开发环境接口会返回 `debugCode`
```bash
curl -X POST "http://127.0.0.1:8787/api/auth/sms/send" \
-H "content-type: application/json" \
-H "x-tenant-id: 00000000-0000-0000-0000-000000000001" \
-d "{\"phone\":\"13900000001\",\"purpose\":\"login\"}"
```
平台运营接口迁移期使用 `x-platform-admin-key`。本地默认值来自 `PLATFORM_ADMIN_API_KEY`,未设置时为 `local-platform-admin-key`
```bash
curl "http://127.0.0.1:8787/api/platform-admin/overview" \
-H "x-platform-admin-key: local-platform-admin-key"
curl "http://127.0.0.1:8787/api/platform-admin/tenants?limit=20" \
-H "x-platform-admin-key: local-platform-admin-key"
```
## Docker 运行 API
本地 Supabase 继续由 Supabase CLI 启动API 可以单独进入 Docker 容器:
```bash
npm run supabase:start
npm run docker:api:build
npm run docker:api:up
```
Compose 文件是 `docker-compose.api.yml`。API 容器默认使用:
```text
DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:54322/postgres
PORT=8787
```
如果本机已有非容器 API 占用 `8787`,先关闭旧进程或临时修改 `docker-compose.api.yml` 的端口映射。当前机器 Docker Desktop 可用Supabase 容器已可运行;若构建 API 镜像时报 Docker Hub 或 ECR 拉取超时,需要先处理 Docker 镜像源或网络代理。
## PocketBase JSON 导入
把 PocketBase 导出的集合 JSON 放到仓库根目录的 `pb_export` 文件夹后执行:
```bash
npm run pb:import:json
npm run pb:import:validate
```
导入真实密钥时必须明确打开开关,且只允许进入 `app_private.tenant_secrets`
```bash
set IMPORT_SECRET_VALUES=true
npm run pb:import:json
```
上线前 `pb:import:validate` 不能有 `FAIL``WARN` 通常代表旧数据缺失关联,需要业务确认后记录处理结论。
## 说明
Supabase Custom Domain 不用于合作商多域名绑定。合作商域名在你们自己的 Web/API 网关层解析,然后从 `tenant_domains` 表得到 `tenant_id`

View File

@@ -0,0 +1,89 @@
# PocketBase 到 Supabase 迁移映射
## 迁移原则
- 保留 PocketBase 旧 ID 到 `legacy_id`,新系统主键统一用 UUID。
- 所有租户内业务表都带 `tenant_id`
- 旧表原始 JSON 进入 `pb_raw_records` 前默认脱敏,保证可追溯但不泄露密钥。
- 旧集合里不符合商用规范的字段不得直接映射到正式表,必须经过清洗和拆表。
- 第一阶段标准化导入覆盖核心商用链路租户、用户、题库目录、题目、订单、支付、权益、激活码、优惠券、词库、手册、运营内容、分数线、视频解析、CRM 和推广关系。未覆盖集合先 raw import并在 `pb_import_issues` 里记录风险。
- `users.isSvip``svipExpiry``svipRegions` 最终迁移为 `entitlements`
- `users.stats.favorites``wrongBook` 最终迁移为 `favorite_questions``wrong_questions`
- `settings` 中的密钥类配置必须进入 `app_private.tenant_secrets` 或外部 Vault不进入 `public` schema。
## 核心映射
| PocketBase | PostgreSQL | 说明 |
| --- | --- | --- |
| `tenant_config` | `tenants` / `tenant_branding` / `tenant_settings` | 从单实例配置升级为平台租户配置 |
| `users` | `platform_users` / `tenant_memberships` / `student_profiles` / `user_identities` | 用户身份、租户角色、学生资料拆分 |
| `regions` | `regions` | 增加 `tenant_id` |
| `region_modules` | `region_modules` | 增加 `tenant_id` |
| `module_nodes` | `module_nodes` | 作为后续题库层级主结构 |
| `subjects` | `subjects` | 保留旧结构兼容,逐步和 `module_nodes` 对齐 |
| `categories` | `categories` | 保留旧结构兼容 |
| `questions` | `questions` / `question_versions` | 题目实体和题目内容版本拆分 |
| `orders` | `orders` / `order_items` / `payments` / `payment_events` | 订单与支付流水分离 |
| `svip_plans` | `svip_plans` | 金额改为分 |
| `codes` | `activation_codes` | 激活码表 |
| `code_batches` | `code_batches` | 批次表 |
| `coupons` | `coupons` | 优惠券 |
| `coupon_redemptions` | `coupon_redemptions` | 兑换流水 |
| `vocabulary_units` | `vocabulary_units` | 背单词单元 |
| `vocabulary` | `vocabulary_words` | 单词表 |
| `handbook_*` | `handbook_*` | 手册内容 |
| `banners` / `faqs` / `announcements` | 同名表 | 运营内容 |
| `settings` | `tenant_settings` / `tenant_payment_accounts` | 拆分公开配置、私密配置、支付配置 |
| `crm_config.secret` | `app_private.tenant_secrets` | 公共表只保留 `secret_ref` |
| `users.stats.favorites` | `favorite_questions` | 迁移为关系表 |
| `users.stats.wrongBook` | `wrong_questions` | 迁移为关系表 |
| `users.isSvip` / `svipExpiry` / `svipRegions` | `entitlements` | 迁移为租户/地区范围权益 |
| `user_word_progress` / `user_word_favorites` | 同名规范表 | 关联到 `platform_users``vocabulary_words` |
| `app_assets` / `images` | `content_assets` | 统一素材索引 |
## 导入命令
先安装导入器依赖:
```bash
cd scripts/import-pocketbase
copy .env.example .env
npm install
```
查看 schema 摘要:
```bash
npm run schema:summary
npm run schema:risk
```
把 PocketBase 导出的集合 JSON 放到 `pb_export`
```text
pb_export/
users.json
regions.json
questions.json
```
执行导入:
```bash
npm run import:json
```
导入后执行验证:
```bash
npm run import:validate
```
根目录也可以执行:
```bash
npm run pb:import:json
npm run pb:import:validate
```
导入器会先把所有 JSON 放入 `pb_raw_records`,并默认对敏感字段脱敏;然后按依赖顺序标准化导入业务表。上线前必须处理 `FAIL` 项;`WARN` 项通常表示旧数据关系缺失,例如旧题目引用了不存在的章节,需要业务确认是否可接受。

5350
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "tianjin-zsb-master",
"version": "1.0.0",
"description": "升本刷题通 - 专业的专升本备考刷题软件",
"author": "升本刷题通团队",
"private": true,
"type": "module",
"homepage": "./",
"workspaces": [
"apps/*",
"packages/*",
"scripts/import-pocketbase"
],
"dependencies": {
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google/genai": "*",
"codemirror": "^6.0.2",
"docx": "^9.6.1",
"html-to-image": "^1.11.13",
"jszip": "^3.10.1",
"katex": "^0.16.45",
"lucide-react": "^0.330.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.6.205",
"perfect-freehand": "^1.2.3",
"pocketbase": "^0.21.1",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-to-print": "^3.3.0",
"recharts": "^2.12.7",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"supabase": "^2.107.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
},
"scripts": {
"dev": "vite --mode public",
"dev:admin": "vite --mode admin",
"dev:api": "npm --workspace @tiku-saas/api run dev",
"build:api": "npm --workspace @tiku-saas/api run build",
"check:api": "npm --workspace @tiku-saas/api run check",
"check:importer": "npm --workspace @tiku-saas/import-pocketbase run check",
"check:refactor": "npm run check:api && npm run check:importer && npm run pb:import:validate && npm run test:api",
"docker:api:build": "docker compose -f docker-compose.api.yml build",
"docker:api:up": "docker compose -f docker-compose.api.yml up api",
"docker:api:down": "docker compose -f docker-compose.api.yml down",
"supabase:start": "supabase start",
"supabase:stop": "supabase stop",
"supabase:status": "supabase status",
"supabase:reset": "supabase db reset",
"db:smoke-seed": "node scripts/smoke-seed.js",
"smoke:core-api": "node scripts/smoke-core-api.js",
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
"test:api:remote": "node scripts/api-integration-test.js",
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
"pb:import:json": "npm --workspace @tiku-saas/import-pocketbase run import:json",
"pb:import:validate": "npm --workspace @tiku-saas/import-pocketbase run import:validate",
"build": "npm run build:public",
"build:public": "tsc && vite build --mode public",
"build:admin": "tsc && vite build --mode admin",
"build:all": "npm run build:public && npm run build:admin",
"preview": "vite preview"
}
}

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';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
TENANT_ID=00000000-0000-0000-0000-000000000001
PB_SCHEMA_PATH=../../docs/pb_schema.json
PB_EXPORT_DIR=../../pb_export

View File

@@ -0,0 +1,729 @@
{
"name": "@tiku-saas/import-pocketbase",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tiku-saas/import-pocketbase",
"version": "0.1.0",
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.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/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.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/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"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"
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "@tiku-saas/import-pocketbase",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"schema:summary": "tsx src/analyze-schema.ts",
"schema:risk": "tsx src/risk-report.ts",
"import:json": "tsx src/import-json.ts",
"import:validate": "tsx src/validate-import.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"pg": "^8.16.3"
},
"devDependencies": {
"@types/node": "^24.0.4",
"@types/pg": "^8.15.4",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,30 @@
import { readPocketBaseSchema, fieldsOf } from './pb-schema.js';
const { absPath, collections, byId } = readPocketBaseSchema();
const businessCollections = collections.filter(c => !c.name.startsWith('_'));
const authCollections = collections.filter(c => c.type === 'auth');
const relationCount = collections.reduce(
(count, collection) => count + fieldsOf(collection).filter(field => field.type === 'relation').length,
0,
);
console.log(`PocketBase schema: ${absPath}`);
console.log(`Collections: ${collections.length}`);
console.log(`Business collections: ${businessCollections.length}`);
console.log(`Auth collections: ${authCollections.map(c => c.name).join(', ') || 'none'}`);
console.log(`Relation fields: ${relationCount}`);
console.log('');
for (const collection of businessCollections.sort((a, b) => a.name.localeCompare(b.name))) {
const fields = fieldsOf(collection);
const relationFields = fields.filter(field => field.type === 'relation');
const relationSummary = relationFields
.map(field => {
const target = field.collectionId ? byId.get(field.collectionId)?.name || field.collectionId : '?';
return `${field.name}->${target}`;
})
.join(', ');
console.log(`${collection.name.padEnd(24)} ${collection.type.padEnd(6)} fields=${String(fields.length).padStart(2)} relations=${relationSummary || '-'}`);
}

View File

@@ -0,0 +1,22 @@
import { DEFAULT_DATABASE_URL } from '../../../packages/config/src/index.js';
import { createPool, query as runQuery, queryOne as runQueryOne } from '../../../packages/db/src/index.js';
import { loadEnv } from './env.js';
loadEnv();
export const pool = createPool({
connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
max: 5,
});
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
return runQuery<T>(pool, sql, params);
}
export async function queryOne<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
return runQueryOne<T>(pool, sql, params);
}
export async function closeDb() {
await pool.end();
}

View File

@@ -0,0 +1,5 @@
import { loadDotenv } from '../../../packages/config/src/index.js';
export function loadEnv() {
loadDotenv();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import fs from 'node:fs';
import path from 'node:path';
export interface PocketBaseField {
name: string;
type: string;
required?: boolean;
hidden?: boolean;
values?: string[];
collectionId?: string;
maxSelect?: number;
}
export interface PocketBaseCollection {
id: string;
name: string;
type: string;
fields?: PocketBaseField[];
schema?: PocketBaseField[];
listRule?: string;
viewRule?: string;
createRule?: string;
updateRule?: string;
deleteRule?: string;
}
export function readPocketBaseSchema(schemaPath = process.env.PB_SCHEMA_PATH || '../../docs/pb_schema.json') {
const absPath = path.resolve(process.cwd(), schemaPath);
const raw = JSON.parse(fs.readFileSync(absPath, 'utf8'));
const collections = (Array.isArray(raw) ? raw : raw.collections || []) as PocketBaseCollection[];
const byId = new Map(collections.map(c => [c.id, c]));
const byName = new Map(collections.map(c => [c.name, c]));
return { absPath, collections, byId, byName };
}
export function fieldsOf(collection: PocketBaseCollection) {
return collection.fields || collection.schema || [];
}

View File

@@ -0,0 +1,80 @@
import { readPocketBaseSchema, fieldsOf } from './pb-schema.js';
const { collections, byId, absPath } = readPocketBaseSchema();
const sensitivePattern = /(password|token|secret|private|session|openid|unionid|accesskey|appkey|apikey|apiV3Key|notifyToken|aesKey|mchId|appSecret)/i;
const likelySafeKeyPattern = /(^key$|fieldKey|tokenKey)$/i;
const jsonRiskPattern = /(stats|progress|config|settings|raw|payload|metadata|fieldValues|svipRegions|recentActivities)/i;
interface Risk {
severity: 'critical' | 'high' | 'medium' | 'low';
collection: string;
field?: string;
message: string;
}
const risks: Risk[] = [];
for (const collection of collections.filter(c => !c.name.startsWith('_'))) {
const fields = fieldsOf(collection);
if (collection.name === 'settings') {
risks.push({
severity: 'critical',
collection: collection.name,
message: 'Monolithic settings table mixes public config and secrets. Split into tenant_settings, tenant_payment_accounts and app_private.tenant_secrets.',
});
}
if (collection.name === 'users') {
risks.push({
severity: 'high',
collection: collection.name,
message: 'Legacy users table mixes auth, profile, roles, learning stats, membership and referral data. Split before migration.',
});
}
for (const field of fields) {
if ((sensitivePattern.test(field.name) && !likelySafeKeyPattern.test(field.name)) || field.hidden) {
risks.push({
severity: field.hidden || sensitivePattern.test(field.name) ? 'critical' : 'high',
collection: collection.name,
field: field.name,
message: `Sensitive or hidden field "${field.name}" must not be copied into public normalized tables.`,
});
}
if (field.type === 'json' && jsonRiskPattern.test(field.name)) {
risks.push({
severity: 'medium',
collection: collection.name,
field: field.name,
message: `JSON field "${field.name}" needs explicit normalization or a documented reason to remain JSONB.`,
});
}
if (field.type === 'relation' && field.collectionId) {
const target = byId.get(field.collectionId);
if (!target) {
risks.push({
severity: 'high',
collection: collection.name,
field: field.name,
message: `Relation points to missing collection id "${field.collectionId}".`,
});
}
}
}
}
const order: Record<Risk['severity'], number> = { critical: 0, high: 1, medium: 2, low: 3 };
risks.sort((a, b) => order[a.severity] - order[b.severity] || a.collection.localeCompare(b.collection));
console.log(`PocketBase schema risk report: ${absPath}`);
console.log(`Risks: ${risks.length}`);
console.log('');
for (const risk of risks) {
const location = risk.field ? `${risk.collection}.${risk.field}` : risk.collection;
console.log(`[${risk.severity.toUpperCase()}] ${location} - ${risk.message}`);
}

View File

@@ -0,0 +1,354 @@
import { closeDb, query } from './db.js';
import { loadEnv } from './env.js';
loadEnv();
type CheckStatus = 'pass' | 'warn' | 'fail';
interface CheckResult {
name: string;
status: CheckStatus;
count?: number;
message: string;
}
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const failOnWarnings = process.env.FAIL_ON_WARNINGS === 'true';
async function scalar(sql: string, params: unknown[] = []) {
const rows = await query<{ count: string }>(sql, params);
return Number(rows[0]?.count || 0);
}
function result(name: string, count: number, failMessage: string, passMessage: string, warnOnly = false): CheckResult {
if (count > 0) {
return {
name,
status: warnOnly ? 'warn' : 'fail',
count,
message: failMessage,
};
}
return { name, status: 'pass', count, message: passMessage };
}
async function tableCounts(): Promise<CheckResult[]> {
const tables = [
'platform_users',
'regions',
'region_modules',
'module_nodes',
'schools',
'majors',
'subjects',
'categories',
'questions',
'orders',
'svip_plans',
'activation_codes',
'vocabulary_units',
'vocabulary_words',
'handbook_subjects',
'handbook_chapters',
'handbook_entries',
'products',
];
const checks: CheckResult[] = [];
for (const table of tables) {
const count =
table === 'platform_users'
? await scalar('select count(*) from public.platform_users')
: await scalar(`select count(*) from public.${table} where tenant_id = $1`, [tenantId]);
checks.push({
name: `count:${table}`,
status: 'pass',
count,
message: `${table} rows: ${count}`,
});
}
return checks;
}
async function validationChecks(): Promise<CheckResult[]> {
const checks: CheckResult[] = [];
checks.push(
result(
'tenant_exists',
(await scalar('select count(*) from public.tenants where id = $1', [tenantId])) === 0 ? 1 : 0,
'Tenant seed is missing. Run Supabase seed or set TENANT_ID to an existing tenant.',
'Tenant exists.',
),
);
checks.push(
result(
'raw_records_not_normalized',
await scalar(
`
select count(*)
from public.pb_raw_records r
join public.pb_import_runs run on run.id = r.run_id
where r.tenant_id = $1
and run.id = (
select id from public.pb_import_runs
where tenant_id = $1
order by started_at desc
limit 1
)
and r.collection_name in (
'regions','region_modules','module_nodes','schools','majors','subjects',
'categories','questions','users','orders','svip_plans','codes',
'vocabulary_units','vocabulary','handbook_subjects','handbook_chapters',
'handbook_entries','banners','faqs','announcements'
)
and r.normalized = false
`,
[tenantId],
),
'Some core raw records from the latest run were not normalized.',
'All latest-run core raw records are marked normalized.',
),
);
checks.push(
result(
'questions_without_current_version',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and current_version_id is null
`,
[tenantId],
),
'Questions exist without a current version.',
'Every question has a current version.',
),
);
checks.push(
result(
'question_versions_wrong_tenant',
await scalar(
`
select count(*)
from public.questions q
join public.question_versions v on v.question_id = q.id
where q.tenant_id = $1 and v.tenant_id <> q.tenant_id
`,
[tenantId],
),
'Some question_versions have a different tenant_id from their question.',
'Question version tenant_id values match their questions.',
),
);
checks.push(
result(
'questions_unresolved_subjects',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and legacy_subject_id is not null and subject_id is null
`,
[tenantId],
),
'Some questions still have legacy_subject_id but no resolved subject_id.',
'Question subject references are resolved.',
true,
),
);
checks.push(
result(
'questions_unresolved_categories',
await scalar(
`
select count(*)
from public.questions
where tenant_id = $1 and legacy_category_id is not null and category_id is null
`,
[tenantId],
),
'Some questions still have legacy_category_id but no resolved category_id.',
'Question category references are resolved.',
true,
),
);
checks.push(
result(
'orders_unresolved_users',
await scalar(
`
select count(*)
from public.orders
where tenant_id = $1 and legacy_user_id is not null and user_id is null
`,
[tenantId],
),
'Some orders still have legacy_user_id but no resolved user_id.',
'Order user references are resolved.',
true,
),
);
checks.push(
result(
'orders_paid_without_payment',
await scalar(
`
select count(*)
from public.orders o
where o.tenant_id = $1
and o.status = 'paid'
and not exists (
select 1 from public.payments p
where p.tenant_id = o.tenant_id and p.order_id = o.id
)
`,
[tenantId],
),
'Paid orders exist without payment rows.',
'Paid orders have payment rows.',
),
);
checks.push(
result(
'entitlements_unresolved_user',
await scalar(
`
select count(*)
from public.entitlements e
left join public.platform_users u on u.id = e.user_id
where e.tenant_id = $1 and u.id is null
`,
[tenantId],
),
'Entitlements exist without a valid user.',
'Entitlements reference valid users.',
),
);
checks.push(
result(
'public_sensitive_profile_keys',
await scalar(
`
select count(*)
from public.platform_users
where raw_profile::text ~* '"(password|tokenKey|secret|sessionKey|openId|unionId|wechatSessionKey|qqOpenId|wechatOpenId|wechatUnionId)"'
`,
),
'Sensitive identity or credential keys remain in platform_users.raw_profile.',
'No sensitive identity or credential keys found in platform_users.raw_profile.',
),
);
checks.push(
result(
'student_stats_legacy_arrays',
await scalar(
`
select count(*)
from public.student_profiles
where tenant_id = $1 and (stats ? 'favorites' or stats ? 'wrongBook')
`,
[tenantId],
),
'student_profiles.stats still contains favorites/wrongBook legacy arrays.',
'student profile stats no longer contain favorites/wrongBook arrays.',
),
);
checks.push(
result(
'public_settings_sensitive_keys',
await scalar(
`
select count(*)
from public.tenant_settings
where tenant_id = $1
and public_config::text ~* '(secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey)'
`,
[tenantId],
),
'Sensitive-looking keys remain in tenant_settings.public_config.',
'No sensitive-looking keys found in tenant public settings.',
),
);
checks.push(
result(
'public_crm_secret_leak',
await scalar(
`
select count(*)
from public.crm_config
where tenant_id = $1 and secret_ref is not null and secret_ref !~ '^app_private\\.tenant_secrets:'
`,
[tenantId],
),
'CRM config contains an unsafe secret_ref value.',
'CRM config stores only private secret references.',
),
);
checks.push(
result(
'critical_import_issues',
await scalar(
`
select count(*)
from public.pb_import_issues
where tenant_id = $1
and severity = 'critical'
and run_id = (
select id from public.pb_import_runs
where tenant_id = $1
order by started_at desc
limit 1
)
`,
[tenantId],
),
'Critical import issues exist in the latest run. Review pb_import_issues before launch.',
'No critical import issues in the latest run.',
true,
),
);
return checks;
}
async function main() {
const checks = [...(await tableCounts()), ...(await validationChecks())];
let failures = 0;
let warnings = 0;
for (const check of checks) {
const prefix = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL';
console.log(`[${prefix}] ${check.name}: ${check.message}${check.count === undefined ? '' : ` (${check.count})`}`);
if (check.status === 'fail') failures += 1;
if (check.status === 'warn') warnings += 1;
}
console.log(`Validation complete: ${failures} failures, ${warnings} warnings.`);
if (failures > 0 || (failOnWarnings && warnings > 0)) {
process.exitCode = 1;
}
}
main()
.catch(error => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await closeDb();
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "../.."
},
"include": ["src/**/*.ts", "../../packages/**/*.ts"]
}

741
scripts/smoke-seed.js Normal file
View File

@@ -0,0 +1,741 @@
import pg from 'pg';
const { Pool } = pg;
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const ids = {
user: '00000000-0000-0000-0000-000000000101',
tenantAdminUser: '00000000-0000-0000-0000-000000000102',
tenantOperatorUser: '00000000-0000-0000-0000-000000000103',
tenantSalesUser: '00000000-0000-0000-0000-000000000104',
tenantAgentUser: '00000000-0000-0000-0000-000000000105',
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
questionBank: '00000000-0000-0000-0000-000000000400',
question: '00000000-0000-0000-0000-000000000401',
questionVersion: '00000000-0000-0000-0000-000000000402',
plan: '00000000-0000-0000-0000-000000000201',
order: '00000000-0000-0000-0000-000000000701',
payment: '00000000-0000-0000-0000-000000000702',
activationCode: '00000000-0000-0000-0000-000000000801',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821',
questionVideo: '00000000-0000-0000-0000-000000000822',
scorelineSchool: '00000000-0000-0000-0000-000000000831',
scorelineMajor: '00000000-0000-0000-0000-000000000832',
scorelineField: '00000000-0000-0000-0000-000000000833',
scorelineRecord: '00000000-0000-0000-0000-000000000834',
recentPractice: '00000000-0000-0000-0000-000000000841',
partnerTenant: '00000000-0000-0000-0000-000000000901',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
partnerInvoice: '00000000-0000-0000-0000-000000000903',
partnerInvoiceItem: '00000000-0000-0000-0000-000000000904',
partnerInvoicePayment: '00000000-0000-0000-0000-000000000905',
};
const pool = new Pool({ connectionString: databaseUrl });
async function main() {
const client = await pool.connect();
try {
await client.query('begin');
await client.query(
`
delete from public.crm_webhook_queue
where tenant_id = $1
and (
record_id in ($2::text, $3::text, $4::text, $5::text)
or lead_id in (
select id::text
from public.referral_leads
where tenant_id = $1
and student_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
)
)
`,
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
delete from public.referral_qrcodes
where tenant_id = $1
and user_id in ($2::uuid, $3::uuid, $4::uuid)
`,
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
delete from public.referral_tracks
where tenant_id = $1
and (
target_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
or ref_user_id in ($3::uuid, $4::uuid, $5::uuid)
)
`,
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
delete from public.referral_leads
where tenant_id = $1
and student_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid)
`,
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
delete from public.referral_team_edges
where tenant_id = $1
and (
member_user_id in ($2::uuid, $3::uuid, $4::uuid)
or leader_user_id in ($2::uuid, $3::uuid, $4::uuid)
)
`,
[tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values ($1, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
updated_at = now()
`,
[ids.user],
);
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values ($1, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantAdminUser],
);
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values
($1, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
($2, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
($3, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', '13800000000', '13800000000')
on conflict (provider, provider_subject)
do update set user_id = excluded.user_id,
phone = excluded.phone,
updated_at = now()
`,
[ids.user],
);
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status)
values ($1, $2, 'student', 'active')
on conflict (tenant_id, user_id, role)
do update set status = 'active', updated_at = now()
`,
[tenantId, ids.user],
);
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values ($1, $2, 'tenant_admin', 'active', '{"content:*":true}'::jsonb)
on conflict (tenant_id, user_id, role)
do update set status = 'active',
permissions = excluded.permissions,
updated_at = now()
`,
[tenantId, ids.tenantAdminUser],
);
await client.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values ($1, $2, '{"totalAnswered":0,"correctCount":0,"wrongCount":0,"studyDays":1}'::jsonb, '{}'::jsonb)
on conflict (tenant_id, user_id) do nothing
`,
[tenantId, ids.user],
);
await client.query(
`
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
values ($1, $2, 'smoke-region', '烟测地区', 'SMOKE', 1, true)
on conflict (id)
do update set name = excluded.name,
code = excluded.code,
updated_at = now()
`,
[ids.region, tenantId],
);
await client.query(
`
insert into public.subjects (id, tenant_id, region_id, legacy_id, name, type, sort_order, is_active)
values ($1, $2, $3, 'smoke-subject', '烟测科目', 'cultural', 1, true)
on conflict (id)
do update set name = excluded.name,
region_id = excluded.region_id,
updated_at = now()
`,
[ids.subject, tenantId, ids.region],
);
await client.query(
`
insert into public.categories (id, tenant_id, subject_id, legacy_id, name, category_type, sort_order, is_active)
values ($1, $2, $3, 'smoke-category', '烟测章节', 'chapter', 1, true)
on conflict (id)
do update set name = excluded.name,
subject_id = excluded.subject_id,
updated_at = now()
`,
[ids.category, tenantId, ids.subject],
);
await client.query(
`
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
values ($1, $2, $3, '烟测题库', 'tenant', 'active', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set name = excluded.name,
region_id = excluded.region_id,
updated_at = now()
`,
[ids.questionBank, tenantId, ids.region],
);
await client.query(
`
insert into public.questions (
id, tenant_id, question_bank_id, subject_id, category_id,
legacy_id, type, type_label, difficulty, status
)
values ($1, $2, $3, $4, $5, 'smoke-question', 'choice', '单选题', 1, 'published')
on conflict (id)
do update set question_bank_id = excluded.question_bank_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
updated_at = now()
`,
[ids.question, tenantId, ids.questionBank, ids.subject, ids.category],
);
await client.query(
`
insert into public.question_versions (
id, tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text, explanation
)
values (
$1, $2, $3, 1, '1 + 1 = ?',
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
1, '[1]'::jsonb, '2', '基础加法。'
)
on conflict (question_id, version_no)
do update set content = excluded.content,
options = excluded.options,
correct_option_index = excluded.correct_option_index,
correct_option_indices = excluded.correct_option_indices,
answer_text = excluded.answer_text,
explanation = excluded.explanation
`,
[ids.questionVersion, tenantId, ids.question],
);
await client.query(
`
update public.questions
set current_version_id = $3, has_video_explanation = true, updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, ids.question, ids.questionVersion],
);
await client.query(
`
insert into public.video_explanations (
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
sort_order, is_active
)
values (
$1, $2, 'smoke-video', '烟测题目视频讲解', '用于验证题目视频 API',
'https://example.test/videos/smoke.mp4', 'https://example.test/videos/smoke.jpg',
180, '["基础加法","烟测"]'::jsonb, true, $3, 1, 1, true
)
on conflict (id)
do update set title = excluded.title,
video_url = excluded.video_url,
subject_id = excluded.subject_id,
is_active = true,
updated_at = now()
`,
[ids.video, tenantId, ids.subject],
);
await client.query(
`
insert into public.question_videos (
id, tenant_id, question_id, video_id, legacy_id, video_type, sort_order
)
values ($1, $2, $3, $4, 'smoke-question-video', 'specific', 1)
on conflict (id)
do update set question_id = excluded.question_id,
video_id = excluded.video_id,
updated_at = now()
`,
[ids.questionVideo, tenantId, ids.question, ids.video],
);
await client.query(
`
insert into public.svip_plans (
id, tenant_id, region_id, legacy_id, name, price_cents, original_price_cents,
days, description, per_day_label, badge, recommended, sort_order, is_active
)
values ($1, $2, $3, 'smoke-plan', '烟测月卡', 990, 1990, 30, '本地 smoke 套餐', '0.33/天', 'SMOKE', true, 1, true)
on conflict (id)
do update set name = excluded.name,
price_cents = excluded.price_cents,
days = excluded.days,
region_id = excluded.region_id,
updated_at = now()
`,
[ids.plan, tenantId, ids.region],
);
await client.query(
`
insert into public.orders (
id, tenant_id, user_id, legacy_id, order_no, status, product_type, product_name,
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload
)
values ($1, $2, $3, 'smoke-order', 'SMOKE-ORDER-20260621', 'pending', 'svip', '烟测月卡', 990, 'manual', 'manual', $4, 30, $5, '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set status = excluded.status,
amount_cents = excluded.amount_cents,
updated_at = now()
`,
[ids.order, tenantId, ids.user, ids.plan, ids.region],
);
await client.query(
`
insert into public.payments (
id, tenant_id, order_id, provider, method, status, amount_cents, raw_payload
)
values ($1, $2, $3, 'manual', 'manual', 'pending', 990, '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set status = excluded.status,
amount_cents = excluded.amount_cents,
updated_at = now()
`,
[ids.payment, tenantId, ids.order],
);
await client.query(
`
insert into public.activation_codes (
id, tenant_id, legacy_id, code, days, is_used, sale_type, unit_price_cents, used_region_id, remark
)
values ($1, $2, 'smoke-code', 'SMOKE20260621', 30, false, 'smoke', 0, $3, '本地 smoke 激活码')
on conflict (id)
do update set is_used = false,
used_by = null,
used_at = null,
used_region_id = excluded.used_region_id,
updated_at = now()
`,
[ids.activationCode, tenantId, ids.region],
);
await client.query(
`
insert into public.vocabulary_units (
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
)
values ($1, $2, $3, 'smoke-vocab-unit', '烟测单词单元', '本地 smoke 背单词单元', 1, 1, true)
on conflict (id)
do update set name = excluded.name,
region_id = excluded.region_id,
word_count = excluded.word_count,
updated_at = now()
`,
[ids.vocabularyUnit, tenantId, ids.region],
);
await client.query(
`
insert into public.vocabulary_words (
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
example, example_translation, difficulty, tags, sort_order, is_active
)
values (
$1, $2, $3, 'smoke-word', 'abandon', '/əˈbændən/', '放弃',
'Do not abandon your plan.', '不要放弃你的计划。', 1,
'["smoke","basic"]'::jsonb, 1, true
)
on conflict (id)
do update set word = excluded.word,
unit_id = excluded.unit_id,
meaning = excluded.meaning,
updated_at = now()
`,
[ids.vocabularyWord, tenantId, ids.vocabularyUnit],
);
await client.query(
`
insert into public.user_word_progress (
tenant_id, user_id, word_id, status, correct_count, wrong_count,
last_review_date, next_review_date
)
values ($1, $2, $3, 'learning', 1, 0, now(), now() + interval '1 day')
on conflict (tenant_id, user_id, word_id)
do update set status = excluded.status,
correct_count = excluded.correct_count,
wrong_count = excluded.wrong_count,
last_review_date = excluded.last_review_date,
next_review_date = excluded.next_review_date,
updated_at = now()
`,
[tenantId, ids.user, ids.vocabularyWord],
);
await client.query(
`
insert into public.user_word_favorites (tenant_id, user_id, word_id, note, favorited_at)
values ($1, $2, $3, null, now())
on conflict (tenant_id, user_id, word_id)
do update set favorited_at = coalesce(public.user_word_favorites.favorited_at, now()),
updated_at = now()
`,
[tenantId, ids.user, ids.vocabularyWord],
);
await client.query(
`
insert into public.scoreline_schools (
id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order
)
values ($1, $2, $3, 'smoke-score-school', '烟测学院', '烟测学院', '普通', true, 1)
on conflict (id)
do update set name = excluded.name,
region_id = excluded.region_id,
updated_at = now()
`,
[ids.scorelineSchool, tenantId, ids.region],
);
await client.query(
`
insert into public.scoreline_majors (
id, tenant_id, region_id, school_id, legacy_id, name, sort_order
)
values ($1, $2, $3, $4, 'smoke-score-major', '计算机科学与技术', 1)
on conflict (id)
do update set name = excluded.name,
school_id = excluded.school_id,
updated_at = now()
`,
[ids.scorelineMajor, tenantId, ids.region, ids.scorelineSchool],
);
await client.query(
`
insert into public.scoreline_fields (
id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
unit, is_filter, is_required, is_visible, is_trend, sort_order
)
values ($1, $2, $3, 'smoke-score-field', 'minScore', '最低录取分', 'number', '分', true, false, true, true, 1)
on conflict (id)
do update set field_name = excluded.field_name,
is_trend = excluded.is_trend,
updated_at = now()
`,
[ids.scorelineField, tenantId, ids.region],
);
await client.query(
`
insert into public.scoreline_records (
id, tenant_id, region_id, school_id, major_id, legacy_id,
year, school_name, major_name, field_values
)
values (
$1, $2, $3, $4, $5, 'smoke-score-record',
2026, '烟测学院', '计算机科学与技术',
'{"minScore": 188, "enrollmentCount": 80}'::jsonb
)
on conflict (id)
do update set year = excluded.year,
field_values = excluded.field_values,
updated_at = now()
`,
[ids.scorelineRecord, tenantId, ids.region, ids.scorelineSchool, ids.scorelineMajor],
);
await client.query(
`
insert into public.recent_practices (
id, tenant_id, user_id, legacy_id, practice_type, target_legacy_id,
target_name, progress, color, last_access_at, last_practice_at, metadata
)
values (
$1, $2, $3, 'smoke-recent-practice', 'question',
'smoke-category', '烟测章节', 20, '#2563eb', now(), now(),
'{"source":"smoke-seed"}'::jsonb
)
on conflict (id)
do update set progress = excluded.progress,
last_access_at = excluded.last_access_at,
last_practice_at = excluded.last_practice_at,
updated_at = now()
`,
[ids.recentPractice, tenantId, ids.user],
);
await client.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values (
$1,
'smoke-partner',
'烟测合作商',
'烟测合作商有限公司',
'active',
'saas',
'active',
'{"source":"smoke-seed","contact":"partner"}'::jsonb
)
on conflict (id)
do update set name = excluded.name,
legal_name = excluded.legal_name,
status = excluded.status,
billing_status = excluded.billing_status,
updated_at = now()
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
values ($1, '烟测合作商题库', '合作商题库', '本地 SaaS 烟测租户')
on conflict (tenant_id)
do update set brand_name = excluded.brand_name,
short_name = excluded.short_name,
slogan = excluded.slogan,
updated_at = now()
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
values (
$1,
'{"enableStore":true,"enableVocabulary":true}'::jsonb,
'{"enableQuestionCRUD":true,"enableMarketing":true}'::jsonb,
'{"appUrl":"http://smoke-partner.localhost"}'::jsonb
)
on conflict (tenant_id)
do update set feature_flags = excluded.feature_flags,
admin_feature_flags = excluded.admin_feature_flags,
public_config = excluded.public_config,
updated_at = now()
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary)
values ($1, 'smoke-partner.localhost', 'custom', 'active', true)
on conflict (host)
do update set tenant_id = excluded.tenant_id,
status = excluded.status,
is_primary = excluded.is_primary,
updated_at = now()
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone,
contact_email, invoice_title, invoice_type
)
values (
$1,
'烟测合作商有限公司',
'91120000SMOKE',
'Smoke Partner',
'13900009999',
'partner@example.test',
'烟测合作商有限公司',
'normal_vat'
)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
updated_at = now()
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_subscriptions (
id, tenant_id, plan_code, status, starts_at, expires_at,
billing_cycle, amount_cents, metadata
)
values (
$1, $2, 'starter_yearly', 'active',
'2026-06-21T00:00:00Z', '2027-06-21T00:00:00Z',
'yearly', 980000, '{"source":"smoke-seed"}'::jsonb
)
on conflict (id)
do update set status = excluded.status,
expires_at = excluded.expires_at,
amount_cents = excluded.amount_cents,
updated_at = now()
`,
[ids.partnerSubscription, ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, discount_cents, tax_cents, total_cents,
paid_cents, balance_cents, billing_period_start, billing_period_end,
due_date, issued_at, note, metadata
)
values (
$1, $2, 'BILL-SMOKE-20260621', 'subscription', 'paid', 'CNY',
980000, 0, 0, 980000, 980000, 0,
'2026-06-21', '2027-06-21', '2026-06-30',
'2026-06-21T00:00:00Z', '烟测合作商年费', '{"source":"smoke-seed"}'::jsonb
)
on conflict (id)
do update set status = excluded.status,
paid_cents = excluded.paid_cents,
balance_cents = excluded.balance_cents,
updated_at = now()
`,
[ids.partnerInvoice, ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_invoice_items (
id, tenant_id, invoice_id, item_type, description,
quantity, unit_amount_cents, amount_cents, metadata
)
values (
$1, $2, $3, 'subscription', '合作商基础版 starter_yearly',
1, 980000, 980000, '{"planCode":"starter_yearly"}'::jsonb
)
on conflict (id) do nothing
`,
[ids.partnerInvoiceItem, ids.partnerTenant, ids.partnerInvoice],
);
await client.query(
`
insert into public.tenant_invoice_payments (
id, tenant_id, invoice_id, payment_no, provider, method, status,
amount_cents, paid_at, provider_trade_no, raw_payload
)
values (
$1, $2, $3, 'PAY-SMOKE-20260621', 'manual', 'bank_transfer',
'paid', 980000, '2026-06-21T00:00:00Z', 'SMOKE-TRANSFER',
'{"source":"smoke-seed"}'::jsonb
)
on conflict (id)
do update set status = excluded.status,
amount_cents = excluded.amount_cents,
updated_at = now()
`,
[ids.partnerInvoicePayment, ids.partnerTenant, ids.partnerInvoice],
);
await client.query(
`
delete from public.tenant_usage_records
where tenant_id = $1
and period_start = '2026-06-01'
and period_end = '2026-06-30'
and metadata->>'source' = 'smoke-seed'
`,
[ids.partnerTenant],
);
await client.query(
`
insert into public.tenant_usage_records (tenant_id, metric_key, metric_value, period_start, period_end, metadata)
values
($1, 'students', 120, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb),
($1, 'questions', 860, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb),
($1, 'storage_gb', 3.5, '2026-06-01', '2026-06-30', '{"source":"smoke-seed"}'::jsonb)
`,
[ids.partnerTenant],
);
await client.query('commit');
console.log(`Smoke seed complete for tenant ${tenantId}`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

55
supabase/config.toml Normal file
View File

@@ -0,0 +1,55 @@
project_id = "tiku-saas-local"
[api]
enabled = true
port = 54321
schemas = ["public", "storage", "graphql_public"]
extra_search_path = ["public", "extensions"]
max_rows = 1000
[db]
port = 54322
shadow_port = 54320
major_version = 15
[db.pooler]
enabled = false
port = 54329
pool_mode = "transaction"
default_pool_size = 20
max_client_conn = 100
[realtime]
enabled = true
[studio]
enabled = true
port = 54323
api_url = "http://127.0.0.1:54321"
[inbucket]
enabled = true
port = 54324
smtp_port = 54325
pop3_port = 54326
[storage]
enabled = true
file_size_limit = "100MiB"
[auth]
enabled = true
site_url = "http://127.0.0.1:5173"
additional_redirect_urls = ["http://127.0.0.1:5173", "http://localhost:5173"]
jwt_expiry = 604800
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
enable_signup = true
[edge_runtime]
enabled = true
policy = "oneshot"
inspector_port = 8083
[analytics]
enabled = false

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 $$;

47
supabase/seed.sql Normal file
View File

@@ -0,0 +1,47 @@
insert into public.tenants (id, slug, name, status, mode)
values (
'00000000-0000-0000-0000-000000000001',
'master',
'升本刷题通主租户',
'active',
'platform_owned'
)
on conflict (id) do nothing;
insert into public.tenant_domains (tenant_id, host, domain_type, status, is_primary)
values (
'00000000-0000-0000-0000-000000000001',
'localhost',
'system',
'active',
true
)
on conflict (host) do nothing;
insert into public.tenant_branding (tenant_id, brand_name, short_name, slogan)
values (
'00000000-0000-0000-0000-000000000001',
'升本刷题通',
'刷题通',
'多租户专升本题库 SaaS'
)
on conflict (tenant_id) do nothing;
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
values (
'00000000-0000-0000-0000-000000000001',
'{"enableStore":true,"enableLeaderboard":true,"enableVocabulary":true,"enableHandbook":true,"enableScoreline":true}'::jsonb,
'{"enableTenantManagement":true,"enableQuestionCRUD":true,"enableRevenueLedger":true,"enableMarketing":true}'::jsonb,
'{"examDate":"","appUrl":"http://127.0.0.1:5173"}'::jsonb
)
on conflict (tenant_id) do nothing;
insert into public.tenant_auth_providers (tenant_id, provider, status, display_name, config_public)
values (
'00000000-0000-0000-0000-000000000001',
'mock',
'testing',
'本地模拟短信',
'{"channel":"local-dev"}'::jsonb
)
on conflict (tenant_id, provider) do nothing;