+
+
+
+
diff --git a/app/services/image_service.ts b/app/services/image_service.ts
index c1483fc..fbeaa0d 100644
--- a/app/services/image_service.ts
+++ b/app/services/image_service.ts
@@ -1,12 +1,16 @@
import type {
ICommonResponse,
IImageGenerateData,
- IImageGenerateRequest
+ IImageGenerateRequest,
+ IImageHistoryListData,
+ IImagePublicStateData,
+ IPlazaPostListData
} from "#shared/types";
export class ImageService {
public static basePath = "/api/images";
+ /** 发起生图请求,成功后返回当前可展示的图片地址。 */
public static GenerateImage(request: IImageGenerateRequest) {
return $fetch
>(
`${ImageService.basePath}/generate`,
@@ -16,4 +20,52 @@ export class ImageService {
}
);
}
+
+ /** 分页读取当前登录用户的生图历史记录。 */
+ public static GetImageHistory(page = 1, size = 10) {
+ return $fetch>(
+ `${ImageService.basePath}/history`,
+ {
+ method: "GET",
+ query: {
+ page,
+ size
+ }
+ }
+ );
+ }
+
+ /** 分页读取广场中所有用户可见的公开图片。 */
+ public static GetPlazaPosts(page = 1, size = 20) {
+ return $fetch>(
+ `${ImageService.basePath}/plaza`,
+ {
+ method: "GET",
+ query: {
+ page,
+ size
+ }
+ }
+ );
+ }
+
+ /** 将当前用户的一条生图历史发布到广场。 */
+ public static PublishHistoryImage(recordId: string) {
+ return $fetch>(
+ `${ImageService.basePath}/history/${recordId}/public`,
+ {
+ method: "POST"
+ }
+ );
+ }
+
+ /** 取消公开当前用户的一条广场图片,服务端会软隐藏发布记录。 */
+ public static HideHistoryImage(recordId: string) {
+ return $fetch>(
+ `${ImageService.basePath}/history/${recordId}/public`,
+ {
+ method: "DELETE"
+ }
+ );
+ }
}
diff --git a/create_tables.sql b/create_tables.sql
index 1688819..44de9d2 100644
--- a/create_tables.sql
+++ b/create_tables.sql
@@ -1,4 +1,4 @@
--- 表1:用户表
+-- 表1:用户快照表
CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL COMMENT 'NewAPI 用户 ID,作为本站用户主键',
`username` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '用户名快照',
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户快照表';
-- 表2:生图记录表
CREATE TABLE IF NOT EXISTS `image_generations` (
@@ -21,12 +21,12 @@ CREATE TABLE IF NOT EXISTS `image_generations` (
`started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生图开始时间',
`ended_at` DATETIME(3) NULL COMMENT '生图结束时间',
`duration_ms` INT NULL COMMENT '生图耗时,单位毫秒',
- `image_url` VARCHAR(2048) NULL COMMENT 'NewAPI 上游返回的图片 URL',
- `hosted_image_url` VARCHAR(2048) NULL COMMENT 'Lsky 图床归档后的图片 URL',
+ `image_url` VARCHAR(2048) NULL COMMENT '生成图片访问地址',
+ `hosted_image_url` VARCHAR(2048) NULL COMMENT '图床归档后的稳定图片地址',
`image_mime_type` VARCHAR(191) NULL COMMENT '图片 MIME 类型',
- `revised_prompt` TEXT NULL COMMENT '上游返回的修订提示词',
- `upstream_response` JSON NULL COMMENT '完整上游生图接口响应',
- `hosted_response` JSON NULL COMMENT '完整 Lsky 图床上传接口响应',
+ `revised_prompt` TEXT NULL COMMENT '修订提示词',
+ `upstream_response` JSON NULL COMMENT '完整上游生图响应,仅服务端排查使用',
+ `hosted_response` JSON NULL COMMENT '完整图床归档响应,仅服务端排查使用',
`error_message` TEXT NULL COMMENT '失败原因或图床归档失败提示',
`archive_status` ENUM('NOT_REQUIRED', 'PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED') NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '图床归档任务状态',
`archive_attempts` INT NOT NULL DEFAULT 0 COMMENT '图床归档已尝试次数',
@@ -48,7 +48,30 @@ CREATE TABLE IF NOT EXISTS `image_generations` (
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生图记录表';
--- 表3:生图统计表
+-- 表3:广场发布表
+CREATE TABLE IF NOT EXISTS `plaza_posts` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '广场发布记录 ID',
+ `image_generation_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的生图记录 ID',
+ `user_id` INT NOT NULL COMMENT 'NewAPI 用户 ID',
+ `status` ENUM('PUBLIC', 'HIDDEN', 'REMOVED') NOT NULL DEFAULT 'PUBLIC' COMMENT '广场发布状态',
+ `published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发布时间',
+ `hidden_at` DATETIME(3) NULL COMMENT '用户取消公开时间',
+ `removed_at` DATETIME(3) NULL COMMENT '管理侧下架时间',
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE INDEX `plaza_posts_image_generation_id_key` (`image_generation_id`),
+ INDEX `plaza_posts_status_published_at_idx` (`status`, `published_at`),
+ INDEX `plaza_posts_user_id_published_at_idx` (`user_id`, `published_at`),
+ CONSTRAINT `plaza_posts_image_generation_id_fkey`
+ FOREIGN KEY (`image_generation_id`) REFERENCES `image_generations` (`id`)
+ ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `plaza_posts_user_id_fkey`
+ FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
+ ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='广场发布表';
+
+-- 表4:生图统计表
CREATE TABLE IF NOT EXISTS `generation_stats` (
`id` VARCHAR(32) NOT NULL COMMENT '统计行 ID,固定为 global',
`total_requests` INT NOT NULL DEFAULT 0 COMMENT '总生图请求数,包含成功和失败',
diff --git a/prisma/migrations/20260426150000_add_plaza_posts/migration.sql b/prisma/migrations/20260426150000_add_plaza_posts/migration.sql
new file mode 100644
index 0000000..eb99c24
--- /dev/null
+++ b/prisma/migrations/20260426150000_add_plaza_posts/migration.sql
@@ -0,0 +1,21 @@
+CREATE TABLE `plaza_posts` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `image_generation_id` BIGINT UNSIGNED NOT NULL,
+ `user_id` INT NOT NULL,
+ `status` ENUM('PUBLIC', 'HIDDEN', 'REMOVED') NOT NULL DEFAULT 'PUBLIC',
+ `published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `hidden_at` DATETIME(3) NULL,
+ `removed_at` DATETIME(3) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (`id`),
+ UNIQUE INDEX `plaza_posts_image_generation_id_key` (`image_generation_id`),
+ INDEX `plaza_posts_status_published_at_idx` (`status`, `published_at`),
+ INDEX `plaza_posts_user_id_published_at_idx` (`user_id`, `published_at`),
+ CONSTRAINT `plaza_posts_image_generation_id_fkey`
+ FOREIGN KEY (`image_generation_id`) REFERENCES `image_generations` (`id`)
+ ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `plaza_posts_user_id_fkey`
+ FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
+ ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a0e0fd5..12a8e8e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -27,6 +27,12 @@ enum ImageArchiveStatus {
FAILED
}
+enum PlazaPostStatus {
+ PUBLIC
+ HIDDEN
+ REMOVED
+}
+
model User {
id Int @id
username String @default("") @db.VarChar(191)
@@ -37,6 +43,7 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
imageGenerations ImageGeneration[]
+ plazaPosts PlazaPost[]
@@map("users")
}
@@ -67,6 +74,7 @@ model ImageGeneration {
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ plazaPost PlazaPost?
@@index([userId, createdAt])
@@index([status])
@@ -76,6 +84,24 @@ model ImageGeneration {
@@map("image_generations")
}
+model PlazaPost {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ imageGenerationId BigInt @unique @map("image_generation_id") @db.UnsignedBigInt
+ userId Int @map("user_id")
+ status PlazaPostStatus @default(PUBLIC)
+ publishedAt DateTime @default(now()) @map("published_at")
+ hiddenAt DateTime? @map("hidden_at")
+ removedAt DateTime? @map("removed_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ imageGeneration ImageGeneration @relation(fields: [imageGenerationId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([status, publishedAt])
+ @@index([userId, publishedAt])
+ @@map("plaza_posts")
+}
+
model GenerationStats {
id String @id @db.VarChar(32)
totalRequests Int @default(0) @map("total_requests")
diff --git a/server/api/images/history/[id].get.ts b/server/api/images/history/[id].get.ts
index 80c9bc6..12e5b56 100644
--- a/server/api/images/history/[id].get.ts
+++ b/server/api/images/history/[id].get.ts
@@ -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
diff --git a/server/api/images/history/[id]/public.delete.ts b/server/api/images/history/[id]/public.delete.ts
new file mode 100644
index 0000000..f5c642a
--- /dev/null
+++ b/server/api/images/history/[id]/public.delete.ts
@@ -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(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
+ );
+};
diff --git a/server/api/images/history/[id]/public.post.ts b/server/api/images/history/[id]/public.post.ts
new file mode 100644
index 0000000..57f701b
--- /dev/null
+++ b/server/api/images/history/[id]/public.post.ts
@@ -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(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
+ );
+};
diff --git a/server/api/images/plaza.get.ts b/server/api/images/plaza.get.ts
new file mode 100644
index 0000000..98ba68d
--- /dev/null
+++ b/server/api/images/plaza.get.ts
@@ -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(
+ 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;
+};
diff --git a/server/utils/imageGenerationRecords.ts b/server/utils/imageGenerationRecords.ts
index 4ce133f..16aa455 100644
--- a/server/utils/imageGenerationRecords.ts
+++ b/server/utils/imageGenerationRecords.ts
@@ -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()
};
};
diff --git a/server/utils/index.ts b/server/utils/index.ts
index b52bb25..fccc0f0 100644
--- a/server/utils/index.ts
+++ b/server/utils/index.ts
@@ -8,4 +8,5 @@ export * from "./lsky";
export * from "./newApiAuthCookies";
export * from "./newApiTokens";
export * from "./openai";
+export * from "./plazaPosts";
export * from "./prisma";
diff --git a/server/utils/plazaPosts.ts b/server/utils/plazaPosts.ts
new file mode 100644
index 0000000..ac81f1b
--- /dev/null
+++ b/server/utils/plazaPosts.ts
@@ -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 => {
+ 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 => {
+ 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 => {
+ 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()
+ };
+};
diff --git a/shared/types/openai.ts b/shared/types/openai.ts
index 5fcda92..dfc03af 100644
--- a/shared/types/openai.ts
+++ b/shared/types/openai.ts
@@ -62,6 +62,8 @@ export interface IImageHistoryItem {
revisedPrompt: string | null;
/** 面向前端的本地泛化错误提示;为空且 hostedImageUrl 也为空时表示仍在归档中 */
errorMessage: string | null;
+ /** 此图片是否已发布到广场 */
+ isPublic: boolean;
/** 记录创建时间,ISO 字符串 */
createdAt: string;
}
@@ -92,6 +94,37 @@ export interface IImageHistoryListData {
items: IImageHistoryItem[];
}
+export interface IPlazaPostItem {
+ /** 广场发布记录 ID,BigInt 会以字符串返回 */
+ id: string;
+ /** 生图记录 ID,BigInt 会以字符串返回 */
+ imageGenerationId: string;
+ /** 广场卡片展示的用户提示词 */
+ prompt: string;
+ /** 广场使用的图床归档图片地址 */
+ hostedImageUrl: string;
+ /** 广场展示的作者名称 */
+ authorDisplayName: string;
+ /** 发布到广场的时间,ISO 字符串 */
+ publishedAt: string;
+}
+
+export interface IPlazaPostListData {
+ /** 当前页码 */
+ page: number;
+ /** 每页数量 */
+ pageSize: number;
+ /** 当前可见广场记录总数 */
+ total: number;
+ /** 当前页广场记录 */
+ items: IPlazaPostItem[];
+}
+
+export interface IImagePublicStateData {
+ /** 此图片是否已发布到广场 */
+ isPublic: boolean;
+}
+
/**
* 全局生图统计响应。
*/