mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
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:
@@ -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
5
be/apps/core/afilmory-builder.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '@afilmory/builder/storage/interfaces.js' {
|
||||
interface CustomStorageConfig {
|
||||
provider: 'super-admin-debug-storage'
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
|
||||
|
||||
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
|
||||
throwOnMissing: true,
|
||||
setResponseHeaders: true,
|
||||
skipInitializationCheck: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
|
||||
}
|
||||
|
||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||
setResponseHeaders: false,
|
||||
skipInitializationCheck: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export class RequestContextMiddleware implements HttpMiddleware {
|
||||
|
||||
try {
|
||||
const tenantContext = await this.tenantContextResolver.resolve(context, {
|
||||
setResponseHeaders: false,
|
||||
throwOnMissing: false,
|
||||
skipInitializationCheck: true,
|
||||
})
|
||||
|
||||
@@ -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<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(
|
||||
{
|
||||
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 } })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
}
|
||||
@@ -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('/')
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
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<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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
129
be/apps/core/src/modules/shared/http/sse.ts
Normal file
129
be/apps/core/src/modules/shared/http/sse.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
512
be/apps/dashboard/src/pages/superadmin/debug.tsx
Normal file
512
be/apps/dashboard/src/pages/superadmin/debug.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
62
docker-compose.yml
Normal 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:
|
||||
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ export class AfilmoryBuilder {
|
||||
}
|
||||
|
||||
getStorageConfig(): StorageConfig {
|
||||
const {storage} = this.getUserSettings()
|
||||
const { storage } = this.getUserSettings()
|
||||
if (!storage) {
|
||||
throw new Error('Storage configuration is missing. 请配置 system/user storage 设置。')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user