feat(project): init

This commit is contained in:
zhangheng
2026-04-25 11:26:33 +08:00
commit 9bf212f67c
63 changed files with 11141 additions and 0 deletions

115
app/api/generate/route.ts Normal file
View File

@@ -0,0 +1,115 @@
import { NextResponse, type NextRequest } from "next/server";
import { randomUUID } from "node:crypto";
import { requireApiUser } from "@/lib/api-auth";
import { appConfig } from "@/lib/config";
import { generateImagesWithOpenAI } from "@/lib/openai";
import { parseGenerateRequest } from "@/lib/request-schema";
import { assertCanGenerate, getQuotaSnapshotForUser } from "@/lib/quota";
import { getCurrentPeriodKey } from "@/lib/quota-core";
import { jsonError, normalizeError } from "@/lib/http";
import {
recordCompletedGeneration,
recordGenerationJob,
} from "@/lib/db";
import {
buildStoredImageUrl,
persistGeneratedImages,
removePersistedImageFiles,
} from "@/lib/image-storage";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const user = requireApiUser(request);
let prompt = "";
let payload:
| ReturnType<typeof parseGenerateRequest>
| null = null;
try {
if (!appConfig.openAiApiKey) {
return jsonError("服务端尚未配置 OPENAI_API_KEY。", 500);
}
assertCanGenerate(user);
const body = (await request.json()) as unknown;
payload = parseGenerateRequest(body, appConfig.openAiImageModel);
prompt = payload.prompt;
const result = await generateImagesWithOpenAI(payload);
const jobId = randomUUID();
const persistedImages = await persistGeneratedImages({
userId: user.id,
jobId,
images: result.images,
});
try {
recordCompletedGeneration({
jobId,
userId: user.id,
prompt,
imageCount: result.images.length,
durationMs: result.meta.durationMs,
model: result.meta.model,
size: result.meta.size,
quality: result.meta.quality,
outputFormat: result.meta.outputFormat,
background: result.meta.background,
moderation: result.meta.moderation,
outputCompression: payload.output_compression ?? null,
images: persistedImages.images,
usageMeta: {
model: result.meta.model,
size: result.meta.size,
quality: result.meta.quality,
outputFormat: result.meta.outputFormat,
background: result.meta.background,
},
periodKey: getCurrentPeriodKey(),
});
} catch (error) {
await removePersistedImageFiles(persistedImages.files);
throw error;
}
const images = result.images.map((image, index) => ({
...image,
id: persistedImages.images[index]?.id ?? image.id,
fileName: persistedImages.images[index]?.fileName ?? image.fileName,
url: persistedImages.images[index]
? buildStoredImageUrl(persistedImages.images[index].id)
: undefined,
}));
const nextQuota = getQuotaSnapshotForUser(user);
return NextResponse.json({
ok: true,
images,
meta: result.meta,
quota: nextQuota,
});
} catch (error) {
if (prompt) {
recordGenerationJob({
userId: user.id,
prompt,
status: "error",
model: payload?.model ?? null,
size: payload?.size ?? null,
quality: payload?.quality ?? null,
outputFormat: payload?.output_format ?? null,
background: payload?.background ?? null,
moderation: payload?.moderation ?? null,
outputCompression: payload?.output_compression ?? null,
errorMessage: error instanceof Error ? error.message : "unknown error",
});
}
const normalizedError = normalizeError(error, "图片生成失败。");
return jsonError(normalizedError.message, normalizedError.statusCode, {
detail: normalizedError.detail,
});
}
}