diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.dto.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.dto.ts new file mode 100644 index 00000000..ca1a0bc0 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.dto.ts @@ -0,0 +1,14 @@ +import { createZodDto } from '@afilmory/framework' +import { z } from 'zod' + +const tagSchema = z + .string() + .trim() + .min(1, { message: '标签不能为空' }) + .max(64, { message: '标签长度不能超过 64 个字符' }) + +export class UpdatePhotoTagsDto extends createZodDto( + z.object({ + tags: z.array(tagSchema).max(32, { message: '单张照片最多可设置 32 个标签' }), + }), +) {} diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index a89c41c4..29f139f5 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -1,6 +1,7 @@ import path from 'node:path' import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageObject } from '@afilmory/builder' +import { createStorageKeyNormalizer } from '@afilmory/builder/photo/index.js' import { DEFAULT_CONTENT_TYPE, DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY, @@ -30,6 +31,7 @@ import { injectable } from 'tsyringe' import { PhotoBuilderService } from '../builder/photo-builder.service' import { PhotoStorageService } from '../storage/photo-storage.service' +import { inferContentTypeFromKey } from './storage.utils' type PhotoAssetRecord = typeof photoAssets.$inferSelect @@ -1002,6 +1004,136 @@ export class PhotoAssetService { return await Promise.resolve(storageManager.generatePublicUrl(storageKey)) } + async updateAssetTags(assetId: string, tagsInput: readonly string[]): Promise { + const tenant = requireTenantContext() + const db = this.dbAccessor.get() + + if (!assetId) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少资源 ID' }) + } + + const record = await db + .select() + .from(photoAssets) + .where(and(eq(photoAssets.tenantId, tenant.tenant.id), eq(photoAssets.id, assetId))) + .limit(1) + .then((rows) => rows[0]) + + if (!record) { + throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到指定的图片资源' }) + } + + if (!record.manifest?.data) { + throw new BizException(ErrorCode.PHOTO_MANIFEST_GENERATION_FAILED, { message: '该资源缺少有效的清单数据' }) + } + + if (record.storageProvider === DATABASE_ONLY_PROVIDER) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '数据库占位资源不支持修改标签' }) + } + + const normalizedTags = this.normalizeTagList(tagsInput) + const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) + const storageManager = this.createStorageManager(builderConfig, storageConfig) + + const sanitizeKey = this.normalizeKeyPath(record.storageKey) + const normalizeStorageKey = createStorageKeyNormalizer(storageConfig) + const relativeKey = normalizeStorageKey(sanitizeKey) + const fileName = path.basename(relativeKey || sanitizeKey) + if (!fileName) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '无法解析当前图片文件名' }) + } + + const prefixSegment = this.extractStoragePrefix(sanitizeKey, relativeKey) + const tagDirectory = normalizedTags.length > 0 ? this.joinStorageSegments(...normalizedTags) : null + const newRelativeKey = tagDirectory ? `${tagDirectory}/${fileName}` : fileName + const normalizedRelativeKey = this.normalizeKeyPath(newRelativeKey) + const newStorageKey = prefixSegment + ? this.joinStorageSegments(prefixSegment, normalizedRelativeKey) + : normalizedRelativeKey + + const manifest = structuredClone(record.manifest) + const photoData = manifest.data + let storageSnapshot: ReturnType | null = null + + if (newStorageKey !== record.storageKey) { + const moved = this.normalizeStorageObjectKey( + await storageManager.moveFile(record.storageKey, newStorageKey, { + contentType: inferContentTypeFromKey(newStorageKey), + }), + newStorageKey, + ) + storageSnapshot = this.createStorageSnapshot(moved) + const livePhotoUpdate = await this.relocateLivePhotoVideo(photoData, storageManager, newStorageKey) + if (livePhotoUpdate) { + photoData.video = { + ...photoData.video, + type: 'live-photo', + videoUrl: livePhotoUpdate.videoUrl, + s3Key: livePhotoUpdate.s3Key, + } + } + + if (storageSnapshot.size !== null) { + photoData.size = storageSnapshot.size + } + if (storageSnapshot.lastModified) { + photoData.lastModified = storageSnapshot.lastModified + } + } + + const originalUrl = await Promise.resolve(storageManager.generatePublicUrl(newStorageKey)) + photoData.tags = normalizedTags + photoData.originalUrl = originalUrl + photoData.s3Key = newStorageKey + + const now = new Date().toISOString() + const updatePayload: Partial = { + storageKey: newStorageKey, + manifest, + updatedAt: now, + syncStatus: 'synced', + } + + if (storageSnapshot) { + updatePayload.size = storageSnapshot.size + updatePayload.etag = storageSnapshot.etag + updatePayload.lastModified = storageSnapshot.lastModified + updatePayload.metadataHash = storageSnapshot.metadataHash + updatePayload.syncedAt = now + } + + const [saved] = await db + .update(photoAssets) + .set(updatePayload) + .where(and(eq(photoAssets.id, record.id), eq(photoAssets.tenantId, tenant.tenant.id))) + .returning() + + if (!saved) { + throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: '更新标签失败,请稍后再试' }) + } + + const publicUrl = + saved.storageProvider === DATABASE_ONLY_PROVIDER + ? null + : await Promise.resolve(storageManager.generatePublicUrl(saved.storageKey)) + + await this.emitManifestChanged(tenant.tenant.id) + + return { + id: saved.id, + photoId: saved.photoId, + storageKey: saved.storageKey, + storageProvider: saved.storageProvider, + manifest: saved.manifest, + syncedAt: saved.syncedAt, + updatedAt: saved.updatedAt, + createdAt: saved.createdAt, + publicUrl, + size: saved.size ?? null, + syncStatus: saved.syncStatus, + } + } + private createUploadSummary(pendingCount: number, existingRecords: number): DataSyncResultSummary { return { storageObjects: pendingCount, @@ -1373,4 +1505,89 @@ export class PhotoAssetService { const base = path.basename(value, ext) return base.trim().toLowerCase() } + + private normalizeTagList(input: readonly string[] | undefined): string[] { + if (!Array.isArray(input) || input.length === 0) { + return [] + } + + const seen = new Set() + const normalized: string[] = [] + + for (const raw of input) { + if (typeof raw !== 'string') { + continue + } + const sanitized = raw + .replaceAll(/[\\/]+/g, '-') + .replaceAll(/\s+/g, ' ') + .trim() + if (!sanitized || sanitized === '.' || sanitized === '..') { + continue + } + const truncated = sanitized.slice(0, 64) + const key = truncated.toLowerCase() + if (seen.has(key)) { + continue + } + seen.add(key) + normalized.push(truncated) + if (normalized.length >= 32) { + break + } + } + + return normalized + } + + private extractStoragePrefix(fullKey: string, relativeKey: string): string | null { + if (!fullKey || fullKey === relativeKey) { + return null + } + + const diffLength = fullKey.length - relativeKey.length + if (diffLength <= 0) { + return null + } + + const prefix = fullKey.slice(0, diffLength).replace(/\/+$/, '') + return prefix.length > 0 ? prefix : null + } + + private async relocateLivePhotoVideo( + manifest: PhotoManifestItem, + storageManager: StorageManager, + newPhotoKey: string, + ): Promise<{ s3Key: string; videoUrl: string } | null> { + const { video } = manifest + if (!video || video.type !== 'live-photo' || !video.s3Key) { + return null + } + + const normalizedVideoKey = this.normalizeKeyPath(video.s3Key) + const { basePath: newPhotoBase } = this.splitStorageKey(newPhotoKey) + if (!newPhotoBase) { + return null + } + + const extension = path.extname(normalizedVideoKey) || '.mov' + const nextVideoKey = `${newPhotoBase}${extension}` + + if (nextVideoKey === normalizedVideoKey) { + const videoUrl = await Promise.resolve(storageManager.generatePublicUrl(nextVideoKey)) + return { s3Key: nextVideoKey, videoUrl } + } + + const moved = this.normalizeStorageObjectKey( + await storageManager.moveFile(normalizedVideoKey, nextVideoKey, { + contentType: inferContentTypeFromKey(nextVideoKey), + }), + nextVideoKey, + ) + const videoUrl = await Promise.resolve(storageManager.generatePublicUrl(moved.key ?? nextVideoKey)) + return { + s3Key: moved.key ?? nextVideoKey, + videoUrl, + } + } } diff --git a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts index 44272c03..a0e8155e 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts @@ -1,11 +1,13 @@ -import { Body, ContextParam, Controller, Delete, Get, Post, Query } from '@afilmory/framework' +import { Body, ContextParam, Controller, Delete, Get, Param, Patch, Post, Query } from '@afilmory/framework' import { BizException, ErrorCode } from 'core/errors' import { Roles } from 'core/guards/roles.decorator' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' import type { DataSyncProgressEvent } from 'core/modules/infrastructure/data-sync/data-sync.types' import { createProgressSseResponse } from 'core/modules/shared/http/sse' import type { Context } from 'hono' import { inject } from 'tsyringe' +import { UpdatePhotoTagsDto } from './photo-asset.dto' import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.service' import { PhotoAssetService } from './photo-asset.service' @@ -20,6 +22,7 @@ export class PhotoController { constructor(@inject(PhotoAssetService) private readonly photoAssetService: PhotoAssetService) {} @Get('assets') + @BypassResponseTransform() async listAssets(): Promise { return await this.photoAssetService.listAssets() } @@ -98,4 +101,9 @@ export class PhotoController { const url = await this.photoAssetService.generatePublicUrl(key) return { url } } + + @Patch('assets/:id/tags') + async updateAssetTags(@Param('id') id: string, @Body() body: UpdatePhotoTagsDto): Promise { + return await this.photoAssetService.updateAssetTags(id, body.tags ?? []) + } } diff --git a/be/apps/core/src/modules/content/photo/assets/storage.utils.ts b/be/apps/core/src/modules/content/photo/assets/storage.utils.ts new file mode 100644 index 00000000..a365d5e3 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/assets/storage.utils.ts @@ -0,0 +1,30 @@ +import path from 'node:path' + +const MIME_MAP: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.jfif': 'image/jpeg', + '.pjpeg': 'image/jpeg', + '.pjp': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.heic': 'image/heic', + '.heif': 'image/heic', + '.hif': 'image/heic', + '.bmp': 'image/bmp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mov': 'video/quicktime', + '.qt': 'video/quicktime', + '.mp4': 'video/mp4', +} + +export function inferContentTypeFromKey(key: string): string | undefined { + const ext = path.extname(key).toLowerCase() + if (!ext) { + return undefined + } + return MIME_MAP[ext] +} diff --git a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts index df619a00..6b09c653 100644 --- a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts +++ b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder' import type { PhotoAssetConflictPayload, PhotoAssetConflictSnapshot, PhotoAssetManifest } from '@afilmory/db' import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, photoSyncRuns } from '@afilmory/db' @@ -867,6 +869,21 @@ export class DataSyncService { for (const candidate of context.conflictCandidates) { processed += 1 const { record, storageObject, storageSnapshot, recordSnapshot } = candidate + + const resolvedWithDigest = await this.tryResolveMetadataMismatchWithDigest({ + context, + candidate, + dryRun, + summary, + actions, + onProgress, + index: processed, + total, + }) + if (resolvedWithDigest) { + continue + } + summary.conflicts += 1 const conflictPayload = this.createConflictPayload('metadata-mismatch', { @@ -917,6 +934,84 @@ export class DataSyncService { return processed } + private async tryResolveMetadataMismatchWithDigest(params: { + context: SyncPreparation + candidate: ConflictCandidate + dryRun: boolean + summary: DataSyncResult['summary'] + actions: DataSyncAction[] + onProgress?: DataSyncProgressEmitter + index: number + total: number + }): Promise { + const { context, candidate, dryRun, summary, actions, onProgress, index, total } = params + const digest = candidate.record.manifest?.data?.digest + if (!digest) { + return false + } + + let buffer: Buffer | null = null + try { + buffer = await context.storageManager.getFile(candidate.storageObject.key) + } catch (error) { + this.logger.warn('Failed to download object for digest comparison', { + key: candidate.storageObject.key, + error, + }) + return false + } + if (!buffer) { + return false + } + + const computedDigest = createHash('sha256').update(buffer).digest('hex') + if (computedDigest !== digest) { + return false + } + + if (!dryRun) { + const now = this.nowIso() + await context.db + .update(photoAssets) + .set({ + syncStatus: 'synced', + conflictReason: null, + conflictPayload: null, + size: candidate.storageSnapshot.size ?? null, + etag: candidate.storageSnapshot.etag ?? null, + lastModified: candidate.storageSnapshot.lastModified ?? null, + metadataHash: candidate.storageSnapshot.metadataHash, + syncedAt: now, + updatedAt: now, + }) + .where(and(eq(photoAssets.id, candidate.record.id), eq(photoAssets.tenantId, context.tenantId))) + } + + summary.updated += 1 + const action: DataSyncAction = { + type: 'update', + storageKey: candidate.storageObject.key, + photoId: candidate.record.photoId, + applied: !dryRun, + reason: 'Storage metadata refreshed (content digest matched)', + snapshots: { + before: candidate.recordSnapshot, + after: candidate.storageSnapshot, + }, + manifestBefore: candidate.record.manifest.data, + manifestAfter: candidate.record.manifest.data, + } + actions.push(action) + await this.emitActionProgress(onProgress, { + stage: 'metadata-conflicts', + index, + total, + action, + summary, + }) + return true + } + private async handleStatusReconciliation( context: SyncPreparation, summary: DataSyncResult['summary'], diff --git a/be/apps/dashboard/src/modules/photos/api.ts b/be/apps/dashboard/src/modules/photos/api.ts index cd5b9829..bfe56e18 100644 --- a/be/apps/dashboard/src/modules/photos/api.ts +++ b/be/apps/dashboard/src/modules/photos/api.ts @@ -226,7 +226,7 @@ export async function resolvePhotoSyncConflict( export async function listPhotoAssets(): Promise { const assets = await coreApi('/photos/assets') - return camelCaseKeys(assets) + return assets } export async function getPhotoAssetSummary(): Promise { @@ -245,6 +245,15 @@ export async function deletePhotoAssets(ids: string[], options?: { deleteFromSto }) } +export async function updatePhotoAssetTags(id: string, tags: string[]): Promise { + const asset = await coreApi(`/photos/assets/${id}/tags`, { + method: 'PATCH', + body: { tags }, + }) + + return camelCaseKeys(asset) +} + export async function uploadPhotoAssets( files: File[], options?: UploadPhotoAssetsOptions, diff --git a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx index ea690b59..0d0266e5 100644 --- a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx +++ b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx @@ -256,8 +256,12 @@ function PhotoPageContent({ activeTab }: PhotoPageContentProps) { void summaryQuery.refetch() refetchLibraryAssets() void refetchSyncStatus() + // 如果同步结果中有冲突,刷新冲突列表 + if (data.summary.conflicts > 0) { + void conflictsQuery.refetch() + } }, - [summaryQuery, refetchLibraryAssets, refetchSyncStatus], + [summaryQuery, refetchLibraryAssets, refetchSyncStatus, conflictsQuery], ) const handleResolveConflict = useCallback( @@ -336,7 +340,10 @@ function PhotoPageContent({ activeTab }: PhotoPageContentProps) { ) const showConflictsPanel = - conflictsQuery.isLoading || conflictsQuery.isFetching || (conflictsQuery.data?.length ?? 0) > 0 + conflictsQuery.isLoading || + conflictsQuery.isFetching || + (conflictsQuery.data?.length ?? 0) > 0 || + (result?.summary.conflicts ?? 0) > 0 let tabContent: ReactNode | null = null diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx index bedaad45..c5f2d56e 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx @@ -2,10 +2,11 @@ import { Button, Modal } from '@afilmory/ui' import { clsxm } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import type { ChangeEventHandler } from 'react' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import { useShallow } from 'zustand/shallow' import { usePhotoLibraryStore } from './PhotoLibraryProvider' +import { PhotoTagEditorModal } from './PhotoTagEditorModal' import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal' export function PhotoLibraryActionBar() { @@ -19,6 +20,8 @@ export function PhotoLibraryActionBar() { deleteSelected, clearSelection, selectAll, + selectedIds, + assets, } = usePhotoLibraryStore( useShallow((state) => ({ selectionCount: state.selectedIds.length, @@ -30,12 +33,21 @@ export function PhotoLibraryActionBar() { deleteSelected: state.deleteSelected, clearSelection: state.clearSelection, selectAll: state.selectAll, + selectedIds: state.selectedIds, + assets: state.assets ?? [], })), ) const fileInputRef = useRef(null) const hasSelection = selectionCount > 0 const hasAssets = totalCount > 0 const canSelectAll = hasAssets && selectionCount < totalCount + const selectedAssets = useMemo(() => { + if (!assets || assets.length === 0 || selectedIds.length === 0) { + return [] + } + const idSet = new Set(selectedIds) + return assets.filter((asset) => idSet.has(asset.id)) + }, [assets, selectedIds]) const handleUploadClick = () => { fileInputRef.current?.click() @@ -64,6 +76,16 @@ export function PhotoLibraryActionBar() { } } + const handleEditSelectedTags = () => { + if (selectedAssets.length === 0) { + return + } + Modal.present(PhotoTagEditorModal, { + assets: selectedAssets, + availableTags, + }) + } + return (
@@ -104,6 +126,17 @@ export function PhotoLibraryActionBar() { > 已选 {selectionCount} 项 +
-
-
- {deviceLabel} - {updatedAtLabel} - {fileSizeLabel} +
+
+
+ + {deviceLabel} +
+
+ + {updatedAtLabel} +
+
+ + {fileSizeLabel} +
-
+
- - + + + + + + } + onSelect={() => onEditTags(asset)} + > + 编辑标签 + + } + disabled={!manifest} + onSelect={handleViewExif} + > + 查看 EXIF + +
+ } + disabled={isDeleting} + onSelect={handleDelete} + className="text-red focus:text-red focus:bg-red/10" + > + 删除资源 + + +
@@ -216,18 +241,29 @@ export function PhotoLibraryGrid() { const columnWidth = viewport.sm ? 320 : 160 const [sortBy, setSortBy] = useState('uploadedAt') const [sortOrder, setSortOrder] = useState('desc') - const { assets, isLoading, selectedIds, toggleSelect, openAsset, deleteAsset, isDeleting } = usePhotoLibraryStore( - useShallow((state) => ({ - assets: state.assets, - isLoading: state.isLoading, - selectedIds: state.selectedIds, - toggleSelect: state.toggleSelect, - openAsset: state.openAsset, - deleteAsset: state.deleteAsset, - isDeleting: state.isDeleting, - })), - ) + const { assets, isLoading, selectedIds, toggleSelect, openAsset, deleteAsset, availableTags, isDeleting } = + usePhotoLibraryStore( + useShallow((state) => ({ + assets: state.assets, + isLoading: state.isLoading, + selectedIds: state.selectedIds, + toggleSelect: state.toggleSelect, + openAsset: state.openAsset, + deleteAsset: state.deleteAsset, + isDeleting: state.isDeleting, + availableTags: state.availableTags, + })), + ) const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const handleEditTags = useCallback( + (asset: PhotoAssetListItem) => { + Modal.present(PhotoTagEditorModal, { + assets: [asset], + availableTags, + }) + }, + [availableTags], + ) const sortedAssets = useMemo(() => { if (!assets) return @@ -271,6 +307,7 @@ export function PhotoLibraryGrid() { onToggleSelect={toggleSelect} onOpenAsset={openAsset} onDeleteAsset={deleteAsset} + onEditTags={handleEditTags} isDeleting={isDeleting} /> )} @@ -283,8 +320,8 @@ export function PhotoLibraryGrid() { const currentSortOrder = SORT_ORDER_OPTIONS.find((option) => option.value === sortOrder) ?? SORT_ORDER_OPTIONS[0] return ( -
-
+
+
+ + +
+ ) +} + +PhotoTagEditorModal.contentClassName = 'w-[min(520px,92vw)]' diff --git a/be/apps/dashboard/src/modules/photos/hooks.ts b/be/apps/dashboard/src/modules/photos/hooks.ts index 59d148e8..eb40e887 100644 --- a/be/apps/dashboard/src/modules/photos/hooks.ts +++ b/be/apps/dashboard/src/modules/photos/hooks.ts @@ -9,6 +9,7 @@ import { listPhotoAssets, listPhotoSyncConflicts, resolvePhotoSyncConflict, + updatePhotoAssetTags, uploadPhotoAssets, } from './api' import type { PhotoAssetListItem, PhotoSyncResolution } from './types' @@ -140,3 +141,19 @@ export function useResolvePhotoSyncConflictMutation() { }, }) } + +export function useUpdatePhotoTagsMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables: { id: string; tags: string[] }) => { + return await updatePhotoAssetTags(variables.id, variables.tags) + }, + onSuccess: (asset) => { + queryClient.setQueryData(PHOTO_ASSET_LIST_QUERY_KEY, (previous) => { + if (!previous) return previous + return previous.map((item) => (item.id === asset.id ? asset : item)) + }) + }, + }) +} diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts index 810201af..63ce5863 100644 --- a/packages/builder/src/photo/image-pipeline.ts +++ b/packages/builder/src/photo/image-pipeline.ts @@ -165,6 +165,7 @@ export async function executePhotoProcessingPipeline( if (!processedData) return null const { sharpInstance, imageBuffer, metadata } = processedData + const contentDigest = crypto.createHash('sha256').update(imageBuffer).digest('hex') // 3. 处理缩略图和 blurhash const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId, existingItem, options) @@ -223,23 +224,24 @@ export async function executePhotoProcessingPipeline( s3Key: photoKey, lastModified: obj.LastModified?.toISOString() || new Date().toISOString(), size: obj.Size || 0, + digest: contentDigest, exif: exifData, toneAnalysis, // Video source (Motion Photo or Live Photo) video: motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined ? { - type: 'motion-photo', - offset: motionPhotoMetadata.motionPhotoOffset, - size: motionPhotoMetadata.motionPhotoVideoSize, - presentationTimestamp: motionPhotoMetadata.presentationTimestampUs, - } + type: 'motion-photo', + offset: motionPhotoMetadata.motionPhotoOffset, + size: motionPhotoMetadata.motionPhotoVideoSize, + presentationTimestamp: motionPhotoMetadata.presentationTimestampUs, + } : livePhotoResult.isLivePhoto ? { - type: 'live-photo', - videoUrl: livePhotoResult.livePhotoVideoUrl!, - s3Key: livePhotoResult.livePhotoVideoS3Key!, - } + type: 'live-photo', + videoUrl: livePhotoResult.livePhotoVideoUrl!, + s3Key: livePhotoResult.livePhotoVideoS3Key!, + } : undefined, // HDR 相关字段 isHDR: diff --git a/packages/builder/src/storage/interfaces.ts b/packages/builder/src/storage/interfaces.ts index 7a765eca..9d43cc40 100644 --- a/packages/builder/src/storage/interfaces.ts +++ b/packages/builder/src/storage/interfaces.ts @@ -69,6 +69,14 @@ export interface StorageProvider { * @param options 上传选项 */ uploadFile: (key: string, data: Buffer, options?: StorageUploadOptions) => Promise + + /** + * 将存储中的文件移动到新的键值/路径 + * @param sourceKey 原文件键值 + * @param targetKey 目标文件键值 + * @param options 上传选项(供部分存储在复制时复用) + */ + moveFile: (sourceKey: string, targetKey: string, options?: StorageUploadOptions) => Promise } export type S3Config = { diff --git a/packages/builder/src/storage/manager.ts b/packages/builder/src/storage/manager.ts index bcd9b271..7ca44ff8 100644 --- a/packages/builder/src/storage/manager.ts +++ b/packages/builder/src/storage/manager.ts @@ -77,6 +77,46 @@ export class StorageManager { return await this.provider.uploadFile(key, data, options) } + async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise { + if (!sourceKey || !targetKey) { + throw new Error('moveFile requires both sourceKey and targetKey') + } + + if (sourceKey === targetKey) { + const buffer = await this.provider.getFile(sourceKey) + if (!buffer) { + throw new Error(`moveFile failed: source ${sourceKey} does not exist`) + } + return { + key: targetKey, + size: buffer.length, + lastModified: new Date(), + } + } + + if (typeof this.provider.moveFile === 'function') { + return await this.provider.moveFile(sourceKey, targetKey, options) + } + + // Fallback: download, upload, delete + const fileBuffer = await this.provider.getFile(sourceKey) + if (!fileBuffer) { + throw new Error(`moveFile failed: source ${sourceKey} does not exist`) + } + const uploaded = await this.provider.uploadFile(targetKey, fileBuffer, options) + try { + await this.provider.deleteFile(sourceKey) + } catch (error) { + try { + await this.provider.deleteFile(targetKey) + } catch { + // ignore rollback error, rethrow original failure + } + throw error + } + return uploaded + } + addExcludeFilter(filter: (key: string) => boolean): void { this.excludeFilters.push(filter) } diff --git a/packages/builder/src/storage/providers/eagle-provider.ts b/packages/builder/src/storage/providers/eagle-provider.ts index 8506af42..0234bb76 100644 --- a/packages/builder/src/storage/providers/eagle-provider.ts +++ b/packages/builder/src/storage/providers/eagle-provider.ts @@ -192,6 +192,10 @@ export class EagleStorageProvider implements StorageProvider { throw new Error('EagleStorageProvider: 当前不支持上传文件操作') } + async moveFile(_sourceKey: string, _targetKey: string): Promise { + throw new Error('EagleStorageProvider: 当前不支持移动文件操作') + } + async generatePublicUrl(key: string) { const imageName = await this.copyToDist(key) const publicPath = path.join(this.config.baseUrl, imageName) diff --git a/packages/builder/src/storage/providers/github-provider.ts b/packages/builder/src/storage/providers/github-provider.ts index 1a609cdc..1f6aafc9 100644 --- a/packages/builder/src/storage/providers/github-provider.ts +++ b/packages/builder/src/storage/providers/github-provider.ts @@ -295,6 +295,36 @@ export class GitHubStorageProvider implements StorageProvider { } } + async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise { + if (sourceKey === targetKey) { + const metadata = await this.fetchContentMetadata(sourceKey) + return { + key: targetKey, + size: metadata?.size, + lastModified: new Date(), + etag: metadata?.sha, + } + } + + const buffer = await this.getFile(sourceKey) + if (!buffer) { + throw new Error(`GitHub move failed:源文件不存在 ${sourceKey}`) + } + + const uploaded = await this.uploadFile(targetKey, buffer, options) + try { + await this.deleteFile(sourceKey) + } catch (error) { + try { + await this.deleteFile(targetKey) + } catch { + // ignore rollback failure + } + throw error + } + return uploaded + } + generatePublicUrl(key: string): string { const fullPath = this.getFullPath(key) diff --git a/packages/builder/src/storage/providers/local-provider.ts b/packages/builder/src/storage/providers/local-provider.ts index c1adffad..55c2fad5 100644 --- a/packages/builder/src/storage/providers/local-provider.ts +++ b/packages/builder/src/storage/providers/local-provider.ts @@ -203,6 +203,38 @@ export class LocalStorageProvider implements StorageProvider { } } + async moveFile(sourceKey: string, targetKey: string): Promise { + const sourcePath = this.resolveSafePath(sourceKey) + const targetPath = this.resolveSafePath(targetKey) + + if (sourceKey === targetKey) { + const stats = await fs.stat(sourcePath) + return { + key: targetKey, + size: stats.size, + lastModified: stats.mtime, + } + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }) + try { + await fs.rename(sourcePath, targetPath) + } catch (error) { + this.logger.error(`重命名本地文件失败:${sourceKey} -> ${targetKey}`, error) + throw error + } + + await this.syncDistFile(targetKey, targetPath) + await this.removeDistFile(sourceKey) + + const stats = await fs.stat(targetPath) + return { + key: targetKey, + size: stats.size, + lastModified: stats.mtime, + } + } + private async scanDirectory( dirPath: string, relativePath: string, diff --git a/packages/builder/src/storage/providers/s3-client.ts b/packages/builder/src/storage/providers/s3-client.ts new file mode 100644 index 00000000..b52b0301 --- /dev/null +++ b/packages/builder/src/storage/providers/s3-client.ts @@ -0,0 +1,107 @@ +import type { SimpleS3Client } from '../../s3/client.js' +import { createS3Client, encodeS3Key } from '../../s3/client.js' +import type { S3Config } from '../interfaces.js' +import { sanitizeS3Etag } from './s3-utils.js' + +export class S3ProviderClient { + private readonly client: SimpleS3Client + + constructor(config: S3Config) { + this.client = createS3Client(config) + } + + buildObjectUrl(key?: string): string { + return this.client.buildObjectUrl(key) + } + + async getObject(key: string, init?: RequestInit): Promise { + return await this.client.fetch(this.buildObjectUrl(key), { + method: 'GET', + ...init, + }) + } + + async headObject(key: string): Promise { + return await this.client.fetch(this.buildObjectUrl(key), { + method: 'HEAD', + }) + } + + async putObject(key: string, body: BodyInit, headers?: HeadersInit): Promise { + return await this.client.fetch(this.buildObjectUrl(key), { + method: 'PUT', + body, + headers, + }) + } + + async deleteObject(key: string): Promise { + return await this.client.fetch(this.buildObjectUrl(key), { + method: 'DELETE', + }) + } + + async copyObject(sourceKey: string, targetKey: string, headers?: HeadersInit): Promise { + const copySource = `/${this.client.bucket}/${encodeS3Key(sourceKey)}` + return await this.client.fetch(this.buildObjectUrl(targetKey), { + method: 'PUT', + headers: { + 'x-amz-copy-source': copySource, + ...headers, + }, + }) + } + + async listObjects(params?: { prefix?: string | null; maxKeys?: number }): Promise { + const url = new URL(this.buildObjectUrl()) + url.searchParams.set('list-type', '2') + if (params?.prefix) { + url.searchParams.set('prefix', encodeS3Key(params.prefix)) + } + if (params?.maxKeys) { + url.searchParams.set('max-keys', String(params.maxKeys)) + } + return await this.client.fetch(url.toString(), { method: 'GET' }) + } + + async moveObject( + sourceKey: string, + targetKey: string, + options?: { headers?: HeadersInit }, + ): Promise<{ metadata: { key: string; size?: number; etag?: string | null; lastModified?: Date } }> { + const copyResponse = await this.copyObject(sourceKey, targetKey, options?.headers) + if (!copyResponse.ok) { + const text = await copyResponse.text().catch(() => '') + throw new Error( + `复制 S3 对象失败:${sourceKey} -> ${targetKey} (status ${copyResponse.status}) ${text ? text.slice(0, 200) : ''}`, + ) + } + + let metadata: { key: string; size?: number; etag?: string | null; lastModified?: Date } = { key: targetKey } + const headResponse = await this.headObject(targetKey) + if (headResponse.ok) { + const size = headResponse.headers.get('content-length') + const etag = headResponse.headers.get('etag') + const lastModified = headResponse.headers.get('last-modified') + metadata = { + key: targetKey, + size: size ? Number(size) : undefined, + etag: sanitizeS3Etag(etag) ?? null, + lastModified: lastModified ? new Date(lastModified) : undefined, + } + } + + const deleteResponse = await this.deleteObject(sourceKey) + if (!deleteResponse.ok) { + await this.deleteObject(targetKey).catch(() => { + /* ignore */ + }) + const text = await deleteResponse.text().catch(() => '') + throw new Error( + `删除源对象失败:${sourceKey} (status ${deleteResponse.status}) ${text ? text.slice(0, 200) : ''}`, + ) + } + + return { metadata } + } +} diff --git a/packages/builder/src/storage/providers/s3-provider.ts b/packages/builder/src/storage/providers/s3-provider.ts index f78246a6..2db580b1 100644 --- a/packages/builder/src/storage/providers/s3-provider.ts +++ b/packages/builder/src/storage/providers/s3-provider.ts @@ -6,9 +6,9 @@ import { backoffDelay, sleep } from '../../../../utils/src/backoff.js' import { Semaphore } from '../../../../utils/src/semaphore.js' import { SUPPORTED_FORMATS } from '../../constants/index.js' import { logger } from '../../logger/index.js' -import type { SimpleS3Client } from '../../s3/client.js' -import { createS3Client, encodeS3Key } from '../../s3/client.js' import type { ProgressCallback, S3Config, StorageObject, StorageProvider, StorageUploadOptions } from '../interfaces' +import { S3ProviderClient } from './s3-client.js' +import { sanitizeS3Etag } from './s3-utils.js' // 将 AWS S3 对象转换为通用存储对象 const xmlParser = new XMLParser({ ignoreAttributes: false }) @@ -110,19 +110,15 @@ function formatS3ErrorBody(body?: string | null): string { export class S3StorageProvider implements StorageProvider { private config: S3Config - private client: SimpleS3Client + private client: S3ProviderClient private limiter: Semaphore constructor(config: S3Config) { this.config = config - this.client = createS3Client(config) + this.client = new S3ProviderClient(config) this.limiter = new Semaphore(this.config.downloadConcurrency ?? 16) } - private buildObjectUrl(key?: string): string { - return this.client.buildObjectUrl(key) - } - private async readStreamWithIdleTimeout( response: Response, controller: AbortController, @@ -184,8 +180,7 @@ export class S3StorageProvider implements StorageProvider { try { logger.s3.info(`下载开始:${key} (attempt ${attempt}/${maxAttempts})`) - const response = await this.client.fetch(this.buildObjectUrl(key), { - method: 'GET', + const response = await this.client.getObject(key, { signal: controller.signal, }) @@ -331,17 +326,10 @@ export class S3StorageProvider implements StorageProvider { } private async listObjects(): Promise { - const url = new URL(this.buildObjectUrl()) - url.search = '' - url.searchParams.set('list-type', '2') - if (this.config.prefix) { - url.searchParams.set('prefix', encodeS3Key(this.config.prefix)) - } - if (this.config.maxFileLimit) { - url.searchParams.set('max-keys', String(this.config.maxFileLimit)) - } - - const response = await this.client.fetch(url.toString(), { method: 'GET' }) + const response = await this.client.listObjects({ + prefix: this.config.prefix, + maxKeys: this.config.maxFileLimit, + }) const text = await response.text() if (!response.ok) { throw new Error(`列出 S3 对象失败 (status ${response.status}): ${formatS3ErrorBody(text)}`) @@ -357,16 +345,14 @@ export class S3StorageProvider implements StorageProvider { key, size: item?.Size !== undefined ? Number(item.Size) : undefined, lastModified: item?.LastModified ? new Date(item.LastModified) : undefined, - etag: typeof item?.ETag === 'string' ? item.ETag.replaceAll('"', '') : undefined, + etag: sanitizeS3Etag(typeof item?.ETag === 'string' ? item.ETag : undefined), } satisfies StorageObject }) .filter((item) => Boolean(item.key)) } async deleteFile(key: string): Promise { - const response = await this.client.fetch(this.buildObjectUrl(key), { - method: 'DELETE', - }) + const response = await this.client.deleteObject(key) if (!response.ok) { const text = await response.text().catch(() => '') @@ -375,13 +361,9 @@ export class S3StorageProvider implements StorageProvider { } async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise { - const response = await this.client.fetch(this.buildObjectUrl(key), { - method: 'PUT', - body: data as unknown as BodyInit, - headers: { - 'content-type': options?.contentType ?? 'application/octet-stream', - 'content-length': data.byteLength.toString(), - }, + const response = await this.client.putObject(key, data as unknown as BodyInit, { + 'content-type': options?.contentType ?? 'application/octet-stream', + 'content-length': data.byteLength.toString(), }) if (!response.ok) { @@ -395,7 +377,31 @@ export class S3StorageProvider implements StorageProvider { key, size: data.byteLength, lastModified, - etag: response.headers.get('etag') ?? undefined, + etag: sanitizeS3Etag(response.headers.get('etag')), + } + } + + async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise { + if (sourceKey === targetKey) { + const object = await this.getFile(sourceKey) + if (!object) { + throw new Error(`S3 move failed:源文件不存在 ${sourceKey}`) + } + return { + key: targetKey, + size: object.length, + lastModified: new Date(), + } + } + + const { metadata } = await this.client.moveObject(sourceKey, targetKey, { + headers: options?.contentType ? { 'content-type': options.contentType } : undefined, + }) + return { + key: metadata.key, + size: metadata.size, + lastModified: metadata.lastModified, + etag: sanitizeS3Etag(metadata.etag), } } } diff --git a/packages/builder/src/storage/providers/s3-utils.ts b/packages/builder/src/storage/providers/s3-utils.ts new file mode 100644 index 00000000..c8864e79 --- /dev/null +++ b/packages/builder/src/storage/providers/s3-utils.ts @@ -0,0 +1,7 @@ +export function sanitizeS3Etag(value?: string | null): string | undefined { + if (!value) { + return undefined + } + const normalized = value.replaceAll('"', '').trim() + return normalized.length > 0 ? normalized : undefined +} diff --git a/packages/builder/src/types/photo.ts b/packages/builder/src/types/photo.ts index 310bb3f1..00ee0cea 100644 --- a/packages/builder/src/types/photo.ts +++ b/packages/builder/src/types/photo.ts @@ -57,6 +57,7 @@ export interface PhotoManifestItem extends PhotoInfo { s3Key: string lastModified: string size: number + digest?: string exif: PickedExif | null toneAnalysis: ToneAnalysis | null // 影调分析结果 isHDR?: boolean diff --git a/packages/ui/src/modal/Dialog.tsx b/packages/ui/src/modal/Dialog.tsx index f51a44a7..6ea62c57 100644 --- a/packages/ui/src/modal/Dialog.tsx +++ b/packages/ui/src/modal/Dialog.tsx @@ -162,8 +162,8 @@ function DialogContent({ {...props} > {children} - -