feat: establish production SaaS foundation

This commit is contained in:
Codex
2026-07-12 19:26:57 +08:00
parent 1c2ce38cea
commit 39f7332f33
219 changed files with 20647 additions and 2628 deletions

View File

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

3
.gitignore vendored
View File

@@ -83,6 +83,9 @@ whisper_models/
# ✅ 部署脚本本地缓存(记录 package-lock 哈希)
.deploy-cache/
/deploy.env
/.deploy.env
/shared/
# ✅ PocketBase 运行期产出(不入 git
pb_data/

984
README.md

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -6,6 +6,10 @@ export interface ApiConfig {
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
corsTenantDomainsEnabled: boolean;
corsTenantDomainCacheTtlMs: number;
corsTenantDomainNegativeCacheTtlMs: number;
corsTenantDomainCacheMaxEntries: number;
maxJsonBodyBytes: number;
maxImportJsonBodyBytes: number;
authCodePepper: string;
@@ -17,7 +21,16 @@ export interface ApiConfig {
authJwtJwksUrl: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSmsTenantDailyLimit: number;
authSmsPhoneDailyLimit: number;
authSmsIpHourlyLimit: number;
authSmsDeviceHourlyLimit: number;
authSessionTtlSeconds: number;
apiHeadersTimeoutMs: number;
apiRequestTimeoutMs: number;
apiKeepAliveTimeoutMs: number;
apiShutdownGracePeriodMs: number;
apiMaxRequestsPerSocket: number;
allowLegacyAuthHeaders: boolean;
allowPlatformAdminKey: boolean;
platformAdminApiKey: string;
@@ -66,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 (
@@ -100,11 +119,35 @@ 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 (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');
}
@@ -185,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),
@@ -196,7 +243,16 @@ const loadedConfig: ApiConfig = {
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSmsTenantDailyLimit: boundedPositiveNumber('AUTH_SMS_TENANT_DAILY_LIMIT', 20_000, 1, 10_000_000),
authSmsPhoneDailyLimit: boundedPositiveNumber('AUTH_SMS_PHONE_DAILY_LIMIT', 10, 1, 10_000),
authSmsIpHourlyLimit: boundedPositiveNumber('AUTH_SMS_IP_HOURLY_LIMIT', 120, 1, 1_000_000),
authSmsDeviceHourlyLimit: boundedPositiveNumber('AUTH_SMS_DEVICE_HOURLY_LIMIT', 10, 1, 100_000),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
apiHeadersTimeoutMs: boundedPositiveNumber('API_HEADERS_TIMEOUT_MS', 15_000, 1_000, 120_000),
apiRequestTimeoutMs: boundedPositiveNumber('API_REQUEST_TIMEOUT_MS', 120_000, 5_000, 600_000),
apiKeepAliveTimeoutMs: boundedPositiveNumber('API_KEEP_ALIVE_TIMEOUT_MS', 5_000, 1_000, 120_000),
apiShutdownGracePeriodMs: boundedPositiveNumber('API_SHUTDOWN_GRACE_PERIOD_MS', 30_000, 1_000, 300_000),
apiMaxRequestsPerSocket: boundedPositiveNumber('API_MAX_REQUESTS_PER_SOCKET', 1_000, 1, 100_000),
allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction),
allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),

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

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

View File

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

View File

@@ -9,10 +9,29 @@ export interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
url: URL;
requestId: string;
}
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 +44,6 @@ export function getHeader(req: IncomingMessage, name: string): string {
return value || '';
}
export function applyCors(req: IncomingMessage, res: ServerResponse) {
const origin = getHeader(req, 'origin');
const allowAll = config.corsOrigins.includes('*');
if (allowAll) {
res.setHeader('access-control-allow-origin', '*');
} else if (origin && config.corsOrigins.includes(origin)) {
res.setHeader('access-control-allow-origin', origin);
res.setHeader('vary', 'origin');
}
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
}
export function routeKey(method: string | undefined, pathname: string) {
return `${method || 'GET'} ${pathname}`;
}

View File

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

View File

