forked from wangziqi/gongxue-base
Compare commits
10 Commits
78d6ccc319
...
f219eb0bf8
| Author | SHA1 | Date | |
|---|---|---|---|
| f219eb0bf8 | |||
|
|
39f7332f33 | ||
|
|
1c2ce38cea | ||
|
|
0efdb0660c | ||
|
|
ca482f7feb | ||
|
|
c71768fdb0 | ||
|
|
ae491f592b | ||
|
|
3a10daf847 | ||
|
|
a6ffb6b962 | ||
|
|
6cf92358e0 |
33
.env.example
33
.env.example
@@ -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_api,worker 必须为 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 会共享 IP,IP 配额应宽于手机号/设备配额。
|
||||
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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
225
apps/api/src/core/cors.ts
Normal 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;
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
10
apps/api/src/core/request-id.ts
Normal file
10
apps/api/src/core/request-id.ts
Normal 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();
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createRouter(definitions: RouteDefinition[] = allRoutes) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
const allRoutes: RouteDefinition[] = [
|
||||
export const allRoutes: RouteDefinition[] = [
|
||||
...healthRoutes,
|
||||
...authRoutes,
|
||||
...tenantRoutes,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
216
apps/api/src/features/auth/sms-limits.ts
Normal file
216
apps/api/src/features/auth/sms-limits.ts
Normal 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];
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal file
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal 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}`)}%`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
153
apps/api/src/features/tenant/locator.ts
Normal file
153
apps/api/src/features/tenant/locator.ts
Normal 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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
48
apps/api/src/nest/ai.module.ts
Normal file
48
apps/api/src/nest/ai.module.ts
Normal 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 {}
|
||||
38
apps/api/src/nest/api-doc.decorators.ts
Normal file
38
apps/api/src/nest/api-doc.decorators.ts
Normal 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 };
|
||||
35
apps/api/src/nest/api-exception.filter.ts
Normal file
35
apps/api/src/nest/api-exception.filter.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
69
apps/api/src/nest/api-response.dto.ts
Normal file
69
apps/api/src/nest/api-response.dto.ts
Normal 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: '收藏接口返回当前收藏状态' },
|
||||
});
|
||||
14
apps/api/src/nest/api.interceptor.ts
Normal file
14
apps/api/src/nest/api.interceptor.ts
Normal 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') || ''))));
|
||||
}
|
||||
}
|
||||
21
apps/api/src/nest/app.module.ts
Normal file
21
apps/api/src/nest/app.module.ts
Normal 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 {}
|
||||
57
apps/api/src/nest/auth.module.ts
Normal file
57
apps/api/src/nest/auth.module.ts
Normal 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 {}
|
||||
9
apps/api/src/nest/database.provider.ts
Normal file
9
apps/api/src/nest/database.provider.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
apps/api/src/nest/domain-route.service.ts
Normal file
16
apps/api/src/nest/domain-route.service.ts
Normal 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
55
apps/api/src/nest/dto.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/nest/health.module.ts
Normal file
31
apps/api/src/nest/health.module.ts
Normal 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 {}
|
||||
63
apps/api/src/nest/http-hooks.ts
Normal file
63
apps/api/src/nest/http-hooks.ts
Normal 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 };
|
||||
87
apps/api/src/nest/learning.module.ts
Normal file
87
apps/api/src/nest/learning.module.ts
Normal 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 {}
|
||||
79
apps/api/src/nest/legacy-bridge.ts
Normal file
79
apps/api/src/nest/legacy-bridge.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
38
apps/api/src/nest/openapi.ts
Normal file
38
apps/api/src/nest/openapi.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
83
apps/api/src/nest/profile.module.ts
Normal file
83
apps/api/src/nest/profile.module.ts
Normal 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 {}
|
||||
75
apps/api/src/nest/query.dto.ts
Normal file
75
apps/api/src/nest/query.dto.ts
Normal 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;
|
||||
}
|
||||
17
apps/api/src/nest/request-context.factory.ts
Normal file
17
apps/api/src/nest/request-context.factory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
apps/api/src/nest/request.dto.ts
Normal file
113
apps/api/src/nest/request.dto.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/nest/tenant.module.ts
Normal file
40
apps/api/src/nest/tenant.module.ts
Normal 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 {}
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -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 patch:Input 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 构建环境。
|
||||
|
||||
## 视觉规范
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/weapp/",
|
||||
"miniprogramRoot": "dist/weapp-student/",
|
||||
"projectname": "tiku-saas-taro",
|
||||
"description": "工学教育 SaaS 题库 Taro 多端前端",
|
||||
"appid": "touristappid",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
368
apps/taro/src/app/AppProvider.tsx
Normal file
368
apps/taro/src/app/AppProvider.tsx
Normal 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;
|
||||
}
|
||||
81
apps/taro/src/app/permissions.ts
Normal file
81
apps/taro/src/app/permissions.ts
Normal 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;
|
||||
}
|
||||
62
apps/taro/src/app/portal-navigation.ts
Normal file
62
apps/taro/src/app/portal-navigation.ts
Normal 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 } : {}) }] : [];
|
||||
});
|
||||
}
|
||||
40
apps/taro/src/app/route-path.ts
Normal file
40
apps/taro/src/app/route-path.ts
Normal 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}`;
|
||||
}
|
||||
50
apps/taro/src/app/session-events.ts
Normal file
50
apps/taro/src/app/session-events.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
40
apps/taro/src/app/storage-scope.ts
Normal file
40
apps/taro/src/app/storage-scope.ts
Normal 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)}`;
|
||||
}
|
||||
40
apps/taro/src/app/tenant-launch.ts
Normal file
40
apps/taro/src/app/tenant-launch.ts
Normal 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)
|
||||
|| '';
|
||||
}
|
||||
32
apps/taro/src/app/tenant-resolution.ts
Normal file
32
apps/taro/src/app/tenant-resolution.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
165
apps/taro/src/capabilities/file.ts
Normal file
165
apps/taro/src/capabilities/file.ts
Normal 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;
|
||||
}
|
||||
19
apps/taro/src/capabilities/media.ts
Normal file
19
apps/taro/src/capabilities/media.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
apps/taro/src/capabilities/navigation.ts
Normal file
61
apps/taro/src/capabilities/navigation.ts
Normal 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;
|
||||
}
|
||||
29
apps/taro/src/capabilities/payment.ts
Normal file
29
apps/taro/src/capabilities/payment.ts
Normal 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;
|
||||
}
|
||||
21
apps/taro/src/capabilities/share.ts
Normal file
21
apps/taro/src/capabilities/share.ts
Normal 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;
|
||||
}
|
||||
104
apps/taro/src/capabilities/storage.ts
Normal file
104
apps/taro/src/capabilities/storage.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legacy-brand-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.student-legacy-main .student-page {
|
||||
padding-bottom: 132px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
apps/taro/src/components/katex-platform.css
Normal file
1
apps/taro/src/components/katex-platform.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WeApp uses the lightweight formula styles in rich-content.css without bundling web fonts. */
|
||||
1
apps/taro/src/components/katex-platform.h5.css
Normal file
1
apps/taro/src/components/katex-platform.h5.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 : '审计导出失败');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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> : {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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('订单售后信息已复制。');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 : '结算导出失败');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user