fix: harden direct upload observability and cleanup

This commit is contained in:
Your Name 2026-05-28 22:08:06 +08:00
parent 3b7eb4a539
commit aa63415b1c
7 changed files with 299 additions and 2 deletions

View File

@ -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
# -----------------------------------------------------------------------------

View File

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

View File

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

View File

@ -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。

View File

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

View File

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

View 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);
});