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

8
lib/admin.ts Normal file
View File

@@ -0,0 +1,8 @@
import "server-only";
import { buildAdminQuotaSummaries } from "@/lib/quota";
import type { AdminSnapshot } from "@/lib/types";
export function getAdminSnapshot(): AdminSnapshot {
return buildAdminQuotaSummaries();
}

39
lib/api-auth.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { NextRequest } from "next/server";
import { SESSION_COOKIE_NAME, getUserFromSessionToken } from "@/lib/auth";
import type { HttpError, UserRecord } from "@/lib/types";
export function getApiCurrentUser(request: NextRequest): UserRecord | null {
const sessionToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
return getUserFromSessionToken(sessionToken);
}
export function requireApiUser(request: NextRequest): UserRecord {
const user = getApiCurrentUser(request);
if (!user) {
const error = new Error("请先登录。") as HttpError;
error.statusCode = 401;
throw error;
}
if (!user.isActive) {
const error = new Error("当前账号已被禁用。") as HttpError;
error.statusCode = 403;
throw error;
}
return user;
}
export function requireApiAdmin(request: NextRequest): UserRecord {
const user = requireApiUser(request);
if (user.role !== "admin") {
const error = new Error("需要管理员权限。") as HttpError;
error.statusCode = 403;
throw error;
}
return user;
}

142
lib/auth.ts Normal file
View File

@@ -0,0 +1,142 @@
import "server-only";
import { randomUUID } from "node:crypto";
import bcrypt from "bcryptjs";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
createSessionRecord,
deleteSessionRecord,
getSessionUser,
getUserByUsername,
normalizeUsername,
} from "@/lib/db";
import { appConfig } from "@/lib/config";
import type { PublicUser, SessionRecord, UserRecord } from "@/lib/types";
export const SESSION_COOKIE_NAME = "image_prompt_session";
export function toPublicUser(user: UserRecord | null): PublicUser | null {
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
role: user.role,
isActive: user.isActive,
monthlyQuota: user.monthlyQuota,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
export function hashPassword(password: string): string {
return bcrypt.hashSync(String(password), 12);
}
export function verifyPassword(
password: string,
passwordHash: string,
): boolean {
return bcrypt.compareSync(String(password), passwordHash);
}
export function authenticateUser(
username: string,
password: string,
): UserRecord | null {
const user = getUserByUsername(normalizeUsername(username));
if (!user || !user.isActive) {
return null;
}
if (!verifyPassword(password, user.passwordHash)) {
return null;
}
return user;
}
export function createSessionForUser(userId: string): SessionRecord {
const sessionId = randomUUID();
const expiresAt = new Date(
Date.now() + appConfig.sessionDays * 24 * 60 * 60 * 1000,
).toISOString();
createSessionRecord({
sessionId,
userId,
expiresAt,
});
return {
sessionId,
userId,
expiresAt,
};
}
export function getSessionCookieOptions(expiresAt: string) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
expires: new Date(expiresAt),
};
}
export function clearSessionById(sessionId: string | undefined): void {
if (!sessionId) {
return;
}
deleteSessionRecord(sessionId);
}
export function getUserFromSessionToken(
sessionToken: string | undefined,
): UserRecord | null {
if (!sessionToken) {
return null;
}
return getSessionUser(sessionToken);
}
export async function getCurrentUser(): Promise<UserRecord | null> {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value;
return getUserFromSessionToken(sessionToken);
}
export const getCachedCurrentUser = cache(getCurrentUser);
export async function requireCurrentUser(): Promise<UserRecord> {
const user = await getCachedCurrentUser();
if (!user) {
redirect("/login");
}
if (!user.isActive) {
redirect("/login?reason=inactive");
}
return user;
}
export async function requireAdminUser(): Promise<UserRecord> {
const user = await requireCurrentUser();
if (user.role !== "admin") {
redirect("/studio");
}
return user;
}

95
lib/config.ts Normal file
View File

