From 0af18ada665afb238960df7da306354ccdb0a6f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 20:03:51 -0700 Subject: [PATCH] feat: per-type attachment metadata (image dims, video/audio/doc fields) - AttachmentMetadata discriminated union (image/video/audio/document/file) in zod/openapi; attachments table gains metadata + metadata_kind (0003) - create/PATCH accept client-supplied metadata; finalize auto-extracts image width/height/format from R2 head bytes via image-size (no client override) - serializeAttachment surfaces parsed metadata in all responses Co-Authored-By: Claude --- src/attachment-service.ts | 90 ++++++++++++++++++++++++---- src/attachment-store.ts | 28 ++++++++- src/object-store.ts | 56 ++++++++++++++++++ src/schemas.ts | 40 +++++++++++++ tests/attachment-worker.test.ts | 100 ++++++++++++++++++++++++++++++++ tests/fakes.ts | 4 +- 6 files changed, 302 insertions(+), 16 deletions(-) diff --git a/src/attachment-service.ts b/src/attachment-service.ts index 20b1b14..a86f7f5 100644 --- a/src/attachment-service.ts +++ b/src/attachment-service.ts @@ -7,6 +7,7 @@ import { abortMultipartUpload, completeMultipartUpload, createMultipartUpload, + extractImageMetadata, getObjectOrNull, objectKeyFor, putObject, @@ -39,6 +40,7 @@ import { markUploadSessionAborted, markUploadSessionCompleted, searchAttachments, + setAttachmentMetadata, setAttachmentTagIds, softDeleteAttachment, updateAttachmentFields, @@ -52,6 +54,7 @@ import { type TagRecord, type UploadSessionRecord, } from "./attachment-store"; +import type { AttachmentMetadata } from "./schemas"; const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800; const MIN_PART_BYTES = 5 * 1024 * 1024; @@ -80,9 +83,12 @@ function validateVisibility(value: string): string { return value; } -export type AttachmentView = AttachmentRecord & { public_url: string | null }; +export type AttachmentView = Omit & { + metadata: AttachmentMetadata | null; + public_url: string | null; +}; -/** 按需计算公开静态 URL:仅 available 且 visibility∈{public,shared} 才有。 */ +/** 按需计算公开静态 URL:仅 available 且 visibility∈{public,shared} 才有。metadata 解析回对象。 */ export function serializeAttachment(record: AttachmentRecord, env: Env): AttachmentView { const publicUrl = record.status === "available" && @@ -90,7 +96,15 @@ export function serializeAttachment(record: AttachmentRecord, env: Env): Attachm env.PUBLIC_BASE_URL ? `${env.PUBLIC_BASE_URL}/files/${record.id}` : null; - return { ...record, public_url: publicUrl }; + let metadata: AttachmentMetadata | null = null; + if (record.metadata) { + try { + metadata = JSON.parse(record.metadata) as AttachmentMetadata; + } catch { + metadata = null; + } + } + return { ...record, metadata, public_url: publicUrl }; } /** /files 公开路由:命中 public/shared + available 才返回流,否则返回 null(由路由转 404)。 */ @@ -155,6 +169,9 @@ export async function createAttachment( const visibility = validateVisibility(optionalString(body, "visibility") ?? "private"); const description = optionalString(body, "description") ?? null; const category_id = optionalString(body, "categoryId") ?? optionalString(body, "category_id") ?? null; + const metadataInput = body.metadata; + const metadata = metadataInput ? JSON.stringify(metadataInput) : null; + const metadata_kind = metadataInput && typeof metadataInput === "object" ? String((metadataInput as { kind?: unknown }).kind ?? "") || null : null; const now = nowIso(); const id = newId("att"); @@ -177,6 +194,8 @@ export async function createAttachment( created_at: now, updated_at: now, deleted_at: null, + metadata, + metadata_kind: metadata_kind || null, }; await insertAttachment(env.DB, record); @@ -225,28 +244,59 @@ export async function finalizeAttachment(env: Env, user: CurrentUser, id: string } const session = await findActiveUploadSession(env.DB, user.id, id); + let didMultipart = false; if (session) { if (record.status === "upload_pending") { await finalizeMultipart(env, user, record, session); - const refreshed = await findAttachmentForUser(env.DB, user.id, id); - return refreshed ?? record; + didMultipart = true; + } else { + // 已单次上传却又残留活动会话:防御性 abort,落到下面的单次路径 + await safeAbortSession(env, session); } - // 已单次上传却又残留活动会话:防御性 abort,落到下面的单次路径 - await safeAbortSession(env, session); } - if (record.status !== "uploaded") { - throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", { - status: record.status, - }); + if (!didMultipart) { + if (record.status !== "uploaded") { + throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", { + status: record.status, + }); + } + await markAvailable(env.DB, user.id, id); } - await markAvailable(env.DB, user.id, id); await recordAudit(env, user, id, "attachment.finalized", {}); + await maybeExtractImageMetadata(env, user.id, id); const refreshed = await findAttachmentForUser(env.DB, user.id, id); return refreshed ?? record; } +/** finalize 后:对 image/* 附件,若尚无图片元信息,从 R2 头部解析宽高/格式回填(不覆盖客户端已给)。 */ +async function maybeExtractImageMetadata(env: Env, userId: string, id: string): Promise { + const record = await findAttachmentForUser(env.DB, userId, id); + if (!record || record.status !== "available" || !record.object_key) { + return; + } + if (!record.content_type.toLowerCase().startsWith("image/")) { + return; + } + let existingKind: string | null = null; + if (record.metadata) { + try { + existingKind = (JSON.parse(record.metadata) as { kind?: string }).kind ?? null; + } catch { + existingKind = null; + } + } + if (existingKind === "image") { + return; + } + const extracted = await extractImageMetadata(env.ATTACHMENTS, record.object_key); + if (!extracted) { + return; + } + await setAttachmentMetadata(env.DB, userId, id, JSON.stringify(extracted), "image", nowIso()); +} + async function finalizeMultipart( env: Env, user: CurrentUser, @@ -526,6 +576,22 @@ export async function updateAttachment( await setAttachmentTagIds(env.DB, user.id, id, tagIds); } + if (body.metadata !== undefined) { + if (body.metadata === null) { + await setAttachmentMetadata(env.DB, user.id, id, null, null, nowIso()); + } else { + const value = body.metadata as { kind?: unknown }; + await setAttachmentMetadata( + env.DB, + user.id, + id, + JSON.stringify(body.metadata), + typeof value.kind === "string" ? value.kind : null, + nowIso(), + ); + } + } + await recordAudit(env, user, id, "attachment.updated", {}); const refreshed = await findAttachmentForUser(env.DB, user.id, id); return refreshed ?? record; diff --git a/src/attachment-store.ts b/src/attachment-store.ts index 81f7c24..5d5625e 100644 --- a/src/attachment-store.ts +++ b/src/attachment-store.ts @@ -19,6 +19,8 @@ export interface AttachmentRecord { created_at: string; updated_at: string; deleted_at: string | null; + metadata: string | null; + metadata_kind: string | null; } export interface CategoryRecord { @@ -77,10 +79,10 @@ export interface UploadPartRecord { } const ATTACHMENT_COLUMNS = - "id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at"; + "id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at, metadata, metadata_kind"; const ATTACHMENT_COLUMNS_ALIASED = - "a.id, a.user_id, a.status, a.filename, a.content_type, a.byte_size, a.checksum_sha256, a.visibility, a.category_id, a.description, a.object_key, a.etag, a.created_by_user_id, a.updated_by_user_id, a.deleted_by_user_id, a.created_at, a.updated_at, a.deleted_at"; + "a.id, a.user_id, a.status, a.filename, a.content_type, a.byte_size, a.checksum_sha256, a.visibility, a.category_id, a.description, a.object_key, a.etag, a.created_by_user_id, a.updated_by_user_id, a.deleted_by_user_id, a.created_at, a.updated_at, a.deleted_at, a.metadata, a.metadata_kind"; const SEARCH_WHERE = ` WHERE a.user_id = ? @@ -109,7 +111,7 @@ function searchBindings(query: AttachmentSearchQuery): unknown[] { export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise { await db - .prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .bind( record.id, record.user_id, @@ -129,6 +131,8 @@ export async function insertAttachment(db: D1Database, record: AttachmentRecord) record.created_at, record.updated_at, record.deleted_at, + record.metadata, + record.metadata_kind, ) .run(); } @@ -571,6 +575,24 @@ export async function markAvailableWithObject( .run(); } +export async function setAttachmentMetadata( + db: D1Database, + userId: string, + id: string, + metadataJson: string | null, + metadataKind: string | null, + updatedAt: string, +): Promise { + await db + .prepare( + `UPDATE attachments + SET metadata = ?, metadata_kind = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + ) + .bind(metadataJson, metadataKind, updatedAt, id, userId) + .run(); +} + // --- 公开静态资源(/files)按 id 查询,SQL 内做 visibility/status/未删 gate --- export async function findPublicAttachmentById(db: D1Database, id: string): Promise { diff --git a/src/object-store.ts b/src/object-store.ts index 6b72c74..649c5a2 100644 --- a/src/object-store.ts +++ b/src/object-store.ts @@ -1,3 +1,5 @@ +import { imageSize } from "image-size"; + export function objectKeyFor(userId: string, attachmentId: string): string { const date = new Date(); const yyyy = String(date.getUTCFullYear()); @@ -23,6 +25,60 @@ export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise { + let body: R2ObjectBody | null = null; + try { + body = await bucket.get(key, { range: { offset: 0, length } }); + } catch { + body = null; + } + if (!body) { + body = await bucket.get(key); + } + if (!body) { + return null; + } + const buffer = await body.arrayBuffer(); + const bytes = new Uint8Array(buffer); + return bytes.length > length ? bytes.slice(0, length) : bytes; +} + +export interface ImageMetadataResult { + kind: "image"; + width: number; + height: number; + format?: string; +} + +/** + * 从对象头部字节解析图片宽高/格式(image-size,纯 JS)。 + * 解析失败或对象缺失时返回 null(不阻断上传流程)。 + */ +export async function extractImageMetadata( + bucket: R2Bucket, + key: string, +): Promise { + const head = await getImageHeadBytes(bucket, key); + if (!head || head.byteLength === 0) { + return null; + } + let parsed: { width?: number; height?: number; type?: string } | null = null; + try { + parsed = imageSize(head) as { width?: number; height?: number; type?: string }; + } catch { + parsed = null; + } + if (!parsed || typeof parsed.width !== "number" || typeof parsed.height !== "number") { + return null; + } + const result: ImageMetadataResult = { kind: "image", width: parsed.width, height: parsed.height }; + if (parsed.type) { + result.format = parsed.type; + } + return result; +} + export interface StartedMultipartUpload { key: string; uploadId: string; diff --git a/src/schemas.ts b/src/schemas.ts index ba63c10..bc790f1 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -7,6 +7,42 @@ const attachmentStatus = z.enum(["upload_pending", "uploaded", "available", "del export const VisibilitySchema = visibility.openapi("Visibility"); export const AttachmentStatusSchema = attachmentStatus.openapi("AttachmentStatus"); +// 按 kind 判别的元信息联合:不同文件类型携带不同字段 +export const AttachmentMetadataSchema = z + .discriminatedUnion("kind", [ + z.object({ + kind: z.literal("image"), + width: z.number().int().positive(), + height: z.number().int().positive(), + format: z.string().optional(), + }), + z.object({ + kind: z.literal("video"), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + duration_seconds: z.number().nonnegative().optional(), + codec: z.string().optional(), + bitrate: z.number().int().nonnegative().optional(), + frame_rate: z.number().positive().optional(), + }), + z.object({ + kind: z.literal("audio"), + duration_seconds: z.number().nonnegative().optional(), + codec: z.string().optional(), + sample_rate: z.number().int().positive().optional(), + channels: z.number().int().positive().optional(), + bitrate: z.number().int().nonnegative().optional(), + }), + z.object({ + kind: z.literal("document"), + pages: z.number().int().positive().optional(), + }), + z.object({ kind: z.literal("file") }), + ]) + .openapi("AttachmentMetadata"); + +export type AttachmentMetadata = z.infer; + export const ProblemSchema = z .object({ type: z.string(), @@ -38,6 +74,8 @@ export const AttachmentSchema = z updated_at: z.string(), deleted_at: z.string().nullable(), public_url: z.string().url().nullable(), + metadata: AttachmentMetadataSchema.nullable(), + metadata_kind: z.string().nullable(), }) .openapi("Attachment"); @@ -49,6 +87,7 @@ export const CreateAttachmentSchema = z description: z.string().optional(), categoryId: z.string().optional(), category_id: z.string().optional(), + metadata: AttachmentMetadataSchema.optional(), }) .openapi("CreateAttachment"); @@ -59,6 +98,7 @@ export const UpdateAttachmentSchema = z description: z.string().nullable().optional(), categoryId: z.string().nullable().optional(), tagIds: z.array(z.string()).optional(), + metadata: AttachmentMetadataSchema.nullable().optional(), }) .openapi("UpdateAttachment"); diff --git a/tests/attachment-worker.test.ts b/tests/attachment-worker.test.ts index 7220d30..3f60af8 100644 --- a/tests/attachment-worker.test.ts +++ b/tests/attachment-worker.test.ts @@ -608,3 +608,103 @@ describe("cfw-attachment worker (multipart & public files)", () => { expect(((await second.json()) as { code: string }).code).toBe("multipart-active"); }); }); + +// 1×1 PNG(合法),用于验证 finalize 时服务端自动解析图片尺寸 +const PNG_1x1_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function decodeBase64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +describe("cfw-attachment worker (metadata)", () => { + it("stores client-supplied video metadata and returns metadata_kind", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const created = await createAttachment(env, { + filename: "clip.mp4", + content_type: "video/mp4", + metadata: { kind: "video", width: 1920, height: 1080, duration_seconds: 12.3, codec: "h264" }, + }); + + const detail = await jsonBody(await call("GET", `/api/attachments/${created.attachment.id}`, env)); + expect(detail.attachment.metadata).toEqual({ + kind: "video", + width: 1920, + height: 1080, + duration_seconds: 12.3, + codec: "h264", + }); + expect(detail.attachment.metadata_kind).toBe("video"); + }); + + it("auto-extracts image dimensions on finalize", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const png = decodeBase64ToBytes(PNG_1x1_BASE64); + const created = await createAttachment(env, { filename: "dot.png", content_type: "image/png" }); + + await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit }); + const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env)); + + expect(finalized.attachment.status).toBe("available"); + expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 1, height: 1, format: "png" }); + expect(finalized.attachment.metadata_kind).toBe("image"); + }); + + it("does not overwrite client-supplied image metadata on finalize", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const png = decodeBase64ToBytes(PNG_1x1_BASE64); + const created = await createAttachment(env, { + filename: "dot.png", + content_type: "image/png", + metadata: { kind: "image", width: 9, height: 9 }, + }); + + await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit }); + const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env)); + + expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 9, height: 9 }); + }); + + it("updates and clears metadata via PATCH", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf" })).attachment.id; + + const updated = await jsonBody( + await call("PATCH", `/api/attachments/${id}`, env, { + body: JSON.stringify({ metadata: { kind: "document", pages: 5 } }), + }), + ); + expect(updated.attachment.metadata).toEqual({ kind: "document", pages: 5 }); + expect(updated.attachment.metadata_kind).toBe("document"); + + const cleared = await jsonBody( + await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ metadata: null }) }), + ); + expect(cleared.attachment.metadata).toBeNull(); + expect(cleared.attachment.metadata_kind).toBeNull(); + }); + + it("rejects invalid metadata with 422", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + + const response = await call("POST", "/api/attachments", env, { + body: JSON.stringify({ + filename: "x.png", + content_type: "image/png", + metadata: { kind: "image", width: -1, height: 1 }, + }), + }); + expect(response.status).toBe(422); + expect(((await response.json()) as { code: string }).code).toBe("validation-failed"); + }); +}); diff --git a/tests/fakes.ts b/tests/fakes.ts index 7f242f9..90c3c86 100644 --- a/tests/fakes.ts +++ b/tests/fakes.ts @@ -1,6 +1,7 @@ import { DatabaseSync } from "node:sqlite"; import migrationSql from "../migrations/0001_initial.sql?raw"; import migration2Sql from "../migrations/0002_upload_sessions.sql?raw"; +import migration3Sql from "../migrations/0003_attachment_metadata.sql?raw"; import { vi } from "vitest"; import type { Env, FetcherLike } from "../src/env"; @@ -15,6 +16,7 @@ export class FakeD1 { this.db = new DatabaseSync(":memory:"); this.db.exec(migrationSql); this.db.exec(migration2Sql); + this.db.exec(migration3Sql); } prepare(sql: string) { @@ -168,7 +170,7 @@ export class FakeR2 { return handle as unknown as R2MultipartUpload; } - async get(key: string): Promise { + async get(key: string, _options?: unknown): Promise { const stored = this.store.get(key); if (!stored) return null; const head = toR2Object(key, stored);