Your Name 10e1096c83
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add detail surface protocol workflow
2026-03-21 20:55:04 +08:00

321 lines
11 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasDetailArchive, canvasProject } from "@/lib/db/schema";
type DetailArchiveBrief = {
productSpu?: string;
demandType?: string;
productName?: string;
targetAudience?: string;
mainUser?: string;
usageScenario?: string;
triggerMoment?: string;
coreNeed?: string;
riskConcern?: string;
expectedResult?: string;
coreSellingPoint?: string;
positioningStatement?: string;
userVoice?: string;
competitorReference?: string;
priceBand?: string;
brandTone?: string;
bannedWords?: string;
};
type DetailArchiveSlot = {
url: string;
name?: string;
contentType?: string;
} | null;
type DetailArchiveSurfaceReview = {
screenIndex?: number;
action?: string;
passed?: boolean;
issues?: string[];
} | null;
type DetailGenerationMode = "classic" | "agent_layout";
function sanitizeGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function sanitizeBrief(input: unknown): DetailArchiveBrief {
const raw = (input || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
return {
productSpu: normalize("productSpu"),
demandType: normalize("demandType"),
productName: normalize("productName"),
targetAudience: normalize("targetAudience"),
mainUser: normalize("mainUser"),
usageScenario: normalize("usageScenario"),
triggerMoment: normalize("triggerMoment"),
coreNeed: normalize("coreNeed"),
riskConcern: normalize("riskConcern"),
expectedResult: normalize("expectedResult"),
coreSellingPoint: normalize("coreSellingPoint"),
positioningStatement: normalize("positioningStatement"),
userVoice: normalize("userVoice"),
competitorReference: normalize("competitorReference"),
priceBand: normalize("priceBand"),
brandTone: normalize("brandTone"),
bannedWords: normalize("bannedWords"),
};
}
function sanitizeSlots(input: unknown): DetailArchiveSlot[] {
if (!Array.isArray(input)) return [];
return input.slice(0, 16).map((item) => {
if (!item || typeof item !== "object") return null;
const raw = item as Record<string, unknown>;
const url = String(raw.url || "").trim();
if (!url) return null;
return {
url,
name: String(raw.name || "").trim() || undefined,
contentType: String(raw.contentType || "").trim() || "image/jpeg",
};
});
}
function sanitizeUiSurfaceSnapshot(input: unknown) {
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
return input as Record<string, unknown>;
}
function sanitizeUiSurfaceLatestReview(input: unknown): DetailArchiveSurfaceReview {
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
const raw = input as Record<string, unknown>;
const issues = Array.isArray(raw.issues)
? raw.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 10)
: [];
return {
screenIndex: Number.isFinite(Number(raw.screenIndex)) ? Math.max(0, Number(raw.screenIndex)) : undefined,
action: String(raw.action || "").trim() || undefined,
passed: typeof raw.passed === "boolean" ? raw.passed : undefined,
issues,
};
}
async function ensureProjectOwnership(projectId: string, userId: string) {
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.limit(1);
return Boolean(project);
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const archives = await db
.select()
.from(canvasDetailArchive)
.where(
and(
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.orderBy(desc(canvasDetailArchive.updatedAt))
.limit(30);
return NextResponse.json({ success: true, data: archives });
} catch (error) {
console.error("[CanvasDetailArchive] GET failed:", error);
return NextResponse.json({ success: false, error: "获取详情存档失败" }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const body = await request.json();
const archiveRaw = (body?.archive || {}) as Record<string, unknown>;
const title = String(archiveRaw.title || "").trim() || "AI详情项目";
const prompt = String(archiveRaw.prompt || "").replace(/\s+/g, " ").trim();
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const generationMode = sanitizeGenerationMode(archiveRaw.generationMode);
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
const uiSurfaceSnapshot = sanitizeUiSurfaceSnapshot(archiveRaw.uiSurfaceSnapshot);
const uiSurfaceLatestReview = sanitizeUiSurfaceLatestReview(archiveRaw.uiSurfaceLatestReview);
const [saved] = await db
.insert(canvasDetailArchive)
.values({
projectId,
userId: session.user.id,
title,
prompt,
generationMode,
brief,
slots,
summary: summary || null,
imageCount: Number.isFinite(imageCount) ? Math.max(0, Math.round(imageCount)) : null,
coverUrl: coverUrl || null,
uiSurfaceSnapshot,
uiSurfaceLatestReview,
})
.returning();
return NextResponse.json({ success: true, data: saved });
} catch (error) {
console.error("[CanvasDetailArchive] POST failed:", error);
return NextResponse.json({ success: false, error: "保存详情存档失败" }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const archiveId = String((body as any)?.archiveId || "").trim();
const archiveRaw = ((body as any)?.archive || {}) as Record<string, unknown>;
if (!archiveId) {
return NextResponse.json({ success: false, error: "缺少存档ID" }, { status: 400 });
}
const title = String(archiveRaw.title || "").trim() || "AI详情项目";
const prompt = String(archiveRaw.prompt || "").replace(/\s+/g, " ").trim();
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const generationMode = sanitizeGenerationMode(archiveRaw.generationMode);
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
const uiSurfaceSnapshot = sanitizeUiSurfaceSnapshot(archiveRaw.uiSurfaceSnapshot);
const uiSurfaceLatestReview = sanitizeUiSurfaceLatestReview(archiveRaw.uiSurfaceLatestReview);
const [updated] = await db
.update(canvasDetailArchive)
.set({
title,
prompt,
generationMode,
brief,
slots,
summary: summary || null,
imageCount: Number.isFinite(imageCount) ? Math.max(0, Math.round(imageCount)) : null,
coverUrl: coverUrl || null,
uiSurfaceSnapshot,
uiSurfaceLatestReview,
updatedAt: new Date(),
})
.where(
and(
eq(canvasDetailArchive.id, archiveId),
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.returning();
if (!updated) {
return NextResponse.json({ success: false, error: "存档不存在或无权限" }, { status: 404 });
}
return NextResponse.json({ success: true, data: updated });
} catch (error) {
console.error("[CanvasDetailArchive] PATCH failed:", error);
return NextResponse.json({ success: false, error: "更新详情存档失败" }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const searchArchiveId = request.nextUrl.searchParams.get("archiveId");
let bodyArchiveId = "";
if (!searchArchiveId) {
const body = await request.json().catch(() => ({}));
bodyArchiveId = String((body as any)?.archiveId || "").trim();
}
const archiveId = String(searchArchiveId || bodyArchiveId || "").trim();
if (!archiveId) {
return NextResponse.json({ success: false, error: "缺少存档ID" }, { status: 400 });
}
const [deleted] = await db
.delete(canvasDetailArchive)
.where(
and(
eq(canvasDetailArchive.id, archiveId),
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.returning();
if (!deleted) {
return NextResponse.json({ success: false, error: "存档不存在或无权限" }, { status: 404 });
}
return NextResponse.json({ success: true, data: deleted });
} catch (error) {
console.error("[CanvasDetailArchive] DELETE failed:", error);
return NextResponse.json({ success: false, error: "删除详情存档失败" }, { status: 500 });
}
}