@@ -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 union,ok=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;
|
||||
};
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 不正确";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user