10 Commits

Author SHA1 Message Date
f219eb0bf8 feat: migrate backend foundation to NestJS 2026-07-17 17:41:41 +08:00
Codex
39f7332f33 feat: establish production SaaS foundation 2026-07-12 19:26:57 +08:00
Codex
1c2ce38cea fix: align SMS expiry with PNVS valid time 2026-07-04 00:47:36 +08:00
Codex
0efdb0660c chore: add legacy SMS provider cleanup helper 2026-07-04 00:43:59 +08:00
Codex
ca482f7feb fix: reject legacy SMS auth providers in production 2026-07-04 00:39:56 +08:00
Codex
c71768fdb0 fix: block legacy SMS auth providers in readiness 2026-07-04 00:36:28 +08:00
Codex
ae491f592b fix: validate PNVS provider rows for alias env 2026-07-04 00:32:31 +08:00
Codex
3a10daf847 fix: normalize PNVS SMS provider aliases 2026-07-04 00:27:56 +08:00
Codex
a6ffb6b962 fix: require PNVS SMS provider in production 2026-07-04 00:24:57 +08:00
Codex
6cf92358e0 fix: make admin sms code input visible 2026-07-04 00:20:40 +08:00
253 changed files with 23630 additions and 2979 deletions

View File

@@ -9,12 +9,36 @@ NODE_ENV=development
# API 服务端口
PORT=8787
API_HEADERS_TIMEOUT_MS=15000
API_REQUEST_TIMEOUT_MS=120000
API_KEEP_ALIVE_TIMEOUT_MS=5000
API_SHUTDOWN_GRACE_PERIOD_MS=30000
API_MAX_REQUESTS_PER_SOCKET=1000
# Supabase/PostgreSQL 重构 API
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
# 本地 postgres 可留空;生产 API 必须为 tiku_apiworker 必须为 tiku_worker。
DB_EXPECTED_RUNTIME_ROLE=
# 不要在 .env 中持久化 SMOKE_SEED_CONFIRM破坏性测试只通过受控 npm script/CLI 临时确认。
# API/worker 各自进程使用。生产建议 API 10-20、worker 4-8并结合 PgBouncer/连接池总量核算。
DB_POOL_MAX=10
DB_CONNECTION_TIMEOUT_MS=5000
DB_QUERY_TIMEOUT_MS=35000
DB_STATEMENT_TIMEOUT_MS=30000
DB_LOCK_TIMEOUT_MS=5000
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
DB_IDLE_TIMEOUT_MS=30000
DB_POOL_MAX_USES=7500
DB_POOL_MAX_LIFETIME_SECONDS=1800
DB_APPLICATION_NAME=tiku-local
DEFAULT_TENANT_SLUG=master
# 生产必须只写真实 HTTPS 域名,不能包含 *
# 生产只列中央平台/运维的固定 HTTPS Origin,不能包含 *
# 各租户 H5 Origin 从 active tenant_domains + active tenants 动态准入,不要把数百个租户域名展开到此列表。
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
CORS_TENANT_DOMAINS_ENABLED=false
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
MAX_JSON_BODY_BYTES=1048576
MAX_IMPORT_JSON_BODY_BYTES=10485760
@@ -32,6 +56,11 @@ AUTH_JWT_SECRET=development-jwt-secret-change-me
AUTH_JWT_JWKS_URL=
AUTH_CODE_TTL_SECONDS=300
AUTH_SMS_COOLDOWN_SECONDS=60
AUTH_SMS_TENANT_DAILY_LIMIT=20000
AUTH_SMS_PHONE_DAILY_LIMIT=10
# 校园/运营商 NAT 会共享 IPIP 配额应宽于手机号/设备配额。
AUTH_SMS_IP_HOURLY_LIMIT=120
AUTH_SMS_DEVICE_HOURLY_LIMIT=10
AUTH_SESSION_TTL_SECONDS=604800
# 远程 Auth/JWKS 验收脚本配置。只在预生产/生产验收命令行临时设置真实 access token
@@ -113,6 +142,8 @@ WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
# Worker 配置:大批量内容导入
WORKER_IMPORT_BATCH_SIZE=5
WORKER_IMPORT_ID=imports-1
WORKER_IMPORT_LEASE_SECONDS=120
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800
# Worker 配置:公共题库自动同步。冲突会保留租户自改题目并等待后台处理。

4
.gitignore vendored
View File

@@ -30,6 +30,7 @@ scripts/satellite/sync-config.json
# ✅ IDE / AI 工具配置
.qoder/
.codex-backups/
/.codegraph/
# ✅ 补丁与压缩包
*.patch
@@ -83,6 +84,9 @@ whisper_models/
# ✅ 部署脚本本地缓存(记录 package-lock 哈希)
.deploy-cache/
/deploy.env
/.deploy.env
/shared/
# ✅ PocketBase 运行期产出(不入 git
pb_data/

984
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
FROM node:20-alpine AS deps
ARG NODE_IMAGE=node:20.20.2-alpine3.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
@@ -9,7 +11,11 @@ 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
FROM deps AS production-deps
RUN npm prune --omit=dev --workspaces --include-workspace-root \
&& npm cache clean --force
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
@@ -17,18 +23,13 @@ COPY apps/api ./apps/api
COPY packages ./packages
RUN npm run build:api
FROM node:20-alpine AS runner
FROM ${NODE_IMAGE} 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
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist
EXPOSE 8787
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]
USER node
CMD ["node", "apps/api/dist/apps/api/src/server.js"]

View File

@@ -10,11 +10,20 @@
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-fastify": "^11.1.28",
"@nestjs/swagger": "^11.4.6",
"@scalar/nestjs-api-reference": "^1.0.31",
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"jose": "^6.2.3",
"pg": "^8.16.3",
"read-excel-file": "^9.2.0"
"read-excel-file": "^9.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node": "^24.0.4",

View File

