import { NextResponse } from "next/server"; import { existsSync } from "fs"; import { prisma } from "@/lib/db"; import { getActiveAccountConnectionSettings } from "@/lib/account-connection-settings"; import { getOrCreateSettings } from "@/lib/user-settings"; import { assertAccountOwnedByUser } from "@/lib/auth/accounts"; import { authErrorResponse } from "@/lib/auth/api"; import { requireSessionUser } from "@/lib/auth/session"; import { requirePublishAuth } from "@/lib/services/threads-auth-guard"; import { factCheckDraft } from "@/lib/ai/fact-check"; import { parseProviderApiKeys } from "@/lib/ai/keys"; import { deleteDraftImages, draftImageAbsolutePath, draftImagePublicUrl, parseDraftImagePaths, } from "@/lib/drafts/images"; import { ensureActiveSession, publish, SessionError } from "@/lib/threads-browser"; import { getAppBaseUrl, isPubliclyReachableUrl, publishViaThreadsApi, } from "@/lib/threads-api"; import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials"; import { trackAiTask } from "@/lib/jobs/track"; export const maxDuration = 120; export async function POST(request: Request) { try { const user = await requireSessionUser(); const authCheck = await requirePublishAuth(); if (!authCheck.ok) { return NextResponse.json({ error: authCheck.error }, { status: 401 }); } const { draftId } = (await request.json()) as { draftId?: string }; if (!draftId) { return NextResponse.json({ error: "缺少 draftId" }, { status: 400 }); } const draft = await prisma.draft.findUnique({ where: { id: draftId } }); if (!draft) { return NextResponse.json({ error: "找不到草稿" }, { status: 404 }); } if (draft.accountId) { await assertAccountOwnedByUser(user.id, draft.accountId); } const [settings, connection] = await Promise.all([ getOrCreateSettings(), getActiveAccountConnectionSettings(), ]); const apiKeys = parseProviderApiKeys(settings.providerApiKeys); let topicLabel: string | undefined; if (draft.topicId) { const topic = await prisma.topic.findUnique({ where: { id: draft.topicId } }); topicLabel = topic?.label; } const factCheck = await trackAiTask("發布前內容查核", () => factCheckDraft({ text: draft.text, angle: draft.angle, searchTag: draft.searchTag, topicLabel, aiProvider: settings.aiProvider, aiModel: settings.aiModel, apiKeys, })); if (factCheck.isKnowledgeContent && !factCheck.passed) { await prisma.draft.update({ where: { id: draftId }, data: { factCheckResult: JSON.stringify(factCheck) }, }); return NextResponse.json( { error: "知識型內容未通過網路查證,請修正後再發布", factCheck, }, { status: 422 } ); } const draftImagePaths = parseDraftImagePaths(draft).filter((imagePath) => existsSync(draftImageAbsolutePath(imagePath)) ); const hasImage = draftImagePaths.length > 0; // API 優先:帳號有連官方 API 就先用 API 發布;失敗或未連線才退回瀏覽器。 const credentials = connection.publishViaApi ? await getActiveThreadsCredentials().catch(() => null) : null; type PublishOutcome = { success: boolean; permalink?: string; mediaId?: string; error?: string; debugRunId?: string; method?: string; warning?: string; }; async function tryApiPublish(creds: NonNullable): Promise { let imageUrl: string | undefined; let warning: string | undefined; if (hasImage) { const primaryPath = draftImagePaths[0]; const publicUrl = draftImagePublicUrl( getAppBaseUrl(request, settings.appUrl), draftId!, primaryPath ); if (isPubliclyReachableUrl(publicUrl)) { imageUrl = publicUrl; if (draftImagePaths.length > 1) { warning = "API 發布目前只會附上第一張配圖。"; } } else { // 配圖需公開網址才能走 API;交給瀏覽器發布以保留配圖。 return { success: false, method: "api", error: "配圖需公開網址,改用瀏覽器發布" }; } } const apiResult = await publishViaThreadsApi(creds, { text: draft!.text, imageUrl }); return { success: apiResult.success, permalink: apiResult.permalink, error: apiResult.error, method: "api", warning: apiResult.warning ?? warning, }; } async function browserPublish(): Promise { const session = await ensureActiveSession(); const browserResult = await publish( session, draft!.text, draftImagePaths.map((imagePath) => draftImageAbsolutePath(imagePath)) ); return { success: browserResult.success, permalink: browserResult.permalink, error: browserResult.error, debugRunId: browserResult.debugRunId, method: "browser", }; } let result: PublishOutcome | null = null; if (credentials) { try { const apiOutcome = await tryApiPublish(credentials); if (apiOutcome.success) result = apiOutcome; } catch { // 落入瀏覽器備援 } } if (!result) { result = await browserPublish(); } if (!result.success) { return NextResponse.json( { error: result.error ?? "發布失敗", method: result.method, debugRunId: result.debugRunId, }, { status: 500 } ); } const published = await prisma.$transaction(async (tx) => { const record = await tx.published.create({ data: { accountId: draft.accountId, topicId: draft.topicId, externalId: result.mediaId, text: draft.text, angle: draft.angle, hook: draft.hook, rationale: draft.rationale, draftType: draft.draftType, permalink: result.permalink, }, }); await deleteDraftImages(draftImagePaths); await tx.draft.delete({ where: { id: draftId } }); return record; }); return NextResponse.json({ published, permalink: result.permalink, factCheck, method: result.method, warning: result.warning, }); } catch (error) { const authRes = authErrorResponse(error); if (authRes) return authRes; if (error instanceof SessionError) { return NextResponse.json({ error: error.message }, { status: 401 }); } const message = error instanceof Error ? error.message : "發布失敗"; return NextResponse.json({ error: message }, { status: 500 }); } }