@@ -0,0 +1,95 @@
import path from "node:path";
interface AppConfig {
openAiApiKey: string;
openAiBaseUrl: string;
openAiImageModel: string;
openAiTimeoutMs: number;
databasePath: string;
generatedImagesPath: string;
adminUsername: string;
adminPassword: string;
adminSyncOnBoot: boolean;
defaultMonthlyQuota: number;
sessionDays: number;
}
function parseInteger(value: unknown, fallbackValue: number): number {
const numericValue = Number.parseInt(String(value ?? ""), 10);
if (Number.isFinite(numericValue)) {
return numericValue;
}
return fallbackValue;
}
function parseBoolean(value: unknown, fallbackValue: boolean): boolean {
if (value === undefined || value === null || value === "") {
return fallbackValue;
}
const normalizedValue = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalizedValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalizedValue)) {
return false;
}
return fallbackValue;
}
function resolveDatabasePath(rawPath?: string): string {
if (!rawPath) {
return path.join(
/* turbopackIgnore: true */ process.cwd(),
"data",
"image-prompt-studio.db",
);
}
if (path.isAbsolute(rawPath)) {
return rawPath;
}
return path.resolve(/* turbopackIgnore: true */ process.cwd(), rawPath);
}
function resolveGeneratedImagesPath(
databasePath: string,
rawPath?: string,
): string {
if (!rawPath) {
return path.join(path.dirname(databasePath), "generated-images");
}
if (path.isAbsolute(rawPath)) {
return rawPath;
}
return path.resolve(/* turbopackIgnore: true */ process.cwd(), rawPath);
}
const databasePath = resolveDatabasePath(process.env.DATABASE_PATH);
export const appConfig: AppConfig = {
openAiApiKey: process.env.OPENAI_API_KEY ?? "",
openAiBaseUrl: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
openAiImageModel: process.env.OPENAI_IMAGE_MODEL ?? "gpt-image-2",
openAiTimeoutMs: parseInteger(process.env.OPENAI_TIMEOUT_MS, 140000),
databasePath,
generatedImagesPath: resolveGeneratedImagesPath(
databasePath,
process.env.GENERATED_IMAGES_PATH,
),
adminUsername: String(process.env.ADMIN_USERNAME ?? "admin")
.trim()
.toLowerCase(),
adminPassword: String(process.env.ADMIN_PASSWORD ?? "change-me-now"),
adminSyncOnBoot: parseBoolean(process.env.ADMIN_SYNC_ON_BOOT, true),
defaultMonthlyQuota: parseInteger(process.env.DEFAULT_MONTHLY_QUOTA, 20),
sessionDays: parseInteger(process.env.SESSION_DAYS, 30),
};

999
lib/db.ts Normal file
View File

