fix: harden direct upload observability and cleanup
This commit is contained in:
parent
3b7eb4a539
commit
aa63415b1c
@ -139,6 +139,13 @@ EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||
|
||||
# Browser direct uploads use signed OSS/S3 URLs. Configure CORS and lifecycle
|
||||
# with the scripts below after changing the default storage bucket.
|
||||
DIRECT_UPLOAD_CORS_ORIGINS=https://ai.yydd.ai,https://yydd.ai
|
||||
DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS=2
|
||||
DIRECT_UPLOAD_LIFECYCLE_PREFIX=uploads/direct/
|
||||
DIRECT_UPLOAD_LIFECYCLE_RULE_ID=abort-incomplete-direct-upload-parts
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent / PSD Runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@ -42,6 +42,7 @@ const InitiateSchema = z.object({
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const CompleteSchema = z.object({
|
||||
@ -60,12 +61,14 @@ const CompleteSchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const AbortSchema = z.object({
|
||||
action: z.literal("abort"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const RequestSchema = z.discriminatedUnion("action", [
|
||||
@ -184,6 +187,7 @@ export async function POST(request: Request) {
|
||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||
size: payload.size,
|
||||
purpose: payload.purpose || null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
||||
@ -219,6 +223,7 @@ export async function POST(request: Request) {
|
||||
"x-amz-acl": "public-read",
|
||||
},
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -255,6 +260,7 @@ export async function POST(request: Request) {
|
||||
partCount,
|
||||
parts,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -335,6 +341,7 @@ export async function POST(request: Request) {
|
||||
expectedSize: payload.size,
|
||||
uploadMode: payload.uploadId ? "multipart" : "single",
|
||||
purpose: payload.purpose || null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
},
|
||||
});
|
||||
|
||||
@ -351,6 +358,7 @@ export async function POST(request: Request) {
|
||||
pathname: key,
|
||||
contentType: resolvedContentType,
|
||||
assetId: asset?.id ?? null,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("direct_upload_request_failed", error, {
|
||||
@ -358,6 +366,8 @@ export async function POST(request: Request) {
|
||||
size: "size" in payload ? payload.size : null,
|
||||
contentType: "contentType" in payload ? payload.contentType : null,
|
||||
purpose: "purpose" in payload ? payload.purpose || null : null,
|
||||
clientUploadId:
|
||||
"clientUploadId" in payload ? payload.clientUploadId || null : null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@ -155,6 +155,8 @@ export async function POST(request: Request) {
|
||||
const filename = (formData.get("file") as File).name;
|
||||
const contentType = normalizeUploadContentType(file.type, filename);
|
||||
const uploadPurpose = String(formData.get("purpose") || "");
|
||||
const clientUploadId =
|
||||
String(formData.get("clientUploadId") || "").trim() || null;
|
||||
const isSvgUpload = contentType === "image/svg+xml";
|
||||
if (
|
||||
isSvgUpload &&
|
||||
@ -190,6 +192,7 @@ export async function POST(request: Request) {
|
||||
filenameExtension: filename.toLowerCase().split(".").pop() || null,
|
||||
size: file.size,
|
||||
purpose: uploadPurpose || null,
|
||||
clientUploadId,
|
||||
});
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
@ -227,6 +230,7 @@ export async function POST(request: Request) {
|
||||
originalSize: fileBuffer.byteLength,
|
||||
previewSize: preview.buffer.length,
|
||||
purpose: uploadPurpose,
|
||||
clientUploadId,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
@ -292,6 +296,7 @@ export async function POST(request: Request) {
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
clientUploadId,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
@ -303,6 +308,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
assetId,
|
||||
clientUploadId,
|
||||
});
|
||||
} catch (_error) {
|
||||
logger.error("[Upload API] Upload failed:", _error);
|
||||
|
||||
@ -159,6 +159,32 @@ curl -I --max-time 30 "http://127.0.0.1:3001/api/featured?page=1&pageSize=24"
|
||||
|
||||
首页 HTML 应快速返回。公开轻量接口在数据库慢或不可用时应快速返回缓存或 fallback,不应长时间挂住首屏。
|
||||
|
||||
## 上传与对象存储
|
||||
|
||||
浏览器图片上传优先走 `/api/files/direct-upload` 获取签名 URL,再由浏览器直传默认 OSS/S3 Bucket;小图或直传失败时才降级到 `/api/files/upload` 服务器中转。排查用户上传失败时,优先按 `clientUploadId` 串联 direct upload、fallback upload 和 `MediaAsset.metadata`。
|
||||
|
||||
直传 Bucket 必须配置 CORS,否则大文件分片上传会在浏览器侧失败:
|
||||
|
||||
```bash
|
||||
DIRECT_UPLOAD_CORS_ORIGINS="https://ai.yydd.ai,https://yydd.ai" pnpm storage:configure-upload-cors
|
||||
```
|
||||
|
||||
核心要求:
|
||||
|
||||
- Allowed Methods 包含 `GET`、`PUT`、`POST`、`HEAD`。
|
||||
- Allowed Headers 允许 `*`,因为签名上传会携带 `Content-Type`、`Cache-Control`、ACL 等请求头。
|
||||
- Exposed Headers 必须包含 `ETag`,否则 multipart upload 无法 complete。
|
||||
|
||||
未完成的 multipart upload 会产生隐藏对象存储费用。生产 Bucket 必须开启 lifecycle 自动清理:
|
||||
|
||||
```bash
|
||||
DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS=2 pnpm storage:configure-upload-lifecycle
|
||||
```
|
||||
|
||||
默认只清理 `uploads/direct/` 前缀下 2 天未完成的分片;如使用自定义前缀,可设置 `DIRECT_UPLOAD_LIFECYCLE_PREFIX`。
|
||||
|
||||
当前上传格式支持 JPEG、PNG、WebP,管理员图标额外允许 SVG。HEIC/HEIF 不是可靠的 Web 展示格式,当前会明确提示用户转为 JPG/PNG;若未来要原生支持,应作为独立专项引入客户端或服务端转码,并评估前端包体、Sharp/libvips HEIF 支持和隐私成本。
|
||||
|
||||
## 路由回归烟测
|
||||
|
||||
推荐使用脚本化检查:
|
||||
@ -190,5 +216,5 @@ curl -I -X POST http://127.0.0.1:3001/api/files/upload
|
||||
- `.next/dev/lock` 阻止启动:已有 dev server 持锁,先确认进程归属,不要误杀其他工作区服务。
|
||||
- 首页接口慢:先看 `Server-Timing`、`X-Cache`、`X-Fallback`,再查数据库连接池和公开接口 fallback。
|
||||
- 登录 Host 错误:生产配置 `AUTH_URL`;仅在可信代理后设置 `AUTH_TRUST_HOST=true`。
|
||||
- 上传失败:确认后台存储配置、环境变量 S3 fallback 或 `BLOB_READ_WRITE_TOKEN` 至少一组可用。
|
||||
- 上传失败:先用 `clientUploadId` 查日志;再确认 Bucket CORS 暴露 `ETag`、生命周期规则已开启、后台存储配置或环境变量 S3 fallback 至少一组可用。
|
||||
- 日志泄露风险:不要把完整 prompt、OAuth token、签名 URL、cookie、request body 放进日志 message。
|
||||
|
||||
@ -6,6 +6,7 @@ const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
|
||||
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
|
||||
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
|
||||
|
||||
const RETRYABLE_UPLOAD_STATUSES = new Set([
|
||||
408,
|
||||
@ -40,6 +41,7 @@ type UploadFormFile = {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
purpose: string;
|
||||
clientUploadId: string;
|
||||
};
|
||||
|
||||
type DirectUploadPart = {
|
||||
@ -178,6 +180,27 @@ function normalizeBrowserImageType(contentType: string, filename: string) {
|
||||
return BROWSER_IMAGE_TYPES_BY_EXTENSION[extension] || normalized;
|
||||
}
|
||||
|
||||
function createClientUploadId() {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function ensureClientUploadId(formData: FormData) {
|
||||
const existing = formData.get(CLIENT_UPLOAD_ID_FIELD);
|
||||
if (typeof existing === "string" && existing.trim()) {
|
||||
return existing.trim();
|
||||
}
|
||||
|
||||
const clientUploadId = createClientUploadId();
|
||||
formData.set(CLIENT_UPLOAD_ID_FIELD, clientUploadId);
|
||||
return clientUploadId;
|
||||
}
|
||||
|
||||
function getUploadFormFile(formData: FormData): UploadFormFile | null {
|
||||
const value = formData.get("file");
|
||||
if (!(value instanceof Blob)) {
|
||||
@ -193,12 +216,14 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
|
||||
filename
|
||||
);
|
||||
const purposeValue = formData.get("purpose");
|
||||
const clientUploadId = ensureClientUploadId(formData);
|
||||
|
||||
return {
|
||||
blob: value,
|
||||
filename,
|
||||
contentType,
|
||||
purpose: typeof purposeValue === "string" ? purposeValue : "",
|
||||
clientUploadId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -362,6 +387,7 @@ async function fetchDirectUpload(
|
||||
contentType: file.contentType,
|
||||
size: file.blob.size,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
);
|
||||
@ -392,6 +418,7 @@ async function fetchDirectUpload(
|
||||
contentType: file.contentType,
|
||||
size: file.blob.size,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
);
|
||||
@ -416,6 +443,7 @@ async function fetchDirectUpload(
|
||||
size: file.blob.size,
|
||||
parts,
|
||||
purpose: file.purpose,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
);
|
||||
@ -428,6 +456,7 @@ async function fetchDirectUpload(
|
||||
action: "abort",
|
||||
key: multipartUpload.key,
|
||||
uploadId: multipartUpload.uploadId,
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
signal
|
||||
).catch(() => {});
|
||||
@ -440,6 +469,7 @@ async function fetchDirectUpload(
|
||||
error:
|
||||
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
clientUploadId: file.clientUploadId,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
|
||||
@ -44,7 +44,9 @@
|
||||
"db:check": "drizzle-kit check",
|
||||
"db:up": "drizzle-kit up",
|
||||
"poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
|
||||
"test": "export PLAYWRIGHT=True && pnpm exec playwright test"
|
||||
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
|
||||
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
|
||||
"storage:configure-upload-lifecycle": "pnpm exec tsx scripts/storage/configure-multipart-lifecycle.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^1.0.15",
|
||||
|
||||
216
scripts/storage/configure-multipart-lifecycle.ts
Normal file
216
scripts/storage/configure-multipart-lifecycle.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
|
||||
import {
|
||||
GetBucketLifecycleConfigurationCommand,
|
||||
PutBucketLifecycleConfigurationCommand,
|
||||
S3Client,
|
||||
type LifecycleRule,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
loadEnv({ path: ".env.local" });
|
||||
loadEnv();
|
||||
|
||||
type S3UploadConfig = {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
provider?: "s3" | "qiniu-kodo";
|
||||
};
|
||||
|
||||
const DEFAULT_RULE_ID = "abort-incomplete-direct-upload-parts";
|
||||
const DEFAULT_PREFIX = "uploads/direct/";
|
||||
const DEFAULT_ABORT_DAYS = 2;
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
const trimmed = endpoint.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
|
||||
if (/aliyuncs\.com/i.test(trimmed)) {
|
||||
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
|
||||
return `https://${withoutProtocol}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createS3Client(config: S3UploadConfig) {
|
||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||||
const isCloudS3 =
|
||||
lowerEndpoint.includes("aliyuncs.com") ||
|
||||
lowerEndpoint.includes("amazonaws.com");
|
||||
|
||||
return new S3Client({
|
||||
endpoint: normalizedEndpoint,
|
||||
region: config.region || "auto",
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
|
||||
});
|
||||
}
|
||||
|
||||
function toS3Config(value: unknown): S3UploadConfig | null {
|
||||
const raw = value as Record<string, unknown> | null | undefined;
|
||||
const endpoint = raw?.endpoint ? normalizeEndpoint(String(raw.endpoint)) : "";
|
||||
const bucket = raw?.bucket ? String(raw.bucket).trim() : "";
|
||||
const accessKeyId = raw?.accessKeyId ? String(raw.accessKeyId).trim() : "";
|
||||
const secretAccessKey = raw?.secretAccessKey
|
||||
? String(raw.secretAccessKey).trim()
|
||||
: "";
|
||||
|
||||
if (
|
||||
!raw ||
|
||||
raw.type !== "s3" ||
|
||||
raw.isEnabled !== true ||
|
||||
!endpoint ||
|
||||
!bucket ||
|
||||
!accessKeyId ||
|
||||
!secretAccessKey
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
region: String(raw.region || "auto").trim() || "auto",
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
provider: raw.provider === "qiniu-kodo" ? "qiniu-kodo" : "s3",
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvS3Config(): S3UploadConfig | null {
|
||||
const endpoint = process.env.S3_ENDPOINT
|
||||
? normalizeEndpoint(process.env.S3_ENDPOINT)
|
||||
: "";
|
||||
const bucket = process.env.S3_BUCKET?.trim() || "";
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() || "";
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() || "";
|
||||
|
||||
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
region: process.env.S3_REGION?.trim() || "auto",
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveS3Config() {
|
||||
const [{ db }, { storageConfig }] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
import("@/lib/db/schema"),
|
||||
]);
|
||||
const [defaultConfig] = await db
|
||||
.select()
|
||||
.from(storageConfig)
|
||||
.where(
|
||||
and(
|
||||
eq(storageConfig.type, "s3"),
|
||||
eq(storageConfig.isDefault, true),
|
||||
eq(storageConfig.isEnabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return toS3Config(defaultConfig) || readEnvS3Config();
|
||||
}
|
||||
|
||||
function readAbortDays() {
|
||||
const value = Number(process.env.DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS || "");
|
||||
if (Number.isFinite(value) && value >= 1 && value <= 30) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
return DEFAULT_ABORT_DAYS;
|
||||
}
|
||||
|
||||
function readRulePrefix() {
|
||||
const raw = process.env.DIRECT_UPLOAD_LIFECYCLE_PREFIX || DEFAULT_PREFIX;
|
||||
const normalized = raw.trim().replace(/^\/+/, "");
|
||||
return normalized || DEFAULT_PREFIX;
|
||||
}
|
||||
|
||||
function normalizeRule(rule: LifecycleRule): LifecycleRule {
|
||||
return {
|
||||
...rule,
|
||||
ID: rule.ID || DEFAULT_RULE_ID,
|
||||
Status: rule.Status || "Enabled",
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = await resolveS3Config();
|
||||
if (!config) {
|
||||
console.log("[multipart-lifecycle] skip: no S3-compatible upload target");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createS3Client(config);
|
||||
const prefix = readRulePrefix();
|
||||
const abortDays = readAbortDays();
|
||||
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
|
||||
|
||||
let existingRules: LifecycleRule[] = [];
|
||||
try {
|
||||
const current = await client.send(
|
||||
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
|
||||
);
|
||||
existingRules = current.Rules || [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const cleanupRule: LifecycleRule = {
|
||||
ID: ruleId,
|
||||
Status: "Enabled",
|
||||
Filter: { Prefix: prefix },
|
||||
AbortIncompleteMultipartUpload: {
|
||||
DaysAfterInitiation: abortDays,
|
||||
},
|
||||
};
|
||||
|
||||
const mergedRules = [
|
||||
cleanupRule,
|
||||
...existingRules.filter((rule) => rule.ID !== ruleId),
|
||||
]
|
||||
.slice(0, 1000)
|
||||
.map(normalizeRule);
|
||||
|
||||
await client.send(
|
||||
new PutBucketLifecycleConfigurationCommand({
|
||||
Bucket: config.bucket,
|
||||
LifecycleConfiguration: {
|
||||
Rules: mergedRules,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log("[multipart-lifecycle] configured", {
|
||||
bucket: config.bucket,
|
||||
prefix,
|
||||
abortDays,
|
||||
ruleCount: mergedRules.length,
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[multipart-lifecycle] failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user