haixunMaster/app/api/publish/route.ts

215 lines
6.7 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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 });
}
}