@@ -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;
@@ -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,50 +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,
});
}));
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 codeHash = hashSmsCode(tenantId, phone, purpose, code);
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,
@@ -352,7 +367,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
metadata: { purpose },
});
return insertResult.rows[0];
return updateResult.rows[0];
});
return {

View File

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

View File

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

View File

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

View File

@@ -314,7 +314,10 @@ export async function questionsRoute(ctx: RequestContext) {
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where ${filters.join(' and ')}
order by q.created_at desc
limit $${params.length}

View File

@@ -1195,7 +1195,10 @@ export async function submitAnswerRoute(ctx: RequestContext) {
q.type, v.correct_option_index, v.correct_option_indices, v.answer_text,
v.sub_questions
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
limit 1
`,
@@ -1431,7 +1434,10 @@ async function buildPracticeSessionReport(
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.sub_questions as "subQuestions"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
left join public.question_collection_items ci
on ci.tenant_id = q.tenant_id
and ci.question_id = q.id
@@ -1772,7 +1778,10 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where q.tenant_id = $1 and q.id = any($2::uuid[])
`,
[tenantId, questionIds],
@@ -2171,7 +2180,10 @@ export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
) as "suggestedReviewAt"
from public.wrong_questions wq
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
@@ -2222,7 +2234,10 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
q.type, q.type_label as "typeLabel", v.content
from public.favorite_questions fq
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where fq.tenant_id = $1 and fq.user_id = $2
order by fq.created_at desc
limit $3
@@ -2276,7 +2291,10 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
q.type, q.type_label as "typeLabel", v.content
from public.wrong_questions wq
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
left join public.question_versions v on v.id = q.current_version_id
left join public.question_versions v
on v.tenant_id = q.tenant_id
and v.question_id = q.id
and v.id = q.current_version_id
where wq.tenant_id = $1 and wq.user_id = $2
and ($3::boolean = false or wq.resolved_at is null)
order by wq.last_wrong_at desc

View File

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

View File

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

View File

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

View File

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

View File

@@ -2686,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,
@@ -2735,6 +2757,26 @@ export async function disableTenantMemberRoute(ctx: RequestContext) {
[auth.tenantId, membershipId],
);
await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $3::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
`,
[
auth.tenantId,
result.rows[0].userId,
JSON.stringify({
revokedBy: 'tenant.member.disabled',
membershipStatus: 'disabled',
}),
],
);
await recordAudit(client, auth, 'tenant.member.disabled', 'tenant_memberships', membershipId, {
userId: result.rows[0].userId,
role: result.rows[0].role,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import http from 'node:http';
import { config } from './core/config.js';
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
import { authorizeCorsRequest, CorsPolicy } from './core/cors.js';
import { closePool } from './core/db.js';
import { publicErrorBody, routeKey, sendJson, withResponseMeta } from './core/http.js';
import { requestIdFrom } from './core/request-id.js';
import { createRouter } from './core/router.js';
const routes = createRouter();
@@ -18,8 +21,58 @@ function resolveHandler(method: string | undefined, url: URL) {
return null;
}
const server = http.createServer(async (req, res) => {
applyCors(req, res);
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);
}
let shuttingDown = false;
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);
},
});
export const server = http.createServer(async (req, res) => {
const requestId = requestIdFrom(req);
const startedAt = process.hrtime.bigint();
res.setHeader('x-request-id', requestId);
res.once('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
writeLog({
event: 'http_request',
requestId,
method: req.method || 'GET',
path: (() => {
try { return new URL(req.url || '/', 'http://localhost').pathname; } catch { return '/'; }
})(),
status: res.statusCode,
durationMs: Number(durationMs.toFixed(2)),
}, res.statusCode >= 500);
});
if (shuttingDown) {
res.setHeader('connection', 'close');
sendJson(res, 503, withResponseMeta({ error: 'Service is shutting down', code: 'SERVICE_UNAVAILABLE', requestId }, requestId));
return;
}
const corsDecision = await authorizeCorsRequest(req, res, corsPolicy);
if (!corsDecision.allowed) {
sendJson(res, 403, withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId));
return;
}
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
@@ -30,19 +83,66 @@ const server = http.createServer(async (req, res) => {
const handler = resolveHandler(req.method, url);
if (!handler) {
sendJson(res, 404, { error: 'Not found', path: url.pathname });
sendJson(res, 404, withResponseMeta({ error: 'Not found', code: 'NOT_FOUND', path: url.pathname, requestId }, requestId));
return;
}
try {
const result = await handler({ req, res, url });
sendJson(res, 200, result);
const result = await handler({ req, res, url, requestId });
sendJson(res, 200, withResponseMeta(result, requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
sendJson(res, statusCode, body);
sendJson(res, statusCode, withResponseMeta({ ...body, requestId }, requestId));
}
});
server.listen(config.port, () => {
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
server.headersTimeout = config.apiHeadersTimeoutMs;
server.requestTimeout = config.apiRequestTimeoutMs;
server.keepAliveTimeout = config.apiKeepAliveTimeoutMs;
server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket;
server.on('clientError', (error, socket) => {
writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true);
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
let shutdownPromise: Promise<void> | null = null;
export function shutdown(signal: string) {
if (shutdownPromise) return shutdownPromise;
shuttingDown = true;
shutdownPromise = new Promise(resolve => {
writeLog({ event: 'shutdown_started', signal });
const forceTimer = setTimeout(() => {
writeLog({ event: 'shutdown_deadline_reached', signal }, true);
server.closeAllConnections();
}, config.apiShutdownGracePeriodMs);
forceTimer.unref();
server.close(() => {
clearTimeout(forceTimer);
closePool()
.catch(error => writeLog({ event: 'database_pool_close_failed', error: error instanceof Error ? error.message : 'unknown' }, true))
.finally(() => {
writeLog({ event: 'shutdown_complete', signal });
resolve();
});
});
server.closeIdleConnections();
});
return shutdownPromise;
}
server.listen(config.port, () => {
writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port });
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
shutdown(signal)
.then(() => { process.exitCode = 0; })
.catch(error => {
writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true);
process.exitCode = 1;
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,13 @@
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('');
@@ -22,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));
@@ -34,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;
@@ -92,7 +89,6 @@ export default function StudentLoginPage() {
setError('');
setMessage('');
try {
await ensureTenantResolved();
const result = await sendSmsCode(phoneValue);
const nextCode = typeof result.debugCode === 'string' ? result.debugCode : '';
setDebugCode(nextCode);
@@ -119,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 {
@@ -161,7 +159,7 @@ export default function StudentLoginPage() {
<Button className='primary-button' disabled={!canSubmit} loading={verifying} onClick={handleLogin}>
</Button>
{error ? <Text className='error-text'>{error}</Text> : null}
{displayError ? <Text className='error-text'>{displayError}</Text> : null}
</View>
</View>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { isH5Runtime } from '../env';
import { appEnv, isH5Runtime } from '../env';
export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy';
@@ -21,6 +21,7 @@ export async function resolveApiAuthorization(input: {
hasTokenOverride: boolean;
explicitToken?: string | null;
legacyToken?: string | null;
legacySource?: string | null;
}) {
const authMode = input.authMode || 'auto';
if (authMode === 'none') return null;
@@ -33,10 +34,15 @@ export async function resolveApiAuthorization(input: {
return input.legacyToken || null;
}
const supabaseToken = await supabaseAccessTokenProvider();
if (supabaseToken) return supabaseToken;
if (authMode === 'auto' && input.legacyToken && input.legacySource === 'app_session') {
return input.legacyToken;
}
const supabaseConfigured = isH5Runtime() && Boolean(appEnv.supabaseUrl && appEnv.supabasePublishableKey);
if (supabaseConfigured || authMode === 'supabase') {
return await supabaseAccessTokenProvider();
}
if (authMode === 'supabase') return null;
return input.legacyToken || null;
}

View File

@@ -3,14 +3,24 @@ import { appEnv, ensureRuntimeConfigLoaded } from '@/env';
import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types';
import { buildApiHeaders, resolveApiAuthorization } from './api-auth';
import type { ApiAuthMode } from './api-auth';
import { getStorage, removeStorage, setStorage } from './storage';
import {
clearActiveStorageUserData,
currentSessionStorageKey,
currentTenantContextStorageKey,
getJsonStorage,
removeJsonStorage,
setJsonStorage,
} from '@/capabilities/storage';
import { emitSessionChange } from '@/app/session-events';
import { tenantResolveQuery } from '@/app/tenant-resolution';
const TENANT_KEY = 'tiku:tenant';
const SESSION_KEY = 'tiku:session';
const LEGACY_TENANT_KEY = 'tiku:tenant';
const LEGACY_SESSION_KEY = 'tiku:session';
export class ApiError extends Error {
status: number;
code: string;
requestId?: string;
details?: unknown;
constructor(payload: ApiErrorPayload) {
@@ -18,37 +28,110 @@ export class ApiError extends Error {
this.name = 'ApiError';
this.status = payload.status;
this.code = payload.code;
this.requestId = payload.requestId;
this.details = payload.details;
}
}
export function getTenantContext() {
return getStorage<TenantContext>(TENANT_KEY);
return getJsonStorage<TenantContext>(currentTenantContextStorageKey());
}
export function saveTenantContext(tenant: TenantContext) {
setStorage(TENANT_KEY, tenant);
const previous = getTenantContext();
if (previous?.tenantId && previous.tenantId !== tenant.tenantId) {
removeJsonStorage(currentSessionStorageKey(previous.tenantId));
clearActiveStorageUserData(previous.tenantId);
emitSessionChange('tenant-changed');
}
setJsonStorage(currentTenantContextStorageKey(), tenant);
removeJsonStorage(LEGACY_TENANT_KEY);
removeJsonStorage(LEGACY_SESSION_KEY);
}
export function clearTenantContext() {
removeStorage(TENANT_KEY);
const tenant = getTenantContext();
if (tenant?.tenantId) {
removeJsonStorage(currentSessionStorageKey(tenant.tenantId));
clearActiveStorageUserData(tenant.tenantId);
}
removeJsonStorage(currentTenantContextStorageKey());
removeJsonStorage(LEGACY_TENANT_KEY);
removeJsonStorage(LEGACY_SESSION_KEY);
emitSessionChange('tenant-changed');
}
function discardRejectedTenantContext() {
const tenant = getTenantContext();
if (tenant?.tenantId) {
removeJsonStorage(currentSessionStorageKey(tenant.tenantId));
clearActiveStorageUserData(tenant.tenantId);
}
removeJsonStorage(currentTenantContextStorageKey());
removeJsonStorage(LEGACY_TENANT_KEY);
removeJsonStorage(LEGACY_SESSION_KEY);
}
export function getSession() {
return getStorage<ApiSession>(SESSION_KEY);
const tenant = getTenantContext();
if (!tenant?.tenantId) return null;
const key = currentSessionStorageKey(tenant.tenantId);
const session = getJsonStorage<ApiSession>(key);
if (session?.expiresAt) {
const expiresAt = Date.parse(session.expiresAt);
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
removeJsonStorage(key);
clearActiveStorageUserData(tenant.tenantId);
emitSessionChange('expired');
return null;
}
}
return session;
}
export function saveSession(session: ApiSession) {
setStorage(SESSION_KEY, session);
const tenant = getTenantContext();
if (!tenant?.tenantId) throw new Error('保存会话前必须先解析租户');
setJsonStorage(currentSessionStorageKey(tenant.tenantId), session);
removeJsonStorage(LEGACY_SESSION_KEY);
emitSessionChange('saved');
}
export function clearSession() {
removeStorage(SESSION_KEY);
export function clearSession(options: { emit?: boolean } = {}) {
const tenant = getTenantContext();
if (tenant?.tenantId) {
removeJsonStorage(currentSessionStorageKey(tenant.tenantId));
clearActiveStorageUserData(tenant.tenantId);
}
removeJsonStorage(LEGACY_SESSION_KEY);
if (options.emit !== false) emitSessionChange('cleared');
}
export type { ApiAuthMode, SupabaseAccessTokenProvider } from './api-auth';
export { setSupabaseAccessTokenProviderForTest } from './api-auth';
async function clearRejectedAuthentication(rejectedToken: string | null) {
const currentSession = getSession();
const { resolveApiAuthorization: resolveCurrentAuthorization } = await import('./api-auth');
const currentToken = await resolveCurrentAuthorization({
authMode: 'auto',
hasTokenOverride: false,
legacyToken: currentSession?.token,
legacySource: currentSession?.source,
});
if ((rejectedToken || null) !== (currentToken || null)) return;
clearSession({ emit: false });
try {
const { ensureSupabaseClient } = await import('./supabase');
const supabase = await ensureSupabaseClient();
if (supabase) await supabase.auth.signOut({ scope: 'local' });
} catch {
// The scoped legacy session is already removed; SDK cleanup is best effort.
} finally {
emitSessionChange('cleared');
}
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, '');
}
@@ -61,6 +144,15 @@ function buildUrl(path: string, query?: Record<string, string | number | boolean
return params.length ? `${url}?${params.join('&')}` : url;
}
function responseHeaderValue(headers: Record<string, unknown> | undefined, name: string) {
if (!headers) return '';
const target = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === target) return String(value || '').trim();
}
return '';
}
export async function apiRequest<T>(
path: string,
options: {
@@ -85,6 +177,7 @@ export async function apiRequest<T>(
hasTokenOverride,
explicitToken: options.token,
legacyToken: session?.token,
legacySource: session?.source,
});
const headers = buildApiHeaders({ tenantId, token, extraHeaders: options.headers });
@@ -95,12 +188,17 @@ export async function apiRequest<T>(
header: headers,
});
const payload = (response.data || {}) as Record<string, unknown>;
const responseMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta)
? payload.meta as Record<string, unknown>
: {};
const requestId = String(responseMeta.requestId || payload.requestId || responseHeaderValue(response.header, 'x-request-id') || '') || undefined;
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode === 401) clearSession();
if (response.statusCode === 401) await clearRejectedAuthentication(token);
throw new ApiError({
status: response.statusCode,
code: String(payload.code || 'API_ERROR'),
message: String(payload.message || payload.error || '请求失败'),
requestId,
details: payload,
});
}
@@ -108,7 +206,12 @@ export async function apiRequest<T>(
}
export async function resolveTenant(input: { host?: string; tenantCode?: string } = {}) {
const payload = await apiRequest<{
const runtimeHost = input.host?.trim() || '';
const resolveQuery = tenantResolveQuery({
host: runtimeHost,
tenantCode: input.tenantCode || appEnv.tenantCode,
});
let payload: {
item?: TenantContext;
tenant?: {
id?: string;
@@ -119,20 +222,28 @@ export async function resolveTenant(input: { host?: string; tenantCode?: string
features?: TenantContext['features'];
adminFeatures?: TenantContext['adminFeatures'];
publicConfig?: TenantContext['publicConfig'];
}>('/api/tenant/resolve', {
query: {
host: input.host,
tenantCode: input.tenantCode || appEnv.tenantCode,
},
tenantId: null,
authMode: 'none',
});
};
try {
payload = await apiRequest('/api/tenant/resolve', {
query: resolveQuery,
tenantId: null,
authMode: 'none',
});
} catch (error) {
if (
error instanceof ApiError
&& ['TENANT_DOMAIN_NOT_BOUND', 'TENANT_CODE_NOT_FOUND', 'TENANT_HOST_CONFLICT', 'TENANT_LOCATOR_CONFLICT'].includes(error.code)
) {
discardRejectedTenantContext();
}
throw error;
}
const tenantId = payload.item?.tenantId || payload.tenant?.tenantId || payload.tenant?.id;
if (!tenantId) throw new ApiError({ status: 500, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' });
if (!tenantId) throw new ApiError({ status: 502, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' });
const context: TenantContext = {
tenantId,
tenantSlug: payload.item?.tenantSlug || payload.tenant?.slug,
host: input.host,
host: runtimeHost || undefined,
branding: payload.item?.branding || payload.branding || {},
features: payload.item?.features || payload.features || {},
adminFeatures: payload.item?.adminFeatures || payload.adminFeatures || {},

View File

@@ -1,10 +1,22 @@
import { apiRequest, clearSession, saveSession } from './api';
import { apiRequest, clearSession, getTenantContext, saveSession } from './api';
import type { ApiEnvelope, CurrentUser } from '@/types';
import { activateStorageUser, getJsonStorage, setJsonStorage } from '@/capabilities/storage';
export { subscribeSessionChanges as subscribeAuthChanges } from '@/app/session-events';
const SMS_DEVICE_ID_KEY = 'tiku:auth:sms-device-id';
function smsDeviceId() {
const existing = getJsonStorage<string>(SMS_DEVICE_ID_KEY);
if (existing) return existing;
const generated = `${Date.now().toString(36)}-${Array.from({ length: 4 }, () => Math.random().toString(36).slice(2)).join('')}`.slice(0, 96);
setJsonStorage(SMS_DEVICE_ID_KEY, generated);
return generated;
}
export async function sendSmsCode(phone: string, purpose: 'login' | 'bind_phone' = 'login') {
return apiRequest<ApiEnvelope<never>>('/api/auth/sms/send', {
method: 'POST',
body: { phone, purpose },
body: { phone, purpose, deviceId: smsDeviceId() },
authMode: 'none',
});
}
@@ -15,7 +27,19 @@ export async function verifySmsCode(phone: string, code: string, purpose: 'login
body: { phone, code, purpose },
authMode: 'none',
});
if (payload.session?.token) saveSession(payload.session);
if (payload.session?.token) {
try {
const { ensureSupabaseClient } = await import('./supabase');
const supabase = await ensureSupabaseClient();
if (supabase) await supabase.auth.signOut({ scope: 'local' });
} catch {
// The app session remains authoritative even if SDK cleanup is unavailable.
}
const tenant = getTenantContext();
const user = payload.user || payload.item;
if (tenant?.tenantId && user?.id) activateStorageUser(tenant.tenantId, user.id);
saveSession({ ...payload.session, source: 'app_session' });
}
return payload;
}
@@ -27,6 +51,14 @@ export async function logout() {
try {
await apiRequest('/api/auth/logout', { method: 'POST' });
} finally {
clearSession();
clearSession({ emit: false });
try {
const { ensureSupabaseClient } = await import('./supabase');
const supabase = await ensureSupabaseClient();
if (supabase) await supabase.auth.signOut();
} finally {
const { emitSessionChange } = await import('@/app/session-events');
emitSessionChange('cleared');
}
}
}

View File

@@ -1,21 +1,22 @@
import Taro from '@tarojs/taro';
import { appEnv, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
import type { ApiEnvelope, CurrentUser } from '@/types';
import { getTenantContext, resolveTenant } from './api';
import { ApiError, getTenantContext, resolveTenant } from './api';
import { loadCurrentUser } from './auth';
import { loadPlatformPermissions } from './platformAdmin';
import { replaceLocation, runtimeHost } from '@/capabilities/navigation';
import { normalizePagePath, safePageRedirectPath } from '@/app/route-path';
export { normalizePagePath } from '@/app/route-path';
let pendingGuardPath = '';
function hostFromRuntime() {
if (isH5Runtime() && typeof window !== 'undefined') return window.location.host;
return '';
return runtimeHost();
}
export function safeRedirectPath(path: string) {
if (!path.startsWith('/pages/') || path.startsWith('/pages/student/login/') || path.startsWith('/pages/bootstrap/')) return landingPath();
if (appEnv.portal === 'tenant-admin') return path.startsWith('/pages/tenant-admin/') ? path : landingPath();
if (appEnv.portal === 'platform-admin') return path.startsWith('/pages/platform-admin/') ? path : landingPath();
return path.startsWith('/pages/student/') ? path : landingPath();
return safePageRedirectPath(path, appEnv.portal, landingPath());
}
function loginUrl(redirectPath: string) {
@@ -27,27 +28,28 @@ function forbiddenUrl(reason: string, redirectPath: string) {
}
function redirectToAuthUrl(url: string) {
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.replace(url);
return;
}
Taro.redirectTo({ url });
void replaceLocation(url);
}
export function redirectToLogin(redirectPath: string) {
redirectToAuthUrl(loginUrl(redirectPath));
}
export function redirectToForbidden(reason: string, redirectPath: string) {
redirectToAuthUrl(forbiddenUrl(reason, redirectPath));
}
export function currentPagePath() {
if (isH5Runtime() && typeof window !== 'undefined') {
const hashPath = (window.location.hash || '').replace(/^#!?/, '').split('?')[0];
const hashPath = normalizePagePath(window.location.hash || '');
if (hashPath.startsWith('/pages/')) return hashPath;
if (hashPath.startsWith('pages/')) return `/${hashPath}`;
const pathname = window.location.pathname || '';
const pageIndex = pathname.indexOf('/pages/');
if (pageIndex >= 0) return pathname.slice(pageIndex).split('?')[0];
if (pageIndex >= 0) return normalizePagePath(pathname.slice(pageIndex));
if (!pathname || pathname === '/' || pathname.endsWith('/index.html')) return landingPath();
}
const instance = Taro.getCurrentInstance();
const path = instance.router?.path || '';
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return normalizedPath;
return normalizePagePath(instance.router?.path || '');
}
export function currentRouteParams() {
@@ -87,28 +89,32 @@ export function landingPath() {
export async function ensureTenantResolved() {
await ensureRuntimeConfigLoaded();
if (appEnv.portal === 'platform-admin') return null;
const current = getTenantContext();
if (current?.tenantId) return current;
return resolveTenant({ host: hostFromRuntime() });
}
export async function requireSignedIn(redirectPath: string): Promise<ApiEnvelope<CurrentUser> | null> {
await ensureTenantResolved();
if (appEnv.portal !== 'platform-admin') await ensureTenantResolved();
try {
return await loadCurrentUser();
} catch {
redirectToAuthUrl(loginUrl(redirectPath));
} catch (error) {
if (error instanceof ApiError && error.status === 403) redirectToForbidden('当前账号不是平台管理员', redirectPath);
else redirectToLogin(redirectPath);
return null;
}
}
export async function requirePlatformAdmin(redirectPath: string) {
const payload = await requireSignedIn(redirectPath);
if (!payload) return null;
const user = payload.user || payload.item;
const roles = user?.roles || [];
if (user?.primaryRole === 'platform_admin' || roles.includes('platform_admin')) return payload;
redirectToAuthUrl(forbiddenUrl('当前账号不是平台管理员', redirectPath));
try {
const payload = await loadPlatformPermissions();
if (payload.item?.userId) return payload;
} catch {
redirectToLogin(redirectPath);
return null;
}
redirectToForbidden('当前账号不是平台管理员', redirectPath);
return null;
}
@@ -121,7 +127,7 @@ export async function requireTenantAdmin(redirectPath: string) {
if (user?.primaryRole === 'platform_admin') return payload;
if (user?.primaryRole && allowedRoles.has(user.primaryRole)) return payload;
if (roles.some(role => allowedRoles.has(role) || role === 'platform_admin')) return payload;
redirectToAuthUrl(forbiddenUrl('当前账号没有租户后台权限', redirectPath));
redirectToForbidden('当前账号没有租户后台权限', redirectPath);
return null;
}
@@ -154,9 +160,5 @@ export async function guardCurrentRoute() {
export function redirectAfterLogin(rawRedirect?: string) {
const redirectPath = rawRedirect ? decodeURIComponent(rawRedirect) : landingPath();
const url = safeRedirectPath(redirectPath);
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.replace(url);
return;
}
Taro.redirectTo({ url });
void replaceLocation(url);
}

View File

@@ -1,19 +1,44 @@
import Taro from '@tarojs/taro';
import {
currentTenantDataStorageKey,
getActiveStorageUserId,
getJsonStorage,
removeJsonStorage,
scopedTenantDataStorageKey,
setJsonStorage,
} from '@/capabilities/storage';
export interface UserStorageScope {
tenantId: string;
userId: string;
}
export function createUserStorage(scope: UserStorageScope) {
const storageKey = (key: string) => scopedTenantDataStorageKey(scope.tenantId, scope.userId, key);
const isActive = () => getActiveStorageUserId(scope.tenantId) === scope.userId;
return {
get<T>(key: string) {
if (!isActive()) return null;
return getJsonStorage<T>(storageKey(key));
},
set<T>(key: string, value: T) {
if (!isActive()) return;
setJsonStorage(storageKey(key), value);
},
remove(key: string) {
if (!isActive()) return;
removeJsonStorage(storageKey(key));
},
};
}
export function getStorage<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;
}
return getJsonStorage<T>(currentTenantDataStorageKey(key));
}
export function setStorage<T>(key: string, value: T) {
Taro.setStorageSync(key, JSON.stringify(value));
setJsonStorage(currentTenantDataStorageKey(key), value);
}
export function removeStorage(key: string) {
Taro.removeStorageSync(key);
removeJsonStorage(currentTenantDataStorageKey(key));
}

View File

@@ -1,4 +1,4 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { createClient, type AuthChangeEvent, type Session, type SupabaseClient } from '@supabase/supabase-js';
import { appEnv, ensureRuntimeConfigLoaded } from '@/env';
let client: SupabaseClient | null = null;
@@ -31,3 +31,10 @@ export async function getSupabaseAccessToken() {
const { data } = await supabase.auth.getSession();
return data.session?.access_token || null;
}
export async function subscribeSupabaseAuthChanges(listener: (event: AuthChangeEvent, session: Session | null) => void) {
const supabase = await ensureSupabaseClient();
if (!supabase) return () => undefined;
const { data } = supabase.auth.onAuthStateChange((event, session) => listener(event, session));
return () => data.subscription.unsubscribe();
}

View File

@@ -1196,8 +1196,8 @@ export async function loadTenantClasses(limit = 50) {
return apiRequest<{ items?: TenantClassItem[]; scoped?: boolean }>('/api/tenant-admin/classes', { query: { limit } });
}
export async function loadTenantStudents(query: { keyword?: string; classId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean }>('/api/tenant-admin/students', {
export async function loadTenantStudents(query: { keyword?: string; classId?: string; status?: string; cursor?: string; limit?: number } = {}) {
return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean; hasMore?: boolean; nextCursor?: string | null }>('/api/tenant-admin/students', {
query: { ...query, limit: query.limit || 50 },
});
}

View File

@@ -0,0 +1,115 @@
import { createContext, type CSSProperties, type PropsWithChildren, useContext, useEffect, useMemo } from 'react';
import { isH5Runtime } from '@/env';
import { useApp } from '@/app/AppProvider';
import { resolveTheme, themeCssVariables, type ThemeAssets, type ThemeCustomCssVars, type ThemeTokens } from './tokens';
interface ThemeContextValue {
tokens: ThemeTokens;
assets: ThemeAssets;
customCssVars: ThemeCustomCssVars;
rootStyle: CSSProperties;
}
const initialTheme = resolveTheme(null);
const ThemeContext = createContext<ThemeContextValue>({
...initialTheme,
rootStyle: themeCssVariables(initialTheme.tokens) as CSSProperties,
});
const managedAttribute = 'data-tiku-theme-managed';
const createdAttribute = 'data-tiku-theme-created';
const originalContentAttribute = 'data-tiku-theme-original-content';
const originalHrefAttribute = 'data-tiku-theme-original-href';
let managedCustomCssVars = new Set<string>();
function updateManagedMeta(selector: string, attributes: Record<string, string>, content?: string) {
let element = document.head.querySelector<HTMLMetaElement>(selector);
if (!content) {
if (!element?.hasAttribute(managedAttribute)) return;
if (element.getAttribute(createdAttribute) === 'true') element.remove();
else {
element.setAttribute('content', element.getAttribute(originalContentAttribute) || '');
element.removeAttribute(managedAttribute);
element.removeAttribute(originalContentAttribute);
}
return;
}
if (!element) {
element = document.createElement('meta');
Object.entries(attributes).forEach(([key, value]) => element?.setAttribute(key, value));
element.setAttribute(createdAttribute, 'true');
document.head.appendChild(element);
} else if (!element.hasAttribute(managedAttribute)) {
element.setAttribute(originalContentAttribute, element.getAttribute('content') || '');
}
element.setAttribute(managedAttribute, 'true');
element.setAttribute('content', content);
}
function updateManagedFavicon(faviconUrl?: string) {
let favicon = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!faviconUrl) {
if (!favicon?.hasAttribute(managedAttribute)) return;
if (favicon.getAttribute(createdAttribute) === 'true') favicon.remove();
else {
const originalHref = favicon.getAttribute(originalHrefAttribute) || '';
if (originalHref) favicon.setAttribute('href', originalHref);
else favicon.removeAttribute('href');
favicon.removeAttribute(managedAttribute);
favicon.removeAttribute(originalHrefAttribute);
}
return;
}
if (!favicon) {
favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.setAttribute(createdAttribute, 'true');
document.head.appendChild(favicon);
} else if (!favicon.hasAttribute(managedAttribute)) {
favicon.setAttribute(originalHrefAttribute, favicon.getAttribute('href') || '');
}
favicon.setAttribute(managedAttribute, 'true');
favicon.href = faviconUrl;
}
function applyDocumentTheme(tokens: ThemeTokens, assets: ThemeAssets, customCssVars: ThemeCustomCssVars) {
const nextCustomCssVarKeys = new Set(Object.keys(customCssVars));
managedCustomCssVars.forEach(key => {
if (!nextCustomCssVarKeys.has(key)) document.documentElement.style.removeProperty(key);
});
const variables = themeCssVariables(tokens);
Object.entries(variables).forEach(([key, value]) => document.documentElement.style.setProperty(key, value));
Object.entries(customCssVars).forEach(([key, value]) => document.documentElement.style.setProperty(key, value));
managedCustomCssVars = nextCustomCssVarKeys;
updateManagedMeta('meta[name="theme-color"]', { name: 'theme-color' }, tokens.primary);
updateManagedMeta('meta[property="og:image"]', { property: 'og:image' }, assets.shareImageUrl);
updateManagedFavicon(assets.faviconUrl);
}
export function ThemeProvider({ children }: PropsWithChildren) {
const { tenant } = useApp();
const value = useMemo<ThemeContextValue>(() => {
const resolved = resolveTheme(tenant?.branding);
return {
...resolved,
rootStyle: {
...themeCssVariables(resolved.tokens),
...resolved.customCssVars,
minHeight: '100%',
backgroundColor: resolved.tokens.page,
color: resolved.tokens.text,
} as CSSProperties,
};
}, [tenant?.tenantId, tenant?.branding]);
useEffect(() => {
if (!isH5Runtime() || typeof document === 'undefined') return;
applyDocumentTheme(value.tokens, value.assets, value.customCssVars);
}, [value.tokens, value.assets, value.customCssVars]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}

View File

@@ -0,0 +1,139 @@
import type { TenantBranding } from '@/types';
export interface ThemeTokens {
primary: string;
primaryStrong: string;
primarySoft: string;
accent: string;
page: string;
card: string;
cardSoft: string;
text: string;
muted: string;
border: string;
borderStrong: string;
danger: string;
success: string;
warning: string;
radius: string;
radiusSmall: string;
}
export interface ThemeAssets {
logoUrl: string;
faviconUrl: string;
shareImageUrl: string;
iconSet: string;
shareCardStyle: string;
}
export type ThemeCustomCssVars = Record<string, string>;
export const defaultThemeTokens: ThemeTokens = {
primary: '#1152d4',
primaryStrong: '#1d4ed8',
primarySoft: '#eef5ff',
accent: '#0f766e',
page: '#f4f6f9',
card: '#ffffff',
cardSoft: '#f8fafc',
text: '#111827',
muted: '#64748b',
border: '#e2e8f0',
borderStrong: '#cbd5e1',
danger: '#dc2626',
success: '#059669',
warning: '#d97706',
radius: '8px',
radiusSmall: '6px',
};
function record(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function safeColor(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback;
const color = value.trim();
if (/^#[0-9a-f]{3,8}$/i.test(color)) return color;
if (/^(?:rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(color)) return color;
return fallback;
}
function safeRadius(value: unknown, fallback: string) {
if (typeof value === 'number' && Number.isFinite(value)) return `${Math.max(0, Math.min(32, value))}px`;
if (typeof value !== 'string') return fallback;
const match = value.trim().match(/^(\d+(?:\.\d+)?)(px|rpx|rem)$/i);
if (!match) return fallback;
const amount = Math.max(0, Math.min(32, Number(match[1])));
return `${amount}${match[2].toLowerCase()}`;
}
function stringAsset(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function safeCustomCssVars(value: unknown): ThemeCustomCssVars {
const result: ThemeCustomCssVars = {};
for (const [key, raw] of Object.entries(record(value))) {
if (!/^--tiku-[a-z0-9-]{1,48}$/i.test(key) || typeof raw !== 'string') continue;
const text = raw.trim();
if (!text || text.length > 96 || /[{};]/.test(text)) continue;
if (/(<script|javascript:|data:text\/html|expression\s*\(|@import|url\s*\()/i.test(text)) continue;
result[key] = text;
}
return result;
}
export function resolveTheme(branding?: TenantBranding | null) {
const theme = record(branding?.theme);
const publicAssets = record(branding?.publicAssets);
const primary = safeColor(theme.primaryColor ?? theme.primary, defaultThemeTokens.primary);
const tokens: ThemeTokens = {
primary,
primaryStrong: safeColor(theme.primaryStrongColor ?? theme.primaryStrong, primary),
primarySoft: safeColor(theme.primarySoftColor ?? theme.primarySoft, defaultThemeTokens.primarySoft),
accent: safeColor(theme.accentColor ?? theme.accent, defaultThemeTokens.accent),
page: safeColor(theme.pageColor ?? theme.backgroundColor, defaultThemeTokens.page),
card: safeColor(theme.cardColor ?? theme.surfaceColor, defaultThemeTokens.card),
cardSoft: safeColor(theme.cardSoftColor ?? theme.mutedSurfaceColor, defaultThemeTokens.cardSoft),
text: safeColor(theme.textColor, defaultThemeTokens.text),
muted: safeColor(theme.mutedColor ?? theme.secondaryTextColor, defaultThemeTokens.muted),
border: safeColor(theme.borderColor, defaultThemeTokens.border),
borderStrong: safeColor(theme.borderStrongColor, defaultThemeTokens.borderStrong),
danger: safeColor(theme.dangerColor, defaultThemeTokens.danger),
success: safeColor(theme.successColor, defaultThemeTokens.success),
warning: safeColor(theme.warningColor, defaultThemeTokens.warning),
radius: safeRadius(theme.radius ?? theme.cardRadius ?? theme.borderRadius, defaultThemeTokens.radius),
radiusSmall: safeRadius(theme.radiusSmall ?? theme.controlRadius ?? theme.buttonRadius, defaultThemeTokens.radiusSmall),
};
const assets: ThemeAssets = {
logoUrl: stringAsset(publicAssets.logoUrl) || stringAsset(branding?.logoUrl),
faviconUrl: stringAsset(publicAssets.faviconUrl) || stringAsset(branding?.faviconUrl),
shareImageUrl: stringAsset(publicAssets.shareImageUrl),
iconSet: stringAsset(publicAssets.iconSet),
shareCardStyle: stringAsset(publicAssets.shareCardStyle),
};
return { tokens, assets, customCssVars: safeCustomCssVars(theme.customCssVars) };
}
export function themeCssVariables(tokens: ThemeTokens) {
return {
'--tiku-primary': tokens.primary,
'--tiku-primary-2': tokens.primaryStrong,
'--tiku-primary-soft': tokens.primarySoft,
'--tiku-accent': tokens.accent,
'--tiku-page': tokens.page,
'--tiku-card': tokens.card,
'--tiku-card-soft': tokens.cardSoft,
'--tiku-text': tokens.text,
'--tiku-muted': tokens.muted,
'--tiku-border': tokens.border,
'--tiku-border-strong': tokens.borderStrong,
'--tiku-danger': tokens.danger,
'--tiku-success': tokens.success,
'--tiku-warning': tokens.warning,
'--tiku-radius': tokens.radius,
'--tiku-radius-sm': tokens.radiusSmall,
} as const;
}

View File

@@ -2,6 +2,7 @@ export interface TenantBranding {
brandName?: string;
shortName?: string;
logoUrl?: string;
faviconUrl?: string;
slogan?: string;
theme?: Record<string, unknown>;
publicAssets?: Record<string, unknown>;
@@ -18,7 +19,9 @@ export interface TenantContext {
}
export interface ApiSession {
token: string;
token?: string;
id?: string;
source?: string;
expiresAt?: string;
}
@@ -42,12 +45,25 @@ export interface ApiEnvelope<T> {
session?: ApiSession;
code?: string;
message?: string;
[key: string]: unknown;
ok?: boolean;
verified?: boolean;
purpose?: string;
phone?: string;
isNewUser?: boolean;
expireIn?: number;
cooldown?: number;
debugCode?: string;
meta?: ApiResponseMeta;
}
export interface ApiResponseMeta {
requestId: string;
}
export interface ApiErrorPayload {
status: number;
code: string;
message: string;
requestId?: string;
details?: unknown;
}

View File

@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts --loop",
"start": "node dist/apps/worker/src/index.js --loop",
"dev": "tsx watch src/index.ts --loop --job crm",
"start": "node dist/apps/worker/src/index.js --loop --job crm",
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"crm:once": "tsx src/index.ts --once --job crm",
@@ -21,7 +21,8 @@
"assets:once": "tsx src/index.ts --once --job assets",
"imports:once": "tsx src/index.ts --once --job imports",
"public-banks:once": "tsx src/index.ts --once --job public-banks",
"exports:once": "tsx src/index.ts --once --job exports"
"exports:once": "tsx src/index.ts --once --job exports",
"student-supervision:once": "tsx src/index.ts --once --job student-supervision"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",

146
apps/worker/src/cli.ts Normal file
View File

@@ -0,0 +1,146 @@
export const WORKER_JOBS = [
'crm',
'commerce',
'provider-bills',
'platform-billing',
'platform-usage',
'platform-usage-overage',
'platform-dunning',
'platform-dunning-notifications',
'platform-audit-alerts',
'platform-audit-notifications',
'assets',
'imports',
'public-banks',
'exports',
'student-supervision',
] as const;
export type WorkerJob = typeof WORKER_JOBS[number];
export const CONTINUOUS_WORKER_JOBS = [
'crm',
'commerce',
'provider-bills',
'platform-dunning-notifications',
'platform-audit-notifications',
'assets',
'imports',
'public-banks',
'exports',
] as const satisfies readonly WorkerJob[];
export type ContinuousWorkerJob = typeof CONTINUOUS_WORKER_JOBS[number];
export const PERIODIC_WORKER_JOBS = [
'platform-billing',
'platform-usage',
'platform-usage-overage',
'platform-dunning',
'platform-audit-alerts',
'student-supervision',
] as const satisfies readonly WorkerJob[];
export interface WorkerCliOptions {
job: WorkerJob;
loop: boolean;
month?: string;
}
function optionValues(argv: string[], name: string) {
const values: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] !== name) continue;
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} requires a value`);
}
values.push(value);
index += 1;
}
return values;
}
function optionTokenIndexes(argv: string[]) {
const indexes = new Set<number>();
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith('--')) continue;
indexes.add(index);
if (value === '--job' || value === '--month') {
if (argv[index + 1]) indexes.add(index + 1);
index += 1;
}
}
return indexes;
}
function isWorkerJob(value: string): value is WorkerJob {
return (WORKER_JOBS as readonly string[]).includes(value);
}
export function isContinuousWorkerJob(value: WorkerJob): value is ContinuousWorkerJob {
return (CONTINUOUS_WORKER_JOBS as readonly string[]).includes(value);
}
function previousShanghaiMonth(now: Date) {
const currentMonth = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).format(now);
const [year, month] = currentMonth.split('-').map(Number);
return new Date(Date.UTC(year, month - 2, 1)).toISOString().slice(0, 7);
}
export function resolveWorkerMonth(value: string, now = new Date()) {
const normalized = value.trim().toLowerCase();
if (normalized === 'previous') return previousShanghaiMonth(now);
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(normalized)) {
throw new Error('--month must be previous or a valid YYYY-MM value');
}
return normalized;
}
export function parseWorkerCli(argv: string[]): WorkerCliOptions {
const loop = argv.includes('--loop');
const once = argv.includes('--once');
if (loop && once) throw new Error('Choose exactly one worker mode: --loop or --once');
const jobValues = optionValues(argv, '--job');
if (jobValues.length !== 1) {
throw new Error('--job is required exactly once; the worker has no implicit default job');
}
const [job] = jobValues;
if (!isWorkerJob(job)) {
throw new Error(`Unsupported worker job: ${job}. Expected one of: ${WORKER_JOBS.join(', ')}`);
}
if (loop && !isContinuousWorkerJob(job)) {
throw new Error(`Worker job ${job} is periodic and must be scheduled with --once`);
}
const monthValues = optionValues(argv, '--month');
if (monthValues.length > 1) throw new Error('--month may only be provided once');
if (monthValues.length > 0 && !['platform-usage', 'platform-usage-overage'].includes(job)) {
throw new Error('--month is only supported by platform-usage and platform-usage-overage');
}
if (loop && monthValues.length > 0) throw new Error('--month cannot be used with --loop');
const recognizedOptions = new Set(['--loop', '--once', '--job', '--month']);
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith('--')) continue;
if (!recognizedOptions.has(value)) throw new Error(`Unknown worker option: ${value}`);
if (value === '--job' || value === '--month') index += 1;
}
const consumedIndexes = optionTokenIndexes(argv);
for (let index = 0; index < argv.length; index += 1) {
if (!consumedIndexes.has(index)) throw new Error(`Unexpected worker argument: ${argv[index]}`);
}
return {
job,
loop,
month: monthValues[0] ? resolveWorkerMonth(monthValues[0]) : undefined,
};
}

