feat: 支持批量生图

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 01:40:46 +08:00
parent 9a715dede8
commit 7f59b555c6
22 changed files with 1731 additions and 394 deletions
+190 -65
View File
@@ -1,22 +1,26 @@
// server/api/images/generate.post.ts - 图片生成接口:创建记录、流式调用上游生图并将图床归档交给数据库队列。
// server/api/images/generate.post.ts - 图片批量生成接口:创建批次与图片子记录、并行调用上游生图并将图床归档交给数据库队列。
import type {
IImageGenerateData,
IImageGenerateRequest
IImageGenerateRequest,
IImageGenerateResultImage
} from "#shared/types/openai";
import { wakeImageArchiveWorker } from "~~/server/utils";
const DEFAULT_BATCH_SIZE = 1;
const DEFAULT_MAX_BATCH_SIZE = 3;
/**
* POST /api/images/generate
*
* 流程:
* 1. 校验请求体prompt空 prompt 不创建数据库记录。
* 1. 校验请求体prompt 与 batch_size空 prompt 和非法数量不创建数据库记录。
* 2. 从 httpOnly cookie 读取当前 NewAPI 用户 ID。
* 3. 创建 RUNNING 生图记录,并递增全局请求/进行中统计。
* 3. 创建 RUNNING 生图批次记录和对应数量的 RUNNING 图片子记录,并递增全局请求/进行中统计。
* 4. 在服务端确保并读取 AIArtStudio 完整 key,完整 key 不返回前端。
* 5. 调用 Chat Completions 流式生图接口,累积 SSE delta content 并提取最终图片 URL
* 6. 上游返回图片 URL 后立刻把生图结果落库为 SUCCEEDED,并把图床归档任务标记为待执行
* 7. 唤醒数据库归档队列 worker;归档失败只补写记录和日志,不影响本次响应。
* 8. 主链路失败时把记录标记为 FAILED鉴权失败会清理本地登录态并返回 401。
* 5. 对每张图片并行调用 Chat Completions 流式生图接口,每张图独立落库成功或失败
* 6. 至少一张成功时批次记为 SUCCEEDED,并按成功图片数递增 totalImages;全部失败时批次记为 FAILED
* 7. 成功图片进入图床归档队列;归档失败只补写图片子记录和日志,不影响本次响应。
* 8. 主链路鉴权失败会清理本地登录态并返回 401;接口不向前端暴露 key、token、上游响应或内部错误详情
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.generate");
@@ -42,12 +46,30 @@ export default defineEventHandler(async (event) => {
return createErrorResponse(400, "请输入图片描述");
}
// 不记录完整 prompt,日志只保留长度,避免把用户输入或潜在敏感内容写进日志。
const maxBatchSize = getMaxBatchSize();
const batchSizeResult = normalizeBatchSize(
requestBody?.batch_size,
maxBatchSize
);
if (!batchSizeResult.ok) {
logger.warn("生图数量参数错误", {
reason: batchSizeResult.reason,
maxBatchSize
});
return createErrorResponse(400, batchSizeResult.message);
}
const batchSize = batchSizeResult.value;
// 不记录完整 prompt,日志只保留长度和数量,避免把用户输入或潜在敏感内容写进日志。
logger.info("开始", {
promptLength: prompt.length
promptLength: prompt.length,
batchSize,
maxBatchSize
});
let record: { id: bigint; startedAt: Date } | null = null;
let record: Awaited<ReturnType<typeof createRunningImageGeneration>> | null =
null;
let batchFinished = false;
try {
stage = "read_user_id";
@@ -59,9 +81,10 @@ export default defineEventHandler(async (event) => {
stage = "create_running_record";
// 从这里开始才写数据库;参数错误和空 prompt 不会留下无效生图记录。
record = await createRunningImageGeneration(userId, prompt);
logger.info("创建生图记录成功", {
recordId: record.id.toString()
record = await createRunningImageGeneration(userId, prompt, batchSize);
logger.info("创建生图批次记录成功", {
recordId: record.id.toString(),
imageCount: record.images.length
});
stage = "get_api_key";
@@ -72,39 +95,67 @@ export default defineEventHandler(async (event) => {
});
stage = "call_image_stream_api";
// 上游通过 SSE 分段返回进度和最终 Markdown 图片链接,这里同步等待流结束。
const result = await askImgStream({
apiKey,
prompt
});
logger.info("上游流式生图成功", {
recordId: record.id.toString(),
hasImageUrl: Boolean(result.imageUrl),
contentLength: getStreamContentLength(result.upstreamResponse),
hasUsage: hasStreamUsage(result.upstreamResponse)
});
stage = "finish_success_record";
await finishImageGenerationSuccess(record.id, record.startedAt, {
imageUrl: result.imageUrl,
revisedPrompt: result.revisedPrompt,
upstreamResponse: result.upstreamResponse
});
logger.done("成功", {
recordId: record.id.toString(),
archiveScheduled: true
});
wakeImageArchiveWorker();
return createSuccessResponse<IImageGenerateData>(
{
imageUrl: result.imageUrl,
revisedPrompt: result.revisedPrompt
},
"图片生成成功"
const settledResults = await Promise.allSettled(
record.images.map((image) =>
generateOneImage({
apiKey,
prompt,
imageId: image.id,
index: image.index
})
)
);
const images: IImageGenerateResultImage[] = [];
const errors: unknown[] = [];
for (const result of settledResults) {
if (result.status === "fulfilled") {
images.push(result.value);
} else {
errors.push(result.reason);
}
}
const successCount = images.length;
const failedCount = batchSize - successCount;
stage = "finish_batch_record";
await finishImageGenerationBatch({
recordId: record.id,
startedAt: record.startedAt,
successCount,
failedCount,
error: errors[0]
});
batchFinished = true;
logger.done(successCount > 0 ? "成功" : "全部失败", {
recordId: record.id.toString(),
successCount,
failedCount,
archiveScheduled: successCount > 0
});
if (successCount > 0) {
wakeImageArchiveWorker();
return createSuccessResponse<IImageGenerateData>(
{
recordId: record.id.toString(),
batchSize,
images: images.sort((left, right) => left.index - right.index),
failedCount
},
failedCount > 0 ? "图片生成完成,部分图片失败" : "图片生成成功"
);
}
if (isUnauthorizedError(errors[0])) {
clearNewApiAuthCookies(event);
return createErrorResponse(401, "未登录");
}
return createUpstreamErrorResponse(errors[0], "图片生成失败");
} catch (error) {
logger.error("失败", {
stage,
@@ -112,13 +163,13 @@ export default defineEventHandler(async (event) => {
error: toSafeLogError(error)
});
if (record) {
if (record && !batchFinished) {
await finishImageGenerationFailed(
record.id,
record.startedAt,
error
).catch((recordError) => {
logger.error("更新失败记录失败", {
logger.error("更新失败批次记录失败", {
recordId: record?.id.toString() ?? null,
error: toSafeLogError(recordError)
});
@@ -135,26 +186,100 @@ export default defineEventHandler(async (event) => {
}
});
// 日志只读取流式聚合结果的元信息,不记录完整提示词、key 或图片内容
const getStreamContentLength = (upstreamResponse: unknown) => {
if (
upstreamResponse &&
typeof upstreamResponse === "object" &&
"content" in upstreamResponse &&
typeof upstreamResponse.content === "string"
) {
return upstreamResponse.content.length;
// 对单张图片发起上游生图调用,成功时落库并返回结果,失败时落库错误记录后向上抛出原始异常
// 落库操作自身的异常会被静默忽略,不会覆盖原始上游错误。
const generateOneImage = async ({
apiKey,
prompt,
imageId,
index
}: {
apiKey: string;
prompt: string;
imageId: bigint;
index: number;
}): Promise<IImageGenerateResultImage> => {
try {
const result = await askImgStream({
apiKey,
prompt
});
await finishImageGenerationImageSuccess(imageId, {
imageUrl: result.imageUrl,
revisedPrompt: result.revisedPrompt,
upstreamResponse: result.upstreamResponse
});
return {
id: imageId.toString(),
index,
status: "SUCCEEDED",
imageUrl: result.imageUrl,
revisedPrompt: result.revisedPrompt
};
} catch (error) {
// 落库失败记录时忽略其自身异常,确保原始上游错误(含 401)能正确向上传递。
// 若不用 .catch() 包裹,finishImageGenerationImageFailed 抛出时 throw error 永远不会执行,
// 导致 Promise.allSettled 收到的是 DB 错误而非真正的上游错误,isUnauthorizedError 检查失效。
await finishImageGenerationImageFailed(imageId, error).catch(() => {});
throw error;
}
};
// normalizeBatchSize 的返回类型:discriminated unionok=true 时携带合法数量,ok=false 时携带错误原因和面向用户的提示。
type BatchSizeResult =
| {
ok: true;
value: number;
}
| {
ok: false;
reason: string; // 机器可读原因,用于日志
message: string; // 面向用户的提示,直接返回给前端
};
// 校验并规范化 batch_size 参数:缺省时返回默认值 1,非正整数或超出上限时返回错误结构。
// 使用 unknown 入参以安全处理来自请求体的任意值,不依赖调用方提前校验类型。
const normalizeBatchSize = (
value: unknown,
maxBatchSize: number
): BatchSizeResult => {
if (value === undefined || value === null) {
return {
ok: true,
value: DEFAULT_BATCH_SIZE
};
}
return 0;
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
return {
ok: false,
reason: "not_positive_integer",
message: "生图数量必须是大于 0 的整数"
};
}
if (value > maxBatchSize) {
return {
ok: false,
reason: "exceeds_max_batch_size",
message: `生图数量不能超过 ${maxBatchSize}`
};
}
return {
ok: true,
value
};
};
/** 判断流式上游响应里是否包含 usage,用于日志确认上游是否正常结束 */
const hasStreamUsage = (upstreamResponse: unknown) => {
return (
upstreamResponse !== null &&
typeof upstreamResponse === "object" &&
"usage" in upstreamResponse &&
Boolean(upstreamResponse.usage)
// 从环境变量读取允许的最大批次大小;解析失败或未配置时回落到内置默认值,不抛出异常。
const getMaxBatchSize = () => {
const value = Number.parseInt(
process.env.IMAGE_GENERATION_MAX_BATCH_SIZE ?? "",
10
);
return Number.isInteger(value) && value > 0 ? value : DEFAULT_MAX_BATCH_SIZE;
};
+1 -1
View File
@@ -37,7 +37,7 @@ export default defineEventHandler(async (event) => {
userId,
recordId: recordId.toString(),
status: result.status,
hasHostedImageUrl: Boolean(result.hostedImageUrl)
imageCount: result.images.length
});
return createSuccessResponse<IImageHistoryDetail>(
+67 -9
View File
@@ -1,5 +1,8 @@
// server/api/images/history/[id]/public.post.ts - 生图历史公开接口:将当前用户单条生图记录发布到广场。
import type { IImagePublicStateData } from "#shared/types/openai";
// server/api/images/history/[id]/public.post.ts - 生图历史公开接口:将当前用户单个批次中选定的图片发布到广场。
import type {
IImagePublicStateData,
IImagePublicStateRequest
} from "#shared/types/openai";
import {
clearNewApiAuthCookies,
createApiLogger,
@@ -17,9 +20,10 @@ import {
*
* 流程:
* 1. 从 httpOnly cookie 读取当前用户 ID。
* 2. 只允许公开当前用户、未软删除的生图记录。
* 3. 允许图片尚未归档时先创建公开记录;广场列表会等 hostedImageUrl 可用后再展示
* 4. 不返回上游响应、图床响应或内部错误详情
* 2. 只允许公开当前用户、未软删除的生图批次记录。
* 3. 请求体可指定 imageIds 和 coverImageId;空 body 时兼容旧前端,默认公开全部成功图
* 4. 允许图片尚未归档时先创建公开记录;广场列表会等封面 hostedImageUrl 可用后再展示
* 5. 不返回上游响应、图床响应或内部错误详情。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.history.public.publish");
@@ -28,13 +32,32 @@ export default defineEventHandler(async (event) => {
try {
const userId = getNewApiUserIdFromCookie(event);
const recordId = parseRecordId(getRouterParam(event, "id"));
const requestBody = await readBody<IImagePublicStateRequest | null>(event);
if (
requestBody === null ||
typeof requestBody !== "object" ||
Array.isArray(requestBody)
) {
return createErrorResponse(400, "请求体必须是 JSON 对象");
}
const imageIds = parseImageIds(requestBody.imageIds);
const coverImageId = parseRecordId(requestBody.coverImageId);
logger.info("公开参数", {
userId,
recordId: recordId.toString()
recordId: recordId.toString(),
imageCount: imageIds.length,
coverImageId: coverImageId.toString()
});
const result = await publishImageGenerationToPlaza(userId, recordId);
const result = await publishImageGenerationToPlaza({
userId,
recordId,
imageIds,
coverImageId
});
if (!result) {
logger.warn("记录不存在", {
@@ -52,9 +75,19 @@ export default defineEventHandler(async (event) => {
return createErrorResponse(403, "图片已下架,无法公开");
}
if (result === "INVALID_IMAGES") {
logger.warn("公开图片参数无效", {
userId,
recordId: recordId.toString()
});
return createErrorResponse(400, "请选择可公开的图片");
}
logger.done("成功", {
userId,
recordId: recordId.toString()
recordId: recordId.toString(),
imageCount: result.imageIds?.length ?? 0,
coverImageId: result.coverImageId
});
return createSuccessResponse<IImagePublicStateData>(result, "图片已公开");
@@ -68,7 +101,7 @@ export default defineEventHandler(async (event) => {
}
if (isBadRequestError(error)) {
return createErrorResponse(400, "生图记录 ID 不正确");
return createErrorResponse(400, getBadRequestMessage(error));
}
logger.error("失败", {
@@ -90,6 +123,17 @@ const parseRecordId = (value: string | undefined): bigint => {
return BigInt(value);
};
const parseImageIds = (value: unknown): bigint[] => {
if (!Array.isArray(value) || value.length === 0) {
throw createError({
statusCode: 400,
statusMessage: "请选择可公开的图片"
});
}
return Array.from(new Set(value.map((item) => parseRecordId(String(item)))));
};
const isBadRequestError = (error: unknown): boolean => {
return (
typeof error === "object" &&
@@ -98,3 +142,17 @@ const isBadRequestError = (error: unknown): boolean => {
error.statusCode === 400
);
};
const getBadRequestMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"statusMessage" in error &&
typeof error.statusMessage === "string" &&
error.statusMessage
) {
return error.statusMessage;
}
return "生图记录 ID 不正确";
};
+79
View File
@@ -0,0 +1,79 @@
// server/api/images/plaza/[id].get.ts - 广场公开详情接口:返回单个公开批次中可展示的全部公开图片。
import type { IPlazaPostDetail } from "#shared/types/openai";
import {
createApiLogger,
createErrorResponse,
createSuccessResponse,
createUpstreamErrorResponse,
getPlazaPostDetail,
toSafeLogError
} from "~~/server/utils";
/**
* GET /api/images/plaza/:id
*
* 流程:
* 1. 校验路由参数 id 必须是数字。
* 2. 只返回 PUBLIC、批次未删除、图片已归档成功的公开详情。
* 3. 不返回上游响应、图床响应或内部错误详情。
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger("images.plaza.detail");
logger.info("开始");
try {
const postId = parseRecordId(getRouterParam(event, "id"));
logger.info("查询参数", {
postId: postId.toString()
});
const result = await getPlazaPostDetail(postId);
if (!result) {
logger.warn("公开记录不存在", {
postId: postId.toString()
});
return createErrorResponse(404, "公开记录不存在");
}
logger.done("成功", {
postId: postId.toString(),
imageCount: result.images.length
});
return createSuccessResponse<IPlazaPostDetail>(
result,
"获取广场图片详情成功"
);
} catch (error) {
if (isBadRequestError(error)) {
return createErrorResponse(400, "公开记录 ID 不正确");
}
logger.error("失败", {
error: toSafeLogError(error)
});
return createUpstreamErrorResponse(error, "获取广场图片详情失败");
}
});
/** 校验并解析路由里的记录 ID */
const parseRecordId = (value: string | undefined): bigint => {
if (!value || !/^\d+$/.test(value)) {
throw createError({
statusCode: 400,
statusMessage: "公开记录 ID 不正确"
});
}
return BigInt(value);
};
const isBadRequestError = (error: unknown): boolean => {
return (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 400
);
};