diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.store.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.store.service.ts index 8709fb57..8204d952 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.store.service.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.store.service.ts @@ -1,4 +1,5 @@ import { systemSettings } from '@afilmory/db' +import { EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' import { eq, inArray } from 'drizzle-orm' import { injectable } from 'tsyringe' @@ -12,7 +13,10 @@ import type { @injectable() export class SystemSettingStore { - constructor(private readonly dbAccessor: DbAccessor) {} + constructor( + private readonly dbAccessor: DbAccessor, + private readonly eventService: EventEmitterService, + ) {} async get(key: SystemSettingKey): Promise { const record = await this.find(key) @@ -73,6 +77,8 @@ export class SystemSettingStore { updatedAt: now, }, }) + + this.eventService.emit('system.setting.updated', { key, value: String(value) }) } async setMany(entries: readonly SystemSettingEntryInput[]): Promise { diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts index 85eb3e5c..71cb0777 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts @@ -11,7 +11,12 @@ import type { import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' import type { BuilderStorageProvider } from '../setting/storage-provider.utils' -import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants' +import type { + BillingPlanSettingField, + SystemSettingDbField, + SystemSettingField, + SystemSettingKey, +} from './system-setting.constants' export interface SystemSettings { allowRegistration: boolean @@ -56,3 +61,9 @@ export type UpdateSystemSettingsInput = Partial & Partial> export { type SystemSettingField } from './system-setting.constants' + +declare module '@afilmory/framework' { + interface Events { + 'system.setting.updated': { key: SystemSettingKey; value: unknown } + } +} diff --git a/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts b/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts index c9192540..576c69de 100644 --- a/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts +++ b/be/apps/core/src/modules/content/photo/storage/photo-storage.service.ts @@ -112,7 +112,7 @@ export class PhotoStorageService { } } - private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig { + mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig { this.assertProviderSupported(provider.type) const config = provider.config ?? {} diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts index d39f28b5..1ce27e3b 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@afilmory/framework' +import { createLogger, EventEmitterService } from '@afilmory/framework' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' import { injectable } from 'tsyringe' @@ -7,7 +7,16 @@ export class StaticAssetHostService { private readonly logger = createLogger('StaticAssetHostService') private readonly cache = new Map() - constructor(private readonly systemSettingService: SystemSettingService) {} + constructor( + private readonly systemSettingService: SystemSettingService, + private readonly eventService: EventEmitterService, + ) { + eventService.on('system.setting.updated', ({ key }) => { + if (key === 'system.domain.base') { + this.cache.clear() + } + }) + } async getStaticAssetHost(requestHost?: string | null): Promise { const cacheKey = this.buildCacheKey(requestHost) diff --git a/be/apps/core/src/modules/platform/data-management/data-management.module.ts b/be/apps/core/src/modules/platform/data-management/data-management.module.ts index 89f0f289..d722f4a3 100644 --- a/be/apps/core/src/modules/platform/data-management/data-management.module.ts +++ b/be/apps/core/src/modules/platform/data-management/data-management.module.ts @@ -1,11 +1,13 @@ import { Module } from '@afilmory/framework' +import { SystemSettingModule } from '../../configuration/system-setting/system-setting.module' import { BillingModule } from '../billing/billing.module' +import { ManagedStorageModule } from '../managed-storage/managed-storage.module' import { DataManagementController } from './data-management.controller' import { DataManagementService } from './data-management.service' @Module({ - imports: [BillingModule], + imports: [BillingModule, SystemSettingModule, ManagedStorageModule], controllers: [DataManagementController], providers: [DataManagementService], }) diff --git a/be/apps/core/src/modules/platform/data-management/data-management.service.ts b/be/apps/core/src/modules/platform/data-management/data-management.service.ts index 428b7bff..45296a99 100644 --- a/be/apps/core/src/modules/platform/data-management/data-management.service.ts +++ b/be/apps/core/src/modules/platform/data-management/data-management.service.ts @@ -1,3 +1,5 @@ +import type { ManagedStorageConfig, RemoteStorageConfig } from '@afilmory/builder' +import { StorageManager } from '@afilmory/builder/storage/index.js' import { authSessions, authUsers, @@ -12,6 +14,8 @@ import { import { EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' +import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants' import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service' import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants' @@ -25,6 +29,8 @@ export class DataManagementService { private readonly dbAccessor: DbAccessor, private readonly eventEmitter: EventEmitterService, private readonly billingUsageService: BillingUsageService, + private readonly systemSettingService: SystemSettingService, + private readonly photoStorageService: PhotoStorageService, ) {} async clearPhotoAssetRecords(): Promise<{ deleted: number }> { @@ -58,6 +64,8 @@ export class DataManagementService { const tenantId = tenant.tenant.id const tenantSlug = tenant.tenant.slug + await this.deleteManagedStorageSpace(tenantId) + if (!tenantSlug) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户缺少 slug,无法删除账户。', @@ -70,6 +78,8 @@ export class DataManagementService { }) } + await this.deleteManagedStorageSpace(tenantId) + const db = this.dbAccessor.get() await db.transaction(async (tx) => { @@ -91,4 +101,32 @@ export class DataManagementService { deletedTenantId: tenantId, } } + + private async deleteManagedStorageSpace(tenantId: string): Promise { + const managedConfig = await this.buildManagedStorageConfig(tenantId) + + if (!managedConfig) { + return + } + + const storageManager = new StorageManager(managedConfig) + await storageManager.deleteFolder('') + } + + private async buildManagedStorageConfig(tenantId: string): Promise { + const provider = await this.systemSettingService.getManagedStorageProvider() + if (!provider) { + return null + } + + const upstream = this.photoStorageService.mapProviderToStorageConfig(provider) + + return { + provider: 'managed', + tenantId, + providerKey: provider.id, + basePrefix: null, + upstream: upstream as RemoteStorageConfig, + } + } } diff --git a/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts b/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts index a6e0ac91..37446ff0 100644 --- a/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts +++ b/be/apps/core/src/modules/platform/managed-storage/managed-storage.provider.ts @@ -80,6 +80,13 @@ export class ManagedStorageProvider implements StorageProvider { await this.upstream.deleteFile(targetKey) } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = normalizePath(prefix) + const targetPrefix = + this.needsManualPrefix && !normalizedPrefix ? this.effectivePrefix : this.prepareKeyForUpstream(normalizedPrefix) + await this.upstream.deleteFolder(targetPrefix) + } + async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise { const targetKey = this.prepareKeyForUpstream(key) const uploaded = await this.upstream.uploadFile(targetKey, data, options) diff --git a/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts b/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts index 7fcca810..dc779292 100644 --- a/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts +++ b/be/apps/core/src/modules/platform/super-admin/InMemoryDebugStorageProvider.ts @@ -97,6 +97,22 @@ export class InMemoryDebugStorageProvider implements StorageProvider { this.files.delete(key) } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = this.normalizeKey(prefix) + const prefixWithSlash = normalizedPrefix ? `${normalizedPrefix}/` : null + + if (!normalizedPrefix) { + this.files.clear() + return + } + + for (const key of this.files.keys()) { + if (key === normalizedPrefix || (prefixWithSlash && key.startsWith(prefixWithSlash))) { + this.files.delete(key) + } + } + } + async uploadFile(key: string, data: Buffer, _options?: StorageUploadOptions): Promise { const normalizedKey = this.normalizeKey(key) const metadata: StorageObject = { diff --git a/be/apps/dashboard/src/hooks/usePageRedirect.ts b/be/apps/dashboard/src/hooks/usePageRedirect.ts index 406ebc64..80499d16 100644 --- a/be/apps/dashboard/src/hooks/usePageRedirect.ts +++ b/be/apps/dashboard/src/hooks/usePageRedirect.ts @@ -94,12 +94,6 @@ export function usePageRedirect() { setAuthUser(sessionQuery.data?.user ?? null) }, [sessionQuery.data, setAuthUser]) - useEffect(() => { - return () => { - queryClient.cancelQueries({ queryKey: AUTH_SESSION_QUERY_KEY }) - } - }, [queryClient]) - useEffect(() => { const matchedTenantNotFound = [sessionQuery.error].some((error) => { const code = extractBizErrorCode(error) diff --git a/be/apps/dashboard/src/hooks/useRequireStorageProvider.ts b/be/apps/dashboard/src/hooks/useRequireStorageProvider.ts index 2bb88639..4417c3e6 100644 --- a/be/apps/dashboard/src/hooks/useRequireStorageProvider.ts +++ b/be/apps/dashboard/src/hooks/useRequireStorageProvider.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router' import { PUBLIC_ROUTES } from '~/constants/routes' @@ -34,7 +34,11 @@ export function useRequireStorageProvider({ session, isLoading }: UseRequireStor (storageProvidersQuery.data?.providers.length ?? 0) === 0 && !storageProvidersQuery.isFetching + const navigateOnceRef = useRef(false) useEffect(() => { + if (navigateOnceRef.current) { + return + } if (!needsSetup) { return } @@ -42,6 +46,8 @@ export function useRequireStorageProvider({ session, isLoading }: UseRequireStor return } + navigateOnceRef.current = true + navigate(STORAGE_SETUP_PATH, { replace: true }) }, [navigate, needsSetup, pathname]) } diff --git a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx index e83fe3b8..adbc5923 100644 --- a/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx +++ b/be/apps/dashboard/src/modules/auth/components/registration-wizard/RegistrationWizard.tsx @@ -201,6 +201,8 @@ export const RegistrationWizard: FC = () => { if (!isEmptyValue(current)) { return false } + // The `form.setFieldValue` cannot bypass the `.` key accessor, requiring two operations. + form.state.values[key] = value form.setFieldValue(key, () => value) return true } diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx index 441730ac..d5a2f4d1 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx @@ -325,15 +325,22 @@ export function StorageProvidersManager() { transition={Spring.presets.smooth} className="col-span-full" > -
+

{t(storageProvidersI18nKeys.empty.title)}

{t(storageProvidersI18nKeys.empty.description)}

- -
+ )} diff --git a/be/packages/framework/src/events/index.ts b/be/packages/framework/src/events/index.ts index 1b8facb6..724dc35a 100644 --- a/be/packages/framework/src/events/index.ts +++ b/be/packages/framework/src/events/index.ts @@ -50,9 +50,8 @@ export interface EventModuleAsyncOptions { inject?: Constructor[] } -export interface Events { - a: '' -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Events {} type EventNameInterface = keyof Events | (string & {}) diff --git a/packages/builder/src/storage/interfaces.ts b/packages/builder/src/storage/interfaces.ts index 4e7da13c..1a5e3b95 100644 --- a/packages/builder/src/storage/interfaces.ts +++ b/packages/builder/src/storage/interfaces.ts @@ -79,6 +79,12 @@ export interface StorageProvider { */ deleteFile: (key: string) => Promise + /** + * 删除指定前缀下的所有文件(通常对应一个“目录”) + * @param prefix 需要删除的目录或前缀(不需要以 / 开头) + */ + deleteFolder: (prefix: string) => Promise + /** * 向存储上传文件 * @param key 文件的键值/路径 diff --git a/packages/builder/src/storage/manager.ts b/packages/builder/src/storage/manager.ts index 1b6dac58..155cf721 100644 --- a/packages/builder/src/storage/manager.ts +++ b/packages/builder/src/storage/manager.ts @@ -110,6 +110,10 @@ export class StorageManager { await this.provider.deleteFile(key) } + async deleteFolder(prefix: string): Promise { + await this.provider.deleteFolder(prefix) + } + async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise { const bytes = data?.byteLength ?? 0 const progressHandler = this.createProgressPipeline(options?.onProgress) diff --git a/packages/builder/src/storage/providers/b2-provider.ts b/packages/builder/src/storage/providers/b2-provider.ts index 4adc7789..a65e1974 100644 --- a/packages/builder/src/storage/providers/b2-provider.ts +++ b/packages/builder/src/storage/providers/b2-provider.ts @@ -562,6 +562,19 @@ export class B2StorageProvider implements StorageProvider { }) } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = sanitizePath(prefix) + const targetPrefix = normalizedPrefix ? `${normalizedPrefix}/` : '' + const allFiles = await this.listAllFiles() + const keysToDelete = allFiles + .map((file) => file.key) + .filter((key): key is string => Boolean(key) && (!targetPrefix || key.startsWith(targetPrefix))) + + for (const key of keysToDelete) { + await this.deleteFile(key) + } + } + async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise { const remoteKey = this.toRemoteKey(key) const file = await this.uploadInternal(remoteKey, data, options) diff --git a/packages/builder/src/storage/providers/eagle-provider.ts b/packages/builder/src/storage/providers/eagle-provider.ts index 0234bb76..f1ad3579 100644 --- a/packages/builder/src/storage/providers/eagle-provider.ts +++ b/packages/builder/src/storage/providers/eagle-provider.ts @@ -188,6 +188,10 @@ export class EagleStorageProvider implements StorageProvider { throw new Error('EagleStorageProvider: 当前不支持删除文件操作') } + async deleteFolder(_prefix: string): Promise { + throw new Error('EagleStorageProvider: 当前不支持删除目录操作') + } + async uploadFile(_key: string, _data: Buffer, _options?: StorageUploadOptions): Promise { throw new Error('EagleStorageProvider: 当前不支持上传文件操作') } diff --git a/packages/builder/src/storage/providers/github-provider.ts b/packages/builder/src/storage/providers/github-provider.ts index 1f6aafc9..8589f2f4 100644 --- a/packages/builder/src/storage/providers/github-provider.ts +++ b/packages/builder/src/storage/providers/github-provider.ts @@ -256,6 +256,19 @@ export class GitHubStorageProvider implements StorageProvider { } } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = this.normalizePrefix(prefix) + const targetPrefix = normalizedPrefix ? `${normalizedPrefix}/` : '' + const allFiles = await this.listAllFiles() + const keysToDelete = allFiles + .map((file) => file.key) + .filter((key): key is string => Boolean(key) && (!targetPrefix || key.startsWith(targetPrefix))) + + for (const key of keysToDelete) { + await this.deleteFile(key) + } + } + async uploadFile(key: string, data: Buffer, _options?: StorageUploadOptions): Promise { const metadata = await this.fetchContentMetadata(key) const fullPath = this.getFullPath(key) @@ -385,4 +398,8 @@ export class GitHubStorageProvider implements StorageProvider { return livePhotoMap } + + private normalizePrefix(prefix: string): string { + return prefix.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + } } diff --git a/packages/builder/src/storage/providers/local-provider.ts b/packages/builder/src/storage/providers/local-provider.ts index 55c2fad5..af626186 100644 --- a/packages/builder/src/storage/providers/local-provider.ts +++ b/packages/builder/src/storage/providers/local-provider.ts @@ -144,6 +144,10 @@ export class LocalStorageProvider implements StorageProvider { return resolvedPath } + private normalizePrefix(prefix: string): string { + return prefix.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + } + private async syncDistFile(key: string, sourcePath: string): Promise { if (!this.distPath) { return @@ -168,6 +172,19 @@ export class LocalStorageProvider implements StorageProvider { } } + private async removeDistFolder(prefix: string): Promise { + if (!this.distPath) { + return + } + + const targetPath = prefix ? path.join(this.distPath, prefix) : this.distPath + try { + await fs.rm(targetPath, { recursive: true, force: true }) + } catch (error) { + this.logger.warn(`删除 dist 目录失败:${targetPath}`, error) + } + } + async deleteFile(key: string): Promise { const filePath = this.resolveSafePath(key) @@ -181,6 +198,20 @@ export class LocalStorageProvider implements StorageProvider { } } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = this.normalizePrefix(prefix) + const targetPath = normalizedPrefix ? this.resolveSafePath(normalizedPrefix) : this.basePath + + try { + await fs.rm(targetPath, { recursive: true, force: true }) + await this.removeDistFolder(normalizedPrefix) + this.logger.success(`已删除本地目录:${normalizedPrefix || '.'}`) + } catch (error) { + this.logger.error(`删除本地目录失败:${normalizedPrefix || '.'}`, error) + throw error + } + } + async uploadFile(key: string, data: Buffer, _options?: StorageUploadOptions): Promise { const filePath = this.resolveSafePath(key) diff --git a/packages/builder/src/storage/providers/s3-provider.ts b/packages/builder/src/storage/providers/s3-provider.ts index eafc7a9c..cf63c299 100644 --- a/packages/builder/src/storage/providers/s3-provider.ts +++ b/packages/builder/src/storage/providers/s3-provider.ts @@ -302,9 +302,9 @@ export class S3StorageProvider implements StorageProvider { return livePhotoMap } - private async listObjects(): Promise { + private async listObjects(prefix?: string): Promise { const response = await this.client.listObjects({ - prefix: this.config.prefix, + prefix: prefix ?? this.config.prefix, maxKeys: this.config.maxFileLimit, }) const text = await response.text() @@ -337,6 +337,27 @@ export class S3StorageProvider implements StorageProvider { } } + async deleteFolder(prefix: string): Promise { + const normalizedPrefix = this.normalizePrefix(prefix) + const basePrefix = normalizedPrefix || this.config.prefix || '' + const listPrefix = basePrefix || undefined + const targetPrefix = basePrefix && !basePrefix.endsWith('/') ? `${basePrefix}/` : basePrefix + + const objects = await this.listObjects(listPrefix) + + const keysToDelete = objects + .map((obj) => obj.key) + .filter((key): key is string => { + if (!key) return false + if (!targetPrefix) return true + return key.startsWith(targetPrefix) + }) + + for (const key of keysToDelete) { + await this.deleteFile(key) + } + } + async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise { const response = await this.client.putObject(key, data as unknown as BodyInit, { 'content-type': options?.contentType ?? 'application/octet-stream', @@ -381,4 +402,8 @@ export class S3StorageProvider implements StorageProvider { etag: sanitizeS3Etag(metadata.etag), } } + + private normalizePrefix(prefix: string): string { + return prefix.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + } }