// server/utils/imageGenerationRecords.ts - 生图相关数据库操作:用户快照、生成记录、历史查询和统计。 import type { IUserBasicData } from "#shared/types"; import type { IImageGenerationStatsData, IImageHistoryDetail, IImageHistoryItem, IImageHistoryListData } from "#shared/types/openai"; import { consola } from "consola"; import { ImageArchiveStatus, ImageGenerationStatus, PlazaPostStatus, Prisma } from "~~/app/generated/prisma/client"; import { prisma } from "~~/server/utils/prisma"; const GLOBAL_STATS_ID = "global"; const DEFAULT_IMAGE_MODEL = "gpt-image-2"; interface IFinishImageGenerationSuccessInput { /** NewAPI 上游返回的生成图片 URL */ imageUrl: string; /** 上游返回的修订提示词,流式生图通常为空 */ revisedPrompt?: string | null; /** 完整上游响应,当前为 chat completions 流式聚合对象 */ upstreamResponse: unknown; } interface IFinishImageGenerationArchiveSuccessInput { /** Lsky 图床归档后的图片 URL */ hostedImageUrl: string; /** 图片 MIME 类型,优先来自图床上传结果 */ imageMimeType: string | null; /** 完整 Lsky 上传响应 */ hostedResponse: unknown; } export interface IImageArchiveTask { /** 生图记录 ID */ id: bigint; /** NewAPI 用户 ID */ userId: number; /** 上游生成图片 URL */ imageUrl: string; /** 记录创建时间,用于构造图床文件名 */ startedAt: Date; /** 当前这次归档是第几次尝试 */ archiveAttempts: number; } /** 登录或恢复登录时保存 NewAPI 用户快照 */ export const upsertUserSnapshot = (user: IUserBasicData) => { return prisma.user.upsert({ where: { id: user.id }, create: { id: user.id, username: user.username, displayName: user.display_name, group: user.group, role: user.role, status: user.status }, update: { username: user.username, displayName: user.display_name, group: user.group, role: user.role, status: user.status } }); }; /** 仅有 userId 时兜底创建用户记录,避免旧 cookie 用户生成时外键失败 */ export const ensureUserRecord = (userId: number) => { return prisma.user.upsert({ where: { id: userId }, create: { id: userId }, update: {} }); }; /** 读取用于 Lsky 文件命名的用户标识,缺失时由调用方使用兜底名 */ export const getUserArchiveIdentity = async (userId: number) => { const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true, displayName: true } }); return { username: user?.username || user?.displayName || "user" }; }; /** 创建进行中的生图记录,并递增全局请求与进行中统计 */ export const createRunningImageGeneration = async ( userId: number, prompt: string ) => { await ensureUserRecord(userId); const record = await prisma.imageGeneration.create({ data: { userId, prompt, status: ImageGenerationStatus.RUNNING, model: DEFAULT_IMAGE_MODEL } }); await safeUpsertGenerationStats({ totalRequests: { increment: 1 }, runningRequests: { increment: 1 } }); return record; }; /** 将生图记录标记为成功,并先写入上游结果;图床字段稍后由后台归档补写 */ export const finishImageGenerationSuccess = async ( recordId: bigint, startedAt: Date, input: IFinishImageGenerationSuccessInput ) => { const endedAt = new Date(); await prisma.imageGeneration.update({ where: { id: recordId }, data: { status: ImageGenerationStatus.SUCCEEDED, endedAt, durationMs: getDurationMs(startedAt, endedAt), imageUrl: input.imageUrl, hostedImageUrl: null, imageMimeType: null, revisedPrompt: input.revisedPrompt || null, upstreamResponse: input.upstreamResponse as Prisma.InputJsonValue, hostedResponse: Prisma.DbNull, errorMessage: null, archiveStatus: ImageArchiveStatus.PENDING, archiveAttempts: 0, archiveNextRunAt: new Date(), archiveLockedAt: null, archiveLockedBy: null, archiveLastError: null } }); await safeUpsertGenerationStats({ successRequests: { increment: 1 }, runningRequests: { decrement: 1 }, totalImages: { increment: 1 } }); }; /** 将生图记录标记为失败,并保存失败原因和耗时 */ export const finishImageGenerationFailed = async ( recordId: bigint, startedAt: Date, error: unknown ) => { const endedAt = new Date(); await prisma.imageGeneration.update({ where: { id: recordId }, data: { status: ImageGenerationStatus.FAILED, endedAt, durationMs: getDurationMs(startedAt, endedAt), errorMessage: getSafeErrorMessage(error) } }); await safeUpsertGenerationStats({ failedRequests: { increment: 1 }, runningRequests: { decrement: 1 } }); }; /** 领取可执行的图床归档任务;通过 updateMany 条件锁避免多实例重复处理 */ export const claimRunnableImageArchiveTasks = async ({ limit, workerId, lockTtlMs }: { limit: number; workerId: string; lockTtlMs: number; }): Promise => { if (limit <= 0) return []; const now = new Date(); const staleBefore = new Date(now.getTime() - lockTtlMs); const runnableWhere = buildRunnableArchiveWhere(now, staleBefore); const candidates = await prisma.imageGeneration.findMany({ where: runnableWhere, select: { id: true }, orderBy: [ { archiveNextRunAt: "asc" }, { createdAt: "asc" } ], take: limit * 3 }); const tasks: IImageArchiveTask[] = []; for (const candidate of candidates) { if (tasks.length >= limit) break; const claimed = await prisma.imageGeneration.updateMany({ where: { id: candidate.id, ...runnableWhere }, data: { archiveStatus: ImageArchiveStatus.RUNNING, archiveAttempts: { increment: 1 }, archiveLockedAt: now, archiveLockedBy: workerId, archiveLastError: null } }); if (claimed.count !== 1) continue; const task = await prisma.imageGeneration.findUnique({ where: { id: candidate.id }, select: { id: true, userId: true, imageUrl: true, startedAt: true, archiveAttempts: true } }); if (task?.imageUrl) { tasks.push({ id: task.id, userId: task.userId, imageUrl: task.imageUrl, startedAt: task.startedAt, archiveAttempts: task.archiveAttempts }); } } return tasks; }; /** 后台归档成功后回写图床地址、MIME 和图床响应 */ export const finishImageGenerationArchiveSuccess = async ( recordId: bigint, input: IFinishImageGenerationArchiveSuccessInput ) => { await prisma.imageGeneration.update({ where: { id: recordId }, data: { hostedImageUrl: input.hostedImageUrl, imageMimeType: input.imageMimeType, hostedResponse: input.hostedResponse as Prisma.InputJsonValue, errorMessage: null, archiveStatus: ImageArchiveStatus.SUCCEEDED, archiveNextRunAt: null, archiveLockedAt: null, archiveLockedBy: null, archiveLastError: null } }); }; /** 归档可重试失败时释放任务锁并安排下一次执行,前端仍显示归档中 */ export const scheduleImageGenerationArchiveRetry = async ({ recordId, nextRunAt, errorMessage }: { recordId: bigint; nextRunAt: Date; errorMessage: string; }) => { await prisma.imageGeneration.update({ where: { id: recordId }, data: { archiveStatus: ImageArchiveStatus.PENDING, archiveNextRunAt: nextRunAt, archiveLockedAt: null, archiveLockedBy: null, archiveLastError: errorMessage, errorMessage: null } }); }; /** 后台归档最终失败时仅补写本地错误文案,不影响已成功的生图状态 */ export const finishImageGenerationArchiveFailed = async ( recordId: bigint, errorMessage: string = "图片归档失败" ) => { await prisma.imageGeneration.update({ where: { id: recordId }, data: { archiveStatus: ImageArchiveStatus.FAILED, archiveNextRunAt: null, archiveLockedAt: null, archiveLockedBy: null, archiveLastError: errorMessage, errorMessage: "图片归档失败" } }); }; /** 查询当前用户未删除的生图历史列表,不返回完整上游/图床响应 */ export const listImageGenerationHistory = async ({ userId, page, pageSize }: { userId: number; page: number; pageSize: number; }): Promise => { const where = { userId, deletedAt: null }; const [total, records] = await Promise.all([ prisma.imageGeneration.count({ where }), prisma.imageGeneration.findMany({ where, include: { plazaPost: { select: { status: true } } }, orderBy: { createdAt: "desc" }, skip: (page - 1) * pageSize, take: pageSize }) ]); return { page, pageSize, total, items: records.map(mapImageGenerationItem) }; }; /** 查询当前用户单条生图历史详情,内部响应字段固定隐藏为 null */ export const getImageGenerationDetail = async ( userId: number, recordId: bigint ): Promise => { const record = await prisma.imageGeneration.findFirst({ where: { id: recordId, userId, deletedAt: null }, include: { plazaPost: { select: { status: true } } } }); if (!record) return null; return { ...mapImageGenerationItem(record), imageMimeType: record.imageMimeType, upstreamResponse: null, hostedResponse: null }; }; /** 软删除当前用户单条生图历史 */ export const softDeleteImageGeneration = async ( userId: number, recordId: bigint ): Promise => { const result = await prisma.imageGeneration.updateMany({ where: { id: recordId, userId, deletedAt: null }, data: { deletedAt: new Date() } }); return result.count > 0; }; /** 读取全局生图统计,没有记录时返回全 0 */ export const getGenerationStats = async (): Promise => { const stats = await prisma.generationStats.findUnique({ where: { id: GLOBAL_STATS_ID } }); return { totalRequests: stats?.totalRequests ?? 0, successRequests: stats?.successRequests ?? 0, failedRequests: stats?.failedRequests ?? 0, queuedRequests: stats?.queuedRequests ?? 0, runningRequests: stats?.runningRequests ?? 0, totalImages: stats?.totalImages ?? 0 }; }; /** 统一创建或更新全局统计行 */ const upsertGenerationStats = (update: Prisma.GenerationStatsUpdateInput) => { return prisma.generationStats.upsert({ where: { id: GLOBAL_STATS_ID }, create: { id: GLOBAL_STATS_ID, ...buildStatsCreateInput(update) }, update }); }; /** 更新统计失败不能影响主链路,避免统计表问题导致生图接口失败 */ const safeUpsertGenerationStats = async ( update: Prisma.GenerationStatsUpdateInput ) => { try { await upsertGenerationStats(update); } catch (error) { consola.error("[imageGenerationRecords] 更新生图统计失败", { message: error instanceof Error ? error.message : String(error) }); } }; /** upsert 创建统计行时,把本次 increment 转换为初始计数 */ const buildStatsCreateInput = (update: Prisma.GenerationStatsUpdateInput) => { return { totalRequests: getIncrementValue(update.totalRequests), successRequests: getIncrementValue(update.successRequests), failedRequests: getIncrementValue(update.failedRequests), queuedRequests: getIncrementValue(update.queuedRequests), runningRequests: getIncrementValue(update.runningRequests), totalImages: getIncrementValue(update.totalImages) }; }; /** 只领取成功生成、未删除、已到执行时间或锁超时的归档任务 */ const buildRunnableArchiveWhere = (now: Date, staleBefore: Date) => { return { status: ImageGenerationStatus.SUCCEEDED, deletedAt: null, imageUrl: { not: null }, OR: [ { archiveStatus: ImageArchiveStatus.PENDING, OR: [ { archiveNextRunAt: null }, { archiveNextRunAt: { lte: now } } ] }, { archiveStatus: ImageArchiveStatus.RUNNING, archiveLockedAt: { lt: staleBefore } } ] } satisfies Prisma.ImageGenerationWhereInput; }; /** 从 Prisma increment 操作里取出增量;非 increment 操作在创建时按 0 处理 */ const getIncrementValue = (value: unknown): number => { if ( value && typeof value === "object" && "increment" in value && typeof value.increment === "number" ) { return value.increment; } return 0; }; /** 将 Prisma 记录转换为前端类型,BigInt/Date 在这里统一序列化 */ const mapImageGenerationItem = (record: { id: bigint; userId: number; prompt: string; status: ImageGenerationStatus; model: string; startedAt: Date; endedAt: Date | null; durationMs: number | null; imageUrl: string | null; hostedImageUrl: string | null; revisedPrompt: string | null; errorMessage: string | null; createdAt: Date; plazaPost?: { status: PlazaPostStatus; } | null; }): IImageHistoryItem => { return { id: record.id.toString(), userId: record.userId, prompt: record.prompt, status: record.status, model: record.model, startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString() ?? null, durationMs: record.durationMs, imageUrl: record.imageUrl, hostedImageUrl: record.hostedImageUrl, revisedPrompt: record.revisedPrompt, errorMessage: getPublicRecordMessage(record.status, record.errorMessage), isPublic: record.plazaPost?.status === PlazaPostStatus.PUBLIC, createdAt: record.createdAt.toISOString() }; }; /** 计算耗时并兜底为非负数,避免系统时间抖动导致负值 */ const getDurationMs = (startedAt: Date, endedAt: Date) => { return Math.max(0, endedAt.getTime() - startedAt.getTime()); }; /** 保存到数据库的错误信息只保留可读摘要,不保存复杂错误对象 */ const getSafeErrorMessage = (error: unknown): string => { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "图片生成失败"; }; /** 返回给前端的记录错误只保留本地泛化文案,具体内部错误留在数据库和服务端日志 */ const getPublicRecordMessage = ( status: ImageGenerationStatus, errorMessage: string | null ): string | null => { if (!errorMessage) return null; return status === ImageGenerationStatus.SUCCEEDED ? "图片归档失败" : "图片生成失败"; };