fix: s3优化

This commit is contained in:
ggyy
2026-05-11 11:25:27 +08:00
parent d2f2bda496
commit ce93be2f52
7 changed files with 388 additions and 31 deletions

View File

@@ -278,4 +278,271 @@ async function fetchPage(page: number) {
### 分页说明
- 总条数 `total <= pageSize` 时,分页控件自动隐藏
- 页码按钮最多显示 5 个,以当前页为中心滑动
- 页码按钮最多显示 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
<FilePickerModal
:show="showFilePicker"
@close="showFilePicker = false"
@select="handleFileSelect"
/>
```
### 带类型预筛选
```components/FilePickerModal.vue#L1-5
<!-- 只显示图片文件 -->
<FilePickerModal
:show="showImagePicker"
type-filter="image/"
@close="showImagePicker = false"
@select="handleImageSelect"
/>
<!-- 只显示 PDF 文件 -->
<FilePickerModal
:show="showPdfPicker"
type-filter="application/pdf"
@close="showPdfPicker = false"
@select="handlePdfSelect"
/>
```
### 与表单集成示例
```pages/admin/products/ProductForm.vue#L100-120
<script setup lang="ts">
import { ref } from "vue";
import FilePickerModal from "../../../components/FilePickerModal.vue";
import AppButton from "../../../components/AppButton.vue";
const showFilePicker = ref(false);
const selectedImageUrl = ref("");
function handleFileSelect(url: string) {
selectedImageUrl.value = url;
showFilePicker.value = false;
}
</script>
<template>
<div class="form-control">
<label class="label">
<span class="label-text">商品图片</span>
</label>
<div class="flex gap-2">
<input
v-model="selectedImageUrl"
type="text"
placeholder="请选择图片"
class="input input-bordered flex-1"
readonly
/>
<AppButton
variant="outline"
@click="showFilePicker = true"
>
选择图片
</AppButton>
</div>
<!-- 预览 -->
<div v-if="selectedImageUrl" class="mt-2">
<img
:src="selectedImageUrl"
alt="预览"
class="max-w-xs max-h-32 object-contain border rounded"
/>
</div>
</div>
<!-- 文件选择弹窗 -->
<FilePickerModal
:show="showFilePicker"
type-filter="image/"
@close="showFilePicker = false"
@select="handleFileSelect"
/>
</template>
```
### 富文本编辑器中插入图片
```pages/admin/products/RichTextEditor.vue#L230-250
<script setup lang="ts">
import { ref } from "vue";
import FilePickerModal from "../../../components/FilePickerModal.vue";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import Image from "@tiptap/extension-image";
const showFilePicker = ref(false);
const editor = useEditor({
extensions: [Image],
// ... 其他配置
});
function handleImageSelect(url: string) {
if (editor.value) {
editor.value.chain().focus().setImage({ src: url }).run();
}
showFilePicker.value = false;
}
</script>
<template>
<div>
<!-- 编辑器工具栏 -->
<div class="toolbar">
<button
@click="showFilePicker = true"
class="btn btn-sm btn-ghost"
title="插入图片"
>
🖼️
</button>
</div>
<!-- 编辑器内容 -->
<EditorContent :editor="editor" />
<!-- 文件选择弹窗 -->
<FilePickerModal
v-if="showFilePicker"
:show="showFilePicker"
type-filter="image/"
@close="showFilePicker = false"
@select="handleImageSelect"
/>
</div>
</template>
```
### 动态类型筛选
```pages/admin/settings/+Page.vue#L70-90
<script setup lang="ts">
import { ref } from "vue";
import FilePickerModal from "../../../components/FilePickerModal.vue";
const showFilePicker = ref(false);
const filePickerTypeFilter = ref(""); // 动态类型筛选
// 根据不同场景设置不同的类型筛选
function openImagePicker() {
filePickerTypeFilter.value = "image/";
showFilePicker.value = true;
}
function openVideoPicker() {
filePickerTypeFilter.value = "video/";
showFilePicker.value = true;
}
function openPdfPicker() {
filePickerTypeFilter.value = "application/pdf";
showFilePicker.value = true;
}
function handleFileSelect(url: string) {
console.log("选中的文件:", url);
showFilePicker.value = false;
}
</script>
<template>
<div class="flex gap-2">
<button @click="openImagePicker" class="btn btn-outline">
选择图片
</button>
<button @click="openVideoPicker" class="btn btn-outline">
选择视频
</button>
<button @click="openPdfPicker" class="btn btn-outline">
选择 PDF
</button>
</div>
<!-- 文件选择弹窗 -->
<FilePickerModal
:show="showFilePicker"
:type-filter="filePickerTypeFilter"
@close="showFilePicker = false"
@select="handleFileSelect"
/>
</template>
```
### 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
<FilePickerModal v-if="showFilePicker" :show="showFilePicker" ... />
```
2. **仅使用 :show**:如果不用 `v-if`,组件会在 `show` 变为 `true` 时自动加载文件:
```vue
<FilePickerModal :show="showFilePicker" ... />
```
3. **预设类型筛选**:使用 `type-filter` prop 可以预设文件类型,适合只需要特定类型文件的场景。
4. **动态类型筛选**:可以绑定动态的 `type-filter` 值,根据应用状态切换筛选类型。
### 样式说明
- 模态框宽度为 `11/12`,最大宽度 `5xl`48rem
- 最大高度为 `80vh`,内容区域可滚动
- 文件网格响应式布局:手机 2 列,平板 3 列,桌面 4 列,大屏 6 列
- 文件卡片悬停显示文件名、大小和选择按钮
- 支持文件类型图标图片、视频、PDF、通用文件

View File

@@ -135,7 +135,7 @@ export class S3Client {
const headers: Record<string, string> = {
"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<Response> {
async getObject(key: string, timeoutMs = 10_000): Promise<Response> {
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,
});
}

View File

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

View File

@@ -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, {

View File

@@ -1,5 +1,5 @@
{
"version": "1.3.2",
"version": "1.3.3",
"scripts": {
"dev": "vike dev",
"build": "bun run db:generate && vike build",

View File

@@ -90,7 +90,8 @@
<input
v-model="form.cacheControl"
class="input input-bordered w-full"
placeholder="public, max-age=31536000, immutable"
placeholder="public, max-age=31536000, s-maxage=31536000, immutable"
required
/>
<span class="text-xs text-base-content/50">
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() {

View File

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