215 lines
6.7 KiB
TypeScript
215 lines
6.7 KiB
TypeScript
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 });
|
||
}
|
||
}
|