mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
fix: purge manage storage data when account delete (#176)
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export class PhotoStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
|
||||
mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
|
||||
this.assertProviderSupported(provider.type)
|
||||
|
||||
const config = provider.config ?? {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export interface StorageProvider {
|
||||
*/
|
||||
deleteFile: (key: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* 删除指定前缀下的所有文件(通常对应一个“目录”)
|
||||
* @param prefix 需要删除的目录或前缀(不需要以 / 开头)
|
||||
*/
|
||||
deleteFolder: (prefix: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* 向存储上传文件
|
||||
* @param key 文件的键值/路径
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 当前不支持上传文件操作')
|
||||
}
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user