feat: 完善广场功能
This commit is contained in:
@@ -1,16 +1,5 @@
|
||||
// server/api/images/history/[id].get.ts - 生图历史详情接口:返回当前用户单条记录的展示字段与归档状态。
|
||||
import type { IImageHistoryDetail } from "#shared/types/openai";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createApiLogger,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
getImageGenerationDetail,
|
||||
getNewApiUserIdFromCookie,
|
||||
isUnauthorizedError,
|
||||
toSafeLogError
|
||||
} from "~~/server/utils";
|
||||
|
||||
/**
|
||||
* GET /api/images/history/:id
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// server/api/images/history/[id]/public.delete.ts - 生图历史取消公开接口:将当前用户广场发布记录软隐藏。
|
||||
import type { IImagePublicStateData } from "#shared/types/openai";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createApiLogger,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
getNewApiUserIdFromCookie,
|
||||
hideImageGenerationFromPlaza,
|
||||
isUnauthorizedError,
|
||||
toSafeLogError
|
||||
} from "~~/server/utils";
|
||||
|
||||
/**
|
||||
* DELETE /api/images/history/:id/public
|
||||
*
|
||||
* 流程:
|
||||
* 1. 从 httpOnly cookie 读取当前用户 ID。
|
||||
* 2. 只允许取消公开当前用户、未软删除的生图记录。
|
||||
* 3. 不删除广场发布记录,只改为隐藏并记录 hiddenAt。
|
||||
* 4. 不返回上游响应、图床响应或内部错误详情。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger("images.history.public.hide");
|
||||
logger.info("开始");
|
||||
|
||||
try {
|
||||
const userId = getNewApiUserIdFromCookie(event);
|
||||
const recordId = parseRecordId(getRouterParam(event, "id"));
|
||||
|
||||
logger.info("取消公开参数", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
|
||||
const result = await hideImageGenerationFromPlaza(userId, recordId);
|
||||
|
||||
if (!result) {
|
||||
logger.warn("公开记录不存在", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
return createErrorResponse(404, "公开记录不存在");
|
||||
}
|
||||
|
||||
logger.done("成功", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
|
||||
return createSuccessResponse<IImagePublicStateData>(result, "已取消公开");
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
clearNewApiAuthCookies(event);
|
||||
logger.warn("未登录或登录态失效", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
return createErrorResponse(401, "未登录");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// server/api/images/history/[id]/public.post.ts - 生图历史公开接口:将当前用户单条生图记录发布到广场。
|
||||
import type { IImagePublicStateData } from "#shared/types/openai";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createApiLogger,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
getNewApiUserIdFromCookie,
|
||||
isUnauthorizedError,
|
||||
publishImageGenerationToPlaza,
|
||||
toSafeLogError
|
||||
} from "~~/server/utils";
|
||||
|
||||
/**
|
||||
* POST /api/images/history/:id/public
|
||||
*
|
||||
* 流程:
|
||||
* 1. 从 httpOnly cookie 读取当前用户 ID。
|
||||
* 2. 只允许公开当前用户、未软删除的生图记录。
|
||||
* 3. 允许图片尚未归档时先创建公开记录;广场列表会等 hostedImageUrl 可用后再展示。
|
||||
* 4. 不返回上游响应、图床响应或内部错误详情。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger("images.history.public.publish");
|
||||
logger.info("开始");
|
||||
|
||||
try {
|
||||
const userId = getNewApiUserIdFromCookie(event);
|
||||
const recordId = parseRecordId(getRouterParam(event, "id"));
|
||||
|
||||
logger.info("公开参数", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
|
||||
const result = await publishImageGenerationToPlaza(userId, recordId);
|
||||
|
||||
if (!result) {
|
||||
logger.warn("记录不存在", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
return createErrorResponse(404, "生图记录不存在");
|
||||
}
|
||||
|
||||
if (result === "REMOVED") {
|
||||
logger.warn("记录已下架,不能重新公开", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
return createErrorResponse(403, "图片已下架,无法公开");
|
||||
}
|
||||
|
||||
logger.done("成功", {
|
||||
userId,
|
||||
recordId: recordId.toString()
|
||||
});
|
||||
|
||||
return createSuccessResponse<IImagePublicStateData>(result, "图片已公开");
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
clearNewApiAuthCookies(event);
|
||||
logger.warn("未登录或登录态失效", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
return createErrorResponse(401, "未登录");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
// server/api/images/plaza.get.ts - 广场列表接口:分页返回所有用户已公开且已归档成功的生图作品。
|
||||
import type { IPlazaPostListData } from "#shared/types/openai";
|
||||
import {
|
||||
createApiLogger,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
listPlazaPosts,
|
||||
toSafeLogError
|
||||
} from "~~/server/utils";
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* GET /api/images/plaza
|
||||
*
|
||||
* 流程:
|
||||
* 1. 读取 page/size 查询参数,并限制最大 pageSize。
|
||||
* 2. 只返回公开、生成成功、未删除、且已有图床归档地址的记录。
|
||||
* 3. 返回广场展示字段,不返回上游响应、图床响应或内部错误详情。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger("images.plaza.list");
|
||||
logger.info("开始");
|
||||
|
||||
try {
|
||||
const query = getQuery(event);
|
||||
const page = normalizePositiveInt(query.page, DEFAULT_PAGE);
|
||||
const pageSize = Math.min(
|
||||
normalizePositiveInt(query.size, DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE
|
||||
);
|
||||
|
||||
logger.info("查询参数", {
|
||||
page,
|
||||
pageSize
|
||||
});
|
||||
|
||||
const result = await listPlazaPosts({
|
||||
page,
|
||||
pageSize
|
||||
});
|
||||
|
||||
logger.done("成功", {
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
itemCount: result.items.length
|
||||
});
|
||||
|
||||
return createSuccessResponse<IPlazaPostListData>(
|
||||
result,
|
||||
"获取广场图片成功"
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("失败", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
return createUpstreamErrorResponse(error, "获取广场图片失败");
|
||||
}
|
||||
});
|
||||
|
||||
/** 将 query 参数归一化为正整数,非法值回退到默认值 */
|
||||
const normalizePositiveInt = (
|
||||
value: unknown,
|
||||
fallbackValue: number
|
||||
): number => {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||
const numberValue = Number.parseInt(String(rawValue ?? ""), 10);
|
||||
|
||||
return Number.isInteger(numberValue) && numberValue > 0
|
||||
? numberValue
|
||||
: fallbackValue;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { consola } from "consola";
|
||||
import {
|
||||
ImageArchiveStatus,
|
||||
ImageGenerationStatus,
|
||||
PlazaPostStatus,
|
||||
Prisma
|
||||
} from "~~/app/generated/prisma/client";
|
||||
import { prisma } from "~~/server/utils/prisma";
|
||||
@@ -368,10 +369,17 @@ export const listImageGenerationHistory = async ({
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
const [total, records] = await prisma.$transaction([
|
||||
const [total, records] = await Promise.all([
|
||||
prisma.imageGeneration.count({ where }),
|
||||
prisma.imageGeneration.findMany({
|
||||
where,
|
||||
include: {
|
||||
plazaPost: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc"
|
||||
},
|
||||
@@ -398,6 +406,13 @@ export const getImageGenerationDetail = async (
|
||||
id: recordId,
|
||||
userId,
|
||||
deletedAt: null
|
||||
},
|
||||
include: {
|
||||
plazaPost: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -549,6 +564,9 @@ const mapImageGenerationItem = (record: {
|
||||
revisedPrompt: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
plazaPost?: {
|
||||
status: PlazaPostStatus;
|
||||
} | null;
|
||||
}): IImageHistoryItem => {
|
||||
return {
|
||||
id: record.id.toString(),
|
||||
@@ -563,6 +581,7 @@ const mapImageGenerationItem = (record: {
|
||||
hostedImageUrl: record.hostedImageUrl,
|
||||
revisedPrompt: record.revisedPrompt,
|
||||
errorMessage: getPublicRecordMessage(record.status, record.errorMessage),
|
||||
isPublic: record.plazaPost?.status === PlazaPostStatus.PUBLIC,
|
||||
createdAt: record.createdAt.toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from "./lsky";
|
||||
export * from "./newApiAuthCookies";
|
||||
export * from "./newApiTokens";
|
||||
export * from "./openai";
|
||||
export * from "./plazaPosts";
|
||||
export * from "./prisma";
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// server/utils/plazaPosts.ts - 广场发布记录数据库操作:公开、取消公开和广场列表查询。
|
||||
import type {
|
||||
IImagePublicStateData,
|
||||
IPlazaPostItem,
|
||||
IPlazaPostListData
|
||||
} from "#shared/types/openai";
|
||||
import {
|
||||
ImageGenerationStatus,
|
||||
PlazaPostStatus
|
||||
} from "~~/app/generated/prisma/client";
|
||||
import { prisma } from "~~/server/utils/prisma";
|
||||
|
||||
const ANONYMOUS_AUTHOR_NAME = "匿名用户";
|
||||
|
||||
export type PublishPlazaPostResult = IImagePublicStateData | null | "REMOVED";
|
||||
|
||||
export const publishImageGenerationToPlaza = async (
|
||||
userId: number,
|
||||
recordId: bigint
|
||||
): Promise<PublishPlazaPostResult> => {
|
||||
const record = await prisma.imageGeneration.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
userId,
|
||||
deletedAt: null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
plazaPost: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
if (record.plazaPost?.status === PlazaPostStatus.REMOVED) return "REMOVED";
|
||||
|
||||
await prisma.plazaPost.upsert({
|
||||
where: {
|
||||
imageGenerationId: record.id
|
||||
},
|
||||
create: {
|
||||
imageGenerationId: record.id,
|
||||
userId: record.userId,
|
||||
status: PlazaPostStatus.PUBLIC
|
||||
},
|
||||
update: {
|
||||
status: PlazaPostStatus.PUBLIC,
|
||||
hiddenAt: null,
|
||||
removedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isPublic: true
|
||||
};
|
||||
};
|
||||
|
||||
export const hideImageGenerationFromPlaza = async (
|
||||
userId: number,
|
||||
recordId: bigint
|
||||
): Promise<IImagePublicStateData | null> => {
|
||||
const record = await prisma.imageGeneration.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
userId,
|
||||
deletedAt: null
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const result = await prisma.plazaPost.updateMany({
|
||||
where: {
|
||||
imageGenerationId: record.id,
|
||||
userId,
|
||||
status: {
|
||||
not: PlazaPostStatus.REMOVED
|
||||
}
|
||||
},
|
||||
data: {
|
||||
status: PlazaPostStatus.HIDDEN,
|
||||
hiddenAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (result.count === 0) return null;
|
||||
|
||||
return {
|
||||
isPublic: false
|
||||
};
|
||||
};
|
||||
|
||||
export const listPlazaPosts = async ({
|
||||
page,
|
||||
pageSize
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<IPlazaPostListData> => {
|
||||
const where = {
|
||||
status: PlazaPostStatus.PUBLIC,
|
||||
imageGeneration: {
|
||||
status: ImageGenerationStatus.SUCCEEDED,
|
||||
deletedAt: null,
|
||||
hostedImageUrl: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [total, posts] = await Promise.all([
|
||||
prisma.plazaPost.count({ where }),
|
||||
prisma.plazaPost.findMany({
|
||||
where,
|
||||
include: {
|
||||
imageGeneration: {
|
||||
select: {
|
||||
id: true,
|
||||
prompt: true,
|
||||
hostedImageUrl: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
displayName: true,
|
||||
username: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
publishedAt: "desc"
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
items: posts
|
||||
.map(mapPlazaPostItem)
|
||||
.filter((item): item is IPlazaPostItem => Boolean(item))
|
||||
};
|
||||
};
|
||||
|
||||
const mapPlazaPostItem = (post: {
|
||||
id: bigint;
|
||||
publishedAt: Date;
|
||||
imageGeneration: {
|
||||
id: bigint;
|
||||
prompt: string;
|
||||
hostedImageUrl: string | null;
|
||||
};
|
||||
user: {
|
||||
displayName: string;
|
||||
username: string;
|
||||
};
|
||||
}): IPlazaPostItem | null => {
|
||||
if (!post.imageGeneration.hostedImageUrl) return null;
|
||||
|
||||
return {
|
||||
id: post.id.toString(),
|
||||
imageGenerationId: post.imageGeneration.id.toString(),
|
||||
prompt: post.imageGeneration.prompt,
|
||||
hostedImageUrl: post.imageGeneration.hostedImageUrl,
|
||||
authorDisplayName:
|
||||
post.user.displayName || post.user.username || ANONYMOUS_AUTHOR_NAME,
|
||||
publishedAt: post.publishedAt.toISOString()
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user