feat(project): init
This commit is contained in:
34
app/api/admin/settings/route.ts
Normal file
34
app/api/admin/settings/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
app/api/admin/users/[id]/route.ts
Normal file
87
app/api/admin/users/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/api/admin/users/route.ts
Normal file
72
app/api/admin/users/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/api/auth/login/route.ts
Normal file
51
app/api/auth/login/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
app/api/auth/logout/route.ts
Normal file
22
app/api/auth/logout/route.ts
Normal 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
115
app/api/generate/route.ts
Normal 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
13
app/api/health/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
53
app/api/images/[id]/route.ts
Normal file
53
app/api/images/[id]/route.ts
Normal 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
24
app/api/images/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user