diff --git a/CLAUDE.md b/CLAUDE.md index 2dae984..bb95ece 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ - 所有返回给前端的 `msg` 必须使用本地业务表达,不提及“上游”“NewAPI”“Lsky”“供应商”“token”“key”等内部来源;内部异常统一返回“服务器内部错误”。 - 注释和日志要解释意图、边界和排查信息,不输出密码、cookie、完整 key、token、图片二进制或完整 prompt。 - 已存在的说明性注释不要随意删除,尤其是 `server/utils/openai.ts` 里的模型调用、视觉输入、流式解析等上下文注释。 +- 不要写任何可能阻塞整个进程的代码。 ## API 路由规范 @@ -59,17 +60,21 @@ ## 生图、图床与历史记录 -- `POST /api/images/generate` 当前同步等待生图完成后返回,不向前端透传流式进度。 +- `POST /api/images/generate` 当前同步等待生图完成后立即返回,不向前端透传流式进度,也不等待图床归档完成。 - 生图成功后返回: - `imageUrl`:生成图片访问地址,前端当前优先展示。 - - `hostedImageUrl`:Lsky 图床归档后的 URL,归档失败时为 `null`。 - `revisedPrompt`:流式 chat completions 当前没有等价字段,通常为空。 +- 生图主链路先把记录落库为 `SUCCEEDED`,并清空 `hostedImageUrl`、`hostedResponse`、`imageMimeType`、`errorMessage`;之后再由响应后的后台任务异步上传 Lsky。 +- 历史记录中的图床归档状态约定: + - `status === "SUCCEEDED"` 且 `hostedImageUrl === null` 且 `errorMessage === null`:表示图片已生成、仍在归档中。 + - `status === "SUCCEEDED"` 且 `hostedImageUrl === null` 且 `errorMessage !== null`:表示归档失败。 + - `hostedImageUrl !== null`:表示归档成功。 - Lsky 归档逻辑集中在 `server/utils/lsky.ts`: - 配置从 `LSKY_BASE_URL`、`LSKY_TOKEN`、`LSKY_STORAGE_ID` 读取。 - 上传时先下载上游图片,再用 `multipart/form-data` 调 Lsky `/upload`。 - 文件名格式为 `userId_username_timestamp_recordId.ext`。 - tags 固定包含 `AIArtStudio`、`user:{userId}`、`record:{recordId}`、`model:gpt-image-2`。 -- 图床上传失败不影响本次生图成功:接口仍返回 `imageUrl`,数据库记录归档失败提示;前端可见文案只能说“图片归档失败”等本地描述。 +- 图床上传失败不影响本次生图成功:接口仍立即返回 `imageUrl`,数据库记录归档失败提示;前端可见文案只能说“图片归档失败”等本地描述。 - 数据库不保存图片 base64;不要恢复 `image_base64` 或类似大文本存图方案。 - 历史列表和详情都不向前端返回完整上游响应、图床响应或内部错误详情;这些内容可以入库供服务端排查,但 API 响应应固定隐藏或置空。 @@ -82,10 +87,13 @@ - 登录或 `/api/auth/me` 成功后会 upsert `User` 快照,主键使用 NewAPI 用户 id。 - 生图记录写入 `ImageGeneration`: - 开始时写 `RUNNING`。 - - 成功时写 `SUCCEEDED`、上游 URL、图床 URL、MIME、完整上游响应、完整图床响应、耗时。 + - 生成成功时先写 `SUCCEEDED`、上游 URL、完整上游响应、耗时。 + - 后台归档成功后再补写图床 URL、MIME、完整图床响应。 + - 后台归档失败时只补写本地错误文案,不把记录改回失败。 - 失败时写 `FAILED`、错误信息、耗时。 - 删除历史采用软删除 `deletedAt`。 - `GenerationStats` 使用固定 id `global` 记录全局统计。 +- `runningRequests` 只表示生图进行中,不包含图床归档阶段。 - 统计更新失败只记录日志,不应阻断用户拿到已经生成的图片。 - 需要变更数据库结构时,同时维护 Prisma migration 和 `create_tables.sql`。 @@ -100,8 +108,8 @@ - `create_running_record` - `get_api_key` - `call_image_stream_api` - - `upload_lsky` - `finish_success_record` +- 图床归档改为响应后的后台日志阶段,继续打印 `recordId`、归档是否成功、失败摘要等信息,但不影响主请求返回。 - 不记录密码、cookie、完整 API key、Lsky token、完整 prompt、图片二进制、base64。 - 全局请求日志中间件只记录 method、path、status、耗时,不记录请求体和响应体。 @@ -131,7 +139,7 @@ - 用户服务在 `app/services/user_service.ts`,图片服务在 `app/services/image_service.ts`。 - 登录成功后可以调用 `UserReady()` 准备环境;`/api/auth/me` 恢复登录状态时不自动调用 ready。 - 当前首页生图组件为 `app/components/ImageGenerateCom.vue`,优先展示响应中的 `imageUrl`。 -- `hostedImageUrl` 作为图床归档地址返回,后续需要切换展示来源时再调整前端策略。 +- `hostedImageUrl` 不在生成接口中返回,只在历史记录中作为归档结果字段保留;后续需要切换展示来源时再调整前端策略。 ## 代码风格 diff --git a/create_tables.sql b/create_tables.sql index 889bbe9..256b70f 100644 --- a/create_tables.sql +++ b/create_tables.sql @@ -18,7 +18,6 @@ CREATE TABLE IF NOT EXISTS `image_generations` ( `prompt` TEXT NOT NULL COMMENT '用户输入的生图提示词', `status` ENUM('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED') NOT NULL DEFAULT 'RUNNING' COMMENT '生图状态', `model` VARCHAR(64) NOT NULL DEFAULT 'gpt-image-2' COMMENT '生图模型', - `size` VARCHAR(32) NOT NULL DEFAULT '1024x1024' COMMENT '图片尺寸', `started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生图开始时间', `ended_at` DATETIME(3) NULL COMMENT '生图结束时间', `duration_ms` INT NULL COMMENT '生图耗时,单位毫秒', diff --git a/prisma/migrations/20260426120000_remove_image_size/migration.sql b/prisma/migrations/20260426120000_remove_image_size/migration.sql new file mode 100644 index 0000000..8844611 --- /dev/null +++ b/prisma/migrations/20260426120000_remove_image_size/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `image_generations` + DROP COLUMN `size`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8ce68f9..454c84e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,7 +39,6 @@ model ImageGeneration { prompt String @db.Text status ImageGenerationStatus @default(RUNNING) model String @default("gpt-image-2") @db.VarChar(64) - size String @default("1024x1024") @db.VarChar(32) startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") durationMs Int? @map("duration_ms") diff --git a/server/api/images/generate.post.ts b/server/api/images/generate.post.ts index a3ecd29..6354375 100644 --- a/server/api/images/generate.post.ts +++ b/server/api/images/generate.post.ts @@ -1,8 +1,13 @@ -// server/api/images/generate.post.ts - 图片生成接口:创建记录、流式调用上游生图、归档图床并返回图片地址。 +// server/api/images/generate.post.ts - 图片生成接口:创建记录、流式调用上游生图并在响应后异步归档图床。 +import { setImmediate } from "node:timers"; import type { IImageGenerateData, IImageGenerateRequest } from "#shared/types/openai"; +import { + finishImageGenerationArchiveFailed, + finishImageGenerationArchiveSuccess +} from "~~/server/utils"; type ApiLogger = ReturnType; @@ -15,9 +20,9 @@ type ApiLogger = ReturnType; * 3. 创建 RUNNING 生图记录,并递增全局请求/进行中统计。 * 4. 在服务端确保并读取 AIArtStudio 完整 key,完整 key 不返回前端。 * 5. 调用 Chat Completions 流式生图接口,累积 SSE delta content 并提取最终图片 URL。 - * 6. 尝试把上游图片下载后上传到 Lsky 图床;图床失败不阻断本次生图成功。 - * 7. 成功时写入上游 URL、图床 URL、完整上游响应、图床响应和耗时。 - * 8. 失败时把记录标记为 FAILED;鉴权失败会清理本地登录态并返回 401。 + * 6. 上游返回图片 URL 后立刻把生图结果落库为 SUCCEEDED,并马上返回前端。 + * 7. 响应返回后再用后台异步任务上传 Lsky;归档失败只补写记录和日志,不影响本次响应。 + * 8. 主链路失败时把记录标记为 FAILED;鉴权失败会清理本地登录态并返回 401。 */ export default defineEventHandler(async (event) => { const logger = createApiLogger("images.generate"); @@ -85,41 +90,32 @@ export default defineEventHandler(async (event) => { hasUsage: hasStreamUsage(result.upstreamResponse) }); - stage = "upload_lsky"; - // 归档图床用于长期保存;失败时仍继续返回 NewAPI 上游图片 URL。 - const archiveResult = await archiveGeneratedImage({ - imageUrl: result.imageUrl, - userId, - recordId: record.id, - createdAt: record.startedAt, - logger - }); - logger.info("图床归档完成", { - recordId: record.id.toString(), - hosted: Boolean(archiveResult.hostedImageUrl), - mimeType: archiveResult.imageMimeType - }); - stage = "finish_success_record"; await finishImageGenerationSuccess(record.id, record.startedAt, { imageUrl: result.imageUrl, - hostedImageUrl: archiveResult.hostedImageUrl, - imageMimeType: archiveResult.imageMimeType, revisedPrompt: result.revisedPrompt, - upstreamResponse: result.upstreamResponse, - hostedResponse: archiveResult.hostedResponse, - errorMessage: archiveResult.errorMessage + upstreamResponse: result.upstreamResponse }); logger.done("成功", { recordId: record.id.toString(), - hosted: Boolean(archiveResult.hostedImageUrl) + archiveScheduled: true + }); + + const finishedRecord = record; + setImmediate(() => { + void archiveGeneratedImageInBackground({ + imageUrl: result.imageUrl, + userId, + recordId: finishedRecord.id, + createdAt: finishedRecord.startedAt, + logger + }); }); return createSuccessResponse( { imageUrl: result.imageUrl, - hostedImageUrl: archiveResult.hostedImageUrl, revisedPrompt: result.revisedPrompt }, "图片生成成功" @@ -154,8 +150,8 @@ export default defineEventHandler(async (event) => { } }); -/** 将生成图上传到 Lsky,返回图床地址;归档失败时降级为空结果 */ -const archiveGeneratedImage = async ({ +/** 响应返回后异步归档图片,成功则补写图床字段,失败则补写本地错误文案 */ +const archiveGeneratedImageInBackground = async ({ imageUrl, userId, recordId, @@ -167,14 +163,12 @@ const archiveGeneratedImage = async ({ recordId: bigint; createdAt: Date; logger: ApiLogger; -}): Promise<{ - hostedImageUrl: string | null; - imageMimeType: string | null; - hostedResponse: unknown; - errorMessage: string | null; -}> => { +}) => { + logger.info("后台归档开始", { + recordId: recordId.toString() + }); + try { - // 图床归档失败不能阻断本次生成结果,前端仍可使用上游图片 URL。 const identity = await getUserArchiveIdentity(userId); const uploaded = await uploadImageFromUrl({ imageUrl, @@ -184,24 +178,29 @@ const archiveGeneratedImage = async ({ createdAt }); - return { + await finishImageGenerationArchiveSuccess(recordId, { hostedImageUrl: uploaded.publicUrl, imageMimeType: uploaded.mimetype, - hostedResponse: uploaded.response, - errorMessage: null - }; + hostedResponse: uploaded.response + }); + + logger.info("后台归档成功", { + recordId: recordId.toString(), + mimeType: uploaded.mimetype, + hosted: true + }); } catch (error) { - logger.error("图床归档失败", { + logger.error("后台归档失败", { recordId: recordId.toString(), error: toSafeLogError(error) }); - return { - hostedImageUrl: null, - imageMimeType: null, - hostedResponse: null, - errorMessage: "图片生成成功,但图片归档失败" - }; + await finishImageGenerationArchiveFailed(recordId).catch((recordError) => { + logger.error("更新归档失败记录失败", { + recordId: recordId.toString(), + error: toSafeLogError(recordError) + }); + }); } }; diff --git a/server/api/images/history.get.ts b/server/api/images/history.get.ts index 1c964d5..2c936b2 100644 --- a/server/api/images/history.get.ts +++ b/server/api/images/history.get.ts @@ -12,7 +12,7 @@ const MAX_PAGE_SIZE = 50; * 1. 从 httpOnly cookie 读取当前用户 ID。 * 2. 读取 page/size 查询参数,并限制最大 pageSize,避免一次查太多。 * 3. 只查询当前用户且未软删除的记录,按创建时间倒序返回。 - * 4. 列表不返回完整上游响应或图床响应,详情接口再返回这些调试数据。 + * 4. 列表只返回页面展示所需字段,不返回内部响应或供应商调试数据。 */ export default defineEventHandler(async (event) => { const logger = createApiLogger("images.history.list"); diff --git a/server/api/images/history/[id].get.ts b/server/api/images/history/[id].get.ts index e470c48..80c9bc6 100644 --- a/server/api/images/history/[id].get.ts +++ b/server/api/images/history/[id].get.ts @@ -1,4 +1,4 @@ -// server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录和完整响应。 +// server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录的展示字段与归档状态。 import type { IImageHistoryDetail } from "#shared/types/openai"; import { clearNewApiAuthCookies, @@ -19,7 +19,7 @@ import { * 1. 从 httpOnly cookie 读取当前用户 ID。 * 2. 校验路由参数 id 必须是数字,并转成 BigInt 查询。 * 3. 只允许读取当前用户、未软删除的记录。 - * 4. 返回详情时包含完整上游响应和 Lsky 响应,便于排查单次生图问题。 + * 4. 详情接口继续隐藏内部响应,只返回页面展示和归档状态所需字段。 */ export default defineEventHandler(async (event) => { const logger = createApiLogger("images.history.detail"); diff --git a/server/utils/imageGenerationRecords.ts b/server/utils/imageGenerationRecords.ts index 65d37ef..e9ca965 100644 --- a/server/utils/imageGenerationRecords.ts +++ b/server/utils/imageGenerationRecords.ts @@ -12,23 +12,23 @@ import { prisma } from "~~/server/utils/prisma"; const GLOBAL_STATS_ID = "global"; const DEFAULT_IMAGE_MODEL = "gpt-image-2"; -const DEFAULT_IMAGE_SIZE = "1024x1024"; interface IFinishImageGenerationSuccessInput { /** NewAPI 上游返回的生成图片 URL */ imageUrl: string; - /** Lsky 图床归档后的图片 URL,归档失败时为空 */ - hostedImageUrl: string | null; - /** 图片 MIME 类型,优先来自图床上传结果 */ - imageMimeType: string | null; /** 上游返回的修订提示词,流式生图通常为空 */ revisedPrompt?: string | null; /** 完整上游响应,当前为 chat completions 流式聚合对象 */ upstreamResponse: unknown; - /** 完整 Lsky 上传响应,归档失败时为空 */ +} + +interface IFinishImageGenerationArchiveSuccessInput { + /** Lsky 图床归档后的图片 URL */ + hostedImageUrl: string; + /** 图片 MIME 类型,优先来自图床上传结果 */ + imageMimeType: string | null; + /** 完整 Lsky 上传响应 */ hostedResponse: unknown; - /** 成功状态下的非阻断提示,例如图床归档失败 */ - errorMessage?: string | null; } /** 登录或恢复登录时保存 NewAPI 用户快照 */ @@ -97,8 +97,7 @@ export const createRunningImageGeneration = async ( userId, prompt, status: ImageGenerationStatus.RUNNING, - model: DEFAULT_IMAGE_MODEL, - size: DEFAULT_IMAGE_SIZE + model: DEFAULT_IMAGE_MODEL } }); @@ -114,7 +113,7 @@ export const createRunningImageGeneration = async ( return record; }; -/** 将生图记录标记为成功,并保存上游 URL、图床 URL、完整响应和耗时 */ +/** 将生图记录标记为成功,并先写入上游结果;图床字段稍后由后台归档补写 */ export const finishImageGenerationSuccess = async ( recordId: bigint, startedAt: Date, @@ -131,15 +130,12 @@ export const finishImageGenerationSuccess = async ( endedAt, durationMs: getDurationMs(startedAt, endedAt), imageUrl: input.imageUrl, - hostedImageUrl: input.hostedImageUrl, - imageMimeType: input.imageMimeType, + hostedImageUrl: null, + imageMimeType: null, revisedPrompt: input.revisedPrompt || null, upstreamResponse: input.upstreamResponse as Prisma.InputJsonValue, - hostedResponse: - input.hostedResponse === null - ? Prisma.DbNull - : (input.hostedResponse as Prisma.InputJsonValue), - errorMessage: input.errorMessage || null + hostedResponse: Prisma.DbNull, + errorMessage: null } }); @@ -186,6 +182,39 @@ export const finishImageGenerationFailed = async ( }); }; +/** 后台归档成功后回写图床地址、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 + } + }); +}; + +/** 后台归档失败时仅补写本地错误文案,不影响已成功的生图状态 */ +export const finishImageGenerationArchiveFailed = async ( + recordId: bigint, + errorMessage: string = "图片归档失败" +) => { + await prisma.imageGeneration.update({ + where: { + id: recordId + }, + data: { + errorMessage + } + }); +}; + /** 查询当前用户未删除的生图历史列表,不返回完整上游/图床响应 */ export const listImageGenerationHistory = async ({ userId, @@ -221,7 +250,7 @@ export const listImageGenerationHistory = async ({ }; }; -/** 查询当前用户单条生图历史详情,包含完整上游与图床响应 */ +/** 查询当前用户单条生图历史详情,内部响应字段固定隐藏为 null */ export const getImageGenerationDetail = async ( userId: number, recordId: bigint @@ -342,7 +371,6 @@ const mapImageGenerationItem = (record: { prompt: string; status: ImageGenerationStatus; model: string; - size: string; startedAt: Date; endedAt: Date | null; durationMs: number | null; @@ -358,7 +386,6 @@ const mapImageGenerationItem = (record: { prompt: record.prompt, status: record.status, model: record.model, - size: record.size, startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString() ?? null, durationMs: record.durationMs, diff --git a/shared/types/openai.ts b/shared/types/openai.ts index c16f8e5..5fcda92 100644 --- a/shared/types/openai.ts +++ b/shared/types/openai.ts @@ -19,10 +19,8 @@ export interface IImageGenerateRequest { * 图片生成成功后返回给前端的数据。 */ export interface IImageGenerateData { - /** NewAPI 上游返回的图片访问地址,前端当前优先展示这个地址 */ + /** 生成图片访问地址,前端当前优先展示这个地址 */ imageUrl: string; - /** Lsky 图床归档后的图片访问地址,归档失败时为空 */ - hostedImageUrl?: string | null; /** 上游返回的修订提示词,可能为空 */ revisedPrompt?: string; } @@ -50,8 +48,6 @@ export interface IImageHistoryItem { status: ImageGenerationStatus; /** 生图模型 */ model: string; - /** 图片尺寸 */ - size: string; /** 生图开始时间,ISO 字符串 */ startedAt: string; /** 生图结束时间,ISO 字符串,未结束时为 null */ @@ -60,11 +56,11 @@ export interface IImageHistoryItem { durationMs: number | null; /** NewAPI 上游返回的图片访问地址 */ imageUrl: string | null; - /** Lsky 图床归档后的图片访问地址 */ + /** Lsky 图床归档后的图片访问地址;为空且没有错误提示时表示仍在归档中 */ hostedImageUrl: string | null; /** 上游返回的修订提示词,可能为空 */ revisedPrompt: string | null; - /** 面向前端的本地泛化错误提示,不包含上游或内部服务细节 */ + /** 面向前端的本地泛化错误提示;为空且 hostedImageUrl 也为空时表示仍在归档中 */ errorMessage: string | null; /** 记录创建时间,ISO 字符串 */ createdAt: string;