haixunMaster/app/api/publish/route.ts

215 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof credentials>): Promise<PublishOutcome> {
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<PublishOutcome> {
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 });
}
}