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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-13 23:43:22 +08:00
parent 936666d8a2
commit 6a4f868a5a
29 changed files with 1562 additions and 382 deletions

View File

@@ -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

5
be/apps/core/afilmory-builder.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '@afilmory/builder/storage/interfaces.js' {
interface CustomStorageConfig {
provider: 'super-admin-debug-storage'
}
}

View File

@@ -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}`,
})
}
}

View File

@@ -8,7 +8,6 @@ import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
throwOnMissing: true,
setResponseHeaders: true,
skipInitializationCheck: false,
}

View File

@@ -131,7 +131,6 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
}
const tenantContext = await this.tenantContextResolver.resolve(context, {
setResponseHeaders: false,
skipInitializationCheck: true,
})

View File

@@ -31,7 +31,6 @@ export class RequestContextMiddleware implements HttpMiddleware {
try {
const tenantContext = await this.tenantContextResolver.resolve(context, {
setResponseHeaders: false,
throwOnMissing: false,
skipInitializationCheck: true,
})

View File

@@ -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,80 +24,13 @@ export class DataSyncController {
@Post('run')
async run(@Body() body: RunDataSyncDto, @ContextParam() context: Context): Promise<Response> {
const payload = body as unknown as RunDataSyncInput
const encoder = new TextEncoder()
let cleanup: (() => void) | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
let closed = false
const rawRequest = context.req.raw
const abortSignal = rawRequest.signal
let heartbeat: ReturnType<typeof setInterval> | 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<DataSyncProgressEvent>({
context,
handler: async ({ sendEvent }) => {
const progressHandler: DataSyncProgressEmitter = async (event) => {
sendEvent(event)
}
;(async () => {
try {
await runWithBuilderLogRelay(progressHandler, () =>
this.dataSyncService.runSync(
@@ -113,32 +47,7 @@ export class DataSyncController {
this.logger.error('Failed to run data sync', error)
sendEvent({ type: 'error', payload: { message } })
} finally {
const currentCleanup = cleanup
cleanup = undefined
currentCleanup?.()
}
})().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',
},
})
}

View File

@@ -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) {
const isReservedTenant = this.isReservedTenant({ root: false })
if (isHtmlRoute) {
if (isReservedTenant) {
return await this.renderTenantRestrictedPage()
}
if (isHtmlRoute && !allowTenantlessAccess && this.shouldRenderTenantMissingPage()) {
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
}
return isTenantSlugReserved(slug)
const candidate = requestedSlug ?? tenantSlug
return isTenantSlugReserved(candidate)
}
if (!tenantSlug) {
return false
}
return isTenantSlugReserved(tenantSlug)
}
private shouldRenderTenantMissingPage(): boolean {

View File

@@ -134,11 +134,6 @@ export class AuthRegistrationService {
headers: Headers,
tenant: TenantRecord,
): Promise<RegisterTenantResult> {
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: {

View File

@@ -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<Response> {
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<string, unknown>),
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,
})
}
}

View File

@@ -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) => {

View File

@@ -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}`,

View File

@@ -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<PhotoBuilderService['createBuilder']>
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<Buffer | null> {
return this.files.get(key)?.buffer ?? null
}
async listImages(): Promise<StorageObject[]> {
return Array.from(this.files.values()).map((entry) => entry.metadata)
}
async listAllFiles(): Promise<StorageObject[]> {
return this.listImages()
}
generatePublicUrl(key: string): string {
return `debug://${encodeURIComponent(key)}`
}
detectLivePhotos(): Map<string, StorageObject> {
return new Map()
}
async deleteFile(key: string): Promise<void> {
this.files.delete(key)
}
async uploadFile(key: string, data: Buffer): Promise<StorageObject> {
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, '')
}
}

View File

@@ -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<Response> {
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<BuilderDebugProgressEvent>({
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<StorageResolution> {
const builderConfig = resolveBuilderConfig({})
const storageConfig: StorageConfig = { provider: DEBUG_STORAGE_PROVIDER }
if (!builderConfig.user || typeof builderConfig.user !== 'object') {
builderConfig.user = {} as NonNullable<BuilderConfig['user']>
}
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<string>()
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<ReturnType<typeof this.photoBuilderService.processPhotoFromStorageObject>> | 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<string, unknown>): Promise<UploadedDebugFile | null> {
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<string>): Promise<boolean> {
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, unknown>): 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 | null | undefined>): 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('/')
}
}

