From 6a4f868a5a6beca680e6d1bceffa2a7a9aa0f3dd Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 13 Nov 2025 23:43:22 +0800 Subject: [PATCH] feat(docker): add Docker Compose configuration for database and caching services - Introduced a new `docker-compose.yml` file to define services for PostgreSQL and Redis, facilitating local development and testing. - Configured health checks for both services to ensure they are ready before the core application starts. - Set up environment variables for the PostgreSQL service to manage user credentials and database initialization. - Added volume mappings for persistent data storage in both PostgreSQL and Redis. refactor(auth): streamline tenant context resolution and error handling - Simplified the `AuthGuard` to throw a `BizException` with a detailed message when tenant IDs do not match. - Removed unnecessary response header settings in the `TenantContextResolver` and middleware, improving clarity and maintainability. - Updated various controllers and services to utilize the new tenant context handling logic, ensuring consistent behavior across the application. feat(super-admin): implement builder debug functionality - Added `SuperAdminBuilderDebugController` to handle image debugging requests with progress tracking. - Introduced an in-memory storage provider for debugging purposes, allowing for temporary file uploads and processing. - Enhanced the dashboard with a new debug page for super admins to test and validate image processing workflows. Signed-off-by: Innei --- apps/ssr/src/app/og/[photoId]/route.tsx | 10 +- be/apps/core/afilmory-builder.d.ts | 5 + be/apps/core/src/guards/auth.guard.ts | 7 +- .../tenant-resolver.interceptor.ts | 1 - .../core/src/middlewares/cors.middleware.ts | 1 - .../middlewares/request-context.middleware.ts | 1 - .../data-sync/data-sync.controller.ts | 129 +---- .../static-web/static-web.controller.ts | 59 +- .../auth/auth-registration.service.ts | 5 - .../modules/platform/auth/auth.controller.ts | 96 ++-- .../modules/platform/auth/auth.provider.ts | 4 +- .../platform/auth/root-account.service.ts | 2 +- .../InMemoryDebugStorageProvider.ts | 110 ++++ .../super-admin-builder.controller.ts | 280 ++++++++++ ....ts => super-admin-settings.controller.ts} | 0 .../super-admin/super-admin.module.ts | 7 +- .../tenant/tenant-context-resolver.service.ts | 118 +--- .../modules/platform/tenant/tenant.types.ts | 8 +- be/apps/core/src/modules/shared/http/sse.ts | 129 +++++ .../components/common/SuperAdminUserMenu.tsx | 97 ++++ .../modules/auth/hooks/useRegisterTenant.ts | 14 +- .../dashboard/src/modules/super-admin/api.ts | 127 ++++- .../src/modules/super-admin/types.ts | 41 ++ .../dashboard/src/pages/superadmin/debug.tsx | 512 ++++++++++++++++++ .../dashboard/src/pages/superadmin/layout.tsx | 94 +--- docker-compose.yml | 62 +++ docs/TENANT_FLOW.md | 3 +- packages/builder/src/builder/builder.ts | 4 +- packages/builder/src/storage/interfaces.ts | 18 +- 29 files changed, 1562 insertions(+), 382 deletions(-) create mode 100644 be/apps/core/afilmory-builder.d.ts create mode 100644 be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts create mode 100644 be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts rename be/apps/core/src/modules/platform/super-admin/{super-admin.controller.ts => super-admin-settings.controller.ts} (100%) create mode 100644 be/apps/core/src/modules/shared/http/sse.ts create mode 100644 be/apps/dashboard/src/components/common/SuperAdminUserMenu.tsx create mode 100644 be/apps/dashboard/src/pages/superadmin/debug.tsx create mode 100644 docker-compose.yml diff --git a/apps/ssr/src/app/og/[photoId]/route.tsx b/apps/ssr/src/app/og/[photoId]/route.tsx index 2ca03fb8..21b12e01 100644 --- a/apps/ssr/src/app/og/[photoId]/route.tsx +++ b/apps/ssr/src/app/og/[photoId]/route.tsx @@ -6,15 +6,7 @@ const CORE_API_BASE = process.env.API_BASE_URL ?? 'http://localhost:3000' -const FORWARDED_HEADER_KEYS = [ - 'cookie', - 'authorization', - 'x-tenant-id', - 'x-tenant-slug', - 'x-forwarded-host', - 'x-forwarded-proto', - 'host', -] +const FORWARDED_HEADER_KEYS = ['cookie', 'authorization', 'x-forwarded-host', 'x-forwarded-proto', 'host'] function buildBackendUrl(photoId: string): string { const base = CORE_API_BASE.endsWith('/') ? CORE_API_BASE.slice(0, -1) : CORE_API_BASE diff --git a/be/apps/core/afilmory-builder.d.ts b/be/apps/core/afilmory-builder.d.ts new file mode 100644 index 00000000..ead36f5e --- /dev/null +++ b/be/apps/core/afilmory-builder.d.ts @@ -0,0 +1,5 @@ +declare module '@afilmory/builder/storage/interfaces.js' { + interface CustomStorageConfig { + provider: 'super-admin-debug-storage' + } +} diff --git a/be/apps/core/src/guards/auth.guard.ts b/be/apps/core/src/guards/auth.guard.ts index ec9716de..9420f699 100644 --- a/be/apps/core/src/guards/auth.guard.ts +++ b/be/apps/core/src/guards/auth.guard.ts @@ -86,10 +86,9 @@ export class AuthGuard implements CanActivate { const tenantId = await this.resolveTenantIdForSession(authSession, method, path) if (tenantId !== tenantContext.tenant.id) { - this.log.warn( - `Denied access: session tenant=${tenantId ?? 'n/a'} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`, - ) - throw new BizException(ErrorCode.AUTH_FORBIDDEN) + throw new BizException(ErrorCode.AUTH_FORBIDDEN, { + message: `Denied access: session tenant=${tenantId ?? 'n/a'} does not match context tenant=${tenantContext.tenant.id} for ${method} ${path}`, + }) } } diff --git a/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts b/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts index 47f598c0..96683ddd 100644 --- a/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts +++ b/be/apps/core/src/interceptors/tenant-resolver.interceptor.ts @@ -8,7 +8,6 @@ import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator' const DEFAULT_OPTIONS: Required = { throwOnMissing: true, - setResponseHeaders: true, skipInitializationCheck: false, } diff --git a/be/apps/core/src/middlewares/cors.middleware.ts b/be/apps/core/src/middlewares/cors.middleware.ts index 59aee802..c9a11e75 100644 --- a/be/apps/core/src/middlewares/cors.middleware.ts +++ b/be/apps/core/src/middlewares/cors.middleware.ts @@ -131,7 +131,6 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes } const tenantContext = await this.tenantContextResolver.resolve(context, { - setResponseHeaders: false, skipInitializationCheck: true, }) diff --git a/be/apps/core/src/middlewares/request-context.middleware.ts b/be/apps/core/src/middlewares/request-context.middleware.ts index ae07fdfd..ab648743 100644 --- a/be/apps/core/src/middlewares/request-context.middleware.ts +++ b/be/apps/core/src/middlewares/request-context.middleware.ts @@ -31,7 +31,6 @@ export class RequestContextMiddleware implements HttpMiddleware { try { const tenantContext = await this.tenantContextResolver.resolve(context, { - setResponseHeaders: false, throwOnMissing: false, skipInitializationCheck: true, }) diff --git a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.controller.ts b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.controller.ts index b8b1035f..1c74dfed 100644 --- a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.controller.ts +++ b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.controller.ts @@ -1,6 +1,7 @@ import type { BuilderConfig, StorageConfig } from '@afilmory/builder' import { Body, ContextParam, Controller, createLogger, Get, Param, Post } from '@afilmory/framework' import { Roles } from 'core/guards/roles.decorator' +import { createProgressSseResponse } from 'core/modules/shared/http/sse' import type { Context } from 'hono' import { runWithBuilderLogRelay } from './builder-log-relay' @@ -23,122 +24,30 @@ export class DataSyncController { @Post('run') async run(@Body() body: RunDataSyncDto, @ContextParam() context: Context): Promise { const payload = body as unknown as RunDataSyncInput - const encoder = new TextEncoder() - let cleanup: (() => void) | undefined - - const stream = new ReadableStream({ - start: (controller) => { - let closed = false - const rawRequest = context.req.raw - const abortSignal = rawRequest.signal - - let heartbeat: ReturnType | undefined - let abortHandler: (() => void) | undefined - - const cleanupInternal = () => { - if (heartbeat) { - clearInterval(heartbeat) - heartbeat = undefined - } - - if (abortHandler) { - abortSignal.removeEventListener('abort', abortHandler) - abortHandler = undefined - } - - if (!closed) { - closed = true - try { - controller.close() - } catch { - /* ignore close errors */ - } - } - } - - const sendChunk = (chunk: string) => { - if (closed) { - return - } - - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - cleanupInternal() - cleanup = undefined - } - } - - const sendEvent = (event: DataSyncProgressEvent) => { - sendChunk(`event: progress\ndata: ${JSON.stringify(event)}\n\n`) - } - - heartbeat = setInterval(() => { - sendChunk(`: keep-alive ${new Date().toISOString()}\n\n`) - }, 15000) - - abortHandler = () => { - const currentCleanup = cleanup - cleanup = undefined - currentCleanup?.() - } - - abortSignal.addEventListener('abort', abortHandler) - - cleanup = () => { - cleanupInternal() - cleanup = undefined - } - - sendChunk(': connected\n\n') - + return createProgressSseResponse({ + context, + handler: async ({ sendEvent }) => { const progressHandler: DataSyncProgressEmitter = async (event) => { sendEvent(event) } - ;(async () => { - try { - await runWithBuilderLogRelay(progressHandler, () => - this.dataSyncService.runSync( - { - builderConfig: payload.builderConfig as BuilderConfig | undefined, - storageConfig: payload.storageConfig as StorageConfig | undefined, - dryRun: payload.dryRun ?? false, - }, - progressHandler, - ), - ) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - - this.logger.error('Failed to run data sync', error) - sendEvent({ type: 'error', payload: { message } }) - } finally { - const currentCleanup = cleanup - cleanup = undefined - currentCleanup?.() - } - })().catch((error) => { + try { + await runWithBuilderLogRelay(progressHandler, () => + this.dataSyncService.runSync( + { + builderConfig: payload.builderConfig as BuilderConfig | undefined, + storageConfig: payload.storageConfig as StorageConfig | undefined, + dryRun: payload.dryRun ?? false, + }, + progressHandler, + ), + ) + } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - sendEvent({ type: 'error', payload: { message } }) - const currentCleanup = cleanup - cleanup = undefined - currentCleanup?.() - }) - }, - cancel() { - const currentCleanup = cleanup - cleanup = undefined - currentCleanup?.() - }, - }) - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', + this.logger.error('Failed to run data sync', error) + sendEvent({ type: 'error', payload: { message } }) + } }, }) } diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts index d136e34a..6c1bbb49 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts @@ -1,5 +1,6 @@ import { ContextParam, Controller, Get, Param } from '@afilmory/framework' import { isTenantSlugReserved } from '@afilmory/utils' +import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator' import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator' import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants' import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context' @@ -30,7 +31,7 @@ export class StaticWebController { @Get(`/explory`) @SkipTenantGuard() async getStaticWebIndex(@ContextParam() context: Context) { - if (this.isReservedTenant()) { + if (this.isReservedTenant({ root: true })) { return await this.renderTenantRestrictedPage() } if (this.shouldRenderTenantMissingPage()) { @@ -46,7 +47,7 @@ export class StaticWebController { @Get(`/photos/:photoId`) async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) { - if (this.isReservedTenant()) { + if (this.isReservedTenant({ root: true })) { return await this.renderTenantRestrictedPage() } if (this.shouldRenderTenantMissingPage()) { @@ -60,31 +61,33 @@ export class StaticWebController { } @SkipTenantGuard() + @AllowPlaceholderTenant() @Get(`${STATIC_DASHBOARD_BASENAME}`) @Get(`${STATIC_DASHBOARD_BASENAME}/*`) async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) { const pathname = context.req.path const isHtmlRoute = this.isHtmlRoute(pathname) - const normalizedPath = this.normalizePathname(pathname) + const allowTenantlessAccess = isHtmlRoute && this.shouldAllowTenantlessDashboardAccess(pathname) - const isRestrictedEntry = normalizedPath === TENANT_RESTRICTED_ENTRY_PATH - if (isHtmlRoute && this.isReservedTenant() && !isRestrictedEntry) { - return await this.renderTenantRestrictedPage() - } - if (isHtmlRoute && !allowTenantlessAccess && this.shouldRenderTenantMissingPage()) { - return await this.renderTenantMissingPage() + + const isReservedTenant = this.isReservedTenant({ root: false }) + + if (isHtmlRoute) { + if (isReservedTenant) { + return await this.renderTenantRestrictedPage() + } + if (!allowTenantlessAccess && this.shouldRenderTenantMissingPage()) { + return await this.renderTenantMissingPage() + } } + const response = await this.serve(context, this.staticDashboardService, false) - if (isHtmlRoute && this.isReservedTenant() && response.status === 404) { - return await this.renderTenantRestrictedPage() - } - if (isHtmlRoute && !allowTenantlessAccess && response.status === 404) { - return await this.renderTenantMissingPage() - } + return response } @SkipTenantGuard() + @AllowPlaceholderTenant() @Get('/*') async getAsset(@ContextParam() context: Context) { return await this.handleRequest(context, false) @@ -188,16 +191,32 @@ export class StaticWebController { return trimmed } - private isReservedTenant(): boolean { + private isReservedTenant({ root = false }: { root?: boolean } = {}): boolean { const tenantContext = getTenantContext() - const slug = tenantContext?.tenant.slug?.toLowerCase() - if (!slug) { + if (!tenantContext) { return false } - if (slug === ROOT_TENANT_SLUG) { + + const tenantSlug = tenantContext.tenant.slug?.toLowerCase() ?? null + if (tenantSlug === ROOT_TENANT_SLUG) { + return !!root + } + + const requestedSlug = tenantContext.requestedSlug?.toLowerCase() ?? null + + if (isPlaceholderTenantContext(tenantContext)) { + if (!requestedSlug) { + return false + } + const candidate = requestedSlug ?? tenantSlug + return isTenantSlugReserved(candidate) + } + + if (!tenantSlug) { return false } - return isTenantSlugReserved(slug) + + return isTenantSlugReserved(tenantSlug) } private shouldRenderTenantMissingPage(): boolean { diff --git a/be/apps/core/src/modules/platform/auth/auth-registration.service.ts b/be/apps/core/src/modules/platform/auth/auth-registration.service.ts index a4a1ad9c..9fbc52c7 100644 --- a/be/apps/core/src/modules/platform/auth/auth-registration.service.ts +++ b/be/apps/core/src/modules/platform/auth/auth-registration.service.ts @@ -134,11 +134,6 @@ export class AuthRegistrationService { headers: Headers, tenant: TenantRecord, ): Promise { - headers.set('x-tenant-id', tenant.id) - if (tenant.slug) { - headers.set('x-tenant-slug', tenant.slug) - } - const auth = await this.authProvider.getAuth() const response = await auth.api.signUpEmail({ body: { diff --git a/be/apps/core/src/modules/platform/auth/auth.controller.ts b/be/apps/core/src/modules/platform/auth/auth.controller.ts index a6ac3aa9..676463b3 100644 --- a/be/apps/core/src/modules/platform/auth/auth.controller.ts +++ b/be/apps/core/src/modules/platform/auth/auth.controller.ts @@ -1,3 +1,5 @@ +import { TextDecoder } from 'node:util' + import { authUsers } from '@afilmory/db' import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework' import { freshSessionMiddleware } from 'better-auth/api' @@ -14,6 +16,7 @@ import type { Context } from 'hono' import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants' import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context' import { TenantService } from '../tenant/tenant.service' +import type { TenantRecord } from '../tenant/tenant.types' import type { SocialProvidersConfig } from './auth.config' import { AuthProvider } from './auth.provider' import { AuthRegistrationService } from './auth-registration.service' @@ -146,11 +149,7 @@ export class AuthController { return { user: authContext.user, session: authContext.session, - tenant: { - id: tenantContext.tenant.id, - slug: tenantContext.requestedSlug ?? tenantContext.tenant.slug ?? null, - isPlaceholder: isPlaceholderTenantContext(tenantContext), - }, + tenant: tenantContext, } } @@ -167,7 +166,7 @@ export class AuthController { @Roles(RoleBit.ADMIN) async getSocialAccounts(@ContextParam() context: Context) { const auth = await this.auth.getAuth() - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const accounts = await auth.api.listUserAccounts({ headers }) const { socialProviders } = await this.systemSettings.getAuthModuleConfig() const enabledProviders = new Set(Object.keys(socialProviders)) @@ -191,7 +190,7 @@ export class AuthController { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前未启用该 OAuth Provider' }) } - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const callbackURL = this.normalizeCallbackUrl(body?.callbackURL) const errorCallbackURL = this.normalizeCallbackUrl(body?.errorCallbackURL) @@ -219,7 +218,7 @@ export class AuthController { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' }) } - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const auth = await this.auth.getAuth() const result = await auth.api.unlinkAccount({ headers, @@ -271,7 +270,7 @@ export class AuthController { } const auth = await this.auth.getAuth() - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const response = await auth.api.signInEmail({ body: { email, @@ -291,7 +290,7 @@ export class AuthController { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' }) } - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const tenantContext = getTenantContext() const auth = await this.auth.getAuth() @@ -305,13 +304,6 @@ export class AuthController { asResponse: true, }) - if (tenantContext) { - context.header('x-tenant-id', tenantContext.tenant.id) - if (tenantContext.tenant.slug) { - context.header('x-tenant-slug', tenantContext.tenant.slug) - } - } - return response } @@ -334,7 +326,7 @@ export class AuthController { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前操作不支持使用已登录账号' }) } - const headers = this.buildTenantAwareHeaders(context) + const { headers } = context.req.raw const result = await this.registration.registerTenant( { @@ -360,8 +352,7 @@ export class AuthController { ) if (result.success && result.tenant) { - context.header('x-tenant-id', result.tenant.id) - context.header('x-tenant-slug', result.tenant.slug) + return await this.attachTenantMetadata(result.response, result.tenant) } return result.response @@ -385,19 +376,6 @@ export class AuthController { return await this.auth.handler(context) } - private buildTenantAwareHeaders(context: Context): Headers { - const headers = new Headers(context.req.raw.headers) - const tenantContext = getTenantContext() - if (tenantContext?.tenant?.id) { - headers.set('x-tenant-id', tenantContext.tenant.id) - const effectiveSlug = tenantContext.requestedSlug ?? tenantContext.tenant.slug - if (effectiveSlug) { - headers.set('x-tenant-slug', effectiveSlug) - } - } - return headers - } - private normalizeCallbackUrl(url?: string | null): string | undefined { if (!url) { return undefined @@ -446,4 +424,56 @@ export class AuthController { const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toISOString() } + + private async attachTenantMetadata(source: Response, tenant: TenantRecord): Promise { + const headers = new Headers(source.headers) + headers.delete('content-length') + + let payload: unknown = null + let isJson = false + let text: string | null = null + + try { + const buffer = await source.arrayBuffer() + if (buffer.byteLength > 0) { + text = new TextDecoder().decode(buffer) + } + } catch { + text = null + } + + if (text && text.length > 0) { + try { + payload = JSON.parse(text) + isJson = true + } catch { + payload = text + } + } + + const tenantPayload = { + id: tenant.id, + slug: tenant.slug, + name: tenant.name, + } + + const responseBody = + isJson && payload && typeof payload === 'object' && !Array.isArray(payload) + ? { + ...(payload as Record), + tenant: tenantPayload, + } + : { + tenant: tenantPayload, + data: payload, + } + + headers.set('content-type', 'application/json; charset=utf-8') + + return new Response(JSON.stringify(responseBody), { + status: source.status, + statusText: source.statusText, + headers, + }) + } } diff --git a/be/apps/core/src/modules/platform/auth/auth.provider.ts b/be/apps/core/src/modules/platform/auth/auth.provider.ts index ef59e365..0a400329 100644 --- a/be/apps/core/src/modules/platform/auth/auth.provider.ts +++ b/be/apps/core/src/modules/platform/auth/auth.provider.ts @@ -329,9 +329,9 @@ export class AuthProvider implements OnModuleInit { : (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext) const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug) const protocol = this.determineProtocol(host, endpoint.protocol) - const slugKey = tenantSlug ?? 'global' + const optionSignature = this.computeOptionsSignature(options) - const cacheKey = `${protocol}://${host}::${slugKey}::${optionSignature}` + const cacheKey = `${protocol}://${host}::${tenantSlug}::${optionSignature}` if (!this.instances.has(cacheKey)) { const instancePromise = this.createAuthForEndpoint(tenantSlug, options).then((instance) => { diff --git a/be/apps/core/src/modules/platform/auth/root-account.service.ts b/be/apps/core/src/modules/platform/auth/root-account.service.ts index 8cdccfdb..7ec49d0f 100644 --- a/be/apps/core/src/modules/platform/auth/root-account.service.ts +++ b/be/apps/core/src/modules/platform/auth/root-account.service.ts @@ -93,7 +93,7 @@ export class RootAccountProvisioner { '', '============================================================', 'Root dashboard access provisioned.', - ` Dashboard URL: ${urls.shift()}`, + ` Dashboard URL: ${urls.shift()}/root-login`, ...(urls.length > 0 ? urls.map((url) => ` Alternate URL: ${url}`) : []), ` Email: ${email}`, ` Username: ${username}`, diff --git a/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts b/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts new file mode 100644 index 00000000..ced2803b --- /dev/null +++ b/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto' + +import type { + BuilderConfig, + PhotoManifestItem, + StorageConfig, + StorageManager, + StorageObject, + StorageProvider, +} from '@afilmory/builder' +import type { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' +import type { DataSyncLogPayload } from 'core/modules/infrastructure/data-sync/data-sync.types' + +type BuilderDebugResultType = 'new' | 'processed' | 'skipped' | 'failed' +export type BuilderDebugProgressEvent = + | { + type: 'start' + payload: { + storageKey: string + filename: string + contentType: string | null + size: number + } + } + | { + type: 'log' + payload: DataSyncLogPayload + } + | { + type: 'complete' + payload: { + storageKey: string + resultType: BuilderDebugResultType + manifestItem: PhotoManifestItem | null + thumbnailUrl?: string | null + filesDeleted: boolean + } + } + | { + type: 'error' + payload: { + message: string + } + } +export type UploadedDebugFile = { + name: string + size: number + contentType: string | null + buffer: Buffer +} +export type StorageResolution = { + builder: ReturnType + builderConfig: BuilderConfig + storageConfig: StorageConfig + storageManager: StorageManager +} +export class InMemoryDebugStorageProvider implements StorageProvider { + private readonly files = new Map< + string, + { + buffer: Buffer + metadata: StorageObject + } + >() + + async getFile(key: string): Promise { + return this.files.get(key)?.buffer ?? null + } + + async listImages(): Promise { + return Array.from(this.files.values()).map((entry) => entry.metadata) + } + + async listAllFiles(): Promise { + return this.listImages() + } + + generatePublicUrl(key: string): string { + return `debug://${encodeURIComponent(key)}` + } + + detectLivePhotos(): Map { + return new Map() + } + + async deleteFile(key: string): Promise { + this.files.delete(key) + } + + async uploadFile(key: string, data: Buffer): Promise { + const normalizedKey = this.normalizeKey(key) + const metadata: StorageObject = { + key: normalizedKey, + size: data.length, + lastModified: new Date(), + etag: randomUUID(), + } + + this.files.set(normalizedKey, { + buffer: data, + metadata, + }) + + return metadata + } + + private normalizeKey(key: string): string { + return key.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + } +} diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts b/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts new file mode 100644 index 00000000..cd5e7348 --- /dev/null +++ b/be/apps/core/src/modules/platform/super-admin/super-admin-builder.controller.ts @@ -0,0 +1,280 @@ +import { randomUUID } from 'node:crypto' +import path from 'node:path' + +import type { BuilderConfig, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder' +import { resolveBuilderConfig } from '@afilmory/builder' +import type { ThumbnailPluginData } from '@afilmory/builder/plugins/thumbnail-storage/shared.js' +import { + DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY, + THUMBNAIL_PLUGIN_DATA_KEY, +} from '@afilmory/builder/plugins/thumbnail-storage/shared.js' +import { ContextParam, Controller, createLogger, Post } from '@afilmory/framework' +import { BizException, ErrorCode } from 'core/errors' +import { Roles } from 'core/guards/roles.decorator' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' +import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' +import { runWithBuilderLogRelay } from 'core/modules/infrastructure/data-sync/builder-log-relay' +import type { DataSyncProgressEmitter } from 'core/modules/infrastructure/data-sync/data-sync.types' +import { createProgressSseResponse } from 'core/modules/shared/http/sse' +import type { Context } from 'hono' + +import type { BuilderDebugProgressEvent, StorageResolution, UploadedDebugFile } from './InMemoryDebugStorageProvider' +import { InMemoryDebugStorageProvider } from './InMemoryDebugStorageProvider' + +const DEBUG_STORAGE_PREFIX = '.afilmory/debug' +const DEBUG_STORAGE_PROVIDER = 'super-admin-debug-storage' + +@Controller('super-admin/builder') +@Roles('superadmin') +export class SuperAdminBuilderDebugController { + private readonly logger = createLogger('SuperAdminBuilderDebugController') + + constructor(private readonly photoBuilderService: PhotoBuilderService) {} + + @Post('debug') + @BypassResponseTransform() + async runDebug(@ContextParam() context: Context): Promise { + const payload = await context.req.parseBody() + const file = await this.extractFirstFile(payload) + if (!file) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: '请上传一张待调试的图片文件', + }) + } + + if (file.contentType && !file.contentType.toLowerCase().startsWith('image/')) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: '调试通道仅支持图片文件', + }) + } + + const storage = await this.createDebugStorageContext() + return createProgressSseResponse({ + context, + handler: async ({ sendEvent }) => { + const logEmitter: DataSyncProgressEmitter = async (event) => { + if (event.type !== 'log') { + return + } + sendEvent({ type: 'log', payload: event.payload }) + } + + try { + const result = await this.executeDebugRun({ + ...storage, + file, + sendEvent, + logEmitter, + }) + + sendEvent({ + type: 'complete', + payload: { + storageKey: result.storageKey, + resultType: result.resultType, + manifestItem: result.manifestItem, + thumbnailUrl: result.thumbnailUrl, + filesDeleted: result.filesDeleted, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + this.logger.error('Builder debug run failed', error) + sendEvent({ type: 'error', payload: { message } }) + } + }, + }) + } + + private async createDebugStorageContext(): Promise { + const builderConfig = resolveBuilderConfig({}) + + const storageConfig: StorageConfig = { provider: DEBUG_STORAGE_PROVIDER } + + if (!builderConfig.user || typeof builderConfig.user !== 'object') { + builderConfig.user = {} as NonNullable + } + + builderConfig.user.storage = storageConfig + + const builder = this.photoBuilderService.createBuilder(builderConfig) + builder.registerStorageProvider(DEBUG_STORAGE_PROVIDER, () => new InMemoryDebugStorageProvider()) + this.photoBuilderService.applyStorageConfig(builder, storageConfig) + + return { + builder, + builderConfig, + storageConfig, + storageManager: builder.getStorageManager(), + } + } + + private async executeDebugRun( + params: StorageResolution & { + file: UploadedDebugFile + sendEvent: (event: BuilderDebugProgressEvent) => void + logEmitter: DataSyncProgressEmitter + }, + ) { + const { builder, builderConfig, storageManager, file, sendEvent, logEmitter } = params + const cleanupKeys = new Set() + + const tempKey = this.createTemporaryStorageKey(file.name) + const uploaded = await storageManager.uploadFile(tempKey, file.buffer, { + contentType: file.contentType ?? undefined, + }) + const normalizedObject = this.normalizeStorageObjectKey(uploaded, tempKey) + cleanupKeys.add(normalizedObject.key) + + sendEvent({ + type: 'start', + payload: { + storageKey: normalizedObject.key, + filename: file.name, + contentType: file.contentType, + size: file.size, + }, + }) + + let processed: Awaited> | null = null + let filesDeleted = false + try { + processed = await runWithBuilderLogRelay(logEmitter, () => + this.photoBuilderService.processPhotoFromStorageObject(normalizedObject, { + builder, + builderConfig, + processorOptions: { + isForceMode: true, + isForceManifest: true, + isForceThumbnails: true, + }, + }), + ) + + const thumbnailKey = this.resolveThumbnailStorageKey(processed?.pluginData) + if (thumbnailKey) { + cleanupKeys.add(thumbnailKey) + } + } finally { + filesDeleted = await this.cleanupDebugArtifacts(storageManager, cleanupKeys) + } + + return { + storageKey: normalizedObject.key, + resultType: processed?.type ?? 'failed', + manifestItem: processed?.item ?? null, + thumbnailUrl: processed?.item?.thumbnailUrl ?? null, + filesDeleted, + } + } + + private async extractFirstFile(payload: Record): Promise { + const files: File[] = [] + + for (const value of Object.values(payload)) { + if (value instanceof File) { + files.push(value) + } else if (Array.isArray(value)) { + for (const entry of value) { + if (entry instanceof File) { + files.push(entry) + } + } + } + } + + const file = files[0] + if (!file) { + return null + } + + const buffer = Buffer.from(await file.arrayBuffer()) + return { + name: file.name || 'debug-upload', + size: file.size, + contentType: file.type || null, + buffer, + } + } + + private createTemporaryStorageKey(filename: string): string { + const ext = path.extname(filename) + const safeExt = ext || '.jpg' + const baseName = `${Date.now()}-${randomUUID()}` + return this.joinSegments(DEBUG_STORAGE_PREFIX, `${baseName}${safeExt}`) + } + + private normalizeStorageObjectKey(object: StorageObject, fallbackKey: string): StorageObject { + const normalizedKey = this.normalizeKeyPath(object?.key ?? fallbackKey) + if (normalizedKey === object?.key) { + return object + } + return { + ...object, + key: normalizedKey, + } + } + + private normalizeKeyPath(raw: string | undefined | null): string { + if (!raw) { + return '' + } + const segments = raw.split(/[\\/]+/) + const safeSegments: string[] = [] + for (const segment of segments) { + const trimmed = segment.trim() + if (!trimmed || trimmed === '.' || trimmed === '..') { + continue + } + safeSegments.push(trimmed) + } + return safeSegments.join('/') + } + + private async cleanupDebugArtifacts(storageManager: StorageManager, keys: Set): Promise { + let success = true + for (const key of keys) { + try { + await storageManager.deleteFile(key) + } catch (error) { + success = false + this.logger.warn(`Failed to delete debug artifact ${key}`, error) + } + } + return success + } + + private resolveThumbnailStorageKey(pluginData?: Record): string | null { + if (!pluginData || !pluginData[THUMBNAIL_PLUGIN_DATA_KEY]) { + return null + } + + const data = pluginData[THUMBNAIL_PLUGIN_DATA_KEY] as ThumbnailPluginData + if (!data?.fileName) { + return null + } + + const remotePrefix = this.joinSegments(DEFAULT_THUMBNAIL_DIRECTORY) + + return this.joinSegments(remotePrefix, data.fileName) + } + + private joinSegments(...segments: Array): string { + const parts: string[] = [] + + for (const raw of segments) { + if (!raw) { + continue + } + const normalized = raw + .replaceAll('\\', '/') + .replaceAll(/^\/+|\/+$/g, '') + .trim() + if (normalized.length > 0) { + parts.push(normalized) + } + } + + return parts.join('/') + } +} diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.controller.ts b/be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts similarity index 100% rename from be/apps/core/src/modules/platform/super-admin/super-admin.controller.ts rename to be/apps/core/src/modules/platform/super-admin/super-admin-settings.controller.ts diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.module.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.module.ts index bb3971e5..24dff82c 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin.module.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin.module.ts @@ -1,10 +1,13 @@ import { Module } from '@afilmory/framework' import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module' +import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' -import { SuperAdminSettingController } from './super-admin.controller' +import { SuperAdminBuilderDebugController } from './super-admin-builder.controller' +import { SuperAdminSettingController } from './super-admin-settings.controller' @Module({ imports: [SystemSettingModule], - controllers: [SuperAdminSettingController], + controllers: [SuperAdminSettingController, SuperAdminBuilderDebugController], + providers: [PhotoBuilderService], }) export class SuperAdminModule {} diff --git a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts index 6db244f5..1cd536c3 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts @@ -1,4 +1,3 @@ -import { env } from '@afilmory/env' import { HttpContext } from '@afilmory/framework' import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils' import { BizException, ErrorCode } from 'core/errors' @@ -13,8 +12,6 @@ import { TenantService } from './tenant.service' import type { TenantAggregate, TenantContext } from './tenant.types' import { extractTenantSlugFromHost } from './tenant-host.utils' -const HEADER_TENANT_ID = 'x-tenant-id' -const HEADER_TENANT_SLUG = 'x-tenant-slug' const ROOT_TENANT_PATH_PREFIXES = [ '/api/super-admin', '/api/settings', @@ -24,7 +21,6 @@ const ROOT_TENANT_PATH_PREFIXES = [ export interface TenantResolutionOptions { throwOnMissing?: boolean - setResponseHeaders?: boolean skipInitializationCheck?: boolean } @@ -41,9 +37,6 @@ export class TenantContextResolver { async resolve(context: Context, options: TenantResolutionOptions = {}): Promise { const existing = this.getExistingContext() if (existing) { - if (options.setResponseHeaders !== false) { - this.applyTenantHeaders(context, existing) - } return existing } @@ -62,36 +55,28 @@ export class TenantContextResolver { this.log.debug(`Forwarded host: ${forwardedHost}, Host header: ${hostHeader}, Origin: ${origin}, Host: ${host}`) - const tenantId = this.normalizeString(context.req.header(HEADER_TENANT_ID)) - const tenantSlugHeader = this.normalizeSlug(context.req.header(HEADER_TENANT_SLUG)) - const baseDomain = await this.getBaseDomain() - let derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined - if (!derivedSlug && host && this.isBaseDomainHost(host, baseDomain)) { - derivedSlug = ROOT_TENANT_SLUG - } if (!derivedSlug && this.isRootTenantPath(context.req.path)) { derivedSlug = ROOT_TENANT_SLUG } - const requestedSlug = derivedSlug ?? tenantSlugHeader ?? null - if (!derivedSlug) { - derivedSlug = tenantSlugHeader - } + const requestedSlug = derivedSlug ?? null this.log.verbose( - `Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, id=${tenantId ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`, + `Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`, ) - let tenantContext = await this.tenantService.resolve( - { - tenantId, - slug: derivedSlug, - }, - true, - ) + let tenantContext: TenantContext | null = null + if (derivedSlug) { + tenantContext = await this.tenantService.resolve( + { + slug: derivedSlug, + }, + true, + ) + } - if (!tenantContext && this.shouldFallbackToPlaceholder(tenantId, derivedSlug)) { + if (!tenantContext && this.shouldFallbackToPlaceholder(derivedSlug)) { const placeholder = await this.tenantService.ensurePlaceholderTenant() tenantContext = this.asTenantContext(placeholder, true, requestedSlug) this.log.verbose( @@ -106,67 +91,15 @@ export class TenantContextResolver { } if (!tenantContext) { - if (options.throwOnMissing && (tenantId || derivedSlug)) { + if (options.throwOnMissing && derivedSlug) { throw new BizException(ErrorCode.TENANT_NOT_FOUND) } return null } - if (options.setResponseHeaders !== false) { - this.applyTenantHeaders(context, tenantContext) - } - return tenantContext } - private isBaseDomainHost(host: string, baseDomain: string): boolean { - const parsed = this.parseHost(host) - if (!parsed.hostname) { - return false - } - - const normalizedHost = parsed.hostname.trim().toLowerCase() - const normalizedBase = baseDomain.trim().toLowerCase() - - if (normalizedBase === 'localhost') { - return normalizedHost === 'localhost' && this.matchesServerPort(parsed.port) - } - - return normalizedHost === normalizedBase && this.matchesServerPort(parsed.port) - } - - private parseHost(host: string): { hostname: string | null; port: string | null } { - if (!host) { - return { hostname: null, port: null } - } - - if (host.startsWith('[')) { - // IPv6 literal (e.g. [::1]:3000) - const closingIndex = host.indexOf(']') - if (closingIndex === -1) { - return { hostname: host, port: null } - } - const hostname = host.slice(1, closingIndex) - const portSegment = host.slice(closingIndex + 1) - const port = portSegment.startsWith(':') ? portSegment.slice(1) : null - return { hostname, port: port && port.length > 0 ? port : null } - } - - const [hostname, port] = host.split(':', 2) - return { hostname: hostname ?? null, port: port ?? null } - } - - private matchesServerPort(port: string | null): boolean { - if (!port) { - return true - } - const parsed = Number.parseInt(port, 10) - if (Number.isNaN(parsed)) { - return false - } - return parsed === env.PORT - } - private isRootTenantPath(path: string | undefined): boolean { if (!path) { return false @@ -185,14 +118,6 @@ export class TenantContextResolver { } } - private applyTenantHeaders(context: Context, tenantContext: TenantContext): void { - context.header(HEADER_TENANT_ID, tenantContext.tenant.id) - const effectiveSlug = tenantContext.requestedSlug ?? tenantContext.tenant.slug - if (effectiveSlug) { - context.header(HEADER_TENANT_SLUG, effectiveSlug) - } - } - private async getBaseDomain(): Promise { if (process.env.NODE_ENV === 'development') { return 'localhost' @@ -223,21 +148,8 @@ export class TenantContextResolver { } } - private normalizeString(value: string | null | undefined): string | undefined { - if (!value) { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined - } - - private normalizeSlug(value: string | null | undefined): string | undefined { - const normalized = this.normalizeString(value) - return normalized ? normalized.toLowerCase() : undefined - } - - private shouldFallbackToPlaceholder(tenantId?: string, slug?: string): boolean { - return !(tenantId && tenantId.length > 0) && !(slug && slug.length > 0) + private shouldFallbackToPlaceholder(slug?: string | null): boolean { + return !slug } private asTenantContext( diff --git a/be/apps/core/src/modules/platform/tenant/tenant.types.ts b/be/apps/core/src/modules/platform/tenant/tenant.types.ts index f282bcfb..89702241 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.types.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.types.ts @@ -1,7 +1,6 @@ -import type { tenants, tenantStatusEnum } from '@afilmory/db' +import type { tenants } from '@afilmory/db' export type TenantRecord = typeof tenants.$inferSelect -export type TenantStatus = (typeof tenantStatusEnum.enumValues)[number] export interface TenantAggregate { tenant: TenantRecord @@ -16,8 +15,3 @@ export interface TenantResolutionInput { tenantId?: string | null slug?: string | null } - -export interface TenantCacheEntry { - aggregate: TenantAggregate - cachedAt: number -} diff --git a/be/apps/core/src/modules/shared/http/sse.ts b/be/apps/core/src/modules/shared/http/sse.ts new file mode 100644 index 00000000..cb42b4b2 --- /dev/null +++ b/be/apps/core/src/modules/shared/http/sse.ts @@ -0,0 +1,129 @@ +import type { Context } from 'hono' + +export interface CreateProgressSseResponseOptions { + context: Context + eventName?: string + heartbeatIntervalMs?: number + handler: (helpers: SseHandlerHelpers) => Promise | void +} + +export interface SseHandlerHelpers { + sendEvent: (event: TEvent) => void + sendChunk: (chunk: string) => void + abortSignal: AbortSignal +} + +const DEFAULT_EVENT_NAME = 'progress' +const DEFAULT_HEARTBEAT_MS = 15_000 + +export function createProgressSseResponse({ + context, + eventName = DEFAULT_EVENT_NAME, + heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS, + handler, +}: CreateProgressSseResponseOptions): Response { + const encoder = new TextEncoder() + let cleanup: (() => void) | undefined + + const stream = new ReadableStream({ + start: (controller) => { + let closed = false + const rawRequest = context.req.raw + const abortSignal = rawRequest.signal + + let heartbeat: ReturnType | undefined + let abortHandler: (() => void) | undefined + + const cleanupInternal = () => { + if (heartbeat) { + clearInterval(heartbeat) + heartbeat = undefined + } + + if (abortHandler) { + abortSignal.removeEventListener('abort', abortHandler) + abortHandler = undefined + } + + if (!closed) { + closed = true + try { + controller.close() + } catch { + /* ignore */ + } + } + } + + const sendChunk = (chunk: string) => { + if (closed) { + return + } + + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + cleanupInternal() + cleanup = undefined + } + } + + const sendEvent = (event: TEvent) => { + sendChunk(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`) + } + + heartbeat = setInterval(() => { + sendChunk(`: keep-alive ${new Date().toISOString()}\n\n`) + }, heartbeatIntervalMs) + + abortHandler = () => { + const currentCleanup = cleanup + cleanup = undefined + currentCleanup?.() + } + + abortSignal.addEventListener('abort', abortHandler) + + cleanup = () => { + cleanupInternal() + cleanup = undefined + } + + sendChunk(': connected\n\n') + ;(async () => { + try { + await handler({ + sendEvent, + sendChunk, + abortSignal, + }) + } catch (error) { + console.error('SSE handler failed', error) + } finally { + const currentCleanup = cleanup + cleanup = undefined + currentCleanup?.() + } + })().catch((error) => { + console.error('Unhandled SSE handler error', error) + const currentCleanup = cleanup + cleanup = undefined + currentCleanup?.() + }) + }, + cancel() { + const currentCleanup = cleanup + cleanup = undefined + currentCleanup?.() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/be/apps/dashboard/src/components/common/SuperAdminUserMenu.tsx b/be/apps/dashboard/src/components/common/SuperAdminUserMenu.tsx new file mode 100644 index 00000000..158fd16a --- /dev/null +++ b/be/apps/dashboard/src/components/common/SuperAdminUserMenu.tsx @@ -0,0 +1,97 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' +import { ChevronDown, LogOut } from 'lucide-react' +import { useState } from 'react' + +import { usePageRedirect } from '~/hooks/usePageRedirect' +import type { BetterAuthUser } from '~/modules/auth/types' + +interface SuperAdminUserMenuProps { + user: BetterAuthUser +} + +export function SuperAdminUserMenu({ user }: SuperAdminUserMenuProps) { + const { logout } = usePageRedirect() + const [isLoggingOut, setIsLoggingOut] = useState(false) + const [isOpen, setIsOpen] = useState(false) + + const handleLogout = async () => { + if (isLoggingOut) return + + setIsLoggingOut(true) + try { + await logout() + } catch (error) { + console.error('Logout failed:', error) + setIsLoggingOut(false) + } + } + + return ( + + + + + + + +
+

{user.name || 'Super Admin'}

+

{user.email}

+
+
+ + + + }> + {isLoggingOut ? 'Logging out...' : 'Log out'} + +
+
+ ) +} diff --git a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts index f544901f..4d9f40c9 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useRegisterTenant.ts @@ -33,9 +33,17 @@ export function useRegisterTenant() { const response = await registerTenant(payload) - const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null - const submittedSlug = payload.tenant.slug?.trim().toLowerCase() ?? '' - const finalSlug = headerSlug && headerSlug.length > 0 ? headerSlug : submittedSlug + let finalSlug = payload.tenant.slug?.trim() ?? '' + + try { + const data = (await response.clone().json()) as { tenant?: { slug?: string } } | null + const slugFromResponse = data?.tenant?.slug?.trim() + if (slugFromResponse) { + finalSlug = slugFromResponse + } + } catch { + // ignore parse errors; fall back to submitted slug + } if (!finalSlug) { throw new Error('Registration succeeded but the workspace slug could not be determined.') diff --git a/be/apps/dashboard/src/modules/super-admin/api.ts b/be/apps/dashboard/src/modules/super-admin/api.ts index 0c82f178..50b353eb 100644 --- a/be/apps/dashboard/src/modules/super-admin/api.ts +++ b/be/apps/dashboard/src/modules/super-admin/api.ts @@ -1,8 +1,20 @@ -import { coreApi } from '~/lib/api-client' +import { coreApi, coreApiBaseURL } from '~/lib/api-client' +import { camelCaseKeys } from '~/lib/case' -import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from './types' +import type { + BuilderDebugProgressEvent, + BuilderDebugResult, + SuperAdminSettingsResponse, + UpdateSuperAdminSettingsPayload, +} from './types' const SUPER_ADMIN_SETTINGS_ENDPOINT = '/super-admin/settings' +const STABLE_NEWLINE = /\r?\n/ + +type RunBuilderDebugOptions = { + signal?: AbortSignal + onEvent?: (event: BuilderDebugProgressEvent) => void +} export async function fetchSuperAdminSettings() { return await coreApi(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, { @@ -19,3 +31,114 @@ export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettings body, }) } + +export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugOptions): Promise { + const formData = new FormData() + formData.append('file', file) + + const response = await fetch(`${coreApiBaseURL}/super-admin/builder/debug`, { + method: 'POST', + headers: { + accept: 'text/event-stream', + }, + credentials: 'include', + body: formData, + signal: options?.signal, + }) + + if (!response.ok || !response.body) { + throw new Error(`调试请求失败:${response.status} ${response.statusText}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let finalResult: BuilderDebugResult | null = null + let lastErrorMessage: string | null = null + + const stageEvent = (rawEvent: string) => { + const lines = rawEvent.split(STABLE_NEWLINE) + let eventName: string | null = null + const dataLines: string[] = [] + + for (const line of lines) { + if (!line) { + continue + } + + if (line.startsWith(':')) { + continue + } + + if (line.startsWith('event:')) { + eventName = line.slice(6).trim() + continue + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()) + } + } + + if (!eventName || dataLines.length === 0) { + return + } + + if (eventName !== 'progress') { + return + } + + const data = dataLines.join('\n') + + try { + const parsed = camelCaseKeys(JSON.parse(data)) + options?.onEvent?.(parsed) + + if (parsed.type === 'complete') { + finalResult = parsed.payload + } + + if (parsed.type === 'error') { + lastErrorMessage = parsed.payload.message + } + } catch (error) { + console.error('Failed to parse builder debug event', error) + } + } + + try { + while (true) { + const { value, done } = await reader.read() + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + + let boundary = buffer.indexOf('\n\n') + while (boundary !== -1) { + const rawEvent = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + stageEvent(rawEvent) + boundary = buffer.indexOf('\n\n') + } + } + + if (buffer.trim().length > 0) { + stageEvent(buffer) + buffer = '' + } + } finally { + reader.releaseLock() + } + + if (lastErrorMessage) { + throw new Error(lastErrorMessage) + } + + if (!finalResult) { + throw new Error('调试过程中未收到最终结果,连接已终止。') + } + + return camelCaseKeys(finalResult) +} diff --git a/be/apps/dashboard/src/modules/super-admin/types.ts b/be/apps/dashboard/src/modules/super-admin/types.ts index 1975a658..355005fd 100644 --- a/be/apps/dashboard/src/modules/super-admin/types.ts +++ b/be/apps/dashboard/src/modules/super-admin/types.ts @@ -1,3 +1,6 @@ +import type { PhotoManifestItem } from '@afilmory/builder' + +import type { PhotoSyncLogLevel } from '../photos/types' import type { SchemaFormValue, UiSchema } from '../schema-form/types' export type SuperAdminSettingField = string @@ -27,3 +30,41 @@ export type SuperAdminSettingsResponse = export type UpdateSuperAdminSettingsPayload = Partial< Record > + +export type BuilderDebugProgressEvent = + | { + type: 'start' + payload: { + storageKey: string + filename: string + contentType: string | null + size: number + } + } + | { + type: 'log' + payload: { + level: PhotoSyncLogLevel + message: string + timestamp: string + details?: Record | null + } + } + | { + type: 'complete' + payload: BuilderDebugResult + } + | { + type: 'error' + payload: { + message: string + } + } + +export interface BuilderDebugResult { + storageKey: string + resultType: 'new' | 'processed' | 'skipped' | 'failed' + manifestItem: PhotoManifestItem | null + thumbnailUrl: string | null + filesDeleted: boolean +} diff --git a/be/apps/dashboard/src/pages/superadmin/debug.tsx b/be/apps/dashboard/src/pages/superadmin/debug.tsx new file mode 100644 index 00000000..1d89f569 --- /dev/null +++ b/be/apps/dashboard/src/pages/superadmin/debug.tsx @@ -0,0 +1,512 @@ +import { Button } from '@afilmory/ui' +import { clsxm, Spring } from '@afilmory/utils' +import { Copy, Play, Square, Upload } from 'lucide-react' +import { m } from 'motion/react' +import { nanoid } from 'nanoid' +import type { ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { getRequestErrorMessage } from '~/lib/errors' +import type { PhotoSyncLogLevel } from '~/modules/photos/types' +import type { BuilderDebugProgressEvent, BuilderDebugResult } from '~/modules/super-admin' +import { runBuilderDebugTest } from '~/modules/super-admin' + +const MAX_LOG_ENTRIES = 300 + +const LEVEL_THEME: Record = { + info: 'border-sky-500/30 bg-sky-500/10 text-sky-100', + success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100', + warn: 'border-amber-500/30 bg-amber-500/10 text-amber-100', + error: 'border-rose-500/30 bg-rose-500/10 text-rose-100', +} + +const STATUS_LABEL: Record = { + idle: { label: '就绪', className: 'text-text-tertiary' }, + running: { label: '调试中', className: 'text-accent' }, + success: { label: '已完成', className: 'text-emerald-400' }, + error: { label: '失败', className: 'text-rose-400' }, +} + +type RunStatus = 'idle' | 'running' | 'success' | 'error' +type DebugStartPayload = Extract['payload'] + +type DebugLogEntry = + | { + id: string + type: 'start' | 'complete' | 'error' + message: string + timestamp: number + } + | { + id: string + type: 'log' + level: PhotoSyncLogLevel + message: string + timestamp: number + } + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +function formatTime(timestamp: number): string { + try { + return timeFormatter.format(timestamp) + } catch { + return '--:--:--' + } +} + +function formatBytes(bytes: number | undefined | null): string { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) { + return '0 B' + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / 1024 ** exponent + return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}` +} + +export function Component() { + return ( + +
+

Builder 调试工具

+

+ 该工具用于单张图片的 Builder 管线验收。调试过程中不会写入数据库,所有上传与生成的文件会在任务完成后立刻清理。 +

+
+ + +
+ ) +} + +function BuilderDebugConsole() { + const [selectedFile, setSelectedFile] = useState(null) + const [runStatus, setRunStatus] = useState('idle') + const [errorMessage, setErrorMessage] = useState(null) + const [logEntries, setLogEntries] = useState([]) + const [result, setResult] = useState(null) + const [runMeta, setRunMeta] = useState(null) + const abortControllerRef = useRef(null) + const logViewportRef = useRef(null) + + const isRunning = runStatus === 'running' + const manifestJson = useMemo( + () => (result?.manifestItem ? JSON.stringify(result.manifestItem, null, 2) : null), + [result], + ) + + useEffect(() => { + return () => { + abortControllerRef.current?.abort() + } + }, []) + + useEffect(() => { + if (!logViewportRef.current) { + return + } + logViewportRef.current.scrollTop = logViewportRef.current.scrollHeight + }, [logEntries]) + + const appendLogEntry = useCallback((event: BuilderDebugProgressEvent) => { + setLogEntries((prev) => { + const entry = buildLogEntry(event) + if (!entry) { + return prev + } + const next = [...prev, entry] + if (next.length > MAX_LOG_ENTRIES) { + return next.slice(-MAX_LOG_ENTRIES) + } + return next + }) + }, []) + + const handleProgressEvent = useCallback( + (event: BuilderDebugProgressEvent) => { + appendLogEntry(event) + + if (event.type === 'start') { + setRunMeta(event.payload) + setErrorMessage(null) + } + + if (event.type === 'complete') { + setResult(event.payload) + setRunStatus('success') + } + + if (event.type === 'error') { + setErrorMessage(event.payload.message) + setRunStatus('error') + } + }, + [appendLogEntry], + ) + + const handleFileChange: React.ChangeEventHandler = (event) => { + const file = event.target.files?.[0] ?? null + event.target.value = '' + setSelectedFile(file) + setResult(null) + setErrorMessage(null) + setRunMeta(null) + setLogEntries([]) + setRunStatus('idle') + } + + const handleClearFile = () => { + setSelectedFile(null) + setResult(null) + setRunMeta(null) + setLogEntries([]) + setRunStatus('idle') + setErrorMessage(null) + } + + const handleStart = useCallback(async () => { + if (!selectedFile) { + toast.info('请选择需要调试的图片文件') + return + } + + abortControllerRef.current?.abort() + const controller = new AbortController() + abortControllerRef.current = controller + + setRunStatus('running') + setErrorMessage(null) + setResult(null) + setLogEntries([]) + setRunMeta(null) + + try { + const debugResult = await runBuilderDebugTest(selectedFile, { + signal: controller.signal, + onEvent: handleProgressEvent, + }) + + setResult(debugResult) + setRunStatus('success') + toast.success('调试完成', { description: 'Builder 管线执行成功,产物已清理。' }) + } catch (error) { + if (controller.signal.aborted) { + toast.info('调试已取消') + setRunStatus('idle') + } else { + const message = getRequestErrorMessage(error, '调试失败,请检查后重试。') + setErrorMessage(message) + setRunStatus('error') + toast.error('调试失败', { description: message }) + } + } finally { + abortControllerRef.current = null + } + }, [handleProgressEvent, selectedFile]) + + const handleCancel = () => { + if (!isRunning) { + return + } + abortControllerRef.current?.abort() + abortControllerRef.current = null + setRunStatus('idle') + setErrorMessage('调试已被手动取消。') + setLogEntries((prev) => [ + ...prev, + { + id: nanoid(), + type: 'error', + message: '手动取消调试任务', + timestamp: Date.now(), + }, + ]) + toast.info('调试已取消') + } + + const handleCopyManifest = async () => { + if (!manifestJson) { + return + } + + try { + await navigator.clipboard.writeText(manifestJson) + toast.success('已复制 manifest 数据') + } catch (error) { + toast.error('复制失败', { + description: getRequestErrorMessage(error, '请手动复制内容'), + }) + } + } + + return ( +
+
+ +
+
+
+
+

调试输入

+

选择一张原始图片,系统将模拟 Builder 处理链路。

+
+ +
+ + + + + {selectedFile ? ( +
+
+

{selectedFile.name}

+

+ {formatBytes(selectedFile.size)} · {selectedFile.type || 'unknown'} +

+
+ +
+ ) : null} +
+ +
+
+ + {isRunning ? ( + + ) : null} +
+

+ 执行期间请保持页面开启。调试依赖与 Data Sync 相同的 builder 配置,并实时返回日志。 +

+ {errorMessage ?

{errorMessage}

: null} +
+ + {runMeta ? ( +
+

最近一次任务

+
+ {runMeta.filename} + {formatBytes(runMeta.size)} + {runMeta.storageKey} +
+
+ ) : null} + +
+

⚠️ 调试以安全模式运行:

+
    +
  • 不写入照片资产数据库记录
  • +
  • 不在存储中保留任何调试产物
  • +
  • 所有日志均实时输出,供排查使用
  • +
+
+
+
+ + +
+
+

实时日志

+

最新 {logEntries.length} 条消息

+
+ 来源:Builder + Data Sync Relay +
+ +
+ {logEntries.length === 0 ? ( +
+ {isRunning ? '正在初始化调试环境...' : '尚无日志'} +
+ ) : ( +
    + {logEntries.map((entry) => ( +
  • + {formatTime(entry.timestamp)} + +
  • + ))} +
+ )} +
+
+
+ + +
+
+

调试输出

+

展示 Builder 返回的 manifest 摘要

+
+ +
+ + {result ? ( +
+
+ + + + +
+ + {manifestJson ? ( +
+                {manifestJson}
+              
+ ) : ( +

当前任务未生成 manifest 数据。

+ )} +
+ ) : ( +
运行调试后,这里会显示 manifest 内容与概要。
+ )} +
+
+ ) +} + +function SummaryTile({ label, value, isMono }: { label: string; value: string; isMono?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function StatusBadge({ status }: { status: RunStatus }) { + const config = STATUS_LABEL[status] + return ( +
+ + + + {config.label} +
+ ) +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function LogPill({ entry }: { entry: DebugLogEntry }) { + if (entry.type === 'log') { + return ( +
+

{entry.level}

+

{entry.message}

+
+ ) + } + + const tone = + entry.type === 'error' + ? 'border border-rose-500/40 bg-rose-500/10 text-rose-100' + : entry.type === 'start' + ? 'bg-accent/10 text-accent' + : 'bg-emerald-500/10 text-emerald-100' + const label = entry.type === 'start' ? 'START' : entry.type === 'complete' ? 'COMPLETE' : 'ERROR' + return ( +
+

{label}

+

{entry.message}

+
+ ) +} + +function buildLogEntry(event: BuilderDebugProgressEvent): DebugLogEntry | null { + const id = nanoid() + const timestamp = Date.now() + + switch (event.type) { + case 'start': { + return { + id, + type: 'start', + message: `上传 ${event.payload.filename},准备执行 Builder`, + timestamp, + } + } + case 'complete': { + return { + id, + type: 'complete', + message: `构建完成 · 结果 ${event.payload.resultType}`, + timestamp, + } + } + case 'error': { + return { + id, + type: 'error', + message: event.payload.message, + timestamp, + } + } + case 'log': { + return { + id, + type: 'log', + level: event.payload.level, + message: event.payload.message, + timestamp: Date.parse(event.payload.timestamp) || timestamp, + } + } + default: { + return null + } + } +} diff --git a/be/apps/dashboard/src/pages/superadmin/layout.tsx b/be/apps/dashboard/src/pages/superadmin/layout.tsx index 8954b1af..41d0106a 100644 --- a/be/apps/dashboard/src/pages/superadmin/layout.tsx +++ b/be/apps/dashboard/src/pages/superadmin/layout.tsx @@ -1,100 +1,48 @@ -import { Button, ScrollArea } from '@afilmory/ui' -import { Spring } from '@afilmory/utils' -import { m } from 'motion/react' -import { useState } from 'react' +import { ScrollArea } from '@afilmory/ui' import { Navigate, NavLink, Outlet } from 'react-router' import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth' -import { usePageRedirect } from '~/hooks/usePageRedirect' - -const navigationTabs = [{ label: 'System Settings', path: '/superadmin/settings' }] as const +import { SuperAdminUserMenu } from '~/components/common/SuperAdminUserMenu' export function Component() { - const { logout } = usePageRedirect() const user = useAuthUserValue() const isSuperAdmin = useIsSuperAdmin() - const [isLoggingOut, setIsLoggingOut] = useState(false) + const navItems = [ + { to: '/superadmin/settings', label: '系统设置', end: true }, + { to: '/superadmin/debug', label: 'Builder 调试', end: false }, + ] as const if (user && !isSuperAdmin) { return } - const handleLogout = async () => { - if (isLoggingOut) { - return - } - - setIsLoggingOut(true) - try { - await logout() - } catch (error) { - console.error('Logout failed:', error) - setIsLoggingOut(false) - } - } - return (
-