From ce93be2f52b589f966eaf33644aece7470f00c4e Mon Sep 17 00:00:00 2001 From: ggyy <34892002@qq.com> Date: Mon, 11 May 2026 11:25:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20s3=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/components.md | 269 +++++++++++++++++- lib/s3/client.ts | 21 +- modules/media/repository.ts | 4 +- modules/media/service.ts | 7 +- package.json | 2 +- .../admin/media/components/S3ConfigModal.vue | 5 +- server/routes/media.ts | 111 ++++++-- 7 files changed, 388 insertions(+), 31 deletions(-) diff --git a/docs/components.md b/docs/components.md index b9b7a0d..8b4d0af 100644 --- a/docs/components.md +++ b/docs/components.md @@ -278,4 +278,271 @@ async function fetchPage(page: number) { ### 分页说明 - 总条数 `total <= pageSize` 时,分页控件自动隐藏 -- 页码按钮最多显示 5 个,以当前页为中心滑动 \ No newline at end of file +- 页码按钮最多显示 5 个,以当前页为中心滑动 + + +## FilePickerModal + +文件选择器模态框组件,用于从媒体库中选择文件。支持搜索、类型筛选和分页功能,可选择图片、视频、音频、PDF 等文件类型。 + +### Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `show` | `boolean` | — | 控制模态框显示/隐藏 | +| `typeFilter` | `string` | — | 预设的类型筛选,如 `'image/'`、`'video/'`、`'audio/'`、`'application/pdf'` | + +### Events + +| 事件 | 参数 | 说明 | +|------|------|------| +| `close` | — | 关闭模态框时触发 | +| `select` | `url: string` | 选择文件时触发,参数为文件的 URL | + +### 功能特性 + +- **搜索功能**:支持按文件名关键词搜索 +- **类型筛选**:可预设或手动切换文件类型(图片、视频、音频、PDF) +- **分页显示**:支持分页浏览大量文件 +- **缩略图预览**:图片和视频显示缩略图,其他文件显示类型图标 +- **响应式网格**:自适应不同屏幕尺寸显示文件网格 + +### 基本用法 + +```components/FilePickerModal.vue#L1-5 + +``` + +### 带类型预筛选 + +```components/FilePickerModal.vue#L1-5 + + + + + +``` + +### 与表单集成示例 + +```pages/admin/products/ProductForm.vue#L100-120 + + + +``` + +### 富文本编辑器中插入图片 + +```pages/admin/products/RichTextEditor.vue#L230-250 + + + +``` + +### 动态类型筛选 + +```pages/admin/settings/+Page.vue#L70-90 + + + +``` + +### MediaItem 类型 + +组件内部使用 `MediaItem` 类型表示文件信息,定义在 `modules/media/types.ts`: + +```modules/media/types.ts#L28-38 +export interface MediaItem { + id: number; + originalName: string; // 原始文件名 + storedName: string; // 存储文件名 + mimeType: string; // MIME 类型,如 'image/jpeg' + fileSize: number; // 文件大小(字节) + fileKey: string; // 存储键 + url: string; // 文件访问 URL + thumbnailUrl: string | null; // 缩略图 URL(图片/视频) + path: string | null; // 存储路径 + metadata: string | null; // 元数据 JSON 字符串 + uploadedBy: number; // 上传者用户 ID + uploadedAt: string; // 上传时间 ISO 字符串 +} +``` + +### 使用建议 + +1. **配合 v-if 使用**:建议使用 `v-if` 控制组件实例化,这样每次打开都会重新加载文件列表: + ```vue + + ``` + +2. **仅使用 :show**:如果不用 `v-if`,组件会在 `show` 变为 `true` 时自动加载文件: + ```vue + + ``` + +3. **预设类型筛选**:使用 `type-filter` prop 可以预设文件类型,适合只需要特定类型文件的场景。 + +4. **动态类型筛选**:可以绑定动态的 `type-filter` 值,根据应用状态切换筛选类型。 + +### 样式说明 + +- 模态框宽度为 `11/12`,最大宽度 `5xl`(48rem) +- 最大高度为 `80vh`,内容区域可滚动 +- 文件网格响应式布局:手机 2 列,平板 3 列,桌面 4 列,大屏 6 列 +- 文件卡片悬停显示文件名、大小和选择按钮 +- 支持文件类型图标:图片、视频、PDF、通用文件 \ No newline at end of file diff --git a/lib/s3/client.ts b/lib/s3/client.ts index 5c3e9cf..225bb8a 100644 --- a/lib/s3/client.ts +++ b/lib/s3/client.ts @@ -135,7 +135,7 @@ export class S3Client { const headers: Record = { "Content-Type": contentType, - "Cache-Control": this.config.cacheControl || "public, max-age=31536000, immutable", + "Cache-Control": this.config.cacheControl, }; let response: Response; @@ -163,19 +163,32 @@ export class S3Client { /** * Get an object from S3 (for proxying) + * @param key S3 object key + * @param timeoutMs Request timeout in milliseconds (default 10s) */ - async getObject(key: string): Promise { + async getObject(key: string, timeoutMs = 10_000): Promise { const url = this.buildUrl(key); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; try { - response = await this.client.fetch(url, { method: "GET" }); + response = await this.client.fetch(url, { + method: "GET", + signal: controller.signal, + }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`S3 获取文件超时(${timeoutMs}ms):存储服务响应过慢,请稍后重试。`); + } if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) { throw new Error(`S3 获取文件失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`); } throw new Error(`S3 获取文件失败:${msg}`); + } finally { + clearTimeout(timer); } return response; @@ -263,6 +276,6 @@ export function createS3ClientFromConfig(config: { region: config.region || "auto", publicDomain: config.publicDomain || undefined, pathPrefix: config.pathPrefix || undefined, - cacheControl: config.cacheControl || "public, max-age=31536000, immutable", + cacheControl: config.cacheControl || undefined, }); } diff --git a/modules/media/repository.ts b/modules/media/repository.ts index ffd3b0e..ede2b86 100644 --- a/modules/media/repository.ts +++ b/modules/media/repository.ts @@ -19,7 +19,7 @@ export function upsertS3ConfigRecord(prisma: PrismaClient, input: S3ConfigInput) region: input.region || "auto", publicDomain: input.publicDomain || null, pathPrefix: input.pathPrefix || null, - cacheControl: input.cacheControl || "public, max-age=31536000, immutable", + cacheControl: input.cacheControl, }, update: { endpoint: input.endpoint, @@ -29,7 +29,7 @@ export function upsertS3ConfigRecord(prisma: PrismaClient, input: S3ConfigInput) region: input.region || "auto", publicDomain: input.publicDomain || null, pathPrefix: input.pathPrefix || null, - cacheControl: input.cacheControl || "public, max-age=31536000, immutable", + cacheControl: input.cacheControl, }, }); } diff --git a/modules/media/service.ts b/modules/media/service.ts index 6f082b1..feb5f09 100644 --- a/modules/media/service.ts +++ b/modules/media/service.ts @@ -43,6 +43,7 @@ export async function saveS3Config(input: S3ConfigInput) { if (!input.accessKeyId?.trim()) throw badRequestError("Access Key ID 不能为空", "ACCESS_KEY_REQUIRED"); if (!input.secretAccessKey?.trim()) throw badRequestError("Secret Access Key 不能为空", "SECRET_KEY_REQUIRED"); if (!input.bucketName?.trim()) throw badRequestError("存储桶名称不能为空", "BUCKET_REQUIRED"); + if (!input.cacheControl?.trim()) throw badRequestError("缓存策略不能为空", "CACHE_CONTROL_REQUIRED"); // Normalize endpoint: remove trailing slash const endpoint = input.endpoint.trim().replace(/\/+$/, ""); @@ -55,7 +56,7 @@ export async function saveS3Config(input: S3ConfigInput) { region: input.region?.trim() || "auto", publicDomain: input.publicDomain?.trim() || undefined, pathPrefix: input.pathPrefix?.trim().replace(/^\/|\/$/g, "") || undefined, - cacheControl: input.cacheControl?.trim() || "public, max-age=31536000, immutable", + cacheControl: input.cacheControl.trim(), }); await logAdminOperation( @@ -173,8 +174,8 @@ export async function handleUpload( // Upload to S3 await s3Client.putObject(fileKey, arrayBuffer, contentType); - // URL always goes through Worker proxy for Cloudflare caching - const url = `/api/media/proxy/${fileKey}`; + // URL uses /media/proxy/ (not /api/) so Cloudflare treats it as static content + const url = `/media/proxy/${fileKey}`; // Save to database const media = await createMediaRecord(prisma, { diff --git a/package.json b/package.json index cff7fad..2884f5a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.3.2", + "version": "1.3.3", "scripts": { "dev": "vike dev", "build": "bun run db:generate && vike build", diff --git a/pages/admin/media/components/S3ConfigModal.vue b/pages/admin/media/components/S3ConfigModal.vue index 468ca4f..16372a8 100644 --- a/pages/admin/media/components/S3ConfigModal.vue +++ b/pages/admin/media/components/S3ConfigModal.vue @@ -90,7 +90,8 @@ Cache-Control 头,用于 Cloudflare 缓存。默认 1 年不变更缓存 @@ -157,7 +158,7 @@ const form = reactive({ region: props.config?.region || "auto", publicDomain: props.config?.publicDomain || "", pathPrefix: props.config?.pathPrefix || "", - cacheControl: props.config?.cacheControl || "public, max-age=31536000, immutable", + cacheControl: props.config?.cacheControl || "public, max-age=31536000, s-maxage=31536000, immutable", }); function handleSave() { diff --git a/server/routes/media.ts b/server/routes/media.ts index 0496b65..1f6f101 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -62,12 +62,13 @@ export function registerMediaRoutes(app: Hono) { }); /** - * GET /api/media/proxy/* - * Proxy media file from S3 through Worker domain - * Adds Cache-Control headers for Cloudflare caching - * This endpoint is public - no auth required (files are served publicly) + * GET /media/proxy/* + * Proxy media file from S3 through Worker domain. + * Uses /media/ prefix (not /api/) so Cloudflare treats it as static + * content and caches it at the edge. + * This endpoint is public - no auth required (files are served publicly). */ - app.get("/api/media/proxy/*", async (c) => { + app.get("/media/proxy/*", async (c) => { try { const database = (c.env as { DB?: D1Database } | undefined)?.DB; if (!database) { @@ -77,7 +78,7 @@ export function registerMediaRoutes(app: Hono) { // Extract the key from the URL path const fullPath = c.req.path; - const keyPrefix = "/api/media/proxy/"; + const keyPrefix = "/media/proxy/"; const fileKey = fullPath.substring(fullPath.indexOf(keyPrefix) + keyPrefix.length); if (!fileKey) { @@ -96,33 +97,107 @@ export function registerMediaRoutes(app: Hono) { return c.json({ message: "S3 配置缺失", code: "S3_CONFIG_MISSING" }, 500); } - // Fetch from S3 + // Fetch from S3 with retry for transient errors const s3Client = createS3ClientFromConfig(s3Config); - const s3Response = await s3Client.getObject(fileKey); + const MAX_RETRIES = 2; + const RETRY_DELAY_MS = 500; - if (!s3Response.ok) { - if (s3Response.status === 404) { - return c.json({ message: "文件不存在于存储中", code: "S3_NOT_FOUND" }, 404); + let lastError: Error | null = null; + let s3Response: Response | null = null; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + s3Response = await s3Client.getObject(fileKey); + + // Success - break out of retry loop + if (s3Response.ok) break; + + // 404 is not retryable - file doesn't exist in S3 + if (s3Response.status === 404) { + logger.warn("S3 文件不存在", { + event: "media.proxy.s3_not_found", + fileKey, + mediaId: media.id, + }); + return c.json({ message: "文件不存在于存储中", code: "S3_NOT_FOUND" }, 404); + } + + // 403 is not retryable - credentials issue + if (s3Response.status === 403) { + logger.warn("S3 认证失败", { + event: "media.proxy.s3_forbidden", + fileKey, + status: s3Response.status, + }); + return c.json({ message: "存储服务认证失败", code: "S3_FORBIDDEN" }, 502); + } + + // Other errors (500, 503, 429 etc.) are retryable + const errorText = await s3Response.text().catch(() => ""); + lastError = new Error(`S3 返回 ${s3Response.status}: ${errorText}`); + + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))); + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))); + } } - return c.json({ message: "获取文件失败", code: "S3_ERROR" }, 502); + } + + // All retries exhausted + if (!s3Response || !s3Response.ok) { + logger.error(lastError || new Error("S3 request failed after retries"), { + event: "media.proxy.s3_failed", + fileKey, + mediaId: media.id, + attempts: MAX_RETRIES + 1, + }); + return c.json({ message: "存储服务暂时不可用,请稍后重试", code: "S3_ERROR" }, 502); + } + + // Buffer the entire S3 response body before returning. + // If we return the raw ReadableStream and the S3 connection drops + // mid-transfer, the stream error propagates to the Worker runtime + // (outside Hono's try-catch), causing "Worker threw exception". + // Buffering first ensures all errors are caught here. + let body: ArrayBuffer; + try { + body = await s3Response.arrayBuffer(); + } catch (err) { + logger.error(err instanceof Error ? err : new Error(String(err)), { + event: "media.proxy.stream_error", + fileKey, + mediaId: media.id, + }); + return c.json({ message: "读取文件内容失败", code: "STREAM_ERROR" }, 502); + } + + if (body.byteLength === 0) { + logger.warn("S3 返回空响应体", { + event: "media.proxy.empty_body", + fileKey, + mediaId: media.id, + }); + return c.json({ message: "文件内容为空", code: "EMPTY_BODY" }, 502); } // Build response with Cache-Control header const headers = new Headers(); headers.set("Content-Type", media.mimeType); - headers.set("Cache-Control", s3Config.cacheControl || "public, max-age=31536000, immutable"); + headers.set("Content-Length", String(body.byteLength)); + headers.set("Cache-Control", s3Config.cacheControl); headers.set("Content-Disposition", `inline; filename="${encodeURIComponent(media.originalName)}"`); - const contentLength = s3Response.headers.get("Content-Length"); - if (contentLength) { - headers.set("Content-Length", contentLength); - } const etag = s3Response.headers.get("ETag"); if (etag) { headers.set("ETag", etag); } - return new Response(s3Response.body, { + return new Response(body, { status: 200, headers, });