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

View File

@@ -0,0 +1,34 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireApiAdmin } from "@/lib/api-auth";
import { setDefaultMonthlyQuota } from "@/lib/db";
import { jsonError, normalizeError } from "@/lib/http";
export const runtime = "nodejs";
export async function PUT(request: NextRequest) {
try {
requireApiAdmin(request);
const body = (await request.json()) as {
defaultMonthlyQuota?: unknown;
};
const numericValue = Number.parseInt(
String(body?.defaultMonthlyQuota ?? ""),
10,
);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return jsonError("默认额度必须是大于等于 0 的整数。", 400);
}
const settings = setDefaultMonthlyQuota(numericValue);
return NextResponse.json({
ok: true,
settings,
});
} catch (error) {
const normalizedError = normalizeError(error, "保存默认额度失败。");
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}

View File

@@ -0,0 +1,87 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireApiAdmin } from "@/lib/api-auth";
import { getUserById, updateUserRecord } from "@/lib/db";
import { hashPassword, toPublicUser } from "@/lib/auth";
import { getQuotaSnapshotForUser } from "@/lib/quota";
import { jsonError, normalizeError } from "@/lib/http";
export const runtime = "nodejs";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const adminUser = requireApiAdmin(request);
const body = (await request.json()) as {
role?: unknown;
monthlyQuota?: unknown;
isActive?: unknown;
password?: unknown;
};
const { id } = await params;
const targetUser = getUserById(id);
const monthlyQuota =
typeof body?.monthlyQuota === "number" ||
typeof body?.monthlyQuota === "string"
? body.monthlyQuota
: undefined;
const password = typeof body?.password === "string" ? body.password : "";
if (!targetUser) {
return jsonError("用户不存在。", 404);
}
if (targetUser.id === adminUser.id) {
if (body?.role && body.role !== "admin") {
return jsonError("不能移除当前管理员自己的 admin 身份。", 400);
}
if (body?.isActive === false) {
return jsonError("不能禁用当前管理员自己的账号。", 400);
}
}
const updatedUser = updateUserRecord(targetUser.id, {
role: body?.role === "admin" ? "admin" : "user",
monthlyQuota:
body?.role === "admin"
? null
: monthlyQuota === null || monthlyQuota === ""
? null
: monthlyQuota,
isActive:
body?.isActive === undefined
? targetUser.isActive
: Boolean(body.isActive),
passwordHash:
password.trim().length >= 8 ? hashPassword(password) : undefined,
});
if (!updatedUser) {
return jsonError("用户不存在。", 404);
}
const publicUser = toPublicUser(updatedUser);
if (!publicUser) {
return jsonError("保存用户失败。", 500);
}
const quota = getQuotaSnapshotForUser(publicUser);
return NextResponse.json({
ok: true,
user: {
...publicUser,
usedThisPeriod: quota.used,
remaining: quota.remaining,
limit: quota.limit,
isUnlimited: quota.isUnlimited,
},
});
} catch (error) {
const normalizedError = normalizeError(error, "保存用户失败。");
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}

View File

@@ -0,0 +1,72 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireApiAdmin } from "@/lib/api-auth";
import { createUserRecord, getUserByUsername } from "@/lib/db";
import { hashPassword, toPublicUser } from "@/lib/auth";
import { getQuotaSnapshotForUser } from "@/lib/quota";
import { jsonError, normalizeError } from "@/lib/http";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
requireApiAdmin(request);
const body = (await request.json()) as {
username?: unknown;
password?: unknown;
role?: unknown;
monthlyQuota?: unknown;
};
const username = String(body?.username ?? "")
.trim()
.toLowerCase();
const password = String(body?.password ?? "");
const role = body?.role === "admin" ? "admin" : "user";
const monthlyQuota =
typeof body?.monthlyQuota === "number" ||
typeof body?.monthlyQuota === "string"
? body.monthlyQuota
: undefined;
if (!username) {
return jsonError("用户名不能为空。", 400);
}
if (password.length < 8) {
return jsonError("密码至少需要 8 个字符。", 400);
}
if (getUserByUsername(username)) {
return jsonError("该用户名已存在。", 409);
}
const publicUser = toPublicUser(
createUserRecord({
username,
passwordHash: hashPassword(password),
role,
monthlyQuota: role === "admin" ? null : monthlyQuota,
}),
);
if (!publicUser) {
return jsonError("创建用户失败。", 500);
}
const quota = getQuotaSnapshotForUser(publicUser);
return NextResponse.json({
ok: true,
user: {
...publicUser,
usedThisPeriod: quota.used,
remaining: quota.remaining,
limit: quota.limit,
isUnlimited: quota.isUnlimited,
},
});
} catch (error) {
const normalizedError = normalizeError(error, "创建用户失败。");
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}

