forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
33
.env.example
33
.env.example
@@ -9,12 +9,36 @@ NODE_ENV=development
|
||||
|
||||
# API 服务端口
|
||||
PORT=8787
|
||||
API_HEADERS_TIMEOUT_MS=15000
|
||||
API_REQUEST_TIMEOUT_MS=120000
|
||||
API_KEEP_ALIVE_TIMEOUT_MS=5000
|
||||
API_SHUTDOWN_GRACE_PERIOD_MS=30000
|
||||
API_MAX_REQUESTS_PER_SOCKET=1000
|
||||
|
||||
# Supabase/PostgreSQL 重构 API
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
# 本地 postgres 可留空;生产 API 必须为 tiku_api,worker 必须为 tiku_worker。
|
||||
DB_EXPECTED_RUNTIME_ROLE=
|
||||
# 不要在 .env 中持久化 SMOKE_SEED_CONFIRM;破坏性测试只通过受控 npm script/CLI 临时确认。
|
||||
# API/worker 各自进程使用。生产建议 API 10-20、worker 4-8,并结合 PgBouncer/连接池总量核算。
|
||||
DB_POOL_MAX=10
|
||||
DB_CONNECTION_TIMEOUT_MS=5000
|
||||
DB_QUERY_TIMEOUT_MS=35000
|
||||
DB_STATEMENT_TIMEOUT_MS=30000
|
||||
DB_LOCK_TIMEOUT_MS=5000
|
||||
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
|
||||
DB_IDLE_TIMEOUT_MS=30000
|
||||
DB_POOL_MAX_USES=7500
|
||||
DB_POOL_MAX_LIFETIME_SECONDS=1800
|
||||
DB_APPLICATION_NAME=tiku-local
|
||||
DEFAULT_TENANT_SLUG=master
|
||||
# 生产必须只写真实 HTTPS 域名,不能包含 *
|
||||
# 生产只列中央平台/运维的固定 HTTPS Origin,不能包含 *。
|
||||
# 各租户 H5 Origin 从 active tenant_domains + active tenants 动态准入,不要把数百个租户域名展开到此列表。
|
||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
CORS_TENANT_DOMAINS_ENABLED=false
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
MAX_JSON_BODY_BYTES=1048576
|
||||
MAX_IMPORT_JSON_BODY_BYTES=10485760
|
||||
|
||||
@@ -32,6 +56,11 @@ AUTH_JWT_SECRET=development-jwt-secret-change-me
|
||||
AUTH_JWT_JWKS_URL=
|
||||
AUTH_CODE_TTL_SECONDS=300
|
||||
AUTH_SMS_COOLDOWN_SECONDS=60
|
||||
AUTH_SMS_TENANT_DAILY_LIMIT=20000
|
||||
AUTH_SMS_PHONE_DAILY_LIMIT=10
|
||||
# 校园/运营商 NAT 会共享 IP,IP 配额应宽于手机号/设备配额。
|
||||
AUTH_SMS_IP_HOURLY_LIMIT=120
|
||||
AUTH_SMS_DEVICE_HOURLY_LIMIT=10
|
||||
AUTH_SESSION_TTL_SECONDS=604800
|
||||
|
||||
# 远程 Auth/JWKS 验收脚本配置。只在预生产/生产验收命令行临时设置真实 access token,
|
||||
@@ -113,6 +142,8 @@ WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
# Worker 配置:大批量内容导入
|
||||
WORKER_IMPORT_BATCH_SIZE=5
|
||||
WORKER_IMPORT_ID=imports-1
|
||||
WORKER_IMPORT_LEASE_SECONDS=120
|
||||
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
|
||||
WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800
|
||||
|
||||
# Worker 配置:公共题库自动同步。冲突会保留租户自改题目并等待后台处理。
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -83,6 +83,9 @@ whisper_models/
|
||||
|
||||
# ✅ 部署脚本本地缓存(记录 package-lock 哈希)
|
||||
.deploy-cache/
|
||||
/deploy.env
|
||||
/.deploy.env
|
||||
/shared/
|
||||
|
||||
# ✅ PocketBase 运行期产出(不入 git)
|
||||
pb_data/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM node:20-alpine AS deps
|
||||
ARG NODE_IMAGE=node:20.20.2-alpine3.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
|
||||
FROM ${NODE_IMAGE} AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -9,7 +11,11 @@ COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
FROM deps AS production-deps
|
||||
RUN npm prune --omit=dev --workspaces --include-workspace-root \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -17,18 +23,13 @@ COPY apps/api ./apps/api
|
||||
COPY packages ./packages
|
||||
RUN npm run build:api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM ${NODE_IMAGE} AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist
|
||||
|
||||
EXPOSE 8787
|
||||
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]
|
||||
USER node
|
||||
CMD ["node", "apps/api/dist/apps/api/src/server.js"]
|
||||
|
||||
@@ -43,9 +43,11 @@ export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
type QueryOne = <T = unknown>(sql: string, params?: unknown[]) => Promise<T | null>;
|
||||
|
||||
export async function findUserBySessionToken(token: string, queryUser: QueryOne = queryOne) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<SessionIdentity>(
|
||||
return queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
@@ -55,10 +57,24 @@ export async function findUserBySessionToken(token: string) {
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
join public.tenants t on t.id = s.tenant_id
|
||||
where s.token_hash = $1
|
||||
and u.status = 'active'
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
and (
|
||||
u.primary_role = 'platform_admin'
|
||||
or (
|
||||
t.status = 'active'
|
||||
and exists (
|
||||
select 1
|
||||
from public.tenant_memberships tm
|
||||
where tm.tenant_id = s.tenant_id
|
||||
and tm.user_id = s.user_id
|
||||
and tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
@@ -105,66 +121,48 @@ function tenantClaimFrom(payload: JWTPayload) {
|
||||
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
|
||||
}
|
||||
|
||||
function appRoleClaimFrom(payload: JWTPayload) {
|
||||
const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role;
|
||||
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
|
||||
}
|
||||
|
||||
function sessionExpiryFrom(payload: JWTPayload) {
|
||||
return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString();
|
||||
}
|
||||
|
||||
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
payload = await verifySupabaseJwt(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findUserByVerifiedSupabasePayload(
|
||||
payload: JWTPayload,
|
||||
requestedTenantContext = '',
|
||||
queryUser: QueryOne = queryOne,
|
||||
) {
|
||||
const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : '';
|
||||
if (!authUserId) return null;
|
||||
|
||||
const tenantClaim = tenantClaimFrom(payload);
|
||||
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
|
||||
const requestedTenantId = tenantClaim || requestedTenantContext;
|
||||
const appRole = appRoleClaimFrom(payload);
|
||||
|
||||
const platformUser = await queryOne<SessionIdentity>(
|
||||
const platformUser = await queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
coalesce($2::uuid, tm.tenant_id) as "tenantId",
|
||||
null::uuid as "tenantId",
|
||||
$1::text as "sessionId",
|
||||
$3::timestamptz as "sessionExpiresAt",
|
||||
$2::timestamptz as "sessionExpiresAt",
|
||||
'supabase_jwt'::text as "authSource",
|
||||
u.auth_user_id as "authUserId",
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and u.primary_role = 'platform_admin'
|
||||
and ($2::uuid is null or exists (
|
||||
select 1
|
||||
from public.tenant_memberships scoped_tm
|
||||
where scoped_tm.user_id = u.id
|
||||
and scoped_tm.tenant_id = $2::uuid
|
||||
and scoped_tm.status = 'active'
|
||||
))
|
||||
order by tm.created_at asc nulls last
|
||||
limit 1
|
||||
`,
|
||||
[authUserId, requestedTenantId || null, sessionExpiryFrom(payload)],
|
||||
[authUserId, sessionExpiryFrom(payload)],
|
||||
);
|
||||
|
||||
if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) {
|
||||
return platformUser;
|
||||
}
|
||||
// Supabase's top-level role is normally "authenticated". Platform authority
|
||||
// comes exclusively from the active database user, never from JWT role claims.
|
||||
if (platformUser) return platformUser;
|
||||
|
||||
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
|
||||
if (!requestedTenantId) return null;
|
||||
|
||||
return queryOne<SessionIdentity>(
|
||||
return queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
@@ -176,8 +174,10 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
join public.tenant_memberships tm on tm.user_id = u.id
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and t.status = 'active'
|
||||
and tm.status = 'active'
|
||||
and tm.tenant_id = $2::uuid
|
||||
order by case
|
||||
@@ -192,6 +192,16 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
);
|
||||
}
|
||||
|
||||
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
payload = await verifySupabaseJwt(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return findUserByVerifiedSupabasePayload(payload, requestedTenantContext);
|
||||
}
|
||||
|
||||
export async function hydrateRequestAuth(ctx: RequestContext) {
|
||||
const cached = requestAuthState.get(ctx);
|
||||
if (cached?.sessionResolved) return cached;
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface ApiConfig {
|
||||
databaseUrl: string;
|
||||
defaultTenantSlug: string;
|
||||
corsOrigins: string[];
|
||||
corsTenantDomainsEnabled: boolean;
|
||||
corsTenantDomainCacheTtlMs: number;
|
||||
corsTenantDomainNegativeCacheTtlMs: number;
|
||||
corsTenantDomainCacheMaxEntries: number;
|
||||
maxJsonBodyBytes: number;
|
||||
maxImportJsonBodyBytes: number;
|
||||
authCodePepper: string;
|
||||
@@ -17,7 +21,16 @@ export interface ApiConfig {
|
||||
authJwtJwksUrl: string;
|
||||
authCodeTtlSeconds: number;
|
||||
authSmsCooldownSeconds: number;
|
||||
authSmsTenantDailyLimit: number;
|
||||
authSmsPhoneDailyLimit: number;
|
||||
authSmsIpHourlyLimit: number;
|
||||
authSmsDeviceHourlyLimit: number;
|
||||
authSessionTtlSeconds: number;
|
||||
apiHeadersTimeoutMs: number;
|
||||
apiRequestTimeoutMs: number;
|
||||
apiKeepAliveTimeoutMs: number;
|
||||
apiShutdownGracePeriodMs: number;
|
||||
apiMaxRequestsPerSocket: number;
|
||||
allowLegacyAuthHeaders: boolean;
|
||||
allowPlatformAdminKey: boolean;
|
||||
platformAdminApiKey: string;
|
||||
@@ -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
225
apps/api/src/core/cors.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { queryOne } from './db.js';
|
||||
import { isLocalTenantHost } from '../features/tenant/locator.js';
|
||||
|
||||
export type CorsDecisionReason =
|
||||
| 'no-origin'
|
||||
| 'wildcard'
|
||||
| 'static-origin'
|
||||
| 'tenant-domain'
|
||||
| 'invalid-origin'
|
||||
| 'tenant-domain-disabled'
|
||||
| 'tenant-domain-lookup-failed';
|
||||
|
||||
export interface CorsDecision {
|
||||
allowed: boolean;
|
||||
allowOrigin: string | null;
|
||||
reason: CorsDecisionReason;
|
||||
}
|
||||
|
||||
export interface CorsPolicyOptions {
|
||||
staticOrigins: string[];
|
||||
tenantDomainsEnabled: boolean;
|
||||
positiveCacheTtlMs: number;
|
||||
negativeCacheTtlMs: number;
|
||||
maxCacheEntries: number;
|
||||
lookupTenantDomain?: (host: string) => Promise<boolean>;
|
||||
now?: () => number;
|
||||
onLookupError?: (error: unknown, host: string) => void;
|
||||
}
|
||||
|
||||
interface NormalizedOrigin {
|
||||
origin: string;
|
||||
hostname: string;
|
||||
protocol: 'http:' | 'https:';
|
||||
hasNonDefaultPort: boolean;
|
||||
}
|
||||
|
||||
interface CachedTenantDomain {
|
||||
allowed: boolean;
|
||||
expiresAt: number;
|
||||
lookupFailed: boolean;
|
||||
}
|
||||
|
||||
const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
||||
const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key';
|
||||
|
||||
function firstHeader(req: IncomingMessage, name: string) {
|
||||
if (name.toLowerCase() === 'origin') {
|
||||
const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin');
|
||||
if (originHeaders.length > 1) return '__multiple_origin_headers__';
|
||||
}
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function addVaryHeader(res: ServerResponse, value: string) {
|
||||
const current = res.getHeader('vary');
|
||||
const values = (Array.isArray(current) ? current : String(current || '').split(','))
|
||||
.map(item => String(item).trim())
|
||||
.filter(Boolean);
|
||||
if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
||||
res.setHeader('vary', values.join(', '));
|
||||
}
|
||||
|
||||
export function normalizeCorsOrigin(value: string): NormalizedOrigin | null {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
|
||||
if (!parsed.hostname || parsed.hostname.endsWith('.')) return null;
|
||||
|
||||
const protocol = parsed.protocol as 'http:' | 'https:';
|
||||
const defaultPort = protocol === 'https:' ? '443' : '80';
|
||||
return {
|
||||
origin: parsed.origin.toLowerCase(),
|
||||
hostname: parsed.hostname.toLowerCase(),
|
||||
protocol,
|
||||
hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupActiveTenantDomain(host: string) {
|
||||
const row = await queryOne<{ allowed: boolean }>(
|
||||
`
|
||||
select exists (
|
||||
select 1
|
||||
from public.tenant_domains d
|
||||
join public.tenants t on t.id = d.tenant_id
|
||||
where d.host = $1
|
||||
and d.status = 'active'
|
||||
and t.status = 'active'
|
||||
) as allowed
|
||||
`,
|
||||
[host],
|
||||
);
|
||||
return row?.allowed === true;
|
||||
}
|
||||
|
||||
export class CorsPolicy {
|
||||
private readonly staticOrigins: Set<string>;
|
||||
private readonly allowAll: boolean;
|
||||
private readonly tenantDomainsEnabled: boolean;
|
||||
private readonly positiveCacheTtlMs: number;
|
||||
private readonly negativeCacheTtlMs: number;
|
||||
private readonly maxCacheEntries: number;
|
||||
private readonly lookupTenantDomain: (host: string) => Promise<boolean>;
|
||||
private readonly now: () => number;
|
||||
private readonly onLookupError?: (error: unknown, host: string) => void;
|
||||
private readonly cache = new Map<string, CachedTenantDomain>();
|
||||
private readonly pendingLookups = new Map<string, Promise<{ allowed: boolean; lookupFailed: boolean }>>();
|
||||
|
||||
constructor(options: CorsPolicyOptions) {
|
||||
this.allowAll = options.staticOrigins.includes('*');
|
||||
this.staticOrigins = new Set(
|
||||
options.staticOrigins
|
||||
.filter(origin => origin !== '*')
|
||||
.map(origin => normalizeCorsOrigin(origin)?.origin || '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
this.tenantDomainsEnabled = options.tenantDomainsEnabled;
|
||||
this.positiveCacheTtlMs = options.positiveCacheTtlMs;
|
||||
this.negativeCacheTtlMs = options.negativeCacheTtlMs;
|
||||
this.maxCacheEntries = options.maxCacheEntries;
|
||||
this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain;
|
||||
this.now = options.now || Date.now;
|
||||
this.onLookupError = options.onLookupError;
|
||||
}
|
||||
|
||||
async evaluate(rawOrigin: string): Promise<CorsDecision> {
|
||||
if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' };
|
||||
|
||||
const normalized = normalizeCorsOrigin(rawOrigin);
|
||||
if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' };
|
||||
if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' };
|
||||
if (this.staticOrigins.has(normalized.origin)) {
|
||||
return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' };
|
||||
}
|
||||
|
||||
// Tenant browser domains are production HTTPS hosts. Development ports belong
|
||||
// in the explicit static allowlist and must never be inferred from Host headers.
|
||||
if (
|
||||
!this.tenantDomainsEnabled
|
||||
|| normalized.protocol !== 'https:'
|
||||
|| normalized.hasNonDefaultPort
|
||||
|| isLocalTenantHost(normalized.hostname)
|
||||
) {
|
||||
return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' };
|
||||
}
|
||||
|
||||
const lookup = await this.lookupWithCache(normalized.hostname);
|
||||
if (!lookup.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
allowOrigin: null,
|
||||
reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' };
|
||||
}
|
||||
|
||||
private async lookupWithCache(host: string) {
|
||||
const now = this.now();
|
||||
const cached = this.cache.get(host);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
this.cache.delete(host);
|
||||
this.cache.set(host, cached);
|
||||
return { allowed: cached.allowed, lookupFailed: cached.lookupFailed };
|
||||
}
|
||||
if (cached) this.cache.delete(host);
|
||||
|
||||
const pending = this.pendingLookups.get(host);
|
||||
if (pending) return pending;
|
||||
|
||||
const lookup = this.performLookup(host);
|
||||
this.pendingLookups.set(host, lookup);
|
||||
try {
|
||||
return await lookup;
|
||||
} finally {
|
||||
this.pendingLookups.delete(host);
|
||||
}
|
||||
}
|
||||
|
||||
private async performLookup(host: string) {
|
||||
let allowed = false;
|
||||
let lookupFailed = false;
|
||||
try {
|
||||
allowed = await this.lookupTenantDomain(host);
|
||||
} catch (error) {
|
||||
lookupFailed = true;
|
||||
this.onLookupError?.(error, host);
|
||||
}
|
||||
|
||||
const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs;
|
||||
this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed });
|
||||
return { allowed, lookupFailed };
|
||||
}
|
||||
|
||||
private storeCache(host: string, entry: CachedTenantDomain) {
|
||||
this.cache.delete(host);
|
||||
while (this.cache.size >= this.maxCacheEntries) {
|
||||
const oldest = this.cache.keys().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(host, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) {
|
||||
const decision = await policy.evaluate(firstHeader(req, 'origin'));
|
||||
res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS);
|
||||
res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS);
|
||||
res.setHeader('access-control-expose-headers', 'x-request-id');
|
||||
if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin);
|
||||
if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin');
|
||||
return decision;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { DEFAULT_DATABASE_URL } from '../../../../packages/config/src/index.js';
|
||||
|
||||
export const pool = createPool({
|
||||
connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
max: 10,
|
||||
applicationName: 'tiku-api',
|
||||
});
|
||||
|
||||
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
|
||||
@@ -9,10 +9,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}`;
|
||||
}
|
||||
|
||||
10
apps/api/src/core/request-id.ts
Normal file
10
apps/api/src/core/request-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { getHeader } from './http.js';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export function requestIdFrom(req: IncomingMessage) {
|
||||
const provided = getHeader(req, 'x-request-id').trim();
|
||||
return REQUEST_ID_PATTERN.test(provided) ? provided : crypto.randomUUID();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,12 @@ export interface LoginSessionSummary {
|
||||
|
||||
export function clientIpFrom(ctx: RequestContext) {
|
||||
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
const remoteAddress = (ctx.req.socket.remoteAddress || '').trim();
|
||||
const trustedProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remoteAddress);
|
||||
if (trustedProxy) {
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || remoteAddress).trim();
|
||||
}
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
export function userAgentFrom(ctx: RequestContext) {
|
||||
@@ -101,6 +106,44 @@ export async function createLoginSession(
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<LoginSessionSummary> {
|
||||
const authority = await client.query<{ tenantStatus: string; userStatus: string; primaryRole: string }>(
|
||||
`
|
||||
select t.status as "tenantStatus",
|
||||
u.status as "userStatus",
|
||||
u.primary_role as "primaryRole"
|
||||
from public.tenants t
|
||||
join public.platform_users u on u.id = $2::uuid
|
||||
where t.id = $1::uuid
|
||||
for update of t, u
|
||||
`,
|
||||
[input.tenantId, input.userId],
|
||||
);
|
||||
const authorityRow = authority.rows[0];
|
||||
if (!authorityRow || authorityRow.userStatus !== 'active') {
|
||||
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
|
||||
}
|
||||
if (authorityRow.primaryRole !== 'platform_admin') {
|
||||
if (authorityRow.tenantStatus !== 'active') {
|
||||
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
|
||||
}
|
||||
const membership = await client.query<{ status: string }>(
|
||||
`
|
||||
select status
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1::uuid
|
||||
and user_id = $2::uuid
|
||||
and status = 'active'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[input.tenantId, input.userId],
|
||||
);
|
||||
if (!membership.rows[0]) {
|
||||
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
|
||||
}
|
||||
}
|
||||
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
|
||||
@@ -393,15 +436,51 @@ export async function upsertOAuthUser(
|
||||
}
|
||||
|
||||
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
await client.query(
|
||||
const authority = await client.query<{ tenantStatus: string; userStatus: string }>(
|
||||
`
|
||||
select t.status as "tenantStatus", u.status as "userStatus"
|
||||
from public.tenants t
|
||||
join public.platform_users u on u.id = $2::uuid
|
||||
where t.id = $1::uuid
|
||||
for update of t, u
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const authorityRow = authority.rows[0];
|
||||
if (!authorityRow || authorityRow.userStatus !== 'active') {
|
||||
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
|
||||
}
|
||||
if (authorityRow.tenantStatus !== 'active') {
|
||||
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
|
||||
}
|
||||
|
||||
const insertedMembership = await client.query<{ status: string }>(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
||||
values ($1, $2, 'student', 'active')
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active', updated_at = now()
|
||||
do nothing
|
||||
returning status
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const membership = insertedMembership.rows[0]
|
||||
? insertedMembership
|
||||
: await client.query<{ status: string }>(
|
||||
`
|
||||
select status
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1::uuid
|
||||
and user_id = $2::uuid
|
||||
and role = 'student'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
if (membership.rows[0]?.status !== 'active') {
|
||||
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
|
||||
216
apps/api/src/features/auth/sms-limits.ts
Normal file
216
apps/api/src/features/auth/sms-limits.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { config } from '../../core/config.js';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
interface SmsSendReservation {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function retryAfterSeconds(createdAt: string) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000);
|
||||
return Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
|
||||
}
|
||||
|
||||
function quotaError(code: string, message: string) {
|
||||
return new HttpError(429, message, code);
|
||||
}
|
||||
|
||||
function quotaScopeHash(dimension: string, value: string) {
|
||||
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-quota:${dimension}:${value}`).digest('hex');
|
||||
}
|
||||
|
||||
async function consumeQuota(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
dimension: 'tenant' | 'phone' | 'ip' | 'device';
|
||||
scopeValue: string;
|
||||
bucket: 'hour' | 'day';
|
||||
limit: number;
|
||||
code: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
if (!input.scopeValue) return;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into app_private.sms_send_rate_limits (
|
||||
tenant_id, dimension, scope_hash, bucket_start, request_count
|
||||
)
|
||||
values (
|
||||
$1, $2, $3,
|
||||
case
|
||||
when $4 = 'day' then date_trunc('day', now() at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai'
|
||||
else date_trunc('hour', now())
|
||||
end,
|
||||
1
|
||||
)
|
||||
on conflict (tenant_id, dimension, scope_hash, bucket_start)
|
||||
do update set request_count = app_private.sms_send_rate_limits.request_count + 1,
|
||||
updated_at = now()
|
||||
where app_private.sms_send_rate_limits.request_count < $5
|
||||
returning request_count
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.dimension,
|
||||
quotaScopeHash(input.dimension, input.scopeValue),
|
||||
input.bucket,
|
||||
input.limit,
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw quotaError(input.code, input.message);
|
||||
}
|
||||
|
||||
export function normalizeSmsDeviceId(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return '';
|
||||
if (normalized.length > 200 || !/^[A-Za-z0-9._:-]+$/.test(normalized)) {
|
||||
throw new HttpError(400, 'Invalid device identifier', 'INVALID_DEVICE_ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function hashSmsDeviceId(value: string) {
|
||||
const normalized = normalizeSmsDeviceId(value);
|
||||
if (!normalized) return '';
|
||||
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-device:${normalized}`).digest('hex');
|
||||
}
|
||||
|
||||
export async function reserveSmsSend(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
purpose: string;
|
||||
codeHash: string;
|
||||
provider: string;
|
||||
expiresAt: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
deviceHash: string;
|
||||
outId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SmsSendReservation> {
|
||||
await client.query(
|
||||
`select pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[`sms-send:${input.tenantId}:${input.phone}:${input.purpose}`],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with stale as (
|
||||
select ctid
|
||||
from app_private.sms_send_rate_limits
|
||||
where updated_at < now() - interval '3 days'
|
||||
order by updated_at asc
|
||||
limit 32
|
||||
for update skip locked
|
||||
)
|
||||
delete from app_private.sms_send_rate_limits limits
|
||||
using stale
|
||||
where limits.ctid = stale.ctid
|
||||
`,
|
||||
);
|
||||
|
||||
const existing = await client.query<{ createdAt: string }>(
|
||||
`
|
||||
select created_at as "createdAt"
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
and created_at > now() - ($4::text || ' seconds')::interval
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.phone, input.purpose, config.authSmsCooldownSeconds],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
const cooldown = retryAfterSeconds(existing.rows[0].createdAt);
|
||||
throw quotaError('SMS_COOLDOWN', `SMS code was sent too frequently. Retry after ${cooldown} seconds.`);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'expired'
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
`,
|
||||
[input.tenantId, input.phone, input.purpose],
|
||||
);
|
||||
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'tenant',
|
||||
scopeValue: input.tenantId,
|
||||
bucket: 'day',
|
||||
limit: config.authSmsTenantDailyLimit,
|
||||
code: 'SMS_TENANT_DAILY_LIMIT',
|
||||
message: 'Tenant SMS daily quota exceeded',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'phone',
|
||||
scopeValue: input.phone,
|
||||
bucket: 'day',
|
||||
limit: config.authSmsPhoneDailyLimit,
|
||||
code: 'SMS_PHONE_DAILY_LIMIT',
|
||||
message: 'SMS daily quota exceeded for this phone number',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'ip',
|
||||
scopeValue: input.ipAddress,
|
||||
bucket: 'hour',
|
||||
limit: config.authSmsIpHourlyLimit,
|
||||
code: 'SMS_IP_HOURLY_LIMIT',
|
||||
message: 'SMS hourly quota exceeded for this network',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'device',
|
||||
scopeValue: input.deviceHash,
|
||||
bucket: 'hour',
|
||||
limit: config.authSmsDeviceHourlyLimit,
|
||||
code: 'SMS_DEVICE_HOURLY_LIMIT',
|
||||
message: 'SMS hourly quota exceeded for this device',
|
||||
});
|
||||
|
||||
const result = await client.query<SmsSendReservation>(
|
||||
`
|
||||
insert into public.sms_verification_codes (
|
||||
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'pending', $6::timestamptz, $7, $8, $9::jsonb)
|
||||
returning id, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.phone,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.provider,
|
||||
input.expiresAt,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify({
|
||||
...input.metadata,
|
||||
outId: input.outId,
|
||||
deviceHash: input.deviceHash || undefined,
|
||||
reservation: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -233,7 +233,10 @@ export async function collectionQuestionsRoute(ctx: RequestContext) {
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.question_collection_items ci
|
||||
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where ci.tenant_id = $1
|
||||
and ci.collection_id = $2
|
||||
and q.status = 'published'
|
||||
|
||||
@@ -314,7 +314,10 @@ export async function questionsRoute(ctx: RequestContext) {
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where ${filters.join(' and ')}
|
||||
order by q.created_at desc
|
||||
limit $${params.length}
|
||||
|
||||
@@ -1195,7 +1195,10 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
q.type, v.correct_option_index, v.correct_option_indices, v.answer_text,
|
||||
v.sub_questions
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
|
||||
limit 1
|
||||
`,
|
||||
@@ -1431,7 +1434,10 @@ async function buildPracticeSessionReport(
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText", v.sub_questions as "subQuestions"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
left join public.question_collection_items ci
|
||||
on ci.tenant_id = q.tenant_id
|
||||
and ci.question_id = q.id
|
||||
@@ -1772,7 +1778,10 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = any($2::uuid[])
|
||||
`,
|
||||
[tenantId, questionIds],
|
||||
@@ -2171,7 +2180,10 @@ export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
|
||||
) as "suggestedReviewAt"
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||
@@ -2222,7 +2234,10 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.favorite_questions fq
|
||||
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where fq.tenant_id = $1 and fq.user_id = $2
|
||||
order by fq.created_at desc
|
||||
limit $3
|
||||
@@ -2276,7 +2291,10 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where wq.tenant_id = $1 and wq.user_id = $2
|
||||
and ($3::boolean = false or wq.resolved_at is null)
|
||||
order by wq.last_wrong_at desc
|
||||
|
||||
@@ -57,6 +57,7 @@ function truncate(value: unknown, max = 1900) {
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TENANT_STATUSES = new Set(['draft', 'active', 'suspended', 'archived']);
|
||||
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
|
||||
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
|
||||
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
|
||||
@@ -701,16 +702,13 @@ export async function upsertPlatformStaffRoute(ctx: RequestContext) {
|
||||
const item = await transaction(async client => {
|
||||
let targetStaffId = staffId || null;
|
||||
|
||||
const authUser = await client.query(
|
||||
const authUser = await client.query<{ exists: boolean }>(
|
||||
`
|
||||
select id
|
||||
from auth.users
|
||||
where id = $1::uuid
|
||||
limit 1
|
||||
select app.auth_user_exists($1::uuid) as exists
|
||||
`,
|
||||
[authUserId],
|
||||
);
|
||||
if (authUser.rowCount === 0) {
|
||||
if (!authUser.rows[0]?.exists) {
|
||||
throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
@@ -2241,6 +2239,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
const status = optionalString(body, 'status');
|
||||
const billingStatus = optionalString(body, 'billingStatus');
|
||||
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
|
||||
if (status && !TENANT_STATUSES.has(status)) throw new HttpError(400, 'Unsupported tenant status', 'INVALID_STATUS');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
@@ -2258,6 +2257,25 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
);
|
||||
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
if (status && status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $2::jsonb
|
||||
where tenant_id = $1
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'platform.tenant.status_updated',
|
||||
tenantStatus: status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
|
||||
status: status || null,
|
||||
billingStatus: billingStatus || null,
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface TenantAdminAuth {
|
||||
dataScope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TenantPermissionContext = Pick<
|
||||
TenantAdminAuth,
|
||||
'role' | 'permissions' | 'templatePermissions'
|
||||
>;
|
||||
|
||||
function permissionKeys(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const keys = [permission];
|
||||
@@ -53,15 +58,23 @@ function explicitPermission(permissions: Record<string, unknown>, permission: st
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
export function hasResolvedTenantPermission(
|
||||
auth: TenantPermissionContext,
|
||||
permission: string,
|
||||
roleDefaultAllowed: boolean,
|
||||
) {
|
||||
const explicit = explicitPermission(auth.permissions, permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const templateExplicit = explicitPermission(auth.templatePermissions, permission);
|
||||
if (templateExplicit !== null) return templateExplicit;
|
||||
|
||||
return roleDefaultAllowed;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantPermissionContext, permission: string) {
|
||||
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
|
||||
return defaults.some(defaultPermission => {
|
||||
const roleDefaultAllowed = defaults.some(defaultPermission => {
|
||||
if (defaultPermission === '*') return true;
|
||||
if (defaultPermission === permission) return true;
|
||||
if (defaultPermission.endsWith(':*')) {
|
||||
@@ -69,6 +82,7 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return hasResolvedTenantPermission(auth, permission, roleDefaultAllowed);
|
||||
}
|
||||
|
||||
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
scopedSupervisionClassIds,
|
||||
supervisionBatchKey,
|
||||
} from './supervision.js';
|
||||
import {
|
||||
containsSearchPattern,
|
||||
decodeTenantStudentsCursor,
|
||||
encodeTenantStudentsCursor,
|
||||
} from './student-cursor.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
@@ -938,6 +943,7 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
const status = stringParam(ctx, 'status') || 'active';
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const classId = stringParam(ctx, 'classId');
|
||||
const cursor = decodeTenantStudentsCursor(stringParam(ctx, 'cursor'));
|
||||
const scopedIds = await scopedClassIds(auth);
|
||||
if (classId) await ensureReadableClass(auth, classId);
|
||||
if (!STUDENT_MEMBER_STATUSES.includes(status)) {
|
||||
@@ -952,8 +958,13 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
}
|
||||
const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2'];
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`);
|
||||
filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`);
|
||||
params.push(containsSearchPattern(keyword));
|
||||
filters.push(`(
|
||||
coalesce(u.username, '') || ' ' ||
|
||||
coalesce(u.name, '') || ' ' ||
|
||||
coalesce(u.phone, '') || ' ' ||
|
||||
coalesce(u.email::text, '')
|
||||
) ilike $${params.length} escape '\\'`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
@@ -981,11 +992,24 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
and scoped_cm.status = 'active'
|
||||
)`);
|
||||
}
|
||||
params.push(limit);
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.membershipId);
|
||||
filters.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
||||
}
|
||||
params.push(limit + 1);
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
const rows = await query<Record<string, unknown>>(
|
||||
`
|
||||
with class_agg as (
|
||||
with student_page as materialized (
|
||||
select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||
where ${filters.join(' and ')}
|
||||
order by tm.created_at desc, tm.id desc
|
||||
limit $${params.length}
|
||||
),
|
||||
class_agg as (
|
||||
select tcm.tenant_id, tcm.user_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
@@ -998,13 +1022,15 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
order by tc.sort_order asc, tc.created_at desc
|
||||
) filter (where tcm.status = 'active') as classes
|
||||
from public.tenant_class_members tcm
|
||||
join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id
|
||||
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
|
||||
where tcm.tenant_id = $1 and tcm.member_type = 'student'
|
||||
${classAggScopeSql}
|
||||
group by tcm.tenant_id, tcm.user_id
|
||||
)
|
||||
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
|
||||
tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt",
|
||||
tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt",
|
||||
tm.updated_at as "memberUpdatedAt",
|
||||
u.username, u.email::text as email, u.phone, u.name,
|
||||
null::text as "avatarUrl", u.primary_role as "primaryRole",
|
||||
u.last_seen_at as "lastSeenAt",
|
||||
@@ -1016,21 +1042,37 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
sp.last_check_in_date as "lastCheckInDate",
|
||||
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
|
||||
coalesce(ca.classes, '[]'::jsonb) as classes
|
||||
from public.tenant_memberships tm
|
||||
from student_page tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
|
||||
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
|
||||
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
|
||||
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
|
||||
where ${filters.join(' and ')}
|
||||
order by tm.created_at desc
|
||||
limit $${params.length}
|
||||
order by tm.created_at desc, tm.id desc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null };
|
||||
const hasMore = rows.length > limit;
|
||||
const pageItems = rows.slice(0, limit);
|
||||
const lastItem = pageItems.at(-1);
|
||||
const nextCursor = hasMore && lastItem
|
||||
? encodeTenantStudentsCursor({
|
||||
createdAt: String(lastItem.cursorCreatedAt || ''),
|
||||
membershipId: String(lastItem.membershipId || ''),
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
items: pageItems.map(item => {
|
||||
const { cursorCreatedAt: _cursorCreatedAt, ...publicItem } = item;
|
||||
return maskStudentFields(auth, publicItem);
|
||||
}),
|
||||
scoped: scopedIds !== null,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentRoute(ctx: RequestContext) {
|
||||
@@ -1049,6 +1091,16 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) {
|
||||
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
|
||||
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and revoked_at is null
|
||||
`,
|
||||
[auth.tenantId, userId],
|
||||
);
|
||||
}
|
||||
const profile = await client.query(
|
||||
`
|
||||
insert into public.student_profiles (
|
||||
@@ -1115,6 +1167,27 @@ export async function updateTenantStudentStatusRoute(ctx: RequestContext) {
|
||||
[auth.tenantId, userId, status],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND');
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $3::jsonb
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'tenant.student.status_updated',
|
||||
membershipStatus: status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, {
|
||||
userId,
|
||||
status,
|
||||
@@ -1148,6 +1221,16 @@ export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) {
|
||||
await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and revoked_at is null
|
||||
`,
|
||||
[auth.tenantId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await client.query(
|
||||
`
|
||||
|
||||
@@ -509,7 +509,10 @@ export async function tenantFeedbackReportRoute(ctx: RequestContext) {
|
||||
max(r.created_at) as "latestAt"
|
||||
from public.reports r
|
||||
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where r.tenant_id = $1
|
||||
and r.question_id is not null
|
||||
and r.created_at >= $2::timestamptz
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal file
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export interface TenantStudentsCursor {
|
||||
createdAt: string;
|
||||
membershipId: string;
|
||||
}
|
||||
|
||||
export function encodeTenantStudentsCursor(cursor: TenantStudentsCursor) {
|
||||
return Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: cursor.createdAt,
|
||||
membershipId: cursor.membershipId,
|
||||
}), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeTenantStudentsCursor(value: string): TenantStudentsCursor | null {
|
||||
if (!value) return null;
|
||||
if (value.length > 512 || !CURSOR_PATTERN.test(value)) {
|
||||
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : '';
|
||||
const membershipId = typeof parsed.membershipId === 'string' ? parsed.membershipId : '';
|
||||
if (parsed.version !== 1 || !createdAt || !Number.isFinite(Date.parse(createdAt)) || !UUID_PATTERN.test(membershipId)) {
|
||||
throw new Error('invalid cursor payload');
|
||||
}
|
||||
return { createdAt, membershipId };
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
|
||||
}
|
||||
}
|
||||
|
||||
export function containsSearchPattern(value: string) {
|
||||
return `%${value.replace(/[\\%_]/g, match => `\\${match}`)}%`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { hasResolvedTenantPermission } from '../tenant-admin/auth.js';
|
||||
|
||||
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
|
||||
|
||||
@@ -12,14 +13,6 @@ export interface TenantContentAuth {
|
||||
templatePermissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasContentPermission(permissions: Record<string, unknown>) {
|
||||
return permissions['*'] === true || permissions['content:*'] === true;
|
||||
}
|
||||
|
||||
function hasPermission(permissions: Record<string, unknown>, permissionKey: string) {
|
||||
return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true;
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -36,13 +29,6 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2
|
||||
and tm.status = 'active'
|
||||
and (
|
||||
tm.role = any($3::text[])
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
|
||||
or tm.permissions ? 'content:*'
|
||||
or tm.permissions ? '*'
|
||||
)
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
@@ -52,17 +38,23 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(CONTENT_ROLES)],
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
if (!CONTENT_ROLES.has(membership.role) && !hasContentPermission(membership.permissions || {}) && !hasContentPermission(membership.templatePermissions || {})) {
|
||||
const permissions = membership.permissions || {};
|
||||
const templatePermissions = membership.templatePermissions || {};
|
||||
if (!hasResolvedTenantPermission(
|
||||
{ role: membership.role, permissions, templatePermissions },
|
||||
'content:write',
|
||||
CONTENT_ROLES.has(membership.role),
|
||||
)) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
|
||||
return { tenantId, userId, role: membership.role, permissions, templatePermissions };
|
||||
}
|
||||
|
||||
export async function requireTenantContentPermission(
|
||||
@@ -85,15 +77,6 @@ export async function requireTenantContentPermission(
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2
|
||||
and tm.status = 'active'
|
||||
and (
|
||||
tm.role = any($3::text[])
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? $4
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
|
||||
or tm.permissions ? $4
|
||||
or tm.permissions ? 'content:*'
|
||||
or tm.permissions ? '*'
|
||||
)
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
@@ -103,7 +86,7 @@ export async function requireTenantContentPermission(
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, defaultRoles, permissionKey],
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
@@ -111,7 +94,11 @@ export async function requireTenantContentPermission(
|
||||
}
|
||||
const permissions = membership.permissions || {};
|
||||
const templatePermissions = membership.templatePermissions || {};
|
||||
if (!defaultRoles.includes(membership.role) && !hasPermission(permissions, permissionKey) && !hasPermission(templatePermissions, permissionKey)) {
|
||||
if (!hasResolvedTenantPermission(
|
||||
{ role: membership.role, permissions, templatePermissions },
|
||||
permissionKey,
|
||||
defaultRoles.includes(membership.role),
|
||||
)) {
|
||||
throw new HttpError(403, 'Tenant content permission is required', 'TENANT_CONTENT_PERMISSION_REQUIRED');
|
||||
}
|
||||
|
||||
|
||||
@@ -589,7 +589,10 @@ async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType:
|
||||
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1
|
||||
and q.status = 'published'
|
||||
and ${scopeFilter}
|
||||
|
||||
@@ -1830,7 +1830,12 @@ async function createGenericPreviewJob<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
|
||||
async function loadPreviewJob(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantContentAuth,
|
||||
jobId: string,
|
||||
leaseToken?: string,
|
||||
) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -1846,16 +1851,18 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
|
||||
target_entry_id: string | null;
|
||||
target_content_node_id: string | null;
|
||||
target_collection_id: string | null;
|
||||
lease_token: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_subject_id, target_category_id,
|
||||
target_node_id, target_question_bank_id,
|
||||
target_entry_id, target_content_node_id, target_collection_id
|
||||
target_entry_id, target_content_node_id, target_collection_id,
|
||||
lease_token
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = 'questions'
|
||||
limit 1
|
||||
for update
|
||||
${leaseToken ? '' : 'for update'}
|
||||
`,
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
@@ -1864,7 +1871,24 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
if (leaseToken) {
|
||||
const lease = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1
|
||||
and id = $2
|
||||
and status = 'importing'
|
||||
and lease_token = $3::uuid
|
||||
and lease_expires_at > now()
|
||||
`,
|
||||
[auth.tenantId, jobId, leaseToken],
|
||||
);
|
||||
if (lease.rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
@@ -1875,6 +1899,7 @@ async function loadGenericPreviewJob(
|
||||
auth: TenantContentAuth,
|
||||
jobId: string,
|
||||
importType: ContentImportType,
|
||||
leaseToken?: string,
|
||||
) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
@@ -1886,21 +1911,39 @@ async function loadGenericPreviewJob(
|
||||
target_region_id: string | null;
|
||||
target_entry_id: string | null;
|
||||
target_content_node_id: string | null;
|
||||
lease_token: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_entry_id, target_content_node_id
|
||||
target_region_id, target_entry_id, target_content_node_id, lease_token
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = $3
|
||||
limit 1
|
||||
for update
|
||||
${leaseToken ? '' : 'for update'}
|
||||
`,
|
||||
[auth.tenantId, jobId, importType],
|
||||
);
|
||||
|
||||
const job = result.rows[0];
|
||||
if (!job) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
if (leaseToken) {
|
||||
const lease = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1
|
||||
and id = $2
|
||||
and status = 'importing'
|
||||
and lease_token = $3::uuid
|
||||
and lease_expires_at > now()
|
||||
`,
|
||||
[auth.tenantId, jobId, leaseToken],
|
||||
);
|
||||
if (lease.rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
@@ -2118,7 +2161,10 @@ async function currentVersionHash(client: pg.PoolClient, questionId: string) {
|
||||
`
|
||||
select v.source_hash
|
||||
from public.questions q
|
||||
join public.question_versions v on v.id = q.current_version_id
|
||||
join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.id = $1
|
||||
limit 1
|
||||
`,
|
||||
@@ -3099,6 +3145,7 @@ interface ContentImportExecutionOptions {
|
||||
importType: ExecutableContentImportType;
|
||||
allowPartial?: boolean;
|
||||
allowQueuedJob?: boolean;
|
||||
leaseToken?: string;
|
||||
}
|
||||
|
||||
interface ContentImportExecutionResult {
|
||||
@@ -3112,15 +3159,40 @@ interface ContentImportExecutionResult {
|
||||
warningCount?: number;
|
||||
}
|
||||
|
||||
type ContentImportLeaseOptions = Pick<ContentImportExecutionOptions, 'allowQueuedJob' | 'leaseToken'>;
|
||||
|
||||
function asyncLeaseWhereClause(input: ContentImportLeaseOptions, firstParam: number) {
|
||||
return input.allowQueuedJob === true
|
||||
? ` and status = 'importing' and lease_token = $${firstParam}::uuid and lease_expires_at > now()`
|
||||
: '';
|
||||
}
|
||||
|
||||
function asyncLeaseParams(input: ContentImportLeaseOptions) {
|
||||
if (input.allowQueuedJob !== true) return [];
|
||||
if (!input.leaseToken) {
|
||||
throw new HttpError(409, 'Import worker lease is required', 'IMPORT_WORKER_LEASE_REQUIRED');
|
||||
}
|
||||
return [input.leaseToken];
|
||||
}
|
||||
|
||||
function assertImportLeaseUpdate(rowCount: number | null, input: ContentImportLeaseOptions) {
|
||||
if (input.allowQueuedJob === true && rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuestionsImportJob(
|
||||
auth: TenantContentAuth,
|
||||
input: Omit<ContentImportExecutionOptions, 'importType'>,
|
||||
) {
|
||||
const allowPartial = input.allowPartial === true;
|
||||
return transaction(async client => {
|
||||
const job = await loadPreviewJob(client, auth, input.jobId);
|
||||
const job = await loadPreviewJob(client, auth, input.jobId, input.leaseToken);
|
||||
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
if (input.allowQueuedJob === true) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
@@ -3142,6 +3214,9 @@ async function executeQuestionsImportJob(
|
||||
error_message = 'Preview contains validation errors',
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
@@ -3150,14 +3225,16 @@ async function executeQuestionsImportJob(
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
if (input.allowQueuedJob !== true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
}
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
@@ -3185,7 +3262,7 @@ async function executeQuestionsImportJob(
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
const completed = await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
@@ -3196,8 +3273,13 @@ async function executeQuestionsImportJob(
|
||||
finished_at = now(),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
${asyncLeaseWhereClause(input, 8)}
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -3207,8 +3289,10 @@ async function executeQuestionsImportJob(
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
...asyncLeaseParams(input),
|
||||
],
|
||||
);
|
||||
assertImportLeaseUpdate(completed.rowCount, input);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -3251,8 +3335,11 @@ async function executeGenericImportJob<T>(
|
||||
) {
|
||||
const allowPartial = input.allowPartial === true;
|
||||
return transaction(async client => {
|
||||
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType);
|
||||
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType, input.leaseToken);
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
if (input.allowQueuedJob === true) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
@@ -3274,6 +3361,9 @@ async function executeGenericImportJob<T>(
|
||||
error_message = 'Preview contains validation errors',
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
@@ -3282,14 +3372,16 @@ async function executeGenericImportJob<T>(
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
if (input.allowQueuedJob !== true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
}
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
@@ -3329,7 +3421,7 @@ async function executeGenericImportJob<T>(
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
const completed = await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
@@ -3340,8 +3432,13 @@ async function executeGenericImportJob<T>(
|
||||
finished_at = now(),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
${asyncLeaseWhereClause(input, 8)}
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -3351,8 +3448,10 @@ async function executeGenericImportJob<T>(
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
...asyncLeaseParams(input),
|
||||
],
|
||||
);
|
||||
assertImportLeaseUpdate(completed.rowCount, input);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -3477,6 +3576,9 @@ async function queueContentImportJob(
|
||||
queued_at = coalesce(queued_at, now()),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
next_attempt_at = now(),
|
||||
summary = coalesce(summary, '{}'::jsonb) || $3::jsonb,
|
||||
updated_at = now()
|
||||
@@ -3676,6 +3778,7 @@ const importJobSelect = `
|
||||
updated_count as "updatedCount", skipped_count as "skippedCount",
|
||||
execution_mode as "executionMode", queued_at as "queuedAt",
|
||||
locked_at as "lockedAt", locked_by as "lockedBy",
|
||||
lease_expires_at as "leaseExpiresAt", last_heartbeat_at as "lastHeartbeatAt",
|
||||
attempt_count as "attemptCount", max_attempts as "maxAttempts",
|
||||
next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata",
|
||||
summary, error_message as "errorMessage",
|
||||
|
||||
@@ -395,7 +395,10 @@ async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
|
||||
v.answer_text, v.explanation, v.sub_questions, v.code_lang,
|
||||
v.code_template, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1
|
||||
and q.question_bank_id = $2
|
||||
and q.status = 'published'
|
||||
@@ -446,7 +449,10 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.legacy_id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
@@ -1440,7 +1446,10 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
@@ -1795,7 +1804,10 @@ async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth,
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
|
||||
153
apps/api/src/features/tenant/locator.ts
Normal file
153
apps/api/src/features/tenant/locator.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export interface TenantLocatorInput {
|
||||
origin?: string;
|
||||
referer?: string;
|
||||
requestedHost?: string;
|
||||
requestHost?: string;
|
||||
tenantCode?: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export interface TenantLocatorError {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TenantHostLocator {
|
||||
kind: 'host';
|
||||
host: string;
|
||||
expectedTenantCode: string | null;
|
||||
source: 'browser' | 'local-development';
|
||||
}
|
||||
|
||||
export interface TenantCodeLocator {
|
||||
kind: 'tenantCode';
|
||||
tenantCode: string;
|
||||
source: 'headless-client' | 'local-development';
|
||||
}
|
||||
|
||||
export type TenantLocatorDecision = TenantLocatorError | {
|
||||
ok: true;
|
||||
locator: TenantHostLocator | TenantCodeLocator;
|
||||
};
|
||||
|
||||
function normalizedHostname(value: string) {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function normalizeTenantHost(value: string) {
|
||||
const raw = value.trim();
|
||||
if (!raw || /[\s,]/.test(raw)) return '';
|
||||
|
||||
const directIp = normalizedHostname(raw);
|
||||
if (isIP(directIp)) return directIp;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`);
|
||||
if (parsed.username || parsed.password) return '';
|
||||
const hostname = normalizedHostname(parsed.hostname);
|
||||
if (!hostname) return '';
|
||||
if (isIP(hostname)) return hostname;
|
||||
if (!/^[a-z0-9.-]+$/.test(hostname)) return '';
|
||||
if (hostname.startsWith('.') || hostname.includes('..')) return '';
|
||||
return hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalTenantHost(value: string) {
|
||||
const hostname = normalizeTenantHost(value);
|
||||
if (!hostname) return false;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (hostname === '::1' || hostname === '0.0.0.0') return true;
|
||||
return isIP(hostname) === 4 && hostname.startsWith('127.');
|
||||
}
|
||||
|
||||
function hostFromBrowserUrl(value: string) {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw === 'null' || raw.includes(',')) return '';
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
|
||||
return normalizeTenantHost(parsed.host);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTenantCode(value: string) {
|
||||
const normalized = value.trim();
|
||||
return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : '';
|
||||
}
|
||||
|
||||
function fail(statusCode: number, code: string, message: string): TenantLocatorError {
|
||||
return { ok: false, statusCode, code, message };
|
||||
}
|
||||
|
||||
export function selectTenantLocator(input: TenantLocatorInput): TenantLocatorDecision {
|
||||
const rawTenantCode = input.tenantCode?.trim() || '';
|
||||
const tenantCode = normalizeTenantCode(rawTenantCode);
|
||||
if (rawTenantCode && !tenantCode) return fail(400, 'TENANT_CODE_INVALID', 'Invalid tenant code');
|
||||
|
||||
const rawRequestedHost = input.requestedHost?.trim() || '';
|
||||
const requestedHost = normalizeTenantHost(rawRequestedHost);
|
||||
if (rawRequestedHost && !requestedHost) return fail(400, 'TENANT_HOST_INVALID', 'Invalid tenant host');
|
||||
|
||||
const rawOrigin = input.origin?.trim() || '';
|
||||
const rawReferer = input.referer?.trim() || '';
|
||||
const browserHost = rawOrigin && rawOrigin !== 'null'
|
||||
? hostFromBrowserUrl(rawOrigin)
|
||||
: hostFromBrowserUrl(rawReferer);
|
||||
if (rawOrigin && rawOrigin !== 'null' && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser origin');
|
||||
if (!rawOrigin && rawReferer && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser referer');
|
||||
|
||||
if (browserHost) {
|
||||
if (requestedHost && requestedHost !== browserHost) {
|
||||
return fail(409, 'TENANT_HOST_CONFLICT', 'Requested host does not match browser origin');
|
||||
}
|
||||
if (isLocalTenantHost(browserHost)) {
|
||||
if (input.isProduction) return fail(400, 'TENANT_LOCAL_HOST_FORBIDDEN', 'Local tenant host is not allowed in production');
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: browserHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
locator: {
|
||||
kind: 'host',
|
||||
host: browserHost,
|
||||
expectedTenantCode: tenantCode || null,
|
||||
source: 'browser',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedHost) {
|
||||
if (input.isProduction || !isLocalTenantHost(requestedHost)) {
|
||||
return fail(400, 'TENANT_HOST_UNTRUSTED', 'Tenant host requires a matching browser origin');
|
||||
}
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: requestedHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
|
||||
const requestHost = normalizeTenantHost(input.requestHost || '');
|
||||
if (!input.isProduction && isLocalTenantHost(requestHost)) {
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: requestHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'headless-client' } };
|
||||
}
|
||||
|
||||
return fail(400, 'TENANT_LOCATOR_REQUIRED', 'Tenant host or tenant code is required');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { config } from '../../core/config.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { HttpError } from '../../core/errors.js';
|
||||
import { getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { selectTenantLocator } from './locator.js';
|
||||
|
||||
interface TenantResolveRow {
|
||||
id: string;
|
||||
@@ -23,24 +24,33 @@ interface TenantResolveRow {
|
||||
public_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string) {
|
||||
return host.split(':')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
type TenantLookup = (sql: string, params: unknown[]) => Promise<TenantResolveRow | null>;
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
const defaultTenantLookup: TenantLookup = (sql, params) => queryOne<TenantResolveRow>(sql, params);
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext, lookup: TenantLookup = defaultTenantLookup) {
|
||||
const hostParam = ctx.url.searchParams.get('host') || '';
|
||||
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
|
||||
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
|
||||
const decision = selectTenantLocator({
|
||||
origin: getHeader(ctx.req, 'origin'),
|
||||
referer: getHeader(ctx.req, 'referer'),
|
||||
requestedHost: hostParam,
|
||||
requestHost: getHeader(ctx.req, 'host'),
|
||||
tenantCode,
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
if (!decision.ok) throw new HttpError(decision.statusCode, decision.message, decision.code);
|
||||
const locator = decision.locator;
|
||||
|
||||
const row = tenantCode
|
||||
? await queryOne<TenantResolveRow>(
|
||||
const row = locator.kind === 'tenantCode'
|
||||
? await lookup(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
null::text as host,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name,
|
||||
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
@@ -51,16 +61,16 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
where t.slug = $1 and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantCode],
|
||||
[locator.tenantCode],
|
||||
)
|
||||
: await queryOne<TenantResolveRow>(
|
||||
: await lookup(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
d.host::text,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name,
|
||||
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
@@ -72,20 +82,20 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[requestHost || 'localhost'],
|
||||
[locator.host],
|
||||
);
|
||||
|
||||
if (!row && requestHost !== 'localhost') {
|
||||
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
|
||||
return resolveTenantRoute(ctx);
|
||||
if (!row) {
|
||||
if (locator.kind === 'host') throw new HttpError(404, 'Tenant domain is not bound', 'TENANT_DOMAIN_NOT_BOUND');
|
||||
throw new HttpError(404, 'Tenant code was not found', 'TENANT_CODE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
found: false,
|
||||
message: 'Tenant not found',
|
||||
lookup: { host: requestHost, tenantCode: tenantCode || null },
|
||||
};
|
||||
if (
|
||||
locator.kind === 'host'
|
||||
&& locator.expectedTenantCode
|
||||
&& row.slug.toLowerCase() !== locator.expectedTenantCode.toLowerCase()
|
||||
) {
|
||||
throw new HttpError(409, 'Tenant code does not match the resolved domain', 'TENANT_LOCATOR_CONFLICT');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ apps/taro/dist/h5-platform-admin
|
||||
|
||||
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。
|
||||
|
||||
Taro 固定稳定版 `4.2.0`。安装时 workspace postinstall 会对精确版本和源码 hash 应用两项 H5-only runtime patch:Input watcher 在 ref 未就绪时安全退出,Button loading 始终保留同一 loading 节点并只切换显示状态。所有 H5 build/dev 命令都会先运行 fail-closed 检查;不要使用 `npm ci --ignore-scripts`。微信小程序使用原生组件,不依赖这两项 H5 patch。
|
||||
|
||||
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行:
|
||||
|
||||
```bash
|
||||
npm run smoke:taro:h5
|
||||
npm run smoke:taro:h5:interaction
|
||||
npm run manifest:taro:h5
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
```
|
||||
@@ -104,6 +107,17 @@ TARO_APP_SUPABASE_PUBLISHABLE_KEY=<publishable-key>
|
||||
TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用>
|
||||
```
|
||||
|
||||
微信小程序本地预览使用 `npm run build:taro:weapp:student`。正式上传前必须使用严格命令,并注入公开的 HTTPS API、固定租户码和真实 AppID:
|
||||
|
||||
```bash
|
||||
TARO_APP_API_BASE_URL=https://api.example.com \
|
||||
TARO_APP_TENANT_CODE=tenant-code \
|
||||
WECHAT_MINIAPP_APP_ID=wx0000000000000000 \
|
||||
npm run build:taro:weapp:student:production
|
||||
```
|
||||
|
||||
共享 SaaS 小程序可改用 `TARO_APP_WEAPP_TENANT_MODE=launch`,由小程序码 query/scene 或 `referrerInfo.extraData.tenantCode` 传入租户码,无需为每个租户重新打包。严格构建会启用微信合法域名检查,并拒绝本地 API、测试 AppID、固定模式缺失 tenantCode 或超出包体预算的产物;launch 模式启动时缺少租户码会明确报错,不会回退默认租户。
|
||||
|
||||
禁止把 service role、数据库连接串、支付私钥、对象存储密钥放进 Taro 构建环境。
|
||||
|
||||
## 视觉规范
|
||||
|
||||
@@ -2,14 +2,75 @@ import type { UserConfigExport } from '@tarojs/cli';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const portal = process.env.TARO_APP_PORTAL || 'student';
|
||||
const portal = process.env.TARO_APP_PORTAL?.trim() || 'student';
|
||||
const taroEnv = process.env.TARO_ENV?.trim() || 'h5';
|
||||
const releaseMode = process.env.TARO_APP_RELEASE_MODE?.trim().toLowerCase() || 'preview';
|
||||
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDirByPortal: Record<string, string> = {
|
||||
student: 'dist/h5-student',
|
||||
'tenant-admin': 'dist/h5-tenant-admin',
|
||||
'platform-admin': 'dist/h5-platform-admin',
|
||||
const supportedPortals = new Set(['student', 'tenant-admin', 'platform-admin']);
|
||||
if (!supportedPortals.has(portal)) throw new Error(`Unsupported Taro portal: ${portal}`);
|
||||
if (!/^[a-z0-9-]+$/i.test(taroEnv)) throw new Error(`Unsupported Taro target: ${taroEnv}`);
|
||||
if (releaseMode !== 'preview' && releaseMode !== 'production') throw new Error(`Unsupported Taro release mode: ${releaseMode}`);
|
||||
const outputRoot = `dist/${taroEnv}-${portal}`;
|
||||
|
||||
function publicBuildValue(name: string) {
|
||||
return process.env[name]?.trim() || '';
|
||||
}
|
||||
|
||||
function normalizedHostname(value: string) {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function isLoopbackHostname(value: string) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return hostname === 'localhost'
|
||||
|| hostname.endsWith('.localhost')
|
||||
|| hostname === '::1'
|
||||
|| hostname === '0.0.0.0'
|
||||
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
||||
}
|
||||
|
||||
function isPlaceholderHostname(value: string) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
||||
}
|
||||
|
||||
function isProductionWeappApiBaseUrl(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:'
|
||||
&& Boolean(parsed.hostname)
|
||||
&& !isLoopbackHostname(parsed.hostname)
|
||||
&& !isPlaceholderHostname(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = publicBuildValue('TARO_APP_API_BASE_URL')
|
||||
|| (releaseMode === 'production' ? '' : 'http://127.0.0.1:8787');
|
||||
const configuredTenantCode = publicBuildValue('TARO_APP_TENANT_CODE');
|
||||
const requestedWeappTenantMode = publicBuildValue('TARO_APP_WEAPP_TENANT_MODE').toLowerCase();
|
||||
if (taroEnv === 'weapp' && requestedWeappTenantMode && !['fixed', 'launch'].includes(requestedWeappTenantMode)) {
|
||||
throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch');
|
||||
}
|
||||
const weappTenantMode = taroEnv === 'weapp'
|
||||
? requestedWeappTenantMode || (releaseMode === 'production' || configuredTenantCode ? 'fixed' : 'launch')
|
||||
: '';
|
||||
const tenantCode = taroEnv === 'weapp' && weappTenantMode === 'launch' ? '' : configuredTenantCode;
|
||||
if (taroEnv === 'weapp' && releaseMode === 'production' && !isProductionWeappApiBaseUrl(apiBaseUrl)) {
|
||||
throw new Error('TARO_APP_API_BASE_URL must use a non-placeholder HTTPS host for a production WeApp build');
|
||||
}
|
||||
|
||||
const publicBuildConfig = {
|
||||
portal,
|
||||
target: taroEnv,
|
||||
releaseMode,
|
||||
weappTenantMode,
|
||||
apiBaseUrl,
|
||||
supabaseUrl: publicBuildValue('TARO_APP_SUPABASE_URL'),
|
||||
supabasePublishableKey: publicBuildValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY'),
|
||||
tenantCode,
|
||||
};
|
||||
const outputRoot = distDirByPortal[portal] || distDirByPortal.student;
|
||||
|
||||
export default {
|
||||
projectName: 'tiku-saas-taro',
|
||||
@@ -32,7 +93,9 @@ export default {
|
||||
alias: {
|
||||
'@': path.resolve(configDir, '..', 'src'),
|
||||
},
|
||||
defineConstants: {},
|
||||
defineConstants: {
|
||||
__TARO_PUBLIC_BUILD_CONFIG__: JSON.stringify(publicBuildConfig),
|
||||
},
|
||||
copy: {
|
||||
patterns: [
|
||||
{
|
||||
@@ -45,6 +108,10 @@ export default {
|
||||
h5: {
|
||||
publicPath: '/',
|
||||
staticDirectory: 'static',
|
||||
devServer: {
|
||||
host: '127.0.0.1',
|
||||
allowedHosts: ['localhost', '127.0.0.1'],
|
||||
},
|
||||
output: {
|
||||
filename: 'js/[name].[contenthash:8].js',
|
||||
chunkFilename: 'js/[name].[contenthash:8].js',
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:h5": "taro build --type h5",
|
||||
"build:h5:student": "cross-env TARO_APP_PORTAL=student taro build --type h5",
|
||||
"build:h5:tenant": "cross-env TARO_APP_PORTAL=tenant-admin taro build --type h5",
|
||||
"build:h5:platform": "cross-env TARO_APP_PORTAL=platform-admin taro build --type h5",
|
||||
"dev:h5": "taro build --type h5 --watch",
|
||||
"postinstall": "node ../../scripts/taro-components-h5-runtime-patch.js --apply",
|
||||
"build:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5",
|
||||
"build:h5:student": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5",
|
||||
"build:h5:tenant": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=production taro build --type h5",
|
||||
"build:h5:platform": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=production taro build --type h5",
|
||||
"build:h5:student:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5",
|
||||
"build:h5:tenant:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=preview taro build --type h5",
|
||||
"build:h5:platform:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=preview taro build --type h5",
|
||||
"build:weapp:student": "node ../../scripts/build-weapp-student.js",
|
||||
"build:weapp:student:production": "node ../../scripts/build-weapp-student.js --production && node ../../scripts/taro-weapp-release-guardrails.js --production",
|
||||
"dev:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5 --watch",
|
||||
"dev:weapp:student": "cross-env TARO_ENV=weapp TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type weapp --watch",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/weapp/",
|
||||
"miniprogramRoot": "dist/weapp-student/",
|
||||
"projectname": "tiku-saas-taro",
|
||||
"description": "工学教育 SaaS 题库 Taro 多端前端",
|
||||
"appid": "touristappid",
|
||||
|
||||
@@ -2,8 +2,10 @@ declare const process: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const allPageRoutes = [
|
||||
'pages/bootstrap/index',
|
||||
const bootstrapRoute = 'pages/bootstrap/index';
|
||||
const loginRoute = 'pages/student/login/index';
|
||||
|
||||
const studentPageRoutes = [
|
||||
'pages/student/login/index',
|
||||
'pages/student/home/index',
|
||||
'pages/student/region/index',
|
||||
@@ -21,6 +23,9 @@ const allPageRoutes = [
|
||||
'pages/student/assets/index',
|
||||
'pages/student/notifications/index',
|
||||
'pages/student/profile/index',
|
||||
];
|
||||
|
||||
const tenantAdminPageRoutes = [
|
||||
'pages/tenant-admin/workbench/index',
|
||||
'pages/tenant-admin/dashboard/index',
|
||||
'pages/tenant-admin/students/index',
|
||||
@@ -28,6 +33,9 @@ const allPageRoutes = [
|
||||
'pages/tenant-admin/marketing/index',
|
||||
'pages/tenant-admin/finance/index',
|
||||
'pages/tenant-admin/settings/index',
|
||||
];
|
||||
|
||||
const platformAdminPageRoutes = [
|
||||
'pages/platform-admin/workbench/index',
|
||||
'pages/platform-admin/tenants/index',
|
||||
'pages/platform-admin/billing/index',
|
||||
@@ -35,22 +43,52 @@ const allPageRoutes = [
|
||||
'pages/platform-admin/staff/index',
|
||||
];
|
||||
|
||||
const portalPageRoutes: Record<string, string[]> = {
|
||||
student: studentPageRoutes,
|
||||
'tenant-admin': tenantAdminPageRoutes,
|
||||
'platform-admin': platformAdminPageRoutes,
|
||||
};
|
||||
|
||||
const portalLandingRoutes: Record<string, string> = {
|
||||
student: 'pages/student/home/index',
|
||||
'tenant-admin': 'pages/tenant-admin/workbench/index',
|
||||
'platform-admin': 'pages/platform-admin/workbench/index',
|
||||
};
|
||||
|
||||
function pagesForPortal(portal: string | undefined) {
|
||||
const landingRoute = portalLandingRoutes[portal || 'student'] || portalLandingRoutes.student;
|
||||
function normalizedPortal(portal: string | undefined) {
|
||||
const normalized = portal?.trim() || 'student';
|
||||
if (!portalPageRoutes[normalized]) throw new Error(`Unsupported Taro portal: ${normalized}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function h5PagesForPortal(portal: string) {
|
||||
const landingRoute = portalLandingRoutes[portal];
|
||||
return [
|
||||
landingRoute,
|
||||
...allPageRoutes.filter(route => route !== landingRoute),
|
||||
];
|
||||
bootstrapRoute,
|
||||
loginRoute,
|
||||
...portalPageRoutes[portal],
|
||||
].filter((route, index, routes) => routes.indexOf(route) === index);
|
||||
}
|
||||
|
||||
const portal = normalizedPortal(process.env.TARO_APP_PORTAL);
|
||||
const isStudentWeapp = process.env.TARO_ENV === 'weapp' && portal === 'student';
|
||||
|
||||
if (process.env.TARO_ENV === 'weapp' && portal !== 'student') {
|
||||
throw new Error(`Unsupported WeApp portal: ${portal}. Tenant and platform administration are H5-only.`);
|
||||
}
|
||||
|
||||
export default defineAppConfig({
|
||||
pages: pagesForPortal(process.env.TARO_APP_PORTAL),
|
||||
pages: isStudentWeapp ? [bootstrapRoute] : h5PagesForPortal(portal),
|
||||
...(isStudentWeapp ? {
|
||||
subPackages: [
|
||||
{
|
||||
root: 'pages/student',
|
||||
pages: studentPageRoutes.map(route => route.replace(/^pages\/student\//, '')),
|
||||
},
|
||||
],
|
||||
lazyCodeLoading: 'requiredComponents' as const,
|
||||
} : {}),
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#0f172a',
|
||||
|
||||
@@ -32,6 +32,12 @@ body {
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.tiku-theme-root {
|
||||
min-height: 100vh;
|
||||
background: var(--tiku-page);
|
||||
color: var(--tiku-text);
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
input,
|
||||
@@ -146,3 +152,15 @@ textarea {
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.route-guard-retry {
|
||||
align-self: flex-start;
|
||||
min-width: 148px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: var(--tiku-radius-sm);
|
||||
background: #fff;
|
||||
color: var(--tiku-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { AppProvider, useApp } from '@/app/AppProvider';
|
||||
import { AdminLegacyShell } from '@/components/AdminLegacyShell';
|
||||
import { StudentLegacyShell } from '@/components/StudentLegacyShell';
|
||||
import { appEnv, isH5Runtime } from '@/env';
|
||||
import { currentPagePath, guardCurrentRoute, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard';
|
||||
import {
|
||||
currentPagePath,
|
||||
isPathAllowedForPortal,
|
||||
landingPath,
|
||||
normalizePagePath,
|
||||
redirectToForbidden,
|
||||
redirectToLogin,
|
||||
shouldGuardPath,
|
||||
} from '@/services/routeGuard';
|
||||
import { applyWeappLaunchTenant, replaceLocation } from '@/capabilities/navigation';
|
||||
import { ThemeProvider, useTheme } from '@/theme/ThemeProvider';
|
||||
import './app.css';
|
||||
|
||||
const routeChangeEvent = 'tiku-route-change';
|
||||
@@ -60,7 +71,7 @@ function shouldUseAdminShell(path: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function RouteGuardOverlay() {
|
||||
function RouteGuardOverlay({ error, onRetry }: { error?: string; onRetry: () => void }) {
|
||||
const copy = appEnv.portal === 'tenant-admin'
|
||||
? {
|
||||
kicker: 'Tenant Admin',
|
||||
@@ -85,32 +96,40 @@ function RouteGuardOverlay() {
|
||||
<View className='route-guard-panel'>
|
||||
<Text className='route-guard-kicker'>{copy.kicker}</Text>
|
||||
<Text className='route-guard-title'>{copy.title}</Text>
|
||||
<Text className='route-guard-subtitle'>{copy.subtitle}</Text>
|
||||
<Text className='route-guard-subtitle'>{error || copy.subtitle}</Text>
|
||||
{error ? <Button className='route-guard-retry' onClick={onRetry}>重新加载</Button> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App({ children }: PropsWithChildren) {
|
||||
const [routeReady, setRouteReady] = useState(false);
|
||||
const [path, setPath] = useState(() => currentPagePath());
|
||||
|
||||
function verifyCurrentRoute() {
|
||||
const nextPath = currentPagePath();
|
||||
setPath(nextPath);
|
||||
const allowedRoute = isPathAllowedForPortal(nextPath);
|
||||
const protectedRoute = shouldGuardPath(nextPath);
|
||||
setRouteReady(allowedRoute && !protectedRoute);
|
||||
guardCurrentRoute()
|
||||
.then(ok => setRouteReady(Boolean(ok)))
|
||||
.catch(() => setRouteReady(false));
|
||||
}
|
||||
function AppFrame({ children, path }: PropsWithChildren<{ path: string }>) {
|
||||
const { bootstrapError, bootstrapStatus, currentUser, refresh, tenant } = useApp();
|
||||
const { rootStyle } = useTheme();
|
||||
const redirectKeyRef = useRef('');
|
||||
const allowedRoute = isPathAllowedForPortal(path);
|
||||
const protectedRoute = shouldGuardPath(path);
|
||||
const routeReady = allowedRoute && (protectedRoute
|
||||
? bootstrapStatus === 'ready'
|
||||
: (appEnv.portal === 'platform-admin' || Boolean(tenant)) && bootstrapStatus !== 'error');
|
||||
const identityKey = `${tenant?.tenantId || 'unresolved'}:${currentUser?.id || 'anonymous'}`;
|
||||
|
||||
useEffect(() => {
|
||||
verifyCurrentRoute();
|
||||
return installH5RouteListener(verifyCurrentRoute);
|
||||
}, []);
|
||||
let redirectKey = '';
|
||||
if (!allowedRoute) {
|
||||
redirectKey = `portal:${path}`;
|
||||
if (redirectKeyRef.current !== redirectKey) void replaceLocation(landingPath());
|
||||
} else if (protectedRoute && bootstrapStatus === 'unauthenticated') {
|
||||
redirectKey = `login:${path}`;
|
||||
if (redirectKeyRef.current !== redirectKey) redirectToLogin(path);
|
||||
} else if (protectedRoute && bootstrapStatus === 'forbidden') {
|
||||
redirectKey = `forbidden:${path}`;
|
||||
const reason = appEnv.portal === 'platform-admin' ? '当前账号不是平台管理员' : '当前账号没有后台访问权限';
|
||||
if (redirectKeyRef.current !== redirectKey) redirectToForbidden(reason, path);
|
||||
}
|
||||
redirectKeyRef.current = redirectKey;
|
||||
}, [allowedRoute, bootstrapStatus, path, protectedRoute]);
|
||||
|
||||
const content = shouldUseStudentShell(path)
|
||||
? <StudentLegacyShell>{children}</StudentLegacyShell>
|
||||
@@ -119,11 +138,36 @@ export default function App({ children }: PropsWithChildren) {
|
||||
: children;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className={routeReady ? '' : 'route-guard-hidden'}>
|
||||
<View className='tiku-theme-root' style={rootStyle}>
|
||||
<View key={identityKey} className={routeReady ? '' : 'route-guard-hidden'}>
|
||||
{content}
|
||||
</View>
|
||||
{!routeReady ? <RouteGuardOverlay /> : null}
|
||||
</>
|
||||
{!routeReady ? (
|
||||
<RouteGuardOverlay
|
||||
error={bootstrapStatus === 'error' ? bootstrapError : undefined}
|
||||
onRetry={() => void refresh({ path, forceTenant: bootstrapStatus === 'error' })}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App({ children }: PropsWithChildren) {
|
||||
applyWeappLaunchTenant();
|
||||
const router = useRouter(true);
|
||||
const [path, setPath] = useState(() => currentPagePath());
|
||||
|
||||
useEffect(() => installH5RouteListener(() => setPath(currentPagePath())), []);
|
||||
useEffect(() => {
|
||||
const dynamicPath = normalizePagePath(router.path || '');
|
||||
if (dynamicPath) setPath(dynamicPath);
|
||||
}, [router.path]);
|
||||
|
||||
return (
|
||||
<AppProvider path={path}>
|
||||
<ThemeProvider>
|
||||
<AppFrame path={path}>{children}</AppFrame>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
368
apps/taro/src/app/AppProvider.tsx
Normal file
368
apps/taro/src/app/AppProvider.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isWeappRuntime, type AppEnv } from '@/env';
|
||||
import type { ApiEnvelope, ApiSession, CurrentUser, TenantContext } from '@/types';
|
||||
import { ApiError, clearSession, clearTenantContext, getSession, getTenantContext, resolveTenant } from '@/services/api';
|
||||
import { loadCurrentUser, logout as logoutRequest, subscribeAuthChanges } from '@/services/auth';
|
||||
import { emitSessionChange } from './session-events';
|
||||
import { loadPlatformPermissions } from '@/services/platformAdmin';
|
||||
import { loadTenantPermissions, type TenantPermissionsPayload } from '@/services/tenantAdmin';
|
||||
import { currentPagePath, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard';
|
||||
import { runtimeHost } from '@/capabilities/navigation';
|
||||
import { activateStorageUser, clearActiveStorageUserData } from '@/capabilities/storage';
|
||||
import {
|
||||
hasPlatformPermission,
|
||||
hasTenantMenuAccess,
|
||||
hasTenantPermission,
|
||||
objectRecord,
|
||||
type PlatformAccessSnapshot,
|
||||
type TenantAccessSnapshot,
|
||||
} from './permissions';
|
||||
import { visiblePortalNavigation, type PortalNavigationItem } from './portal-navigation';
|
||||
|
||||
export type BootstrapStatus =
|
||||
| 'idle'
|
||||
| 'loading-runtime'
|
||||
| 'resolving-tenant'
|
||||
| 'authenticating'
|
||||
| 'ready'
|
||||
| 'unauthenticated'
|
||||
| 'forbidden'
|
||||
| 'error';
|
||||
|
||||
interface AppState {
|
||||
runtimeConfig: AppEnv;
|
||||
tenant: TenantContext | null;
|
||||
currentUser: CurrentUser | null;
|
||||
session: ApiSession | null;
|
||||
tenantAccess: TenantAccessSnapshot | null;
|
||||
platformAccess: PlatformAccessSnapshot | null;
|
||||
bootstrapStatus: BootstrapStatus;
|
||||
bootstrapError: string;
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
path?: string;
|
||||
forceTenant?: boolean;
|
||||
authenticatePublic?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
interface AppContextValue extends AppState {
|
||||
currentPath: string;
|
||||
refresh: (options?: RefreshOptions) => Promise<boolean>;
|
||||
refreshTenant: () => Promise<boolean>;
|
||||
switchTenant: (tenantCode: string) => Promise<boolean>;
|
||||
signOut: () => Promise<void>;
|
||||
canTenant: (permission?: string) => boolean;
|
||||
canPlatform: (permission?: string) => boolean;
|
||||
canTenantMenu: (input: { menuKey?: string; moduleKey?: string; permission?: string }) => boolean;
|
||||
navigationItems: PortalNavigationItem[];
|
||||
}
|
||||
|
||||
function initialState(): AppState {
|
||||
return {
|
||||
runtimeConfig: { ...appEnv },
|
||||
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
|
||||
currentUser: null,
|
||||
session: appEnv.portal === 'platform-admin' ? null : getSession(),
|
||||
tenantAccess: null,
|
||||
platformAccess: null,
|
||||
bootstrapStatus: 'idle',
|
||||
bootstrapError: '',
|
||||
};
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
function currentUserFrom(payload: ApiEnvelope<CurrentUser> | null) {
|
||||
return payload?.user || payload?.item || null;
|
||||
}
|
||||
|
||||
function currentSessionFrom(payload: ApiEnvelope<CurrentUser> | null) {
|
||||
const stored = getSession();
|
||||
if (!payload?.session) return stored;
|
||||
if (payload.session.source !== 'app_session' || stored?.source !== 'app_session') return payload.session;
|
||||
return {
|
||||
...payload.session,
|
||||
...(stored?.token ? { token: stored.token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function tenantAccessFrom(payload: TenantPermissionsPayload): TenantAccessSnapshot {
|
||||
const current = payload.current || {};
|
||||
return {
|
||||
role: String(current.role || ''),
|
||||
permissions: objectRecord(current.permissions),
|
||||
templatePermissions: objectRecord(current.templatePermissions),
|
||||
effectivePermissions: objectRecord(current.effectivePermissions),
|
||||
menuPermissions: objectRecord(current.menuPermissions),
|
||||
modulePermissions: objectRecord(current.modulePermissions),
|
||||
fieldPermissions: objectRecord(current.fieldPermissions),
|
||||
dataScope: objectRecord(current.dataScope),
|
||||
roleDefaults: payload.roleDefaults || {},
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapFailureStatus(error: unknown): BootstrapStatus {
|
||||
if (error instanceof ApiError && error.status === 401) return 'unauthenticated';
|
||||
if (error instanceof ApiError && error.status === 403) return 'forbidden';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function bootstrapFailureMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '应用初始化失败';
|
||||
}
|
||||
|
||||
export function AppProvider({ children, path }: PropsWithChildren<{ path: string }>) {
|
||||
const [state, setState] = useState<AppState>(initialState);
|
||||
const stateRef = useRef(state);
|
||||
const requestIdRef = useRef(0);
|
||||
const pathRef = useRef(path);
|
||||
const authenticatedAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
pathRef.current = path;
|
||||
}, [path]);
|
||||
|
||||
const refresh = useCallback(async (options: RefreshOptions = {}) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const targetPath = options.path || pathRef.current || currentPagePath();
|
||||
if (!options.silent) {
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
bootstrapStatus: 'loading-runtime',
|
||||
bootstrapError: '',
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
assertFrontendSecretsAreAbsent();
|
||||
await ensureRuntimeConfigLoaded();
|
||||
if (isWeappRuntime() && !appEnv.tenantCode) throw new Error('小程序启动参数缺少 tenantCode');
|
||||
if (requestId !== requestIdRef.current) return false;
|
||||
if (!options.silent) {
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
runtimeConfig: { ...appEnv },
|
||||
bootstrapStatus: 'resolving-tenant',
|
||||
}));
|
||||
}
|
||||
|
||||
let tenant = appEnv.portal === 'platform-admin'
|
||||
? null
|
||||
: (options.forceTenant ? null : getTenantContext());
|
||||
if (appEnv.portal !== 'platform-admin' && !tenant) tenant = await resolveTenant({ host: runtimeHost() });
|
||||
if (requestId !== requestIdRef.current) return false;
|
||||
|
||||
if (!isPathAllowedForPortal(targetPath)) {
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
runtimeConfig: { ...appEnv },
|
||||
tenant,
|
||||
bootstrapStatus: 'forbidden',
|
||||
bootstrapError: '当前构建入口不包含该页面',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
const protectedPath = shouldGuardPath(targetPath);
|
||||
const shouldAuthenticate = protectedPath || options.authenticatePublic;
|
||||
if (!shouldAuthenticate) {
|
||||
const storedSession = getSession();
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
runtimeConfig: { ...appEnv },
|
||||
tenant,
|
||||
currentUser: storedSession ? previous.currentUser : null,
|
||||
session: storedSession,
|
||||
tenantAccess: null,
|
||||
platformAccess: null,
|
||||
bootstrapStatus: 'ready',
|
||||
bootstrapError: '',
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!options.silent) setState(previous => ({ ...previous, tenant, bootstrapStatus: 'authenticating' }));
|
||||
let userPayload: ApiEnvelope<CurrentUser> | null = null;
|
||||
let tenantAccess: TenantAccessSnapshot | null = null;
|
||||
let platformAccess: PlatformAccessSnapshot | null = null;
|
||||
|
||||
if (appEnv.portal === 'tenant-admin') {
|
||||
const [permissionPayload, optionalUserPayload] = await Promise.all([
|
||||
loadTenantPermissions(),
|
||||
loadCurrentUser().catch(() => null),
|
||||
]);
|
||||
tenantAccess = tenantAccessFrom(permissionPayload);
|
||||
userPayload = optionalUserPayload;
|
||||
const tenantUserId = String(permissionPayload.current?.userId || '');
|
||||
if (!userPayload && tenantUserId) {
|
||||
userPayload = {
|
||||
user: {
|
||||
id: tenantUserId,
|
||||
primaryRole: tenantAccess.role,
|
||||
roles: [tenantAccess.role],
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (appEnv.portal === 'platform-admin') {
|
||||
const permissionPayload = await loadPlatformPermissions();
|
||||
const item = permissionPayload.item;
|
||||
platformAccess = {
|
||||
permissions: objectRecord(item?.permissions),
|
||||
effectivePermissions: objectRecord(item?.effective),
|
||||
};
|
||||
if (item?.userId) {
|
||||
userPayload = {
|
||||
user: {
|
||||
id: item.userId,
|
||||
primaryRole: item.primaryRole || 'platform_admin',
|
||||
roles: ['platform_admin'],
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
userPayload = await loadCurrentUser();
|
||||
}
|
||||
|
||||
if (requestId !== requestIdRef.current) return false;
|
||||
let currentUser = currentUserFrom(userPayload);
|
||||
if (currentUser && tenantAccess?.role) {
|
||||
currentUser = {
|
||||
...currentUser,
|
||||
roles: Array.from(new Set([...(currentUser.roles || []), tenantAccess.role])),
|
||||
};
|
||||
}
|
||||
if (currentUser?.id && tenant?.tenantId) activateStorageUser(tenant.tenantId, currentUser.id);
|
||||
authenticatedAtRef.current = Date.now();
|
||||
setState({
|
||||
runtimeConfig: { ...appEnv },
|
||||
tenant,
|
||||
currentUser,
|
||||
session: appEnv.portal === 'platform-admin' ? null : currentSessionFrom(userPayload),
|
||||
tenantAccess,
|
||||
platformAccess,
|
||||
bootstrapStatus: 'ready',
|
||||
bootstrapError: '',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestId !== requestIdRef.current) return false;
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
runtimeConfig: { ...appEnv },
|
||||
tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(),
|
||||
currentUser: null,
|
||||
session: appEnv.portal === 'platform-admin' ? null : getSession(),
|
||||
tenantAccess: null,
|
||||
platformAccess: null,
|
||||
bootstrapStatus: bootstrapFailureStatus(error),
|
||||
bootstrapError: bootstrapFailureMessage(error),
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const current = stateRef.current;
|
||||
const canReuseAuthenticatedState = shouldGuardPath(path)
|
||||
&& current.bootstrapStatus === 'ready'
|
||||
&& current.tenant
|
||||
&& current.currentUser
|
||||
&& Date.now() - authenticatedAtRef.current < 60_000;
|
||||
if (!canReuseAuthenticatedState) void refresh({ path });
|
||||
}, [path, refresh]);
|
||||
|
||||
useEffect(() => subscribeAuthChanges(() => {
|
||||
void refresh({ path: pathRef.current, authenticatePublic: true });
|
||||
}), [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unsubscribe: () => void = () => undefined;
|
||||
import('@/services/supabase')
|
||||
.then(({ subscribeSupabaseAuthChanges }) => subscribeSupabaseAuthChanges((event) => {
|
||||
if (event === 'SIGNED_IN') clearSession({ emit: false });
|
||||
emitSessionChange('supabase');
|
||||
}))
|
||||
.then(nextUnsubscribe => {
|
||||
if (disposed) nextUnsubscribe();
|
||||
else unsubscribe = nextUnsubscribe;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [state.runtimeConfig.supabasePublishableKey, state.runtimeConfig.supabaseUrl]);
|
||||
|
||||
const refreshTenant = useCallback(() => refresh({ path: pathRef.current, forceTenant: true, silent: true }), [refresh]);
|
||||
|
||||
const switchTenant = useCallback(async (tenantCode: string) => {
|
||||
if (appEnv.portal === 'platform-admin') throw new Error('平台后台不使用业务租户启动上下文');
|
||||
if (runtimeHost() && !isWeappRuntime()) throw new Error('H5 租户由当前域名确定,不能使用 tenantCode 覆盖');
|
||||
const normalizedCode = tenantCode.trim();
|
||||
if (!normalizedCode) throw new Error('tenantCode 不能为空');
|
||||
clearTenantContext();
|
||||
appEnv.tenantCode = normalizedCode;
|
||||
return refresh({ path: pathRef.current, forceTenant: true });
|
||||
}, [refresh]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
requestIdRef.current += 1;
|
||||
try {
|
||||
await logoutRequest();
|
||||
} finally {
|
||||
requestIdRef.current += 1;
|
||||
authenticatedAtRef.current = 0;
|
||||
if (stateRef.current.tenant?.tenantId) clearActiveStorageUserData(stateRef.current.tenant.tenantId);
|
||||
setState(previous => ({
|
||||
...previous,
|
||||
currentUser: null,
|
||||
session: null,
|
||||
tenantAccess: null,
|
||||
platformAccess: null,
|
||||
bootstrapStatus: 'unauthenticated',
|
||||
bootstrapError: '',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppContextValue>(() => ({
|
||||
...state,
|
||||
currentPath: path,
|
||||
refresh,
|
||||
refreshTenant,
|
||||
switchTenant,
|
||||
signOut,
|
||||
canTenant: permission => hasTenantPermission(state.tenantAccess, permission),
|
||||
canPlatform: permission => hasPlatformPermission(state.platformAccess, permission),
|
||||
canTenantMenu: input => hasTenantMenuAccess(state.tenantAccess, input),
|
||||
navigationItems: visiblePortalNavigation({
|
||||
portal: state.runtimeConfig.portal,
|
||||
tenantAccess: state.tenantAccess,
|
||||
platformAccess: state.platformAccess,
|
||||
}),
|
||||
}), [state, path, refresh, refreshTenant, switchTenant, signOut]);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
const value = useContext(AppContext);
|
||||
if (!value) throw new Error('useApp must be used inside AppProvider');
|
||||
return value;
|
||||
}
|
||||
81
apps/taro/src/app/permissions.ts
Normal file
81
apps/taro/src/app/permissions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type PermissionMap = Record<string, unknown>;
|
||||
|
||||
export interface TenantAccessSnapshot {
|
||||
role: string;
|
||||
permissions: PermissionMap;
|
||||
templatePermissions: PermissionMap;
|
||||
effectivePermissions: PermissionMap;
|
||||
menuPermissions: PermissionMap;
|
||||
modulePermissions: PermissionMap;
|
||||
fieldPermissions: PermissionMap;
|
||||
dataScope: PermissionMap;
|
||||
roleDefaults: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface PlatformAccessSnapshot {
|
||||
permissions: PermissionMap;
|
||||
effectivePermissions: PermissionMap;
|
||||
}
|
||||
|
||||
export function objectRecord(value: unknown): PermissionMap {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as PermissionMap : {};
|
||||
}
|
||||
|
||||
function permissionCandidates(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const candidates = [permission];
|
||||
for (let index = parts.length - 1; index >= 1; index -= 1) {
|
||||
candidates.push(`${parts.slice(0, index).join(':')}:*`);
|
||||
}
|
||||
candidates.push('*');
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function explicitPermission(permissions: PermissionMap, permission: string) {
|
||||
for (const key of permissionCandidates(permission)) {
|
||||
if (typeof permissions[key] === 'boolean') return permissions[key] as boolean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultPermissionAllowed(defaults: string[], permission: string) {
|
||||
return defaults.some(item => {
|
||||
if (item === '*' || item === permission) return true;
|
||||
return item.endsWith(':*') && permission.startsWith(item.slice(0, -1));
|
||||
});
|
||||
}
|
||||
|
||||
export function hasTenantPermission(access: TenantAccessSnapshot | null, permission?: string) {
|
||||
if (!permission) return true;
|
||||
if (!access) return false;
|
||||
|
||||
const direct = explicitPermission(access.permissions, permission);
|
||||
if (direct !== null) return direct;
|
||||
const template = explicitPermission(access.templatePermissions, permission);
|
||||
if (template !== null) return template;
|
||||
const effective = explicitPermission(access.effectivePermissions, permission);
|
||||
if (effective !== null) return effective;
|
||||
return defaultPermissionAllowed(access.roleDefaults[access.role] || [], permission);
|
||||
}
|
||||
|
||||
export function hasTenantMenuAccess(
|
||||
access: TenantAccessSnapshot | null,
|
||||
input: { menuKey?: string; moduleKey?: string; permission?: string },
|
||||
) {
|
||||
if (!access) return false;
|
||||
if (input.menuKey && typeof access.menuPermissions[input.menuKey] === 'boolean') {
|
||||
return access.menuPermissions[input.menuKey] as boolean;
|
||||
}
|
||||
if (input.moduleKey && typeof access.modulePermissions[input.moduleKey] === 'boolean') {
|
||||
return access.modulePermissions[input.moduleKey] as boolean;
|
||||
}
|
||||
return hasTenantPermission(access, input.permission);
|
||||
}
|
||||
|
||||
export function hasPlatformPermission(access: PlatformAccessSnapshot | null, permission?: string) {
|
||||
if (!permission) return true;
|
||||
if (!access) return false;
|
||||
const effective = explicitPermission(access.effectivePermissions, permission);
|
||||
if (effective !== null) return effective;
|
||||
return explicitPermission(access.permissions, permission) === true;
|
||||
}
|
||||
62
apps/taro/src/app/portal-navigation.ts
Normal file
62
apps/taro/src/app/portal-navigation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Portal } from '@/env';
|
||||
import type { PlatformAccessSnapshot, TenantAccessSnapshot } from './permissions';
|
||||
import { hasPlatformPermission, hasTenantMenuAccess } from './permissions';
|
||||
|
||||
export interface PortalNavigationItem {
|
||||
name: string;
|
||||
path: string;
|
||||
mark: string;
|
||||
group?: string;
|
||||
menuKey?: string;
|
||||
moduleKey?: string;
|
||||
permission?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export const portalNavigation: Record<Portal, PortalNavigationItem[]> = {
|
||||
student: [
|
||||
{ name: '学习工作台', path: '/pages/student/home/index', mark: '台', mobile: true },
|
||||
{ name: '背单词', path: '/pages/student/vocabulary/index', mark: '词', mobile: true },
|
||||
{ name: '知识手册', path: '/pages/student/handbook/index', mark: '册', mobile: true },
|
||||
{ name: '购买', path: '/pages/student/checkout/index', mark: '购', mobile: true },
|
||||
{ name: '分数线', path: '/pages/student/scoreline/index', mark: '线', mobile: true },
|
||||
{ name: '个人中心', path: '/pages/student/profile/index', mark: '我' },
|
||||
],
|
||||
'tenant-admin': [
|
||||
{ group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' },
|
||||
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数', menuKey: 'dashboard', permission: 'dashboard:read' },
|
||||
{ group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生', menuKey: 'students', permission: 'students:read' },
|
||||
{ name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题', menuKey: 'content', permission: 'content:*' },
|
||||
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销', menuKey: 'marketing', permission: 'marketing:read' },
|
||||
{ name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财', menuKey: 'commerce', permission: 'tenant:reconciliation:read' },
|
||||
{ group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设', menuKey: 'settings', permission: 'tenant:overview:read' },
|
||||
],
|
||||
'platform-admin': [
|
||||
{ group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台', permission: 'platform:overview:read' },
|
||||
{ group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租', permission: 'platform:tenant:read' },
|
||||
{ name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账', permission: 'platform:billing:read' },
|
||||
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库', permission: 'platform:question_bank:read' },
|
||||
{ group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员', permission: 'platform:staff:read' },
|
||||
],
|
||||
};
|
||||
|
||||
export function visiblePortalNavigation(input: {
|
||||
portal: Portal;
|
||||
tenantAccess: TenantAccessSnapshot | null;
|
||||
platformAccess: PlatformAccessSnapshot | null;
|
||||
}) {
|
||||
let currentGroup = '';
|
||||
return portalNavigation[input.portal].flatMap(item => {
|
||||
if (item.group) currentGroup = item.group;
|
||||
const allowed = (() => {
|
||||
if (input.portal === 'tenant-admin') {
|
||||
return hasTenantMenuAccess(input.tenantAccess, item);
|
||||
}
|
||||
if (input.portal === 'platform-admin') {
|
||||
return hasPlatformPermission(input.platformAccess, item.permission);
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
return allowed ? [{ ...item, ...(currentGroup ? { group: currentGroup } : {}) }] : [];
|
||||
});
|
||||
}
|
||||
40
apps/taro/src/app/route-path.ts
Normal file
40
apps/taro/src/app/route-path.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export function normalizePagePath(rawPath: string) {
|
||||
const path = String(rawPath || '')
|
||||
.replace(/^#!?/, '')
|
||||
.split('?')[0]
|
||||
.split('#')[0]
|
||||
.replace(/^\/+/, '');
|
||||
const pageIndex = path.indexOf('pages/');
|
||||
const normalized = pageIndex < 0
|
||||
? path ? `/${path}` : ''
|
||||
: `/${path.slice(pageIndex)}`;
|
||||
|
||||
if (!normalized.startsWith('/pages/')) return normalized;
|
||||
const trimmed = normalized.replace(/\/+$/, '');
|
||||
return trimmed.endsWith('/index') ? trimmed : `${trimmed}/index`;
|
||||
}
|
||||
|
||||
export function safePageRedirectPath(
|
||||
rawPath: string,
|
||||
portal: 'student' | 'tenant-admin' | 'platform-admin',
|
||||
fallbackPath: string,
|
||||
) {
|
||||
const path = String(rawPath || '').split('#')[0];
|
||||
const queryIndex = path.indexOf('?');
|
||||
const normalizedPath = normalizePagePath(queryIndex >= 0 ? path.slice(0, queryIndex) : path);
|
||||
const query = queryIndex >= 0 ? path.slice(queryIndex) : '';
|
||||
|
||||
if (
|
||||
!normalizedPath.startsWith('/pages/')
|
||||
|| normalizedPath === '/pages/student/login/index'
|
||||
|| normalizedPath.startsWith('/pages/student/login/')
|
||||
|| normalizedPath === '/pages/bootstrap/index'
|
||||
|| normalizedPath.startsWith('/pages/bootstrap/')
|
||||
) {
|
||||
return fallbackPath;
|
||||
}
|
||||
if (portal === 'tenant-admin' && !normalizedPath.startsWith('/pages/tenant-admin/')) return fallbackPath;
|
||||
if (portal === 'platform-admin' && !normalizedPath.startsWith('/pages/platform-admin/')) return fallbackPath;
|
||||
if (portal === 'student' && !normalizedPath.startsWith('/pages/student/')) return fallbackPath;
|
||||
return `${normalizedPath}${query}`;
|
||||
}
|
||||
50
apps/taro/src/app/session-events.ts
Normal file
50
apps/taro/src/app/session-events.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type SessionChangeReason = 'saved' | 'cleared' | 'expired' | 'tenant-changed' | 'supabase';
|
||||
export type SessionChangeListener = (reason: SessionChangeReason) => void;
|
||||
|
||||
const listeners = new Set<SessionChangeListener>();
|
||||
let authorizationInvalidated = false;
|
||||
let h5BridgeInstalled = false;
|
||||
const h5SessionEventKey = 'tiku:v2:session-event';
|
||||
|
||||
function notifyListeners(reason: SessionChangeReason) {
|
||||
listeners.forEach(listener => listener(reason));
|
||||
}
|
||||
|
||||
function installH5SessionBridge() {
|
||||
if (h5BridgeInstalled || typeof window === 'undefined') return;
|
||||
h5BridgeInstalled = true;
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key !== h5SessionEventKey || !event.newValue) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue) as { reason?: SessionChangeReason };
|
||||
if (payload.reason) notifyListeners(payload.reason);
|
||||
} catch {
|
||||
// Ignore malformed events from unrelated scripts.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function emitSessionChange(reason: SessionChangeReason) {
|
||||
if (reason === 'cleared' || reason === 'expired') {
|
||||
if (authorizationInvalidated) return;
|
||||
authorizationInvalidated = true;
|
||||
} else {
|
||||
authorizationInvalidated = false;
|
||||
}
|
||||
notifyListeners(reason);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(h5SessionEventKey, JSON.stringify({ reason, nonce: `${Date.now()}:${Math.random()}` }));
|
||||
} catch {
|
||||
// Cross-tab synchronization is best effort when storage is unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeSessionChanges(listener: SessionChangeListener) {
|
||||
installH5SessionBridge();
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
40
apps/taro/src/app/storage-scope.ts
Normal file
40
apps/taro/src/app/storage-scope.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Portal } from '@/env';
|
||||
|
||||
export interface StorageScopeInput {
|
||||
portal: Portal;
|
||||
host?: string;
|
||||
tenantCode?: string;
|
||||
}
|
||||
|
||||
function normalizeSegment(value: string) {
|
||||
return encodeURIComponent(value.trim().toLowerCase() || 'default');
|
||||
}
|
||||
|
||||
export function createStorageScope(input: StorageScopeInput) {
|
||||
const tenantLocator = input.host?.trim() || (input.tenantCode?.trim() ? `tenant:${input.tenantCode}` : 'default');
|
||||
return `${normalizeSegment(input.portal)}:${normalizeSegment(tenantLocator)}`;
|
||||
}
|
||||
|
||||
export function tenantContextStorageKey(input: StorageScopeInput) {
|
||||
return `tiku:v2:${createStorageScope(input)}:tenant`;
|
||||
}
|
||||
|
||||
export function sessionStorageKey(input: StorageScopeInput, tenantId: string) {
|
||||
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId)}:session`;
|
||||
}
|
||||
|
||||
export function activeUserStorageKey(input: StorageScopeInput, tenantId: string) {
|
||||
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:active-user`;
|
||||
}
|
||||
|
||||
export function userDataStoragePrefix(input: StorageScopeInput, tenantId: string, userId: string) {
|
||||
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:user:${normalizeSegment(userId || 'anonymous')}:data:`;
|
||||
}
|
||||
|
||||
export function legacyTenantDataStoragePrefix(input: StorageScopeInput, tenantId: string) {
|
||||
return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:data:`;
|
||||
}
|
||||
|
||||
export function tenantDataStorageKey(input: StorageScopeInput, tenantId: string, userId: string, key: string) {
|
||||
return `${userDataStoragePrefix(input, tenantId, userId)}${encodeURIComponent(key)}`;
|
||||
}
|
||||
40
apps/taro/src/app/tenant-launch.ts
Normal file
40
apps/taro/src/app/tenant-launch.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface TenantLaunchInput {
|
||||
query?: Record<string, unknown>;
|
||||
referrerExtraData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizedTenantCode(value: unknown) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const normalized = value.trim();
|
||||
return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : '';
|
||||
}
|
||||
|
||||
function tenantCodeFromScene(value: unknown) {
|
||||
if (typeof value !== 'string' || !value.trim()) return '';
|
||||
let decoded = value.trim();
|
||||
try {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (!decoded.includes('=')) return normalizedTenantCode(decoded);
|
||||
for (const segment of decoded.split('&')) {
|
||||
const separator = segment.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const key = segment.slice(0, separator).trim();
|
||||
if (key !== 'tenantCode' && key !== 'tenant') continue;
|
||||
return normalizedTenantCode(segment.slice(separator + 1));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function tenantCodeFromLaunch(input: TenantLaunchInput) {
|
||||
const query = input.query || {};
|
||||
const extraData = input.referrerExtraData || {};
|
||||
return normalizedTenantCode(query.tenantCode)
|
||||
|| normalizedTenantCode(query.tenant)
|
||||
|| tenantCodeFromScene(query.scene)
|
||||
|| normalizedTenantCode(extraData.tenantCode)
|
||||
|| normalizedTenantCode(extraData.tenant)
|
||||
|| '';
|
||||
}
|
||||
32
apps/taro/src/app/tenant-resolution.ts
Normal file
32
apps/taro/src/app/tenant-resolution.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface TenantResolveQueryInput {
|
||||
host?: string;
|
||||
tenantCode?: string;
|
||||
}
|
||||
|
||||
function normalizedHostname(value: string) {
|
||||
const raw = value.trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(`http://${raw}`).hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalRuntimeHost(value: string) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return hostname === 'localhost'
|
||||
|| hostname.endsWith('.localhost')
|
||||
|| hostname === '::1'
|
||||
|| hostname === '0.0.0.0'
|
||||
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
||||
}
|
||||
|
||||
export function tenantResolveQuery(input: TenantResolveQueryInput) {
|
||||
const host = input.host?.trim() || '';
|
||||
const tenantCode = input.tenantCode?.trim() || '';
|
||||
return {
|
||||
host: host || undefined,
|
||||
tenantCode: !host || isLocalRuntimeHost(host) ? (tenantCode || undefined) : undefined,
|
||||
};
|
||||
}
|
||||
165
apps/taro/src/capabilities/file.ts
Normal file
165
apps/taro/src/capabilities/file.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { isH5Runtime } from '@/env';
|
||||
import { copyText } from './share';
|
||||
import { openExternalUrl } from './navigation';
|
||||
|
||||
export interface PickLocalFileOptions {
|
||||
accept: string;
|
||||
extensions?: string[];
|
||||
readAs: 'text' | 'base64';
|
||||
}
|
||||
|
||||
export interface PickedLocalFile {
|
||||
fileName: string;
|
||||
text?: string;
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
function readBrowserFile(file: File, readAs: PickLocalFileOptions['readAs']) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
resolve(readAs === 'base64' && result.includes(',') ? result.slice(result.indexOf(',') + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
if (readAs === 'base64') reader.readAsDataURL(file);
|
||||
else reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
async function pickBrowserFile(options: PickLocalFileOptions): Promise<PickedLocalFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = options.accept;
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
reject(new Error('未选择文件'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await readBrowserFile(file, options.readAs);
|
||||
resolve({
|
||||
fileName: file.name,
|
||||
...(options.readAs === 'base64' ? { base64: content } : { text: content }),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function readMiniProgramFile(filePath: string, encoding: 'utf8' | 'base64') {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
Taro.getFileSystemManager().readFile({
|
||||
filePath,
|
||||
encoding,
|
||||
success: result => resolve(String(result.data || '')),
|
||||
fail: result => reject(new Error(result.errMsg || '文件读取失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickLocalFile(options: PickLocalFileOptions): Promise<PickedLocalFile> {
|
||||
if (isH5Runtime() && typeof document !== 'undefined') return pickBrowserFile(options);
|
||||
if (typeof Taro.chooseMessageFile !== 'function') throw new Error('当前端暂不支持文件选择');
|
||||
|
||||
const result = await Taro.chooseMessageFile({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: options.extensions,
|
||||
});
|
||||
const file = result.tempFiles[0];
|
||||
if (!file?.path) throw new Error('未选择文件');
|
||||
const content = await readMiniProgramFile(file.path, options.readAs === 'base64' ? 'base64' : 'utf8');
|
||||
return {
|
||||
fileName: file.name || 'import-file',
|
||||
...(options.readAs === 'base64' ? { base64: content } : { text: content }),
|
||||
};
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName.replace(/[\\/:*?"<>|]/g, '-').slice(0, 120) || 'download';
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(href: string, fileName: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = fileName;
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function writeMiniProgramFile(input: { fileName: string; data: string; encoding: 'utf8' | 'base64' }) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const filePath = `${Taro.env.USER_DATA_PATH}/${safeFileName(input.fileName)}`;
|
||||
Taro.getFileSystemManager().writeFile({
|
||||
filePath,
|
||||
data: input.data,
|
||||
encoding: input.encoding,
|
||||
success: () => resolve(filePath),
|
||||
fail: result => reject(new Error(result.errMsg || '文件保存失败')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openMiniProgramFile(filePath: string) {
|
||||
try {
|
||||
await Taro.openDocument({ filePath, showMenu: true });
|
||||
return 'opened' as const;
|
||||
} catch {
|
||||
return 'saved' as const;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadBase64File(fileName: string, contentBase64: string, mimeType = 'application/octet-stream') {
|
||||
if (!contentBase64) throw new Error('文件内容为空');
|
||||
if (isH5Runtime() && typeof document !== 'undefined') {
|
||||
triggerBrowserDownload(`data:${mimeType};base64,${contentBase64}`, safeFileName(fileName));
|
||||
return 'downloaded' as const;
|
||||
}
|
||||
const filePath = await writeMiniProgramFile({ fileName, data: contentBase64, encoding: 'base64' });
|
||||
return openMiniProgramFile(filePath);
|
||||
}
|
||||
|
||||
export async function downloadTextFile(fileName: string, content: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (isH5Runtime() && typeof document !== 'undefined') {
|
||||
const blobUrl = URL.createObjectURL(new Blob([content], { type: mimeType }));
|
||||
try {
|
||||
triggerBrowserDownload(blobUrl, safeFileName(fileName));
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
return 'downloaded' as const;
|
||||
}
|
||||
if (typeof Taro.getFileSystemManager === 'function' && Taro.env?.USER_DATA_PATH) {
|
||||
const filePath = await writeMiniProgramFile({ fileName, data: content, encoding: 'utf8' });
|
||||
return openMiniProgramFile(filePath);
|
||||
}
|
||||
await copyText(content);
|
||||
return 'copied' as const;
|
||||
}
|
||||
|
||||
export async function openRemoteFile(url: string) {
|
||||
if (!url) throw new Error('文件链接为空');
|
||||
if (isH5Runtime()) {
|
||||
openExternalUrl(url);
|
||||
return 'opened' as const;
|
||||
}
|
||||
if (typeof Taro.downloadFile === 'function') {
|
||||
try {
|
||||
const result = await Taro.downloadFile({ url });
|
||||
if (result.statusCode >= 200 && result.statusCode < 300) return openMiniProgramFile(result.tempFilePath);
|
||||
} catch {
|
||||
// Fall back to copying signed URLs when the mini program domain is not whitelisted yet.
|
||||
}
|
||||
}
|
||||
await copyText(url);
|
||||
return 'copied' as const;
|
||||
}
|
||||
19
apps/taro/src/capabilities/media.ts
Normal file
19
apps/taro/src/capabilities/media.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export async function chooseImages(count = 1) {
|
||||
const result = await Taro.chooseImage({ count, sizeType: ['compressed'], sourceType: ['album', 'camera'] });
|
||||
return result.tempFilePaths;
|
||||
}
|
||||
|
||||
export function previewImage(current: string, urls: string[] = [current]) {
|
||||
return Taro.previewImage({ current, urls });
|
||||
}
|
||||
|
||||
export async function chooseVideo() {
|
||||
const result = await Taro.chooseVideo({ sourceType: ['album', 'camera'], compressed: true });
|
||||
return {
|
||||
tempFilePath: result.tempFilePath,
|
||||
duration: result.duration,
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
61
apps/taro/src/capabilities/navigation.ts
Normal file
61
apps/taro/src/capabilities/navigation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { appEnv, isH5Runtime, isWeappRuntime, taroWeappTenantMode } from '@/env';
|
||||
import { tenantCodeFromLaunch } from '@/app/tenant-launch';
|
||||
|
||||
let launchTenantApplied = false;
|
||||
|
||||
export function applyWeappLaunchTenant() {
|
||||
if (launchTenantApplied || !isWeappRuntime() || taroWeappTenantMode() !== 'launch') return appEnv.tenantCode;
|
||||
launchTenantApplied = true;
|
||||
appEnv.tenantCode = '';
|
||||
if (typeof Taro.getLaunchOptionsSync !== 'function') return appEnv.tenantCode;
|
||||
const options = Taro.getLaunchOptionsSync();
|
||||
const tenantCode = tenantCodeFromLaunch({
|
||||
query: options.query as Record<string, unknown>,
|
||||
referrerExtraData: options.referrerInfo?.extraData as Record<string, unknown> | undefined,
|
||||
});
|
||||
if (tenantCode) appEnv.tenantCode = tenantCode;
|
||||
return appEnv.tenantCode;
|
||||
}
|
||||
|
||||
export function runtimeHost() {
|
||||
return isH5Runtime() && typeof window !== 'undefined' ? window.location.host : '';
|
||||
}
|
||||
|
||||
export function currentLocationUrl() {
|
||||
return isH5Runtime() && typeof window !== 'undefined' ? window.location.href : '';
|
||||
}
|
||||
|
||||
export function navigateTo(url: string) {
|
||||
return Taro.navigateTo({ url });
|
||||
}
|
||||
|
||||
export function redirectTo(url: string) {
|
||||
return Taro.redirectTo({ url });
|
||||
}
|
||||
|
||||
export function reLaunch(url: string) {
|
||||
return Taro.reLaunch({ url });
|
||||
}
|
||||
|
||||
export function navigateBack(delta = 1) {
|
||||
return Taro.navigateBack({ delta });
|
||||
}
|
||||
|
||||
export function replaceLocation(url: string) {
|
||||
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||
window.location.replace(url);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return redirectTo(url).then(() => undefined);
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string, target: 'same-window' | 'new-window' = 'new-window') {
|
||||
if (!/^https?:\/\//i.test(url)) throw new Error('只允许打开 http(s) 链接');
|
||||
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||
if (target === 'same-window') window.location.assign(url);
|
||||
else window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
29
apps/taro/src/capabilities/payment.ts
Normal file
29
apps/taro/src/capabilities/payment.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { isH5Runtime, isWeappRuntime } from '@/env';
|
||||
import { currentLocationUrl, openExternalUrl } from './navigation';
|
||||
import { copyText } from './share';
|
||||
|
||||
export function paymentReturnUrl() {
|
||||
return currentLocationUrl() || undefined;
|
||||
}
|
||||
|
||||
export async function launchPayment(input: {
|
||||
provider?: string | null;
|
||||
paymentParams?: Record<string, unknown> | null;
|
||||
paymentUrl?: string;
|
||||
}) {
|
||||
if (input.provider === 'wechat_pay' && isWeappRuntime()) {
|
||||
await Taro.requestPayment((input.paymentParams || {}) as unknown as Taro.requestPayment.Option);
|
||||
return 'completed' as const;
|
||||
}
|
||||
if (input.paymentUrl && isH5Runtime()) {
|
||||
openExternalUrl(input.paymentUrl, 'same-window');
|
||||
return 'redirected' as const;
|
||||
}
|
||||
if (input.paymentUrl) {
|
||||
await copyText(input.paymentUrl);
|
||||
return 'copied-url' as const;
|
||||
}
|
||||
await copyText(JSON.stringify(input.paymentParams || {}));
|
||||
return 'copied-params' as const;
|
||||
}
|
||||
21
apps/taro/src/capabilities/share.ts
Normal file
21
apps/taro/src/capabilities/share.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { isH5Runtime } from '@/env';
|
||||
|
||||
export async function copyText(content: string) {
|
||||
await Taro.setClipboardData({ data: content });
|
||||
}
|
||||
|
||||
export async function shareText(input: { title: string; text?: string; url?: string }) {
|
||||
if (isH5Runtime() && typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
await navigator.share({ title: input.title, text: input.text, url: input.url });
|
||||
return 'shared' as const;
|
||||
}
|
||||
await copyText([input.title, input.text, input.url].filter(Boolean).join('\n'));
|
||||
return 'copied' as const;
|
||||
}
|
||||
|
||||
export async function enableNativeShareMenu() {
|
||||
if (typeof Taro.showShareMenu !== 'function') return false;
|
||||
await Taro.showShareMenu({ withShareTicket: true });
|
||||
return true;
|
||||
}
|
||||
104
apps/taro/src/capabilities/storage.ts
Normal file
104
apps/taro/src/capabilities/storage.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { appEnv, isH5Runtime } from '@/env';
|
||||
import {
|
||||
activeUserStorageKey,
|
||||
legacyTenantDataStoragePrefix,
|
||||
sessionStorageKey,
|
||||
tenantContextStorageKey,
|
||||
tenantDataStorageKey,
|
||||
type StorageScopeInput,
|
||||
userDataStoragePrefix,
|
||||
} from '@/app/storage-scope';
|
||||
|
||||
export function runtimeStorageScope(): StorageScopeInput {
|
||||
const host = isH5Runtime() && typeof window !== 'undefined'
|
||||
? window.location.host
|
||||
: '';
|
||||
return {
|
||||
portal: appEnv.portal,
|
||||
host,
|
||||
tenantCode: appEnv.tenantCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJsonStorage<T>(key: string): T | null {
|
||||
try {
|
||||
const value = Taro.getStorageSync<string>(key);
|
||||
if (!value) return null;
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setJsonStorage<T>(key: string, value: T) {
|
||||
Taro.setStorageSync(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function removeJsonStorage(key: string) {
|
||||
Taro.removeStorageSync(key);
|
||||
}
|
||||
|
||||
export function removeStorageByPrefix(prefix: string) {
|
||||
if (!prefix) return;
|
||||
try {
|
||||
const keys = Taro.getStorageInfoSync().keys || [];
|
||||
keys.filter(key => key.startsWith(prefix)).forEach(key => Taro.removeStorageSync(key));
|
||||
} catch {
|
||||
// Storage cleanup is best effort on constrained runtimes.
|
||||
}
|
||||
}
|
||||
|
||||
export function currentTenantContextStorageKey() {
|
||||
return tenantContextStorageKey(runtimeStorageScope());
|
||||
}
|
||||
|
||||
export function currentSessionStorageKey(tenantId: string) {
|
||||
return sessionStorageKey(runtimeStorageScope(), tenantId);
|
||||
}
|
||||
|
||||
export function getActiveStorageUserId(tenantId: string) {
|
||||
return getJsonStorage<string>(activeUserStorageKey(runtimeStorageScope(), tenantId)) || '';
|
||||
}
|
||||
|
||||
export function setActiveStorageUserId(tenantId: string, userId: string) {
|
||||
if (!tenantId || !userId) return;
|
||||
setJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId), userId);
|
||||
}
|
||||
|
||||
export function clearActiveStorageUserId(tenantId: string) {
|
||||
if (!tenantId) return;
|
||||
removeJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId));
|
||||
}
|
||||
|
||||
export function clearStorageUserData(tenantId: string, userId: string) {
|
||||
if (!tenantId || !userId) return;
|
||||
removeStorageByPrefix(userDataStoragePrefix(runtimeStorageScope(), tenantId, userId));
|
||||
}
|
||||
|
||||
export function clearActiveStorageUserData(tenantId: string) {
|
||||
if (!tenantId) return;
|
||||
const userId = getActiveStorageUserId(tenantId);
|
||||
if (userId) clearStorageUserData(tenantId, userId);
|
||||
clearActiveStorageUserId(tenantId);
|
||||
}
|
||||
|
||||
export function activateStorageUser(tenantId: string, userId: string) {
|
||||
if (!tenantId || !userId) return;
|
||||
const previousUserId = getActiveStorageUserId(tenantId);
|
||||
if (previousUserId && previousUserId !== userId) clearStorageUserData(tenantId, previousUserId);
|
||||
removeStorageByPrefix(legacyTenantDataStoragePrefix(runtimeStorageScope(), tenantId));
|
||||
removeStorageByPrefix('tiku:practice:');
|
||||
removeStorageByPrefix('tiku:vocabulary:');
|
||||
setActiveStorageUserId(tenantId, userId);
|
||||
}
|
||||
|
||||
export function currentTenantDataStorageKey(key: string) {
|
||||
const tenant = getJsonStorage<{ tenantId?: string }>(currentTenantContextStorageKey());
|
||||
const tenantId = tenant?.tenantId || 'unresolved';
|
||||
return tenantDataStorageKey(runtimeStorageScope(), tenantId, getActiveStorageUserId(tenantId) || 'anonymous', key);
|
||||
}
|
||||
|
||||
export function scopedTenantDataStorageKey(tenantId: string, userId: string, key: string) {
|
||||
return tenantDataStorageKey(runtimeStorageScope(), tenantId || 'unresolved', userId || 'anonymous', key);
|
||||
}
|
||||
@@ -67,6 +67,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backoffice-brand-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.backoffice-legacy-main .admin-page,
|
||||
.backoffice-legacy-main .platform-page {
|
||||
min-height: auto;
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { appEnv } from '@/env';
|
||||
import { currentPagePath } from '@/services/routeGuard';
|
||||
import { Image, Text, View } from '@tarojs/components';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { redirectTo } from '@/capabilities/navigation';
|
||||
import { useTheme } from '@/theme/ThemeProvider';
|
||||
import './AdminLegacyShell.css';
|
||||
|
||||
type AdminNavItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
mark: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const tenantNavItems: AdminNavItem[] = [
|
||||
{ group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' },
|
||||
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数' },
|
||||
{ group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生' },
|
||||
{ name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题' },
|
||||
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销' },
|
||||
{ name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财' },
|
||||
{ group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设' },
|
||||
];
|
||||
|
||||
const platformNavItems: AdminNavItem[] = [
|
||||
{ group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台' },
|
||||
{ group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租' },
|
||||
{ name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账' },
|
||||
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库' },
|
||||
{ group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员' },
|
||||
];
|
||||
|
||||
function isActivePath(currentPath: string, itemPath: string) {
|
||||
return currentPath === itemPath;
|
||||
}
|
||||
|
||||
function navItemsForPortal() {
|
||||
return appEnv.portal === 'platform-admin' ? platformNavItems : tenantNavItems;
|
||||
}
|
||||
|
||||
function titleForPortal() {
|
||||
if (appEnv.portal === 'platform-admin') {
|
||||
function titleForPortal(portal: 'tenant-admin' | 'platform-admin', brandName?: string) {
|
||||
if (portal === 'platform-admin') {
|
||||
return {
|
||||
title: '平台管理后台',
|
||||
subtitle: '租户、账务、题库授权、员工权限',
|
||||
@@ -48,7 +19,7 @@ function titleForPortal() {
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: '租户运营后台',
|
||||
title: brandName || '租户运营后台',
|
||||
subtitle: '学生、题库、营销、财务与设置',
|
||||
badge: 'Admin',
|
||||
mark: '题',
|
||||
@@ -56,17 +27,22 @@ function titleForPortal() {
|
||||
}
|
||||
|
||||
export function AdminLegacyShell({ children }: PropsWithChildren) {
|
||||
const currentPath = currentPagePath();
|
||||
const navItems = navItemsForPortal();
|
||||
const title = titleForPortal();
|
||||
const { currentPath, currentUser, navigationItems, runtimeConfig, tenant } = useApp();
|
||||
const { assets } = useTheme();
|
||||
const portal = runtimeConfig.portal === 'platform-admin' ? 'platform-admin' : 'tenant-admin';
|
||||
const navItems = navigationItems;
|
||||
const title = titleForPortal(portal, tenant?.branding.brandName || tenant?.branding.shortName);
|
||||
const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '管理账号';
|
||||
let activeGroup = '';
|
||||
|
||||
return (
|
||||
<View className={`backoffice-legacy-layout ${appEnv.portal === 'platform-admin' ? 'platform-mode' : 'tenant-mode'}`}>
|
||||
<View className={`backoffice-legacy-layout ${portal === 'platform-admin' ? 'platform-mode' : 'tenant-mode'}`}>
|
||||
<View className='backoffice-legacy-sidebar'>
|
||||
<View className='backoffice-brand'>
|
||||
<View className='backoffice-brand-icon'>
|
||||
<Text className='backoffice-brand-mark'>{title.mark}</Text>
|
||||
{assets.logoUrl
|
||||
? <Image className='backoffice-brand-image' src={assets.logoUrl} mode='aspectFit' />
|
||||
: <Text className='backoffice-brand-mark'>{title.mark}</Text>}
|
||||
</View>
|
||||
<View className='backoffice-brand-copy'>
|
||||
<Text className='backoffice-brand-title'>{title.title}</Text>
|
||||
@@ -88,7 +64,7 @@ export function AdminLegacyShell({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<View className='backoffice-nav-block' key={item.path}>
|
||||
{nextGroup ? <Text className='backoffice-nav-group'>{nextGroup}</Text> : null}
|
||||
<View className={`backoffice-nav-btn ${isActivePath(currentPath, item.path) ? 'active' : ''}`} onClick={() => Taro.redirectTo({ url: item.path })}>
|
||||
<View className={`backoffice-nav-btn ${isActivePath(currentPath, item.path) ? 'active' : ''}`} onClick={() => void redirectTo(item.path)}>
|
||||
<Text className='backoffice-nav-mark'>{item.mark}</Text>
|
||||
<Text className='backoffice-nav-text'>{item.name}</Text>
|
||||
</View>
|
||||
@@ -99,18 +75,18 @@ export function AdminLegacyShell({ children }: PropsWithChildren) {
|
||||
|
||||
<View className='backoffice-footer'>
|
||||
<View className='backoffice-footer-avatar'>
|
||||
<Text className='backoffice-footer-mark'>管</Text>
|
||||
<Text className='backoffice-footer-mark'>{userName.slice(0, 1)}</Text>
|
||||
</View>
|
||||
<View className='backoffice-footer-copy'>
|
||||
<Text className='backoffice-footer-name'>管理账号</Text>
|
||||
<Text className='backoffice-footer-meta'>已启用权限校验</Text>
|
||||
<Text className='backoffice-footer-name'>{userName}</Text>
|
||||
<Text className='backoffice-footer-meta'>{currentUser?.primaryRole || '已启用权限校验'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='backoffice-mobile-nav'>
|
||||
{navItems.map(item => (
|
||||
<View className={`backoffice-mobile-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.redirectTo({ url: item.path })}>
|
||||
<View className={`backoffice-mobile-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void redirectTo(item.path)}>
|
||||
<Text className='backoffice-mobile-mark'>{item.mark}</Text>
|
||||
<Text className='backoffice-mobile-text'>{item.name}</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Image, RichText, Text, View } from '@tarojs/components';
|
||||
import './katex-platform.css';
|
||||
import { signAssetPreview, type AssetWatermarkContext, type SignedAssetLink } from '@/services/catalog';
|
||||
import './rich-content.css';
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legacy-brand-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.student-legacy-main .student-page {
|
||||
padding-bottom: 132px;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { logout } from '@/services/auth';
|
||||
import { currentPagePath } from '@/services/routeGuard';
|
||||
import { Button, Image, Text, View } from '@tarojs/components';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { navigateTo, reLaunch } from '@/capabilities/navigation';
|
||||
import { useTheme } from '@/theme/ThemeProvider';
|
||||
import './StudentLegacyShell.css';
|
||||
|
||||
const navItems = [
|
||||
{ name: '学习工作台', path: '/pages/student/home/index', mark: '台' },
|
||||
{ name: '背单词', path: '/pages/student/vocabulary/index', mark: '词' },
|
||||
{ name: '知识手册', path: '/pages/student/handbook/index', mark: '册' },
|
||||
{ name: '购买', path: '/pages/student/checkout/index', mark: '购' },
|
||||
{ name: '分数线', path: '/pages/student/scoreline/index', mark: '线' },
|
||||
{ name: '个人中心', path: '/pages/student/profile/index', mark: '我' },
|
||||
];
|
||||
|
||||
function isActivePath(currentPath: string, itemPath: string) {
|
||||
if (itemPath === '/pages/student/home/index') return currentPath === itemPath;
|
||||
return currentPath.startsWith(itemPath);
|
||||
}
|
||||
|
||||
export function StudentLegacyShell({ children }: PropsWithChildren) {
|
||||
const tenant = getTenantContext();
|
||||
const currentPath = currentPagePath();
|
||||
const { currentPath, currentUser, navigationItems, signOut, tenant } = useApp();
|
||||
const { assets } = useTheme();
|
||||
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
|
||||
const navItems = navigationItems;
|
||||
const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '学习账号';
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
Taro.reLaunch({ url: '/pages/student/login/index' });
|
||||
await signOut();
|
||||
await reLaunch('/pages/student/login/index');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -35,7 +27,9 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
|
||||
<View className='legacy-student-sidebar'>
|
||||
<View className='legacy-brand-block'>
|
||||
<View className='legacy-brand-icon'>
|
||||
<Text className='legacy-brand-mark'>题</Text>
|
||||
{assets.logoUrl
|
||||
? <Image className='legacy-brand-image' src={assets.logoUrl} mode='aspectFit' />
|
||||
: <Text className='legacy-brand-mark'>题</Text>}
|
||||
</View>
|
||||
<View className='legacy-brand-copy'>
|
||||
<Text className='legacy-brand-name'>{brandName}</Text>
|
||||
@@ -44,7 +38,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
|
||||
</View>
|
||||
<View className='legacy-nav-list'>
|
||||
{navItems.map(item => (
|
||||
<View className={`legacy-nav-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||
<View className={`legacy-nav-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void navigateTo(item.path)}>
|
||||
<Text className='legacy-nav-dot'>{item.mark}</Text>
|
||||
<Text className='legacy-nav-text'>{item.name}</Text>
|
||||
</View>
|
||||
@@ -55,7 +49,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
|
||||
<Text className='legacy-mini-avatar-mark'>同</Text>
|
||||
</View>
|
||||
<View className='legacy-sidebar-user-copy'>
|
||||
<Text className='legacy-sidebar-user-name'>学习账号</Text>
|
||||
<Text className='legacy-sidebar-user-name'>{userName}</Text>
|
||||
<Text className='legacy-sidebar-user-tag'>SVIP</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -63,8 +57,8 @@ export function StudentLegacyShell({ children }: PropsWithChildren) {
|
||||
</View>
|
||||
<View className='student-legacy-main'>{children}</View>
|
||||
<View className='legacy-mobile-dock'>
|
||||
{navItems.slice(0, 5).map(item => (
|
||||
<View className={`legacy-dock-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||
{navItems.filter(item => item.mobile).map(item => (
|
||||
<View className={`legacy-dock-item ${isActivePath(currentPath, item.path) ? 'active' : ''}`} key={item.path} onClick={() => void navigateTo(item.path)}>
|
||||
<Text className='legacy-dock-dot'>{item.mark}</Text>
|
||||
<Text className='legacy-dock-text'>{item.name === '学习工作台' ? '首页' : item.name}</Text>
|
||||
</View>
|
||||
|
||||
1
apps/taro/src/components/katex-platform.css
Normal file
1
apps/taro/src/components/katex-platform.css
Normal file
@@ -0,0 +1 @@
|
||||
/* WeApp uses the lightweight formula styles in rich-content.css without bundling web fonts. */
|
||||
1
apps/taro/src/components/katex-platform.h5.css
Normal file
1
apps/taro/src/components/katex-platform.h5.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Portal = 'student' | 'tenant-admin' | 'platform-admin';
|
||||
export type WeappTenantMode = 'fixed' | 'launch';
|
||||
|
||||
export interface AppEnv {
|
||||
portal: Portal;
|
||||
@@ -21,6 +22,19 @@ export interface RuntimeConfigInput {
|
||||
TARO_APP_TENANT_CODE?: string;
|
||||
}
|
||||
|
||||
interface PublicBuildConfig {
|
||||
portal?: string;
|
||||
target?: string;
|
||||
releaseMode?: string;
|
||||
weappTenantMode?: string;
|
||||
apiBaseUrl?: string;
|
||||
supabaseUrl?: string;
|
||||
supabasePublishableKey?: string;
|
||||
tenantCode?: string;
|
||||
}
|
||||
|
||||
declare const __TARO_PUBLIC_BUILD_CONFIG__: Readonly<PublicBuildConfig>;
|
||||
|
||||
declare const process: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
@@ -68,6 +82,38 @@ function normalizePortal(value: unknown): Portal | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizedHostname(value: string) {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function isLoopbackHostname(value: string) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return hostname === 'localhost'
|
||||
|| hostname.endsWith('.localhost')
|
||||
|| hostname === '::1'
|
||||
|| hostname === '0.0.0.0'
|
||||
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
||||
}
|
||||
|
||||
function isProductionApiBaseUrl(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:'
|
||||
&& Boolean(parsed.hostname)
|
||||
&& !isLoopbackHostname(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const publicBuildConfig = typeof __TARO_PUBLIC_BUILD_CONFIG__ === 'undefined'
|
||||
? null
|
||||
: __TARO_PUBLIC_BUILD_CONFIG__;
|
||||
|
||||
function publicBuildConfigValue(key: keyof PublicBuildConfig, envKey: string) {
|
||||
return normalizeString(publicBuildConfig ? publicBuildConfig[key] : envValue(envKey));
|
||||
}
|
||||
|
||||
function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
|
||||
const leaked = forbiddenFrontendKeys.filter(key => Object.prototype.hasOwnProperty.call(input, key));
|
||||
if (leaked.length) {
|
||||
@@ -81,11 +127,13 @@ function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
|
||||
}
|
||||
|
||||
export const appEnv: AppEnv = {
|
||||
portal: (envValue('TARO_APP_PORTAL') || 'student') as Portal,
|
||||
apiBaseUrl: envValue('TARO_APP_API_BASE_URL') || 'http://127.0.0.1:8787',
|
||||
supabaseUrl: envValue('TARO_APP_SUPABASE_URL') || '',
|
||||
supabasePublishableKey: envValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY') || '',
|
||||
tenantCode: envValue('TARO_APP_TENANT_CODE') || '',
|
||||
portal: normalizePortal(publicBuildConfigValue('portal', 'TARO_APP_PORTAL')) || 'student',
|
||||
apiBaseUrl: publicBuildConfigValue('apiBaseUrl', 'TARO_APP_API_BASE_URL'),
|
||||
supabaseUrl: publicBuildConfigValue('supabaseUrl', 'TARO_APP_SUPABASE_URL'),
|
||||
supabasePublishableKey: publicBuildConfigValue('supabasePublishableKey', 'TARO_APP_SUPABASE_PUBLISHABLE_KEY'),
|
||||
tenantCode: publicBuildConfigValue('target', 'TARO_ENV') === 'weapp' && taroWeappTenantMode() === 'launch'
|
||||
? ''
|
||||
: publicBuildConfigValue('tenantCode', 'TARO_APP_TENANT_CODE'),
|
||||
};
|
||||
|
||||
let runtimeConfigPromise: Promise<AppEnv> | null = null;
|
||||
@@ -105,14 +153,23 @@ export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime
|
||||
const supabasePublishableKey = normalizeString(input.supabasePublishableKey || input.TARO_APP_SUPABASE_PUBLISHABLE_KEY);
|
||||
if (supabasePublishableKey) appEnv.supabasePublishableKey = supabasePublishableKey;
|
||||
|
||||
const tenantCode = normalizeString(input.tenantCode || input.TARO_APP_TENANT_CODE);
|
||||
if (tenantCode) appEnv.tenantCode = tenantCode;
|
||||
if (Object.prototype.hasOwnProperty.call(input, 'tenantCode')) {
|
||||
appEnv.tenantCode = normalizeString(input.tenantCode);
|
||||
} else if (Object.prototype.hasOwnProperty.call(input, 'TARO_APP_TENANT_CODE')) {
|
||||
appEnv.tenantCode = normalizeString(input.TARO_APP_TENANT_CODE);
|
||||
}
|
||||
|
||||
return appEnv;
|
||||
}
|
||||
|
||||
export async function loadRuntimeConfig() {
|
||||
if (!isH5Runtime() || typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
||||
if (!isH5Runtime()) {
|
||||
return appEnv;
|
||||
}
|
||||
|
||||
const strictRuntimeConfig = taroReleaseMode() === 'production';
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
||||
if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json loader is unavailable');
|
||||
return appEnv;
|
||||
}
|
||||
|
||||
@@ -123,13 +180,22 @@ export async function loadRuntimeConfig() {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (strictRuntimeConfig) {
|
||||
throw new Error(`Production H5 runtime-config.json request failed: ${(error as Error).message || 'unknown error'}`);
|
||||
}
|
||||
return appEnv;
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (strictRuntimeConfig) throw new Error(`Production H5 runtime-config.json request failed with status ${response.status}`);
|
||||
return appEnv;
|
||||
}
|
||||
if (!response.ok) return appEnv;
|
||||
|
||||
const text = (await response.text()).trim();
|
||||
if (!text || !text.startsWith('{')) return appEnv;
|
||||
if (!text || !text.startsWith('{')) {
|
||||
if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json is empty or unreadable');
|
||||
return appEnv;
|
||||
}
|
||||
|
||||
let config: RuntimeConfigInput;
|
||||
try {
|
||||
@@ -138,6 +204,17 @@ export async function loadRuntimeConfig() {
|
||||
throw new Error(`Invalid Taro runtime-config.json: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (strictRuntimeConfig) {
|
||||
const runtimePortal = normalizePortal(config.portal || config.TARO_APP_PORTAL);
|
||||
if (runtimePortal !== appEnv.portal) throw new Error(`Production H5 runtime-config.json portal must be ${appEnv.portal}`);
|
||||
const runtimeApiBaseUrl = normalizeString(config.apiBaseUrl || config.TARO_APP_API_BASE_URL);
|
||||
if (!isProductionApiBaseUrl(runtimeApiBaseUrl)) {
|
||||
throw new Error('Production H5 runtime-config.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback');
|
||||
}
|
||||
const runtimeTenantCode = normalizeString(config.tenantCode || config.TARO_APP_TENANT_CODE);
|
||||
if (runtimeTenantCode) throw new Error('Production H5 runtime-config.json tenantCode must be empty; tenant is resolved from the browser origin');
|
||||
}
|
||||
|
||||
return applyRuntimeConfig(config, 'runtime-config.json');
|
||||
}
|
||||
|
||||
@@ -154,7 +231,15 @@ export function assertFrontendSecretsAreAbsent() {
|
||||
}
|
||||
|
||||
export function taroRuntimeEnv() {
|
||||
return envValue('TARO_ENV') || '';
|
||||
return publicBuildConfigValue('target', 'TARO_ENV');
|
||||
}
|
||||
|
||||
export function taroReleaseMode() {
|
||||
return publicBuildConfigValue('releaseMode', 'TARO_APP_RELEASE_MODE') === 'production' ? 'production' : 'preview';
|
||||
}
|
||||
|
||||
export function taroWeappTenantMode(): WeappTenantMode {
|
||||
return publicBuildConfigValue('weappTenantMode', 'TARO_APP_WEAPP_TENANT_MODE') === 'launch' ? 'launch' : 'fixed';
|
||||
}
|
||||
|
||||
export function isH5Runtime() {
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
|
||||
import { resolveTenant } from '@/services/api';
|
||||
import { currentRouteParams, landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { reLaunch, replaceLocation } from '@/capabilities/navigation';
|
||||
import { currentRouteParams, landingPath, redirectToLogin, safeRedirectPath } from '@/services/routeGuard';
|
||||
import './index.css';
|
||||
|
||||
function hostFromRuntime() {
|
||||
if (isH5Runtime() && typeof window !== 'undefined') return window.location.host;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function BootstrapPage() {
|
||||
const { refresh } = useApp();
|
||||
const [status, setStatus] = useState('正在解析租户');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = currentRouteParams();
|
||||
const redirectPath = safeRedirectPath(params.redirect ? decodeURIComponent(String(params.redirect)) : landingPath());
|
||||
assertFrontendSecretsAreAbsent();
|
||||
ensureRuntimeConfigLoaded()
|
||||
.then(() => resolveTenant({ host: hostFromRuntime() }))
|
||||
.then(async () => {
|
||||
setStatus('正在校验登录状态');
|
||||
if (appEnv.portal === 'student') return requireSignedIn(redirectPath);
|
||||
if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(redirectPath);
|
||||
return requireTenantAdmin(redirectPath);
|
||||
})
|
||||
.then(authPayload => {
|
||||
if (!authPayload) return;
|
||||
setStatus('租户解析完成');
|
||||
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||
window.location.replace(redirectPath);
|
||||
setStatus('正在校验登录状态');
|
||||
refresh({ path: redirectPath })
|
||||
.then(authorized => {
|
||||
if (!authorized) {
|
||||
setStatus('正在前往登录');
|
||||
redirectToLogin(redirectPath);
|
||||
return;
|
||||
}
|
||||
Taro.redirectTo({ url: redirectPath });
|
||||
setStatus('租户解析完成');
|
||||
void replaceLocation(redirectPath);
|
||||
})
|
||||
.catch((nextError: Error) => {
|
||||
setError(nextError.message);
|
||||
@@ -50,7 +38,7 @@ export default function BootstrapPage() {
|
||||
<Text className='status'>{status}</Text>
|
||||
{error ? <Text className='error'>{error}</Text> : null}
|
||||
{error ? (
|
||||
<Button className='primary-button' onClick={() => Taro.reLaunch({ url: '/pages/bootstrap/index' })}>
|
||||
<Button className='primary-button' onClick={() => void reLaunch('/pages/bootstrap/index')}>
|
||||
重新尝试
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { downloadBase64File } from '@/capabilities/file';
|
||||
import {
|
||||
exportPlatformAuditLogs,
|
||||
loadPlatformAuditAlerts,
|
||||
@@ -27,25 +29,14 @@ import {
|
||||
type PlatformQuestionBankItem,
|
||||
type PlatformTenantItem,
|
||||
} from '@/services/platformAdmin';
|
||||
import { requirePlatformAdmin } from '@/services/routeGuard';
|
||||
import '../platform.css';
|
||||
|
||||
function money(cents: unknown) {
|
||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const link = document.createElement('a');
|
||||
link.href = `data:${mimeType};base64,${contentBase64}`;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function PlatformWorkbenchPage() {
|
||||
const { canPlatform } = useApp();
|
||||
const [overview, setOverview] = useState<PlatformOverview | null>(null);
|
||||
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
|
||||
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
|
||||
@@ -59,13 +50,9 @@ export default function PlatformWorkbenchPage() {
|
||||
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [authorized, setAuthorized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
requirePlatformAdmin('/pages/platform-admin/workbench/index').then(payload => {
|
||||
if (!payload) return;
|
||||
setAuthorized(true);
|
||||
return Promise.all([
|
||||
Promise.all([
|
||||
loadPlatformOverview().catch(() => ({ item: null })),
|
||||
loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
@@ -77,9 +64,7 @@ export default function PlatformWorkbenchPage() {
|
||||
loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformDunningNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformDunningNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
]);
|
||||
}).then(result => {
|
||||
if (!result) return;
|
||||
]).then(result => {
|
||||
const [overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload, dunningChannelPayload, dunningEventPayload] = result;
|
||||
setOverview(overviewPayload.item || null);
|
||||
setTenants(tenantPayload.items || []);
|
||||
@@ -96,25 +81,11 @@ export default function PlatformWorkbenchPage() {
|
||||
}, []);
|
||||
|
||||
const modules = [
|
||||
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' },
|
||||
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' },
|
||||
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
|
||||
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
|
||||
];
|
||||
|
||||
if (!authorized) {
|
||||
return (
|
||||
<View className='platform-page'>
|
||||
<View className='platform-shell'>
|
||||
<View className='platform-header'>
|
||||
<Text className='platform-kicker'>Platform Admin</Text>
|
||||
<Text className='platform-title'>正在校验平台权限</Text>
|
||||
<Text className='platform-subtitle'>请先完成登录,系统会确认当前账号是否拥有平台管理员权限。</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期', permission: 'platform:tenant:read' },
|
||||
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量', permission: 'platform:billing:read' },
|
||||
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围', permission: 'platform:question_bank:read' },
|
||||
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复', permission: 'platform:staff:read' },
|
||||
].filter(item => canPlatform(item.permission));
|
||||
|
||||
async function exportAuditLogs() {
|
||||
setBusy('audit-export');
|
||||
@@ -123,8 +94,8 @@ export default function PlatformWorkbenchPage() {
|
||||
const payload = await exportPlatformAuditLogs({ format: 'csv', limit: 1000 });
|
||||
const item = payload.item;
|
||||
if (item?.contentBase64 && item.filename) {
|
||||
const ok = downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv');
|
||||
Taro.showToast({ title: ok ? '已导出' : '已生成', icon: 'success' });
|
||||
await downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv');
|
||||
Taro.showToast({ title: '已导出', icon: 'success' });
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '审计导出失败');
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components';
|
||||
import { downloadTextFile } from '@/capabilities/file';
|
||||
import {
|
||||
exportSchoolRecommendationReport,
|
||||
generateSchoolRecommendation,
|
||||
loadSchoolRecommendationReports,
|
||||
type SchoolRecommendationReport,
|
||||
} from '@/services/ai';
|
||||
import { isH5Runtime } from '@/env';
|
||||
import { loadProfile, type StudentProfile } from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
@@ -25,24 +25,6 @@ function recommendationRows(report: SchoolRecommendationReport | null) {
|
||||
return report?.resultPayload?.recommendedSchools || [];
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, content: string, mimeType: string) {
|
||||
if (isH5Runtime() && typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
Taro.setClipboardData({ data: content });
|
||||
Taro.showToast({ title: '报告内容已复制', icon: 'none' });
|
||||
}
|
||||
|
||||
export default function StudentAiSchoolPage() {
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [reports, setReports] = useState<SchoolRecommendationReport[]>([]);
|
||||
@@ -94,8 +76,8 @@ export default function StudentAiSchoolPage() {
|
||||
try {
|
||||
const payload = await exportSchoolRecommendationReport(current.id, format);
|
||||
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
|
||||
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
|
||||
if (isH5Runtime()) Taro.showToast({ title: '报告已导出', icon: 'success' });
|
||||
const result = await downloadTextFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
|
||||
Taro.showToast({ title: result === 'copied' ? '报告内容已复制' : '报告已导出', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '导出失败');
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View, WebView } from '@tarojs/components';
|
||||
import { openRemoteFile } from '@/capabilities/file';
|
||||
import {
|
||||
loadContentAssets,
|
||||
signAssetDownload,
|
||||
@@ -19,14 +20,10 @@ type PreviewState = {
|
||||
watermark?: AssetWatermarkContext;
|
||||
};
|
||||
|
||||
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
|
||||
async function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
|
||||
if (!url) return;
|
||||
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
Taro.setClipboardData({ data: url });
|
||||
Taro.showToast({ title: copiedText, icon: 'none' });
|
||||
const result = await openRemoteFile(url);
|
||||
if (result === 'copied') Taro.showToast({ title: copiedText, icon: 'none' });
|
||||
}
|
||||
|
||||
function isImageAsset(asset: ContentAsset) {
|
||||
@@ -92,7 +89,7 @@ export default function StudentAssetsPage() {
|
||||
Taro.showToast({ title: '请确认水印追踪码后下载', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
openUrl(payload.download?.url, '下载签名已复制,请及时使用');
|
||||
await openUrl(payload.download?.url, '下载签名已复制,请及时使用');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '下载失败');
|
||||
} finally {
|
||||
@@ -100,8 +97,8 @@ export default function StudentAssetsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSignedLink() {
|
||||
openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开');
|
||||
async function openSignedLink() {
|
||||
await openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开');
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { launchPayment, paymentReturnUrl } from '@/capabilities/payment';
|
||||
import {
|
||||
claimCoupon,
|
||||
createOrder,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
type PaymentCreateResult,
|
||||
type SvipPlan,
|
||||
} from '@/services/commerce';
|
||||
import { isH5Runtime, isWeappRuntime } from '@/env';
|
||||
import { loadProfile, type StudentProfile } from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
@@ -55,16 +55,6 @@ function buildPaymentUrl(result?: PaymentCreateResult) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function tryOpenPaymentUrl(url: string) {
|
||||
if (!url) return false;
|
||||
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
return true;
|
||||
}
|
||||
Taro.setClipboardData({ data: url });
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function StudentCheckoutPage() {
|
||||
const router = useRouter();
|
||||
const params = router.params || {};
|
||||
@@ -168,8 +158,8 @@ export default function StudentCheckoutPage() {
|
||||
const paymentPayload = await createPayment({
|
||||
orderNo: nextOrder.orderNo,
|
||||
provider,
|
||||
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
returnUrl: paymentReturnUrl(),
|
||||
quitUrl: paymentReturnUrl(),
|
||||
});
|
||||
setPayment(paymentPayload.item || null);
|
||||
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
|
||||
@@ -182,19 +172,18 @@ export default function StudentCheckoutPage() {
|
||||
|
||||
async function handleOpenPayment() {
|
||||
if (!payment) return;
|
||||
if (payment.provider === 'wechat_pay' && isWeappRuntime()) {
|
||||
const paramsForWeapp = payment.paymentParams || {};
|
||||
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
|
||||
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '微信支付未完成'));
|
||||
return;
|
||||
try {
|
||||
const result = await launchPayment({
|
||||
provider: payment.provider,
|
||||
paymentParams: payment.paymentParams,
|
||||
paymentUrl,
|
||||
});
|
||||
if (result === 'completed' && order?.orderNo) await pollStatus(order.orderNo);
|
||||
if (result === 'copied-url') setMessage('支付链接已复制,请在支持的支付容器中打开。');
|
||||
if (result === 'copied-params') setMessage('支付参数已复制,请交给支付容器或客服处理。');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '支付未完成');
|
||||
}
|
||||
if (!paymentUrl) {
|
||||
await Taro.setClipboardData({ data: JSON.stringify(payment.paymentParams || {}) });
|
||||
setMessage('支付参数已复制,请交给支付容器或客服处理。');
|
||||
return;
|
||||
}
|
||||
tryOpenPaymentUrl(paymentUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { loadStudentDashboard, type DashboardSnapshot } from '@/services/catalog';
|
||||
import { requireSignedIn } from '@/services/routeGuard';
|
||||
import './index.css';
|
||||
|
||||
export default function StudentHomePage() {
|
||||
const tenant = getTenantContext();
|
||||
const { currentUser, tenant } = useApp();
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||
const [userName, setUserName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
requireSignedIn('/pages/student/home/index').then(payload => {
|
||||
if (payload) setUserName(payload.user?.name || payload.item?.name || '');
|
||||
}).catch(() => undefined);
|
||||
loadStudentDashboard().then(setSnapshot).catch(() => setSnapshot({ entries: [], banners: [], announcements: [], profile: null }));
|
||||
}, []);
|
||||
|
||||
const entryNames = useMemo(() => (snapshot?.entries || []).slice(0, 6), [snapshot]);
|
||||
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
|
||||
const userName = currentUser?.name || currentUser?.username || '';
|
||||
const profile = snapshot?.profile && typeof snapshot.profile === 'object' ? snapshot.profile as Record<string, unknown> : {};
|
||||
const stats = profile.stats && typeof profile.stats === 'object' ? profile.stats as Record<string, unknown> : {};
|
||||
const answerStats = stats.answers && typeof stats.answers === 'object' ? stats.answers as Record<string, unknown> : {};
|
||||
|
||||
@@ -1,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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { launchPayment, paymentReturnUrl } from '@/capabilities/payment';
|
||||
import { copyText } from '@/capabilities/share';
|
||||
import {
|
||||
createPayment,
|
||||
loadOrderDetail,
|
||||
@@ -9,8 +12,6 @@ import {
|
||||
type OrderStatus,
|
||||
type PaymentCreateResult,
|
||||
} from '@/services/commerce';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { isH5Runtime } from '@/env';
|
||||
import '../student.css';
|
||||
|
||||
function centsToYuan(value?: number | null) {
|
||||
@@ -49,7 +50,7 @@ function paymentUrl(result?: PaymentCreateResult | null) {
|
||||
export default function StudentOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const orderNo = router.params?.orderNo || '';
|
||||
const tenant = getTenantContext();
|
||||
const { tenant } = useApp();
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [status, setStatus] = useState<OrderStatus | null>(null);
|
||||
const [payment, setPayment] = useState<PaymentCreateResult | null>(null);
|
||||
@@ -95,20 +96,24 @@ export default function StudentOrderDetailPage() {
|
||||
const payload = await createPayment({
|
||||
orderNo,
|
||||
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
|
||||
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
returnUrl: paymentReturnUrl(),
|
||||
quitUrl: paymentReturnUrl(),
|
||||
});
|
||||
const nextPayment = payload.item || null;
|
||||
setPayment(nextPayment);
|
||||
const url = paymentUrl(nextPayment);
|
||||
if (url && isH5Runtime() && typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
} else if (url) {
|
||||
await Taro.setClipboardData({ data: url });
|
||||
setMessage('支付链接已复制。');
|
||||
} else {
|
||||
if (nextPayment?.provider === 'manual' && !url) {
|
||||
setMessage(nextPayment?.provider === 'manual' ? '线下支付订单已生成,请联系教务或客服确认。' : '支付参数已生成。');
|
||||
return;
|
||||
}
|
||||
const result = await launchPayment({
|
||||
provider: nextPayment?.provider,
|
||||
paymentParams: nextPayment?.paymentParams,
|
||||
paymentUrl: url,
|
||||
});
|
||||
if (result === 'completed') reload();
|
||||
if (result === 'copied-url') setMessage('支付链接已复制。');
|
||||
if (result === 'copied-params') setMessage('支付参数已生成并复制。');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '继续支付失败');
|
||||
} finally {
|
||||
@@ -118,9 +123,7 @@ export default function StudentOrderDetailPage() {
|
||||
|
||||
async function copyAfterSalesInfo() {
|
||||
const serviceText = tenant?.branding?.slogan || tenant?.branding?.brandName || '请联系当前租户客服处理售后';
|
||||
await Taro.setClipboardData({
|
||||
data: `订单号:${orderNo}\n售后说明:${serviceText}`,
|
||||
});
|
||||
await copyText(`订单号:${orderNo}\n售后说明:${serviceText}`);
|
||||
setMessage('订单售后信息已复制。');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
type QuestionItem,
|
||||
} from '@/services/learning';
|
||||
import { submitFeedback } from '@/services/profile';
|
||||
import { getStorage, setStorage } from '@/services/storage';
|
||||
import { createUserStorage } from '@/services/storage';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import '../student.css';
|
||||
|
||||
type AnswerState = {
|
||||
@@ -148,7 +149,12 @@ function secondsUntil(value?: string | null) {
|
||||
|
||||
export default function StudentPracticePage() {
|
||||
const router = useRouter();
|
||||
const { currentUser, tenant } = useApp();
|
||||
const params = router.params || {};
|
||||
const userStorage = useMemo(
|
||||
() => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }),
|
||||
[tenant?.tenantId, currentUser?.id],
|
||||
);
|
||||
const [session, setSession] = useState<PracticeSession | null>(null);
|
||||
const [questions, setQuestions] = useState<QuestionItem[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
@@ -185,7 +191,7 @@ export default function StudentPracticePage() {
|
||||
setSession(nextSession);
|
||||
setQuestions(nextSession.questions || []);
|
||||
setAnswerByQuestion(backendAnswers);
|
||||
const savedIndex = getStorage<number>(indexStorageKey(nextSession.id));
|
||||
const savedIndex = userStorage.get<number>(indexStorageKey(nextSession.id));
|
||||
const firstUnanswered = (nextSession.questionIds || []).findIndex(questionId => !backendAnswers[questionId]);
|
||||
setIndex(typeof savedIndex === 'number' ? savedIndex : Math.max(0, firstUnanswered));
|
||||
const remaining = secondsUntil(nextSession.expiresAt);
|
||||
@@ -202,8 +208,8 @@ export default function StudentPracticePage() {
|
||||
const nextSession = payload.item;
|
||||
setSession(nextSession);
|
||||
if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60);
|
||||
const savedIndex = getStorage<number>(indexStorageKey(nextSession.id));
|
||||
const savedAnswers = getStorage<Record<string, AnswerState>>(answerStorageKey(nextSession.id));
|
||||
const savedIndex = userStorage.get<number>(indexStorageKey(nextSession.id));
|
||||
const savedAnswers = userStorage.get<Record<string, AnswerState>>(answerStorageKey(nextSession.id));
|
||||
if (savedAnswers) setAnswerByQuestion(savedAnswers);
|
||||
if (typeof savedIndex === 'number') setIndex(savedIndex);
|
||||
const questionPayload = collectionId
|
||||
@@ -238,13 +244,13 @@ export default function StudentPracticePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setStorage(indexStorageKey(session.id), index);
|
||||
}, [index, session?.id]);
|
||||
userStorage.set(indexStorageKey(session.id), index);
|
||||
}, [index, session?.id, userStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setStorage(answerStorageKey(session.id), answerByQuestion);
|
||||
}, [answerByQuestion, session?.id]);
|
||||
userStorage.set(answerStorageKey(session.id), answerByQuestion);
|
||||
}, [answerByQuestion, session?.id, userStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { logout } from '@/services/auth';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce';
|
||||
import {
|
||||
loadLearningStats,
|
||||
@@ -143,6 +143,7 @@ function avatarPresetLabel(preset?: string | null) {
|
||||
}
|
||||
|
||||
export default function StudentProfilePage() {
|
||||
const { signOut } = useApp();
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [plans, setPlans] = useState<SvipPlan[]>([]);
|
||||
const [orders, setOrders] = useState<OrderItem[]>([]);
|
||||
@@ -236,7 +237,7 @@ export default function StudentProfilePage() {
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
await signOut();
|
||||
Taro.redirectTo({ url: '/pages/student/login/index' });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
type VocabularyWord,
|
||||
} from '@/services/learning';
|
||||
import { playWordPronunciation, type AccentType } from '@/services/pronunciation';
|
||||
import { getStorage, removeStorage, setStorage } from '@/services/storage';
|
||||
import { createUserStorage } from '@/services/storage';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import '../student.css';
|
||||
|
||||
type StudyMode = 'plan' | 'unit' | 'favorites';
|
||||
@@ -56,6 +57,11 @@ function modeLabel(mode: StudyMode) {
|
||||
}
|
||||
|
||||
export default function StudentVocabularyPage() {
|
||||
const { currentUser, tenant } = useApp();
|
||||
const userStorage = useMemo(
|
||||
() => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }),
|
||||
[tenant?.tenantId, currentUser?.id],
|
||||
);
|
||||
const [units, setUnits] = useState<VocabularyUnit[]>([]);
|
||||
const [unitId, setUnitId] = useState('');
|
||||
const [mode, setMode] = useState<StudyMode>('plan');
|
||||
@@ -110,7 +116,7 @@ export default function StudentVocabularyPage() {
|
||||
newCount: Number(planPayload.item?.newCount || 0),
|
||||
totalPlanned: Number(planPayload.item?.totalPlanned || planned.length || 0),
|
||||
});
|
||||
const savedIndex = getStorage<number>(progressStorageKey(unitId, mode));
|
||||
const savedIndex = userStorage.get<number>(progressStorageKey(unitId, mode));
|
||||
setWords(nextWords);
|
||||
setFavoriteIds(Object.fromEntries((favoritesForStatus.items || []).map(item => wordRecordId(item)).filter(Boolean).map(id => [id, true])));
|
||||
setStats(statPayload.item || null);
|
||||
@@ -118,12 +124,12 @@ export default function StudentVocabularyPage() {
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '单词加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [unitId, mode]);
|
||||
}, [unitId, mode, userStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId || !words.length) return;
|
||||
setStorage(progressStorageKey(unitId, mode), index);
|
||||
}, [index, mode, unitId, words.length]);
|
||||
userStorage.set(progressStorageKey(unitId, mode), index);
|
||||
}, [index, mode, unitId, userStorage, words.length]);
|
||||
|
||||
const current = words[index] || null;
|
||||
const progressPercent = words.length ? Math.round(((Math.min(index + 1, words.length)) / words.length) * 100) : 0;
|
||||
@@ -141,7 +147,7 @@ export default function StudentVocabularyPage() {
|
||||
const unitName = useMemo(() => units.find(item => item.id === unitId)?.name || '单词单元', [units, unitId]);
|
||||
|
||||
function resetPosition(nextMode = mode) {
|
||||
removeStorage(progressStorageKey(unitId, nextMode));
|
||||
userStorage.remove(progressStorageKey(unitId, nextMode));
|
||||
setIndex(0);
|
||||
setShowAnswer(false);
|
||||
setCompleted(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
|
||||
import { downloadBase64File, pickLocalFile } from '@/capabilities/file';
|
||||
import {
|
||||
adoptPublicQuestionBank,
|
||||
executeContentImport,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
type PublicQuestionBankItem,
|
||||
type TenantContentNotificationItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import { isH5Runtime } from '@/env';
|
||||
import '../admin.css';
|
||||
|
||||
function displayBankName(item: PublicQuestionBankItem) {
|
||||
@@ -52,83 +52,6 @@ function adoptionIdOf(item: PublicQuestionBankItem) {
|
||||
const importTypes: ImportType[] = ['questions', 'vocabulary', 'handbook', 'scoreline', 'videos'];
|
||||
const importFormats: ImportSourceFormat[] = ['json', 'csv', 'excel'];
|
||||
|
||||
function readLocalFileAsBase64(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function readLocalFileAsText(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
function openH5FilePicker(format: ImportSourceFormat) {
|
||||
return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => {
|
||||
if (!isH5Runtime() || typeof document === 'undefined') {
|
||||
reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。'));
|
||||
return;
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = format === 'excel' ? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : format === 'csv' ? '.csv,text/csv,text/plain' : '.json,application/json,text/plain';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
reject(new Error('未选择文件'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (format === 'excel') {
|
||||
resolve({ fileName: file.name, fileBase64: await readLocalFileAsBase64(file) });
|
||||
} else {
|
||||
resolve({ fileName: file.name, text: await readLocalFileAsText(file) });
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function downloadTemplateFile(template: ImportTemplateItem) {
|
||||
if (!isH5Runtime() || typeof document === 'undefined') {
|
||||
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
|
||||
}
|
||||
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
|
||||
const blob = new Blob([base64ToUint8Array(template.contentBase64)], {
|
||||
type: template.mimeType || 'application/octet-stream',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = template.fileName || `import-template.${template.format || 'json'}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function safeJsonPreview(value: unknown, maxLength = 360) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2).slice(0, maxLength);
|
||||
@@ -316,7 +239,12 @@ export default function TenantContentPage() {
|
||||
? { item: template }
|
||||
: await loadImportTemplate(selectedImportType, sourceFormat === 'excel' ? 'csv' : sourceFormat);
|
||||
if (!payload.item) throw new Error('模板不存在。');
|
||||
downloadTemplateFile(payload.item);
|
||||
if (!payload.item.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
|
||||
await downloadBase64File(
|
||||
payload.item.fileName || `import-template.${payload.item.format || 'json'}`,
|
||||
payload.item.contentBase64,
|
||||
payload.item.mimeType || 'application/octet-stream',
|
||||
);
|
||||
setTemplate(payload.item);
|
||||
Taro.showToast({ title: '已下载', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
@@ -327,14 +255,22 @@ export default function TenantContentPage() {
|
||||
async function chooseImportFile() {
|
||||
setError('');
|
||||
try {
|
||||
const file = await openH5FilePicker(sourceFormat);
|
||||
const file = await pickLocalFile({
|
||||
accept: sourceFormat === 'excel'
|
||||
? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
: sourceFormat === 'csv'
|
||||
? '.csv,text/csv,text/plain'
|
||||
: '.json,application/json,text/plain',
|
||||
extensions: sourceFormat === 'excel' ? ['xlsx'] : sourceFormat === 'csv' ? ['csv', 'txt'] : ['json', 'txt'],
|
||||
readAs: sourceFormat === 'excel' ? 'base64' : 'text',
|
||||
});
|
||||
setSourceName(file.fileName);
|
||||
if (file.text !== undefined) {
|
||||
setImportText(file.text);
|
||||
setFileBase64('');
|
||||
}
|
||||
if (file.fileBase64 !== undefined) {
|
||||
setFileBase64(file.fileBase64);
|
||||
if (file.base64 !== undefined) {
|
||||
setFileBase64(file.base64);
|
||||
setImportText('');
|
||||
}
|
||||
} catch (nextError) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { downloadBase64File } from '@/capabilities/file';
|
||||
import {
|
||||
createCommissionSettlementProof,
|
||||
exportCommissionSettlement,
|
||||
@@ -166,17 +167,6 @@ function shortDate(value?: string | null) {
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const link = document.createElement('a');
|
||||
link.href = `data:${mimeType};base64,${contentBase64}`;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
interface CouponFormState {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -964,8 +954,8 @@ export default function TenantMarketingPage() {
|
||||
const payload = await exportCommissionSettlement(item.id, 'csv');
|
||||
const exportItem = payload.item;
|
||||
if (exportItem?.contentBase64 && exportItem.filename) {
|
||||
const downloaded = downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv');
|
||||
Taro.showToast({ title: downloaded ? '导出已下载' : '导出已生成', icon: 'success' });
|
||||
await downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv');
|
||||
Taro.showToast({ title: '导出已下载', icon: 'success' });
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '结算导出失败');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import {
|
||||
disableTenantMember,
|
||||
disableRoleTemplate,
|
||||
@@ -228,6 +229,7 @@ function catalogLabel(item: TenantPermissionCatalogItem) {
|
||||
}
|
||||
|
||||
export default function TenantSettingsPage() {
|
||||
const { refreshTenant } = useApp();
|
||||
const [overview, setOverview] = useState<TenantOverview | null>(null);
|
||||
const [domains, setDomains] = useState<Record<string, unknown>[]>([]);
|
||||
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
|
||||
@@ -541,6 +543,7 @@ export default function TenantSettingsPage() {
|
||||
...(themeForm.shareCardStyle.trim() ? { shareCardStyle: themeForm.shareCardStyle.trim() } : {}),
|
||||
},
|
||||
});
|
||||
await refreshTenant();
|
||||
Taro.showToast({ title: '主题已发布', icon: 'success' });
|
||||
await reloadSettings(selectedRoleId);
|
||||
} catch (nextError) {
|
||||
|
||||
@@ -145,6 +145,8 @@ export default function TenantStudentsPage() {
|
||||
const [supervisionRulesList, setSupervisionRulesList] = useState<TenantStudentSupervisionRuleItem[]>([]);
|
||||
const [supervisionRules, setSupervisionRules] = useState(defaultSupervisionRules);
|
||||
const [scoped, setScoped] = useState(false);
|
||||
const [studentHasMore, setStudentHasMore] = useState(false);
|
||||
const [nextStudentCursor, setNextStudentCursor] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -174,12 +176,40 @@ export default function TenantStudentsPage() {
|
||||
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
|
||||
setStudents(studentPayload.items || []);
|
||||
setScoped(studentPayload.scoped === true);
|
||||
setStudentHasMore(studentPayload.hasMore === true);
|
||||
setNextStudentCursor(studentPayload.nextCursor || '');
|
||||
setFollowups(followupPayload.items || []);
|
||||
setFollowupReport(reportPayload.item || null);
|
||||
setSupervisionRulesList(supervisionRulePayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
|
||||
}
|
||||
|
||||
async function loadMoreStudents() {
|
||||
if (!studentHasMore || !nextStudentCursor || busy === 'studentsMore') return;
|
||||
setBusy('studentsMore');
|
||||
setError('');
|
||||
try {
|
||||
const payload = await loadTenantStudents({
|
||||
classId: selectedClassId || undefined,
|
||||
keyword: keyword || undefined,
|
||||
status: studentStatus || undefined,
|
||||
cursor: nextStudentCursor,
|
||||
limit: 80,
|
||||
});
|
||||
setStudents(previous => {
|
||||
const byUserId = new Map(previous.map(item => [item.userId, item]));
|
||||
for (const item of payload.items || []) byUserId.set(item.userId, item);
|
||||
return Array.from(byUserId.values());
|
||||
});
|
||||
setStudentHasMore(payload.hasMore === true);
|
||||
setNextStudentCursor(payload.nextCursor || '');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '加载更多学生失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload('', '');
|
||||
}, []);
|
||||
@@ -636,6 +666,11 @@ export default function TenantStudentsPage() {
|
||||
))}
|
||||
</View>
|
||||
{!students.length ? <View className='admin-empty'>暂无学生,或当前角色没有可见学生范围。</View> : null}
|
||||
{studentHasMore ? (
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button' loading={busy === 'studentsMore'} onClick={loadMoreStudents}>加载更多</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { useApp } from '@/app/AppProvider';
|
||||
import {
|
||||
loadTenantDashboard,
|
||||
loadTenantOverview,
|
||||
loadTenantPermissions,
|
||||
type TenantDashboard,
|
||||
type TenantOverview,
|
||||
type TenantPermissionsPayload,
|
||||
} from '@/services/tenantAdmin';
|
||||
import { requireTenantAdmin } from '@/services/routeGuard';
|
||||
import '../admin.css';
|
||||
|
||||
interface AdminModule {
|
||||
@@ -30,79 +27,24 @@ const MODULES: AdminModule[] = [
|
||||
{ key: 'settings', name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录、角色', permission: 'tenant:overview:read' },
|
||||
];
|
||||
|
||||
function boolRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const candidates = [permission];
|
||||
for (let index = parts.length - 1; index >= 1; index -= 1) {
|
||||
candidates.push(`${parts.slice(0, index).join(':')}:*`);
|
||||
}
|
||||
candidates.push('*');
|
||||
for (const key of candidates) {
|
||||
if (typeof permissions[key] === 'boolean') return permissions[key] as boolean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canOpenModule(payload: TenantPermissionsPayload, item: AdminModule) {
|
||||
const current = payload.current || {};
|
||||
const menuPermissions = boolRecord(current.menuPermissions);
|
||||
if (typeof menuPermissions[item.key] === 'boolean') return menuPermissions[item.key] as boolean;
|
||||
|
||||
if (!item.permission) return true;
|
||||
const effectivePermissions = boolRecord(current.effectivePermissions);
|
||||
const explicit = explicitPermission(effectivePermissions, item.permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const role = String(current.role || '');
|
||||
const defaults = payload.roleDefaults?.[role] || [];
|
||||
return defaults.some(permission => {
|
||||
if (permission === '*') return true;
|
||||
if (permission === item.permission) return true;
|
||||
if (permission.endsWith(':*')) return item.permission?.startsWith(permission.slice(0, -1));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export default function TenantWorkbenchPage() {
|
||||
const tenant = getTenantContext();
|
||||
const { canTenantMenu, tenant } = useApp();
|
||||
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
|
||||
const [overview, setOverview] = useState<TenantOverview | null>(null);
|
||||
const [permissions, setPermissions] = useState<TenantPermissionsPayload>({});
|
||||
const [authorized, setAuthorized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
requireTenantAdmin('/pages/tenant-admin/workbench/index')
|
||||
.then(payload => {
|
||||
if (!payload) return;
|
||||
setAuthorized(true);
|
||||
loadTenantDashboard('30d').then(next => setDashboard(next.item || null)).catch(() => setDashboard(null));
|
||||
loadTenantOverview().then(next => setOverview(next.item || null)).catch(() => setOverview(null));
|
||||
loadTenantPermissions().then(next => setPermissions(next)).catch(() => setPermissions({}));
|
||||
})
|
||||
.catch(() => setPermissions({}));
|
||||
Promise.all([
|
||||
loadTenantDashboard('30d').catch(() => ({ item: null })),
|
||||
loadTenantOverview().catch(() => ({ item: null })),
|
||||
]).then(([dashboardPayload, overviewPayload]) => {
|
||||
setDashboard(dashboardPayload.item || null);
|
||||
setOverview(overviewPayload.item || null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cards = dashboard?.cards || {};
|
||||
const payment = dashboard?.paymentStats || {};
|
||||
const modules = MODULES.filter(item => canOpenModule(permissions, item));
|
||||
|
||||
if (!authorized) {
|
||||
return (
|
||||
<View className='admin-page'>
|
||||
<View className='admin-shell'>
|
||||
<View className='admin-header'>
|
||||
<Text className='admin-kicker'>Tenant Admin</Text>
|
||||
<Text className='admin-title'>正在校验后台权限</Text>
|
||||
<Text className='admin-subtitle'>请先完成登录,系统会确认当前账号是否拥有租户后台权限。</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const modules = MODULES.filter(item => canTenantMenu({ menuKey: item.key, permission: item.permission }));
|
||||
|
||||
return (
|
||||
<View className='admin-page'>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || {},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
115
apps/taro/src/theme/ThemeProvider.tsx
Normal file
115
apps/taro/src/theme/ThemeProvider.tsx
Normal 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);
|
||||
}
|
||||
139
apps/taro/src/theme/tokens.ts
Normal file
139
apps/taro/src/theme/tokens.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
146
apps/worker/src/cli.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
93
deploy.env.example
Normal 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
686
deploy.sh
Executable 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 "$@"
|
||||
@@ -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>`。
|
||||
|
||||
下一步优先级:
|
||||
|
||||
|
||||
@@ -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/tsx,UID 为 `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` 可解析主租户。
|
||||
|
||||
@@ -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。
|
||||
|
||||
前端第一阶段应该先做能跑完整学生链路的页面:
|
||||
|
||||
|
||||
127
docs/refactor/clean-room-migration-audit-20260712.md
Normal file
127
docs/refactor/clean-room-migration-audit-20260712.md
Normal 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.
|
||||
@@ -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。
|
||||
|
||||
## 模板、字段映射和导入后复检
|
||||
|
||||
租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口:
|
||||
|
||||
@@ -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`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||
|
||||
@@ -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` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
|
||||
42
docs/refactor/import-worker-lease-verification.md
Normal file
42
docs/refactor/import-worker-lease-verification.md
Normal 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
Reference in New Issue
Block a user