// server/utils/imageGenerationRecords.ts - 生图相关数据库操作:用户快照、批次记录、图片子记录、历史查询和统计。 import type { IUserBasicData } from "#shared/types"; import type { IImageGenerationStatsData, IImageHistoryDetail, IImageHistoryImageItem, 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 IFinishImageGenerationImageSuccessInput { /** NewAPI 上游返回的生成图片 URL */ imageUrl: string; /** 上游返回的修订提示词,流式生图通常为空 */ revisedPrompt?: string | null; /** 完整上游响应,仅服务端内部保存 */ upstreamResponse: unknown; } interface IFinishImageGenerationImageArchiveSuccessInput { /** Lsky 图床归档后的图片 URL */ hostedImageUrl: string; /** 图片 MIME 类型,优先来自图床上传结果 */ imageMimeType: string | null; /** 完整 Lsky 上传响应 */ hostedResponse: unknown; } export interface IImageArchiveTask { /** 生图图片子记录 ID */ imageId: bigint; /** 所属生图批次记录 ID */ recordId: bigint; /** NewAPI 用户 ID */ userId: number; /** 上游生成图片 URL */ imageUrl: string; /** 图片子记录创建时间,用于构造图床文件名 */ createdAt: 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, batchSize: number ) => { await ensureUserRecord(userId); const record = await prisma.imageGeneration.create({ data: { userId, prompt, status: ImageGenerationStatus.RUNNING, model: DEFAULT_IMAGE_MODEL, batchSize, images: { create: Array.from({ length: batchSize }, (_, index) => ({ userId, index, status: ImageGenerationStatus.RUNNING })) } }, include: { images: { orderBy: { index: "asc" }, select: { id: true, index: true } } } }); await safeUpsertGenerationStats({ totalRequests: { increment: 1 }, runningRequests: { increment: 1 } }); return record; }; /** 将单张图片子记录标记为成功,并先写入上游结果;图床字段稍后由后台归档补写 */ export const finishImageGenerationImageSuccess = async ( imageId: bigint, input: IFinishImageGenerationImageSuccessInput ) => { await prisma.imageGenerationImage.update({ where: { id: imageId }, data: { status: ImageGenerationStatus.SUCCEEDED, 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 } }); }; /** 将单张图片子记录标记为失败,只保留服务端可排查摘要 */ export const finishImageGenerationImageFailed = async ( imageId: bigint, error: unknown ) => { await prisma.imageGenerationImage.update({ where: { id: imageId }, data: { status: ImageGenerationStatus.FAILED, errorMessage: getSafeErrorMessage(error), archiveStatus: ImageArchiveStatus.NOT_REQUIRED } }); }; /** 批次内所有图片都结束后,汇总批次状态与全局统计 */ export const finishImageGenerationBatch = async ({ recordId, startedAt, successCount, failedCount, error }: { recordId: bigint; startedAt: Date; successCount: number; failedCount: number; error?: unknown; }) => { const endedAt = new Date(); const hasSuccess = successCount > 0; await prisma.imageGeneration.update({ where: { id: recordId }, data: { status: hasSuccess ? ImageGenerationStatus.SUCCEEDED : ImageGenerationStatus.FAILED, endedAt, durationMs: getDurationMs(startedAt, endedAt), successCount, failedCount, errorMessage: hasSuccess ? null : getSafeErrorMessage(error) } }); await safeUpsertGenerationStats({ successRequests: hasSuccess ? { increment: 1 } : undefined, failedRequests: hasSuccess ? undefined : { increment: 1 }, runningRequests: { decrement: 1 }, totalImages: successCount > 0 ? { increment: successCount } : undefined }); }; /** 兼容旧调用:主链路异常且尚未逐图结算时,把整个批次标记为失败 */ export const finishImageGenerationFailed = async ( recordId: bigint, startedAt: Date, error: unknown ) => { const record = await prisma.imageGeneration.findUnique({ where: { id: recordId }, select: { batchSize: true } }); await prisma.imageGenerationImage.updateMany({ where: { imageGenerationId: recordId, status: ImageGenerationStatus.RUNNING }, data: { status: ImageGenerationStatus.FAILED, errorMessage: getSafeErrorMessage(error), archiveStatus: ImageArchiveStatus.NOT_REQUIRED } }); await finishImageGenerationBatch({ recordId, startedAt, successCount: 0, failedCount: record?.batchSize ?? 1, error }); }; /** 领取可执行的图床归档任务;通过 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.imageGenerationImage.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.imageGenerationImage.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.imageGenerationImage.findUnique({ where: { id: candidate.id }, select: { id: true, imageGenerationId: true, userId: true, imageUrl: true, createdAt: true, archiveAttempts: true } }); if (task?.imageUrl) { tasks.push({ imageId: task.id, recordId: task.imageGenerationId, userId: task.userId, imageUrl: task.imageUrl, createdAt: task.createdAt, archiveAttempts: task.archiveAttempts }); } } return tasks; }; /** 后台归档成功后回写图床地址、MIME 和图床响应 */ export const finishImageGenerationArchiveSuccess = async ( imageId: bigint, input: IFinishImageGenerationImageArchiveSuccessInput ) => { await prisma.imageGenerationImage.update({ where: { id: imageId }, 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 ({ imageId, nextRunAt, errorMessage }: { imageId: bigint; nextRunAt: Date; errorMessage: string; }) => { await prisma.imageGenerationImage.update({ where: { id: imageId }, data: { archiveStatus: ImageArchiveStatus.PENDING, archiveNextRunAt: nextRunAt, archiveLockedAt: null, archiveLockedBy: null, archiveLastError: errorMessage, errorMessage: null } }); }; /** 后台归档最终失败时仅补写本地错误文案,不影响已成功的生图状态 */ export const finishImageGenerationArchiveFailed = async ( imageId: bigint, errorMessage: string = "图片归档失败" ) => { await prisma.imageGenerationImage.update({ where: { id: imageId }, 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: historyInclude, 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: historyInclude }); if (!record) return null; return mapImageGenerationItem(record); }; /** 软删除当前用户单条生图批次历史 */ 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 ) => { const cleanUpdate = Object.fromEntries( Object.entries(update).filter(([, value]) => value !== undefined) ) as Prisma.GenerationStatsUpdateInput; try { await upsertGenerationStats(cleanUpdate); } 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, imageUrl: { not: null }, imageGeneration: { deletedAt: null }, OR: [ { archiveStatus: ImageArchiveStatus.PENDING, OR: [ { archiveNextRunAt: null }, { archiveNextRunAt: { lte: now } } ] }, { archiveStatus: ImageArchiveStatus.RUNNING, archiveLockedAt: { lt: staleBefore } } ] } satisfies Prisma.ImageGenerationImageWhereInput; }; /** 从 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; }; const historyInclude = { images: { orderBy: { index: "asc" as const } }, plazaPost: { select: { status: true, coverImageId: true, images: { select: { imageGenerationImageId: true } } } } }; type ImageGenerationWithHistory = Prisma.ImageGenerationGetPayload<{ include: typeof historyInclude; }>; /** 将 Prisma 批次记录转换为前端类型,BigInt/Date 在这里统一序列化 */ const mapImageGenerationItem = ( record: ImageGenerationWithHistory ): IImageHistoryItem => { const images = record.images.map((image) => mapImageGenerationImageItem(image, record.plazaPost) ); const publicImageIds = getPublicImageIds(record.plazaPost); return { id: record.id.toString(), userId: record.userId, prompt: record.prompt, status: record.status, model: record.model, batchSize: record.batchSize, successCount: record.successCount, failedCount: record.failedCount, startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString() ?? null, durationMs: record.durationMs, errorMessage: getPublicBatchMessage(record.status, record.errorMessage), isPublic: record.plazaPost?.status === PlazaPostStatus.PUBLIC && publicImageIds.size > 0, coverImageId: record.plazaPost?.coverImageId?.toString() ?? null, images, createdAt: record.createdAt.toISOString() }; }; const mapImageGenerationImageItem = ( image: ImageGenerationWithHistory["images"][number], plazaPost: ImageGenerationWithHistory["plazaPost"] ): IImageHistoryImageItem => { const publicImageIds = getPublicImageIds(plazaPost); return { id: image.id.toString(), index: image.index, status: image.status, imageUrl: image.imageUrl, hostedImageUrl: image.hostedImageUrl, imageMimeType: image.imageMimeType, revisedPrompt: image.revisedPrompt, errorMessage: getPublicImageMessage(image.status, image.errorMessage), isPublic: plazaPost?.status === PlazaPostStatus.PUBLIC && publicImageIds.has(image.id), isCover: plazaPost?.status === PlazaPostStatus.PUBLIC && plazaPost.coverImageId === image.id, createdAt: image.createdAt.toISOString() }; }; /** 展示图优先使用公开封面,其次使用第一张成功图,再兜底第一张子记录 */ const getPublicImageIds = ( plazaPost: ImageGenerationWithHistory["plazaPost"] ): Set => { if (!plazaPost || plazaPost.status !== PlazaPostStatus.PUBLIC) { return new Set(); } return new Set( plazaPost.images.map((image) => image.imageGenerationImageId) ); }; /** 计算耗时并兜底为非负数,避免系统时间抖动导致负值 */ 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 getPublicBatchMessage = ( status: ImageGenerationStatus, errorMessage: string | null ): string | null => { if (!errorMessage) return null; return status === ImageGenerationStatus.FAILED ? "图片生成失败" : null; }; /** 图片错误返回本地泛化文案,归档失败和生成失败分开表达 */ const getPublicImageMessage = ( status: ImageGenerationStatus, errorMessage: string | null ): string | null => { if (!errorMessage) return null; return status === ImageGenerationStatus.SUCCEEDED ? "图片归档失败" : "图片生成失败"; };