fix: purge manage storage data when account delete (#176)

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-27 21:19:28 +08:00
committed by GitHub
parent b48f4bcd62
commit 843ff8130d
20 changed files with 218 additions and 21 deletions

View File

@@ -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<SystemSettingRecord['value']> {
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<void> {

View File

@@ -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<SystemSettings> &
Partial<Record<BillingPlanSettingField, string | number | boolean | null | undefined>>
export { type SystemSettingField } from './system-setting.constants'
declare module '@afilmory/framework' {
interface Events {
'system.setting.updated': { key: SystemSettingKey; value: unknown }
}
}

View File

@@ -112,7 +112,7 @@ export class PhotoStorageService {
}
}
private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
this.assertProviderSupported(provider.type)
const config = provider.config ?? {}

View File

@@ -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<string, string | null>()
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<string | null> {
const cacheKey = this.buildCacheKey(requestHost)

View File

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

View File

@@ -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<void> {
const managedConfig = await this.buildManagedStorageConfig(tenantId)
if (!managedConfig) {
return
}
const storageManager = new StorageManager(managedConfig)
await storageManager.deleteFolder('')
}
private async buildManagedStorageConfig(tenantId: string): Promise<ManagedStorageConfig | null> {
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,
}
}
}

View File

@@ -80,6 +80,13 @@ export class ManagedStorageProvider implements StorageProvider {
await this.upstream.deleteFile(targetKey)
}
async deleteFolder(prefix: string): Promise<void> {
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<StorageObject> {
const targetKey = this.prepareKeyForUpstream(key)
const uploaded = await this.upstream.uploadFile(targetKey, data, options)

View File

@@ -97,6 +97,22 @@ export class InMemoryDebugStorageProvider implements StorageProvider {
this.files.delete(key)
}
async deleteFolder(prefix: string): Promise<void> {
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<StorageObject> {
const normalizedKey = this.normalizeKey(key)
const metadata: StorageObject = {

View File

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

View File

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

View File

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

View File

@@ -325,15 +325,22 @@ export function StorageProvidersManager() {
transition={Spring.presets.smooth}
className="col-span-full"
>
<div className="bg-background-tertiary border-fill-tertiary flex flex-col items-center justify-center gap-3 rounded-lg border p-8 text-center">
<LinearBorderPanel className="bg-background-tertiary border-fill-tertiary flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="space-y-1">
<p className="text-text-secondary text-sm">{t(storageProvidersI18nKeys.empty.title)}</p>
<p className="text-text-tertiary text-xs">{t(storageProvidersI18nKeys.empty.description)}</p>
</div>
<Button type="button" size="sm" variant="primary" onClick={handleAddProvider} disabled={!schemaReady}>
<Button
type="button"
size="sm"
className="mt-4"
variant="primary"
onClick={handleAddProvider}
disabled={!schemaReady}
>
{t(storageProvidersI18nKeys.empty.action)}
</Button>
</div>
</LinearBorderPanel>
</m.div>
)}
</m.div>

View File

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

View File

@@ -79,6 +79,12 @@ export interface StorageProvider {
*/
deleteFile: (key: string) => Promise<void>
/**
* 删除指定前缀下的所有文件(通常对应一个“目录”)
* @param prefix 需要删除的目录或前缀(不需要以 / 开头)
*/
deleteFolder: (prefix: string) => Promise<void>
/**
* 向存储上传文件
* @param key 文件的键值/路径

View File

@@ -110,6 +110,10 @@ export class StorageManager {
await this.provider.deleteFile(key)
}
async deleteFolder(prefix: string): Promise<void> {
await this.provider.deleteFolder(prefix)
}
async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise<StorageObject> {
const bytes = data?.byteLength ?? 0
const progressHandler = this.createProgressPipeline(options?.onProgress)

View File

@@ -562,6 +562,19 @@ export class B2StorageProvider implements StorageProvider {
})
}
async deleteFolder(prefix: string): Promise<void> {
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<StorageObject> {
const remoteKey = this.toRemoteKey(key)
const file = await this.uploadInternal(remoteKey, data, options)

View File

@@ -188,6 +188,10 @@ export class EagleStorageProvider implements StorageProvider {
throw new Error('EagleStorageProvider: 当前不支持删除文件操作')
}
async deleteFolder(_prefix: string): Promise<void> {
throw new Error('EagleStorageProvider: 当前不支持删除目录操作')
}
async uploadFile(_key: string, _data: Buffer, _options?: StorageUploadOptions): Promise<StorageObject> {
throw new Error('EagleStorageProvider: 当前不支持上传文件操作')
}

View File

@@ -256,6 +256,19 @@ export class GitHubStorageProvider implements StorageProvider {
}
}
async deleteFolder(prefix: string): Promise<void> {
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<StorageObject> {
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, '')
}
}

View File

@@ -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<void> {
if (!this.distPath) {
return
@@ -168,6 +172,19 @@ export class LocalStorageProvider implements StorageProvider {
}
}
private async removeDistFolder(prefix: string): Promise<void> {
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<void> {
const filePath = this.resolveSafePath(key)
@@ -181,6 +198,20 @@ export class LocalStorageProvider implements StorageProvider {
}
}
async deleteFolder(prefix: string): Promise<void> {
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<StorageObject> {
const filePath = this.resolveSafePath(key)

View File

@@ -302,9 +302,9 @@ export class S3StorageProvider implements StorageProvider {
return livePhotoMap
}
private async listObjects(): Promise<StorageObject[]> {
private async listObjects(prefix?: string): Promise<StorageObject[]> {
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<void> {
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<StorageObject> {
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, '')
}
}