@@ -0,0 +1,999 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import bcrypt from "bcryptjs";
import Database from "better-sqlite3";
import { appConfig } from "./config";
import {
buildStoredImageDownloadUrl,
buildStoredImageUrl,
} from "@/lib/image-storage";
import type {
ImageHistoryItem,
GenerationJobStatus,
SessionRecord,
SettingsSnapshot,
StoredGeneratedImageInput,
StoredImageAsset,
UsageEventMeta,
UserRecord,
UserRole,
UserUsageSummary,
} from "@/lib/types";
type SqliteDatabase = InstanceType<typeof Database>;
interface UserRow {
id: string;
username: string;
password_hash: string;
role: UserRole;
monthly_quota: number | null;
is_active: number;
created_at: string;
updated_at: string;
}
interface UserSummaryRow extends UserRow {
used_this_period?: number | null;
}
interface SettingRow {
key: string;
value: string;
}
interface UsageRow {
used_units?: number | null;
}
interface TableInfoRow {
name: string;
}
interface StoredImageAssetRow {
id: string;
user_id: string;
file_name: string;
mime_type: string;
file_size: number;
storage_key: string;
}
interface ImageHistoryRow extends StoredImageAssetRow {
job_id: string;
username: string;
prompt: string;
revised_prompt: string | null;
model: string | null;
size: string | null;
quality: string | null;
output_format: string | null;
background: string | null;
moderation: string | null;
created_at: string;
}
declare global {
var __imagePromptStudioDb: SqliteDatabase | undefined;
}
function initializeDatabase(): SqliteDatabase {
const databaseDirectory = path.dirname(appConfig.databasePath);
fs.mkdirSync(databaseDirectory, { recursive: true });
const database = new Database(appConfig.databasePath);
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
monthly_quota INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
units INTEGER NOT NULL,
period_key TEXT NOT NULL,
created_at TEXT NOT NULL,
meta_json TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS generation_jobs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
image_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
model TEXT,
size TEXT,
quality TEXT,
output_format TEXT,
background TEXT,
moderation TEXT,
output_compression INTEGER,
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS generated_images (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
user_id TEXT NOT NULL,
file_name TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
revised_prompt TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES generation_jobs(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_usage_events_user_period ON usage_events(user_id, period_key);
CREATE INDEX IF NOT EXISTS idx_generation_jobs_user_id ON generation_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_generated_images_user_created_at ON generated_images(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_generated_images_job_id ON generated_images(job_id);
`);
ensureColumnExists(database, "generation_jobs", "model", "TEXT");
ensureColumnExists(database, "generation_jobs", "size", "TEXT");
ensureColumnExists(database, "generation_jobs", "quality", "TEXT");
ensureColumnExists(database, "generation_jobs", "output_format", "TEXT");
ensureColumnExists(database, "generation_jobs", "background", "TEXT");
ensureColumnExists(database, "generation_jobs", "moderation", "TEXT");
ensureColumnExists(
database,
"generation_jobs",
"output_compression",
"INTEGER",
);
upsertSettingRecord(
database,
"default_monthly_quota",
String(appConfig.defaultMonthlyQuota),
);
ensureAdminUser(database);
return database;
}
function ensureAdminUser(database: SqliteDatabase): void {
const now = new Date().toISOString();
const existingAdmin = database
.prepare("SELECT id FROM users WHERE username = ?")
.get(appConfig.adminUsername) as { id: string } | undefined;
const passwordHash = bcrypt.hashSync(appConfig.adminPassword, 12);
if (existingAdmin) {
if (!appConfig.adminSyncOnBoot) {
return;
}
database
.prepare(
`
UPDATE users
SET
password_hash = ?,
role = 'admin',
monthly_quota = NULL,
is_active = 1,
updated_at = ?
WHERE username = ?
`,
)
.run(passwordHash, now, appConfig.adminUsername);
return;
}
database
.prepare(
`
INSERT OR IGNORE INTO users (
id,
username,
password_hash,
role,
monthly_quota,
is_active,
created_at,
updated_at
) VALUES (?, ?, ?, 'admin', NULL, 1, ?, ?)
`,
)
.run(randomUUID(), appConfig.adminUsername, passwordHash, now, now);
}
function upsertSettingRecord(
database: SqliteDatabase,
key: string,
value: string,
): void {
database
.prepare(
`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`,
)
.run(key, value, new Date().toISOString());
}
function ensureColumnExists(
database: SqliteDatabase,
tableName: string,
columnName: string,
columnSql: string,
): void {
const columns = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as TableInfoRow[];
if (columns.some((column) => column.name === columnName)) {
return;
}
database.exec(
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnSql}`,
);
}
if (!globalThis.__imagePromptStudioDb) {
globalThis.__imagePromptStudioDb = initializeDatabase();
}
export const db = globalThis.__imagePromptStudioDb as SqliteDatabase;
export function syncAdminUserFromConfig(): void {
ensureAdminUser(db);
}
export function normalizeUsername(value: unknown): string {
return String(value ?? "")
.trim()
.toLowerCase();
}
export function getUserById(id: string): UserRecord | null {
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as
| UserRow
| undefined;
return mapUserRecord(row);
}
export function getUserByUsername(username: string): UserRecord | null {
const row = db
.prepare("SELECT * FROM users WHERE username = ?")
.get(normalizeUsername(username)) as UserRow | undefined;
return mapUserRecord(row);
}
export function listUserSummaries(periodKey: string): UserUsageSummary[] {
const rows = db
.prepare(
`
SELECT
u.*,
COALESCE(SUM(CASE WHEN ue.period_key = ? THEN ue.units ELSE 0 END), 0) AS used_this_period
FROM users u
LEFT JOIN usage_events ue ON ue.user_id = u.id
GROUP BY u.id
ORDER BY CASE u.role WHEN 'admin' THEN 0 ELSE 1 END, u.created_at ASC
`,
)
.all(periodKey) as UserSummaryRow[];
return rows.map((row) => {
const user = mapUserRecord(row);
if (!user) {
throw new Error("Failed to map user record.");
}
return {
...user,
usedThisPeriod: Number(row.used_this_period ?? 0),
};
});
}
export function createUserRecord({
username,
passwordHash,
role,
monthlyQuota,
}: {
username: string;
passwordHash: string;
role: UserRole;
monthlyQuota: number | string | null | undefined;
}): UserRecord {
const now = new Date().toISOString();
const id = randomUUID();
db.prepare(
`
INSERT INTO users (
id,
username,
password_hash,
role,
monthly_quota,
is_active,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, 1, ?, ?)
`,
).run(
id,
normalizeUsername(username),
passwordHash,
role,
normalizeMonthlyQuota(monthlyQuota),
now,
now,
);
const user = getUserById(id);
if (!user) {
throw new Error("Failed to create user record.");
}
return user;
}
export function updateUserRecord(
id: string,
updates: {
role?: UserRole;
monthlyQuota?: number | string | null;
isActive?: boolean;
passwordHash?: string;
},
): UserRecord | null {
const currentUser = getUserById(id);
if (!currentUser) {
return null;
}
const nextUser = {
...currentUser,
role: updates.role ?? currentUser.role,
monthlyQuota:
updates.monthlyQuota === undefined
? currentUser.monthlyQuota
: normalizeMonthlyQuota(updates.monthlyQuota),
isActive:
updates.isActive === undefined
? currentUser.isActive
: Boolean(updates.isActive),
passwordHash: updates.passwordHash ?? currentUser.passwordHash,
};
db.prepare(
`
UPDATE users
SET
role = ?,
monthly_quota = ?,
is_active = ?,
password_hash = ?,
updated_at = ?
WHERE id = ?
`,
).run(
nextUser.role,
nextUser.monthlyQuota,
nextUser.isActive ? 1 : 0,
nextUser.passwordHash,
new Date().toISOString(),
id,
);
return getUserById(id);
}
export function createSessionRecord({
sessionId,
userId,
expiresAt,
}: SessionRecord): void {
db.prepare(
`
INSERT INTO sessions (id, user_id, expires_at, created_at)
VALUES (?, ?, ?, ?)
`,
).run(sessionId, userId, expiresAt, new Date().toISOString());
}
export function deleteSessionRecord(sessionId: string): void {
db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
}
export function deleteExpiredSessions(): void {
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(
new Date().toISOString(),
);
}
export function getSessionUser(sessionId: string): UserRecord | null {
deleteExpiredSessions();
const row = db
.prepare(
`
SELECT u.*
FROM sessions s
INNER JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ?
LIMIT 1
`,
)
.get(sessionId, new Date().toISOString()) as UserRow | undefined;
return mapUserRecord(row);
}
export function getSettingsSnapshot(): SettingsSnapshot {
const rows = db
.prepare("SELECT key, value FROM settings")
.all() as SettingRow[];
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
return {
defaultMonthlyQuota: Number.parseInt(
settings.default_monthly_quota ?? String(appConfig.defaultMonthlyQuota),
10,
),
};
}
export function setDefaultMonthlyQuota(
value: number | string,
): SettingsSnapshot {
upsertSettingRecord(
db,
"default_monthly_quota",
String(normalizeQuotaInput(value)),
);
return getSettingsSnapshot();
}
export function getUsageForUser(userId: string, periodKey: string): number {
const row = db
.prepare(
`
SELECT COALESCE(SUM(units), 0) AS used_units
FROM usage_events
WHERE user_id = ? AND period_key = ?
`,
)
.get(userId, periodKey) as UsageRow | undefined;
return Number(row?.used_units ?? 0);
}
export function recordUsageEvent({
userId,
units,
periodKey,
meta,
}: {
userId: string;
units: number;
periodKey: string;
meta?: UsageEventMeta | null;
}): void {
insertUsageEvent(db, {
userId,
units,
periodKey,
meta,
createdAt: new Date().toISOString(),
});
}
export function recordCompletedGeneration({
jobId = randomUUID(),
userId,
prompt,
imageCount,
durationMs,
model,
size,
quality,
outputFormat,
background,
moderation,
outputCompression = null,
periodKey,
usageMeta,
images,
}: {
jobId?: string;
userId: string;
prompt: string;
imageCount: number;
durationMs: number | null;
model: string;
size: string;
quality: string;
outputFormat: string;
background: string;
moderation: string;
outputCompression?: number | null;
periodKey: string;
usageMeta?: UsageEventMeta | null;
images: StoredGeneratedImageInput[];
}): string {
const now = new Date().toISOString();
const commitGeneration = db.transaction(() => {
insertGenerationJob(db, {
id: jobId,
userId,
prompt,
status: "success",
imageCount,
durationMs,
errorMessage: null,
model,
size,
quality,
outputFormat,
background,
moderation,
outputCompression,
createdAt: now,
});
insertGeneratedImages(db, {
jobId,
userId,
images,
createdAt: now,
});
insertUsageEvent(db, {
userId,
units: imageCount,
periodKey,
meta: usageMeta,
createdAt: now,
});
});
commitGeneration();
return jobId;
}
export function listImageHistoryForViewer({
viewerId,
viewerRole,
}: {
viewerId: string;
viewerRole: UserRole;
}): ImageHistoryItem[] {
const rows =
viewerRole === "admin"
? (db
.prepare(
`
SELECT
gi.id,
gi.job_id,
gi.user_id,
gi.file_name,
gi.storage_key,
gi.mime_type,
gi.file_size,
gi.revised_prompt,
gi.created_at,
u.username,
gj.prompt,
gj.model,
gj.size,
gj.quality,
gj.output_format,
gj.background,
gj.moderation
FROM generated_images gi
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
INNER JOIN users u ON u.id = gi.user_id
ORDER BY gi.created_at DESC
`,
)
.all() as ImageHistoryRow[])
: (db
.prepare(
`
SELECT
gi.id,
gi.job_id,
gi.user_id,
gi.file_name,
gi.storage_key,
gi.mime_type,
gi.file_size,
gi.revised_prompt,
gi.created_at,
u.username,
gj.prompt,
gj.model,
gj.size,
gj.quality,
gj.output_format,
gj.background,
gj.moderation
FROM generated_images gi
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
INNER JOIN users u ON u.id = gi.user_id
WHERE gi.user_id = ?
ORDER BY gi.created_at DESC
`,
)
.all(viewerId) as ImageHistoryRow[]);
return rows.map(mapImageHistoryItem);
}
export function getStoredImageAssetForViewer({
viewerId,
viewerRole,
imageId,
}: {
viewerId: string;
viewerRole: UserRole;
imageId: string;
}): StoredImageAsset | null {
const row =
viewerRole === "admin"
? (db
.prepare(
`
SELECT
id,
user_id,
file_name,
mime_type,
file_size,
storage_key
FROM generated_images
WHERE id = ?
LIMIT 1
`,
)
.get(imageId) as StoredImageAssetRow | undefined)
: (db
.prepare(
`
SELECT
id,
user_id,
file_name,
mime_type,
file_size,
storage_key
FROM generated_images
WHERE id = ? AND user_id = ?
LIMIT 1
`,
)
.get(imageId, viewerId) as StoredImageAssetRow | undefined);
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
fileName: row.file_name,
mimeType: row.mime_type,
fileSize: Number(row.file_size),
storageKey: row.storage_key,
};
}
export function recordGenerationJob({
userId,
prompt,
status,
imageCount = 0,
durationMs = null,
errorMessage = null,
model = null,
size = null,
quality = null,
outputFormat = null,
background = null,
moderation = null,
outputCompression = null,
}: {
userId: string;
prompt: string;
status: GenerationJobStatus;
imageCount?: number;
durationMs?: number | null;
errorMessage?: string | null;
model?: string | null;
size?: string | null;
quality?: string | null;
outputFormat?: string | null;
background?: string | null;
moderation?: string | null;
outputCompression?: number | null;
}): string {
const id = randomUUID();
insertGenerationJob(db, {
id,
userId,
prompt,
status,
imageCount,
durationMs,
errorMessage,
model,
size,
quality,
outputFormat,
background,
moderation,
outputCompression,
createdAt: new Date().toISOString(),
});
return id;
}
function insertUsageEvent(
database: SqliteDatabase,
{
userId,
units,
periodKey,
meta,
createdAt,
}: {
userId: string;
units: number;
periodKey: string;
meta?: UsageEventMeta | null;
createdAt: string;
},
): void {
database.prepare(
`
INSERT INTO usage_events (id, user_id, units, period_key, created_at, meta_json)
VALUES (?, ?, ?, ?, ?, ?)
`,
).run(
randomUUID(),
userId,
units,
periodKey,
createdAt,
meta ? JSON.stringify(meta) : null,
);
}
function insertGenerationJob(
database: SqliteDatabase,
{
id,
userId,
prompt,
status,
imageCount,
durationMs,
errorMessage,
model,
size,
quality,
outputFormat,
background,
moderation,
outputCompression,
createdAt,
}: {
id: string;
userId: string;
prompt: string;
status: GenerationJobStatus;
imageCount: number;
durationMs: number | null;
errorMessage: string | null;
model: string | null;
size: string | null;
quality: string | null;
outputFormat: string | null;
background: string | null;
moderation: string | null;
outputCompression: number | null;
createdAt: string;
},
): void {
database.prepare(
`
INSERT INTO generation_jobs (
id,
user_id,
prompt,
status,
image_count,
duration_ms,
model,
size,
quality,
output_format,
background,
moderation,
output_compression,
error_message,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
userId,
prompt,
status,
imageCount,
durationMs,
model,
size,
quality,
outputFormat,
background,
moderation,
outputCompression,
errorMessage,
createdAt,
);
}
function insertGeneratedImages(
database: SqliteDatabase,
{
jobId,
userId,
images,
createdAt,
}: {
jobId: string;
userId: string;
images: StoredGeneratedImageInput[];
createdAt: string;
},
): void {
const statement = database.prepare(
`
INSERT INTO generated_images (
id,
job_id,
user_id,
file_name,
storage_key,
mime_type,
file_size,
revised_prompt,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
for (const image of images) {
statement.run(
image.id,
jobId,
userId,
image.fileName,
image.storageKey,
image.mimeType,
image.fileSize,
image.revisedPrompt,
createdAt,
);
}
}
function mapImageHistoryItem(row: ImageHistoryRow): ImageHistoryItem {
return {
id: row.id,
jobId: row.job_id,
userId: row.user_id,
username: row.username,
prompt: row.prompt,
revisedPrompt: row.revised_prompt,
model: row.model,
size: row.size,
quality: row.quality,
outputFormat: row.output_format,
background: row.background,
moderation: row.moderation,
fileName: row.file_name,
mimeType: row.mime_type,
fileSize: Number(row.file_size),
createdAt: row.created_at,
imageUrl: buildStoredImageUrl(row.id),
downloadUrl: buildStoredImageDownloadUrl(row.id),
};
}
function mapUserRecord(row: UserRow | null | undefined): UserRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
role: row.role,
monthlyQuota:
row.monthly_quota === null || row.monthly_quota === undefined
? null
: Number(row.monthly_quota),
isActive: Boolean(row.is_active),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function normalizeMonthlyQuota(value: unknown): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
return normalizeQuotaInput(value);
}
function normalizeQuotaInput(value: unknown): number {
const numericValue = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return 0;
}
return numericValue;
}

29
lib/http.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import type { HttpError } from "@/lib/types";
export function jsonError(
message: string,
statusCode = 500,
extra: Record<string, unknown> = {},
) {
return NextResponse.json(
{
ok: false,
error: message,
...extra,
},
{
status: statusCode,
},
);
}
export function normalizeError(error: unknown, fallbackMessage: string) {
const normalizedError = error as HttpError | null;
return {
statusCode: normalizedError?.statusCode ?? 500,
message: normalizedError?.message ?? fallbackMessage,
detail: normalizedError?.detail ?? null,
};
}

141
lib/image-storage.ts Normal file
View File

@@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { appConfig } from "@/lib/config";
import type { GeneratedImage, StoredGeneratedImageInput } from "@/lib/types";
export interface PersistedImageFile {
absolutePath: string;
storageKey: string;
}
export async function persistGeneratedImages({
userId,
jobId,
images,
createdAt = new Date(),
}: {
userId: string;
jobId: string;
images: GeneratedImage[];
createdAt?: Date;
}): Promise<{
images: StoredGeneratedImageInput[];
files: PersistedImageFile[];
}> {
const year = String(createdAt.getUTCFullYear());
const month = String(createdAt.getUTCMonth() + 1).padStart(2, "0");
const writtenFiles: PersistedImageFile[] = [];
try {
const persistedImages: StoredGeneratedImageInput[] = [];
for (const image of images) {
const extension = resolveFileExtension(image);
const imageId = randomUUID();
const safeFileName = sanitizeFileName(image.fileName, extension);
const storageKey = path.posix.join(
userId,
year,
month,
jobId,
`${imageId}.${extension}`,
);
const absolutePath = resolveStoredImageAbsolutePath(storageKey);
const directory = path.dirname(absolutePath);
const buffer = Buffer.from(image.base64, "base64");
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(absolutePath, buffer);
writtenFiles.push({
absolutePath,
storageKey,
});
persistedImages.push({
id: imageId,
fileName: safeFileName,
storageKey,
mimeType: image.mimeType,
fileSize: buffer.byteLength,
revisedPrompt: image.revisedPrompt,
});
}
return {
images: persistedImages,
files: writtenFiles,
};
} catch (error) {
await removePersistedImageFiles(writtenFiles);
throw error;
}
}
export async function removePersistedImageFiles(
files: PersistedImageFile[],
): Promise<void> {
await Promise.all(
files.map(async (file) => {
try {
await fs.unlink(file.absolutePath);
} catch {
return;
}
}),
);
}
export async function readStoredImageFile(storageKey: string): Promise<Buffer> {
return fs.readFile(resolveStoredImageAbsolutePath(storageKey));
}
export function buildStoredImageUrl(imageId: string): string {
return `/api/images/${imageId}`;
}
export function buildStoredImageDownloadUrl(imageId: string): string {
return `/api/images/${imageId}?download=1`;
}
function resolveStoredImageAbsolutePath(storageKey: string): string {
const rootPath = path.resolve(appConfig.generatedImagesPath);
const absolutePath = path.resolve(rootPath, storageKey);
if (
absolutePath !== rootPath &&
!absolutePath.startsWith(`${rootPath}${path.sep}`)
) {
throw new Error("Invalid storage key.");
}
return absolutePath;
}
function resolveFileExtension(image: GeneratedImage): string {
const explicitExtension = path.extname(image.fileName).replace(/^\./, "");
if (explicitExtension) {
return explicitExtension.toLowerCase();
}
if (image.mimeType === "image/jpeg") {
return "jpg";
}
if (image.mimeType === "image/webp") {
return "webp";
}
return "png";
}
function sanitizeFileName(fileName: string, extension: string): string {
const baseName = path.basename(fileName, path.extname(fileName));
const safeBaseName = baseName
.replace(/[^a-zA-Z0-9-_]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return `${safeBaseName || "generated-image"}.${extension}`;
}

130
lib/openai.ts Normal file
View File

@@ -0,0 +1,130 @@
import "server-only";
import { appConfig } from "@/lib/config";
import type {
GenerateOutputFormat,
GeneratePayload,
GenerateResult,
GeneratedImage,
HttpError,
} from "@/lib/types";
interface OpenAiImageEntry {
b64_json?: string;
revised_prompt?: string | null;
}
interface OpenAiErrorBody {
message?: string;
[key: string]: unknown;
}
interface OpenAiImageResponse {
created?: number | null;
data?: OpenAiImageEntry[];
error?: OpenAiErrorBody;
usage?: unknown;
}
export async function generateImagesWithOpenAI(
payload: GeneratePayload,
): Promise<GenerateResult> {
const startedAt = Date.now();
const response = await fetch(
`${appConfig.openAiBaseUrl.replace(/\/$/, "")}/images/generations`,
{
method: "POST",
headers: {
Authorization: `Bearer ${appConfig.openAiApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(appConfig.openAiTimeoutMs),
},
);
const responseBody = await parseJsonSafely(response);
if (!response.ok) {
const error = new Error(
responseBody?.error?.message ?? `OpenAI 返回了 ${response.status} 错误。`,
) as HttpError;
error.statusCode = response.status;
error.detail = responseBody?.error ?? null;
throw error;
}
const images = Array.isArray(responseBody?.data)
? responseBody.data
.map((entry, index) => mapGeneratedImage(entry, index, payload))
.filter((image): image is GeneratedImage => image !== null)
: [];
if (images.length === 0) {
const error = new Error(
"上游已返回成功,但没有拿到图片数据。",
) as HttpError;
error.statusCode = 502;
throw error;
}
return {
images,
meta: {
model: payload.model,
size: payload.size,
quality: payload.quality,
outputFormat: payload.output_format,
background: payload.background,
moderation: payload.moderation,
outputCompression: payload.output_compression,
count: images.length,
durationMs: Date.now() - startedAt,
created: responseBody?.created ?? null,
usage: responseBody?.usage ?? null,
},
};
}
async function parseJsonSafely(
response: Response,
): Promise<OpenAiImageResponse | null> {
try {
return (await response.json()) as OpenAiImageResponse;
} catch {
return null;
}
}
function mapGeneratedImage(
entry: OpenAiImageEntry,
index: number,
payload: GeneratePayload,
): GeneratedImage | null {
if (!entry?.b64_json) {
return null;
}
const fileExtension =
payload.output_format === "jpeg" ? "jpg" : payload.output_format;
const mimeType = resolveMimeType(payload.output_format);
return {
id: `image-${index + 1}`,
base64: entry.b64_json,
mimeType,
fileName: `gpt-image-${Date.now()}-${index + 1}.${fileExtension}`,
revisedPrompt: entry.revised_prompt ?? null,
};
}
function resolveMimeType(outputFormat: GenerateOutputFormat): string {
if (outputFormat === "jpeg") {
return "image/jpeg";
}
if (outputFormat === "webp") {
return "image/webp";
}
return "image/png";
}

6
lib/quota-core.ts Normal file
View File

@@ -0,0 +1,6 @@
export function getCurrentPeriodKey(date = new Date()): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}

101
lib/quota.ts Normal file
View File

@@ -0,0 +1,101 @@
import "server-only";
import { toPublicUser } from "@/lib/auth";
import {
getSettingsSnapshot,
getUsageForUser,
listUserSummaries,
} from "@/lib/db";
import { getCurrentPeriodKey } from "@/lib/quota-core";
import type { AdminSnapshot, QuotaSnapshot, UserRecord } from "@/lib/types";
type QuotaUser = Pick<UserRecord, "id" | "role" | "monthlyQuota">;
export function getQuotaSnapshotForUser(
user: QuotaUser | null | undefined,
): QuotaSnapshot {
const periodKey = getCurrentPeriodKey();
if (!user || user.role === "admin") {
return {
periodKey,
limit: null,
used: user ? getUsageForUser(user.id, periodKey) : 0,
remaining: null,
isUnlimited: true,
};
}
const settings = getSettingsSnapshot();
const limit =
user.monthlyQuota === null
? settings.defaultMonthlyQuota
: user.monthlyQuota;
const used = getUsageForUser(user.id, periodKey);
return {
periodKey,
limit,
used,
remaining: Math.max(limit - used, 0),
isUnlimited: false,
};
}
export function assertCanGenerate(user: QuotaUser): QuotaSnapshot {
const quotaSnapshot = getQuotaSnapshotForUser(user);
const remaining = quotaSnapshot.remaining ?? 0;
if (!quotaSnapshot.isUnlimited && remaining <= 0) {
const error = new Error(
"本月额度已用完,请联系管理员调整额度。",
) as Error & {
statusCode?: number;
};
error.statusCode = 403;
throw error;
}
return quotaSnapshot;
}
export function buildAdminQuotaSummaries(): AdminSnapshot {
const settings = getSettingsSnapshot();
const periodKey = getCurrentPeriodKey();
const users = listUserSummaries(periodKey).map((user) => {
const publicUser = toPublicUser(user);
if (!publicUser) {
throw new Error("Failed to sanitize user record.");
}
if (user.role === "admin") {
return {
...publicUser,
usedThisPeriod: user.usedThisPeriod,
limit: null,
remaining: null,
isUnlimited: true,
};
}
const limit =
user.monthlyQuota === null
? settings.defaultMonthlyQuota
: user.monthlyQuota;
return {
...publicUser,
usedThisPeriod: user.usedThisPeriod,
limit,
remaining: Math.max(limit - user.usedThisPeriod, 0),
isUnlimited: false,
};
});
return {
settings,
periodKey,
users,
};
}

155
lib/request-schema.ts Normal file
View File

@@ -0,0 +1,155 @@
import type {
GenerateOutputFormat,
GeneratePayload,
HttpError,
} from "@/lib/types";
const SIZE_OPTIONS = ["auto", "1024x1024", "1536x1024", "1024x1536"] as const;
const QUALITY_OPTIONS = ["auto", "low", "medium", "high"] as const;
const OUTPUT_FORMAT_OPTIONS = ["png", "jpeg", "webp"] as const;
const BACKGROUND_OPTIONS = ["auto", "transparent", "opaque"] as const;
const MODERATION_OPTIONS = ["auto", "low"] as const;
export function parseGenerateRequest(
body: unknown,
model: string,
): GeneratePayload {
if (!isRecord(body)) {
throw createRequestError(400, "请求体必须是 JSON 对象。");
}
const prompt = normalizePrompt(body.prompt);
const size = normalizeEnum(body.size, SIZE_OPTIONS, "size");
const quality = normalizeEnum(body.quality, QUALITY_OPTIONS, "quality");
const outputFormat = normalizeEnum(
body.outputFormat,
OUTPUT_FORMAT_OPTIONS,
"outputFormat",
);
const background = normalizeEnum(
body.background,
BACKGROUND_OPTIONS,
"background",
);
const moderation = normalizeEnum(
body.moderation,
MODERATION_OPTIONS,
"moderation",
);
const count = normalizeCount(body.count);
const outputCompression = normalizeCompression(
body.outputCompression,
outputFormat,
);
const user = normalizeUser(body.user);
if (outputFormat === "jpeg" && background === "transparent") {
throw createRequestError(
400,
"JPEG 不支持透明背景,请改为 PNG / WEBP 或将背景改成不透明。",
);
}
const payload: GeneratePayload = {
model,
prompt,
size,
quality,
n: count,
moderation,
output_format: outputFormat,
background,
};
if (typeof outputCompression === "number") {
payload.output_compression = outputCompression;
}
if (user) {
payload.user = user;
}
return payload;
}
export function createRequestError(
statusCode: number,
message: string,
): HttpError {
const error = new Error(message) as HttpError;
error.statusCode = statusCode;
return error;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizePrompt(value: unknown): string {
const prompt = String(value ?? "").trim();
if (!prompt) {
throw createRequestError(400, "Prompt 不能为空。");
}
if (prompt.length > 32000) {
throw createRequestError(400, "Prompt 不能超过 32000 个字符。");
}
return prompt;
}
function normalizeEnum<T extends string>(
value: unknown,
options: readonly T[],
fieldName: string,
): T {
const normalizedValue = (String(value ?? "").trim() || options[0]) as T;
if (!options.includes(normalizedValue)) {
throw createRequestError(400, `字段 ${fieldName} 的取值不合法。`);
}
return normalizedValue;
}
function normalizeCount(value: unknown): number {
const numericValue = Number.parseInt(String(value ?? "1"), 10);
if (!Number.isFinite(numericValue) || numericValue < 1 || numericValue > 4) {
throw createRequestError(400, "单次最多生成 4 张图片。");
}
return numericValue;
}
function normalizeCompression(
value: unknown,
outputFormat: GenerateOutputFormat,
): number | undefined {
if (outputFormat === "png") {
return undefined;
}
const numericValue = Number.parseInt(String(value ?? "90"), 10);
if (
!Number.isFinite(numericValue) ||
numericValue < 0 ||
numericValue > 100
) {
throw createRequestError(400, "压缩质量必须在 0 到 100 之间。");
}
return numericValue;
}
function normalizeUser(value: unknown): string | undefined {
const user = String(value ?? "").trim();
if (!user) {
return undefined;
}
return user.slice(0, 64);
}

156
lib/types.ts Normal file
View File

@@ -0,0 +1,156 @@
export type UserRole = "admin" | "user";
export type GenerationJobStatus = "success" | "error";
export interface UserRecord {
id: string;
username: string;
passwordHash: string;
role: UserRole;
monthlyQuota: number | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PublicUser {
id: string;
username: string;
role: UserRole;
monthlyQuota: number | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface UserUsageSummary extends UserRecord {
usedThisPeriod: number;
}
export interface SettingsSnapshot {
defaultMonthlyQuota: number;
}
export interface QuotaSnapshot {
periodKey: string;
limit: number | null;
used: number;
remaining: number | null;
isUnlimited: boolean;
}
export interface AdminUserSummary extends PublicUser {
usedThisPeriod: number;
limit: number | null;
remaining: number | null;
isUnlimited: boolean;
}
export interface AdminSnapshot {
settings: SettingsSnapshot;
periodKey: string;
users: AdminUserSummary[];
}
export type GenerateSize = "auto" | "1024x1024" | "1536x1024" | "1024x1536";
export type GenerateQuality = "auto" | "low" | "medium" | "high";
export type GenerateOutputFormat = "png" | "jpeg" | "webp";
export type GenerateBackground = "auto" | "transparent" | "opaque";
export type GenerateModeration = "auto" | "low";
export interface GeneratePayload {
model: string;
prompt: string;
size: GenerateSize;
quality: GenerateQuality;
n: number;
moderation: GenerateModeration;
output_format: GenerateOutputFormat;
background: GenerateBackground;
output_compression?: number;
user?: string;
}
export interface GeneratedImage {
id: string;
base64: string;
mimeType: string;
fileName: string;
revisedPrompt: string | null;
url?: string;
}
export interface GenerateMeta {
model: string;
size: GenerateSize;
quality: GenerateQuality;
outputFormat: GenerateOutputFormat;
background: GenerateBackground;
moderation: GenerateModeration;
outputCompression?: number;
count: number;
durationMs: number;
created: number | null;
usage: unknown;
}
export interface GenerateResult {
images: GeneratedImage[];
meta: GenerateMeta;
}
export interface SessionRecord {
sessionId: string;
userId: string;
expiresAt: string;
}
export interface StoredGeneratedImageInput {
id: string;
fileName: string;
storageKey: string;
mimeType: string;
fileSize: number;
revisedPrompt: string | null;
}
export interface ImageHistoryItem {
id: string;
jobId: string;
userId: string;
username: string;
prompt: string;
revisedPrompt: string | null;
model: string | null;
size: string | null;
quality: string | null;
outputFormat: string | null;
background: string | null;
moderation: string | null;
fileName: string;
mimeType: string;
fileSize: number;
createdAt: string;
imageUrl: string;
downloadUrl: string;
}
export interface StoredImageAsset {
id: string;
userId: string;
fileName: string;
mimeType: string;
fileSize: number;
storageKey: string;
}
export type UsageEventMeta = Record<string, unknown>;
export interface HttpError extends Error {
statusCode?: number;
detail?: unknown;
}