View File

@@ -13,9 +13,11 @@ export interface WorkerConfig {
crmRequestTimeoutMs: number;
crmAllowInsecureLocalhost: boolean;
commerceBatchSize: number;
commercePollIntervalMs: number;
commerceMinAgeSeconds: number;
commerceRequestTimeoutMs: number;
providerBillBatchSize: number;
providerBillPollIntervalMs: number;
providerBillWorkerId: string;
providerBillClaimStaleSeconds: number;
platformBillingBatchSize: number;
@@ -32,6 +34,7 @@ export interface WorkerConfig {
platformDunningBatchSize: number;
platformDunningWorkerId: string;
platformDunningNotificationBatchSize: number;
platformDunningNotificationPollIntervalMs: number;
platformDunningNotificationMaxAttempts: number;
platformDunningNotificationBackoffSeconds: number[];
platformDunningNotificationRequestTimeoutMs: number;
@@ -40,11 +43,13 @@ export interface WorkerConfig {
platformAuditAlertWorkerId: string;
platformAuditAlertLookbackDays: number;
platformAuditNotificationBatchSize: number;
platformAuditNotificationPollIntervalMs: number;
platformAuditNotificationMaxAttempts: number;
platformAuditNotificationBackoffSeconds: number[];
platformAuditNotificationRequestTimeoutMs: number;
platformAuditNotificationAllowInsecureLocalhost: boolean;
assetBatchSize: number;
assetPollIntervalMs: number;
assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number;
assetRequestTimeoutMs: number;
@@ -54,13 +59,18 @@ export interface WorkerConfig {
assetSecurityScanHttpTimeoutMs: number;
assetSecurityScanFailOpen: boolean;
importBatchSize: number;
importPollIntervalMs: number;
importWorkerId: string;
importLeaseSeconds: number;
importHeartbeatIntervalMs: number;
importBackoffSeconds: number[];
publicBankSyncBatchSize: number;
publicBankSyncPollIntervalMs: number;
publicBankSyncCopyLimit: number;
publicBankSyncWorkerId: string;
publicBankSyncClaimStaleSeconds: number;
exportBatchSize: number;
exportPollIntervalMs: number;
exportWorkerId: string;
exportBackoffSeconds: number[];
studentSupervisionBatchSize: number;
@@ -161,6 +171,32 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (nextConfig.platformDunningNotificationAllowInsecureLocalhost) {
failures.push('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
}
const pollIntervals = [
['WORKER_CRM_POLL_INTERVAL_MS', nextConfig.crmPollIntervalMs],
['WORKER_COMMERCE_POLL_INTERVAL_MS', nextConfig.commercePollIntervalMs],
['WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', nextConfig.providerBillPollIntervalMs],
['WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformDunningNotificationPollIntervalMs],
['WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformAuditNotificationPollIntervalMs],
['WORKER_ASSET_POLL_INTERVAL_MS', nextConfig.assetPollIntervalMs],
['WORKER_IMPORT_POLL_INTERVAL_MS', nextConfig.importPollIntervalMs],
['WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', nextConfig.publicBankSyncPollIntervalMs],
['WORKER_EXPORT_POLL_INTERVAL_MS', nextConfig.exportPollIntervalMs],
] as const;
for (const [name, value] of pollIntervals) {
if (!Number.isFinite(value) || value < 1_000 || value > 3_600_000) {
failures.push(`${name} must be between 1000 and 3600000`);
}
}
if (!Number.isFinite(nextConfig.importLeaseSeconds) || nextConfig.importLeaseSeconds < 10 || nextConfig.importLeaseSeconds > 86_400) {
failures.push('WORKER_IMPORT_LEASE_SECONDS must be between 10 and 86400');
}
if (
!Number.isFinite(nextConfig.importHeartbeatIntervalMs)
|| nextConfig.importHeartbeatIntervalMs < 1_000
|| nextConfig.importHeartbeatIntervalMs >= nextConfig.importLeaseSeconds * 500
) {
failures.push('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS must be at least 1000 and less than half the lease duration');
}
const scannerModes = nextConfig.assetSecurityScanner
.split(',')
.map(item => item.trim().toLowerCase())
@@ -229,9 +265,11 @@ const loadedConfig: WorkerConfig = {
crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000),
crmAllowInsecureLocalhost: envBoolean('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false),
commerceBatchSize: envNumber('WORKER_COMMERCE_BATCH_SIZE', 20),
commercePollIntervalMs: envNumber('WORKER_COMMERCE_POLL_INTERVAL_MS', 30_000),
commerceMinAgeSeconds: envNumber('WORKER_COMMERCE_MIN_AGE_SECONDS', 300),
commerceRequestTimeoutMs: envNumber('WORKER_COMMERCE_REQUEST_TIMEOUT_MS', 10_000),
providerBillBatchSize: envNumber('WORKER_PROVIDER_BILL_BATCH_SIZE', 5),
providerBillPollIntervalMs: envNumber('WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', 60_000),
providerBillWorkerId: envString('WORKER_PROVIDER_BILL_ID', `provider-bills-${process.pid}`),
providerBillClaimStaleSeconds: envNumber('WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS', 15 * 60),
platformBillingBatchSize: envNumber('WORKER_PLATFORM_BILLING_BATCH_SIZE', 50),
@@ -248,6 +286,7 @@ const loadedConfig: WorkerConfig = {
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
platformDunningNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', 30_000),
platformDunningNotificationMaxAttempts: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS', 5),
platformDunningNotificationBackoffSeconds: envList('WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
@@ -258,6 +297,7 @@ const loadedConfig: WorkerConfig = {
platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`),
platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14),
platformAuditNotificationBatchSize: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE', 50),
platformAuditNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', 30_000),
platformAuditNotificationMaxAttempts: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS', 5),
platformAuditNotificationBackoffSeconds: envList('WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
@@ -265,6 +305,7 @@ const loadedConfig: WorkerConfig = {
platformAuditNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000),
platformAuditNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false),
assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50),
assetPollIntervalMs: envNumber('WORKER_ASSET_POLL_INTERVAL_MS', 30_000),
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),
assetRequestTimeoutMs: envNumber('WORKER_ASSET_REQUEST_TIMEOUT_MS', 10_000),
@@ -274,15 +315,20 @@ const loadedConfig: WorkerConfig = {
assetSecurityScanHttpTimeoutMs: envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000),
assetSecurityScanFailOpen: envBoolean('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false),
importBatchSize: envNumber('WORKER_IMPORT_BATCH_SIZE', 5),
importPollIntervalMs: envNumber('WORKER_IMPORT_POLL_INTERVAL_MS', 10_000),
importWorkerId: envString('WORKER_IMPORT_ID', `imports-${process.pid}`),
importLeaseSeconds: envNumber('WORKER_IMPORT_LEASE_SECONDS', 120),
importHeartbeatIntervalMs: envNumber('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS', 30_000),
importBackoffSeconds: envList('WORKER_IMPORT_BACKOFF_SECONDS', '30,120,600,1800')
.map((value: string) => Number(value))
.filter((value: number) => Number.isFinite(value) && value > 0),
publicBankSyncBatchSize: envNumber('WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE', 5),
publicBankSyncPollIntervalMs: envNumber('WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', 60_000),
publicBankSyncCopyLimit: envNumber('WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT', 1000),
publicBankSyncWorkerId: envString('WORKER_PUBLIC_BANK_SYNC_ID', `public-banks-${process.pid}`),
publicBankSyncClaimStaleSeconds: envNumber('WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS', 15 * 60),
exportBatchSize: envNumber('WORKER_EXPORT_BATCH_SIZE', 5),
exportPollIntervalMs: envNumber('WORKER_EXPORT_POLL_INTERVAL_MS', 10_000),
exportWorkerId: envString('WORKER_EXPORT_ID', `exports-${process.pid}`),
exportBackoffSeconds: envList('WORKER_EXPORT_BACKOFF_SECONDS', '30,120,600,1800')
.map((value: string) => Number(value))

View File

@@ -3,7 +3,7 @@ import { config } from './config.js';
export const pool = createPool({
connectionString: config.databaseUrl,
max: 5,
applicationName: 'tiku-worker',
});
export async function closePool() {

View File

@@ -3,20 +3,15 @@ import { config } from './config.js';
import { processCrmBatch } from './jobs/crm.js';
import { processCommerceBatch } from './jobs/commerce.js';
import { processAssetBatch } from './jobs/assets.js';
import {
parseWorkerCli,
type ContinuousWorkerJob,
type WorkerJob,
} from './cli.js';
const extraClosers = new Set<() => Promise<void>>();
function hasArg(name: string) {
return process.argv.includes(name);
}
function argValue(name: string, fallback = '') {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] || fallback : fallback;
}
async function runOnce() {
const job = argValue('--job', 'crm');
async function runOnce(job: WorkerJob, month?: string) {
if (job === 'crm') {
const result = await processCrmBatch();
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
@@ -53,7 +48,7 @@ async function runOnce() {
}
if (job === 'platform-usage') {
const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js');
const result = await processPlatformUsageBatch();
const result = await processPlatformUsageBatch({ month });
console.log(
`[worker] platform-usage batch processed=${result.processed}`
+ ` metrics=${result.metrics} created=${result.created}`
@@ -63,7 +58,7 @@ async function runOnce() {
}
if (job === 'platform-usage-overage') {
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
const result = await processPlatformUsageOverageBatch();
const result = await processPlatformUsageOverageBatch({ month });
console.log(
`[worker] platform-usage-overage batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped}`
@@ -125,7 +120,8 @@ async function runOnce() {
console.log(
`[worker] imports batch processed=${result.processed}`
+ ` completed=${result.completed} completedWithErrors=${result.completedWithErrors}`
+ ` failed=${result.failed} retrying=${result.retrying} skipped=${result.skipped}`,
+ ` failed=${result.failed} retrying=${result.retrying}`
+ ` leaseLost=${result.leaseLost} skipped=${result.skipped}`,
);
return;
}
@@ -166,8 +162,21 @@ async function runOnce() {
throw new Error(`Unsupported worker job: ${job}`);
}
async function runLoop() {
console.log('[worker] started');
function loopPollIntervalMs(job: ContinuousWorkerJob) {
if (job === 'crm') return config.crmPollIntervalMs;
if (job === 'commerce') return config.commercePollIntervalMs;
if (job === 'provider-bills') return config.providerBillPollIntervalMs;
if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs;
if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs;
if (job === 'assets') return config.assetPollIntervalMs;
if (job === 'imports') return config.importPollIntervalMs;
if (job === 'public-banks') return config.publicBankSyncPollIntervalMs;
return config.exportPollIntervalMs;
}
async function runLoop(job: ContinuousWorkerJob) {
const pollIntervalMs = loopPollIntervalMs(job);
console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`);
let stopped = false;
const stop = () => {
stopped = true;
@@ -177,19 +186,21 @@ async function runLoop() {
while (!stopped) {
try {
await runOnce();
await runOnce(job);
} catch (error) {
console.error('[worker] job failed', error);
console.error(`[worker] job=${job} failed`, error);
}
await new Promise(resolve => setTimeout(resolve, config.crmPollIntervalMs));
if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
const cli = parseWorkerCli(process.argv.slice(2));
try {
if (hasArg('--loop')) {
await runLoop();
if (cli.loop) {
await runLoop(cli.job as ContinuousWorkerJob);
} else {
await runOnce();
await runOnce(cli.job, cli.month);
}
} finally {
for (const closeExtra of extraClosers) {

View File

@@ -3,7 +3,7 @@ import { config } from '../config.js';
import { executeContentImportJob, type ExecutableContentImportType } from '../../../api/src/features/tenant-content/imports.js';
import { closePool as closeApiImportPool } from '../../../api/src/core/db.js';
interface ImportJobRow {
export interface ImportJobRow {
id: string;
tenantId: string;
createdBy: string | null;
@@ -12,6 +12,8 @@ interface ImportJobRow {
attemptCount: number;
maxAttempts: number;
summary: Record<string, unknown>;
leaseToken: string;
leaseExpiresAt: Date;
}
interface ImportWorkerResult {
@@ -20,9 +22,22 @@ interface ImportWorkerResult {
completedWithErrors: number;
failed: number;
retrying: number;
leaseLost: number;
skipped: number;
}
interface ImportLeaseOptions {
workerId?: string;
batchSize?: number;
leaseSeconds?: number;
heartbeatIntervalMs?: number;
}
interface ImportLeaseHeartbeat {
stop: () => Promise<void>;
ownershipLost: () => boolean;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
@@ -31,11 +46,6 @@ function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function numberValue(value: unknown, fallback: number) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
@@ -62,155 +72,341 @@ function importOptions(summary: Record<string, unknown>) {
};
}
async function claimImportJobs() {
function leaseSettings(options: ImportLeaseOptions = {}) {
return {
workerId: options.workerId || config.importWorkerId,
batchSize: options.batchSize ?? config.importBatchSize,
leaseSeconds: options.leaseSeconds ?? config.importLeaseSeconds,
heartbeatIntervalMs: options.heartbeatIntervalMs ?? config.importHeartbeatIntervalMs,
};
}
export async function claimImportJobs(options: ImportLeaseOptions = {}) {
const settings = leaseSettings(options);
const client = await pool.connect();
try {
await client.query('begin');
const result = await client.query<ImportJobRow>(
const exhausted = await client.query<{
id: string;
tenantId: string;
createdBy: string | null;
importType: ExecutableContentImportType;
attemptCount: number;
maxAttempts: number;
lockedBy: string | null;
}>(
`
select id,
tenant_id as "tenantId",
created_by as "createdBy",
import_type as "importType",
status,
attempt_count as "attemptCount",
max_attempts as "maxAttempts",
summary
from public.content_import_jobs
update public.content_import_jobs
set status = 'failed',
error_message = 'Import worker lease expired after the final attempt',
summary = coalesce(summary, '{}'::jsonb) || jsonb_build_object(
'lastWorkerError', jsonb_build_object(
'code', 'IMPORT_WORKER_LEASE_EXPIRED',
'message', 'Import worker lease expired after the final attempt',
'workerId', locked_by,
'failedAt', now(),
'attemptCount', attempt_count,
'maxAttempts', max_attempts,
'willRetry', false
)
),
next_attempt_at = null,
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
finished_at = now(),
updated_at = now()
where execution_mode = 'async'
and status = 'pending'
and attempt_count < max_attempts
and (next_attempt_at is null or next_attempt_at <= now())
order by created_at asc
limit $1
for update skip locked
and status = 'importing'
and lease_expires_at <= now()
and attempt_count >= max_attempts
returning id,
tenant_id as "tenantId",
created_by as "createdBy",
import_type as "importType",
attempt_count as "attemptCount",
max_attempts as "maxAttempts",
summary #>> '{lastWorkerError,workerId}' as "lockedBy"
`,
[config.importBatchSize],
);
const ids = result.rows.map(row => row.id);
if (ids.length > 0) {
for (const job of exhausted.rows) {
await client.query(
`
update public.content_import_jobs
set locked_at = now(),
locked_by = $2,
attempt_count = attempt_count + 1,
updated_at = now()
where id = any($1::uuid[])
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[ids, config.importWorkerId],
[
job.tenantId,
job.createdBy,
`content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: 'IMPORT_WORKER_LEASE_EXPIRED',
workerId: job.lockedBy,
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
}),
],
);
}
const result = await client.query<ImportJobRow>(
`
with candidates as (
select id
from public.content_import_jobs
where execution_mode = 'async'
and attempt_count < max_attempts
and (
(
status = 'pending'
and (next_attempt_at is null or next_attempt_at <= now())
)
or (
status = 'importing'
and lease_expires_at <= now()
)
)
order by
case when status = 'importing' then 0 else 1 end,
coalesce(lease_expires_at, next_attempt_at, created_at) asc,
created_at asc,
id asc
limit $1
for update skip locked
)
update public.content_import_jobs job
set status = 'importing',
dry_run = false,
locked_at = now(),
locked_by = $2,
lease_token = gen_random_uuid(),
lease_expires_at = now() + make_interval(secs => $3::integer),
last_heartbeat_at = now(),
attempt_count = job.attempt_count + 1,
next_attempt_at = null,
error_message = null,
started_at = coalesce(job.started_at, now()),
finished_at = null,
updated_at = now()
from candidates
where job.id = candidates.id
returning job.id,
job.tenant_id as "tenantId",
job.created_by as "createdBy",
job.import_type as "importType",
job.status,
job.attempt_count as "attemptCount",
job.max_attempts as "maxAttempts",
job.summary,
job.lease_token as "leaseToken",
job.lease_expires_at as "leaseExpiresAt"
`,
[settings.batchSize, settings.workerId, settings.leaseSeconds],
);
await client.query('commit');
return result.rows;
} catch (error) {
await client.query('rollback');
await client.query('rollback').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function markImportFailed(job: ImportJobRow, error: unknown) {
const nextAttempt = job.attemptCount + 1;
const willRetry = nextAttempt < job.maxAttempts;
export function startImportLeaseHeartbeat(
job: Pick<ImportJobRow, 'id' | 'tenantId' | 'leaseToken'>,
options: ImportLeaseOptions = {},
): ImportLeaseHeartbeat {
const settings = leaseSettings(options);
let stopped = false;
let lost = false;
let inFlight: Promise<void> | null = null;
const heartbeat = async () => {
try {
const result = await pool.query(
`
update public.content_import_jobs
set lease_expires_at = now() + make_interval(secs => $4::integer),
last_heartbeat_at = now(),
updated_at = now()
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
returning id
`,
[job.tenantId, job.id, job.leaseToken, settings.leaseSeconds],
);
if (result.rowCount !== 1) lost = true;
} catch (error) {
console.error(`[worker] import lease heartbeat failed jobId=${job.id}`, error);
}
};
const timer = setInterval(() => {
if (stopped || inFlight) return;
inFlight = heartbeat().finally(() => {
inFlight = null;
});
}, settings.heartbeatIntervalMs);
timer.unref();
return {
ownershipLost: () => lost,
stop: async () => {
stopped = true;
clearInterval(timer);
if (inFlight) await inFlight;
},
};
}
export async function markImportFailed(job: ImportJobRow, error: unknown) {
const willRetry = job.attemptCount < job.maxAttempts;
const status = willRetry ? 'pending' : 'failed';
await pool.query(
`
update public.content_import_jobs
set status = $3,
error_message = $4,
summary = coalesce(summary, '{}'::jsonb) || $5::jsonb,
next_attempt_at = case when $6::boolean then now() + make_interval(secs => $7::integer) else null end,
locked_at = null,
locked_by = null,
finished_at = case when $3 = 'failed' then now() else finished_at end,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
job.tenantId,
job.id,
status,
truncate(errorMessage(error)),
JSON.stringify({
lastWorkerError: {
const client = await pool.connect();
try {
await client.query('begin');
const updated = await client.query(
`
update public.content_import_jobs
set status = $4,
error_message = $5,
summary = coalesce(summary, '{}'::jsonb) || $6::jsonb,
next_attempt_at = case when $7::boolean then now() + make_interval(secs => $8::integer) else null end,
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
finished_at = case when $4 = 'failed' then now() else null end,
updated_at = now()
where tenant_id = $1
and id = $2
and status = 'importing'
and lease_token = $3::uuid
and lease_expires_at > now()
returning id
`,
[
job.tenantId,
job.id,
job.leaseToken,
status,
truncate(errorMessage(error)),
JSON.stringify({
lastWorkerError: {
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
failedAt: new Date().toISOString(),
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
willRetry,
},
}),
willRetry,
backoffSeconds(job.attemptCount),
],
);
if (updated.rowCount !== 1) {
await client.query('rollback');
return 'lease_lost' as const;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[
job.tenantId,
job.createdBy,
willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
failedAt: new Date().toISOString(),
nextAttempt,
attemptCount: job.attemptCount,
maxAttempts: job.maxAttempts,
willRetry,
},
}),
willRetry,
backoffSeconds(nextAttempt),
],
);
await pool.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[
job.tenantId,
job.createdBy,
willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`,
job.id,
JSON.stringify({
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.importWorkerId,
nextAttempt,
maxAttempts: job.maxAttempts,
}),
],
);
return willRetry ? 'retrying' : 'failed';
}),
],
);
await client.query('commit');
return willRetry ? 'retrying' as const : 'failed' as const;
} catch (failure) {
await client.query('rollback').catch(() => undefined);
throw failure;
} finally {
client.release();
}
}
export async function processImportBatch(): Promise<ImportWorkerResult> {
const jobs = await claimImportJobs();
const heartbeats = new Map(
jobs.map(job => [job.id, startImportLeaseHeartbeat(job)]),
);
const result: ImportWorkerResult = {
processed: jobs.length,
completed: 0,
completedWithErrors: 0,
failed: 0,
retrying: 0,
leaseLost: 0,
skipped: 0,
};
for (const job of jobs) {
try {
const execution = await executeContentImportJob(
{
tenantId: job.tenantId,
userId: job.createdBy || job.tenantId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
},
{
jobId: job.id,
importType: job.importType,
allowPartial: importOptions(job.summary).allowPartial,
allowQueuedJob: true,
},
);
try {
for (const job of jobs) {
const heartbeat = heartbeats.get(job.id);
try {
const execution = await executeContentImportJob(
{
tenantId: job.tenantId,
userId: job.createdBy || job.tenantId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
},
{
jobId: job.id,
importType: job.importType,
allowPartial: importOptions(job.summary).allowPartial,
allowQueuedJob: true,
leaseToken: job.leaseToken,
},
);
if (execution.idempotent) result.skipped += 1;
else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1;
else if (execution.status === 'completed') result.completed += 1;
else result.skipped += 1;
} catch (error) {
const state = await markImportFailed(job, error);
if (state === 'retrying') result.retrying += 1;
else result.failed += 1;
if (execution.idempotent) result.skipped += 1;
else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1;
else if (execution.status === 'completed') result.completed += 1;
else result.skipped += 1;
} catch (error) {
const state = await markImportFailed(job, error);
if (state === 'retrying') result.retrying += 1;
else if (state === 'failed') result.failed += 1;
else {
result.leaseLost += 1;
result.skipped += 1;
}
} finally {
await heartbeat?.stop();
heartbeats.delete(job.id);
}
}
} finally {
await Promise.all([...heartbeats.values()].map(heartbeat => heartbeat.stop()));
}
return result;

93
deploy.env.example Normal file
View File

@@ -0,0 +1,93 @@
# Copy this file to /opt/tiku-saas/shared/deploy.env on the server.
# Do not commit the real deploy.env.
APP_NAME=tiku-supabase
REPO_URL=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git
BRANCH=main
DEPLOY_ROOT=/opt/tiku-saas
KEEP_RELEASES=5
# Private Gitea repository access.
# Prefer a short-lived token with read-only repository scope.
# Do not put the token in REPO_URL.
# GIT_USERNAME=chenhaogxjy
# GITEA_TOKEN=replace-with-rotated-readonly-token
# Install/build/check gates.
NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev"
# Audit metadata must come from an npm registry that implements the audit API.
# Keep this on the official registry even if package downloads use a mirror.
NPM_AUDIT_REGISTRY=https://registry.npmjs.org/
RUN_CHECKS=true
CHECK_COMMANDS="npm run check:api
npm run check:worker
npm run check:taro"
RUN_API_BUILD=true
RUN_WORKER_BUILD=true
RUN_TARO_H5_BUILD=true
RUN_SECURITY_REPO_SCAN=true
RUN_RUNTIME_AUDIT=true
# This audit covers dependencies that ship in the Taro H5/miniapp bundle.
RUN_TARO_SUPPLY_CHAIN_AUDIT=true
# Production gates.
# auto runs readiness:production when NODE_ENV=production.
NODE_ENV=production
RUN_PRODUCTION_READINESS=auto
RUN_DB_READINESS=true
# Database migrations are intentionally opt-in. Enable only after backup and rehearsal.
# When enabled, the deploy script runs readiness:production:db again after db push
# and before launch:gate; production refuses to disable that post-migration check.
RUN_DB_MIGRATIONS=false
# Do not assign this in the file; inject DATABASE_MIGRATION_URL for the standard
# migration role from a secret manager immediately before deployment.
DB_MIGRATION_COMMAND='supabase db push --db-url "$DATABASE_MIGRATION_URL"'
# H5 runtime configs stay in shared/ and are symlinked into each release dist.
# Production deployment blocks until these files exist and pass strict validation.
STRICT_H5_RUNTIME_CONFIG=true
RUN_H5_SMOKE=true
H5_STUDENT_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-student.runtime-config.json
H5_TENANT_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-tenant-admin.runtime-config.json
H5_PLATFORM_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-platform-admin.runtime-config.json
# Nginx serves /srv/tiku-saas/www/{student,tenant-admin,platform-admin}.
# The deployer stages versioned Web roots beside this path and atomically switches
# /srv/tiku-saas/www after all candidate checks pass.
WWW_ROOT=/srv/tiku-saas/www
WWW_RELEASES_DIR=/srv/tiku-saas/www-releases
# The bundled systemd units run from /opt/tiku-saas/repo. The deployer explicitly
# synchronizes the selected current release into this runtime directory before restart.
SERVICE_REPO_DIR=/opt/tiku-saas/repo
SYNC_SERVICE_REPO=true
# Real production evidence is generated out-of-band and stays in shared/, never in Git.
RUN_LAUNCH_GATE=true
PRODUCTION_LAUNCH_EVIDENCE=/opt/tiku-saas/shared/production-launch-evidence.json
# Production defaults are fail-closed: a real restart strategy and healthcheck are required.
SERVICE_MODE=systemd
SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
# systemd example:
# SERVICE_MODE=systemd
# SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
# pm2 example:
# SERVICE_MODE=pm2
# PM2_ECOSYSTEM=/opt/tiku-saas/current/ecosystem.config.cjs
# PM2_PROCESS_NAMES="tiku-api tiku-worker"
# docker compose example:
# SERVICE_MODE=compose
# COMPOSE_FILE=/opt/tiku-saas/current/docker-compose.api.yml
# Fully custom restart hook. Runs after current symlink switches.
# RESTART_COMMAND='systemctl restart tiku-api.service tiku-workers.target'
# Production refuses to report success without this healthcheck.
HEALTHCHECK_URL=http://127.0.0.1:8787/health
HEALTHCHECK_TIMEOUT_SECONDS=60
HEALTHCHECK_INTERVAL_SECONDS=2

686
deploy.sh Executable file
View File

@@ -0,0 +1,686 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Safe release-style deploy script for tiku-supabase.
#
# Server layout:
# DEPLOY_ROOT/
# current -> releases/<timestamp>-<commit>
# releases/
# shared/
# deploy.env # deploy settings, not committed
# .env # API/worker production env, not committed
# h5-student.runtime-config.json
# h5-tenant-admin.runtime-config.json
# h5-platform-admin.runtime-config.json
#
# Recommended first server run:
# mkdir -p /opt/tiku-saas/shared
# cp deploy.env.example /opt/tiku-saas/shared/deploy.env
# vim /opt/tiku-saas/shared/deploy.env
# vim /opt/tiku-saas/shared/.env
# bash deploy.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() {
printf '[deploy] %s\n' "$*"
}
warn() {
printf '[deploy][warn] %s\n' "$*" >&2
}
die() {
printf '[deploy][error] %s\n' "$*" >&2
exit 1
}
truthy() {
case "${1:-}" in
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
*) return 1 ;;
esac
}
run() {
log "+ $*"
"$@"
}
run_shell() {
log "+ $*"
bash -lc "$*"
}
source_if_exists() {
local file="$1"
if [[ -f "$file" ]]; then
# shellcheck source=/dev/null
source "$file"
fi
}
export_dotenv_if_exists() {
local file="$1"
local line key value
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
line="${line#export }"
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
value="${line#*=}"
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
if [[ -z "${!key+x}" ]]; then
export "$key=$value"
fi
done < "$file"
}
if [[ -n "${DEPLOY_CONFIG:-}" ]]; then
source_if_exists "$DEPLOY_CONFIG"
else
source_if_exists "$SCRIPT_DIR/deploy.env"
source_if_exists "$SCRIPT_DIR/.deploy.env"
fi
: "${APP_NAME:=tiku-supabase}"
: "${REPO_URL:=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git}"
: "${BRANCH:=main}"
: "${DEPLOY_ROOT:=/opt/tiku-saas}"
source_if_exists "$DEPLOY_ROOT/shared/deploy.env"
: "${RELEASES_DIR:=$DEPLOY_ROOT/releases}"
: "${SHARED_DIR:=$DEPLOY_ROOT/shared}"
: "${CURRENT_LINK:=$DEPLOY_ROOT/current}"
: "${KEEP_RELEASES:=5}"
: "${GIT_DEPTH:=1}"
: "${NODE_ENV:=production}"
: "${NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev}"
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
: "${RUN_CHECKS:=true}"
: "${CHECK_COMMANDS:=npm run check:api
npm run check:worker
npm run check:taro}"
: "${RUN_API_BUILD:=true}"
: "${RUN_WORKER_BUILD:=true}"
: "${RUN_TARO_H5_BUILD:=true}"
: "${RUN_SECURITY_REPO_SCAN:=true}"
: "${RUN_RUNTIME_AUDIT:=true}"
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
: "${RUN_PRODUCTION_READINESS:=auto}"
: "${RUN_DB_READINESS:=true}"
: "${RUN_DB_MIGRATIONS:=false}"
: "${DATABASE_MIGRATION_URL:=}"
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
: "${STRICT_H5_RUNTIME_CONFIG:=true}"
: "${RUN_H5_SMOKE:=true}"
: "${RUN_LAUNCH_GATE:=true}"
: "${PRODUCTION_LAUNCH_EVIDENCE:=$SHARED_DIR/production-launch-evidence.json}"
: "${H5_STUDENT_RUNTIME_CONFIG:=$SHARED_DIR/h5-student.runtime-config.json}"
: "${H5_TENANT_RUNTIME_CONFIG:=$SHARED_DIR/h5-tenant-admin.runtime-config.json}"
: "${H5_PLATFORM_RUNTIME_CONFIG:=$SHARED_DIR/h5-platform-admin.runtime-config.json}"
: "${WWW_ROOT:=/srv/tiku-saas/www}"
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
: "${WWW_CURRENT_LINK:=$WWW_ROOT}"
: "${SERVICE_REPO_DIR:=/opt/tiku-saas/repo}"
: "${SYNC_SERVICE_REPO:=true}"
: "${SERVICE_MODE:=systemd}"
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
: "${PM2_ECOSYSTEM:=}"
: "${PM2_PROCESS_NAMES:=}"
: "${COMPOSE_FILE:=}"
: "${RESTART_COMMAND:=}"
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
: "${GIT_TERMINAL_PROMPT:=0}"
export GIT_TERMINAL_PROMPT
export DATABASE_MIGRATION_URL
LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"
LOCK_ACQUIRED=false
PREVIOUS_RELEASE=""
NEW_RELEASE=""
PREVIOUS_WWW_RELEASE=""
NEW_WWW_RELEASE=""
PREVIOUS_SERVICE_BACKUP=""
ROLLBACK_ARMED=false
APP_SWITCHED=false
SERVICE_SYNC_STARTED=false
WWW_SWITCH_STARTED=false
ASKPASS_FILE=""
cleanup() {
local status=$?
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
ROLLBACK_ARMED=false
rollback || true
fi
if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then
rm -f "$ASKPASS_FILE"
fi
if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then
rmdir "$LOCK_DIR" 2>/dev/null || true
fi
}
trap cleanup EXIT
prepare_askpass() {
if [[ -z "${GIT_TOKEN:-}" && -z "${GITEA_TOKEN:-}" ]]; then
return 0
fi
local token="${GIT_TOKEN:-${GITEA_TOKEN:-}}"
local username="${GIT_USERNAME:-oauth2}"
ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/tiku-git-askpass.XXXXXX")"
chmod 700 "$ASKPASS_FILE"
cat > "$ASKPASS_FILE" <<'EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' "$GIT_ASKPASS_USERNAME" ;;
*Password*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
esac
EOF
export GIT_ASKPASS="$ASKPASS_FILE"
export GIT_ASKPASS_USERNAME="$username"
export GIT_ASKPASS_TOKEN="$token"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
validate_deploy_contract() {
[[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT"
case "${WWW_RELEASES_DIR%/}/" in
"${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;;
esac
[[ "$SERVICE_REPO_DIR" != "$RELEASES_DIR" ]] || die "SERVICE_REPO_DIR must not equal RELEASES_DIR"
if [[ "$NODE_ENV" != "production" ]]; then
return 0
fi
truthy "$RUN_TARO_H5_BUILD" || die "Production deployment requires RUN_TARO_H5_BUILD=true"
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
truthy "$STRICT_H5_RUNTIME_CONFIG" || die "Production deployment requires STRICT_H5_RUNTIME_CONFIG=true"
truthy "$RUN_H5_SMOKE" || die "Production deployment requires RUN_H5_SMOKE=true"
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true"
truthy "$RUN_DB_READINESS" \
|| die "Production deployment requires RUN_DB_READINESS=true before launch gate"
if truthy "$RUN_DB_MIGRATIONS"; then
[[ -n "$DATABASE_MIGRATION_URL" ]] \
|| die "Production database migrations require a separate DATABASE_MIGRATION_URL"
fi
[[ -n "$RESTART_COMMAND" || "$SERVICE_MODE" != "none" ]] \
|| die "Production deployment requires a service restart strategy"
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
if [[ "$SERVICE_MODE" == "systemd" ]]; then
truthy "$SYNC_SERVICE_REPO" || die "Production systemd deployment requires SYNC_SERVICE_REPO=true"
[[ -n "$SERVICE_REPO_DIR" ]] || die "Production systemd deployment requires SERVICE_REPO_DIR"
fi
}
acquire_lock() {
mkdir -p "$DEPLOY_ROOT"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
die "Another deployment appears to be running: $LOCK_DIR"
fi
LOCK_ACQUIRED=true
}
link_shared_env() {
local release="$1"
local env_file="$SHARED_DIR/.env"
if [[ -f "$env_file" ]]; then
ln -sfn "$env_file" "$release/.env"
else
warn "No $env_file found. Production readiness and runtime may fail until it exists."
fi
}
load_runtime_env() {
local env_file="$SHARED_DIR/.env"
[[ -r "$env_file" ]] || die "Missing API runtime config: $env_file"
export_dotenv_if_exists "$env_file"
}
link_runtime_config() {
local source_file="$1"
local target_dir="$2"
local label="$3"
if [[ ! -d "$target_dir" ]]; then
warn "H5 dist directory missing for $label: $target_dir"
return 0
fi
if [[ -f "$source_file" ]]; then
ln -sfn "$source_file" "$target_dir/runtime-config.json"
elif truthy "$STRICT_H5_RUNTIME_CONFIG"; then
die "Missing $label runtime config: $source_file"
else
warn "Missing $label runtime config: $source_file"
fi
}
link_h5_runtime_configs() {
local release="$1"
link_runtime_config "$H5_STUDENT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-student" "student"
link_runtime_config "$H5_TENANT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-tenant-admin" "tenant-admin"
link_runtime_config "$H5_PLATFORM_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-platform-admin" "platform-admin"
}
should_run_production_readiness() {
case "$RUN_PRODUCTION_READINESS" in
true|TRUE|1|yes|YES|on|ON) return 0 ;;
false|FALSE|0|no|NO|off|OFF) return 1 ;;
auto)
if [[ "$NODE_ENV" == "production" ]]; then
return 0
fi
if [[ -f "$SHARED_DIR/.env" ]] && grep -Eq '^NODE_ENV=production($|[[:space:]]*)' "$SHARED_DIR/.env"; then
return 0
fi
return 1
;;
*) die "RUN_PRODUCTION_READINESS must be true, false, or auto" ;;
esac
}
run_build_and_checks() {
local release="$1"
cd "$release"
[[ "$NPM_INSTALL_COMMAND" == *"npm ci"* ]] \
|| die "NPM_INSTALL_COMMAND must use npm ci for a locked production install"
[[ "$NPM_INSTALL_COMMAND" == *"--include=dev"* ]] \
|| die "NPM_INSTALL_COMMAND must include Taro build dependencies with --include=dev"
[[ "$NPM_INSTALL_COMMAND" != *"--ignore-scripts"* ]] \
|| die "NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches"
run_shell "$NPM_INSTALL_COMMAND"
if truthy "$RUN_CHECKS"; then
while IFS= read -r command_line; do
[[ -z "$command_line" ]] && continue
run_shell "$command_line"
done <<< "$CHECK_COMMANDS"
fi
if truthy "$RUN_API_BUILD"; then
run npm run build:api
fi
if truthy "$RUN_WORKER_BUILD"; then
run npm run build:worker
fi
if truthy "$RUN_TARO_H5_BUILD"; then
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
run npm run audit:taro:supply-chain
fi
run npm run build:taro:h5:student
run npm run build:taro:h5:tenant
run npm run build:taro:h5:platform
link_h5_runtime_configs "$release"
if truthy "$STRICT_H5_RUNTIME_CONFIG"; then
run node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
run npm run manifest:taro:h5 -- --require-dist --require-runtime-config
else
run node scripts/taro-h5-release-guardrails-test.js --require-dist
run npm run manifest:taro:h5 -- --require-dist
fi
if truthy "$RUN_H5_SMOKE"; then
run npm run smoke:taro:h5
run npm run smoke:taro:h5:interaction
fi
fi
if truthy "$RUN_SECURITY_REPO_SCAN"; then
run npm run security:repo
fi
if truthy "$RUN_RUNTIME_AUDIT"; then
run env NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
fi
link_shared_env "$release"
load_runtime_env
if should_run_production_readiness; then
run npm run readiness:production
fi
if truthy "$RUN_DB_MIGRATIONS"; then
run_shell "$DB_MIGRATION_COMMAND"
fi
if truthy "$RUN_DB_READINESS"; then
log "Running production database readiness after the optional migration step"
run npm run readiness:production:db
fi
if truthy "$RUN_LAUNCH_GATE"; then
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
run env \
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$release" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
fi
}
verify_live_h5_release() {
local release="$1"
truthy "$RUN_LAUNCH_GATE" || return 0
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
log "Verifying the activated H5 release against production URLs"
(
cd "$release"
env \
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$release" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
)
}
stage_h5_release() {
local release="$1"
local release_name
release_name="$(basename "$release")"
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
require_command rsync
rm -rf "$staging"
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-student/" "$staging/student/"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
NEW_WWW_RELEASE="$WWW_RELEASES_DIR/$release_name"
rm -rf "$NEW_WWW_RELEASE"
mv "$staging" "$NEW_WWW_RELEASE"
}
atomic_symlink() {
local target="$1"
local link="$2"
local next_link="${link}.next.$$"
rm -f "$next_link"
ln -s "$target" "$next_link"
if [[ -L "$link" || ! -e "$link" ]]; then
mv -Tf "$next_link" "$link"
return 0
fi
rm -f "$next_link"
return 1
}
switch_www_release() {
[[ -n "$NEW_WWW_RELEASE" ]] || return 0
WWW_SWITCH_STARTED=true
if [[ -L "$WWW_CURRENT_LINK" ]]; then
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_CURRENT_LINK")"
elif [[ -d "$WWW_CURRENT_LINK" ]]; then
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-$(date +%Y%m%d%H%M%S)"
log "Moving existing Web root to $PREVIOUS_WWW_RELEASE"
mv "$WWW_CURRENT_LINK" "$PREVIOUS_WWW_RELEASE"
elif [[ -e "$WWW_CURRENT_LINK" ]]; then
die "WWW root exists but is not a directory or symlink: $WWW_CURRENT_LINK"
fi
atomic_symlink "$NEW_WWW_RELEASE" "$WWW_CURRENT_LINK" \
|| die "$WWW_CURRENT_LINK exists and cannot be replaced by the release symlink"
}
snapshot_service_repo_for_rollback() {
if [[ -n "$PREVIOUS_RELEASE" || ! -d "$SERVICE_REPO_DIR" || -L "$SERVICE_REPO_DIR" ]]; then
return 0
fi
require_command rsync
PREVIOUS_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
mkdir -p "$PREVIOUS_SERVICE_BACKUP"
run rsync -a --delete --exclude .git "$SERVICE_REPO_DIR/" "$PREVIOUS_SERVICE_BACKUP/"
}
sync_service_repo() {
local release="$1"
truthy "$SYNC_SERVICE_REPO" || return 0
[[ -n "$release" && -d "$release" ]] || return 1
require_command rsync
if [[ -L "$SERVICE_REPO_DIR" ]]; then
[[ "$(readlink -f "$SERVICE_REPO_DIR")" == "$(readlink -f "$release")" ]] \
|| die "SERVICE_REPO_DIR symlink must resolve to the selected current release"
return 0
fi
mkdir -p "$SERVICE_REPO_DIR"
run rsync -a --delete --exclude .git "$release/" "$SERVICE_REPO_DIR/"
}
restart_services() {
if [[ -d "$CURRENT_LINK" ]]; then
cd "$CURRENT_LINK"
elif [[ -d "$SERVICE_REPO_DIR" ]]; then
cd "$SERVICE_REPO_DIR"
else
die "Neither CURRENT_LINK nor SERVICE_REPO_DIR is available for service restart"
fi
if [[ -n "$RESTART_COMMAND" ]]; then
run_shell "$RESTART_COMMAND" || return 1
return 0
fi
case "$SERVICE_MODE" in
none)
warn "SERVICE_MODE=none; release switched but no service was restarted."
;;
systemd)
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required when SERVICE_MODE=systemd"
for unit in $SYSTEMD_UNITS; do
run systemctl restart "$unit" || return 1
done
for unit in $SYSTEMD_UNITS; do
run systemctl is-active --quiet "$unit" || return 1
done
;;
pm2)
require_command pm2
if [[ -n "$PM2_ECOSYSTEM" ]]; then
run pm2 startOrReload "$PM2_ECOSYSTEM" --update-env || return 1
elif [[ -n "$PM2_PROCESS_NAMES" ]]; then
for name in $PM2_PROCESS_NAMES; do
run pm2 restart "$name" --update-env || return 1
done
else
die "PM2_ECOSYSTEM or PM2_PROCESS_NAMES is required when SERVICE_MODE=pm2"
fi
;;
compose)
require_command docker
[[ -n "$COMPOSE_FILE" ]] || die "COMPOSE_FILE is required when SERVICE_MODE=compose"
run docker compose -f "$COMPOSE_FILE" up -d --build || return 1
;;
*)
die "Unknown SERVICE_MODE: $SERVICE_MODE"
;;
esac
}
healthcheck() {
if [[ -z "$HEALTHCHECK_URL" ]]; then
return 0
fi
require_command curl
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
log "Waiting for healthcheck: $HEALTHCHECK_URL"
while (( SECONDS < deadline )); do
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
log "Healthcheck passed."
return 0
fi
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
done
return 1
}
switch_current() {
local release="$1"
if [[ -e "$CURRENT_LINK" && ! -L "$CURRENT_LINK" ]]; then
die "$CURRENT_LINK exists and is not a symlink; refusing to replace it"
fi
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
fi
atomic_symlink "$release" "$CURRENT_LINK" || die "Failed to switch $CURRENT_LINK"
}
rollback() {
local rollback_failed=false
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
warn "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_CURRENT_LINK" || rollback_failed=true
elif [[ "$WWW_SWITCH_STARTED" == "true" && -n "$NEW_WWW_RELEASE" ]]; then
warn "No previous Web release recorded; restoring an absent Web root."
rm -f "$WWW_CURRENT_LINK" || rollback_failed=true
fi
local service_rollback_release="$PREVIOUS_RELEASE"
if [[ -z "$service_rollback_release" ]]; then
service_rollback_release="$PREVIOUS_SERVICE_BACKUP"
fi
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_RELEASE" ]]; then
warn "Rolling application release back to $PREVIOUS_RELEASE"
atomic_symlink "$PREVIOUS_RELEASE" "$CURRENT_LINK" || rollback_failed=true
elif [[ "$APP_SWITCHED" == "true" && -n "$NEW_RELEASE" ]]; then
warn "No previous application current release recorded; restoring an absent current link."
rm -f "$CURRENT_LINK" || rollback_failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]] && truthy "$SYNC_SERVICE_REPO" && [[ -n "$service_rollback_release" ]]; then
sync_service_repo "$service_rollback_release" || rollback_failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
restart_services || rollback_failed=true
healthcheck || rollback_failed=true
fi
[[ "$rollback_failed" == "false" ]]
}
prune_releases() {
local keep="$1"
local directory="${2:-$RELEASES_DIR}"
[[ "$keep" =~ ^[0-9]+$ ]] || return 0
(( keep > 0 )) || return 0
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
| sort -r \
| tail -n +"$((keep + 1))" \
| while IFS= read -r old_release; do
if [[ "$old_release" == "$NEW_RELEASE" || "$old_release" == "$PREVIOUS_RELEASE" \
|| "$old_release" == "$NEW_WWW_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]]; then
continue
fi
log "Pruning old release: $old_release"
rm -rf "$old_release"
done
}
main() {
require_command git
require_command npm
acquire_lock
validate_deploy_contract
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$WWW_RELEASES_DIR"
prepare_askpass
local timestamp
timestamp="$(date +%Y%m%d%H%M%S)"
local tmp_release="$RELEASES_DIR/.tmp-$timestamp"
log "Cloning $REPO_URL#$BRANCH"
run git clone --depth "$GIT_DEPTH" --branch "$BRANCH" "$REPO_URL" "$tmp_release"
local commit
commit="$(git -C "$tmp_release" rev-parse --short=12 HEAD)"
NEW_RELEASE="$RELEASES_DIR/$timestamp-$commit"
mv "$tmp_release" "$NEW_RELEASE"
log "Building release $NEW_RELEASE"
run_build_and_checks "$NEW_RELEASE"
log "Staging H5 release for $WWW_ROOT"
stage_h5_release "$NEW_RELEASE"
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
fi
snapshot_service_repo_for_rollback
ROLLBACK_ARMED=true
log "Switching current release to $NEW_RELEASE"
switch_current "$NEW_RELEASE"
APP_SWITCHED=true
log "Synchronizing service runtime to $SERVICE_REPO_DIR"
SERVICE_SYNC_STARTED=true
if ! sync_service_repo "$NEW_RELEASE"; then
ROLLBACK_ARMED=false
rollback || true
die "Service runtime synchronization failed; rollback attempted."
fi
if ! restart_services; then
ROLLBACK_ARMED=false
rollback || true
die "Service restart failed; rollback attempted."
fi
if ! healthcheck; then
ROLLBACK_ARMED=false
rollback || true
die "Healthcheck failed; rollback attempted."
fi
log "Switching Web root to $NEW_WWW_RELEASE"
switch_www_release
verify_live_h5_release "$NEW_RELEASE"
ROLLBACK_ARMED=false
prune_releases "$KEEP_RELEASES" "$RELEASES_DIR"
prune_releases "$KEEP_RELEASES" "$WWW_RELEASES_DIR"
log "Deploy complete: $APP_NAME @ $commit"
}
main "$@"

View File

@@ -1,5 +1,12 @@
# SaaS 重构工作区
当前接管先读:
- `production-foundation-baseline-20260712.md`:后端/API 冻结结论、前端启动边界、生产硬阻断和正式上线顺序。
- `clean-room-migration-audit-20260712.md`:官方 Supabase PG15 空库 78 个迁移、扩展/ACL、运行角色、RLS、约束和幂等审计证据。
- `taro-h5-browser-qa-20260712.md`:学生端、租户后台、平台后台桌面/移动浏览器和主交互验收记录。
- `taro-supply-chain-baseline-20260712.md`Taro 4.2.0 安全 override、H5 runtime patch、干净安装与构建工具链风险边界。
这个目录记录从 PocketBase 单体项目迁移到 Supabase/PostgreSQL + 新 API + Taro 学生端的重构过程。
当前阶段目标:
@@ -18,7 +25,7 @@
- `scripts/import-pocketbase`PocketBase schema/数据导入工具。
- `docker-compose.api.yml``docker-compose.api.benchmark.yml``apps/api/Dockerfile`:本地 Docker API 和受限资源压测入口。
- `scripts/deploy/README.md`:云服务器部署 runbook覆盖 `tjszsb.com` 六域名规划、服务器目录、Gitea 安全部署、Nginx、systemd 和更新脚本。
- `scripts/deploy/bin/deploy.sh`:服务器端发布脚本模板,负责拉取 Gitea、构建 API/worker/Taro H5、发布静态文件和重启服务真实密钥只从 `/etc/tiku-saas/*.env` 读取。
- 根目录 `deploy.sh`:推荐的 release/symlink 原子发布入口,包含严格 runtime config、readiness、安全、H5 smoke、manifest 和真实 production launch gate`scripts/deploy/bin/deploy.sh` 仅保留为旧服务器兼容入口并同步执行同类门禁。真实密钥只从服务器受控 env 读取。
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
- `docs/refactor/ai-development-guardrails.md`:后续 AI/开发者必须遵守的 Supabase-first 架构和安全守则。
- `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。
@@ -32,10 +39,11 @@
- `docs/refactor/taro-h5-deployment.md`Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。
- `docs/refactor/postgresql-4c16g-tuning.md`4 核 16G 自托管 PostgreSQL 起步调参、观察 SQL 和回滚方式。
- `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵、Docker 受限资源预演和报告归档方式。
- `docs/refactor/tenant-student-capacity-runbook.md`:单租户最多 10 万学生的安全合成夹具、cursor/搜索 SQL 基准、EXPLAIN 证据和专用命名空间清理流程。
- `docs/refactor/performance-benchmark-summary-20260630.md`:真实迁移数据压测脱敏摘要。
- `docs/refactor/backend-open-items-and-capacity-20260701.md`:后端剩余功能、已定稿产品口径和最新在线容量估算。
- `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。
- `docs/refactor/production-launch-evidence.template.json`:生产上线证据模板;真实证据填入本地 `production-launch-evidence.json` 后运行 `npm run launch:gate`
- `docs/refactor/production-launch-evidence.template.json`:生产上线证据模板;真实 evidence 与 `launch-artifacts/` 作为完整 bundle 放在服务器受控目录,不进入 Git再运行 `npm run launch:gate -- --evidence <absolute-path>`
下一步优先级:

View File

@@ -48,7 +48,7 @@ docs/refactor/
```bash
npm run supabase:start
npm run supabase:reset
npm run db:smoke-seed
npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY
npm run dev:api
```
@@ -60,7 +60,7 @@ npm run docker:api:build
npm run docker:api:up
```
如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源代理,再重试 `npm run docker:api:build`
API Dockerfile 锁定 `node:20.20.2-alpine3.23` 多架构 manifest最终镜像以 `node` 用户运行,只复制生产依赖和编译产物。若 Docker Hub 超时,先配置受信镜像源/代理并确认拉取到相同 digest,再重试 `npm run docker:api:build`,不要移除 digest 锁定
本地容量预演可以使用专门的 benchmark override
@@ -89,10 +89,10 @@ npm run pb:import:validate
- Docker Desktop 可用。
- Supabase 本地容器可启动。
- API Dockerfile 已验证可构建benchmark override 可启动受限 API 容器并通过短压测 smoke。
- API Dockerfile 已验证可构建;最终镜像约 `53 MB`、生产 `node_modules``24.3 MB`,不含 TypeScript/tsxUID 为 `1000(node)`,连接隔离测试库通过 `/health`benchmark override 可启动受限 API 容器并通过短压测 smoke。
- `supabase db reset` 可完整执行三份 migration 和 seed。
- `supabase db reset` 可完整执行全部 migration 和 seed。
- `npm run db:smoke-seed`恢复最小业务烟测数据。
- `npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY` 只能在已标记为 `local/test/ci` 的隔离库恢复最小业务烟测数据。
- `platform-admin` 可完成平台概览、租户创建、订阅、账单生成、人工收款确认、使用量记录。
- API `/health` 可连 PostgreSQL 并返回 `db: ok`
- API `/api/tenant/resolve?host=localhost` 可解析主租户。

View File

@@ -1,6 +1,8 @@
# 后端进度同步与前端接入路线图
更新时间2026-06-30
更新时间2026-07-12
> 权威决策已迁移到 `production-foundation-baseline-20260712.md`。本文继续保留详细能力清单,凡与 2026-07-12 基线冲突的历史表述,以该基线为准。
这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。
@@ -14,7 +16,7 @@
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、销售/代理转化报表、CRM 配置、入队、worker 推送、失败死信运营、手动重试/忽略和分佣结算基础闭环。
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。
因此,后端现在已经具备在现有 `apps/taro` 中开始正式前端重构的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。
## 后端模块进度
@@ -39,7 +41,7 @@
## 前端接入建议
建议新建 `apps/taro`,不要在旧 React Web 上继续堆大量兼容。旧项目继续作为样式、页面和交互参照,真正的新业务调用以 `apps/api` 为准
继续在已建立的 `apps/taro` 中开发,不新建第二套前端,也不在旧 React Web 上继续堆大量兼容。旧项目作为样式、页面状态和微信能力参照,新业务调用以 `apps/api` 和受控兼容基线为准。学生端共享 H5/小程序/后续 App租户后台和平台后台首发仅做响应式 H5
前端第一阶段应该先做能跑完整学生链路的页面:

View File

@@ -0,0 +1,127 @@
# Clean-room migration audit - 2026-07-12
## Audit scope
- Isolated project ID: `tiku-clean-final-20260712-v2`
- Database image: official Supabase PostgreSQL `15.8`
- Isolated database endpoint used during verification: `127.0.0.1:55522`
- Migration role: standard non-superuser `postgres`
- Privileged bootstrap/test role: `supabase_admin`, used for the runtime-role bootstrap and controlled role-boundary probes
- The existing databases on ports `5432`, `54322`, and `55432` were explicitly excluded and were not modified.
The clean-room startup log reported that no `supabase/seed.sql` matched. The seed was therefore not executed. At the end of migration verification, `public.tenants`, `app_private.environment_safety`, and `auth.users` all contained zero rows.
## Migration result
All 78 migrations were applied successfully in filename order to the empty database. `supabase migration list --local` subsequently showed every local version paired with the applied version through `202607120019`.
Migration history verification returned:
- Rows: `78`
- Distinct versions: `78`
- Duplicate versions: `0`
- Maximum version: `202607120019`
The privileged `scripts/deploy/sql/bootstrap-backend-runtime-roles.sql` bootstrap ran before the normal migrations. Migrations `202607120013_backend_runtime_roles.sql`, `202607120018_auth_user_reference_boundary.sql`, and `202607120019_production_migration_history_boundary.sql` were later replayed directly by the non-superuser `postgres` role. All three replays succeeded. A normalized fingerprint covering role attributes, memberships, schema/table/sequence/function ACLs, default ACLs, and security-definer attributes remained `1691|7aee08a0b05a30fc49fd278748d4c4e3` before and after replay.
## Extensions and function ACLs
The required extensions were installed in the `extensions` schema:
- `citext`
- `ltree`
- `pg_trgm`
- `pgcrypto`
No client-facing role can execute a function exposed through `public`. The final extension-function matrix was:
| Role | `citext` | `ltree` | `pg_trgm` | `pgcrypto` |
| --- | ---: | ---: | ---: | ---: |
| `anon` | 0/45 | 0/78 | 0/31 | 0/36 |
| `authenticated` | 0/45 | 0/78 | 0/31 | 0/36 |
| `tiku_api` | 45/45 | 78/78 | 31/31 | 0/36 |
| `tiku_worker` | 45/45 | 78/78 | 31/31 | 0/36 |
`citext` also owns two aggregates; when all `pg_proc` extension members are counted, API and worker have all 47 required `citext` members while client roles still have zero.
## Runtime roles and Auth boundary
Both backend roles were verified as:
- `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS`
- No parent-role memberships
- `search_path=pg_catalog, public, extensions`
Runtime identity checks confirmed that both roles can execute `citext` comparisons, `ltree` operators, and `extensions.similarity()`.
The API role could execute exactly five reviewed `app` functions:
- `app.auth_user_exists(uuid)`
- `app.production_migration_history(text)`
- `app.uuid_array_from_jsonb(jsonb)`
- `app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[])`
- `app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[])`
Direct `tiku_api` access to `supabase_migrations.schema_migrations` was denied. The API-only `app.production_migration_history('202607120019')` boundary returned `latest_version=202607120019`, `applied_count=78`, `distinct_version_count=78`, and `expected_version_applied=true`. Worker and anonymous execution were denied.
The worker has no `app` schema usage and can execute no `app` functions. Both API-only boundary functions are stable `SECURITY DEFINER` functions with an empty search path. `app.auth_user_exists(uuid)` returned `false` for an absent UUID and `true` for a temporary clean-room Auth user; that user was deleted immediately after the probe. Worker and anonymous execution were denied. Direct reads of `auth.users` were denied for both API and worker.
Supabase internal compatibility checks also passed:
- `supabase_auth_admin` could query `auth.users`.
- `supabase_storage_admin` could query `storage.objects`.
- `authenticator` could still `SET ROLE anon` and `SET ROLE authenticated`.
## RLS, constraints, and indexes
All 140 `public` tables had RLS enabled. Across `public` and `app_private`, all 134 tables containing a `tenant_id` column had RLS enabled.
The only checked internal table without RLS was `app_private.environment_safety`. It is not tenant data, is intentionally fail-closed, and explicitly revokes access from `public`, `anon`, and `authenticated`.
The project tenant-foreign-key audit matched all `189/189` expected relations and its expected SHA-256 fingerprint, found all three reviewed exceptions, found no unvalidated relations, and reported `0` data violations.
All 22 explicitly checked critical foreign keys, unique constraints, and check constraints introduced by migrations `202607120010` through `202607120017` existed with `convalidated=true`. All 16 explicitly checked critical indexes existed with `indisvalid=true` and `indisready=true`, including tenant-safe question/version relations, answer semantics, tenant student keyset/search indexes, SMS reservation limits, audit-log capacity indexes, and import-job lease indexes.
Supabase lint completed successfully:
```text
Linting schema: public
Linting schema: app
Linting schema: app_private
No schema errors found
```
The command used was:
```bash
npx --no-install supabase db lint \
--local \
--workdir /tmp/tiku-clean-final-20260712-v2 \
--schema public,app,app_private \
--level error \
--fail-on error
```
## Residual foreign-key index risk
The application schemas (`public` and `app_private`) contain 465 foreign keys. The structural audit found:
- 146 with an unconditional complete left-prefix index
- 6 covered only by a partial left-prefix index
- 313 without a complete left-prefix index
The newly hardened `202607120015` and `202607120017` hot paths are substantially covered. The remaining count is a capacity and operations backlog, not a migration correctness or tenant-isolation failure.
Adding 313 indexes blindly is not recommended. Every index increases storage, write amplification, vacuum work, cache pressure, migration time, and lock risk. Some foreign keys are low-volume, rarely joined, never cascaded in normal operations, or already served by a more useful query-specific index. Production indexing should therefore be prioritized from representative capacity tests, cascade/delete behavior, slow-query evidence, and `pg_stat_statements`, then introduced in controlled batches.
## Cleanup
After all checks passed, the isolated Supabase project was stopped with `--no-backup`. The following were verified absent:
- `/tmp/tiku-clean-final-20260712-v2`
- Clean-room containers
- Clean-room Docker volumes
- Clean-room Docker networks
The pre-existing databases on ports `5432`, `54322`, and `55432` remained running after cleanup.

View File

@@ -1,6 +1,6 @@
# 内容导入契约
更新时间2026-06-29
更新时间2026-07-12
## 结论
@@ -98,6 +98,24 @@ npm --workspace @tiku-saas/worker run imports:once
前端提交异步导入后不要重复同步执行同一 job只需要轮询 `GET /api/tenant-content/imports` 并用 `GET /api/tenant-content/imports/issues` 展示问题行。worker 会按 `attempt_count/max_attempts` 记录重试,失败时写入 `errorMessage` 和审计日志。
异步 worker 使用数据库持久 lease不能只依赖进程内状态
- claim 是单条 `UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED)`,多实例不会领取同一个 job。
- 每次 claim 都生成新的 `lease_token` fencing token并写入 `locked_by``locked_at``lease_expires_at``last_heartbeat_at`
- 长任务按 `WORKER_IMPORT_HEARTBEAT_INTERVAL_MS` 续租;该值必须小于 `WORKER_IMPORT_LEASE_SECONDS` 的一半。
- worker 崩溃后,其他实例可在 lease 过期后重新领取,并原子增加 `attempt_count`
- 完成、失败和重试提交都必须同时匹配 job、`status=importing`、未过期 lease 和 `lease_token`。旧实例丢失 lease 后,其导入事务整体回滚,不能覆盖接管者的结果或审计。
- attempt 已耗尽的过期 job 会直接转为 `failed`,不会额外执行一次。
生产建议先保持默认配置:
```env
WORKER_IMPORT_LEASE_SECONDS=120
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
```
lease 应覆盖数据库短暂抖动,但不应长到显著拖慢崩溃恢复;调整时必须同时运行 `test:worker:imports` 和 production readiness。
## 模板、字段映射和导入后复检
租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口:

View File

@@ -1,15 +1,18 @@
# 前端交接索引
更新时间2026-07-02
更新时间2026-07-12
这份文件是给 Taro/H5/小程序前端同事的入口。当前仓库的前端重构建议从 `apps/taro` 新建工程开始,不再把旧 React/Vite 前端搬回根目录继续开发
这份文件是给 Taro/H5/小程序前端同事的入口。`apps/taro` 已经是唯一的新前端工程,后续应在该工程内重构,不要重新新建第二套 Taro 工程,也不要把旧 React/Vite 前端搬回根目录。
## 2026-07-02 接管重点
开始设计或改页面前先读 `production-foundation-baseline-20260712.md`。该文件冻结了当前 API、身份和状态机边界并区分了“可以开始前端”与“已经可切生产流量”。浏览器现状证据见 `taro-h5-browser-qa-20260712.md`
当前 `main` 已包含旧题库视觉对齐版本,最新提交是 `f54421f test: align Taro visual guardrails with legacy UI`。另一台工作机接管后,先确认本地代码至少包含该提交:
## 2026-07-12 接管重点
接管后先确认当前候选分支和工作区,不要依赖历史 commit 文案判断是否最新:
```bash
git log -2 --oneline
git status --short --branch
git log -3 --oneline
```
本轮前端变化的边界:
@@ -87,6 +90,7 @@ node scripts/taro-h5-release-guardrails-test.js
- H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、会员收银台下单/支付参数/订单状态,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
- 学生端当前 production 入口约 `500 KiB`,前端重构必须先建立路由拆包、延迟加载和资源预算;视觉组件不得无约束进入首包。
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config``runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账、`security:repo` 和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`
- 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。

View File

@@ -1,6 +1,8 @@
# Supabase 重构功能进度矩阵
更新时间2026-06-30
更新时间2026-07-12
> 2026-07-12 权威基线:多租户 RLS、运行角色/Data API 权限、Auth 用户最小边界、动态 CORS、短信限流、Worker lease/fencing、生产 readiness 和十万学生容量证据已经完成本地及独立 clean-room 验证,后端/API 可以进入受控冻结并开始正式前端重构。正式上线仍需目标云服务器的真实 provider、数据迁移、备份恢复、首个平台超管、systemd、真实压测、三套 H5 runtime config 和 launch evidence。完整结论见 `production-foundation-baseline-20260712.md`;下方长表保留历史功能明细,若与该基线冲突,以 7 月 12 日基线为准。
## 当前结论
@@ -23,7 +25,7 @@
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
| --- | --- | --- | --- | --- | --- |
| 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环完成 |
| 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`完整 RLS/ACL/运行角色边界 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API、动态 RLS、Data API ACL、clean-room migration 和 readiness 均有自动化证据 | 本地与 clean-room 闭环完成;目标生产环境仍需真实 Auth/JWKS、运行角色 bootstrap 和远程隔离验收 |
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata、PDF/Word 运营版式、ZIP 图片素材包、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word/每日一练 ZIP job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言Taro 类型检查覆盖 RichContent 接入 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染和逐题复盘第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练 ZIP 基础导出可联调;真正 KaTeX/小程序公式方案、私有题图签名映射、公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |

View File

@@ -0,0 +1,42 @@
# Import worker 持久 lease 验证报告
更新时间2026-07-12
## 结论
`content_import_jobs` 已具备多实例和进程重启所需的持久 lease 与 fencing 语义。验证只在专用测试库 `127.0.0.1:55432` 执行,没有连接生产环境。
## 实现边界
- migration `202607120016_content_import_job_leases.sql` 增加 `lease_token``lease_expires_at``last_heartbeat_at`、一致性约束和 pending/expired 部分索引。
- claim 使用原子 `SKIP LOCKED`,可同时领取 ready pending job 和 lease 已过期的 importing job。
- 每次 claim 只增加一次 `attempt_count` 并生成新 token失败调度只写 `next_attempt_at`,不会重复增加 attempt。
- worker 在执行期间续租;续租、完成、失败和重试都要求 token 匹配且 lease 未过期。
- API executor 的业务写入和终态更新处于同一事务。fencing 校验失败会回滚题目、版本、集合绑定、item 和 audit 写入。
- 最后一次 attempt 的 lease 过期后由 claim/reaper 路径直接标记 failed避免第 `max_attempts + 1` 次执行。
## 动态覆盖
`scripts/import-worker-integration-test.js` 在 destructive-test database guard 后验证:
1. 两个并发 worker 对三个 job 原子 claim没有重复领取。
2. 心跳推进 `last_heartbeat_at` 并延长 `lease_expires_at`
3. 模拟崩溃后,过期 job 被新 worker 接管token 旋转且 attempt 从 1 变为 2。
4. 旧 token 的执行在业务写入前被拒绝,旧 token 的失败提交也不能覆盖新 lease。
5. 新 lease 可完成 job终态清空 lease 字段并保持准确 attempt。
6. retry 保持 pending、持久 `next_attempt_at`,再次 claim 才增加 attempt。
7. 最后 attempt 过期后进入 failed不再重新执行。
## 验证命令
```bash
npm run check:worker
npm run check:api
npm run build:worker
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/postgres \
node scripts/import-worker-integration-test.js \
--confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY
node scripts/production-readiness-check-test.js
```
完整 worker integration 会先跑 smoke seed执行时必须明确指向允许 destructive tests 的本地/CI 数据库。

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