mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -69,6 +69,14 @@ export interface StorageProvider {
|
||||
* @param options 上传选项
|
||||
*/
|
||||
uploadFile: (key: string, data: Buffer, options?: StorageUploadOptions) => Promise<StorageObject>
|
||||
|
||||
/**
|
||||
* 将存储中的文件移动到新的键值/路径
|
||||
* @param sourceKey 原文件键值
|
||||
* @param targetKey 目标文件键值
|
||||
* @param options 上传选项(供部分存储在复制时复用)
|
||||
*/
|
||||
moveFile: (sourceKey: string, targetKey: string, options?: StorageUploadOptions) => Promise<StorageObject>
|
||||
}
|
||||
|
||||
export type S3Config = {
|
||||
|
||||
@@ -77,6 +77,46 @@ export class StorageManager {
|
||||
return await this.provider.uploadFile(key, data, options)
|
||||
}
|
||||
|
||||
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -192,6 +192,10 @@ export class EagleStorageProvider implements StorageProvider {
|
||||
throw new Error('EagleStorageProvider: 当前不支持上传文件操作')
|
||||
}
|
||||
|
||||
async moveFile(_sourceKey: string, _targetKey: string): Promise<StorageObject> {
|
||||
throw new Error('EagleStorageProvider: 当前不支持移动文件操作')
|
||||
}
|
||||
|
||||
async generatePublicUrl(key: string) {
|
||||
const imageName = await this.copyToDist(key)
|
||||
const publicPath = path.join(this.config.baseUrl, imageName)
|
||||
|
||||
@@ -295,6 +295,36 @@ export class GitHubStorageProvider implements StorageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -203,6 +203,38 @@ export class LocalStorageProvider implements StorageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async moveFile(sourceKey: string, targetKey: string): Promise<StorageObject> {
|
||||
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,
|
||||
|
||||
107
packages/builder/src/storage/providers/s3-client.ts
Normal file
107
packages/builder/src/storage/providers/s3-client.ts
Normal file
@@ -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<Response> {
|
||||
return await this.client.fetch(this.buildObjectUrl(key), {
|
||||
method: 'GET',
|
||||
...init,
|
||||
})
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<Response> {
|
||||
return await this.client.fetch(this.buildObjectUrl(key), {
|
||||
method: 'HEAD',
|
||||
})
|
||||
}
|
||||
|
||||
async putObject(key: string, body: BodyInit, headers?: HeadersInit): Promise<Response> {
|
||||
return await this.client.fetch(this.buildObjectUrl(key), {
|
||||
method: 'PUT',
|
||||
body,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<Response> {
|
||||
return await this.client.fetch(this.buildObjectUrl(key), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async copyObject(sourceKey: string, targetKey: string, headers?: HeadersInit): Promise<Response> {
|
||||
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<Response> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<StorageObject[]> {
|
||||
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<void> {
|
||||
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<StorageObject> {
|
||||
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<StorageObject> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
packages/builder/src/storage/providers/s3-utils.ts
Normal file
7
packages/builder/src/storage/providers/s3-utils.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -162,8 +162,8 @@ function DialogContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-2 flex size-6 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
|
||||
<i className="i-mingcute-close-line h-4 w-4" aria-hidden="true" />
|
||||
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-2 flex size-6 items-center justify-center rounded opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
|
||||
<i className="i-mingcute-close-line size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user