View File

@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from "next/server";
import {
SESSION_COOKIE_NAME,
authenticateUser,
createSessionForUser,
getSessionCookieOptions,
toPublicUser,
} from "@/lib/auth";
import { getQuotaSnapshotForUser } from "@/lib/quota";
import { jsonError, normalizeError } from "@/lib/http";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as {
username?: unknown;
password?: unknown;
};
const username = String(body?.username ?? "").trim();
const password = String(body?.password ?? "");
if (!username || !password) {
return jsonError("用户名和密码不能为空。", 400);
}
const user = authenticateUser(username, password);
if (!user) {
return jsonError("用户名或密码错误。", 401);
}
const session = createSessionForUser(user.id);
const response = NextResponse.json({
ok: true,
user: toPublicUser(user),
quota: getQuotaSnapshotForUser(user),
});
response.cookies.set(
SESSION_COOKIE_NAME,
session.sessionId,
getSessionCookieOptions(session.expiresAt),
);
return response;
} catch (error) {
const normalizedError = normalizeError(error, "登录失败。");
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE_NAME, clearSessionById } from "@/lib/auth";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const sessionId = request.cookies.get(SESSION_COOKIE_NAME)?.value;
clearSessionById(sessionId);
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: new Date(0),
});
return response;
}

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

13
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { appConfig } from "@/lib/config";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({
ok: true,
configured: Boolean(appConfig.openAiApiKey),
model: appConfig.openAiImageModel,
timeoutMs: appConfig.openAiTimeoutMs,
});
}

View File

@@ -0,0 +1,53 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireApiUser } from "@/lib/api-auth";
import { getStoredImageAssetForViewer } from "@/lib/db";
import { jsonError, normalizeError } from "@/lib/http";
import { readStoredImageFile } from "@/lib/image-storage";
export const runtime = "nodejs";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const viewer = requireApiUser(request);
const { id } = await params;
const asset = getStoredImageAssetForViewer({
viewerId: viewer.id,
viewerRole: viewer.role,
imageId: id,
});
if (!asset) {
return jsonError("图片不存在。", 404);
}
const fileBuffer = await readStoredImageFile(asset.storageKey);
const shouldDownload = request.nextUrl.searchParams.get("download") === "1";
const dispositionType = shouldDownload ? "attachment" : "inline";
return new NextResponse(new Uint8Array(fileBuffer), {
status: 200,
headers: {
"Content-Type": asset.mimeType,
"Content-Length": String(fileBuffer.byteLength),
"Cache-Control": "private, max-age=86400",
"Content-Disposition": `${dispositionType}; filename*=UTF-8''${encodeURIComponent(asset.fileName)}`,
},
});
} catch (error) {
const normalizedError = normalizeError(error, "读取图片失败。");
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return jsonError("图片不存在。", 404);
}
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}

24
app/api/images/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireApiUser } from "@/lib/api-auth";
import { listImageHistoryForViewer } from "@/lib/db";
import { jsonError, normalizeError } from "@/lib/http";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
try {
const viewer = requireApiUser(request);
const items = listImageHistoryForViewer({
viewerId: viewer.id,
viewerRole: viewer.role,
});
return NextResponse.json({
ok: true,
items,
});
} catch (error) {
const normalizedError = normalizeError(error, "读取图片历史失败。");
return jsonError(normalizedError.message, normalizedError.statusCode);
}
}