Skip to content

Commit 76debaa

Browse files
committed
feat(photo): implement photo tag management and update functionality
- Added UpdatePhotoTagsDto for validating photo tag updates. - Implemented updateAssetTags method in PhotoAssetService to handle tag updates, including validation and error handling. - Enhanced PhotoController with a new endpoint for updating photo tags. - Introduced PhotoTagEditorModal for editing tags in the UI, allowing batch updates for selected photos. - Updated PhotoLibrary components to support tag editing and display changes in the UI. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 765cd18 commit 76debaa

File tree

22 files changed

+948
-94
lines changed

22 files changed

+948
-94
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createZodDto } from '@afilmory/framework'
2+
import { z } from 'zod'
3+
4+
const tagSchema = z
5+
.string()
6+
.trim()
7+
.min(1, { message: '标签不能为空' })
8+
.max(64, { message: '标签长度不能超过 64 个字符' })
9+
10+
export class UpdatePhotoTagsDto extends createZodDto(
11+
z.object({
12+
tags: z.array(tagSchema).max(32, { message: '单张照片最多可设置 32 个标签' }),
13+
}),
14+
) {}

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'node:path'
22

33
import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageObject } from '@afilmory/builder'
4+
import { createStorageKeyNormalizer } from '@afilmory/builder/photo/index.js'
45
import {
56
DEFAULT_CONTENT_TYPE,
67
DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY,
@@ -30,6 +31,7 @@ import { injectable } from 'tsyringe'
3031

3132
import { PhotoBuilderService } from '../builder/photo-builder.service'
3233
import { PhotoStorageService } from '../storage/photo-storage.service'
34+
import { inferContentTypeFromKey } from './storage.utils'
3335

3436
type PhotoAssetRecord = typeof photoAssets.$inferSelect
3537

@@ -1002,6 +1004,136 @@ export class PhotoAssetService {
10021004
return await Promise.resolve(storageManager.generatePublicUrl(storageKey))
10031005
}
10041006

1007+
async updateAssetTags(assetId: string, tagsInput: readonly string[]): Promise<PhotoAssetListItem> {
1008+
const tenant = requireTenantContext()
1009+
const db = this.dbAccessor.get()
1010+
1011+
if (!assetId) {
1012+
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少资源 ID' })
1013+
}
1014+
1015+
const record = await db
1016+
.select()
1017+
.from(photoAssets)
1018+
.where(and(eq(photoAssets.tenantId, tenant.tenant.id), eq(photoAssets.id, assetId)))
1019+
.limit(1)
1020+
.then((rows) => rows[0])
1021+
1022+
if (!record) {
1023+
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到指定的图片资源' })
1024+
}
1025+
1026+
if (!record.manifest?.data) {
1027+
throw new BizException(ErrorCode.PHOTO_MANIFEST_GENERATION_FAILED, { message: '该资源缺少有效的清单数据' })
1028+
}
1029+
1030+
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
1031+
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '数据库占位资源不支持修改标签' })
1032+
}
1033+
1034+
const normalizedTags = this.normalizeTagList(tagsInput)
1035+
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
1036+
const storageManager = this.createStorageManager(builderConfig, storageConfig)
1037+
1038+
const sanitizeKey = this.normalizeKeyPath(record.storageKey)
1039+
const normalizeStorageKey = createStorageKeyNormalizer(storageConfig)
1040+
const relativeKey = normalizeStorageKey(sanitizeKey)
1041+
const fileName = path.basename(relativeKey || sanitizeKey)
1042+
if (!fileName) {
1043+
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '无法解析当前图片文件名' })
1044+
}
1045+
1046+
const prefixSegment = this.extractStoragePrefix(sanitizeKey, relativeKey)
1047+
const tagDirectory = normalizedTags.length > 0 ? this.joinStorageSegments(...normalizedTags) : null
1048+
const newRelativeKey = tagDirectory ? `${tagDirectory}/${fileName}` : fileName
1049+
const normalizedRelativeKey = this.normalizeKeyPath(newRelativeKey)
1050+
const newStorageKey = prefixSegment
1051+
? this.joinStorageSegments(prefixSegment, normalizedRelativeKey)
1052+
: normalizedRelativeKey
1053+
1054+
const manifest = structuredClone(record.manifest)
1055+
const photoData = manifest.data
1056+
let storageSnapshot: ReturnType<typeof this.createStorageSnapshot> | null = null
1057+
1058+
if (newStorageKey !== record.storageKey) {
1059+
const moved = this.normalizeStorageObjectKey(
1060+
await storageManager.moveFile(record.storageKey, newStorageKey, {
1061+
contentType: inferContentTypeFromKey(newStorageKey),
1062+
}),
1063+
newStorageKey,
1064+
)
1065+
storageSnapshot = this.createStorageSnapshot(moved)
1066+
const livePhotoUpdate = await this.relocateLivePhotoVideo(photoData, storageManager, newStorageKey)
1067+
if (livePhotoUpdate) {
1068+
photoData.video = {
1069+
...photoData.video,
1070+
type: 'live-photo',
1071+
videoUrl: livePhotoUpdate.videoUrl,
1072+
s3Key: livePhotoUpdate.s3Key,
1073+
}
1074+
}
1075+
1076+
if (storageSnapshot.size !== null) {
1077+
photoData.size = storageSnapshot.size
1078+
}
1079+
if (storageSnapshot.lastModified) {
1080+
photoData.lastModified = storageSnapshot.lastModified
1081+
}
1082+
}
1083+
1084+
const originalUrl = await Promise.resolve(storageManager.generatePublicUrl(newStorageKey))
1085+
photoData.tags = normalizedTags
1086+
photoData.originalUrl = originalUrl
1087+
photoData.s3Key = newStorageKey
1088+
1089+
const now = new Date().toISOString()
1090+
const updatePayload: Partial<typeof photoAssets.$inferInsert> = {
1091+
storageKey: newStorageKey,
1092+
manifest,
1093+
updatedAt: now,
1094+
syncStatus: 'synced',
1095+
}
1096+
1097+
if (storageSnapshot) {
1098+
updatePayload.size = storageSnapshot.size
1099+
updatePayload.etag = storageSnapshot.etag
1100+
updatePayload.lastModified = storageSnapshot.lastModified
1101+
updatePayload.metadataHash = storageSnapshot.metadataHash
1102+
updatePayload.syncedAt = now
1103+
}
1104+
1105+
const [saved] = await db
1106+
.update(photoAssets)
1107+
.set(updatePayload)
1108+
.where(and(eq(photoAssets.id, record.id), eq(photoAssets.tenantId, tenant.tenant.id)))
1109+
.returning()
1110+
1111+
if (!saved) {
1112+
throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: '更新标签失败,请稍后再试' })
1113+
}
1114+
1115+
const publicUrl =
1116+
saved.storageProvider === DATABASE_ONLY_PROVIDER
1117+
? null
1118+
: await Promise.resolve(storageManager.generatePublicUrl(saved.storageKey))
1119+
1120+
await this.emitManifestChanged(tenant.tenant.id)
1121+
1122+
return {
1123+
id: saved.id,
1124+
photoId: saved.photoId,
1125+
storageKey: saved.storageKey,
1126+
storageProvider: saved.storageProvider,
1127+
manifest: saved.manifest,
1128+
syncedAt: saved.syncedAt,
1129+
updatedAt: saved.updatedAt,
1130+
createdAt: saved.createdAt,
1131+
publicUrl,
1132+
size: saved.size ?? null,
1133+
syncStatus: saved.syncStatus,
1134+
}
1135+
}
1136+
10051137
private createUploadSummary(pendingCount: number, existingRecords: number): DataSyncResultSummary {
10061138
return {
10071139
storageObjects: pendingCount,
@@ -1373,4 +1505,89 @@ export class PhotoAssetService {
13731505
const base = path.basename(value, ext)
13741506
return base.trim().toLowerCase()
13751507
}
1508+
1509+
private normalizeTagList(input: readonly string[] | undefined): string[] {
1510+
if (!Array.isArray(input) || input.length === 0) {
1511+
return []
1512+
}
1513+
1514+
const seen = new Set<string>()
1515+
const normalized: string[] = []
1516+
1517+
for (const raw of input) {
1518+
if (typeof raw !== 'string') {
1519+
continue
1520+
}
1521+
const sanitized = raw
1522+
.replaceAll(/[\\/]+/g, '-')
1523+
.replaceAll(/\s+/g, ' ')
1524+
.trim()
1525+
if (!sanitized || sanitized === '.' || sanitized === '..') {
1526+
continue
1527+
}
1528+
const truncated = sanitized.slice(0, 64)
1529+
const key = truncated.toLowerCase()
1530+
if (seen.has(key)) {
1531+
continue
1532+
}
1533+
seen.add(key)
1534+
normalized.push(truncated)
1535+
if (normalized.length >= 32) {
1536+
break
1537+
}
1538+
}
1539+
1540+
return normalized
1541+
}
1542+
1543+
private extractStoragePrefix(fullKey: string, relativeKey: string): string | null {
1544+
if (!fullKey || fullKey === relativeKey) {
1545+
return null
1546+
}
1547+
1548+
const diffLength = fullKey.length - relativeKey.length
1549+
if (diffLength <= 0) {
1550+
return null
1551+
}
1552+
1553+
const prefix = fullKey.slice(0, diffLength).replace(/\/+$/, '')
1554+
return prefix.length > 0 ? prefix : null
1555+
}
1556+
1557+
private async relocateLivePhotoVideo(
1558+
manifest: PhotoManifestItem,
1559+
storageManager: StorageManager,
1560+
newPhotoKey: string,
1561+
): Promise<{ s3Key: string; videoUrl: string } | null> {
1562+
const { video } = manifest
1563+
if (!video || video.type !== 'live-photo' || !video.s3Key) {
1564+
return null
1565+
}
1566+
1567+
const normalizedVideoKey = this.normalizeKeyPath(video.s3Key)
1568+
const { basePath: newPhotoBase } = this.splitStorageKey(newPhotoKey)
1569+
if (!newPhotoBase) {
1570+
return null
1571+
}
1572+
1573+
const extension = path.extname(normalizedVideoKey) || '.mov'
1574+
const nextVideoKey = `${newPhotoBase}${extension}`
1575+
1576+
if (nextVideoKey === normalizedVideoKey) {
1577+
const videoUrl = await Promise.resolve(storageManager.generatePublicUrl(nextVideoKey))
1578+
return { s3Key: nextVideoKey, videoUrl }
1579+
}
1580+
1581+
const moved = this.normalizeStorageObjectKey(
1582+
await storageManager.moveFile(normalizedVideoKey, nextVideoKey, {
1583+
contentType: inferContentTypeFromKey(nextVideoKey),
1584+
}),
1585+
nextVideoKey,
1586+
)
1587+
const videoUrl = await Promise.resolve(storageManager.generatePublicUrl(moved.key ?? nextVideoKey))
1588+
return {
1589+
s3Key: moved.key ?? nextVideoKey,
1590+
videoUrl,
1591+
}
1592+
}
13761593
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { Body, ContextParam, Controller, Delete, Get, Post, Query } from '@afilmory/framework'
1+
import { Body, ContextParam, Controller, Delete, Get, Param, Patch, Post, Query } from '@afilmory/framework'
22
import { BizException, ErrorCode } from 'core/errors'
33
import { Roles } from 'core/guards/roles.decorator'
4+
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
45
import type { DataSyncProgressEvent } from 'core/modules/infrastructure/data-sync/data-sync.types'
56
import { createProgressSseResponse } from 'core/modules/shared/http/sse'
67
import type { Context } from 'hono'
78
import { inject } from 'tsyringe'
89

10+
import { UpdatePhotoTagsDto } from './photo-asset.dto'
911
import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.service'
1012
import { PhotoAssetService } from './photo-asset.service'
1113

@@ -20,6 +22,7 @@ export class PhotoController {
2022
constructor(@inject(PhotoAssetService) private readonly photoAssetService: PhotoAssetService) {}
2123

2224
@Get('assets')
25+
@BypassResponseTransform()
2326
async listAssets(): Promise<PhotoAssetListItem[]> {
2427
return await this.photoAssetService.listAssets()
2528
}
@@ -98,4 +101,9 @@ export class PhotoController {
98101
const url = await this.photoAssetService.generatePublicUrl(key)
99102
return { url }
100103
}
104+
105+
@Patch('assets/:id/tags')
106+
async updateAssetTags(@Param('id') id: string, @Body() body: UpdatePhotoTagsDto): Promise<PhotoAssetListItem> {
107+
return await this.photoAssetService.updateAssetTags(id, body.tags ?? [])
108+
}
101109
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import path from 'node:path'
2+
3+
const MIME_MAP: Record<string, string> = {
4+
'.jpg': 'image/jpeg',
5+
'.jpeg': 'image/jpeg',
6+
'.jfif': 'image/jpeg',
7+
'.pjpeg': 'image/jpeg',
8+
'.pjp': 'image/jpeg',
9+
'.png': 'image/png',
10+
'.gif': 'image/gif',
11+
'.webp': 'image/webp',
12+
'.avif': 'image/avif',
13+
'.heic': 'image/heic',
14+
'.heif': 'image/heic',
15+
'.hif': 'image/heic',
16+
'.bmp': 'image/bmp',
17+
'.tif': 'image/tiff',
18+
'.tiff': 'image/tiff',
19+
'.mov': 'video/quicktime',
20+
'.qt': 'video/quicktime',
21+
'.mp4': 'video/mp4',
22+
}
23+
24+
export function inferContentTypeFromKey(key: string): string | undefined {
25+
const ext = path.extname(key).toLowerCase()
26+
if (!ext) {
27+
return undefined
28+
}
29+
return MIME_MAP[ext]
30+
}

0 commit comments

Comments
 (0)