mirror of
https://github.com/34892002/edgeKey.git
synced 2026-06-05 19:21:43 +08:00
fix: s3优化
This commit is contained in:
@@ -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、通用文件
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"scripts": {
|
||||
"dev": "vike dev",
|
||||
"build": "bun run db:generate && vike build",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user