View File

@@ -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 {}

View File

@@ -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<TenantContext | null> {
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(
let tenantContext: TenantContext | null = null
if (derivedSlug) {
tenantContext = await this.tenantService.resolve(
{
tenantId,
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<string> {
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(

View File

@@ -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
}

View File

@@ -0,0 +1,129 @@
import type { Context } from 'hono'
export interface CreateProgressSseResponseOptions<TEvent> {
context: Context
eventName?: string
heartbeatIntervalMs?: number
handler: (helpers: SseHandlerHelpers<TEvent>) => Promise<void> | void
}
export interface SseHandlerHelpers<TEvent> {
sendEvent: (event: TEvent) => void
sendChunk: (chunk: string) => void
abortSignal: AbortSignal
}
const DEFAULT_EVENT_NAME = 'progress'
const DEFAULT_HEARTBEAT_MS = 15_000
export function createProgressSseResponse<TEvent>({
context,
eventName = DEFAULT_EVENT_NAME,
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
handler,
}: CreateProgressSseResponseOptions<TEvent>): Response {
const encoder = new TextEncoder()
let cleanup: (() => void) | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
let closed = false
const rawRequest = context.req.raw
const abortSignal = rawRequest.signal
let heartbeat: ReturnType<typeof setInterval> | 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',
},
})
}

View File

@@ -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 (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={clsxm(
'flex items-center gap-2.5 rounded-lg px-2 py-1.5',
'transition-all duration-200',
'hover:bg-fill/50',
'active:scale-[0.98]',
'focus:outline-none focus:ring-2 focus:ring-accent/40',
)}
>
{/* User Avatar */}
<div className="relative">
{user.image ? (
<img src={user.image} alt={user.name || user.email} className="size-7 rounded-full object-cover" />
) : (
<div className="bg-accent/20 text-accent flex size-7 items-center justify-center rounded-full text-xs font-medium">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
{/* User Info */}
<div className="text-left">
<div className="text-text text-[13px] leading-tight font-medium">{user.name || user.email}</div>
<div className="text-text-tertiary text-[11px] leading-tight capitalize">{user.role}</div>
</div>
{/* Chevron Icon */}
<ChevronDown
className={clsxm('text-text-tertiary size-3.5 transition-transform duration-200', isOpen && 'rotate-180')}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-fill-tertiary bg-background w-56 shadow-lg"
style={{
backgroundImage: 'none',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
}}
>
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-text text-sm font-medium">{user.name || 'Super Admin'}</p>
<p className="text-text-tertiary text-xs">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} icon={<LogOut className="text-red size-4" />}>
<span className="text-red">{isLoggingOut ? 'Logging out...' : 'Log out'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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.')

View File

@@ -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<SuperAdminSettingsResponse>(`${SUPER_ADMIN_SETTINGS_ENDPOINT}`, {
@@ -19,3 +31,114 @@ export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettings
body,
})
}
export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugOptions): Promise<BuilderDebugResult> {
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<BuilderDebugProgressEvent>(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<BuilderDebugResult>(finalResult)
}

View File

@@ -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<SuperAdminSettingField, SchemaFormValue | null | undefined>
>
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<string, unknown> | 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
}

View File

@@ -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<PhotoSyncLogLevel, string> = {
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<RunStatus, { label: string; className: string }> = {
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<BuilderDebugProgressEvent, { type: 'start' }>['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 (
<m.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<header className="space-y-2">
<h1 className="text-text text-2xl font-semibold">Builder </h1>
<p className="text-text-tertiary text-sm">
Builder 线
</p>
</header>
<BuilderDebugConsole />
</m.div>
)
}
function BuilderDebugConsole() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [runStatus, setRunStatus] = useState<RunStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [logEntries, setLogEntries] = useState<DebugLogEntry[]>([])
const [result, setResult] = useState<BuilderDebugResult | null>(null)
const [runMeta, setRunMeta] = useState<DebugStartPayload | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const logViewportRef = useRef<HTMLDivElement>(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<HTMLInputElement> = (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 (
<div className="space-y-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,360px)_1fr]">
<LinearBorderPanel className="bg-background-tertiary/70 relative overflow-hidden rounded-xl p-6">
<div className="space-y-5">
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-text text-base font-semibold"></p>
<p className="text-text-tertiary text-xs"> Builder </p>
</div>
<StatusBadge status={runStatus} />
</div>
<label
htmlFor="builder-debug-file"
className={clsxm(
'border-border/30 bg-fill/10 flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed px-4 py-8 text-center transition hover:border-accent/40 hover:bg-accent/5',
isRunning && 'pointer-events-none opacity-60',
)}
>
<Upload className="mb-3 h-6 w-6 text-text" />
<p className="text-text text-sm font-medium">
{selectedFile ? selectedFile.name : '点击或拖拽图片到此区域'}
</p>
<p className="text-text-tertiary mt-1 text-xs"> 25 MB</p>
</label>
<input
id="builder-debug-file"
type="file"
accept="image/*"
className="sr-only"
onChange={handleFileChange}
disabled={isRunning}
/>
{selectedFile ? (
<div className="text-text-secondary flex items-center justify-between rounded-lg bg-background-secondary/80 px-3 py-2 text-xs">
<div>
<p className="text-text text-sm font-medium">{selectedFile.name}</p>
<p className="text-text-tertiary text-xs mt-0.5">
{formatBytes(selectedFile.size)} · {selectedFile.type || 'unknown'}
</p>
</div>
<Button type="button" variant="ghost" size="xs" onClick={handleClearFile} disabled={isRunning}>
</Button>
</div>
) : null}
</section>
<section className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleStart} disabled={!selectedFile || isRunning}>
<Play className="mr-2 h-4 w-4" />
</Button>
{isRunning ? (
<Button type="button" variant="ghost" onClick={handleCancel}>
<Square className="mr-2 h-4 w-4" />
</Button>
) : null}
</div>
<p className="text-text-tertiary text-xs">
Data Sync builder
</p>
{errorMessage ? <p className="text-rose-400 text-xs">{errorMessage}</p> : null}
</section>
{runMeta ? (
<section className="space-y-2 rounded-lg bg-background-secondary/70 px-4 py-3 text-xs">
<p className="text-text text-sm font-semibold"></p>
<div className="space-y-1">
<DetailRow label="文件">{runMeta.filename}</DetailRow>
<DetailRow label="大小">{formatBytes(runMeta.size)}</DetailRow>
<DetailRow label="Storage Key">{runMeta.storageKey}</DetailRow>
</div>
</section>
) : null}
<section className="rounded-lg bg-fill/10 px-3 py-2 text-[11px] leading-5 text-text-tertiary">
<p> </p>
<ul className="mt-1 list-disc pl-4">
<li></li>
<li></li>
<li>使</li>
</ul>
</section>
</div>
</LinearBorderPanel>
<LinearBorderPanel className="bg-background-tertiary/70 relative flex min-h-[420px] flex-col rounded-xl p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-text text-base font-semibold"></p>
<p className="text-text-tertiary text-xs"> {logEntries.length} </p>
</div>
<span className="text-text-tertiary text-xs">Builder + Data Sync Relay</span>
</div>
<div
ref={logViewportRef}
className="border-border/20 bg-background-secondary/40 mt-4 flex-1 overflow-y-auto rounded-xl border p-3"
>
{logEntries.length === 0 ? (
<div className="text-text-tertiary flex h-full items-center justify-center text-sm">
{isRunning ? '正在初始化调试环境...' : '尚无日志'}
</div>
) : (
<ul className="space-y-2 text-xs">
{logEntries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3">
<span className="text-text-tertiary w-14 shrink-0 font-mono">{formatTime(entry.timestamp)}</span>
<LogPill entry={entry} />
</li>
))}
</ul>
)}
</div>
</LinearBorderPanel>
</div>
<LinearBorderPanel className="bg-background-tertiary/70 rounded-xl p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-text text-base font-semibold"></p>
<p className="text-text-tertiary text-xs"> Builder manifest </p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={handleCopyManifest} disabled={!manifestJson}>
<Copy className="mr-2 h-4 w-4" />
manifest
</Button>
</div>
{result ? (
<div className="mt-4 space-y-4">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
<SummaryTile label="Result Type" value={result.resultType.toUpperCase()} />
<SummaryTile label="Storage Key" value={result.storageKey} isMono />
<SummaryTile label="缩略图 URL" value={result.thumbnailUrl || '未生成'} isMono />
<SummaryTile label="产物已清理" value={result.filesDeleted ? 'Yes' : 'No'} />
</div>
{manifestJson ? (
<pre className="border-border/30 bg-background-secondary/70 relative max-h-[360px] overflow-auto rounded-xl border p-4 text-xs text-text">
{manifestJson}
</pre>
) : (
<p className="text-text-tertiary text-sm"> manifest </p>
)}
</div>
) : (
<div className="text-text-tertiary mt-4 text-sm"> manifest </div>
)}
</LinearBorderPanel>
</div>
)
}
function SummaryTile({ label, value, isMono }: { label: string; value: string; isMono?: boolean }) {
return (
<div className="border-border/30 bg-background-secondary/60 rounded-lg border px-3 py-2 text-xs">
<p className="text-text-tertiary uppercase tracking-wide">{label}</p>
<p className={clsxm('text-text mt-1 text-sm wrap-break-word', isMono && 'font-mono text-[11px]')}>{value}</p>
</div>
)
}
function StatusBadge({ status }: { status: RunStatus }) {
const config = STATUS_LABEL[status]
return (
<div className="flex items-center gap-1 text-xs font-medium">
<span className={clsxm('relative inline-flex h-2.5 w-2.5 items-center justify-center', config.className)}>
<span className="bg-current inline-flex h-1.5 w-1.5 rounded-full" />
</span>
<span className={config.className}>{config.label}</span>
</div>
)
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="text-text-tertiary flex gap-2">
<span className="min-w-[72px] text-right text-[11px] uppercase tracking-wide">{label}</span>
<span className="text-text text-xs font-mono">{children}</span>
</div>
)
}
function LogPill({ entry }: { entry: DebugLogEntry }) {
if (entry.type === 'log') {
return (
<div className={clsxm('min-w-0 flex-1 rounded-lg border px-3 py-2 text-xs', LEVEL_THEME[entry.level])}>
<p className="font-semibold uppercase tracking-wide text-[10px]">{entry.level}</p>
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
</div>
)
}
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 (
<div className={clsxm('min-w-0 flex-1 rounded-lg px-3 py-2 text-xs', tone)}>
<p className="font-semibold uppercase tracking-wide text-[10px]">{label}</p>
<p className="mt-0.5 wrap-break-word text-[11px]">{entry.message}</p>
</div>
)
}
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
}
}
}