@@ -43,9 +43,11 @@ export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
export async function findUserBySessionToken(token: string) {
type QueryOne = <T = unknown>(sql: string, params?: unknown[]) => Promise<T | null>;
export async function findUserBySessionToken(token: string, queryUser: QueryOne = queryOne) {
const tokenHash = hashSessionToken(token);
return queryOne<SessionIdentity>(
return queryUser<SessionIdentity>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
@@ -55,10 +57,24 @@ export async function findUserBySessionToken(token: string) {
u.platform_permissions as "platformPermissions"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
join public.tenants t on t.id = s.tenant_id
where s.token_hash = $1
and u.status = 'active'
and s.revoked_at is null
and s.expires_at > now()
and (
u.primary_role = 'platform_admin'
or (
t.status = 'active'
and exists (
select 1
from public.tenant_memberships tm
where tm.tenant_id = s.tenant_id
and tm.user_id = s.user_id
and tm.status = 'active'
)
)
)
limit 1
`,
[tokenHash],
@@ -105,66 +121,48 @@ function tenantClaimFrom(payload: JWTPayload) {
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
}
function appRoleClaimFrom(payload: JWTPayload) {
const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role;
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
}
function sessionExpiryFrom(payload: JWTPayload) {
return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString();
}
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
let payload: JWTPayload;
try {
payload = await verifySupabaseJwt(token);
} catch {
return null;
}
export async function findUserByVerifiedSupabasePayload(
payload: JWTPayload,
requestedTenantContext = '',
queryUser: QueryOne = queryOne,
) {
const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : '';
if (!authUserId) return null;
const tenantClaim = tenantClaimFrom(payload);
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
const requestedTenantId = tenantClaim || requestedTenantContext;
const appRole = appRoleClaimFrom(payload);
const platformUser = await queryOne<SessionIdentity>(
const platformUser = await queryUser<SessionIdentity>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
coalesce($2::uuid, tm.tenant_id) as "tenantId",
null::uuid as "tenantId",
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
$2::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId",
u.platform_permissions as "platformPermissions"
from public.platform_users u
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
where u.auth_user_id = $1::uuid
and u.status = 'active'
and u.primary_role = 'platform_admin'
and ($2::uuid is null or exists (
select 1
from public.tenant_memberships scoped_tm
where scoped_tm.user_id = u.id
and scoped_tm.tenant_id = $2::uuid
and scoped_tm.status = 'active'
))
order by tm.created_at asc nulls last
limit 1
`,
[authUserId, requestedTenantId || null, sessionExpiryFrom(payload)],
[authUserId, sessionExpiryFrom(payload)],
);
if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) {
return platformUser;
}
// Supabase's top-level role is normally "authenticated". Platform authority
// comes exclusively from the active database user, never from JWT role claims.
if (platformUser) return platformUser;
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
if (!requestedTenantId) return null;
return queryOne<SessionIdentity>(
return queryUser<SessionIdentity>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
@@ -176,8 +174,10 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
u.platform_permissions as "platformPermissions"
from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id
join public.tenants t on t.id = tm.tenant_id
where u.auth_user_id = $1::uuid
and u.status = 'active'
and t.status = 'active'
and tm.status = 'active'
and tm.tenant_id = $2::uuid
order by case
@@ -192,6 +192,16 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
);
}
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
let payload: JWTPayload;
try {
payload = await verifySupabaseJwt(token);
} catch {
return null;
}
return findUserByVerifiedSupabasePayload(payload, requestedTenantContext);
}
export async function hydrateRequestAuth(ctx: RequestContext) {
const cached = requestAuthState.get(ctx);
if (cached?.sessionResolved) return cached;

View File

@@ -6,6 +6,10 @@ export interface ApiConfig {
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
corsTenantDomainsEnabled: boolean;
corsTenantDomainCacheTtlMs: number;
corsTenantDomainNegativeCacheTtlMs: number;
corsTenantDomainCacheMaxEntries: number;
maxJsonBodyBytes: number;
maxImportJsonBodyBytes: number;
authCodePepper: string;
@@ -17,7 +21,16 @@ export interface ApiConfig {
authJwtJwksUrl: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSmsTenantDailyLimit: number;
authSmsPhoneDailyLimit: number;
authSmsIpHourlyLimit: number;
authSmsDeviceHourlyLimit: number;
authSessionTtlSeconds: number;
apiHeadersTimeoutMs: number;
apiRequestTimeoutMs: number;
apiKeepAliveTimeoutMs: number;
apiShutdownGracePeriodMs: number;
apiMaxRequestsPerSocket: number;
allowLegacyAuthHeaders: boolean;
allowPlatformAdminKey: boolean;
platformAdminApiKey: string;
@@ -54,16 +67,9 @@ const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
const PRODUCTION_SMS_PROVIDERS = new Set([
'aliyun',
'aliyun-sms',
'aliyun_sms',
'aliyun-pnvs',
'aliyun_pnvs',
'aliyun-pnvs-sms',
'aliyun-sms-auth',
'aliyun_sms_auth',
'tencent',
'tencent-sms',
'tencent_sms',
]);
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
@@ -73,6 +79,12 @@ function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BOD
return Math.min(Math.trunc(value), hardMax);
}
function boundedPositiveNumber(key: string, fallback: number, min: number, max: number) {
const value = envNumber(key, fallback);
if (!Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(max, Math.trunc(value)));
}
function isUnsafeSecret(value: string, defaultValue: string) {
const normalized = value.trim().toLowerCase();
return (
@@ -107,13 +119,37 @@ function isAllowedHost(value: string, allowedHosts: string[]) {
return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`));
}
function isProductionCorsOrigin(value: string) {
try {
const parsed = new URL(value.trim());
const host = parsed.hostname.toLowerCase();
const localHost = isLocalHost(host) || host.endsWith('.localhost') || /^127\./.test(host);
return (
parsed.protocol === 'https:'
&& !parsed.username
&& !parsed.password
&& parsed.pathname === '/'
&& !parsed.search
&& !parsed.hash
&& Boolean(host)
&& !localHost
);
} catch {
return false;
}
}
function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.isProduction) return;
const failures: string[] = [];
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase())) {
failures.push('AUTH_SMS_PROVIDER must be aliyun/aliyun-sms, aliyun-pnvs, or tencent/tencent-sms in production');
if (nextConfig.corsOrigins.length === 0 || nextConfig.corsOrigins.some(origin => !isProductionCorsOrigin(origin))) {
failures.push('CORS_ORIGIN must contain only production HTTPS origins without paths, query strings or credentials');
}
if (!nextConfig.corsTenantDomainsEnabled) failures.push('CORS_TENANT_DOMAINS_ENABLED must be true in production');
if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase().replace(/[_\s]/g, '-'))) {
failures.push('AUTH_SMS_PROVIDER must be aliyun-pnvs in production');
}
if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) {
failures.push('AUTH_CODE_PEPPER must be a strong production secret');
@@ -192,6 +228,10 @@ const loadedConfig: ApiConfig = {
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
corsOrigins: envList('CORS_ORIGIN', '*'),
corsTenantDomainsEnabled: envBoolean('CORS_TENANT_DOMAINS_ENABLED', false),
corsTenantDomainCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_TTL_MS', 60_000, 1_000, 600_000),
corsTenantDomainNegativeCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', 10_000, 1_000, 300_000),
corsTenantDomainCacheMaxEntries: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', 10_000, 100, 100_000),
maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES),
maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES),
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
@@ -203,7 +243,16 @@ const loadedConfig: ApiConfig = {
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSmsTenantDailyLimit: boundedPositiveNumber('AUTH_SMS_TENANT_DAILY_LIMIT', 20_000, 1, 10_000_000),
authSmsPhoneDailyLimit: boundedPositiveNumber('AUTH_SMS_PHONE_DAILY_LIMIT', 10, 1, 10_000),
authSmsIpHourlyLimit: boundedPositiveNumber('AUTH_SMS_IP_HOURLY_LIMIT', 120, 1, 1_000_000),
authSmsDeviceHourlyLimit: boundedPositiveNumber('AUTH_SMS_DEVICE_HOURLY_LIMIT', 10, 1, 100_000),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
apiHeadersTimeoutMs: boundedPositiveNumber('API_HEADERS_TIMEOUT_MS', 15_000, 1_000, 120_000),
apiRequestTimeoutMs: boundedPositiveNumber('API_REQUEST_TIMEOUT_MS', 120_000, 5_000, 600_000),
apiKeepAliveTimeoutMs: boundedPositiveNumber('API_KEEP_ALIVE_TIMEOUT_MS', 5_000, 1_000, 120_000),
apiShutdownGracePeriodMs: boundedPositiveNumber('API_SHUTDOWN_GRACE_PERIOD_MS', 30_000, 1_000, 300_000),
apiMaxRequestsPerSocket: boundedPositiveNumber('API_MAX_REQUESTS_PER_SOCKET', 1_000, 1, 100_000),
allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction),
allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),

225
apps/api/src/core/cors.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { queryOne } from './db.js';
import { isLocalTenantHost } from '../features/tenant/locator.js';
export type CorsDecisionReason =
| 'no-origin'
| 'wildcard'
| 'static-origin'
| 'tenant-domain'
| 'invalid-origin'
| 'tenant-domain-disabled'
| 'tenant-domain-lookup-failed';
export interface CorsDecision {
allowed: boolean;
allowOrigin: string | null;
reason: CorsDecisionReason;
}
export interface CorsPolicyOptions {
staticOrigins: string[];
tenantDomainsEnabled: boolean;
positiveCacheTtlMs: number;
negativeCacheTtlMs: number;
maxCacheEntries: number;
lookupTenantDomain?: (host: string) => Promise<boolean>;
now?: () => number;
onLookupError?: (error: unknown, host: string) => void;
}
interface NormalizedOrigin {
origin: string;
hostname: string;
protocol: 'http:' | 'https:';
hasNonDefaultPort: boolean;
}
interface CachedTenantDomain {
allowed: boolean;
expiresAt: number;
lookupFailed: boolean;
}
const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key';
function firstHeader(req: IncomingMessage, name: string) {
if (name.toLowerCase() === 'origin') {
const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin');
if (originHeaders.length > 1) return '__multiple_origin_headers__';
}
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value[0] || '';
return value || '';
}
function addVaryHeader(res: ServerResponse, value: string) {
const current = res.getHeader('vary');
const values = (Array.isArray(current) ? current : String(current || '').split(','))
.map(item => String(item).trim())
.filter(Boolean);
if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value);
res.setHeader('vary', values.join(', '));
}
export function normalizeCorsOrigin(value: string): NormalizedOrigin | null {
const raw = value.trim();
if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null;
try {
const parsed = new URL(raw);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
if (!parsed.hostname || parsed.hostname.endsWith('.')) return null;
const protocol = parsed.protocol as 'http:' | 'https:';
const defaultPort = protocol === 'https:' ? '443' : '80';
return {
origin: parsed.origin.toLowerCase(),
hostname: parsed.hostname.toLowerCase(),
protocol,
hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort),
};
} catch {
return null;
}
}
async function lookupActiveTenantDomain(host: string) {
const row = await queryOne<{ allowed: boolean }>(
`
select exists (
select 1
from public.tenant_domains d
join public.tenants t on t.id = d.tenant_id
where d.host = $1
and d.status = 'active'
and t.status = 'active'
) as allowed
`,
[host],
);
return row?.allowed === true;
}
export class CorsPolicy {
private readonly staticOrigins: Set<string>;
private readonly allowAll: boolean;
private readonly tenantDomainsEnabled: boolean;
private readonly positiveCacheTtlMs: number;
private readonly negativeCacheTtlMs: number;
private readonly maxCacheEntries: number;
private readonly lookupTenantDomain: (host: string) => Promise<boolean>;
private readonly now: () => number;
private readonly onLookupError?: (error: unknown, host: string) => void;
private readonly cache = new Map<string, CachedTenantDomain>();
private readonly pendingLookups = new Map<string, Promise<{ allowed: boolean; lookupFailed: boolean }>>();
constructor(options: CorsPolicyOptions) {
this.allowAll = options.staticOrigins.includes('*');
this.staticOrigins = new Set(
options.staticOrigins
.filter(origin => origin !== '*')
.map(origin => normalizeCorsOrigin(origin)?.origin || '')
.filter(Boolean),
);
this.tenantDomainsEnabled = options.tenantDomainsEnabled;
this.positiveCacheTtlMs = options.positiveCacheTtlMs;
this.negativeCacheTtlMs = options.negativeCacheTtlMs;
this.maxCacheEntries = options.maxCacheEntries;
this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain;
this.now = options.now || Date.now;
this.onLookupError = options.onLookupError;
}
async evaluate(rawOrigin: string): Promise<CorsDecision> {
if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' };
const normalized = normalizeCorsOrigin(rawOrigin);
if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' };
if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' };
if (this.staticOrigins.has(normalized.origin)) {
return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' };
}
// Tenant browser domains are production HTTPS hosts. Development ports belong
// in the explicit static allowlist and must never be inferred from Host headers.
if (
!this.tenantDomainsEnabled
|| normalized.protocol !== 'https:'
|| normalized.hasNonDefaultPort
|| isLocalTenantHost(normalized.hostname)
) {
return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' };
}
const lookup = await this.lookupWithCache(normalized.hostname);
if (!lookup.allowed) {
return {
allowed: false,
allowOrigin: null,
reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled',
};
}
return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' };
}
private async lookupWithCache(host: string) {
const now = this.now();
const cached = this.cache.get(host);
if (cached && cached.expiresAt > now) {
this.cache.delete(host);
this.cache.set(host, cached);
return { allowed: cached.allowed, lookupFailed: cached.lookupFailed };
}
if (cached) this.cache.delete(host);
const pending = this.pendingLookups.get(host);
if (pending) return pending;
const lookup = this.performLookup(host);
this.pendingLookups.set(host, lookup);
try {
return await lookup;
} finally {
this.pendingLookups.delete(host);
}
}
private async performLookup(host: string) {
let allowed = false;
let lookupFailed = false;
try {
allowed = await this.lookupTenantDomain(host);
} catch (error) {
lookupFailed = true;
this.onLookupError?.(error, host);
}
const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs;
this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed });
return { allowed, lookupFailed };
}
private storeCache(host: string, entry: CachedTenantDomain) {
this.cache.delete(host);
while (this.cache.size >= this.maxCacheEntries) {
const oldest = this.cache.keys().next().value as string | undefined;
if (!oldest) break;
this.cache.delete(oldest);
}
this.cache.set(host, entry);
}
}
export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) {
const decision = await policy.evaluate(firstHeader(req, 'origin'));
res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS);
res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS);
res.setHeader('access-control-expose-headers', 'x-request-id');
if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin);
if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin');
return decision;
}

View File

@@ -4,7 +4,7 @@ import { DEFAULT_DATABASE_URL } from '../../../../packages/config/src/index.js';
export const pool = createPool({
connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
max: 10,
applicationName: 'tiku-api',
});
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {

View File

@@ -9,10 +9,30 @@ export interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
url: URL;
requestId: string;
parsedBody?: unknown;
}
export type Handler = (ctx: RequestContext) => Promise<unknown>;
export interface ApiResponseMeta {
requestId: string;
}
export function withResponseMeta(body: unknown, requestId: string) {
if (body && typeof body === 'object' && !Array.isArray(body)) {
const record = body as Record<string, unknown>;
const existingMeta = record.meta && typeof record.meta === 'object' && !Array.isArray(record.meta)
? record.meta as Record<string, unknown>
: {};
return {
...record,
meta: { ...existingMeta, requestId },
};
}
return { data: body, meta: { requestId } };
}
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader('content-type', 'application/json; charset=utf-8');
@@ -25,19 +45,6 @@ export function getHeader(req: IncomingMessage, name: string): string {
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}`;
}

View File

@@ -0,0 +1,10 @@
import crypto from 'node:crypto';
import type { IncomingMessage } from 'node:http';
import { getHeader } from './http.js';
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export function requestIdFrom(req: IncomingMessage) {
const provided = getHeader(req, 'x-request-id').trim();
return REQUEST_ID_PATTERN.test(provided) ? provided : crypto.randomUUID();
}

View File

@@ -90,6 +90,16 @@ export interface ReadJsonBodyOptions {
export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise<JsonObject> {
const maxBytes = options.maxBytes ?? config.maxJsonBodyBytes;
if (ctx.parsedBody !== undefined) {
const serializedBytes = Buffer.byteLength(JSON.stringify(ctx.parsedBody));
if (serializedBytes > maxBytes) {
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');
}
if (!ctx.parsedBody || typeof ctx.parsedBody !== 'object' || Array.isArray(ctx.parsedBody)) {
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
}
return ctx.parsedBody as JsonObject;
}
const contentLength = Number(getHeader(ctx.req, 'content-length') || 0);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');

View File

@@ -28,7 +28,7 @@ export function createRouter(definitions: RouteDefinition[] = allRoutes) {
return routes;
}
const allRoutes: RouteDefinition[] = [
export const allRoutes: RouteDefinition[] = [
...healthRoutes,
...authRoutes,
...tenantRoutes,

View File

@@ -29,6 +29,7 @@ export interface SmsSendResult {
provider: SmsProviderName;
status: 'sent' | 'mocked';
verification?: 'local' | 'provider';
ttlSeconds?: number;
providerMessageId?: string;
raw?: Record<string, unknown>;
}
@@ -242,6 +243,11 @@ function optionalIntegerString(config: TenantAuthProviderConfig, keys: string[],
return fallback;
}
function positiveInteger(value: string, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
}
class AliyunPnvsSmsProvider implements SmsProvider {
readonly name = 'aliyun-pnvs' as const;
@@ -250,6 +256,7 @@ class AliyunPnvsSmsProvider implements SmsProvider {
async send(input: SmsSendInput): Promise<SmsSendResult> {
const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED');
const templateCode = requirePublicString(this.providerConfig, ['templateCode'], 'SMS_PUBLIC_CONFIG_REQUIRED');
const validTime = optionalIntegerString(this.providerConfig, ['validTime'], String(input.ttlSeconds)) || String(input.ttlSeconds);
const params: Record<string, string> = {
CountryCode: optionalPublicString(this.providerConfig, ['countryCode']) || '86',
PhoneNumber: input.phone,
@@ -259,7 +266,7 @@ class AliyunPnvsSmsProvider implements SmsProvider {
OutId: input.outId,
CodeType: optionalIntegerString(this.providerConfig, ['codeType'], '1') || '1',
CodeLength: optionalIntegerString(this.providerConfig, ['codeLength'], '6') || '6',
ValidTime: optionalIntegerString(this.providerConfig, ['validTime'], String(input.ttlSeconds)) || String(input.ttlSeconds),
ValidTime: validTime,
DuplicatePolicy: optionalIntegerString(this.providerConfig, ['duplicatePolicy'], '1') || '1',
Interval: optionalIntegerString(this.providerConfig, ['interval'], String(input.cooldownSeconds)) || String(input.cooldownSeconds),
};
@@ -286,6 +293,7 @@ class AliyunPnvsSmsProvider implements SmsProvider {
provider: this.name,
status: 'sent',
verification: 'provider',
ttlSeconds: positiveInteger(validTime, input.ttlSeconds),
providerMessageId: typeof model.BizId === 'string' ? model.BizId : undefined,
raw: {
requestId: raw.RequestId,

View File

@@ -26,6 +26,7 @@ import {
writeLoginEvent,
type PlatformUserSummary,
} from './service.js';
import { hashSmsDeviceId, normalizeSmsDeviceId, reserveSmsSend } from './sms-limits.js';
interface SmsCodeRow {
id: string;
@@ -37,10 +38,6 @@ interface SmsCodeRow {
metadata: Record<string, unknown> | null;
}
interface CooldownRow {
createdAt: string;
}
type SmsVerifyResult =
| {
ok: false;
@@ -208,7 +205,7 @@ async function consumeSmsCode(
}
function normalizeSmsProviderName(value: string) {
const normalized = value.toLowerCase().replace(/_/g, '-');
const normalized = value.toLowerCase().replace(/[_\s]/g, '-');
if (normalized === 'aliyun-pnvs' || normalized === 'aliyun-pnvs-sms' || normalized === 'aliyun-sms-auth') return 'aliyun-pnvs';
if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun';
if (normalized === 'tencent' || normalized === 'tencent-sms') return 'tencent';
@@ -265,29 +262,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
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 deviceId = normalizeSmsDeviceId(optionalString(body, 'deviceId'));
const deviceHash = hashSmsDeviceId(deviceId);
const providerChoice = await activeSmsProvider(tenantId);
const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig);
@@ -297,47 +273,89 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
const code = generateSmsCode();
const outId = crypto.randomUUID();
const providerResult = await provider.send({
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
const reservedExpiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
const reservation = await transaction(client => reserveSmsSend(client, {
tenantId,
phone,
code,
purpose,
codeHash,
provider: provider.name,
expiresAt: reservedExpiresAt,
ipAddress,
userAgent,
deviceHash,
outId,
ttlSeconds: config.authCodeTtlSeconds,
cooldownSeconds: config.authSmsCooldownSeconds,
metadata,
});
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
}));
let providerResult;
try {
providerResult = await provider.send({
tenantId,
phone,
code,
purpose,
outId,
ttlSeconds: config.authCodeTtlSeconds,
cooldownSeconds: config.authSmsCooldownSeconds,
metadata,
});
} catch (error) {
await transaction(async client => {
await client.query(
`
update public.sms_verification_codes
set metadata = metadata || $3::jsonb
where tenant_id = $1 and id = $2 and status = 'pending'
`,
[tenantId, reservation.id, JSON.stringify({ providerSendFailedAt: new Date().toISOString() })],
);
await writeLoginEvent(client, {
tenantId,
provider: `sms:${provider.name}`,
identifier: phone,
result: 'failed',
failureCode: 'SMS_PROVIDER_SEND_FAILED',
ipAddress,
userAgent,
metadata: { purpose, reservationId: reservation.id },
});
}).catch(() => undefined);
throw error;
}
const ttlSeconds = Number.isFinite(providerResult.ttlSeconds) && Number(providerResult.ttlSeconds) > 0
? Math.trunc(Number(providerResult.ttlSeconds))
: config.authCodeTtlSeconds;
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
const item = await transaction(async client => {
const insertResult = await client.query(
const updateResult = 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)
update public.sms_verification_codes
set provider = $3,
status = 'sent',
expires_at = $4::timestamptz,
metadata = metadata || $5::jsonb
where tenant_id = $1 and id = $2 and status = 'pending'
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
`,
[
tenantId,
phone,
purpose,
codeHash,
reservation.id,
providerResult.provider,
expiresAt,
ipAddress || null,
userAgent || null,
JSON.stringify({
...metadata,
outId,
verification: providerResult.verification || 'local',
providerStatus: providerResult.status,
providerMessageId: providerResult.providerMessageId || null,
reservation: false,
}),
],
);
if (!updateResult.rows[0]) {
throw new HttpError(409, 'SMS send reservation is no longer active', 'SMS_RESERVATION_LOST');
}
await writeLoginEvent(client, {
tenantId,
@@ -349,12 +367,12 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
metadata: { purpose },
});
return insertResult.rows[0];
return updateResult.rows[0];
});
return {
item,
expireIn: config.authCodeTtlSeconds,
expireIn: ttlSeconds,
cooldown: config.authSmsCooldownSeconds,
debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
};

View File

@@ -21,7 +21,12 @@ export interface LoginSessionSummary {
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();
const remoteAddress = (ctx.req.socket.remoteAddress || '').trim();
const trustedProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remoteAddress);
if (trustedProxy) {
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || remoteAddress).trim();
}
return remoteAddress;
}
export function userAgentFrom(ctx: RequestContext) {
@@ -101,6 +106,44 @@ export async function createLoginSession(
metadata?: Record<string, unknown>;
},
): Promise<LoginSessionSummary> {
const authority = await client.query<{ tenantStatus: string; userStatus: string; primaryRole: string }>(
`
select t.status as "tenantStatus",
u.status as "userStatus",
u.primary_role as "primaryRole"
from public.tenants t
join public.platform_users u on u.id = $2::uuid
where t.id = $1::uuid
for update of t, u
`,
[input.tenantId, input.userId],
);
const authorityRow = authority.rows[0];
if (!authorityRow || authorityRow.userStatus !== 'active') {
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
}
if (authorityRow.primaryRole !== 'platform_admin') {
if (authorityRow.tenantStatus !== 'active') {
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
}
const membership = await client.query<{ status: string }>(
`
select status
from public.tenant_memberships
where tenant_id = $1::uuid
and user_id = $2::uuid
and status = 'active'
order by created_at asc
limit 1
for update
`,
[input.tenantId, input.userId],
);
if (!membership.rows[0]) {
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
}
}
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
@@ -393,15 +436,51 @@ export async function upsertOAuthUser(
}
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
await client.query(
const authority = await client.query<{ tenantStatus: string; userStatus: string }>(
`
select t.status as "tenantStatus", u.status as "userStatus"
from public.tenants t
join public.platform_users u on u.id = $2::uuid
where t.id = $1::uuid
for update of t, u
`,
[tenantId, userId],
);
const authorityRow = authority.rows[0];
if (!authorityRow || authorityRow.userStatus !== 'active') {
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
}
if (authorityRow.tenantStatus !== 'active') {
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
}
const insertedMembership = await client.query<{ status: string }>(
`
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()
do nothing
returning status
`,
[tenantId, userId],
);
const membership = insertedMembership.rows[0]
? insertedMembership
: await client.query<{ status: string }>(
`
select status
from public.tenant_memberships
where tenant_id = $1::uuid
and user_id = $2::uuid
and role = 'student'
limit 1
for update
`,
[tenantId, userId],
);
if (membership.rows[0]?.status !== 'active') {
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
}
await client.query(
`

View File

@@ -0,0 +1,216 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { config } from '../../core/config.js';
import { HttpError } from '../../core/http.js';
interface SmsSendReservation {
id: string;
createdAt: string;
}
function retryAfterSeconds(createdAt: string) {
const elapsedSeconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000);
return Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
}
function quotaError(code: string, message: string) {
return new HttpError(429, message, code);
}
function quotaScopeHash(dimension: string, value: string) {
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-quota:${dimension}:${value}`).digest('hex');
}
async function consumeQuota(
client: pg.PoolClient,
input: {
tenantId: string;
dimension: 'tenant' | 'phone' | 'ip' | 'device';
scopeValue: string;
bucket: 'hour' | 'day';
limit: number;
code: string;
message: string;
},
) {
if (!input.scopeValue) return;
const result = await client.query(
`
insert into app_private.sms_send_rate_limits (
tenant_id, dimension, scope_hash, bucket_start, request_count
)
values (
$1, $2, $3,
case
when $4 = 'day' then date_trunc('day', now() at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai'
else date_trunc('hour', now())
end,
1
)
on conflict (tenant_id, dimension, scope_hash, bucket_start)
do update set request_count = app_private.sms_send_rate_limits.request_count + 1,
updated_at = now()
where app_private.sms_send_rate_limits.request_count < $5
returning request_count
`,
[
input.tenantId,
input.dimension,
quotaScopeHash(input.dimension, input.scopeValue),
input.bucket,
input.limit,
],
);
if (!result.rows[0]) throw quotaError(input.code, input.message);
}
export function normalizeSmsDeviceId(value: string) {
const normalized = value.trim();
if (!normalized) return '';
if (normalized.length > 200 || !/^[A-Za-z0-9._:-]+$/.test(normalized)) {
throw new HttpError(400, 'Invalid device identifier', 'INVALID_DEVICE_ID');
}
return normalized;
}
export function hashSmsDeviceId(value: string) {
const normalized = normalizeSmsDeviceId(value);
if (!normalized) return '';
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-device:${normalized}`).digest('hex');
}
export async function reserveSmsSend(
client: pg.PoolClient,
input: {
tenantId: string;
phone: string;
purpose: string;
codeHash: string;
provider: string;
expiresAt: string;
ipAddress: string;
userAgent: string;
deviceHash: string;
outId: string;
metadata: Record<string, unknown>;
},
): Promise<SmsSendReservation> {
await client.query(
`select pg_advisory_xact_lock(hashtextextended($1, 0))`,
[`sms-send:${input.tenantId}:${input.phone}:${input.purpose}`],
);
await client.query(
`
with stale as (
select ctid
from app_private.sms_send_rate_limits
where updated_at < now() - interval '3 days'
order by updated_at asc
limit 32
for update skip locked
)
delete from app_private.sms_send_rate_limits limits
using stale
where limits.ctid = stale.ctid
`,
);
const existing = await client.query<{ createdAt: string }>(
`
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
`,
[input.tenantId, input.phone, input.purpose, config.authSmsCooldownSeconds],
);
if (existing.rows[0]) {
const cooldown = retryAfterSeconds(existing.rows[0].createdAt);
throw quotaError('SMS_COOLDOWN', `SMS code was sent too frequently. Retry after ${cooldown} seconds.`);
}
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
`,
[input.tenantId, input.phone, input.purpose],
);
await consumeQuota(client, {
tenantId: input.tenantId,
dimension: 'tenant',
scopeValue: input.tenantId,
bucket: 'day',
limit: config.authSmsTenantDailyLimit,
code: 'SMS_TENANT_DAILY_LIMIT',
message: 'Tenant SMS daily quota exceeded',
});
await consumeQuota(client, {
tenantId: input.tenantId,
dimension: 'phone',
scopeValue: input.phone,
bucket: 'day',
limit: config.authSmsPhoneDailyLimit,
code: 'SMS_PHONE_DAILY_LIMIT',
message: 'SMS daily quota exceeded for this phone number',
});
await consumeQuota(client, {
tenantId: input.tenantId,
dimension: 'ip',
scopeValue: input.ipAddress,
bucket: 'hour',
limit: config.authSmsIpHourlyLimit,
code: 'SMS_IP_HOURLY_LIMIT',
message: 'SMS hourly quota exceeded for this network',
});
await consumeQuota(client, {
tenantId: input.tenantId,
dimension: 'device',
scopeValue: input.deviceHash,
bucket: 'hour',
limit: config.authSmsDeviceHourlyLimit,
code: 'SMS_DEVICE_HOURLY_LIMIT',
message: 'SMS hourly quota exceeded for this device',
});
const result = await client.query<SmsSendReservation>(
`
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, 'pending', $6::timestamptz, $7, $8, $9::jsonb)
returning id, created_at as "createdAt"
`,
[
input.tenantId,
input.phone,
input.purpose,
input.codeHash,
input.provider,
input.expiresAt,
input.ipAddress || null,
input.userAgent || null,
JSON.stringify({
...input.metadata,
outId: input.outId,
deviceHash: input.deviceHash || undefined,
reservation: true,
}),
],
);
return result.rows[0];
}

View File

@@ -233,7 +233,10 @@ export async function collectionQuestionsRoute(ctx: RequestContext) {
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.question_collection_items ci
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where ci.tenant_id = $1
and ci.collection_id = $2
and q.status = 'published'

View File

@@ -314,7 +314,10 @@ export async function questionsRoute(ctx: RequestContext) {
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
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where ${filters.join(' and ')}
order by q.created_at desc
limit $${params.length}

View File

@@ -1195,7 +1195,10 @@ export async function submitAnswerRoute(ctx: RequestContext) {
q.type, v.correct_option_index, v.correct_option_indices, v.answer_text,
v.sub_questions
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
limit 1
`,
@@ -1431,7 +1434,10 @@ async function buildPracticeSessionReport(
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.sub_questions as "subQuestions"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
left join public.question_collection_items ci
on ci.tenant_id = q.tenant_id
and ci.question_id = q.id
@@ -1772,7 +1778,10 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
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
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = any($2::uuid[])
`,
[tenantId, questionIds],
@@ -2171,7 +2180,10 @@ export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
) as "suggestedReviewAt"
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
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
@@ -2222,7 +2234,10 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
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
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where fq.tenant_id = $1 and fq.user_id = $2
order by fq.created_at desc
limit $3
@@ -2276,7 +2291,10 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
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
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and 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

View File

@@ -57,6 +57,7 @@ function truncate(value: unknown, max = 1900) {
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TENANT_STATUSES = new Set(['draft', 'active', 'suspended', 'archived']);
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
@@ -701,16 +702,13 @@ export async function upsertPlatformStaffRoute(ctx: RequestContext) {
const item = await transaction(async client => {
let targetStaffId = staffId || null;
const authUser = await client.query(
const authUser = await client.query<{ exists: boolean }>(
`
select id
from auth.users
where id = $1::uuid
limit 1
select app.auth_user_exists($1::uuid) as exists
`,
[authUserId],
);
if (authUser.rowCount === 0) {
if (!authUser.rows[0]?.exists) {
throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND');
}
@@ -2241,6 +2239,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
const status = optionalString(body, 'status');
const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
if (status && !TENANT_STATUSES.has(status)) throw new HttpError(400, 'Unsupported tenant status', 'INVALID_STATUS');
const item = await transaction(async client => {
const result = await client.query(
@@ -2258,6 +2257,25 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
);
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
if (status && status !== 'active') {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $2::jsonb
where tenant_id = $1
and revoked_at is null
`,
[
tenantId,
JSON.stringify({
revokedBy: 'platform.tenant.status_updated',
tenantStatus: status,
}),
],
);
}
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
status: status || null,
billingStatus: billingStatus || null,

View File

@@ -35,6 +35,11 @@ export interface TenantAdminAuth {
dataScope: Record<string, unknown>;
}
export type TenantPermissionContext = Pick<
TenantAdminAuth,
'role' | 'permissions' | 'templatePermissions'
>;
function permissionKeys(permission: string) {
const parts = permission.split(':').filter(Boolean);
const keys = [permission];
@@ -53,15 +58,23 @@ function explicitPermission(permissions: Record<string, unknown>, permission: st
return null;
}
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
export function hasResolvedTenantPermission(
auth: TenantPermissionContext,
permission: string,
roleDefaultAllowed: boolean,
) {
const explicit = explicitPermission(auth.permissions, permission);
if (explicit !== null) return explicit;
const templateExplicit = explicitPermission(auth.templatePermissions, permission);
if (templateExplicit !== null) return templateExplicit;
return roleDefaultAllowed;
}
export function hasTenantPermission(auth: TenantPermissionContext, permission: string) {
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
return defaults.some(defaultPermission => {
const roleDefaultAllowed = defaults.some(defaultPermission => {
if (defaultPermission === '*') return true;
if (defaultPermission === permission) return true;
if (defaultPermission.endsWith(':*')) {
@@ -69,6 +82,7 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
}
return false;
});
return hasResolvedTenantPermission(auth, permission, roleDefaultAllowed);
}
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {

View File

@@ -21,6 +21,11 @@ import {
scopedSupervisionClassIds,
supervisionBatchKey,
} from './supervision.js';
import {
containsSearchPattern,
decodeTenantStudentsCursor,
encodeTenantStudentsCursor,
} from './student-cursor.js';
type JsonBody = Record<string, unknown>;
@@ -938,6 +943,7 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
const status = stringParam(ctx, 'status') || 'active';
const regionId = stringParam(ctx, 'regionId');
const classId = stringParam(ctx, 'classId');
const cursor = decodeTenantStudentsCursor(stringParam(ctx, 'cursor'));
const scopedIds = await scopedClassIds(auth);
if (classId) await ensureReadableClass(auth, classId);
if (!STUDENT_MEMBER_STATUSES.includes(status)) {
@@ -952,8 +958,13 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
}
const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2'];
if (keyword) {
params.push(`%${keyword}%`);
filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`);
params.push(containsSearchPattern(keyword));
filters.push(`(
coalesce(u.username, '') || ' ' ||
coalesce(u.name, '') || ' ' ||
coalesce(u.phone, '') || ' ' ||
coalesce(u.email::text, '')
) ilike $${params.length} escape '\\'`);
}
if (regionId) {
params.push(regionId);
@@ -981,11 +992,24 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
and scoped_cm.status = 'active'
)`);
}
params.push(limit);
if (cursor) {
params.push(cursor.createdAt, cursor.membershipId);
filters.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
}
params.push(limit + 1);
const items = await query<Record<string, unknown>>(
const rows = await query<Record<string, unknown>>(
`
with class_agg as (
with student_page as materialized (
select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
where ${filters.join(' and ')}
order by tm.created_at desc, tm.id desc
limit $${params.length}
),
class_agg as (
select tcm.tenant_id, tcm.user_id,
jsonb_agg(
jsonb_build_object(
@@ -998,13 +1022,15 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
order by tc.sort_order asc, tc.created_at desc
) filter (where tcm.status = 'active') as classes
from public.tenant_class_members tcm
join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
where tcm.tenant_id = $1 and tcm.member_type = 'student'
${classAggScopeSql}
group by tcm.tenant_id, tcm.user_id
)
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt",
tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt",
tm.updated_at as "memberUpdatedAt",
u.username, u.email::text as email, u.phone, u.name,
null::text as "avatarUrl", u.primary_role as "primaryRole",
u.last_seen_at as "lastSeenAt",
@@ -1016,21 +1042,37 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
sp.last_check_in_date as "lastCheckInDate",
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
coalesce(ca.classes, '[]'::jsonb) as classes
from public.tenant_memberships tm
from student_page tm
join public.platform_users u on u.id = tm.user_id
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
where ${filters.join(' and ')}
order by tm.created_at desc
limit $${params.length}
order by tm.created_at desc, tm.id desc
`,
params,
);
return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null };
const hasMore = rows.length > limit;
const pageItems = rows.slice(0, limit);
const lastItem = pageItems.at(-1);
const nextCursor = hasMore && lastItem
? encodeTenantStudentsCursor({
createdAt: String(lastItem.cursorCreatedAt || ''),
membershipId: String(lastItem.membershipId || ''),
})
: null;
return {
items: pageItems.map(item => {
const { cursorCreatedAt: _cursorCreatedAt, ...publicItem } = item;
return maskStudentFields(auth, publicItem);
}),
scoped: scopedIds !== null,
hasMore,
nextCursor,
};
}
export async function upsertTenantStudentRoute(ctx: RequestContext) {
@@ -1049,6 +1091,16 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) {
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
if (status !== 'active') {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(), updated_at = now()
where tenant_id = $1 and user_id = $2 and revoked_at is null
`,
[auth.tenantId, userId],
);
}
const profile = await client.query(
`
insert into public.student_profiles (
@@ -1115,6 +1167,27 @@ export async function updateTenantStudentStatusRoute(ctx: RequestContext) {
[auth.tenantId, userId, status],
);
if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND');
if (status !== 'active') {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $3::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
`,
[
auth.tenantId,
userId,
JSON.stringify({
revokedBy: 'tenant.student.status_updated',
membershipStatus: status,
}),
],
);
}
await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, {
userId,
status,
@@ -1148,6 +1221,16 @@ export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) {
await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND');
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
if (status !== 'active') {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(), updated_at = now()
where tenant_id = $1 and user_id = $2 and revoked_at is null
`,
[auth.tenantId, userId],
);
}
const profile = await client.query(
`

View File

@@ -509,7 +509,10 @@ export async function tenantFeedbackReportRoute(ctx: RequestContext) {
max(r.created_at) as "latestAt"
from public.reports r
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where r.tenant_id = $1
and r.question_id is not null
and r.created_at >= $2::timestamptz

View File

@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { config as appConfig } from '../../core/config.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import {
@@ -390,6 +391,14 @@ function defaultAuthSecretScope(provider: string): SecretScope {
return 'oauth';
}
function assertProductionAuthProviderAllowed(provider: string, status: string) {
if (!appConfig.isProduction || !['active', 'testing'].includes(status)) return;
const normalized = provider.toLowerCase().replace(/[_\s]/g, '-');
if (normalized === 'aliyun' || normalized === 'aliyun-sms' || normalized === 'tencent' || normalized === 'tencent-sms') {
throw new HttpError(400, 'Production SMS auth providers must use aliyun-pnvs', 'PRODUCTION_SMS_PROVIDER_MUST_BE_PNVS');
}
}
function parseSecretScope(value: unknown, fallback: SecretScope): SecretScope {
const candidate = (nullableString(value) || fallback) as SecretScope;
if (!SECRET_SCOPES.has(candidate)) {
@@ -1400,6 +1409,8 @@ export async function upsertAuthProviderRoute(ctx: RequestContext) {
const fallbackScope = defaultAuthSecretScope(provider);
const secretPayload = parseSecretPayload(body, fallbackScope, provider, provider);
const configPublic = objectValue(body.configPublic);
const status = optionalStatus(body.status, AUTH_STATUSES, 'disabled');
assertProductionAuthProviderAllowed(provider, status);
const item = await transaction(async client => {
const secret = secretPayload ? await upsertTenantSecret(client, auth, secretPayload) : null;
@@ -1420,7 +1431,7 @@ export async function upsertAuthProviderRoute(ctx: RequestContext) {
[
auth.tenantId,
provider,
optionalStatus(body.status, AUTH_STATUSES, 'disabled'),
status,
optionalString(body, 'displayName') || null,
publicJsonValue(configPublic),
],
@@ -2675,6 +2686,28 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) {
if (!result.rows[0]) throw new HttpError(404, 'Tenant member not found', 'TENANT_MEMBER_NOT_FOUND');
if (status !== 'active') {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $3::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
`,
[
auth.tenantId,
userId,
JSON.stringify({
revokedBy: 'tenant.member.upserted',
membershipStatus: status,
}),
],
);
}
await recordAudit(client, auth, 'tenant.member.upserted', 'tenant_memberships', result.rows[0].id, {
userId,
role,
@@ -2724,6 +2757,26 @@ export async function disableTenantMemberRoute(ctx: RequestContext) {
[auth.tenantId, membershipId],
);
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $3::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
`,
[
auth.tenantId,
result.rows[0].userId,
JSON.stringify({
revokedBy: 'tenant.member.disabled',
membershipStatus: 'disabled',
}),
],
);
await recordAudit(client, auth, 'tenant.member.disabled', 'tenant_memberships', membershipId, {
userId: result.rows[0].userId,
role: result.rows[0].role,

View File

@@ -0,0 +1,39 @@
import { HttpError } from '../../core/http.js';
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
export interface TenantStudentsCursor {
createdAt: string;
membershipId: string;
}
export function encodeTenantStudentsCursor(cursor: TenantStudentsCursor) {
return Buffer.from(JSON.stringify({
version: 1,
createdAt: cursor.createdAt,
membershipId: cursor.membershipId,
}), 'utf8').toString('base64url');
}
export function decodeTenantStudentsCursor(value: string): TenantStudentsCursor | null {
if (!value) return null;
if (value.length > 512 || !CURSOR_PATTERN.test(value)) {
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
}
try {
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : '';
const membershipId = typeof parsed.membershipId === 'string' ? parsed.membershipId : '';
if (parsed.version !== 1 || !createdAt || !Number.isFinite(Date.parse(createdAt)) || !UUID_PATTERN.test(membershipId)) {
throw new Error('invalid cursor payload');
}
return { createdAt, membershipId };
} catch {
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
}
}
export function containsSearchPattern(value: string) {
return `%${value.replace(/[\\%_]/g, match => `\\${match}`)}%`;
}

View File

@@ -1,6 +1,7 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
import { hasResolvedTenantPermission } from '../tenant-admin/auth.js';
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
@@ -12,14 +13,6 @@ export interface TenantContentAuth {
templatePermissions: Record<string, unknown>;
}
function hasContentPermission(permissions: Record<string, unknown>) {
return permissions['*'] === true || permissions['content:*'] === true;
}
function hasPermission(permissions: Record<string, unknown>, permissionKey: string) {
return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -36,13 +29,6 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and (
tm.role = any($3::text[])
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
or tm.permissions ? 'content:*'
or tm.permissions ? '*'
)
order by case tm.role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
@@ -52,17 +38,23 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
end
limit 1
`,
[tenantId, userId, Array.from(CONTENT_ROLES)],
[tenantId, userId],
);
if (!membership) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
if (!CONTENT_ROLES.has(membership.role) && !hasContentPermission(membership.permissions || {}) && !hasContentPermission(membership.templatePermissions || {})) {
const permissions = membership.permissions || {};
const templatePermissions = membership.templatePermissions || {};
if (!hasResolvedTenantPermission(
{ role: membership.role, permissions, templatePermissions },
'content:write',
CONTENT_ROLES.has(membership.role),
)) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
return { tenantId, userId, role: membership.role, permissions, templatePermissions };
}
export async function requireTenantContentPermission(
@@ -85,15 +77,6 @@ export async function requireTenantContentPermission(
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and (
tm.role = any($3::text[])
or coalesce(rt.permissions, '{}'::jsonb) ? $4
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
or tm.permissions ? $4
or tm.permissions ? 'content:*'
or tm.permissions ? '*'
)
order by case tm.role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
@@ -103,7 +86,7 @@ export async function requireTenantContentPermission(
end
limit 1
`,
[tenantId, userId, defaultRoles, permissionKey],
[tenantId, userId],
);
if (!membership) {
@@ -111,7 +94,11 @@ export async function requireTenantContentPermission(
}
const permissions = membership.permissions || {};
const templatePermissions = membership.templatePermissions || {};
if (!defaultRoles.includes(membership.role) && !hasPermission(permissions, permissionKey) && !hasPermission(templatePermissions, permissionKey)) {
if (!hasResolvedTenantPermission(
{ role: membership.role, permissions, templatePermissions },
permissionKey,
defaultRoles.includes(membership.role),
)) {
throw new HttpError(403, 'Tenant content permission is required', 'TENANT_CONTENT_PERMISSION_REQUIRED');
}

View File

@@ -589,7 +589,10 @@ async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType:
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1
and q.status = 'published'
and ${scopeFilter}

View File

@@ -1830,7 +1830,12 @@ async function createGenericPreviewJob<T>(
});
}
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
async function loadPreviewJob(
client: pg.PoolClient,
auth: TenantContentAuth,
jobId: string,
leaseToken?: string,
) {
const result = await client.query<{
id: string;
status: string;
@@ -1846,16 +1851,18 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
target_entry_id: string | null;
target_content_node_id: string | null;
target_collection_id: string | null;
lease_token: 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,
target_entry_id, target_content_node_id, target_collection_id
target_entry_id, target_content_node_id, target_collection_id,
lease_token
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
for update
${leaseToken ? '' : 'for update'}
`,
[auth.tenantId, jobId],
);
@@ -1864,7 +1871,24 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
if (['importing', 'failed'].includes(job.status)) {
if (leaseToken) {
const lease = await client.query(
`
select 1
from public.content_import_jobs
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
`,
[auth.tenantId, jobId, leaseToken],
);
if (lease.rowCount !== 1) {
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
}
}
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
}
return job;
@@ -1875,6 +1899,7 @@ async function loadGenericPreviewJob(
auth: TenantContentAuth,
jobId: string,
importType: ContentImportType,
leaseToken?: string,
) {
const result = await client.query<{
id: string;
@@ -1886,21 +1911,39 @@ async function loadGenericPreviewJob(
target_region_id: string | null;
target_entry_id: string | null;
target_content_node_id: string | null;
lease_token: string | null;
}>(
`
select id, status, total_count, valid_count, error_count, warning_count,
target_region_id, target_entry_id, target_content_node_id
target_region_id, target_entry_id, target_content_node_id, lease_token
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = $3
limit 1
for update
${leaseToken ? '' : 'for update'}
`,
[auth.tenantId, jobId, importType],
);
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)) {
if (leaseToken) {
const lease = await client.query(
`
select 1
from public.content_import_jobs
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
`,
[auth.tenantId, jobId, leaseToken],
);
if (lease.rowCount !== 1) {
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
}
}
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
}
return job;
@@ -2118,7 +2161,10 @@ async function currentVersionHash(client: pg.PoolClient, questionId: string) {
`
select v.source_hash
from public.questions q
join public.question_versions v on v.id = q.current_version_id
join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.id = $1
limit 1
`,
@@ -3099,6 +3145,7 @@ interface ContentImportExecutionOptions {
importType: ExecutableContentImportType;
allowPartial?: boolean;
allowQueuedJob?: boolean;
leaseToken?: string;
}
interface ContentImportExecutionResult {
@@ -3112,15 +3159,40 @@ interface ContentImportExecutionResult {
warningCount?: number;
}
type ContentImportLeaseOptions = Pick<ContentImportExecutionOptions, 'allowQueuedJob' | 'leaseToken'>;
function asyncLeaseWhereClause(input: ContentImportLeaseOptions, firstParam: number) {
return input.allowQueuedJob === true
? ` and status = 'importing' and lease_token = $${firstParam}::uuid and lease_expires_at > now()`
: '';
}
function asyncLeaseParams(input: ContentImportLeaseOptions) {
if (input.allowQueuedJob !== true) return [];
if (!input.leaseToken) {
throw new HttpError(409, 'Import worker lease is required', 'IMPORT_WORKER_LEASE_REQUIRED');
}
return [input.leaseToken];
}
function assertImportLeaseUpdate(rowCount: number | null, input: ContentImportLeaseOptions) {
if (input.allowQueuedJob === true && rowCount !== 1) {
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
}
}
async function executeQuestionsImportJob(
auth: TenantContentAuth,
input: Omit<ContentImportExecutionOptions, 'importType'>,
) {
const allowPartial = input.allowPartial === true;
return transaction(async client => {
const job = await loadPreviewJob(client, auth, input.jobId);
const job = await loadPreviewJob(client, auth, input.jobId, input.leaseToken);
if (job.status === 'completed' || job.status === 'completed_with_errors') {
if (input.allowQueuedJob === true) {
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
}
return {
jobId: job.id,
status: job.status,
@@ -3142,6 +3214,9 @@ async function executeQuestionsImportJob(
error_message = 'Preview contains validation errors',
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
updated_at = now()
where tenant_id = $1 and id = $2
`,
@@ -3150,14 +3225,16 @@ async function executeQuestionsImportJob(
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],
);
if (input.allowQueuedJob !== true) {
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;
@@ -3185,7 +3262,7 @@ async function executeQuestionsImportJob(
}
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
await client.query(
const completed = await client.query(
`
update public.content_import_jobs
set status = $3,
@@ -3196,8 +3273,13 @@ async function executeQuestionsImportJob(
finished_at = now(),
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
updated_at = now()
where tenant_id = $1 and id = $2
${asyncLeaseWhereClause(input, 8)}
returning id
`,
[
auth.tenantId,
@@ -3207,8 +3289,10 @@ async function executeQuestionsImportJob(
updatedCount,
skippedCount,
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
...asyncLeaseParams(input),
],
);
assertImportLeaseUpdate(completed.rowCount, input);
await client.query(
`
@@ -3251,8 +3335,11 @@ async function executeGenericImportJob<T>(
) {
const allowPartial = input.allowPartial === true;
return transaction(async client => {
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType);
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType, input.leaseToken);
if (job.status === 'completed' || job.status === 'completed_with_errors') {
if (input.allowQueuedJob === true) {
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
}
return {
jobId: job.id,
status: job.status,
@@ -3274,6 +3361,9 @@ async function executeGenericImportJob<T>(
error_message = 'Preview contains validation errors',
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
updated_at = now()
where tenant_id = $1 and id = $2
`,
@@ -3282,14 +3372,16 @@ async function executeGenericImportJob<T>(
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],
);
if (input.allowQueuedJob !== true) {
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;
@@ -3329,7 +3421,7 @@ async function executeGenericImportJob<T>(
}
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
await client.query(
const completed = await client.query(
`
update public.content_import_jobs
set status = $3,
@@ -3340,8 +3432,13 @@ async function executeGenericImportJob<T>(
finished_at = now(),
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
updated_at = now()
where tenant_id = $1 and id = $2
${asyncLeaseWhereClause(input, 8)}
returning id
`,
[
auth.tenantId,
@@ -3351,8 +3448,10 @@ async function executeGenericImportJob<T>(
updatedCount,
skippedCount,
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
...asyncLeaseParams(input),
],
);
assertImportLeaseUpdate(completed.rowCount, input);
await client.query(
`
@@ -3477,6 +3576,9 @@ async function queueContentImportJob(
queued_at = coalesce(queued_at, now()),
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
next_attempt_at = now(),
summary = coalesce(summary, '{}'::jsonb) || $3::jsonb,
updated_at = now()
@@ -3676,6 +3778,7 @@ const importJobSelect = `
updated_count as "updatedCount", skipped_count as "skippedCount",
execution_mode as "executionMode", queued_at as "queuedAt",
locked_at as "lockedAt", locked_by as "lockedBy",
lease_expires_at as "leaseExpiresAt", last_heartbeat_at as "lastHeartbeatAt",
attempt_count as "attemptCount", max_attempts as "maxAttempts",
next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata",
summary, error_message as "errorMessage",

View File

@@ -395,7 +395,10 @@ async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
v.answer_text, v.explanation, v.sub_questions, v.code_lang,
v.code_template, v.source_hash
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1
and q.question_bank_id = $2
and q.status = 'published'
@@ -446,7 +449,10 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
`
select q.id, v.source_hash
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.legacy_id = $2
limit 1
for update of q
@@ -1440,7 +1446,10 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext
`
select q.id, v.source_hash
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2
limit 1
for update of q
@@ -1795,7 +1804,10 @@ async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth,
`
select q.id, v.source_hash
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2
limit 1
for update of q

View File

@@ -0,0 +1,153 @@
import { isIP } from 'node:net';
export interface TenantLocatorInput {
origin?: string;
referer?: string;
requestedHost?: string;
requestHost?: string;
tenantCode?: string;
isProduction: boolean;
}
export interface TenantLocatorError {
ok: false;
statusCode: number;
code: string;
message: string;
}
export interface TenantHostLocator {
kind: 'host';
host: string;
expectedTenantCode: string | null;
source: 'browser' | 'local-development';
}
export interface TenantCodeLocator {
kind: 'tenantCode';
tenantCode: string;
source: 'headless-client' | 'local-development';
}
export type TenantLocatorDecision = TenantLocatorError | {
ok: true;
locator: TenantHostLocator | TenantCodeLocator;
};
function normalizedHostname(value: string) {
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
export function normalizeTenantHost(value: string) {
const raw = value.trim();
if (!raw || /[\s,]/.test(raw)) return '';
const directIp = normalizedHostname(raw);
if (isIP(directIp)) return directIp;
try {
const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`);
if (parsed.username || parsed.password) return '';
const hostname = normalizedHostname(parsed.hostname);
if (!hostname) return '';
if (isIP(hostname)) return hostname;
if (!/^[a-z0-9.-]+$/.test(hostname)) return '';
if (hostname.startsWith('.') || hostname.includes('..')) return '';
return hostname;
} catch {
return '';
}
}
export function isLocalTenantHost(value: string) {
const hostname = normalizeTenantHost(value);
if (!hostname) return false;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (hostname === '::1' || hostname === '0.0.0.0') return true;
return isIP(hostname) === 4 && hostname.startsWith('127.');
}
function hostFromBrowserUrl(value: string) {
const raw = value.trim();
if (!raw || raw === 'null' || raw.includes(',')) return '';
try {
const parsed = new URL(raw);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
return normalizeTenantHost(parsed.host);
} catch {
return '';
}
}
function normalizeTenantCode(value: string) {
const normalized = value.trim();
return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : '';
}
function fail(statusCode: number, code: string, message: string): TenantLocatorError {
return { ok: false, statusCode, code, message };
}
export function selectTenantLocator(input: TenantLocatorInput): TenantLocatorDecision {
const rawTenantCode = input.tenantCode?.trim() || '';
const tenantCode = normalizeTenantCode(rawTenantCode);
if (rawTenantCode && !tenantCode) return fail(400, 'TENANT_CODE_INVALID', 'Invalid tenant code');
const rawRequestedHost = input.requestedHost?.trim() || '';
const requestedHost = normalizeTenantHost(rawRequestedHost);
if (rawRequestedHost && !requestedHost) return fail(400, 'TENANT_HOST_INVALID', 'Invalid tenant host');
const rawOrigin = input.origin?.trim() || '';
const rawReferer = input.referer?.trim() || '';
const browserHost = rawOrigin && rawOrigin !== 'null'
? hostFromBrowserUrl(rawOrigin)
: hostFromBrowserUrl(rawReferer);
if (rawOrigin && rawOrigin !== 'null' && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser origin');
if (!rawOrigin && rawReferer && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser referer');
if (browserHost) {
if (requestedHost && requestedHost !== browserHost) {
return fail(409, 'TENANT_HOST_CONFLICT', 'Requested host does not match browser origin');
}
if (isLocalTenantHost(browserHost)) {
if (input.isProduction) return fail(400, 'TENANT_LOCAL_HOST_FORBIDDEN', 'Local tenant host is not allowed in production');
if (tenantCode) {
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
}
return { ok: true, locator: { kind: 'host', host: browserHost, expectedTenantCode: null, source: 'local-development' } };
}
return {
ok: true,
locator: {
kind: 'host',
host: browserHost,
expectedTenantCode: tenantCode || null,
source: 'browser',
},
};
}
if (requestedHost) {
if (input.isProduction || !isLocalTenantHost(requestedHost)) {
return fail(400, 'TENANT_HOST_UNTRUSTED', 'Tenant host requires a matching browser origin');
}
if (tenantCode) {
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
}
return { ok: true, locator: { kind: 'host', host: requestedHost, expectedTenantCode: null, source: 'local-development' } };
}
const requestHost = normalizeTenantHost(input.requestHost || '');
if (!input.isProduction && isLocalTenantHost(requestHost)) {
if (tenantCode) {
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
}
return { ok: true, locator: { kind: 'host', host: requestHost, expectedTenantCode: null, source: 'local-development' } };
}
if (tenantCode) {
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'headless-client' } };
}
return fail(400, 'TENANT_LOCATOR_REQUIRED', 'Tenant host or tenant code is required');
}

View File

@@ -1,6 +1,7 @@
import { config } from '../../core/config.js';
import { queryOne } from '../../core/db.js';
import { HttpError } from '../../core/errors.js';
import { getHeader, type RequestContext } from '../../core/http.js';
import { selectTenantLocator } from './locator.js';
interface TenantResolveRow {
id: string;
@@ -23,24 +24,33 @@ interface TenantResolveRow {
public_config: Record<string, unknown>;
}
function normalizeHost(host: string) {
return host.split(':')[0]?.trim().toLowerCase() || '';
}
type TenantLookup = (sql: string, params: unknown[]) => Promise<TenantResolveRow | null>;
export async function resolveTenantRoute(ctx: RequestContext) {
const defaultTenantLookup: TenantLookup = (sql, params) => queryOne<TenantResolveRow>(sql, params);
export async function resolveTenantRoute(ctx: RequestContext, lookup: TenantLookup = defaultTenantLookup) {
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 decision = selectTenantLocator({
origin: getHeader(ctx.req, 'origin'),
referer: getHeader(ctx.req, 'referer'),
requestedHost: hostParam,
requestHost: getHeader(ctx.req, 'host'),
tenantCode,
isProduction: process.env.NODE_ENV === 'production',
});
if (!decision.ok) throw new HttpError(decision.statusCode, decision.message, decision.code);
const locator = decision.locator;
const row = tenantCode
? await queryOne<TenantResolveRow>(
const row = locator.kind === 'tenantCode'
? await lookup(
`
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(tc.active_theme, b.theme, '{}'::jsonb) as theme,
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
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
@@ -51,16 +61,16 @@ export async function resolveTenantRoute(ctx: RequestContext) {
where t.slug = $1 and t.status = 'active'
limit 1
`,
[tenantCode],
[locator.tenantCode],
)
: await queryOne<TenantResolveRow>(
: await lookup(
`
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(tc.active_theme, b.theme, '{}'::jsonb) as theme,
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
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
@@ -72,20 +82,20 @@ export async function resolveTenantRoute(ctx: RequestContext) {
where d.host = $1 and d.status = 'active' and t.status = 'active'
limit 1
`,
[requestHost || 'localhost'],
[locator.host],
);
if (!row && requestHost !== 'localhost') {
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
return resolveTenantRoute(ctx);
if (!row) {
if (locator.kind === 'host') throw new HttpError(404, 'Tenant domain is not bound', 'TENANT_DOMAIN_NOT_BOUND');
throw new HttpError(404, 'Tenant code was not found', 'TENANT_CODE_NOT_FOUND');
}
if (!row) {
return {
found: false,
message: 'Tenant not found',
lookup: { host: requestHost, tenantCode: tenantCode || null },
};
if (
locator.kind === 'host'
&& locator.expectedTenantCode
&& row.slug.toLowerCase() !== locator.expectedTenantCode.toLowerCase()
) {
throw new HttpError(409, 'Tenant code does not match the resolved domain', 'TENANT_LOCATOR_CONFLICT');
}
return {

View File

@@ -0,0 +1,48 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/ai/routes.js';
import { ApiStandardResponses } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { GenerateRecommendationDto } from './request.dto.js';
import { RecommendationDetailQueryDto, RecommendationExportQueryDto, RecommendationListQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const AI_HANDLERS = Symbol('AI_HANDLERS');
const aiHandlers = {
list: routes.schoolRecommendationReportsRoute,
detail: routes.schoolRecommendationReportDetailRoute,
export: routes.schoolRecommendationReportExportRoute,
generate: routes.generateSchoolRecommendationRoute,
};
@Injectable()
class AiService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(AI_HANDLERS) injectedHandlers: typeof aiHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('AI 推荐')
@ApiBearerAuth()
@Controller('/api/ai/school-recommendations')
class AiController {
constructor(private readonly service: AiService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get() @ApiOperation({ summary: '查询院校推荐报告' })
@ApiStandardResponses('items')
list(@Query() _q: RecommendationListQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('list', req, res); }
@Get('detail') @ApiOperation({ summary: '获取院校推荐报告详情' })
@ApiStandardResponses('item')
detail(@Query() _q: RecommendationDetailQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('detail', req, res); }
@Get('export') @ApiOperation({ summary: '导出院校推荐报告' })
@ApiStandardResponses('item')
exportReport(@Query() _q: RecommendationExportQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('export', req, res); }
@Post('generate') @HttpCode(200) @ApiBody({ type: GenerateRecommendationDto }) @ApiOperation({ summary: '生成院校推荐报告', description: '根据学生成绩、地区、专业偏好等条件生成并保存推荐报告。' })
@ApiStandardResponses('item')
generate(@Body() _b: GenerateRecommendationDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('generate', req, res); }
}
@Module({ controllers: [AiController], providers: [AiService, { provide: AI_HANDLERS, useValue: aiHandlers }] })
export class AiModule {}

View File

@@ -0,0 +1,38 @@
import { applyDecorators } from '@nestjs/common';
import { ApiBadRequestResponse, ApiExtraModels, ApiForbiddenResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse, getSchemaPath } from '@nestjs/swagger';
import { ApiErrorResponseDto, ApiResponseMetaDto, GenericItemDto, envelopeSchema, itemEnvelopeSchema, itemsEnvelopeSchema, okEnvelopeSchema } from './api-response.dto.js';
type ResponseKind = 'item' | 'items' | 'ok' | 'object';
export function ApiStandardResponses(kind: ResponseKind = 'object', description = '请求成功') {
const schema = kind === 'item'
? itemEnvelopeSchema
: kind === 'items'
? itemsEnvelopeSchema
: kind === 'ok'
? okEnvelopeSchema
: envelopeSchema({ data: { type: 'object', additionalProperties: true, description: '接口业务数据' } });
return applyDecorators(
ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto),
ApiOkResponse({ description, schema }),
ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }),
ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }),
ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }),
);
}
export function ApiEnvelopeProperties(properties: Record<string, unknown>, description = '请求成功') {
return applyDecorators(
ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto),
ApiOkResponse({ description, schema: envelopeSchema(properties) }),
ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }),
ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }),
ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }),
);
}
export const itemProperty = { $ref: getSchemaPath(GenericItemDto) };
export const itemsProperty = { type: 'array', items: itemProperty };

View File

@@ -0,0 +1,35 @@
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
import { Catch, HttpException } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { config } from '../core/config.js';
import { HttpError } from '../core/errors.js';
import { withResponseMeta } from '../core/http.js';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(error: unknown, host: ArgumentsHost) {
const reply = host.switchToHttp().getResponse<FastifyReply>();
const requestId = String(reply.getHeader('x-request-id') || '');
let statusCode = 500;
let message = config.isProduction ? 'Internal server error' : error instanceof Error ? error.message : 'Unknown error';
let code = 'INTERNAL_ERROR';
if (error instanceof HttpError) {
statusCode = error.statusCode;
message = error.message;
code = error.code;
} else if (error instanceof HttpException) {
statusCode = error.getStatus();
const response = error.getResponse();
if (typeof response === 'string') message = response;
else if (response && typeof response === 'object') {
const body = response as Record<string, unknown>;
const responseMessage = body.message;
message = Array.isArray(responseMessage) ? responseMessage.join('; ') : String(responseMessage || message);
code = typeof body.code === 'string' ? body.code : statusCode === 400 ? 'VALIDATION_ERROR' : 'HTTP_ERROR';
}
}
reply.status(statusCode).send(withResponseMeta({ error: message, code, requestId }, requestId));
}
}

View File

@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger';
export class ApiResponseMetaDto {
@ApiProperty({ description: '请求追踪 ID排查问题时请提供该值', example: '8e25cb25-2347-4c41-a734-2dd7c35df90f' })
requestId!: string;
}
export class ApiErrorResponseDto {
@ApiProperty({ description: '面向调用方的错误信息', example: 'questionId is required' })
error!: string;
@ApiProperty({ description: '稳定的业务错误码', example: 'REQUIRED_FIELD' })
code!: string;
@ApiProperty({ description: '请求追踪 ID' })
requestId!: string;
@ApiProperty({ type: ApiResponseMetaDto })
meta!: ApiResponseMetaDto;
}
export class GenericItemDto {
@ApiPropertyOptional({ description: '资源 ID', format: 'uuid' }) id?: string;
@ApiPropertyOptional({ description: '租户 ID', format: 'uuid' }) tenantId?: string;
@ApiPropertyOptional({ description: '用户 ID', format: 'uuid' }) userId?: string;
@ApiPropertyOptional({ description: '题目 ID', format: 'uuid' }) questionId?: string;
@ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) practiceSessionId?: string;
@ApiPropertyOptional({ description: '单词 ID', format: 'uuid' }) wordId?: string;
@ApiPropertyOptional({ description: '资源名称或标题' }) name?: string;
@ApiPropertyOptional({ description: '标题' }) title?: string;
@ApiPropertyOptional({ description: '资源类型' }) type?: string;
@ApiPropertyOptional({ description: '资源状态' }) status?: string;
@ApiPropertyOptional({ description: '是否操作成功' }) ok?: boolean;
@ApiPropertyOptional({ description: '是否收藏' }) favorite?: boolean;
@ApiPropertyOptional({ description: '积分或数量值' }) score?: number;
@ApiPropertyOptional({ description: '统计数量' }) count?: number;
@ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) metadata?: Record<string, unknown>;
@ApiPropertyOptional({ description: '创建时间', format: 'date-time' }) createdAt?: string;
@ApiPropertyOptional({ description: '更新时间', format: 'date-time' }) updatedAt?: string;
}
export class BooleanResultDto {
@ApiProperty({ description: '操作是否成功', example: true }) ok!: boolean;
@ApiPropertyOptional({ description: '当前是否收藏', example: true }) favorite?: boolean;
}
export function envelopeSchema(payload: Record<string, unknown>) {
return {
type: 'object',
properties: {
...payload,
meta: { $ref: getSchemaPath(ApiResponseMetaDto) },
},
required: ['meta'],
};
}
export const itemEnvelopeSchema = envelopeSchema({
item: { $ref: getSchemaPath(GenericItemDto), description: '接口返回的单个业务对象;具体字段见接口说明。' },
});
export const itemsEnvelopeSchema = envelopeSchema({
items: { type: 'array', items: { $ref: getSchemaPath(GenericItemDto) }, description: '业务对象列表' },
});
export const okEnvelopeSchema = envelopeSchema({
ok: { type: 'boolean', example: true },
favorite: { type: 'boolean', description: '收藏接口返回当前收藏状态' },
});

View File

@@ -0,0 +1,14 @@
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import type { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { withResponseMeta } from '../core/http.js';
@Injectable()
export class ApiEnvelopeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const reply = context.switchToHttp().getResponse<FastifyReply>();
return next.handle().pipe(map(body => withResponseMeta(body, String(reply.getHeader('x-request-id') || ''))));
}
}

View File

@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { AiModule } from './ai.module.js';
import { AuthModule } from './auth.module.js';
import { DatabaseLifecycle } from './database.provider.js';
import { HealthModule } from './health.module.js';
import { LearningModule } from './learning.module.js';
import { ProfileModule } from './profile.module.js';
import { RequestContextFactory } from './request-context.factory.js';
import { TenantModule } from './tenant.module.js';
@Global()
@Module({
providers: [RequestContextFactory, DatabaseLifecycle],
exports: [RequestContextFactory],
})
class CoreModule {}
@Module({
imports: [CoreModule, HealthModule, TenantModule, AuthModule, ProfileModule, LearningModule, AiModule],
})
export class AppModule {}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as handlers from '../features/auth/routes.js';
import { DomainRouteService } from './domain-route.service.js';
import { BindPhoneDto, OAuthCodeDto, SmsSendDto, SmsVerifyDto } from './dto.js';
import { RequestContextFactory } from './request-context.factory.js';
import { ApiEnvelopeProperties, ApiStandardResponses } from './api-doc.decorators.js';
const AUTH_HANDLERS = Symbol('AUTH_HANDLERS');
const authHandlers = {
sendSms: handlers.sendSmsCodeRoute, verifySms: handlers.verifySmsCodeRoute, me: handlers.meRoute,
logout: handlers.logoutRoute, bindPhone: handlers.bindPhoneRoute, wechat: handlers.wechatWebLoginRoute,
miniapp: handlers.wechatMiniappLoginRoute, qq: handlers.qqLoginRoute,
};
@Injectable()
class AuthService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(AUTH_HANDLERS) injectedHandlers: typeof authHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('认证与登录')
@Controller('/api/auth')
class AuthController {
constructor(private readonly service: AuthService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Post('sms/send') @HttpCode(200) @ApiBody({ type: SmsSendDto }) @ApiOperation({ summary: '发送短信验证码', description: '根据用途发送登录、绑定手机号或重置密码验证码,并应用租户级限流。' })
@ApiEnvelopeProperties({ item: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, phone: { type: 'string' }, purpose: { type: 'string' }, provider: { type: 'string' }, status: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } }, expireIn: { type: 'integer', description: '验证码有效秒数' }, cooldown: { type: 'integer', description: '再次发送冷却秒数' }, debugCode: { type: 'string', description: '仅开发环境 mock provider 返回' } })
sendSms(@Body() _body: SmsSendDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sendSms', req, res); }
@Post('sms/verify') @HttpCode(200) @ApiBody({ type: SmsVerifyDto }) @ApiOperation({ summary: '校验短信验证码并登录', description: '校验验证码;登录用途会创建或更新用户,并签发应用会话。' })
@ApiEnvelopeProperties({ ok: { type: 'boolean' }, verified: { type: 'boolean' }, purpose: { type: 'string' }, phone: { type: 'string' }, user: { type: 'object', description: '登录用户;非 login 用途可能不返回', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', description: '应用会话 token 与过期时间', properties: { token: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } } })
verifySms(@Body() _body: SmsVerifyDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('verifySms', req, res); }
@Get('me') @ApiBearerAuth() @ApiOperation({ summary: '获取当前登录用户', description: '根据 Bearer Token 返回当前用户与会话有效期。' })
@ApiEnvelopeProperties({ user: { type: 'object', description: '当前用户信息', additionalProperties: true }, session: { type: 'object', description: '当前会话信息', additionalProperties: true } })
me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); }
@Post('logout') @HttpCode(200) @ApiBearerAuth() @ApiOperation({ summary: '退出登录', description: '撤销当前应用会话Supabase JWT 会话由其认证服务管理。' })
@ApiStandardResponses('ok')
logout(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('logout', req, res); }
@Post('phone/bind') @HttpCode(200) @ApiBearerAuth() @ApiBody({ type: BindPhoneDto }) @ApiOperation({ summary: '绑定手机号', description: '使用 bind_phone 用途的短信验证码为当前用户绑定中国大陆手机号。' })
@ApiEnvelopeProperties({ ok: { type: 'boolean' }, phone: { type: 'string' }, user: { type: 'object', additionalProperties: true } })
bindPhone(@Body() _body: BindPhoneDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('bindPhone', req, res); }
@Post('oauth/wechat') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信网页 OAuth 登录', description: '使用微信网页授权 code 换取用户身份并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
wechat(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wechat', req, res); }
@Post('oauth/wechat-miniapp') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信小程序登录', description: '使用小程序 wx.login 返回的 code 换取 openid 并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
miniapp(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('miniapp', req, res); }
@Post('oauth/qq') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: 'QQ OAuth 登录', description: '使用 QQ OAuth 授权 code 和回调地址换取用户身份并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
qq(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('qq', req, res); }
}
@Module({ controllers: [AuthController], providers: [AuthService, { provide: AUTH_HANDLERS, useValue: authHandlers }] })
export class AuthModule {}

View File

@@ -0,0 +1,9 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { closePool } from '../core/db.js';
@Injectable()
export class DatabaseLifecycle implements OnApplicationShutdown {
async onApplicationShutdown() {
await closePool();
}
}

View File

@@ -0,0 +1,16 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { Handler } from '../core/http.js';
import { RequestContextFactory } from './request-context.factory.js';
export abstract class DomainRouteService {
protected constructor(
protected readonly contextFactory: RequestContextFactory,
private readonly handlers: Record<string, Handler>,
) {}
execute(name: string, request: FastifyRequest, reply: FastifyReply) {
const handler = this.handlers[name];
if (!handler) throw new Error(`Missing domain route handler: ${name}`);
return handler(this.contextFactory.create(request, reply));
}
}

55
apps/api/src/nest/dto.ts Normal file
View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class TenantResolveQueryDto {
@ApiPropertyOptional({ description: '要解析的访问域名,例如 student.example.com' }) @IsOptional() @IsString() host?: string;
@ApiPropertyOptional({ description: '租户编码;本地开发或无独立域名时使用', example: 'master' }) @IsOptional() @IsString() tenantCode?: string;
}
export class PaginationQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}
export class FlexibleQueryDto extends PaginationQueryDto {
@ApiPropertyOptional() @IsOptional() @IsString() id?: string;
@ApiPropertyOptional() @IsOptional() @IsString() sessionId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() questionId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() unitId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() reportId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() days?: string;
}
export class JsonObjectDto {
[key: string]: unknown;
}
const SMS_PURPOSES = ['login', 'bind_phone', 'reset_password'] as const;
export class SmsSendDto {
@ApiProperty({ description: '中国大陆手机号,允许 +86 前缀和空格', example: '13800138000' }) @IsString() phone!: string;
@ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string;
@ApiPropertyOptional({ description: '客户端设备标识,用于风控限流' }) @IsOptional() @IsString() deviceId?: string;
@ApiPropertyOptional({ description: '短信请求扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class SmsVerifyDto {
@ApiProperty({ description: '中国大陆手机号', example: '13800138000' }) @IsString() phone!: string;
@ApiProperty({ description: '收到的短信验证码', example: '123456' }) @IsString() code!: string;
@ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string;
}
export class BindPhoneDto extends SmsVerifyDto {}
export class OAuthCodeDto {
@ApiProperty({ description: 'OAuth 平台返回的一次性授权 code' }) @IsString() code!: string;
@ApiPropertyOptional({ description: '客户端可提供的公开用户资料,不允许包含 token/secret', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() profile?: Record<string, unknown>;
@ApiPropertyOptional({ description: '微信用户资料语言', example: 'zh_CN' }) @IsOptional() @IsString() lang?: string;
@ApiPropertyOptional({ description: 'QQ OAuth 回调地址;租户未配置时必填' }) @IsOptional() @IsString() redirectUri?: string;
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Inject, Injectable, Module } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { healthRoute } from '../features/health/routes.js';
import { ApiEnvelopeProperties } from './api-doc.decorators.js';
const HEALTH_CHECK = Symbol('HEALTH_CHECK');
@Injectable()
class HealthService {
constructor(@Inject(HEALTH_CHECK) private readonly healthCheck: typeof healthRoute) {}
check() { return this.healthCheck(); }
}
@ApiTags('健康检查')
@Controller()
class HealthController {
constructor(private readonly service: HealthService) {}
@Get('/health')
@ApiOperation({ summary: '检查 API 与数据库健康状态', description: '用于部署探针和人工排查,返回 API、PostgreSQL 连接及服务器时间。' })
@ApiEnvelopeProperties({
ok: { type: 'boolean', example: true },
service: { type: 'string', example: 'tiku-saas-api' },
db: { type: 'string', example: 'ok' },
time: { type: 'string', format: 'date-time' },
})
check() { return this.service.check(); }
}
@Module({ controllers: [HealthController], providers: [HealthService, { provide: HEALTH_CHECK, useValue: healthRoute }] })
export class HealthModule {}

View File

@@ -0,0 +1,63 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { config } from '../core/config.js';
import { authorizeCorsRequest, CorsPolicy } from '../core/cors.js';
import { requestIdFrom } from '../core/request-id.js';
import { withResponseMeta } from '../core/http.js';
function writeLog(event: Record<string, unknown>, error = false) {
const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event });
if (error) console.error(line);
else console.log(line);
}
function requestPath(request: FastifyRequest) {
try {
return new URL(request.raw.url || '/', 'http://localhost').pathname;
} catch {
return '/';
}
}
export function registerHttpHooks(instance: FastifyInstance) {
// 使用 Fastify hook 覆盖原生与兼容路由,避免两套路由出现不同的 CORS 和访问日志行为。
const corsPolicy = new CorsPolicy({
staticOrigins: config.corsOrigins,
tenantDomainsEnabled: config.corsTenantDomainsEnabled,
positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs,
negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs,
maxCacheEntries: config.corsTenantDomainCacheMaxEntries,
onLookupError(error, host) {
writeLog({ event: 'cors_tenant_domain_lookup_failed', host, error: error instanceof Error ? error.message : 'unknown' }, true);
},
});
instance.addHook('onRequest', async (request, reply) => {
const requestId = requestIdFrom(request.raw);
reply.header('x-request-id', requestId);
(request as FastifyRequest & { startedAt?: bigint }).startedAt = process.hrtime.bigint();
const corsDecision = await authorizeCorsRequest(request.raw, reply.raw, corsPolicy);
if (!corsDecision.allowed) {
reply.status(403).send(withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId));
return reply;
}
if (request.method === 'OPTIONS') {
reply.status(204).send();
return reply;
}
});
instance.addHook('onResponse', async (request, reply) => {
const startedAt = (request as FastifyRequest & { startedAt?: bigint }).startedAt || process.hrtime.bigint();
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
writeLog({
event: 'http_request',
requestId: String(reply.getHeader('x-request-id') || ''),
method: request.method,
path: requestPath(request),
status: reply.statusCode,
durationMs: Number(durationMs.toFixed(2)),
}, reply.statusCode >= 500);
});
}
export { writeLog };

View File

@@ -0,0 +1,87 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/learning/routes.js';
import { learningLeaderboardRoute } from '../features/learning/leaderboard.js';
import { ApiEnvelopeProperties, ApiStandardResponses, itemsProperty } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { CreatePracticeSessionDto, FavoriteWordDto, QuestionActionDto, SubmitAnswerDto, SubmitPracticeSessionDto, WordProgressDto, WordReviewDto } from './request.dto.js';
import { LeaderboardQueryDto, LearningWindowQueryDto, LimitQueryDto, PracticeSessionQueryDto, UnitQueryDto, WordProgressQueryDto, WordReviewPlanQueryDto, WrongQuestionQueryDto, WrongReviewPlanQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const learningHandlers = {
leaderboard: learningLeaderboardRoute, createSession: routes.createPracticeSessionRoute,
sessionDetail: routes.practiceSessionDetailRoute, submitSession: routes.submitPracticeSessionRoute,
sessionReport: routes.practiceSessionReportRoute, history: routes.practiceHistoryRoute,
reports: routes.practiceReportsRoute, stats: routes.learningStatsRoute, trend: routes.learningTrendRoute,
answer: routes.submitAnswerRoute, favoriteQuestions: routes.favoriteQuestionsRoute,
toggleFavoriteQuestion: routes.toggleFavoriteQuestionRoute, wrongQuestions: routes.wrongQuestionsRoute,
wrongPlan: routes.wrongQuestionReviewPlanRoute, resolveWrong: routes.resolveWrongQuestionRoute,
wordProgress: routes.wordProgressRoute, updateWordProgress: routes.updateWordProgressRoute,
wordPlan: routes.wordReviewPlanRoute, reviewWord: routes.reviewWordRoute, favoriteWords: routes.favoriteWordsRoute,
toggleFavoriteWord: routes.toggleFavoriteWordRoute, wordStats: routes.wordStatsRoute,
};
const LEARNING_HANDLERS = Symbol('LEARNING_HANDLERS');
@Injectable()
class LearningService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(LEARNING_HANDLERS) injectedHandlers: typeof learningHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('学习与刷题')
@ApiBearerAuth()
@Controller('/api/learning')
class LearningController {
constructor(private readonly service: LearningService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get('leaderboard') @ApiOperation({ summary: '查询学习排行榜' }) @ApiEnvelopeProperties({ metric: { type: 'string' }, label: { type: 'string' }, unit: { type: 'string' }, period: { type: 'string' }, scope: { type: 'object', additionalProperties: true }, page: { type: 'integer' }, pageSize: { type: 'integer' }, items: itemsProperty, currentUser: { type: 'object', nullable: true, additionalProperties: true }, generatedAt: { type: 'string', format: 'date-time' } })
leaderboard(@Query() _q: LeaderboardQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('leaderboard', req, res); }
@Post('practice-sessions') @HttpCode(200) @ApiBody({ type: CreatePracticeSessionDto }) @ApiOperation({ summary: '创建练习会话', description: '可通过蓝图、题集、内容节点或练习模式组装题目,并校验练习权益。' }) @ApiStandardResponses('item')
createSession(@Body() _b: CreatePracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('createSession', req, res); }
@Get('practice-sessions/detail') @ApiOperation({ summary: '获取练习会话详情' }) @ApiStandardResponses('item')
sessionDetail(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionDetail', req, res); }
@Post('practice-sessions/submit') @HttpCode(200) @ApiBody({ type: SubmitPracticeSessionDto }) @ApiOperation({ summary: '提交练习会话', description: '结束练习、生成成绩报告并触发自动徽章判定。' }) @ApiStandardResponses('item')
submitSession(@Body() _b: SubmitPracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitSession', req, res); }
@Get('practice-sessions/report') @ApiOperation({ summary: '获取练习会话报告' }) @ApiStandardResponses('item')
sessionReport(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionReport', req, res); }
@Get('practice-sessions/history') @ApiOperation({ summary: '查询练习历史' }) @ApiStandardResponses('items')
history(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('history', req, res); }
@Get('practice-reports') @ApiOperation({ summary: '查询练习报告列表' }) @ApiStandardResponses('items')
reports(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reports', req, res); }
@Get('stats') @ApiOperation({ summary: '查询学习统计' }) @ApiStandardResponses('item')
stats(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('stats', req, res); }
@Get('trend') @ApiOperation({ summary: '查询学习趋势' }) @ApiStandardResponses('items')
trend(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('trend', req, res); }
@Post('answers') @HttpCode(200) @ApiBody({ type: SubmitAnswerDto }) @ApiOperation({ summary: '提交题目答案', description: '支持选择题、主观题自评和复合题子题答案;可关联练习会话。' }) @ApiStandardResponses('item')
answer(@Body() _b: SubmitAnswerDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('answer', req, res); }
@Get('favorites/questions') @ApiOperation({ summary: '查询收藏题目' }) @ApiStandardResponses('items')
favoriteQuestions(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteQuestions', req, res); }
@Post('favorites/questions') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '收藏或取消收藏题目' }) @ApiStandardResponses('ok')
toggleFavoriteQuestion(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteQuestion', req, res); }
@Get('wrong-questions') @ApiOperation({ summary: '查询错题列表' }) @ApiStandardResponses('items')
wrongQuestions(@Query() _q: WrongQuestionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongQuestions', req, res); }
@Get('wrong-questions/review-plan') @ApiOperation({ summary: '生成错题复习计划' }) @ApiEnvelopeProperties({ items: itemsProperty, nextAction: { type: 'object', description: '下一步复习建议', additionalProperties: true } })
wrongPlan(@Query() _q: WrongReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongPlan', req, res); }
@Post('wrong-questions/resolve') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '将错题标记为已解决' }) @ApiStandardResponses('ok')
resolveWrong(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('resolveWrong', req, res); }
@Get('vocabulary/progress') @ApiOperation({ summary: '查询单词学习进度' }) @ApiStandardResponses('items')
wordProgress(@Query() _q: WordProgressQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordProgress', req, res); }
@Post('vocabulary/progress') @HttpCode(200) @ApiBody({ type: WordProgressDto }) @ApiOperation({ summary: '更新单词学习进度' }) @ApiStandardResponses('item')
updateWordProgress(@Body() _b: WordProgressDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateWordProgress', req, res); }
@Get('vocabulary/review-plan') @ApiOperation({ summary: '生成单词复习计划' }) @ApiStandardResponses('item')
wordPlan(@Query() _q: WordReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordPlan', req, res); }
@Post('vocabulary/review') @HttpCode(200) @ApiBody({ type: WordReviewDto }) @ApiOperation({ summary: '提交单词复习结果' }) @ApiStandardResponses('item')
reviewWord(@Body() _b: WordReviewDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reviewWord', req, res); }
@Get('vocabulary/favorites') @ApiOperation({ summary: '查询收藏单词' }) @ApiStandardResponses('items')
favoriteWords(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteWords', req, res); }
@Post('vocabulary/favorites') @HttpCode(200) @ApiBody({ type: FavoriteWordDto }) @ApiOperation({ summary: '收藏或取消收藏单词' }) @ApiStandardResponses('ok')
toggleFavoriteWord(@Body() _b: FavoriteWordDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteWord', req, res); }
@Get('vocabulary/stats') @ApiOperation({ summary: '查询单词学习统计' }) @ApiStandardResponses('item')
wordStats(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordStats', req, res); }
}
@Module({ controllers: [LearningController], providers: [LearningService, { provide: LEARNING_HANDLERS, useValue: learningHandlers }] })
export class LearningModule {}

View File

@@ -0,0 +1,79 @@
import type { FastifyInstance, HTTPMethods } from 'fastify';
import { publicErrorBody, withResponseMeta } from '../core/http.js';
import { allRoutes, type RouteDefinition } from '../core/router.js';
import { RequestContextFactory } from './request-context.factory.js';
export const NATIVE_ROUTE_KEYS = new Set([
...['GET /health', 'GET /api/tenant/resolve'],
...[
'POST /api/auth/sms/send', 'POST /api/auth/sms/verify', 'GET /api/auth/me', 'POST /api/auth/logout',
'POST /api/auth/phone/bind', 'POST /api/auth/oauth/wechat', 'POST /api/auth/oauth/wechat-miniapp', 'POST /api/auth/oauth/qq',
],
...[
'GET /api/profile/me', 'PATCH /api/profile/me', 'POST /api/profile/check-in', 'GET /api/profile/score-events',
'GET /api/profile/activity-tasks', 'POST /api/profile/activity-tasks/claim', 'GET /api/profile/exchange-items',
'POST /api/profile/exchange-items/redeem', 'GET /api/profile/notifications', 'POST /api/profile/notifications/status',
'GET /api/profile/badges', 'GET /api/profile/feedbacks', 'POST /api/profile/feedbacks', 'GET /api/profile/exam-countdowns',
],
...[
'GET /api/learning/leaderboard', 'POST /api/learning/practice-sessions', 'GET /api/learning/practice-sessions/detail',
'POST /api/learning/practice-sessions/submit', 'GET /api/learning/practice-sessions/report',
'GET /api/learning/practice-sessions/history', 'GET /api/learning/practice-reports', 'GET /api/learning/stats',
'GET /api/learning/trend', 'POST /api/learning/answers', 'GET /api/learning/favorites/questions',
'POST /api/learning/favorites/questions', 'GET /api/learning/wrong-questions',
'GET /api/learning/wrong-questions/review-plan', 'POST /api/learning/wrong-questions/resolve',
'GET /api/learning/vocabulary/progress', 'POST /api/learning/vocabulary/progress',
'GET /api/learning/vocabulary/review-plan', 'POST /api/learning/vocabulary/review',
'GET /api/learning/vocabulary/favorites', 'POST /api/learning/vocabulary/favorites',
'GET /api/learning/vocabulary/stats',
],
...[
'GET /api/ai/school-recommendations', 'GET /api/ai/school-recommendations/detail',
'GET /api/ai/school-recommendations/export', 'POST /api/ai/school-recommendations/generate',
],
]);
export function legacyRoutes(definitions: RouteDefinition[] = allRoutes) {
return definitions.filter(([method, path]) => !NATIVE_ROUTE_KEYS.has(`${method} ${path}`));
}
export function registerLegacyRoutes(instance: FastifyInstance, contextFactory: RequestContextFactory) {
// 未迁移模块仍由 Fastify 承载,但请求上下文、错误结构和 requestId 与 Nest 原生路由保持一致。
const seen = new Set<string>();
for (const [method, path, handler] of legacyRoutes()) {
const key = `${method} ${path}`;
if (seen.has(key) || NATIVE_ROUTE_KEYS.has(key)) throw new Error(`Duplicate API route: ${key}`);
seen.add(key);
instance.route({
method: method as HTTPMethods,
url: path,
handler: async (request, reply) => {
const requestId = String(reply.getHeader('x-request-id') || '');
try {
const result = await handler(contextFactory.create(request, reply));
return reply.send(withResponseMeta(result, requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId));
}
},
});
}
instance.get('/api/questions/:questionId/videos', async (request, reply) => {
const route = allRoutes.find(([method, path]) => method === 'GET' && path === '/api/questions/videos');
if (!route) return reply.status(404).send();
const params = request.params as { questionId: string };
const url = new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`);
url.searchParams.set('questionId', params.questionId);
const ctx = contextFactory.create(request, reply);
ctx.url = url;
const requestId = ctx.requestId;
try {
return reply.send(withResponseMeta(await route[2](ctx), requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId));
}
});
}

View File

@@ -0,0 +1,38 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { apiReference } from '@scalar/nestjs-api-reference';
import type { FastifyInstance } from 'fastify';
import { allRoutes } from '../core/router.js';
import { legacyRoutes } from './legacy-bridge.js';
export function registerOpenApi(app: INestApplication, instance: FastifyInstance) {
const document = SwaggerModule.createDocument(app, new DocumentBuilder()
.setTitle('Tiku SaaS API')
.setDescription('NestJS migration API reference')
.setVersion('0.1.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', in: 'header', name: 'x-tenant-id' }, 'tenant-id')
.build());
for (const [method, path] of legacyRoutes(allRoutes)) {
const lowerMethod = method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';
const pathItem = document.paths[path] || {};
if (!pathItem[lowerMethod]) {
pathItem[lowerMethod] = {
tags: ['legacy'],
summary: `待迁移接口:${method} ${path}`,
description: '该接口仍通过兼容桥运行,后续按业务模块迁移为原生 Nest Controller 和强类型 DTO。',
responses: { '200': { description: '兼容接口响应;具体字段暂以现有调用契约为准。' } },
'x-migration-status': 'legacy',
} as never;
}
document.paths[path] = pathItem;
}
instance.get('/openapi.json', async (_request, reply) => reply.send(document));
const scalar = apiReference({ withFastify: true, content: document }) as (request: unknown, response: unknown) => void;
instance.get('/docs', async (request, reply) => {
scalar(request, reply.raw);
return reply;
});
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Patch, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/profile/routes.js';
import * as points from '../features/profile/points.js';
import * as notifications from '../features/profile/notifications.js';
import { ApiEnvelopeProperties, ApiStandardResponses, itemProperty, itemsProperty } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { PaginationQueryDto } from './dto.js';
import { ActivityTaskClaimDto, ExchangeRedeemDto, NotificationStatusDto, SubmitFeedbackDto, UpdateProfileDto } from './request.dto.js';
import { BadgeQueryDto, FeedbackQueryDto, LimitQueryDto, ProfileNotificationQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const PROFILE_HANDLERS = Symbol('PROFILE_HANDLERS');
const profileHandlers = {
me: routes.profileMeRoute, updateMe: routes.updateProfileMeRoute, checkIn: routes.checkInRoute,
scoreEvents: routes.scoreEventsRoute, tasks: points.activityTasksRoute, claimTask: points.claimActivityTaskRoute,
exchangeItems: points.exchangeItemsRoute, redeem: points.redeemExchangeItemRoute,
notifications: notifications.profileNotificationsRoute, notificationStatus: notifications.updateProfileNotificationStatusRoute,
badges: routes.profileBadgesRoute, feedbacks: routes.feedbacksRoute, submitFeedback: routes.submitFeedbackRoute,
countdowns: routes.examCountdownRoute,
};
@Injectable()
class ProfileService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(PROFILE_HANDLERS) injectedHandlers: typeof profileHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('个人中心')
@ApiBearerAuth()
@Controller('/api/profile')
class ProfileController {
constructor(private readonly service: ProfileService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get('me') @ApiOperation({ summary: '获取当前学生资料' })
@ApiStandardResponses('item')
me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); }
@Patch('me') @ApiBody({ type: UpdateProfileDto }) @ApiOperation({ summary: '更新当前学生资料', description: '更新姓名、预设头像、地区、意向院校专业及学习扩展数据。' })
@ApiStandardResponses('item')
updateMe(@Body() _body: UpdateProfileDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateMe', req, res); }
@Post('check-in') @HttpCode(200) @ApiOperation({ summary: '每日签到', description: '完成当日签到并返回积分奖励及连续签到状态。' })
@ApiStandardResponses('item')
checkIn(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('checkIn', req, res); }
@Get('score-events') @ApiOperation({ summary: '查询积分流水' })
@ApiStandardResponses('items')
scoreEvents(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('scoreEvents', req, res); }
@Get('activity-tasks') @ApiOperation({ summary: '查询积分活动任务' })
@ApiStandardResponses('items')
tasks(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('tasks', req, res); }
@Post('activity-tasks/claim') @HttpCode(200) @ApiBody({ type: ActivityTaskClaimDto }) @ApiOperation({ summary: '领取活动任务奖励', description: '使用任务 ID 或编码领取奖励,部分任务还需要 sourceId 作为完成证据。' })
@ApiStandardResponses('item')
claimTask(@Body() _body: ActivityTaskClaimDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('claimTask', req, res); }
@Get('exchange-items') @ApiOperation({ summary: '查询积分兑换商品' })
@ApiStandardResponses('items')
exchangeItems(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('exchangeItems', req, res); }
@Post('exchange-items/redeem') @HttpCode(200) @ApiBody({ type: ExchangeRedeemDto }) @ApiOperation({ summary: '兑换积分商品', description: '使用商品 ID 或编码创建积分兑换订单;建议提供客户端幂等键。' })
@ApiStandardResponses('item')
redeem(@Body() _body: ExchangeRedeemDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('redeem', req, res); }
@Get('notifications') @ApiOperation({ summary: '查询用户通知' })
@ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', description: '按状态统计的通知数量', additionalProperties: { type: 'integer' } } })
notificationList(@Query() _query: ProfileNotificationQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notifications', req, res); }
@Post('notifications/status') @HttpCode(200) @ApiBody({ type: NotificationStatusDto }) @ApiOperation({ summary: '更新通知状态', description: '批量将通知更新为已读、忽略或归档,最多 100 条。' })
@ApiStandardResponses('item')
notificationStatus(@Body() _body: NotificationStatusDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notificationStatus', req, res); }
@Get('badges') @ApiOperation({ summary: '查询徽章列表' })
@ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', properties: { total: { type: 'integer' }, unlocked: { type: 'integer' }, includeLocked: { type: 'boolean' } } } })
badges(@Query() _query: BadgeQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('badges', req, res); }
@Get('feedbacks') @ApiOperation({ summary: '查询反馈记录' })
@ApiStandardResponses('items')
feedbacks(@Query() _query: FeedbackQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('feedbacks', req, res); }
@Post('feedbacks') @HttpCode(200) @ApiBody({ type: SubmitFeedbackDto }) @ApiOperation({ summary: '提交意见反馈', description: '提交题目错误或产品建议,可附带题目 ID、联系方式和附件引用。' })
@ApiStandardResponses('item')
submitFeedback(@Body() _body: SubmitFeedbackDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitFeedback', req, res); }
@Get('exam-countdowns') @ApiOperation({ summary: '查询考试倒计时' })
@ApiStandardResponses('items')
countdowns(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('countdowns', req, res); }
}
@Module({ controllers: [ProfileController], providers: [ProfileService, { provide: PROFILE_HANDLERS, useValue: profileHandlers }] })
export class ProfileModule {}

View File

@@ -0,0 +1,75 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class LimitQueryDto {
@ApiPropertyOptional({ description: '返回条数上限', minimum: 1, maximum: 500 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(500) limit?: number;
}
export class ProfileNotificationQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '通知状态', enum: ['unread', 'read', 'dismissed', 'archived'] }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ description: '通知业务类型type 是兼容别名' }) @IsOptional() @IsString() notificationType?: string;
@ApiPropertyOptional({ description: 'notificationType 的兼容别名' }) @IsOptional() @IsString() type?: string;
}
export class BadgeQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '是否同时返回未解锁徽章', default: false }) @IsOptional() @Type(() => Boolean) @IsBoolean() includeLocked?: boolean;
@ApiPropertyOptional({ description: '徽章分类' }) @IsOptional() @IsString() category?: string;
}
export class FeedbackQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '反馈处理状态' }) @IsOptional() @IsString() status?: string;
}
export class PracticeSessionQueryDto {
@ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string;
}
export class LearningWindowQueryDto {
@ApiPropertyOptional({ description: '统计时间窗口天数', example: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) days?: number;
}
export class LeaderboardQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '排行指标' }) @IsOptional() @IsString() metric?: string;
@ApiPropertyOptional({ description: '统计周期' }) @IsOptional() @IsString() period?: string;
@ApiPropertyOptional({ description: '页码', minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '班级 ID', format: 'uuid' }) @IsOptional() @IsUUID() classId?: string;
}
export class WrongQuestionQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '传 all 返回全部错题,否则仅返回未解决错题', example: 'all' }) @IsOptional() @IsString() status?: string;
}
export class WrongReviewPlanQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '科目 ID', format: 'uuid' }) @IsOptional() @IsUUID() subjectId?: string;
@ApiPropertyOptional({ description: '分类 ID', format: 'uuid' }) @IsOptional() @IsUUID() categoryId?: string;
}
export class WordProgressQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
@ApiPropertyOptional({ description: '学习状态筛选' }) @IsOptional() @IsString() status?: string;
}
export class WordReviewPlanQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
@ApiPropertyOptional({ description: '到期复习词数量', minimum: 1, maximum: 200, default: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) reviewLimit?: number;
@ApiPropertyOptional({ description: '新词数量', minimum: 1, maximum: 100, default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) newLimit?: number;
}
export class UnitQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
}
export class RecommendationListQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
}
export class RecommendationDetailQueryDto {
@ApiPropertyOptional({ description: '推荐报告 ID', format: 'uuid' }) @IsOptional() @IsUUID() reportId?: string;
}
export class RecommendationExportQueryDto extends RecommendationDetailQueryDto {
@ApiPropertyOptional({ description: '导出格式', enum: ['markdown', 'html'], default: 'markdown' }) @IsOptional() @IsIn(['markdown', 'html']) format?: string;
}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { RequestContext } from '../core/http.js';
@Injectable()
export class RequestContextFactory {
create(request: FastifyRequest, reply: FastifyReply): RequestContext {
const requestId = String(reply.getHeader('x-request-id') || request.headers['x-request-id'] || '');
return {
req: request.raw,
res: reply.raw,
url: new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`),
requestId,
parsedBody: request.body,
};
}
}

View File

@@ -0,0 +1,113 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: '学生姓名', example: '张三' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ description: '预设头像标识,不允许直接传 avatarUrl', example: 'male' }) @IsOptional() @IsString() avatarPreset?: string;
@ApiPropertyOptional({ description: '所属地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '已选择院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedSchoolId?: string;
@ApiPropertyOptional({ description: '已选择专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedMajorId?: string;
@ApiPropertyOptional({ description: '个人统计扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() stats?: Record<string, unknown>;
@ApiPropertyOptional({ description: '学习进度扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() progress?: Record<string, unknown>;
@ApiPropertyOptional({ description: '模块选择配置', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() moduleSelections?: Record<string, unknown>;
@ApiPropertyOptional({ description: '最近活动记录', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() recentActivities?: unknown[];
}
export class ActivityTaskClaimDto {
@ApiPropertyOptional({ description: '活动任务 ID与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() taskId?: string;
@ApiPropertyOptional({ description: '活动任务编码,与 taskId 至少提供一个' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ description: '业务来源类型' }) @IsOptional() @IsString() sourceType?: string;
@ApiPropertyOptional({ description: '业务来源记录 ID', format: 'uuid' }) @IsOptional() @IsUUID() sourceId?: string;
@ApiPropertyOptional({ description: '幂等键;重复请求返回冲突或既有结果' }) @IsOptional() @IsString() idempotencyKey?: string;
}
export class ExchangeRedeemDto {
@ApiPropertyOptional({ description: '兑换商品 ID与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() itemId?: string;
@ApiPropertyOptional({ description: '兑换商品编码,与 itemId 至少提供一个' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ description: '客户端幂等键' }) @IsOptional() @IsString() idempotencyKey?: string;
}
export class NotificationStatusDto {
@ApiProperty({ description: '要更新的通知 ID最多 100 个', type: [String], format: 'uuid' }) @IsArray() @IsUUID(undefined, { each: true }) notificationIds!: string[];
@ApiPropertyOptional({ description: '目标状态', enum: ['read', 'dismissed', 'archived'], default: 'read' }) @IsOptional() @IsIn(['read', 'dismissed', 'archived']) status?: string;
}
export class SubmitFeedbackDto {
@ApiPropertyOptional({ description: '相关题目 ID', format: 'uuid' }) @IsOptional() @IsUUID() questionId?: string;
@ApiPropertyOptional({ description: '反馈类型', example: 'question_error' }) @IsOptional() @IsString() type?: string;
@ApiPropertyOptional({ description: '反馈分类' }) @IsOptional() @IsString() category?: string;
@ApiPropertyOptional({ description: '反馈标题' }) @IsOptional() @IsString() title?: string;
@ApiProperty({ description: '问题详细描述', example: '题目答案与解析不一致' }) @IsString() description!: string;
@ApiPropertyOptional({ description: '优先级', enum: ['low', 'normal', 'high', 'urgent'], default: 'normal' }) @IsOptional() @IsIn(['low', 'normal', 'high', 'urgent']) priority?: string;
@ApiPropertyOptional({ description: '联系方式' }) @IsOptional() @IsString() contact?: string;
@ApiPropertyOptional({ description: '附件描述或资源引用数组', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() attachments?: unknown[];
@ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class CreatePracticeSessionDto {
@ApiPropertyOptional({ description: '练习模式', example: 'practice' }) @IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ description: '练习目标类型' }) @IsOptional() @IsString() targetType?: string;
@ApiPropertyOptional({ description: '练习目标 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetId?: string;
@ApiPropertyOptional({ description: '组卷蓝图 ID', format: 'uuid' }) @IsOptional() @IsUUID() blueprintId?: string;
@ApiPropertyOptional({ description: '题集 ID', format: 'uuid' }) @IsOptional() @IsUUID() collectionId?: string;
@ApiPropertyOptional({ description: '内容条目 ID', format: 'uuid' }) @IsOptional() @IsUUID() entryId?: string;
@ApiPropertyOptional({ description: '内容节点 ID', format: 'uuid' }) @IsOptional() @IsUUID() contentNodeId?: string;
@ApiPropertyOptional({ description: '题目数量', minimum: 1, default: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) questionLimit?: number;
@ApiPropertyOptional({ description: '限时分钟数', minimum: 1, maximum: 1440 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1440) durationMinutes?: number;
@ApiPropertyOptional({ description: '试卷总分' }) @IsOptional() @Type(() => Number) @IsNumber() totalScore?: number;
@ApiPropertyOptional({ description: '组卷分区定义', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() sections?: unknown[];
@ApiPropertyOptional({ description: '组卷规则', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() rules?: Record<string, unknown>;
@ApiPropertyOptional({ description: '会话扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class SubmitAnswerDto {
@ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string;
@ApiPropertyOptional({ description: '所属练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string;
@ApiPropertyOptional({ description: '选择题选项值数组', type: [String], example: ['A'] }) @IsOptional() @IsArray() @IsString({ each: true }) selectedOptions?: string[];
@ApiPropertyOptional({ description: '主观题文本答案' }) @IsOptional() @IsString() answerText?: string;
@ApiPropertyOptional({ description: '主观题由用户自评是否正确' }) @IsOptional() @IsBoolean() selfJudgedCorrect?: boolean;
@ApiPropertyOptional({ description: '复合题子题答案', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() subAnswers?: unknown[];
}
export class SubmitPracticeSessionDto {
@ApiProperty({ description: '要提交的练习会话 ID', format: 'uuid' }) @IsUUID() practiceSessionId!: string;
}
export class QuestionActionDto {
@ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string;
@ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean;
}
export class WordReviewDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '复习结果', enum: ['known', 'unknown', 'again', 'hard', 'good', 'easy'] }) @IsOptional() @IsString() result?: string;
@ApiPropertyOptional({ description: 'result 的兼容别名' }) @IsOptional() @IsString() answerResult?: string;
}
export class WordProgressDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '学习状态', enum: ['new', 'learning', 'reviewing', 'mastered'], default: 'learning' }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ description: '正确次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) correctDelta?: number;
@ApiPropertyOptional({ description: '错误次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) wrongDelta?: number;
@ApiPropertyOptional({ description: '下次复习时间', format: 'date-time' }) @IsOptional() @IsString() nextReviewDate?: string;
}
export class FavoriteWordDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean;
@ApiPropertyOptional({ description: '收藏备注' }) @IsOptional() @IsString() @MaxLength(1000) note?: string;
}
export class GenerateRecommendationDto {
@ApiPropertyOptional({ description: '地区 ID未传时使用学生资料中的地区', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '预估成绩', minimum: 0, maximum: 1000 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(0) @Max(1000) estimatedScore?: number;
@ApiPropertyOptional({ description: '考试科类或选科', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) examTrack?: string;
@ApiPropertyOptional({ description: '意向城市', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) preferredCity?: string;
@ApiPropertyOptional({ description: '目标院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetSchoolId?: string;
@ApiPropertyOptional({ description: '目标专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetMajorId?: string;
@ApiPropertyOptional({ description: '风险偏好', enum: ['safe', 'balanced', 'sprint'], default: 'balanced' }) @IsOptional() @IsIn(['safe', 'balanced', 'sprint']) riskPreference?: string;
@ApiPropertyOptional({ description: '其他限制条件', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) constraints?: string;
@ApiPropertyOptional({ description: '补充说明', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) notes?: string;
@ApiPropertyOptional({ description: '推荐数量', minimum: 1, maximum: 12, default: 5 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(12) recommendationLimit?: number;
}

View File

@@ -0,0 +1,40 @@
import { Controller, Get, Inject, Injectable, Module, Query, Req, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { resolveTenantRoute } from '../features/tenant/routes.js';
import { DomainRouteService } from './domain-route.service.js';
import { TenantResolveQueryDto } from './dto.js';
import { RequestContextFactory } from './request-context.factory.js';
import { ApiEnvelopeProperties } from './api-doc.decorators.js';
const TENANT_HANDLERS = Symbol('TENANT_HANDLERS');
const tenantHandlers = { resolve: resolveTenantRoute };
@Injectable()
class TenantService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(TENANT_HANDLERS) injectedHandlers: typeof tenantHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('租户解析')
@Controller('/api/tenant')
class TenantController {
constructor(private readonly service: TenantService) {}
@Get('resolve')
@ApiOperation({ summary: '解析当前租户', description: '根据访问域名、host 参数或 tenantCode 解析启用中的租户及公开品牌配置。' })
@ApiEnvelopeProperties({
tenant: { type: 'object', description: '租户基本信息', properties: { id: { type: 'string', format: 'uuid' }, slug: { type: 'string' }, name: { type: 'string' }, mode: { type: 'string' } } },
branding: { type: 'object', description: '公开品牌与主题配置', additionalProperties: true },
features: { type: 'object', description: '学生端功能开关', additionalProperties: true },
adminFeatures: { type: 'object', description: '管理端功能开关', additionalProperties: true },
publicConfig: { type: 'object', description: '允许公开给客户端的租户配置', additionalProperties: true },
})
resolve(@Query() _query: TenantResolveQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) {
return this.service.execute('resolve', req, res);
}
}
@Module({ controllers: [TenantController], providers: [TenantService, { provide: TENANT_HANDLERS, useValue: tenantHandlers }] })
export class TenantModule {}

View File

@@ -1,48 +1,64 @@
import http from 'node:http';
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { config } from './core/config.js';
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
import { createRouter } from './core/router.js';
import { ApiExceptionFilter } from './nest/api-exception.filter.js';
import { ApiEnvelopeInterceptor } from './nest/api.interceptor.js';
import { AppModule } from './nest/app.module.js';
import { registerHttpHooks, writeLog } from './nest/http-hooks.js';
import { registerLegacyRoutes } from './nest/legacy-bridge.js';
import { registerOpenApi } from './nest/openapi.js';
import { RequestContextFactory } from './nest/request-context.factory.js';
const routes = createRouter();
async function bootstrap() {
const adapter = new FastifyAdapter({
bodyLimit: config.maxImportJsonBodyBytes,
requestTimeout: config.apiRequestTimeoutMs,
keepAliveTimeout: config.apiKeepAliveTimeoutMs,
maxRequestsPerSocket: config.apiMaxRequestsPerSocket,
});
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { logger: false });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: false, forbidUnknownValues: false }));
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
app.useGlobalFilters(new ApiExceptionFilter());
const instance = adapter.getInstance();
registerHttpHooks(instance);
registerLegacyRoutes(instance, app.get(RequestContextFactory));
if (!config.isProduction) registerOpenApi(app, instance);
function resolveHandler(method: string | undefined, url: URL) {
const exact = routes.get(routeKey(method, url.pathname));
if (exact) return exact;
instance.server.headersTimeout = config.apiHeadersTimeoutMs;
instance.server.requestTimeout = config.apiRequestTimeoutMs;
instance.server.keepAliveTimeout = config.apiKeepAliveTimeoutMs;
instance.server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket;
instance.server.on('clientError', error => {
writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true);
});
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'));
let closing = false;
const shutdown = async (signal: string) => {
if (closing) return;
closing = true;
writeLog({ event: 'shutdown_started', signal });
const timer = setTimeout(() => instance.server.closeAllConnections(), config.apiShutdownGracePeriodMs);
timer.unref();
try {
await app.close();
writeLog({ event: 'shutdown_complete', signal });
} finally {
clearTimeout(timer);
}
};
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => void shutdown(signal).catch(error => {
writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true);
process.exitCode = 1;
}));
}
return null;
await app.listen(config.port, '127.0.0.1');
writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port });
return app;
}
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}`);
});
export const application = bootstrap();

View File

@@ -4,6 +4,8 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",

View File

@@ -21,10 +21,13 @@ apps/taro/dist/h5-platform-admin
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`
Taro 固定稳定版 `4.2.0`。安装时 workspace postinstall 会对精确版本和源码 hash 应用两项 H5-only runtime patchInput watcher 在 ref 未就绪时安全退出Button loading 始终保留同一 loading 节点并只切换显示状态。所有 H5 build/dev 命令都会先运行 fail-closed 检查;不要使用 `npm ci --ignore-scripts`。微信小程序使用原生组件,不依赖这两项 H5 patch。
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行:
```bash
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
npm run manifest:taro:h5
node scripts/taro-h5-release-guardrails-test.js --require-dist
```
@@ -104,6 +107,17 @@ TARO_APP_SUPABASE_PUBLISHABLE_KEY=<publishable-key>
TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用>
```
微信小程序本地预览使用 `npm run build:taro:weapp:student`。正式上传前必须使用严格命令,并注入公开的 HTTPS API、固定租户码和真实 AppID
```bash
TARO_APP_API_BASE_URL=https://api.example.com \
TARO_APP_TENANT_CODE=tenant-code \
WECHAT_MINIAPP_APP_ID=wx0000000000000000 \
npm run build:taro:weapp:student:production
```
共享 SaaS 小程序可改用 `TARO_APP_WEAPP_TENANT_MODE=launch`,由小程序码 query/scene 或 `referrerInfo.extraData.tenantCode` 传入租户码,无需为每个租户重新打包。严格构建会启用微信合法域名检查,并拒绝本地 API、测试 AppID、固定模式缺失 tenantCode 或超出包体预算的产物launch 模式启动时缺少租户码会明确报错,不会回退默认租户。
禁止把 service role、数据库连接串、支付私钥、对象存储密钥放进 Taro 构建环境。
## 视觉规范

View File

@@ -2,14 +2,75 @@ import type { UserConfigExport } from '@tarojs/cli';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const portal = process.env.TARO_APP_PORTAL || 'student';
const portal = process.env.TARO_APP_PORTAL?.trim() || 'student';
const taroEnv = process.env.TARO_ENV?.trim() || 'h5';
const releaseMode = process.env.TARO_APP_RELEASE_MODE?.trim().toLowerCase() || 'preview';
const configDir = path.dirname(fileURLToPath(import.meta.url));
const distDirByPortal: Record<string, string> = {
student: 'dist/h5-student',
'tenant-admin': 'dist/h5-tenant-admin',
'platform-admin': 'dist/h5-platform-admin',
const supportedPortals = new Set(['student', 'tenant-admin', 'platform-admin']);
if (!supportedPortals.has(portal)) throw new Error(`Unsupported Taro portal: ${portal}`);
if (!/^[a-z0-9-]+$/i.test(taroEnv)) throw new Error(`Unsupported Taro target: ${taroEnv}`);
if (releaseMode !== 'preview' && releaseMode !== 'production') throw new Error(`Unsupported Taro release mode: ${releaseMode}`);
const outputRoot = `dist/${taroEnv}-${portal}`;
function publicBuildValue(name: string) {
return process.env[name]?.trim() || '';
}
function normalizedHostname(value: string) {
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function isLoopbackHostname(value: string) {
const hostname = normalizedHostname(value);
return hostname === 'localhost'
|| hostname.endsWith('.localhost')
|| hostname === '::1'
|| hostname === '0.0.0.0'
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
}
function isPlaceholderHostname(value: string) {
const hostname = normalizedHostname(value);
return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
}
function isProductionWeappApiBaseUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:'
&& Boolean(parsed.hostname)
&& !isLoopbackHostname(parsed.hostname)
&& !isPlaceholderHostname(parsed.hostname);
} catch {
return false;
}
}
const apiBaseUrl = publicBuildValue('TARO_APP_API_BASE_URL')
|| (releaseMode === 'production' ? '' : 'http://127.0.0.1:8787');
const configuredTenantCode = publicBuildValue('TARO_APP_TENANT_CODE');
const requestedWeappTenantMode = publicBuildValue('TARO_APP_WEAPP_TENANT_MODE').toLowerCase();
if (taroEnv === 'weapp' && requestedWeappTenantMode && !['fixed', 'launch'].includes(requestedWeappTenantMode)) {
throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch');
}
const weappTenantMode = taroEnv === 'weapp'
? requestedWeappTenantMode || (releaseMode === 'production' || configuredTenantCode ? 'fixed' : 'launch')
: '';
const tenantCode = taroEnv === 'weapp' && weappTenantMode === 'launch' ? '' : configuredTenantCode;
if (taroEnv === 'weapp' && releaseMode === 'production' && !isProductionWeappApiBaseUrl(apiBaseUrl)) {
throw new Error('TARO_APP_API_BASE_URL must use a non-placeholder HTTPS host for a production WeApp build');
}
const publicBuildConfig = {
portal,
target: taroEnv,
releaseMode,
weappTenantMode,
apiBaseUrl,
supabaseUrl: publicBuildValue('TARO_APP_SUPABASE_URL'),
supabasePublishableKey: publicBuildValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY'),
tenantCode,
};
const outputRoot = distDirByPortal[portal] || distDirByPortal.student;
export default {
projectName: 'tiku-saas-taro',
@@ -32,7 +93,9 @@ export default {
alias: {
'@': path.resolve(configDir, '..', 'src'),
},
defineConstants: {},
defineConstants: {
__TARO_PUBLIC_BUILD_CONFIG__: JSON.stringify(publicBuildConfig),
},
copy: {
patterns: [
{
@@ -45,6 +108,10 @@ export default {
h5: {
publicPath: '/',
staticDirectory: 'static',
devServer: {
host: '127.0.0.1',
allowedHosts: ['localhost', '127.0.0.1'],
},
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].js',

View File

@@ -4,11 +4,18 @@
"private": true,
"type": "module",
"scripts": {
"build:h5": "taro build --type h5",
"build:h5:student": "cross-env TARO_APP_PORTAL=student taro build --type h5",
"build:h5:tenant": "cross-env TARO_APP_PORTAL=tenant-admin taro build --type h5",
"build:h5:platform": "cross-env TARO_APP_PORTAL=platform-admin taro build --type h5",
"dev:h5": "taro build --type h5 --watch",
"postinstall": "node ../../scripts/taro-components-h5-runtime-patch.js --apply",
"build:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5",
"build:h5:student": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5",
"build:h5:tenant": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=production taro build --type h5",
"build:h5:platform": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=production taro build --type h5",
"build:h5:student:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5",
"build:h5:tenant:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=preview taro build --type h5",
"build:h5:platform:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=preview taro build --type h5",
"build:weapp:student": "node ../../scripts/build-weapp-student.js",
"build:weapp:student:production": "node ../../scripts/build-weapp-student.js --production && node ../../scripts/taro-weapp-release-guardrails.js --production",
"dev:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5 --watch",
"dev:weapp:student": "cross-env TARO_ENV=weapp TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type weapp --watch",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {

View File

@@ -1,5 +1,5 @@
{
"miniprogramRoot": "dist/weapp/",
"miniprogramRoot": "dist/weapp-student/",
"projectname": "tiku-saas-taro",
"description": "工学教育 SaaS 题库 Taro 多端前端",
"appid": "touristappid",

View File

@@ -2,8 +2,10 @@ declare const process: {
env: Record<string, string | undefined>;
};
const allPageRoutes = [
'pages/bootstrap/index',
const bootstrapRoute = 'pages/bootstrap/index';
const loginRoute = 'pages/student/login/index';
const studentPageRoutes = [
'pages/student/login/index',
'pages/student/home/index',
'pages/student/region/index',
@@ -21,6 +23,9 @@ const allPageRoutes = [
'pages/student/assets/index',
'pages/student/notifications/index',
'pages/student/profile/index',
];
const tenantAdminPageRoutes = [
'pages/tenant-admin/workbench/index',
'pages/tenant-admin/dashboard/index',
'pages/tenant-admin/students/index',
@@ -28,6 +33,9 @@ const allPageRoutes = [
'pages/tenant-admin/marketing/index',
'pages/tenant-admin/finance/index',
'pages/tenant-admin/settings/index',
];
const platformAdminPageRoutes = [
'pages/platform-admin/workbench/index',
'pages/platform-admin/tenants/index',
'pages/platform-admin/billing/index',
@@ -35,22 +43,52 @@ const allPageRoutes = [
'pages/platform-admin/staff/index',
];
const portalPageRoutes: Record<string, string[]> = {
student: studentPageRoutes,
'tenant-admin': tenantAdminPageRoutes,
'platform-admin': platformAdminPageRoutes,
};
const portalLandingRoutes: Record<string, string> = {
student: 'pages/student/home/index',
'tenant-admin': 'pages/tenant-admin/workbench/index',
'platform-admin': 'pages/platform-admin/workbench/index',
};
function pagesForPortal(portal: string | undefined) {
const landingRoute = portalLandingRoutes[portal || 'student'] || portalLandingRoutes.student;
function normalizedPortal(portal: string | undefined) {
const normalized = portal?.trim() || 'student';
if (!portalPageRoutes[normalized]) throw new Error(`Unsupported Taro portal: ${normalized}`);
return normalized;
}
function h5PagesForPortal(portal: string) {
const landingRoute = portalLandingRoutes[portal];
return [
landingRoute,
...allPageRoutes.filter(route => route !== landingRoute),
];
bootstrapRoute,
loginRoute,
...portalPageRoutes[portal],
].filter((route, index, routes) => routes.indexOf(route) === index);
}
const portal = normalizedPortal(process.env.TARO_APP_PORTAL);
const isStudentWeapp = process.env.TARO_ENV === 'weapp' && portal === 'student';
if (process.env.TARO_ENV === 'weapp' && portal !== 'student') {
throw new Error(`Unsupported WeApp portal: ${portal}. Tenant and platform administration are H5-only.`);
}
export default defineAppConfig({
pages: pagesForPortal(process.env.TARO_APP_PORTAL),
pages: isStudentWeapp ? [bootstrapRoute] : h5PagesForPortal(portal),
...(isStudentWeapp ? {
subPackages: [
{
root: 'pages/student',
pages: studentPageRoutes.map(route => route.replace(/^pages\/student\//, '')),
},
],
lazyCodeLoading: 'requiredComponents' as const,
} : {}),
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0f172a',

View File

@@ -32,6 +32,12 @@ body {
background-size: 40px 40px;
}
.tiku-theme-root {
min-height: 100vh;
background: var(--tiku-page);
color: var(--tiku-text);
}
view,
text,
input,
@@ -146,3 +152,15 @@ textarea {
line-height: 1.6;
letter-spacing: 0;
}
.route-guard-retry {
align-self: flex-start;
min-width: 148px;
margin-top: 8px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--tiku-radius-sm);
background: #fff;
color: var(--tiku-primary);
font-size: 22px;
font-weight: 800;
}

View File

@@ -1,10 +1,21 @@
import { PropsWithChildren, useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import 'katex/dist/katex.min.css';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { useRouter } from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { AppProvider, useApp } from '@/app/AppProvider';
import { AdminLegacyShell } from '@/components/AdminLegacyShell';
import { StudentLegacyShell } from '@/components/StudentLegacyShell';
import { appEnv, isH5Runtime } from '@/env';
import { currentPagePath, guardCurrentRoute, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard';
import {
currentPagePath,
isPathAllowedForPortal,
landingPath,
normalizePagePath,
redirectToForbidden,
redirectToLogin,
shouldGuardPath,
} from '@/services/routeGuard';
import { applyWeappLaunchTenant, replaceLocation } from '@/capabilities/navigation';
import { ThemeProvider, useTheme } from '@/theme/ThemeProvider';
import './app.css';
const routeChangeEvent = 'tiku-route-change';
@@ -60,7 +71,7 @@ function shouldUseAdminShell(path: string) {
return false;
}
function RouteGuardOverlay() {
function RouteGuardOverlay({ error, onRetry }: { error?: string; onRetry: () => void }) {
const copy = appEnv.portal === 'tenant-admin'
? {
kicker: 'Tenant Admin',
@@ -85,32 +96,40 @@ function RouteGuardOverlay() {
<View className='route-guard-panel'>
<Text className='route-guard-kicker'>{copy.kicker}</Text>
<Text className='route-guard-title'>{copy.title}</Text>
<Text className='route-guard-subtitle'>{copy.subtitle}</Text>
<Text className='route-guard-subtitle'>{error || copy.subtitle}</Text>
{error ? <Button className='route-guard-retry' onClick={onRetry}></Button> : null}
</View>
</View>
</View>
);
}
export default function App({ children }: PropsWithChildren) {
const [routeReady, setRouteReady] = useState(false);
const [path, setPath] = useState(() => currentPagePath());
function verifyCurrentRoute() {
const nextPath = currentPagePath();
setPath(nextPath);
const allowedRoute = isPathAllowedForPortal(nextPath);
const protectedRoute = shouldGuardPath(nextPath);
setRouteReady(allowedRoute && !protectedRoute);
guardCurrentRoute()
.then(ok => setRouteReady(Boolean(ok)))
.catch(() => setRouteReady(false));
}
function AppFrame({ children, path }: PropsWithChildren<{ path: string }>) {
const { bootstrapError, bootstrapStatus, currentUser, refresh, tenant } = useApp();
const { rootStyle } = useTheme();
const redirectKeyRef = useRef('');
const allowedRoute = isPathAllowedForPortal(path);
const protectedRoute = shouldGuardPath(path);
const routeReady = allowedRoute && (protectedRoute
? bootstrapStatus === 'ready'
: (appEnv.portal === 'platform-admin' || Boolean(tenant)) && bootstrapStatus !== 'error');
const identityKey = `${tenant?.tenantId || 'unresolved'}:${currentUser?.id || 'anonymous'}`;
useEffect(() => {
verifyCurrentRoute();
return installH5RouteListener(verifyCurrentRoute);
}, []);
let redirectKey = '';
if (!allowedRoute) {
redirectKey = `portal:${path}`;
if (redirectKeyRef.current !== redirectKey) void replaceLocation(landingPath());
} else if (protectedRoute && bootstrapStatus === 'unauthenticated') {
redirectKey = `login:${path}`;
if (redirectKeyRef.current !== redirectKey) redirectToLogin(path);
} else if (protectedRoute && bootstrapStatus === 'forbidden') {
redirectKey = `forbidden:${path}`;
const reason = appEnv.portal === 'platform-admin' ? '当前账号不是平台管理员' : '当前账号没有后台访问权限';
if (redirectKeyRef.current !== redirectKey) redirectToForbidden(reason, path);
}
redirectKeyRef.current = redirectKey;
}, [allowedRoute, bootstrapStatus, path, protectedRoute]);
const content = shouldUseStudentShell(path)
? <StudentLegacyShell>{children}</StudentLegacyShell>
@@ -119,11 +138,36 @@ export default function App({ children }: PropsWithChildren) {
: children;
return (
<>
<View className={routeReady ? '' : 'route-guard-hidden'}>
<View className='tiku-theme-root' style={rootStyle}>
<View key={identityKey} className={routeReady ? '' : 'route-guard-hidden'}>
{content}
</View>
{!routeReady ? <RouteGuardOverlay /> : null}
</>
{!routeReady ? (
<RouteGuardOverlay
error={bootstrapStatus === 'error' ? bootstrapError : undefined}
onRetry={() => void refresh({ path, forceTenant: bootstrapStatus === 'error' })}
/>
) : null}
</View>
);
}
export default function App({ children }: PropsWithChildren) {
applyWeappLaunchTenant();
const router = useRouter(true);
const [path, setPath] = useState(() => currentPagePath());
useEffect(() => installH5RouteListener(() => setPath(currentPagePath())), []);
useEffect(() => {
const dynamicPath = normalizePagePath(router.path || '');
if (dynamicPath) setPath(dynamicPath);
}, [router.path]);
return (
<AppProvider path={path}>
<ThemeProvider>
<AppFrame path={path}>{children}</AppFrame>
</ThemeProvider>
</AppProvider>
);
}

View File

@@ -0,0 +1,368 @@
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isWeappRuntime, type AppEnv } from '@/env';
import type { ApiEnvelope, ApiSession, CurrentUser, TenantContext } from '@/types';
import { ApiError, clearSession, clearTenantContext, getSession, getTenantContext, resolveTenant } from '@/services/api';
import { loadCurrentUser, logout as logoutRequest, subscribeAuthChanges } from '@/services/auth';
import { emitSessionChange } from './session-events';
import { loadPlatformPermissions } from '@/services/platformAdmin';
import { loadTenantPermissions, type TenantPermissionsPayload } from '@/services/tenantAdmin';
import { currentPagePath, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard';
import { runtimeHost } from '@/capabilities/navigation';
import { activateStorageUser, clearActiveStorageUserData } from '@/capabilities/storage';
import {
hasPlatformPermission,
hasTenantMenuAccess,
hasTenantPermission,
objectRecord,
type PlatformAccessSnapshot,
type TenantAccessSnapshot,
} from './permissions';
import { visiblePortalNavigation, type PortalNavigationItem } from './portal-navigation';
export type BootstrapStatus =
| 'idle'
| 'loading-runtime'
| 'resolving-tenant'
| 'authenticating'
| 'ready'
| 'unauthenticated'
| 'forbidden'
| 'error';
interface AppState {
runtimeConfig: AppEnv;
tenant: TenantContext | null;
currentUser: CurrentUser | null;
session: ApiSession | null;
tenantAccess: TenantAccessSnapshot | null;
platformAccess: PlatformAccessSnapshot | null;
bootstrapStatus: BootstrapStatus;
bootstrapError: string;
}
interface RefreshOptions {
path?: string;
forceTenant?: boolean;
authenticatePublic?: boolean;
silent?: boolean;
}
interface AppContextValue extends AppState {
currentPath: string;
refresh: (options?: RefreshOptions) => Promise<boolean>;
refreshTenant: () => Promise<boolean>;
switchTenant: (tenantCode: string) => Promise<boolean>;
signOut: () => Promise<void>;
canTenant: (permission?: string) => boolean;
canPlatform: (permission?: string) => boolean;
canTenantMenu: (input: { menuKey?: string; moduleKey?: string; permission?: string }) => boolean;
navigationItems: PortalNavigationItem[];
}
function initialState(): AppState {
return {
runtimeConfig: { ...appEnv },
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
currentUser: null,
session: appEnv.portal === 'platform-admin' ? null : getSession(),
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'idle',
bootstrapError: '',
};
}
const AppContext = createContext<AppContextValue | null>(null);
function currentUserFrom(payload: ApiEnvelope<CurrentUser> | null) {
return payload?.user || payload?.item || null;
}
function currentSessionFrom(payload: ApiEnvelope<CurrentUser> | null) {
const stored = getSession();
if (!payload?.session) return stored;
if (payload.session.source !== 'app_session' || stored?.source !== 'app_session') return payload.session;
return {
...payload.session,
...(stored?.token ? { token: stored.token } : {}),
};
}
function tenantAccessFrom(payload: TenantPermissionsPayload): TenantAccessSnapshot {
const current = payload.current || {};
return {
role: String(current.role || ''),
permissions: objectRecord(current.permissions),
templatePermissions: objectRecord(current.templatePermissions),
effectivePermissions: objectRecord(current.effectivePermissions),
menuPermissions: objectRecord(current.menuPermissions),
modulePermissions: objectRecord(current.modulePermissions),
fieldPermissions: objectRecord(current.fieldPermissions),
dataScope: objectRecord(current.dataScope),
roleDefaults: payload.roleDefaults || {},
};
}
function bootstrapFailureStatus(error: unknown): BootstrapStatus {
if (error instanceof ApiError && error.status === 401) return 'unauthenticated';
if (error instanceof ApiError && error.status === 403) return 'forbidden';
return 'error';
}
function bootstrapFailureMessage(error: unknown) {
return error instanceof Error ? error.message : '应用初始化失败';
}
export function AppProvider({ children, path }: PropsWithChildren<{ path: string }>) {
const [state, setState] = useState<AppState>(initialState);
const stateRef = useRef(state);
const requestIdRef = useRef(0);
const pathRef = useRef(path);
const authenticatedAtRef = useRef(0);
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
pathRef.current = path;
}, [path]);
const refresh = useCallback(async (options: RefreshOptions = {}) => {
const requestId = ++requestIdRef.current;
const targetPath = options.path || pathRef.current || currentPagePath();
if (!options.silent) {
setState(previous => ({
...previous,
bootstrapStatus: 'loading-runtime',
bootstrapError: '',
}));
}
try {
assertFrontendSecretsAreAbsent();
await ensureRuntimeConfigLoaded();
if (isWeappRuntime() && !appEnv.tenantCode) throw new Error('小程序启动参数缺少 tenantCode');
if (requestId !== requestIdRef.current) return false;
if (!options.silent) {
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
bootstrapStatus: 'resolving-tenant',
}));
}
let tenant = appEnv.portal === 'platform-admin'
? null
: (options.forceTenant ? null : getTenantContext());
if (appEnv.portal !== 'platform-admin' && !tenant) tenant = await resolveTenant({ host: runtimeHost() });
if (requestId !== requestIdRef.current) return false;
if (!isPathAllowedForPortal(targetPath)) {
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant,
bootstrapStatus: 'forbidden',
bootstrapError: '当前构建入口不包含该页面',
}));
return false;
}
const protectedPath = shouldGuardPath(targetPath);
const shouldAuthenticate = protectedPath || options.authenticatePublic;
if (!shouldAuthenticate) {
const storedSession = getSession();
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant,
currentUser: storedSession ? previous.currentUser : null,
session: storedSession,
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'ready',
bootstrapError: '',
}));
return true;
}
if (!options.silent) setState(previous => ({ ...previous, tenant, bootstrapStatus: 'authenticating' }));
let userPayload: ApiEnvelope<CurrentUser> | null = null;
let tenantAccess: TenantAccessSnapshot | null = null;
let platformAccess: PlatformAccessSnapshot | null = null;
if (appEnv.portal === 'tenant-admin') {
const [permissionPayload, optionalUserPayload] = await Promise.all([
loadTenantPermissions(),
loadCurrentUser().catch(() => null),
]);
tenantAccess = tenantAccessFrom(permissionPayload);
userPayload = optionalUserPayload;
const tenantUserId = String(permissionPayload.current?.userId || '');
if (!userPayload && tenantUserId) {
userPayload = {
user: {
id: tenantUserId,
primaryRole: tenantAccess.role,
roles: [tenantAccess.role],
},
};
}
} else if (appEnv.portal === 'platform-admin') {
const permissionPayload = await loadPlatformPermissions();
const item = permissionPayload.item;
platformAccess = {
permissions: objectRecord(item?.permissions),
effectivePermissions: objectRecord(item?.effective),
};
if (item?.userId) {
userPayload = {
user: {
id: item.userId,
primaryRole: item.primaryRole || 'platform_admin',
roles: ['platform_admin'],
},
};
}
} else {
userPayload = await loadCurrentUser();
}
if (requestId !== requestIdRef.current) return false;
let currentUser = currentUserFrom(userPayload);
if (currentUser && tenantAccess?.role) {
currentUser = {
...currentUser,
roles: Array.from(new Set([...(currentUser.roles || []), tenantAccess.role])),
};
}
if (currentUser?.id && tenant?.tenantId) activateStorageUser(tenant.tenantId, currentUser.id);
authenticatedAtRef.current = Date.now();
setState({
runtimeConfig: { ...appEnv },
tenant,
currentUser,
session: appEnv.portal === 'platform-admin' ? null : currentSessionFrom(userPayload),
tenantAccess,
platformAccess,
bootstrapStatus: 'ready',
bootstrapError: '',
});
return true;
} catch (error) {
if (requestId !== requestIdRef.current) return false;
setState(previous => ({
...previous,
runtimeConfig: { ...appEnv },
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
currentUser: null,
session: appEnv.portal === 'platform-admin' ? null : getSession(),
tenantAccess: null,
platformAccess: null,
bootstrapStatus: bootstrapFailureStatus(error),
bootstrapError: bootstrapFailureMessage(error),
}));
return false;
}
}, []);
useEffect(() => {
const current = stateRef.current;
const canReuseAuthenticatedState = shouldGuardPath(path)
&& current.bootstrapStatus === 'ready'
&& current.tenant
&& current.currentUser
&& Date.now() - authenticatedAtRef.current < 60_000;
if (!canReuseAuthenticatedState) void refresh({ path });
}, [path, refresh]);
useEffect(() => subscribeAuthChanges(() => {
void refresh({ path: pathRef.current, authenticatePublic: true });
}), [refresh]);
useEffect(() => {
let disposed = false;
let unsubscribe: () => void = () => undefined;
import('@/services/supabase')
.then(({ subscribeSupabaseAuthChanges }) => subscribeSupabaseAuthChanges((event) => {
if (event === 'SIGNED_IN') clearSession({ emit: false });
emitSessionChange('supabase');
}))
.then(nextUnsubscribe => {
if (disposed) nextUnsubscribe();
else unsubscribe = nextUnsubscribe;
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe();
};
}, [state.runtimeConfig.supabasePublishableKey, state.runtimeConfig.supabaseUrl]);
const refreshTenant = useCallback(() => refresh({ path: pathRef.current, forceTenant: true, silent: true }), [refresh]);
const switchTenant = useCallback(async (tenantCode: string) => {
if (appEnv.portal === 'platform-admin') throw new Error('平台后台不使用业务租户启动上下文');
if (runtimeHost() && !isWeappRuntime()) throw new Error('H5 租户由当前域名确定,不能使用 tenantCode 覆盖');
const normalizedCode = tenantCode.trim();
if (!normalizedCode) throw new Error('tenantCode 不能为空');
clearTenantContext();
appEnv.tenantCode = normalizedCode;
return refresh({ path: pathRef.current, forceTenant: true });
}, [refresh]);
const signOut = useCallback(async () => {
requestIdRef.current += 1;
try {
await logoutRequest();
} finally {
requestIdRef.current += 1;
authenticatedAtRef.current = 0;
if (stateRef.current.tenant?.tenantId) clearActiveStorageUserData(stateRef.current.tenant.tenantId);
setState(previous => ({
...previous,
currentUser: null,
session: null,
tenantAccess: null,
platformAccess: null,
bootstrapStatus: 'unauthenticated',
bootstrapError: '',
}));
}
}, []);
const value = useMemo<AppContextValue>(() => ({
...state,
currentPath: path,
refresh,
refreshTenant,
switchTenant,
signOut,
canTenant: permission => hasTenantPermission(state.tenantAccess, permission),
canPlatform: permission => hasPlatformPermission(state.platformAccess, permission),
canTenantMenu: input => hasTenantMenuAccess(state.tenantAccess, input),
navigationItems: visiblePortalNavigation({
portal: state.runtimeConfig.portal,
tenantAccess: state.tenantAccess,
platformAccess: state.platformAccess,
}),
}), [state, path, refresh, refreshTenant, switchTenant, signOut]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp() {
const value = useContext(AppContext);
if (!value) throw new Error('useApp must be used inside AppProvider');
return value;
}

View File

@@ -0,0 +1,81 @@
export type PermissionMap = Record<string, unknown>;
export interface TenantAccessSnapshot {
role: string;
permissions: PermissionMap;
templatePermissions: PermissionMap;
effectivePermissions: PermissionMap;
menuPermissions: PermissionMap;
modulePermissions: PermissionMap;
fieldPermissions: PermissionMap;
dataScope: PermissionMap;
roleDefaults: Record<string, string[]>;
}
export interface PlatformAccessSnapshot {
permissions: PermissionMap;
effectivePermissions: PermissionMap;
}
export function objectRecord(value: unknown): PermissionMap {
return value && typeof value === 'object' && !Array.isArray(value) ? value as PermissionMap : {};
}
function permissionCandidates(permission: string) {
const parts = permission.split(':').filter(Boolean);
const candidates = [permission];
for (let index = parts.length - 1; index >= 1; index -= 1) {
candidates.push(`${parts.slice(0, index).join(':')}:*`);
}
candidates.push('*');
return candidates;
}
export function explicitPermission(permissions: PermissionMap, permission: string) {
for (const key of permissionCandidates(permission)) {
if (typeof permissions[key] === 'boolean') return permissions[key] as boolean;
}
return null;
}
function defaultPermissionAllowed(defaults: string[], permission: string) {
return defaults.some(item => {
if (item === '*' || item === permission) return true;
return item.endsWith(':*') && permission.startsWith(item.slice(0, -1));
});
}
export function hasTenantPermission(access: TenantAccessSnapshot | null, permission?: string) {
if (!permission) return true;
if (!access) return false;
const direct = explicitPermission(access.permissions, permission);
if (direct !== null) return direct;
const template = explicitPermission(access.templatePermissions, permission);
if (template !== null) return template;
const effective = explicitPermission(access.effectivePermissions, permission);
if (effective !== null) return effective;
return defaultPermissionAllowed(access.roleDefaults[access.role] || [], permission);
}
export function hasTenantMenuAccess(
access: TenantAccessSnapshot | null,
input: { menuKey?: string; moduleKey?: string; permission?: string },
) {
if (!access) return false;
if (input.menuKey && typeof access.menuPermissions[input.menuKey] === 'boolean') {
return access.menuPermissions[input.menuKey] as boolean;
}
if (input.moduleKey && typeof access.modulePermissions[input.moduleKey] === 'boolean') {
return access.modulePermissions[input.moduleKey] as boolean;
}
return hasTenantPermission(access, input.permission);
}
export function hasPlatformPermission(access: PlatformAccessSnapshot | null, permission?: string) {
if (!permission) return true;
if (!access) return false;
const effective = explicitPermission(access.effectivePermissions, permission);
if (effective !== null) return effective;
return explicitPermission(access.permissions, permission) === true;
}

View File

@@ -0,0 +1,62 @@
import type { Portal } from '@/env';
import type { PlatformAccessSnapshot, TenantAccessSnapshot } from './permissions';
import { hasPlatformPermission, hasTenantMenuAccess } from './permissions';
export interface PortalNavigationItem {
name: string;
path: string;
mark: string;
group?: string;
menuKey?: string;
moduleKey?: string;
permission?: string;
mobile?: boolean;
}
export const portalNavigation: Record<Portal, PortalNavigationItem[]> = {
student: [
{ name: '学习工作台', path: '/pages/student/home/index', mark: '台', mobile: true },
{ name: '背单词', path: '/pages/student/vocabulary/index', mark: '词', mobile: true },
{ name: '知识手册', path: '/pages/student/handbook/index', mark: '册', mobile: true },
{ name: '购买', path: '/pages/student/checkout/index', mark: '购', mobile: true },
{ name: '分数线', path: '/pages/student/scoreline/index', mark: '线', mobile: true },
{ name: '个人中心', path: '/pages/student/profile/index', mark: '我' },
],
'tenant-admin': [
{ group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' },
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数', menuKey: 'dashboard', permission: 'dashboard:read' },
{ group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生', menuKey: 'students', permission: 'students:read' },
{ name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题', menuKey: 'content', permission: 'content:*' },
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销', menuKey: 'marketing', permission: 'marketing:read' },
{ name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财', menuKey: 'commerce', permission: 'tenant:reconciliation:read' },
{ group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设', menuKey: 'settings', permission: 'tenant:overview:read' },
],
'platform-admin': [
{ group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台', permission: 'platform:overview:read' },
{ group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租', permission: 'platform:tenant:read' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账', permission: 'platform:billing:read' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库', permission: 'platform:question_bank:read' },
{ group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员', permission: 'platform:staff:read' },
],
};
export function visiblePortalNavigation(input: {
portal: Portal;
tenantAccess: TenantAccessSnapshot | null;
platformAccess: PlatformAccessSnapshot | null;
}) {
let currentGroup = '';
return portalNavigation[input.portal].flatMap(item => {
if (item.group) currentGroup = item.group;
const allowed = (() => {
if (input.portal === 'tenant-admin') {
return hasTenantMenuAccess(input.tenantAccess, item);
}
if (input.portal === 'platform-admin') {
return hasPlatformPermission(input.platformAccess, item.permission);
}
return true;
})();
return allowed ? [{ ...item, ...(currentGroup ? { group: currentGroup } : {}) }] : [];
});
}

View File

@@ -0,0 +1,40 @@
export function normalizePagePath(rawPath: string) {
const path = String(rawPath || '')
.replace(/^#!?/, '')
.split('?')[0]
.split('#')[0]
.replace(/^\/+/, '');
const pageIndex = path.indexOf('pages/');
const normalized = pageIndex < 0
? path ? `/${path}` : ''
: `/${path.slice(pageIndex)}`;
if (!normalized.startsWith('/pages/')) return normalized;
const trimmed = normalized.replace(/\/+$/, '');
return trimmed.endsWith('/index') ? trimmed : `${trimmed}/index`;
}
export function safePageRedirectPath(
rawPath: string,
portal: 'student' | 'tenant-admin' | 'platform-admin',
fallbackPath: string,
) {
const path = String(rawPath || '').split('#')[0];
const queryIndex = path.indexOf('?');
const normalizedPath = normalizePagePath(queryIndex >= 0 ? path.slice(0, queryIndex) : path);
const query = queryIndex >= 0 ? path.slice(queryIndex) : '';
if (
!normalizedPath.startsWith('/pages/')
|| normalizedPath === '/pages/student/login/index'
|| normalizedPath.startsWith('/pages/student/login/')
|| normalizedPath === '/pages/bootstrap/index'
|| normalizedPath.startsWith('/pages/bootstrap/')
) {
return fallbackPath;
}
if (portal === 'tenant-admin' && !normalizedPath.startsWith('/pages/tenant-admin/')) return fallbackPath;
if (portal === 'platform-admin' && !normalizedPath.startsWith('/pages/platform-admin/')) return fallbackPath;
if (portal === 'student' && !normalizedPath.startsWith('/pages/student/')) return fallbackPath;
return `${normalizedPath}${query}`;
}

View File

@@ -0,0 +1,50 @@
export type SessionChangeReason = 'saved' | 'cleared' | 'expired' | 'tenant-changed' | 'supabase';
export type SessionChangeListener = (reason: SessionChangeReason) => void;
const listeners = new Set<SessionChangeListener>();
let authorizationInvalidated = false;
let h5BridgeInstalled = false;
const h5SessionEventKey = 'tiku:v2:session-event';
function notifyListeners(reason: SessionChangeReason) {
listeners.forEach(listener => listener(reason));
}
function installH5SessionBridge() {
if (h5BridgeInstalled || typeof window === 'undefined') return;
h5BridgeInstalled = true;
window.addEventListener('storage', event => {
if (event.key !== h5SessionEventKey || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { reason?: SessionChangeReason };
if (payload.reason) notifyListeners(payload.reason);
} catch {
// Ignore malformed events from unrelated scripts.
}
});
}
export function emitSessionChange(reason: SessionChangeReason) {
if (reason === 'cleared' || reason === 'expired') {
if (authorizationInvalidated) return;
authorizationInvalidated = true;
} else {
authorizationInvalidated = false;
}
notifyListeners(reason);
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(h5SessionEventKey, JSON.stringify({ reason, nonce: `${Date.now()}:${Math.random()}` }));
} catch {
// Cross-tab synchronization is best effort when storage is unavailable.
}
}
}
export function subscribeSessionChanges(listener: SessionChangeListener) {
installH5SessionBridge();
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

View File

@@ -0,0 +1,40 @@
import type { Portal } from '@/env';
export interface StorageScopeInput {
portal: Portal;
host?: string;
tenantCode?: string;
}
function normalizeSegment(value: string) {
return encodeURIComponent(value.trim().toLowerCase() || 'default');
}
export function createStorageScope(input: StorageScopeInput) {
const tenantLocator = input.host?.trim() || (input.tenantCode?.trim() ? `tenant:${input.tenantCode}` : 'default');
return `${normalizeSegment(input.portal)}:${normalizeSegment(tenantLocator)}`;
}
export function tenantContextStorageKey(input: StorageScopeInput) {
return `tiku:v2:${createStorageScope(input)}:tenant`;
}
export function sessionStorageKey(input: StorageScopeInput, tenantId: string) {
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId)}:session`;
}
export function activeUserStorageKey(input: StorageScopeInput, tenantId: string) {
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:active-user`;
}
export function userDataStoragePrefix(input: StorageScopeInput, tenantId: string, userId: string) {
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:user:${normalizeSegment(userId || 'anonymous')}:data:`;
}
export function legacyTenantDataStoragePrefix(input: StorageScopeInput, tenantId: string) {
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:data:`;
}
export function tenantDataStorageKey(input: StorageScopeInput, tenantId: string, userId: string, key: string) {
return `${userDataStoragePrefix(input, tenantId, userId)}${encodeURIComponent(key)}`;
}

View File

@@ -0,0 +1,40 @@
export interface TenantLaunchInput {
query?: Record<string, unknown>;
referrerExtraData?: Record<string, unknown>;
}
function normalizedTenantCode(value: unknown) {
if (typeof value !== 'string') return '';
const normalized = value.trim();
return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : '';
}
function tenantCodeFromScene(value: unknown) {
if (typeof value !== 'string' || !value.trim()) return '';
let decoded = value.trim();
try {
decoded = decodeURIComponent(decoded);
} catch {
return '';
}
if (!decoded.includes('=')) return normalizedTenantCode(decoded);
for (const segment of decoded.split('&')) {
const separator = segment.indexOf('=');
if (separator < 0) continue;
const key = segment.slice(0, separator).trim();
if (key !== 'tenantCode' && key !== 'tenant') continue;
return normalizedTenantCode(segment.slice(separator + 1));
}
return '';
}
export function tenantCodeFromLaunch(input: TenantLaunchInput) {
const query = input.query || {};
const extraData = input.referrerExtraData || {};
return normalizedTenantCode(query.tenantCode)
|| normalizedTenantCode(query.tenant)
|| tenantCodeFromScene(query.scene)
|| normalizedTenantCode(extraData.tenantCode)
|| normalizedTenantCode(extraData.tenant)
|| '';
}

View File

@@ -0,0 +1,32 @@
export interface TenantResolveQueryInput {
host?: string;
tenantCode?: string;
}
function normalizedHostname(value: string) {
const raw = value.trim();
if (!raw) return '';
try {
return new URL(`http://${raw}`).hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
} catch {
return '';
}
}
export function isLocalRuntimeHost(value: string) {
const hostname = normalizedHostname(value);
return hostname === 'localhost'
|| hostname.endsWith('.localhost')
|| hostname === '::1'
|| hostname === '0.0.0.0'
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
}
export function tenantResolveQuery(input: TenantResolveQueryInput) {
const host = input.host?.trim() || '';
const tenantCode = input.tenantCode?.trim() || '';
return {
host: host || undefined,
tenantCode: !host || isLocalRuntimeHost(host) ? (tenantCode || undefined) : undefined,
};
}

View File

@@ -0,0 +1,165 @@
import Taro from '@tarojs/taro';
import { isH5Runtime } from '@/env';
import { copyText } from './share';
import { openExternalUrl } from './navigation';
export interface PickLocalFileOptions {
accept: string;
extensions?: string[];
readAs: 'text' | 'base64';
}
export interface PickedLocalFile {
fileName: string;
text?: string;
base64?: string;
}
function readBrowserFile(file: File, readAs: PickLocalFileOptions['readAs']) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
resolve(readAs === 'base64' && result.includes(',') ? result.slice(result.indexOf(',') + 1) : result);
};
reader.onerror = () => reject(new Error('文件读取失败'));
if (readAs === 'base64') reader.readAsDataURL(file);
else reader.readAsText(file, 'utf-8');
});
}
async function pickBrowserFile(options: PickLocalFileOptions): Promise<PickedLocalFile> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = options.accept;
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
reject(new Error('未选择文件'));
return;
}
try {
const content = await readBrowserFile(file, options.readAs);
resolve({
fileName: file.name,
...(options.readAs === 'base64' ? { base64: content } : { text: content }),
});
} catch (error) {
reject(error);
}
};
input.click();
});
}
function readMiniProgramFile(filePath: string, encoding: 'utf8' | 'base64') {
return new Promise<string>((resolve, reject) => {
Taro.getFileSystemManager().readFile({
filePath,
encoding,
success: result => resolve(String(result.data || '')),
fail: result => reject(new Error(result.errMsg || '文件读取失败')),
});
});
}
export async function pickLocalFile(options: PickLocalFileOptions): Promise<PickedLocalFile> {
if (isH5Runtime() && typeof document !== 'undefined') return pickBrowserFile(options);
if (typeof Taro.chooseMessageFile !== 'function') throw new Error('当前端暂不支持文件选择');
const result = await Taro.chooseMessageFile({
count: 1,
type: 'file',
extension: options.extensions,
});
const file = result.tempFiles[0];
if (!file?.path) throw new Error('未选择文件');
const content = await readMiniProgramFile(file.path, options.readAs === 'base64' ? 'base64' : 'utf8');
return {
fileName: file.name || 'import-file',
...(options.readAs === 'base64' ? { base64: content } : { text: content }),
};
}
function safeFileName(fileName: string) {
return fileName.replace(/[\\/:*?"<>|]/g, '-').slice(0, 120) || 'download';
}
function triggerBrowserDownload(href: string, fileName: string) {
const link = document.createElement('a');
link.href = href;
link.download = fileName;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
function writeMiniProgramFile(input: { fileName: string; data: string; encoding: 'utf8' | 'base64' }) {
return new Promise<string>((resolve, reject) => {
const filePath = `${Taro.env.USER_DATA_PATH}/${safeFileName(input.fileName)}`;
Taro.getFileSystemManager().writeFile({
filePath,
data: input.data,
encoding: input.encoding,
success: () => resolve(filePath),
fail: result => reject(new Error(result.errMsg || '文件保存失败')),
});
});
}
async function openMiniProgramFile(filePath: string) {
try {
await Taro.openDocument({ filePath, showMenu: true });
return 'opened' as const;
} catch {
return 'saved' as const;
}
}
export async function downloadBase64File(fileName: string, contentBase64: string, mimeType = 'application/octet-stream') {
if (!contentBase64) throw new Error('文件内容为空');
if (isH5Runtime() && typeof document !== 'undefined') {
triggerBrowserDownload(`data:${mimeType};base64,${contentBase64}`, safeFileName(fileName));
return 'downloaded' as const;
}
const filePath = await writeMiniProgramFile({ fileName, data: contentBase64, encoding: 'base64' });
return openMiniProgramFile(filePath);
}
export async function downloadTextFile(fileName: string, content: string, mimeType = 'text/plain;charset=utf-8') {
if (isH5Runtime() && typeof document !== 'undefined') {
const blobUrl = URL.createObjectURL(new Blob([content], { type: mimeType }));
try {
triggerBrowserDownload(blobUrl, safeFileName(fileName));
} finally {
URL.revokeObjectURL(blobUrl);
}
return 'downloaded' as const;
}
if (typeof Taro.getFileSystemManager === 'function' && Taro.env?.USER_DATA_PATH) {
const filePath = await writeMiniProgramFile({ fileName, data: content, encoding: 'utf8' });
return openMiniProgramFile(filePath);
}
await copyText(content);
return 'copied' as const;
}
export async function openRemoteFile(url: string) {
if (!url) throw new Error('文件链接为空');
if (isH5Runtime()) {
openExternalUrl(url);
return 'opened' as const;
}
if (typeof Taro.downloadFile === 'function') {
try {
const result = await Taro.downloadFile({ url });
if (result.statusCode >= 200 && result.statusCode < 300) return openMiniProgramFile(result.tempFilePath);
} catch {
// Fall back to copying signed URLs when the mini program domain is not whitelisted yet.
}
}
await copyText(url);
return 'copied' as const;
}

View File

@@ -0,0 +1,19 @@
import Taro from '@tarojs/taro';
export async function chooseImages(count = 1) {
const result = await Taro.chooseImage({ count, sizeType: ['compressed'], sourceType: ['album', 'camera'] });
return result.tempFilePaths;
}
export function previewImage(current: string, urls: string[] = [current]) {
return Taro.previewImage({ current, urls });
}
export async function chooseVideo() {
const result = await Taro.chooseVideo({ sourceType: ['album', 'camera'], compressed: true });
return {
tempFilePath: result.tempFilePath,
duration: result.duration,
size: result.size,
};
}

View File

@@ -0,0 +1,61 @@
import Taro from '@tarojs/taro';
import { appEnv, isH5Runtime, isWeappRuntime, taroWeappTenantMode } from '@/env';
import { tenantCodeFromLaunch } from '@/app/tenant-launch';
let launchTenantApplied = false;
export function applyWeappLaunchTenant() {
if (launchTenantApplied || !isWeappRuntime() || taroWeappTenantMode() !== 'launch') return appEnv.tenantCode;
launchTenantApplied = true;
appEnv.tenantCode = '';
if (typeof Taro.getLaunchOptionsSync !== 'function') return appEnv.tenantCode;
const options = Taro.getLaunchOptionsSync();
const tenantCode = tenantCodeFromLaunch({
query: options.query as Record<string, unknown>,
referrerExtraData: options.referrerInfo?.extraData as Record<string, unknown> | undefined,
});
if (tenantCode) appEnv.tenantCode = tenantCode;
return appEnv.tenantCode;
}
export function runtimeHost() {
return isH5Runtime() && typeof window !== 'undefined' ? window.location.host : '';
}
export function currentLocationUrl() {
return isH5Runtime() && typeof window !== 'undefined' ? window.location.href : '';
}
export function navigateTo(url: string) {
return Taro.navigateTo({ url });
}
export function redirectTo(url: string) {
return Taro.redirectTo({ url });
}
export function reLaunch(url: string) {
return Taro.reLaunch({ url });
}
export function navigateBack(delta = 1) {
return Taro.navigateBack({ delta });
}
export function replaceLocation(url: string) {
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.replace(url);
return Promise.resolve();
}
return redirectTo(url).then(() => undefined);
}
export function openExternalUrl(url: string, target: 'same-window' | 'new-window' = 'new-window') {
if (!/^https?:\/\//i.test(url)) throw new Error('只允许打开 http(s) 链接');
if (isH5Runtime() && typeof window !== 'undefined') {
if (target === 'same-window') window.location.assign(url);
else window.open(url, '_blank', 'noopener,noreferrer');
return true;
}
return false;
}

View File

@@ -0,0 +1,29 @@
import Taro from '@tarojs/taro';
import { isH5Runtime, isWeappRuntime } from '@/env';
import { currentLocationUrl, openExternalUrl } from './navigation';
import { copyText } from './share';
export function paymentReturnUrl() {
return currentLocationUrl() || undefined;
}
export async function launchPayment(input: {
provider?: string | null;
paymentParams?: Record<string, unknown> | null;
paymentUrl?: string;
}) {
if (input.provider === 'wechat_pay' && isWeappRuntime()) {
await Taro.requestPayment((input.paymentParams || {}) as unknown as Taro.requestPayment.Option);
return 'completed' as const;
}
if (input.paymentUrl && isH5Runtime()) {
openExternalUrl(input.paymentUrl, 'same-window');
return 'redirected' as const;
}
if (input.paymentUrl) {
await copyText(input.paymentUrl);
return 'copied-url' as const;
}
await copyText(JSON.stringify(input.paymentParams || {}));
return 'copied-params' as const;
}

View File

@@ -0,0 +1,21 @@
import Taro from '@tarojs/taro';
import { isH5Runtime } from '@/env';
export async function copyText(content: string) {
await Taro.setClipboardData({ data: content });
}
export async function shareText(input: { title: string; text?: string; url?: string }) {
if (isH5Runtime() && typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
await navigator.share({ title: input.title, text: input.text, url: input.url });
return 'shared' as const;
}
await copyText([input.title, input.text, input.url].filter(Boolean).join('\n'));
return 'copied' as const;
}
export async function enableNativeShareMenu() {
if (typeof Taro.showShareMenu !== 'function') return false;
await Taro.showShareMenu({ withShareTicket: true });
return true;
}

View File

@@ -0,0 +1,104 @@
import Taro from '@tarojs/taro';
import { appEnv, isH5Runtime } from '@/env';
import {
activeUserStorageKey,
legacyTenantDataStoragePrefix,
sessionStorageKey,
tenantContextStorageKey,
tenantDataStorageKey,
type StorageScopeInput,
userDataStoragePrefix,
} from '@/app/storage-scope';
export function runtimeStorageScope(): StorageScopeInput {
const host = isH5Runtime() && typeof window !== 'undefined'
? window.location.host
: '';
return {
portal: appEnv.portal,
host,
tenantCode: appEnv.tenantCode,
};
}
export function getJsonStorage<T>(key: string): T | null {
try {
const value = Taro.getStorageSync<string>(key);
if (!value) return null;
return JSON.parse(value) as T;
} catch {
return null;
}
}
export function setJsonStorage<T>(key: string, value: T) {
Taro.setStorageSync(key, JSON.stringify(value));
}
export function removeJsonStorage(key: string) {
Taro.removeStorageSync(key);
}
export function removeStorageByPrefix(prefix: string) {
if (!prefix) return;
try {
const keys = Taro.getStorageInfoSync().keys || [];
keys.filter(key => key.startsWith(prefix)).forEach(key => Taro.removeStorageSync(key));
} catch {
// Storage cleanup is best effort on constrained runtimes.
}
}
export function currentTenantContextStorageKey() {
return tenantContextStorageKey(runtimeStorageScope());
}
export function currentSessionStorageKey(tenantId: string) {
return sessionStorageKey(runtimeStorageScope(), tenantId);
}
export function getActiveStorageUserId(tenantId: string) {
return getJsonStorage<string>(activeUserStorageKey(runtimeStorageScope(), tenantId)) || '';
}
export function setActiveStorageUserId(tenantId: string, userId: string) {
if (!tenantId || !userId) return;
setJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId), userId);
}
export function clearActiveStorageUserId(tenantId: string) {
if (!tenantId) return;
removeJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId));
}
export function clearStorageUserData(tenantId: string, userId: string) {
if (!tenantId || !userId) return;
removeStorageByPrefix(userDataStoragePrefix(runtimeStorageScope(), tenantId, userId));
}
export function clearActiveStorageUserData(tenantId: string) {
if (!tenantId) return;
const userId = getActiveStorageUserId(tenantId);
if (userId) clearStorageUserData(tenantId, userId);
clearActiveStorageUserId(tenantId);
}
export function activateStorageUser(tenantId: string, userId: string) {
if (!tenantId || !userId) return;
const previousUserId = getActiveStorageUserId(tenantId);
if (previousUserId && previousUserId !== userId) clearStorageUserData(tenantId, previousUserId);
removeStorageByPrefix(legacyTenantDataStoragePrefix(runtimeStorageScope(), tenantId));
removeStorageByPrefix('tiku:practice:');
removeStorageByPrefix('tiku:vocabulary:');
setActiveStorageUserId(tenantId, userId);
}
export function currentTenantDataStorageKey(key: string) {
const tenant = getJsonStorage<{ tenantId?: string }>(currentTenantContextStorageKey());
const tenantId = tenant?.tenantId || 'unresolved';
return tenantDataStorageKey(runtimeStorageScope(), tenantId, getActiveStorageUserId(tenantId) || 'anonymous', key);
}
export function scopedTenantDataStorageKey(tenantId: string, userId: string, key: string) {
return tenantDataStorageKey(runtimeStorageScope(), tenantId || 'unresolved', userId || 'anonymous', key);
}

View File

@@ -67,6 +67,11 @@
white-space: nowrap;
}
.backoffice-brand-image {
width: 100%;
height: 100%;
}
.backoffice-legacy-main .admin-page,
.backoffice-legacy-main .platform-page {
min-height: auto;

View File

@@ -1,45 +1,16 @@
import { PropsWithChildren } from 'react';
import Taro from '@tarojs/taro';
import { Text, View } from '@tarojs/components';
import { appEnv } from '@/env';
import { currentPagePath } from '@/services/routeGuard';
import { Image, Text, View } from '@tarojs/components';
import { useApp } from '@/app/AppProvider';
import { redirectTo } from '@/capabilities/navigation';
import { useTheme } from '@/theme/ThemeProvider';
import './AdminLegacyShell.css';
type AdminNavItem = {
name: string;
path: string;
mark: string;
group?: string;
};
const tenantNavItems: AdminNavItem[] = [
{ group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' },
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数' },
{ group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生' },
{ name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题' },
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销' },
{ name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财' },
{ group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设' },
];
const platformNavItems: AdminNavItem[] = [
{ group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台' },
{ group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库' },
{ group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员' },
];
function isActivePath(currentPath: string, itemPath: string) {
return currentPath === itemPath;
}
function navItemsForPortal() {
return appEnv.portal === 'platform-admin' ? platformNavItems : tenantNavItems;
}
function titleForPortal() {
if (appEnv.portal === 'platform-admin') {
function titleForPortal(portal: 'tenant-admin' | 'platform-admin', brandName?: string) {
if (portal === 'platform-admin') {
return {
title: '平台管理后台',
subtitle: '租户、账务、题库授权、员工权限',
@@ -48,7 +19,7 @@ function titleForPortal() {
};
}
return {
title: '租户运营后台',
title: brandName || '租户运营后台',
subtitle: '学生、题库、营销、财务与设置',
badge: 'Admin',
mark: '题',
@@ -56,17 +27,22 @@ function titleForPortal() {
}
export function AdminLegacyShell({ children }: PropsWithChildren) {
const currentPath = currentPagePath();
const navItems = navItemsForPortal();
const title = titleForPortal();
const { currentPath, currentUser, navigationItems, runtimeConfig, tenant } = useApp();
const { assets } = useTheme();
const portal = runtimeConfig.portal === 'platform-admin' ? 'platform-admin' : 'tenant-admin';
const navItems = navigationItems;
const title = titleForPortal(portal, tenant?.branding.brandName || tenant?.branding.shortName);
const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '管理账号';
let activeGroup = '';
return (
<View className={`backoffice-legacy-layout ${appEnv.portal === 'platform-admin' ? 'platform-mode' : 'tenant-mode'}`}>
<View className={`backoffice-legacy-layout ${portal === 'platform-admin' ? 'platform-mode' : 'tenant-mode'}`}>
<View className='backoffice-legacy-sidebar'>
<View className='backoffice-brand'>
<View className='backoffice-brand-icon'>
<Text className='backoffice-brand-mark'>{title.mark}</Text>
{assets.logoUrl
? <Image className='backoffice-brand-image' src={assets.logoUrl} mode='aspectFit' />
: <Text className='backoffice-brand-mark'>{title.mark}</Text>}
</View>
<View className='backoffice-brand-copy'>
<Text className='backoffice-brand-title'>{title.title}</Text>
@@ -88,7 +64,7 @@ export function AdminLegacyShell({ children }: PropsWithChildren) {
return (
<View className='backoffice-nav-block' key={item.path}>
{nextGroup ? <Text className='backoffice-nav-group'>{nextGroup}</Text> : null}
<View className={`backoffice-nav-btn ${isActivePath(currentPath, item.path) ? 'active' : ''}`} onClick={() => Taro.redirectTo({ url: item.path })}>
<View className={`backoffice-nav-btn ${isActivePath(currentPath, item.path) ? 'active' : ''}`} onClick={() => void redirectTo(item.path)}>
<Text className='backoffice-nav-mark'>{item.mark}</Text>
<Text className='backoffice-nav-text'>{item.name}</Text>
</View>
@@ -99,18 +75,18 @@ export function AdminLegacyShell({ children }: PropsWithChildren) {
<View className='backoffice-footer'>
<View className='backoffice-footer-avatar'>
<Text className='backoffice-footer-mark'></Text>
<Text className='backoffice-footer-mark'>{userName.slice(0, 1)}</Text>
</View>
<View className='backoffice-footer-copy'>
<Text className='backoffice-footer-name'></Text>
<Text className='backoffice-footer-meta'></Text>
<Text className='backoffice-footer-name'>{userName}</Text>
<Text className='backoffice-footer-meta'>{currentUser?.primaryRole || '已启用权限校验'}</Text>
</View>
</View>
</View>
<View className='backoffice-mobile-nav'>
{navItems.map(item => (
<View className={`backoffice-mobile-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.redirectTo({ url: item.path })}>
<View className={`backoffice-mobile-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void redirectTo(item.path)}>
<Text className='backoffice-mobile-mark'>{item.mark}</Text>
<Text className='backoffice-mobile-text'>{item.name}</Text>
</View>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Taro from '@tarojs/taro';
import { Image, RichText, Text, View } from '@tarojs/components';
import './katex-platform.css';
import { signAssetPreview, type AssetWatermarkContext, type SignedAssetLink } from '@/services/catalog';
import './rich-content.css';

View File

@@ -7,6 +7,11 @@
min-width: 0;
}
.legacy-brand-image {
width: 100%;
height: 100%;
}
.student-legacy-main .student-page {
padding-bottom: 132px;
}

View File

@@ -1,33 +1,25 @@
import { PropsWithChildren } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { getTenantContext } from '@/services/api';
import { logout } from '@/services/auth';
import { currentPagePath } from '@/services/routeGuard';
import { Button, Image, Text, View } from '@tarojs/components';
import { useApp } from '@/app/AppProvider';
import { navigateTo, reLaunch } from '@/capabilities/navigation';
import { useTheme } from '@/theme/ThemeProvider';
import './StudentLegacyShell.css';
const navItems = [
{ name: '学习工作台', path: '/pages/student/home/index', mark: '台' },
{ name: '背单词', path: '/pages/student/vocabulary/index', mark: '词' },
{ name: '知识手册', path: '/pages/student/handbook/index', mark: '册' },
{ name: '购买', path: '/pages/student/checkout/index', mark: '购' },
{ name: '分数线', path: '/pages/student/scoreline/index', mark: '线' },
{ name: '个人中心', path: '/pages/student/profile/index', mark: '我' },
];
function isActivePath(currentPath: string, itemPath: string) {
if (itemPath === '/pages/student/home/index') return currentPath === itemPath;
return currentPath.startsWith(itemPath);
}
export function StudentLegacyShell({ children }: PropsWithChildren) {
const tenant = getTenantContext();
const currentPath = currentPagePath();
const { currentPath, currentUser, navigationItems, signOut, tenant } = useApp();
const { assets } = useTheme();
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
const navItems = navigationItems;
const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '学习账号';
async function handleLogout() {
await logout();
Taro.reLaunch({ url: '/pages/student/login/index' });
await signOut();
await reLaunch('/pages/student/login/index');
}
return (
@@ -35,7 +27,9 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
<View className='legacy-student-sidebar'>
<View className='legacy-brand-block'>
<View className='legacy-brand-icon'>
<Text className='legacy-brand-mark'></Text>
{assets.logoUrl
? <Image className='legacy-brand-image' src={assets.logoUrl} mode='aspectFit' />
: <Text className='legacy-brand-mark'></Text>}
</View>
<View className='legacy-brand-copy'>
<Text className='legacy-brand-name'>{brandName}</Text>
@@ -44,7 +38,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
</View>
<View className='legacy-nav-list'>
{navItems.map(item => (
<View className={`legacy-nav-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<View className={`legacy-nav-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void navigateTo(item.path)}>
<Text className='legacy-nav-dot'>{item.mark}</Text>
<Text className='legacy-nav-text'>{item.name}</Text>
</View>
@@ -55,7 +49,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
<Text className='legacy-mini-avatar-mark'></Text>
</View>
<View className='legacy-sidebar-user-copy'>
<Text className='legacy-sidebar-user-name'></Text>
<Text className='legacy-sidebar-user-name'>{userName}</Text>
<Text className='legacy-sidebar-user-tag'>SVIP</Text>
</View>
</View>
@@ -63,8 +57,8 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
</View>
<View className='student-legacy-main'>{children}</View>
<View className='legacy-mobile-dock'>
{navItems.slice(0, 5).map(item => (
<View className={`legacy-dock-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
{navItems.filter(item => item.mobile).map(item => (
<View className={`legacy-dock-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void navigateTo(item.path)}>
<Text className='legacy-dock-dot'>{item.mark}</Text>
<Text className='legacy-dock-text'>{item.name === '学习工作台' ? '首页' : item.name}</Text>
</View>

View File

@@ -0,0 +1 @@
/* WeApp uses the lightweight formula styles in rich-content.css without bundling web fonts. */

View File

@@ -0,0 +1 @@
@import "katex/dist/katex.min.css";

View File

@@ -1,4 +1,5 @@
export type Portal = 'student' | 'tenant-admin' | 'platform-admin';
export type WeappTenantMode = 'fixed' | 'launch';
export interface AppEnv {
portal: Portal;
@@ -21,6 +22,19 @@ export interface RuntimeConfigInput {
TARO_APP_TENANT_CODE?: string;
}
interface PublicBuildConfig {
portal?: string;
target?: string;
releaseMode?: string;
weappTenantMode?: string;
apiBaseUrl?: string;
supabaseUrl?: string;
supabasePublishableKey?: string;
tenantCode?: string;
}
declare const __TARO_PUBLIC_BUILD_CONFIG__: Readonly<PublicBuildConfig>;
declare const process: {
env: Record<string, string | undefined>;
};
@@ -68,6 +82,38 @@ function normalizePortal(value: unknown): Portal | null {
return null;
}
function normalizedHostname(value: string) {
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function isLoopbackHostname(value: string) {
const hostname = normalizedHostname(value);
return hostname === 'localhost'
|| hostname.endsWith('.localhost')
|| hostname === '::1'
|| hostname === '0.0.0.0'
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
}
function isProductionApiBaseUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === 'https:'
&& Boolean(parsed.hostname)
&& !isLoopbackHostname(parsed.hostname);
} catch {
return false;
}
}
const publicBuildConfig = typeof __TARO_PUBLIC_BUILD_CONFIG__ === 'undefined'
? null
: __TARO_PUBLIC_BUILD_CONFIG__;
function publicBuildConfigValue(key: keyof PublicBuildConfig, envKey: string) {
return normalizeString(publicBuildConfig ? publicBuildConfig[key] : envValue(envKey));
}
function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
const leaked = forbiddenFrontendKeys.filter(key => Object.prototype.hasOwnProperty.call(input, key));
if (leaked.length) {
@@ -81,11 +127,13 @@ function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
}
export const appEnv: AppEnv = {
portal: (envValue('TARO_APP_PORTAL') || 'student') as Portal,
apiBaseUrl: envValue('TARO_APP_API_BASE_URL') || 'http://127.0.0.1:8787',
supabaseUrl: envValue('TARO_APP_SUPABASE_URL') || '',
supabasePublishableKey: envValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY') || '',
tenantCode: envValue('TARO_APP_TENANT_CODE') || '',
portal: normalizePortal(publicBuildConfigValue('portal', 'TARO_APP_PORTAL')) || 'student',
apiBaseUrl: publicBuildConfigValue('apiBaseUrl', 'TARO_APP_API_BASE_URL'),
supabaseUrl: publicBuildConfigValue('supabaseUrl', 'TARO_APP_SUPABASE_URL'),
supabasePublishableKey: publicBuildConfigValue('supabasePublishableKey', 'TARO_APP_SUPABASE_PUBLISHABLE_KEY'),
tenantCode: publicBuildConfigValue('target', 'TARO_ENV') === 'weapp' && taroWeappTenantMode() === 'launch'
? ''
: publicBuildConfigValue('tenantCode', 'TARO_APP_TENANT_CODE'),
};
let runtimeConfigPromise: Promise<AppEnv> | null = null;
@@ -105,14 +153,23 @@ export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime
const supabasePublishableKey = normalizeString(input.supabasePublishableKey || input.TARO_APP_SUPABASE_PUBLISHABLE_KEY);
if (supabasePublishableKey) appEnv.supabasePublishableKey = supabasePublishableKey;
const tenantCode = normalizeString(input.tenantCode || input.TARO_APP_TENANT_CODE);
if (tenantCode) appEnv.tenantCode = tenantCode;
if (Object.prototype.hasOwnProperty.call(input, 'tenantCode')) {
appEnv.tenantCode = normalizeString(input.tenantCode);
} else if (Object.prototype.hasOwnProperty.call(input, 'TARO_APP_TENANT_CODE')) {
appEnv.tenantCode = normalizeString(input.TARO_APP_TENANT_CODE);
}
return appEnv;
}
export async function loadRuntimeConfig() {
if (!isH5Runtime() || typeof window === 'undefined' || typeof window.fetch !== 'function') {
if (!isH5Runtime()) {
return appEnv;
}
const strictRuntimeConfig = taroReleaseMode() === 'production';
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json loader is unavailable');
return appEnv;
}
@@ -123,13 +180,22 @@ export async function loadRuntimeConfig() {
cache: 'no-store',
credentials: 'same-origin',
});
} catch {
} catch (error) {
if (strictRuntimeConfig) {
throw new Error(`Production H5 runtime-config.json request failed: ${(error as Error).message || 'unknown error'}`);
}
return appEnv;
}
if (!response.ok) {
if (strictRuntimeConfig) throw new Error(`Production H5 runtime-config.json request failed with status ${response.status}`);
return appEnv;
}
if (!response.ok) return appEnv;
const text = (await response.text()).trim();
if (!text || !text.startsWith('{')) return appEnv;
if (!text || !text.startsWith('{')) {
if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json is empty or unreadable');
return appEnv;
}
let config: RuntimeConfigInput;
try {
@@ -138,6 +204,17 @@ export async function loadRuntimeConfig() {
throw new Error(`Invalid Taro runtime-config.json: ${(error as Error).message}`);
}
if (strictRuntimeConfig) {
const runtimePortal = normalizePortal(config.portal || config.TARO_APP_PORTAL);
if (runtimePortal !== appEnv.portal) throw new Error(`Production H5 runtime-config.json portal must be ${appEnv.portal}`);
const runtimeApiBaseUrl = normalizeString(config.apiBaseUrl || config.TARO_APP_API_BASE_URL);
if (!isProductionApiBaseUrl(runtimeApiBaseUrl)) {
throw new Error('Production H5 runtime-config.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback');
}
const runtimeTenantCode = normalizeString(config.tenantCode || config.TARO_APP_TENANT_CODE);
if (runtimeTenantCode) throw new Error('Production H5 runtime-config.json tenantCode must be empty; tenant is resolved from the browser origin');
}
return applyRuntimeConfig(config, 'runtime-config.json');
}
@@ -154,7 +231,15 @@ export function assertFrontendSecretsAreAbsent() {
}
export function taroRuntimeEnv() {
return envValue('TARO_ENV') || '';
return publicBuildConfigValue('target', 'TARO_ENV');
}
export function taroReleaseMode() {
return publicBuildConfigValue('releaseMode', 'TARO_APP_RELEASE_MODE') === 'production' ? 'production' : 'preview';
}
export function taroWeappTenantMode(): WeappTenantMode {
return publicBuildConfigValue('weappTenantMode', 'TARO_APP_WEAPP_TENANT_MODE') === 'launch' ? 'launch' : 'fixed';
}
export function isH5Runtime() {

View File

@@ -1,40 +1,28 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
import { resolveTenant } from '@/services/api';
import { currentRouteParams, landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard';
import { useApp } from '@/app/AppProvider';
import { reLaunch, replaceLocation } from '@/capabilities/navigation';
import { currentRouteParams, landingPath, redirectToLogin, safeRedirectPath } from '@/services/routeGuard';
import './index.css';
function hostFromRuntime() {
if (isH5Runtime() && typeof window !== 'undefined') return window.location.host;
return '';
}
export default function BootstrapPage() {
const { refresh } = useApp();
const [status, setStatus] = useState('正在解析租户');
const [error, setError] = useState('');
useEffect(() => {
const params = currentRouteParams();
const redirectPath = safeRedirectPath(params.redirect ? decodeURIComponent(String(params.redirect)) : landingPath());
assertFrontendSecretsAreAbsent();
ensureRuntimeConfigLoaded()
.then(() => resolveTenant({ host: hostFromRuntime() }))
.then(async () => {
setStatus('正在校验登录状态');
if (appEnv.portal === 'student') return requireSignedIn(redirectPath);
if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(redirectPath);
return requireTenantAdmin(redirectPath);
})
.then(authPayload => {
if (!authPayload) return;
setStatus('租户解析完成');
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.replace(redirectPath);
setStatus('正在校验登录状态');
refresh({ path: redirectPath })
.then(authorized => {
if (!authorized) {
setStatus('正在前往登录');
redirectToLogin(redirectPath);
return;
}
Taro.redirectTo({ url: redirectPath });
setStatus('租户解析完成');
void replaceLocation(redirectPath);
})
.catch((nextError: Error) => {
setError(nextError.message);
@@ -50,7 +38,7 @@ export default function BootstrapPage() {
<Text className='status'>{status}</Text>
{error ? <Text className='error'>{error}</Text> : null}
{error ? (
<Button className='primary-button' onClick={() => Taro.reLaunch({ url: '/pages/bootstrap/index' })}>
<Button className='primary-button' onClick={() => void reLaunch('/pages/bootstrap/index')}>
</Button>
) : null}

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { useApp } from '@/app/AppProvider';
import { downloadBase64File } from '@/capabilities/file';
import {
exportPlatformAuditLogs,
loadPlatformAuditAlerts,
@@ -27,25 +29,14 @@ import {
type PlatformQuestionBankItem,
type PlatformTenantItem,
} from '@/services/platformAdmin';
import { requirePlatformAdmin } from '@/services/routeGuard';
import '../platform.css';
function money(cents: unknown) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
if (typeof document === 'undefined') return false;
const link = document.createElement('a');
link.href = `data:${mimeType};base64,${contentBase64}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
return true;
}
export default function PlatformWorkbenchPage() {
const { canPlatform } = useApp();
const [overview, setOverview] = useState<PlatformOverview | null>(null);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
@@ -59,13 +50,9 @@ export default function PlatformWorkbenchPage() {
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
requirePlatformAdmin('/pages/platform-admin/workbench/index').then(payload => {
if (!payload) return;
setAuthorized(true);
return Promise.all([
Promise.all([
loadPlatformOverview().catch(() => ({ item: null })),
loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })),
@@ -77,9 +64,7 @@ export default function PlatformWorkbenchPage() {
loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformDunningNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
loadPlatformDunningNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
]);
}).then(result => {
if (!result) return;
]).then(result => {
const [overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload, dunningChannelPayload, dunningEventPayload] = result;
setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []);
@@ -96,25 +81,11 @@ export default function PlatformWorkbenchPage() {
}, []);
const modules = [
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
];
if (!authorized) {
return (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Platform Admin</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'></Text>
</View>
</View>
</View>
);
}
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期', permission: 'platform:tenant:read' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量', permission: 'platform:billing:read' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围', permission: 'platform:question_bank:read' },
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复', permission: 'platform:staff:read' },
].filter(item => canPlatform(item.permission));
async function exportAuditLogs() {
setBusy('audit-export');
@@ -123,8 +94,8 @@ export default function PlatformWorkbenchPage() {
const payload = await exportPlatformAuditLogs({ format: 'csv', limit: 1000 });
const item = payload.item;
if (item?.contentBase64 && item.filename) {
const ok = downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv');
Taro.showToast({ title: ok ? '已导出' : '已生成', icon: 'success' });
await downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv');
Taro.showToast({ title: '已导出', icon: 'success' });
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '审计导出失败');

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components';
import { downloadTextFile } from '@/capabilities/file';
import {
exportSchoolRecommendationReport,
generateSchoolRecommendation,
loadSchoolRecommendationReports,
type SchoolRecommendationReport,
} from '@/services/ai';
import { isH5Runtime } from '@/env';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
@@ -25,24 +25,6 @@ function recommendationRows(report: SchoolRecommendationReport | null) {
return report?.resultPayload?.recommendedSchools || [];
}
function saveExportFile(fileName: string, content: string, mimeType: string) {
if (isH5Runtime() && typeof window !== 'undefined' && typeof document !== 'undefined') {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return;
}
Taro.setClipboardData({ data: content });
Taro.showToast({ title: '报告内容已复制', icon: 'none' });
}
export default function StudentAiSchoolPage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [reports, setReports] = useState<SchoolRecommendationReport[]>([]);
@@ -94,8 +76,8 @@ export default function StudentAiSchoolPage() {
try {
const payload = await exportSchoolRecommendationReport(current.id, format);
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
if (isH5Runtime()) Taro.showToast({ title: '报告已导出', icon: 'success' });
const result = await downloadTextFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
Taro.showToast({ title: result === 'copied' ? '报告内容已复制' : '报告已导出', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '导出失败');
} finally {

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View, WebView } from '@tarojs/components';
import { openRemoteFile } from '@/capabilities/file';
import {
loadContentAssets,
signAssetDownload,
@@ -19,14 +20,10 @@ type PreviewState = {
watermark?: AssetWatermarkContext;
};
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
async function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
if (!url) return;
if (isH5Runtime() && typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
Taro.setClipboardData({ data: url });
Taro.showToast({ title: copiedText, icon: 'none' });
const result = await openRemoteFile(url);
if (result === 'copied') Taro.showToast({ title: copiedText, icon: 'none' });
}
function isImageAsset(asset: ContentAsset) {
@@ -92,7 +89,7 @@ export default function StudentAssetsPage() {
Taro.showToast({ title: '请确认水印追踪码后下载', icon: 'none' });
return;
}
openUrl(payload.download?.url, '下载签名已复制,请及时使用');
await openUrl(payload.download?.url, '下载签名已复制,请及时使用');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '下载失败');
} finally {
@@ -100,8 +97,8 @@ export default function StudentAssetsPage() {
}
}
function openSignedLink() {
openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开');
async function openSignedLink() {
await openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开');
}
return (

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import { launchPayment, paymentReturnUrl } from '@/capabilities/payment';
import {
claimCoupon,
createOrder,
@@ -12,7 +13,6 @@ import {
type PaymentCreateResult,
type SvipPlan,
} from '@/services/commerce';
import { isH5Runtime, isWeappRuntime } from '@/env';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
@@ -55,16 +55,6 @@ function buildPaymentUrl(result?: PaymentCreateResult) {
return '';
}
function tryOpenPaymentUrl(url: string) {
if (!url) return false;
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.href = url;
return true;
}
Taro.setClipboardData({ data: url });
return true;
}
export default function StudentCheckoutPage() {
const router = useRouter();
const params = router.params || {};
@@ -168,8 +158,8 @@ export default function StudentCheckoutPage() {
const paymentPayload = await createPayment({
orderNo: nextOrder.orderNo,
provider,
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
returnUrl: paymentReturnUrl(),
quitUrl: paymentReturnUrl(),
});
setPayment(paymentPayload.item || null);
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
@@ -182,19 +172,18 @@ export default function StudentCheckoutPage() {
async function handleOpenPayment() {
if (!payment) return;
if (payment.provider === 'wechat_pay' && isWeappRuntime()) {
const paramsForWeapp = payment.paymentParams || {};
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '微信支付未完成'));
return;
try {
const result = await launchPayment({
provider: payment.provider,
paymentParams: payment.paymentParams,
paymentUrl,
});
if (result === 'completed' && order?.orderNo) await pollStatus(order.orderNo);
if (result === 'copied-url') setMessage('支付链接已复制,请在支持的支付容器中打开。');
if (result === 'copied-params') setMessage('支付参数已复制,请交给支付容器或客服处理。');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '支付未完成');
}
if (!paymentUrl) {
await Taro.setClipboardData({ data: JSON.stringify(payment.paymentParams || {}) });
setMessage('支付参数已复制,请交给支付容器或客服处理。');
return;
}
tryOpenPaymentUrl(paymentUrl);
}
return (

View File

@@ -1,25 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { getTenantContext } from '@/services/api';
import { useApp } from '@/app/AppProvider';
import { loadStudentDashboard, type DashboardSnapshot } from '@/services/catalog';
import { requireSignedIn } from '@/services/routeGuard';
import './index.css';
export default function StudentHomePage() {
const tenant = getTenantContext();
const { currentUser, tenant } = useApp();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [userName, setUserName] = useState('');
useEffect(() => {
requireSignedIn('/pages/student/home/index').then(payload => {
if (payload) setUserName(payload.user?.name || payload.item?.name || '');
}).catch(() => undefined);
loadStudentDashboard().then(setSnapshot).catch(() => setSnapshot({ entries: [], banners: [], announcements: [], profile: null }));
}, []);
const entryNames = useMemo(() => (snapshot?.entries || []).slice(0, 6), [snapshot]);
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
const userName = currentUser?.name || currentUser?.username || '';
const profile = snapshot?.profile && typeof snapshot.profile === 'object' ? snapshot.profile as Record<string, unknown> : {};
const stats = profile.stats && typeof profile.stats === 'object' ? profile.stats as Record<string, unknown> : {};
const answerStats = stats.answers && typeof stats.answers === 'object' ? stats.answers as Record<string, unknown> : {};

View File

@@ -1,19 +1,17 @@
import { useEffect, useState } from 'react';
import { Button, Input, Text, View } from '@tarojs/components';
import { appEnv, ensureRuntimeConfigLoaded, type Portal } from '@/env';
import { getTenantContext } from '@/services/api';
import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth';
import { currentRouteParams, ensureTenantResolved, landingPath, redirectAfterLogin } from '@/services/routeGuard';
import { useApp } from '@/app/AppProvider';
import { sendSmsCode, verifySmsCode } from '@/services/auth';
import { currentRouteParams, landingPath, redirectAfterLogin } from '@/services/routeGuard';
import '../student.css';
export default function StudentLoginPage() {
const tenant = getTenantContext();
const { bootstrapError, bootstrapStatus, currentUser, refresh, runtimeConfig, tenant } = useApp();
const params = currentRouteParams();
const [portal, setPortal] = useState<Portal>(appEnv.portal);
const [runtimeReady, setRuntimeReady] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [debugCode, setDebugCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [reason, setReason] = useState('');
@@ -21,8 +19,11 @@ export default function StudentLoginPage() {
const [verifying, setVerifying] = useState(false);
const phoneValue = phone.trim();
const codeValue = code.trim();
const portal = runtimeConfig.portal;
const runtimeReady = Boolean(tenant) && !['idle', 'loading-runtime', 'resolving-tenant'].includes(bootstrapStatus);
const canSendCode = runtimeReady && /^1\d{10}$/.test(phoneValue) && !sending;
const canSubmit = runtimeReady && /^1\d{10}$/.test(phoneValue) && /^\d{4,8}$/.test(codeValue || debugCode) && !verifying;
const displayError = error || (bootstrapStatus === 'forbidden' ? bootstrapError : '');
function setPhoneDigits(value: string) {
setPhone(value.replace(/\D/g, '').slice(0, 11));
@@ -33,25 +34,22 @@ export default function StudentLoginPage() {
}
useEffect(() => {
ensureRuntimeConfigLoaded()
.then(async () => {
await ensureTenantResolved();
setPortal(appEnv.portal);
setRuntimeReady(true);
if (params.reason) {
setReason(decodeURIComponent(String(params.reason)));
return;
}
loadCurrentUser()
.then(() => redirectAfterLogin(params.redirect || landingPath()))
.catch(() => undefined);
})
.catch(() => {
setPortal(appEnv.portal);
setRuntimeReady(true);
if (params.reason) {
setReason(decodeURIComponent(String(params.reason)));
return;
}
refresh({ path: landingPath(), authenticatePublic: true })
.then(authorized => {
if (authorized) redirectAfterLogin(params.redirect || landingPath());
});
}, []);
useEffect(() => {
if (bootstrapStatus === 'ready' && currentUser && !reason) {
redirectAfterLogin(params.redirect || landingPath());
}
}, [bootstrapStatus, currentUser, reason]);
const isPlatformAdmin = portal === 'platform-admin';
const isTenantAdmin = portal === 'tenant-admin';
const isAdminPortal = isPlatformAdmin || isTenantAdmin;
@@ -91,11 +89,11 @@ export default function StudentLoginPage() {
setError('');
setMessage('');
try {
await ensureTenantResolved();
const result = await sendSmsCode(phoneValue);
const nextCode = typeof result.debugCode === 'string' ? result.debugCode : '';
setDebugCode(nextCode);
setCode('');
setCodeSent(true);
setMessage(nextCode ? `验证码已发送,开发环境验证码 ${nextCode}` : '验证码已发送');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '验证码发送失败');
@@ -117,7 +115,9 @@ export default function StudentLoginPage() {
setError('');
try {
await verifySmsCode(phoneValue, codeValue || debugCode);
redirectAfterLogin(params.redirect || landingPath());
const redirectPath = params.redirect || landingPath();
const authorized = await refresh({ path: redirectPath, authenticatePublic: true });
if (authorized) redirectAfterLogin(redirectPath);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '登录失败');
} finally {
@@ -142,26 +142,24 @@ export default function StudentLoginPage() {
{reason ? <Text className='error-text'>{reason}</Text> : null}
<View className='login-field'>
<Text className='login-field-label'></Text>
<View className='login-input-shell'>
<View className='login-input-shell login-phone-shell'>
<Input className='login-input-native' type='text' maxlength={11} placeholder='请输入手机号' value={phone} onInput={event => setPhoneDigits(String(event.detail.value || ''))} />
</View>
</View>
<View className='login-field'>
<Text className='login-field-label'></Text>
<View className='login-code-row'>
<View className='login-input-shell login-code-shell'>
<Input className='login-input-native' type='text' maxlength={6} placeholder='请输入验证码' value={code} onInput={event => setCodeDigits(String(event.detail.value || ''))} />
</View>
<Button className='login-send-button secondary-button' disabled={!canSendCode} loading={sending} onClick={handleSendCode}>
{message ? '重发' : '发送'}
</Button>
<View className='login-input-shell login-code-shell'>
<Input className='login-input-native' type='text' maxlength={6} placeholder={codeSent ? '请输入收到的验证码' : '先发送验证码'} value={code} onInput={event => setCodeDigits(String(event.detail.value || ''))} />
</View>
<Text className={`login-field-help ${message ? 'success' : ''}`}>{message || '收到短信后在这里填写验证码'}</Text>
<Button className='login-send-button secondary-button' disabled={!canSendCode} loading={sending} onClick={handleSendCode}>
{codeSent ? '重新发送验证码' : '发送验证码'}
</Button>
<Text className={`login-field-help ${message ? 'success' : ''}`}>{message || '收到短信后在上方填写验证码'}</Text>
</View>
<Button className='primary-button' disabled={!canSubmit} loading={verifying} onClick={handleLogin}>
</Button>
{error ? <Text className='error-text'>{error}</Text> : null}
{displayError ? <Text className='error-text'>{displayError}</Text> : null}
</View>
</View>
);

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { useApp } from '@/app/AppProvider';
import { launchPayment, paymentReturnUrl } from '@/capabilities/payment';
import { copyText } from '@/capabilities/share';
import {
createPayment,
loadOrderDetail,
@@ -9,8 +12,6 @@ import {
type OrderStatus,
type PaymentCreateResult,
} from '@/services/commerce';
import { getTenantContext } from '@/services/api';
import { isH5Runtime } from '@/env';
import '../student.css';
function centsToYuan(value?: number | null) {
@@ -49,7 +50,7 @@ function paymentUrl(result?: PaymentCreateResult | null) {
export default function StudentOrderDetailPage() {
const router = useRouter();
const orderNo = router.params?.orderNo || '';
const tenant = getTenantContext();
const { tenant } = useApp();
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [status, setStatus] = useState<OrderStatus | null>(null);
const [payment, setPayment] = useState<PaymentCreateResult | null>(null);
@@ -95,20 +96,24 @@ export default function StudentOrderDetailPage() {
const payload = await createPayment({
orderNo,
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
returnUrl: paymentReturnUrl(),
quitUrl: paymentReturnUrl(),
});
const nextPayment = payload.item || null;
setPayment(nextPayment);
const url = paymentUrl(nextPayment);
if (url && isH5Runtime() && typeof window !== 'undefined') {
window.location.href = url;
} else if (url) {
await Taro.setClipboardData({ data: url });
setMessage('支付链接已复制。');
} else {
if (nextPayment?.provider === 'manual' && !url) {
setMessage(nextPayment?.provider === 'manual' ? '线下支付订单已生成,请联系教务或客服确认。' : '支付参数已生成。');
return;
}
const result = await launchPayment({
provider: nextPayment?.provider,
paymentParams: nextPayment?.paymentParams,
paymentUrl: url,
});
if (result === 'completed') reload();
if (result === 'copied-url') setMessage('支付链接已复制。');
if (result === 'copied-params') setMessage('支付参数已生成并复制。');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '继续支付失败');
} finally {
@@ -118,9 +123,7 @@ export default function StudentOrderDetailPage() {
async function copyAfterSalesInfo() {
const serviceText = tenant?.branding?.slogan || tenant?.branding?.brandName || '请联系当前租户客服处理售后';
await Taro.setClipboardData({
data: `订单号:${orderNo}\n售后说明${serviceText}`,
});
await copyText(`订单号:${orderNo}\n售后说明${serviceText}`);
setMessage('订单售后信息已复制。');
}

View File

@@ -17,7 +17,8 @@ import {
type QuestionItem,
} from '@/services/learning';
import { submitFeedback } from '@/services/profile';
import { getStorage, setStorage } from '@/services/storage';
import { createUserStorage } from '@/services/storage';
import { useApp } from '@/app/AppProvider';
import '../student.css';
type AnswerState = {
@@ -148,7 +149,12 @@ function secondsUntil(value?: string | null) {
export default function StudentPracticePage() {
const router = useRouter();
const { currentUser, tenant } = useApp();
const params = router.params || {};
const userStorage = useMemo(
() => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }),
[tenant?.tenantId, currentUser?.id],
);
const [session, setSession] = useState<PracticeSession | null>(null);
const [questions, setQuestions] = useState<QuestionItem[]>([]);
const [index, setIndex] = useState(0);
@@ -185,7 +191,7 @@ export default function StudentPracticePage() {
setSession(nextSession);
setQuestions(nextSession.questions || []);
setAnswerByQuestion(backendAnswers);
const savedIndex = getStorage<number>(indexStorageKey(nextSession.id));
const savedIndex = userStorage.get<number>(indexStorageKey(nextSession.id));
const firstUnanswered = (nextSession.questionIds || []).findIndex(questionId => !backendAnswers[questionId]);
setIndex(typeof savedIndex === 'number' ? savedIndex : Math.max(0, firstUnanswered));
const remaining = secondsUntil(nextSession.expiresAt);
@@ -202,8 +208,8 @@ export default function StudentPracticePage() {
const nextSession = payload.item;
setSession(nextSession);
if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60);
const savedIndex = getStorage<number>(indexStorageKey(nextSession.id));
const savedAnswers = getStorage<Record<string, AnswerState>>(answerStorageKey(nextSession.id));
const savedIndex = userStorage.get<number>(indexStorageKey(nextSession.id));
const savedAnswers = userStorage.get<Record<string, AnswerState>>(answerStorageKey(nextSession.id));
if (savedAnswers) setAnswerByQuestion(savedAnswers);
if (typeof savedIndex === 'number') setIndex(savedIndex);
const questionPayload = collectionId
@@ -238,13 +244,13 @@ export default function StudentPracticePage() {
useEffect(() => {
if (!session) return;
setStorage(indexStorageKey(session.id), index);
}, [index, session?.id]);
userStorage.set(indexStorageKey(session.id), index);
}, [index, session?.id, userStorage]);
useEffect(() => {
if (!session) return;
setStorage(answerStorageKey(session.id), answerByQuestion);
}, [answerByQuestion, session?.id]);
userStorage.set(answerStorageKey(session.id), answerByQuestion);
}, [answerByQuestion, session?.id, userStorage]);
useEffect(() => {
if (!current) return;

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import { logout } from '@/services/auth';
import { useApp } from '@/app/AppProvider';
import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce';
import {
loadLearningStats,
@@ -143,6 +143,7 @@ function avatarPresetLabel(preset?: string | null) {
}
export default function StudentProfilePage() {
const { signOut } = useApp();
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [plans, setPlans] = useState<SvipPlan[]>([]);
const [orders, setOrders] = useState<OrderItem[]>([]);
@@ -236,7 +237,7 @@ export default function StudentProfilePage() {
}
async function handleLogout() {
await logout();
await signOut();
Taro.redirectTo({ url: '/pages/student/login/index' });
}

View File

@@ -119,7 +119,8 @@
.login-input,
.login-input-shell {
box-sizing: border-box;
display: block;
display: flex;
align-items: center;
width: 100%;
height: 54px;
min-height: 54px;
@@ -133,8 +134,6 @@
}
.login-input-shell {
display: flex;
align-items: center;
padding: 0;
overflow: hidden;
}
@@ -156,7 +155,10 @@
}
.login-input-native .taro-input,
.login-input-native .weui-input,
.login-input-native .taro-input__input,
.login-input-native input,
.login-input-native > input,
.login-input-shell input {
box-sizing: border-box;
display: block;
@@ -171,34 +173,23 @@
line-height: 52px;
}
.login-code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 104px;
align-items: center;
gap: 10px;
min-width: 0;
}
.login-code-input {
min-width: 0;
}
.login-code-shell {
min-width: 0;
width: 100%;
padding: 0;
}
.login-send-button {
width: 104px;
min-width: 104px;
max-width: 104px;
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
white-space: nowrap;
}
.login-card .login-input,
.login-card .login-input-shell {
display: block;
display: flex;
align-items: center;
width: 100%;
height: 54px;
min-height: 54px;
@@ -212,8 +203,6 @@
}
.login-card .login-input-shell {
display: flex;
align-items: center;
padding: 0;
}
@@ -231,7 +220,10 @@
}
.login-card .login-input-native .taro-input,
.login-card .login-input-native .weui-input,
.login-card .login-input-native .taro-input__input,
.login-card .login-input-native input,
.login-card .login-input-native > input,
.login-card .login-input-shell input {
box-sizing: border-box;
display: block;
@@ -247,15 +239,15 @@
line-height: 52px;
}
.login-card .login-code-row .login-code-input,
.login-card .login-code-row .login-code-shell {
.login-card .login-phone-shell,
.login-card .login-code-shell {
width: 100%;
}
.login-card .login-send-button.secondary-button {
width: 104px;
min-width: 104px;
max-width: 104px;
width: 100%;
min-width: 0;
max-width: 100%;
height: 54px;
min-height: 54px;
padding: 0;
@@ -2458,13 +2450,9 @@
flex: 1 1 150px;
}
.login-code-row {
grid-template-columns: minmax(0, 1fr) 92px;
}
.login-send-button {
width: 92px;
min-width: 92px;
width: 100%;
min-width: 0;
}
.asset-preview-frame,

View File

@@ -11,7 +11,8 @@ import {
type VocabularyWord,
} from '@/services/learning';
import { playWordPronunciation, type AccentType } from '@/services/pronunciation';
import { getStorage, removeStorage, setStorage } from '@/services/storage';
import { createUserStorage } from '@/services/storage';
import { useApp } from '@/app/AppProvider';
import '../student.css';
type StudyMode = 'plan' | 'unit' | 'favorites';
@@ -56,6 +57,11 @@ function modeLabel(mode: StudyMode) {
}
export default function StudentVocabularyPage() {
const { currentUser, tenant } = useApp();
const userStorage = useMemo(
() => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }),
[tenant?.tenantId, currentUser?.id],
);
const [units, setUnits] = useState<VocabularyUnit[]>([]);
const [unitId, setUnitId] = useState('');
const [mode, setMode] = useState<StudyMode>('plan');
@@ -110,7 +116,7 @@ export default function StudentVocabularyPage() {
newCount: Number(planPayload.item?.newCount || 0),
totalPlanned: Number(planPayload.item?.totalPlanned || planned.length || 0),
});
const savedIndex = getStorage<number>(progressStorageKey(unitId, mode));
const savedIndex = userStorage.get<number>(progressStorageKey(unitId, mode));
setWords(nextWords);
setFavoriteIds(Object.fromEntries((favoritesForStatus.items || []).map(item => wordRecordId(item)).filter(Boolean).map(id => [id, true])));
setStats(statPayload.item || null);
@@ -118,12 +124,12 @@ export default function StudentVocabularyPage() {
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '单词加载失败'))
.finally(() => setLoading(false));
}, [unitId, mode]);
}, [unitId, mode, userStorage]);
useEffect(() => {
if (!unitId || !words.length) return;
setStorage(progressStorageKey(unitId, mode), index);
}, [index, mode, unitId, words.length]);
userStorage.set(progressStorageKey(unitId, mode), index);
}, [index, mode, unitId, userStorage, words.length]);
const current = words[index] || null;
const progressPercent = words.length ? Math.round(((Math.min(index + 1, words.length)) / words.length) * 100) : 0;
@@ -141,7 +147,7 @@ export default function StudentVocabularyPage() {
const unitName = useMemo(() => units.find(item => item.id === unitId)?.name || '单词单元', [units, unitId]);
function resetPosition(nextMode = mode) {
removeStorage(progressStorageKey(unitId, nextMode));
userStorage.remove(progressStorageKey(unitId, nextMode));
setIndex(0);
setShowAnswer(false);
setCompleted(false);

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
import { downloadBase64File, pickLocalFile } from '@/capabilities/file';
import {
adoptPublicQuestionBank,
executeContentImport,
@@ -34,7 +35,6 @@ import {
type PublicQuestionBankItem,
type TenantContentNotificationItem,
} from '@/services/tenantAdmin';
import { isH5Runtime } from '@/env';
import '../admin.css';
function displayBankName(item: PublicQuestionBankItem) {
@@ -52,83 +52,6 @@ function adoptionIdOf(item: PublicQuestionBankItem) {
const importTypes: ImportType[] = ['questions', 'vocabulary', 'handbook', 'scoreline', 'videos'];
const importFormats: ImportSourceFormat[] = ['json', 'csv', 'excel'];
function readLocalFileAsBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result);
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsDataURL(file);
});
}
function readLocalFileAsText(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file, 'utf-8');
});
}
function openH5FilePicker(format: ImportSourceFormat) {
return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => {
if (!isH5Runtime() || typeof document === 'undefined') {
reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。'));
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = format === 'excel' ? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : format === 'csv' ? '.csv,text/csv,text/plain' : '.json,application/json,text/plain';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
reject(new Error('未选择文件'));
return;
}
try {
if (format === 'excel') {
resolve({ fileName: file.name, fileBase64: await readLocalFileAsBase64(file) });
} else {
resolve({ fileName: file.name, text: await readLocalFileAsText(file) });
}
} catch (error) {
reject(error);
}
};
input.click();
});
}
function base64ToUint8Array(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function downloadTemplateFile(template: ImportTemplateItem) {
if (!isH5Runtime() || typeof document === 'undefined') {
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
}
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
const blob = new Blob([base64ToUint8Array(template.contentBase64)], {
type: template.mimeType || 'application/octet-stream',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = template.fileName || `import-template.${template.format || 'json'}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function safeJsonPreview(value: unknown, maxLength = 360) {
try {
return JSON.stringify(value, null, 2).slice(0, maxLength);
@@ -316,7 +239,12 @@ export default function TenantContentPage() {
? { item: template }
: await loadImportTemplate(selectedImportType, sourceFormat === 'excel' ? 'csv' : sourceFormat);
if (!payload.item) throw new Error('模板不存在。');
downloadTemplateFile(payload.item);
if (!payload.item.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
await downloadBase64File(
payload.item.fileName || `import-template.${payload.item.format || 'json'}`,
payload.item.contentBase64,
payload.item.mimeType || 'application/octet-stream',
);
setTemplate(payload.item);
Taro.showToast({ title: '已下载', icon: 'success' });
} catch (nextError) {
@@ -327,14 +255,22 @@ export default function TenantContentPage() {
async function chooseImportFile() {
setError('');
try {
const file = await openH5FilePicker(sourceFormat);
const file = await pickLocalFile({
accept: sourceFormat === 'excel'
? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: sourceFormat === 'csv'
? '.csv,text/csv,text/plain'
: '.json,application/json,text/plain',
extensions: sourceFormat === 'excel' ? ['xlsx'] : sourceFormat === 'csv' ? ['csv', 'txt'] : ['json', 'txt'],
readAs: sourceFormat === 'excel' ? 'base64' : 'text',
});
setSourceName(file.fileName);
if (file.text !== undefined) {
setImportText(file.text);
setFileBase64('');
}
if (file.fileBase64 !== undefined) {
setFileBase64(file.fileBase64);
if (file.base64 !== undefined) {
setFileBase64(file.base64);
setImportText('');
}
} catch (nextError) {

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import { downloadBase64File } from '@/capabilities/file';
import {
createCommissionSettlementProof,
exportCommissionSettlement,
@@ -166,17 +167,6 @@ function shortDate(value?: string | null) {
return value.replace('T', ' ').slice(0, 16);
}
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
if (typeof document === 'undefined') return false;
const link = document.createElement('a');
link.href = `data:${mimeType};base64,${contentBase64}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
return true;
}
interface CouponFormState {
id: string;
code: string;
@@ -964,8 +954,8 @@ export default function TenantMarketingPage() {
const payload = await exportCommissionSettlement(item.id, 'csv');
const exportItem = payload.item;
if (exportItem?.contentBase64 && exportItem.filename) {
const downloaded = downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv');
Taro.showToast({ title: downloaded ? '导出已下载' : '导出已生成', icon: 'success' });
await downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv');
Taro.showToast({ title: '导出已下载', icon: 'success' });
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '结算导出失败');

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
import { useApp } from '@/app/AppProvider';
import {
disableTenantMember,
disableRoleTemplate,
@@ -228,6 +229,7 @@ function catalogLabel(item: TenantPermissionCatalogItem) {
}
export default function TenantSettingsPage() {
const { refreshTenant } = useApp();
const [overview, setOverview] = useState<TenantOverview | null>(null);
const [domains, setDomains] = useState<Record<string, unknown>[]>([]);
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
@@ -541,6 +543,7 @@ export default function TenantSettingsPage() {
...(themeForm.shareCardStyle.trim() ? { shareCardStyle: themeForm.shareCardStyle.trim() } : {}),
},
});
await refreshTenant();
Taro.showToast({ title: '主题已发布', icon: 'success' });
await reloadSettings(selectedRoleId);
} catch (nextError) {

View File

@@ -145,6 +145,8 @@ export default function TenantStudentsPage() {
const [supervisionRulesList, setSupervisionRulesList] = useState<TenantStudentSupervisionRuleItem[]>([]);
const [supervisionRules, setSupervisionRules] = useState(defaultSupervisionRules);
const [scoped, setScoped] = useState(false);
const [studentHasMore, setStudentHasMore] = useState(false);
const [nextStudentCursor, setNextStudentCursor] = useState('');
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -174,12 +176,40 @@ export default function TenantStudentsPage() {
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true);
setStudentHasMore(studentPayload.hasMore === true);
setNextStudentCursor(studentPayload.nextCursor || '');
setFollowups(followupPayload.items || []);
setFollowupReport(reportPayload.item || null);
setSupervisionRulesList(supervisionRulePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
}
async function loadMoreStudents() {
if (!studentHasMore || !nextStudentCursor || busy === 'studentsMore') return;
setBusy('studentsMore');
setError('');
try {
const payload = await loadTenantStudents({
classId: selectedClassId || undefined,
keyword: keyword || undefined,
status: studentStatus || undefined,
cursor: nextStudentCursor,
limit: 80,
});
setStudents(previous => {
const byUserId = new Map(previous.map(item => [item.userId, item]));
for (const item of payload.items || []) byUserId.set(item.userId, item);
return Array.from(byUserId.values());
});
setStudentHasMore(payload.hasMore === true);
setNextStudentCursor(payload.nextCursor || '');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '加载更多学生失败');
} finally {
setBusy('');
}
}
useEffect(() => {
reload('', '');
}, []);
@@ -636,6 +666,11 @@ export default function TenantStudentsPage() {
))}
</View>
{!students.length ? <View className='admin-empty'></View> : null}
{studentHasMore ? (
<View className='admin-actions compact'>
<Button className='admin-button' loading={busy === 'studentsMore'} onClick={loadMoreStudents}></Button>
</View>
) : null}
</View>
<View className='admin-section'>

View File

@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { getTenantContext } from '@/services/api';
import { useApp } from '@/app/AppProvider';
import {
loadTenantDashboard,
loadTenantOverview,
loadTenantPermissions,
type TenantDashboard,
type TenantOverview,
type TenantPermissionsPayload,
} from '@/services/tenantAdmin';
import { requireTenantAdmin } from '@/services/routeGuard';
import '../admin.css';
interface AdminModule {
@@ -30,79 +27,24 @@ const MODULES: AdminModule[] = [
{ key: 'settings', name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录、角色', permission: 'tenant:overview:read' },
];
function boolRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
const parts = permission.split(':').filter(Boolean);
const candidates = [permission];
for (let index = parts.length - 1; index >= 1; index -= 1) {
candidates.push(`${parts.slice(0, index).join(':')}:*`);
}
candidates.push('*');
for (const key of candidates) {
if (typeof permissions[key] === 'boolean') return permissions[key] as boolean;
}
return null;
}
function canOpenModule(payload: TenantPermissionsPayload, item: AdminModule) {
const current = payload.current || {};
const menuPermissions = boolRecord(current.menuPermissions);
if (typeof menuPermissions[item.key] === 'boolean') return menuPermissions[item.key] as boolean;
if (!item.permission) return true;
const effectivePermissions = boolRecord(current.effectivePermissions);
const explicit = explicitPermission(effectivePermissions, item.permission);
if (explicit !== null) return explicit;
const role = String(current.role || '');
const defaults = payload.roleDefaults?.[role] || [];
return defaults.some(permission => {
if (permission === '*') return true;
if (permission === item.permission) return true;
if (permission.endsWith(':*')) return item.permission?.startsWith(permission.slice(0, -1));
return false;
});
}
export default function TenantWorkbenchPage() {
const tenant = getTenantContext();
const { canTenantMenu, tenant } = useApp();
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
const [overview, setOverview] = useState<TenantOverview | null>(null);
const [permissions, setPermissions] = useState<TenantPermissionsPayload>({});
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
requireTenantAdmin('/pages/tenant-admin/workbench/index')
.then(payload => {
if (!payload) return;
setAuthorized(true);
loadTenantDashboard('30d').then(next => setDashboard(next.item || null)).catch(() => setDashboard(null));
loadTenantOverview().then(next => setOverview(next.item || null)).catch(() => setOverview(null));
loadTenantPermissions().then(next => setPermissions(next)).catch(() => setPermissions({}));
})
.catch(() => setPermissions({}));
Promise.all([
loadTenantDashboard('30d').catch(() => ({ item: null })),
loadTenantOverview().catch(() => ({ item: null })),
]).then(([dashboardPayload, overviewPayload]) => {
setDashboard(dashboardPayload.item || null);
setOverview(overviewPayload.item || null);
});
}, []);
const cards = dashboard?.cards || {};
const payment = dashboard?.paymentStats || {};
const modules = MODULES.filter(item => canOpenModule(permissions, item));
if (!authorized) {
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Tenant Admin</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'></Text>
</View>
</View>
</View>
);
}
const modules = MODULES.filter(item => canTenantMenu({ menuKey: item.key, permission: item.permission }));
return (
<View className='admin-page'>

Some files were not shown because too many files have changed in this diff Show More