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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-02 20:03:51 -07:00
parent 89cef6966e
commit 0af18ada66
6 changed files with 302 additions and 16 deletions

View File

@@ -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<AttachmentRecord, "metadata"> & {
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<void> {
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;

View File

@@ -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<void> {
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<void> {
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<AttachmentRecord | null> {

View File

@@ -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<R2
return bucket.get(key);
}
/** 读取对象头部字节(用于解析图片尺寸);优先 R2 range get,失败回退全量取前 length 字节。 */
export async function getImageHeadBytes(bucket: R2Bucket, key: string, length = 32_768): Promise<Uint8Array | null> {
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<ImageMetadataResult | null> {
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;

View File

@@ -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<typeof AttachmentMetadataSchema>;
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");

View File

@@ -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");
});
});

View File

@@ -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<R2ObjectBody | null> {
async get(key: string, _options?: unknown): Promise<R2ObjectBody | null> {
const stored = this.store.get(key);
if (!stored) return null;
const head = toR2Object(key, stored);