View File

@@ -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 <Navigate to="/" replace />
}
const handleLogout = async () => {
if (isLoggingOut) {
return
}
setIsLoggingOut(true)
try {
await logout()
} catch (error) {
console.error('Logout failed:', error)
setIsLoggingOut(false)
}
}
return (
<div className="flex h-screen flex-col">
<nav className="border-border/50 bg-background-tertiary shrink-0 border-b px-6 py-3">
<div className="flex items-center gap-6">
<nav className="bg-background-tertiary relative shrink-0 px-6 py-3">
{/* Bottom border with gradient */}
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
<div className="flex items-center justify-between gap-6">
{/* Logo/Brand */}
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
<div className="flex flex-1 items-center gap-1">
{navigationTabs.map((tab) => (
<NavLink key={tab.path} to={tab.path} end={tab.path === '/superadmin/settings'}>
{navItems.map((tab) => (
<NavLink key={tab.to} to={tab.to} end={tab.end}>
{({ isActive }) => (
<m.div
className="relative overflow-hidden rounded-md px-3 py-1.5"
initial={false}
animate={{
backgroundColor: isActive
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
: 'transparent',
}}
whileHover={{
backgroundColor: isActive
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
: 'color-mix(in srgb, var(--color-fill) 60%, transparent)',
}}
transition={Spring.presets.snappy}
<div
className="relative overflow-hidden rounded-md shape-squircle px-3 py-1.5 group data-[state=active]:bg-accent/80 data-[state=active]:text-white"
data-state={isActive ? 'active' : 'inactive'}
>
<span
className="relative z-10 text-[13px] font-medium transition-colors"
style={{
color: isActive ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
{tab.label}
</span>
</m.div>
<span className="relative z-10 text-[13px] font-medium">{tab.label}</span>
</div>
)}
</NavLink>
))}
</div>
<div className="flex items-center gap-3">
{user && (
<div className="flex items-center gap-2">
<div className="text-right">
<div className="text-text text-[13px] font-medium">{user.name || user.email}</div>
<div className="text-text-tertiary text-[11px] capitalize">{user.role}</div>
</div>
{user.image && <img src={user.image} alt={user.name || user.email} className="size-7 rounded-full" />}
</div>
)}
<Button
type="button"
onClick={handleLogout}
disabled={isLoggingOut}
isLoading={isLoggingOut}
loadingText="Logging out..."
variant="primary"
size="sm"
>
Logout
</Button>
</div>
{/* Right side - User Menu */}
{user && <SuperAdminUserMenu user={user} />}
</div>
</nav>

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
version: '3.9'
services:
db:
image: postgres:16-alpine
container_name: afilmory_db
environment:
POSTGRES_USER: afilmory
POSTGRES_PASSWORD: afilmory
POSTGRES_DB: afilmory
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER']
interval: 5s
timeout: 5s
retries: 20
start_period: 5s
redis:
image: redis:7-alpine
container_name: afilmory_redis
command: ['redis-server', '--appendonly', 'yes']
ports:
- '6379:6379'
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 30
start_period: 5s
core:
container_name: afilmory_core
build:
context: .
dockerfile: Dockerfile.core
target: runner
environment:
NODE_ENV: production
HOSTNAME: '0.0.0.0'
PORT: '1841'
DATABASE_URL: 'postgresql://afilmory:afilmory@db:5432/afilmory'
REDIS_URL: 'redis://redis:6379/0'
CONFIG_ENCRYPTION_KEY: '756256a24394342622db58ee05ee61a64a3ae14c22d4fe665b753cba0fa6333e'
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- '1841:1841'
restart: unless-stopped
volumes:
pgdata:
redisdata:

View File

@@ -10,7 +10,7 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
- Extracts a slug via `tenant-host.utils.ts`.
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
- Always stores the original `requestedSlug` (even when placeholder), echoes headers `x-tenant-id` and `x-tenant-slug` (effective slug).
- Always stores the original `requestedSlug` (even when placeholder) so downstream services know which workspace was requested.
## Auth Provider
@@ -76,4 +76,3 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
- Only a single `tenant.slug` crosses the API boundary; there are no ambiguous fields.
- Placeholder detection is a boolean (`isPlaceholder`).
- Better Auth instances survive OAuth handshakes regardless of tenant provisioning state.
- Headers `x-tenant-id` / `x-tenant-slug` always mirror the effective slug, so backend services and the dashboard remain consistent.

View File

@@ -658,7 +658,7 @@ export class AfilmoryBuilder {
addReference(ref)
}
if (this.getUserSettings().repo.enable && !hasPluginWithName('afilmory:github-repo-sync')) {
if (this.getUserSettings().repo?.enable && !hasPluginWithName('afilmory:github-repo-sync')) {
addReference(() => import('@afilmory/builder/plugins/github-repo-sync.js'))
}

View File

@@ -177,4 +177,20 @@ export type EagleConfig = {
omitTagNamesInMetadata?: string[]
}
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig
/**
* Additional storage configuration surface that downstream projects can extend via
* module augmentation, e.g.
*
* declare module '@afilmory/builder/storage/interfaces.js' {
* interface CustomStorageConfig {
* provider: 'my-provider'
* foo?: string
* }
* }
*/
export interface CustomStorageConfig {
provider: string
[key: string]: unknown
}
export type StorageConfig = S3Config | GitHubConfig | EagleConfig | LocalConfig | CustomStorageConfig