Skip to content

Commit 5e4b4bb

Browse files
committed
feat(data-management): add data management module for photo asset maintenance
- Introduced the DataManagementModule, including a controller and service for managing photo asset records. - Implemented functionality to truncate photo asset records from the database, enhancing data management capabilities. - Updated existing photo asset deletion logic to support optional deletion from storage. - Added a new DataManagementPanel in the dashboard for user interaction with data management features. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 2b30668 commit 5e4b4bb

File tree

33 files changed

+628
-72
lines changed

33 files changed

+628
-72
lines changed

be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export class PhotoAssetService {
149149
return summary
150150
}
151151

152-
async deleteAssets(ids: readonly string[]): Promise<void> {
152+
async deleteAssets(ids: readonly string[], options?: { deleteFromStorage?: boolean }): Promise<void> {
153153
if (ids.length === 0) {
154154
return
155155
}
@@ -166,14 +166,20 @@ export class PhotoAssetService {
166166
return
167167
}
168168

169-
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
170-
const storageManager = this.createStorageManager(builderConfig, storageConfig)
171-
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
172-
const deletedThumbnailKeys = new Set<string>()
173-
const deletedVideoKeys = new Set<string>()
169+
const shouldDeleteFromStorage = options?.deleteFromStorage === true
170+
171+
if (shouldDeleteFromStorage) {
172+
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
173+
const storageManager = this.createStorageManager(builderConfig, storageConfig)
174+
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
175+
const deletedThumbnailKeys = new Set<string>()
176+
const deletedVideoKeys = new Set<string>()
177+
178+
for (const record of records) {
179+
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
180+
continue
181+
}
174182

175-
for (const record of records) {
176-
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
177183
try {
178184
await storageManager.deleteFile(record.storageKey)
179185
} catch (error) {

be/apps/core/src/modules/content/photo/assets/photo.controller.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.servic
88
import { PhotoAssetService } from './photo-asset.service'
99

1010
type DeleteAssetsDto = {
11-
ids: string[]
11+
ids?: string[]
12+
deleteFromStorage?: boolean
1213
}
1314

1415
@Controller('photos')
@@ -29,8 +30,9 @@ export class PhotoController {
2930
@Delete('assets')
3031
async deleteAssets(@Body() body: DeleteAssetsDto) {
3132
const ids = Array.isArray(body?.ids) ? body.ids : []
32-
await this.photoAssetService.deleteAssets(ids)
33-
return { ids, deleted: true }
33+
const deleteFromStorage = body?.deleteFromStorage === true
34+
await this.photoAssetService.deleteAssets(ids, { deleteFromStorage })
35+
return { ids, deleted: true, deleteFromStorage }
3436
}
3537

3638
@Post('assets/upload')

be/apps/core/src/modules/index.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DataSyncModule } from './infrastructure/data-sync/data-sync.module'
2626
import { StaticWebModule } from './infrastructure/static-web/static-web.module'
2727
import { AuthModule } from './platform/auth/auth.module'
2828
import { DashboardModule } from './platform/dashboard/dashboard.module'
29+
import { DataManagementModule } from './platform/data-management/data-management.module'
2930
import { SuperAdminModule } from './platform/super-admin/super-admin.module'
3031
import { TenantModule } from './platform/tenant/tenant.module'
3132

@@ -55,6 +56,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
5556
PhotoModule,
5657
ReactionModule,
5758
DashboardModule,
59+
DataManagementModule,
5860
TenantModule,
5961
DataSyncModule,
6062
FeedModule,

be/apps/core/src/modules/platform/auth/auth.controller.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,12 +365,33 @@ export class AuthController {
365365
}
366366

367367
@AllowPlaceholderTenant()
368+
@SkipTenantGuard()
369+
@Get('/callback/*')
370+
async callback(@ContextParam() context: Context) {
371+
const query = context.req.query()
372+
const { tenantSlug } = query
373+
374+
const reqUrl = new URL(context.req.url)
375+
376+
if (tenantSlug) {
377+
reqUrl.hostname = `${tenantSlug}.${reqUrl.hostname}`
378+
reqUrl.searchParams.delete('tenantSlug')
379+
380+
return context.redirect(reqUrl.toString(), 302)
381+
}
382+
383+
return await this.auth.handler(context)
384+
}
385+
386+
@AllowPlaceholderTenant()
387+
@SkipTenantGuard()
368388
@Get('/*')
369389
async passthroughGet(@ContextParam() context: Context) {
370390
return await this.auth.handler(context)
371391
}
372392

373393
@AllowPlaceholderTenant()
394+
@SkipTenantGuard()
374395
@Post('/*')
375396
async passthroughPost(@ContextParam() context: Context) {
376397
return await this.auth.handler(context)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Controller, Post } from '@afilmory/framework'
2+
import { Roles } from 'core/guards/roles.decorator'
3+
4+
import { DataManagementService } from './data-management.service'
5+
6+
@Controller('data-management')
7+
@Roles('admin')
8+
export class DataManagementController {
9+
constructor(private readonly dataManagementService: DataManagementService) {}
10+
11+
@Post('photo-assets/truncate')
12+
async truncatePhotoAssetRecords() {
13+
return await this.dataManagementService.clearPhotoAssetRecords()
14+
}
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@afilmory/framework'
2+
3+
import { DataManagementController } from './data-management.controller'
4+
import { DataManagementService } from './data-management.service'
5+
6+
@Module({
7+
controllers: [DataManagementController],
8+
providers: [DataManagementService],
9+
})
10+
export class DataManagementModule {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { photoAssets } from '@afilmory/db'
2+
import { EventEmitterService } from '@afilmory/framework'
3+
import { DbAccessor } from 'core/database/database.provider'
4+
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
5+
import { eq } from 'drizzle-orm'
6+
import { injectable } from 'tsyringe'
7+
8+
@injectable()
9+
export class DataManagementService {
10+
constructor(
11+
private readonly dbAccessor: DbAccessor,
12+
private readonly eventEmitter: EventEmitterService,
13+
) {}
14+
15+
async clearPhotoAssetRecords(): Promise<{ deleted: number }> {
16+
const tenant = requireTenantContext()
17+
const db = this.dbAccessor.get()
18+
19+
const deletedRecords = await db
20+
.delete(photoAssets)
21+
.where(eq(photoAssets.tenantId, tenant.tenant.id))
22+
.returning({ id: photoAssets.id })
23+
24+
if (deletedRecords.length > 0) {
25+
await this.eventEmitter.emit('photo.manifest.changed', { tenantId: tenant.tenant.id })
26+
}
27+
28+
return {
29+
deleted: deletedRecords.length,
30+
}
31+
}
32+
}

be/apps/dashboard/src/components/common/UserMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export function UserMenu({ user }: UserMenuProps) {
6161

6262
{/* User Info - Hidden on small screens */}
6363
<div className="hidden text-left md:block">
64-
<div className="text-text text-[13px] leading-tight font-medium">{user.name || user.email}</div>
65-
<div className="text-text-tertiary text-[11px] leading-tight capitalize">{user.role}</div>
64+
<div className="text-text text-sm leading-tight font-medium">{user.name || user.email}</div>
65+
<div className="text-text-tertiary text-[10px] leading-tight capitalize">{user.role}</div>
6666
</div>
6767

6868
{/* Chevron Icon */}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { coreApi } from '~/lib/api-client'
2+
3+
type TruncatePhotoAssetsResponse = {
4+
deleted: number
5+
}
6+
7+
export async function truncatePhotoAssetRecords() {
8+
return await coreApi<TruncatePhotoAssetsResponse>('/data-management/photo-assets/truncate', {
9+
method: 'POST',
10+
})
11+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Button, Prompt } from '@afilmory/ui'
2+
import { clsxm } from '@afilmory/utils'
3+
import { DynamicIcon } from 'lucide-react/dynamic'
4+
5+
import { LinearBorderPanel } from '~/components/common/GlassPanel'
6+
import { usePhotoAssetSummaryQuery } from '~/modules/photos/hooks'
7+
8+
import { useTruncatePhotoAssetsMutation } from '../hooks'
9+
10+
const SUMMARY_PLACEHOLDER = {
11+
total: 0,
12+
synced: 0,
13+
pending: 0,
14+
conflicts: 0,
15+
}
16+
17+
const SUMMARY_STATS = [
18+
{ id: 'total', label: '总记录', accent: 'text-text', chip: '全部' },
19+
{ id: 'synced', label: '已同步', accent: 'text-emerald-300', chip: '正常' },
20+
{ id: 'pending', label: '待同步', accent: 'text-amber-300', chip: '排队中' },
21+
{ id: 'conflicts', label: '冲突', accent: 'text-rose-300', chip: '需处理' },
22+
] as const
23+
24+
const numberFormatter = new Intl.NumberFormat('zh-CN')
25+
26+
export function DataManagementPanel() {
27+
const summaryQuery = usePhotoAssetSummaryQuery()
28+
const summary = summaryQuery.data ?? SUMMARY_PLACEHOLDER
29+
const truncateMutation = useTruncatePhotoAssetsMutation()
30+
31+
const handleTruncate = () => {
32+
if (truncateMutation.isPending) {
33+
return
34+
}
35+
36+
Prompt.prompt({
37+
title: '确认清空照片数据表?',
38+
description: '该操作会删除数据库中的所有照片记录,但会保留对象存储中的原始文件。清空后需要重新执行一次照片同步。',
39+
variant: 'danger',
40+
onConfirmText: '立即清空',
41+
onCancelText: '取消',
42+
onConfirm: () => truncateMutation.mutateAsync().then(() => {}),
43+
})
44+
}
45+
46+
return (
47+
<div className="space-y-6">
48+
<LinearBorderPanel className="rounded-3xl bg-background-secondary/40 p-6">
49+
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
50+
<div className="space-y-4">
51+
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
52+
<DynamicIcon name="database" className="h-4 w-4" />
53+
当前数据概况
54+
</span>
55+
<div className="space-y-2">
56+
<h3 className="text-text text-xl font-semibold">照片数据表状态</h3>
57+
<p className="text-text-secondary text-sm">以下统计来自数据库记录,不含对象存储中的原始文件。</p>
58+
</div>
59+
{summaryQuery.isError ? <p className="text-red text-sm">无法加载数据统计,请稍后再试。</p> : null}
60+
</div>
61+
<div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
62+
{SUMMARY_STATS.map((stat) => (
63+
<div
64+
key={stat.id}
65+
className={clsxm(
66+
'rounded-2xl border border-white/5 bg-background-tertiary/60 px-4 py-3 shadow-sm backdrop-blur',
67+
summaryQuery.isLoading && 'animate-pulse',
68+
)}
69+
>
70+
<div className="flex items-center justify-between text-[11px] text-text-tertiary">
71+
<span>{stat.label}</span>
72+
<span className="shape-squircle bg-white/5 px-2 py-0.5 font-medium text-white/80">{stat.chip}</span>
73+
</div>
74+
<div className={clsxm('mt-2 text-2xl font-semibold', stat.accent)}>
75+
{summaryQuery.isLoading ? '—' : numberFormatter.format(summary[stat.id])}
76+
</div>
77+
</div>
78+
))}
79+
</div>
80+
</div>
81+
</LinearBorderPanel>
82+
83+
<LinearBorderPanel className="rounded-3xl bg-background-secondary/40 p-6">
84+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
85+
<div className="space-y-2">
86+
<div className="flex items-center gap-2 text-red">
87+
<DynamicIcon name="triangle-alert" className="h-4 w-4" />
88+
<span className="text-sm font-semibold">危险操作</span>
89+
</div>
90+
<div>
91+
<h4 className="text-text text-lg font-semibold">清空照片数据表</h4>
92+
<p className="text-text-secondary text-sm">
93+
删除数据库中的所有照片记录,仅保留对象存储文件。通常用于处理数据不一致、重新同步或迁移场景。
94+
</p>
95+
</div>
96+
</div>
97+
<Button
98+
type="button"
99+
variant="destructive"
100+
size="sm"
101+
isLoading={truncateMutation.isPending}
102+
loadingText="清理中…"
103+
onClick={handleTruncate}
104+
>
105+
清空数据库记录
106+
</Button>
107+
</div>
108+
<p className="text-text-tertiary mt-4 text-xs">
109+
操作完成后请立即重新执行「照片同步」,以便使用存储中的原始文件重建数据库与 manifest。
110+
</p>
111+
</LinearBorderPanel>
112+
</div>
113+
)
114+
}

0 commit comments

Comments
 (0)