|
1 | 1 | import path from 'node:path' |
2 | 2 |
|
3 | 3 | import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageObject } from '@afilmory/builder' |
| 4 | +import { createStorageKeyNormalizer } from '@afilmory/builder/photo/index.js' |
4 | 5 | import { |
5 | 6 | DEFAULT_CONTENT_TYPE, |
6 | 7 | DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY, |
@@ -30,6 +31,7 @@ import { injectable } from 'tsyringe' |
30 | 31 |
|
31 | 32 | import { PhotoBuilderService } from '../builder/photo-builder.service' |
32 | 33 | import { PhotoStorageService } from '../storage/photo-storage.service' |
| 34 | +import { inferContentTypeFromKey } from './storage.utils' |
33 | 35 |
|
34 | 36 | type PhotoAssetRecord = typeof photoAssets.$inferSelect |
35 | 37 |
|
@@ -1002,6 +1004,136 @@ export class PhotoAssetService { |
1002 | 1004 | return await Promise.resolve(storageManager.generatePublicUrl(storageKey)) |
1003 | 1005 | } |
1004 | 1006 |
|
| 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 | + |
1005 | 1137 | private createUploadSummary(pendingCount: number, existingRecords: number): DataSyncResultSummary { |
1006 | 1138 | return { |
1007 | 1139 | storageObjects: pendingCount, |
@@ -1373,4 +1505,89 @@ export class PhotoAssetService { |
1373 | 1505 | const base = path.basename(value, ext) |
1374 | 1506 | return base.trim().toLowerCase() |
1375 | 1507 | } |
| 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 | + } |
1376 | 1593 | } |
0 commit comments