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 });
|
|||
|
|
}
|
|||
|
|
}
|