Files
koc-loop/app/api/action/route.ts

555 lines
18 KiB
TypeScript
Raw Normal View History

import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
const env = getRuntimeEnv();
import {
backfillAccountProfiles,
enrichDistributionAccount,
} from "../../../lib/account-enrichment-service";
2026-07-30 12:06:41 +08:00
import {
ensureSchema,
getDashboardData,
getRawDb,
uid,
} from "../../../lib/mvp-db";
import {
collectDistributionMetrics,
createCollectionRunTasks,
retryFailedCollections,
runDueScheduledCollections,
shanghaiDateFromTimestamp,
} from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import {
FeishuSourceError,
readFeishuSource,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
createDistributionTask,
createScreenshotTask,
} from "../../../lib/task-service";
import {
DistributionReleaseError,
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url";
2026-07-30 12:06:41 +08:00
type ActionBody = {
action?: string;
[key: string]: unknown;
};
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export async function POST(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
2026-07-30 12:06:41 +08:00
try {
await ensureSchema();
const body = (await request.json()) as ActionBody;
const db = getRawDb();
if (body.action === "inspect_feishu") {
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
);
return Response.json({
sheetId: source.sheetId,
sheetName: source.sheetName,
syncedAt: source.syncedAt,
rowCount: source.rows.length,
imageCount: source.rows.reduce(
(total, row) => total + row.images.length,
0,
),
videoCount: source.rows.reduce(
(total, row) => total + row.videos.length,
0,
),
2026-07-30 12:06:41 +08:00
columns: source.columns,
preview: source.rows.slice(0, 3),
});
}
if (body.action === "create_task") {
const name = String(body.name ?? "").trim();
const brand = String(body.brand ?? "").trim();
const dueAt = String(body.dueAt ?? "").trim();
const platform = body.platform === "抖音" ? "抖音" : "小红书";
const contentFormat = body.contentFormat === "video" ? "video" : "image_text";
2026-07-30 12:06:41 +08:00
if (!name || !brand || !dueAt) {
return Response.json(
{ error: "请补全任务名称、品牌和截止日期" },
{ status: 400 },
);
}
2026-08-07 11:59:45 +08:00
await createDistributionTask(
{
feishuUrl: String(body.feishuUrl ?? "").trim(),
name,
brand,
dueAt,
platform,
contentFormat,
2026-08-07 11:59:45 +08:00
},
2026-07-30 12:06:41 +08:00
env as unknown as FeishuBindings,
);
} else if (body.action === "create_screenshot_task") {
await createScreenshotTask({
name: String(body.name ?? "").trim(),
brand: String(body.brand ?? "").trim(),
dueAt: String(body.dueAt ?? "").trim(),
keyword: String(body.keyword ?? "").trim(),
instructions: String(body.instructions ?? "").trim(),
quantity: numberValue(body.quantity),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
});
2026-07-30 12:06:41 +08:00
} else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? "");
const taskId = String(body.taskId ?? "");
const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1)));
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY created_at, id
LIMIT ${quantity}`,
2026-07-30 12:06:41 +08:00
)
.bind(taskId)
2026-07-30 12:06:41 +08:00
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
}
const statements = available.results.flatMap((content) => [
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, status)
VALUES (?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), taskId, content.id, partnerId),
db
.prepare("UPDATE contents SET status = 'allocated' WHERE id = ?")
.bind(content.id),
]);
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, taskId),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
} else if (body.action === "release_distribution") {
if (!(await isManagerRequest(request))) return adminForbidden();
await releaseUnfinishedDistribution(
db,
String(body.distributionId ?? "").trim(),
);
} else if (body.action === "update_distribution_publish_url") {
if (!(await isManagerRequest(request))) return adminForbidden();
const distributionId = String(body.distributionId ?? "").trim();
if (!distributionId) {
return Response.json(
{ error: "作品记录不存在" },
{ status: 400 },
);
}
const current = await db
.prepare(
`SELECT d.id, d.task_id, d.partner_id, d.publish_url,
t.task_type, t.platform, t.collection_start_date, t.collection_days,
COALESCE(a.nickname, '待识别账号') AS account_nickname
FROM distributions d
JOIN tasks t ON t.id = d.task_id
LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<{
id: string;
task_id: string;
partner_id: string;
publish_url: string | null;
task_type?: string | null;
platform: string;
collection_start_date: string | null;
collection_days: string;
account_nickname: string;
}>();
if (!current) {
return Response.json({ error: "作品记录不存在" }, { status: 404 });
}
if (current.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要填写发布链接" },
{ status: 400 },
);
}
const platform = current.platform === "抖音" ? "抖音" : "小红书";
const publishUrl = extractPublishUrl(
String(body.publishUrl ?? "").trim(),
platform,
);
if (!publishUrl) {
return Response.json(
{ error: `请填写包含${platform}作品链接的发布内容` },
{ status: 400 },
);
}
if (current.publish_url === publishUrl) {
return Response.json(await getDashboardData());
}
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(current.collection_days || "[]");
if (Array.isArray(parsed)) {
collectionDays = [...new Set(parsed.map(Number))]
.filter(
(day) =>
Number.isInteger(day) && day >= 1 && day <= 7,
)
.sort((a, b) => a - b);
}
} catch {
collectionDays = [];
}
const isScheduled = Boolean(
current.collection_start_date && collectionDays.length > 0,
);
const statements = [
db
.prepare(
`UPDATE distributions SET
publish_url = ?,
publish_time = CURRENT_TIMESTAMP,
status = 'published',
d2_likes = NULL,
d2_comments = NULL,
d2_collects = NULL,
d5_likes = NULL,
d5_comments = NULL,
d5_collects = NULL,
d7_likes = NULL,
d7_comments = NULL,
d7_collects = NULL,
latest_likes = NULL,
latest_comments = NULL,
latest_collects = NULL,
latest_shares = NULL,
collection_status = ?,
collection_status_description = ?,
collection_updated_at = NULL,
last_collection_day = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
publishUrl,
isScheduled ? "scheduled" : "pending",
isScheduled
? `管理员已更新链接,等待${collectionDays.length}个采集日`
: "管理员已更新链接,等待设置采集计划",
distributionId,
),
db
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
.bind(distributionId),
];
if (!current.publish_url) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(current.partner_id),
);
}
await db.batch(statements);
if (isScheduled && current.collection_start_date) {
await createCollectionRunTasks(
db,
current.task_id,
current.collection_start_date,
collectionDays,
);
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
current.task_id,
).catch(() => undefined),
"collection catchup after publish URL update",
);
}
runInBackground(
enrichDistributionAccount(
db,
distributionId,
publishUrl,
current.account_nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined),
"account enrichment after publish URL update",
);
2026-07-30 12:06:41 +08:00
} else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();
const days = Array.isArray(body.days)
? [...new Set(body.days.map(Number))]
.filter(
(day) =>
Number.isInteger(day) && day >= 1 && day <= 7,
)
.sort((a, b) => a - b)
: [];
if (!taskId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) {
return Response.json(
{ error: "请选择有效的开始采集日期" },
{ status: 400 },
);
}
if (days.length === 0) {
return Response.json(
{ error: "请至少选择一个自动采集日" },
{ status: 400 },
);
}
const task = await db
.prepare("SELECT id, task_type FROM tasks WHERE id = ?")
2026-07-30 12:06:41 +08:00
.bind(taskId)
.first<{ id: string; task_type?: string | null }>();
2026-07-30 12:06:41 +08:00
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要设置数据采集计划" },
{ status: 400 },
);
}
2026-07-30 12:06:41 +08:00
await db.batch([
db
.prepare(
`UPDATE tasks
SET collection_start_date = ?,
collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(startDate, JSON.stringify(days), taskId),
db
.prepare(
`UPDATE distributions
SET collection_status = CASE
WHEN latest_likes IS NULL THEN 'scheduled'
ELSE collection_status
END,
collection_status_description = CASE
WHEN latest_likes IS NULL THEN ?
ELSE collection_status_description
END,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(
`已安排${days.length}个采集日每日09:00执行`,
2026-07-30 12:06:41 +08:00
taskId,
),
]);
await createCollectionRunTasks(db, taskId, startDate, days);
const catchup = runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
taskId,
).catch(() => undefined);
runInBackground(catchup, "collection catchup");
2026-07-30 12:06:41 +08:00
} else if (body.action === "run_due_collections") {
await runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
);
} else if (
body.action === "collect_now" ||
body.action === "collect"
) {
const distributionId = String(body.distributionId ?? "");
const rawDay =
body.day === null || body.day === undefined
? null
: numberValue(body.day);
const day =
rawDay !== null && Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 7
? rawDay
: null;
if (body.action === "collect" && day === null) {
return Response.json({ error: "采集周期无效" }, { status: 400 });
}
const distribution = await db
.prepare(
`SELECT t.task_type
FROM distributions d
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<{ task_type?: string | null }>();
if (!distribution) {
return Response.json({ error: "笔记记录不存在" }, { status: 404 });
}
if (distribution.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不支持公开数据采集" },
{ status: 400 },
);
}
2026-07-30 12:06:41 +08:00
await collectDistributionMetrics(
db,
distributionId,
shanghaiDateFromTimestamp(Date.now()),
day,
"manual",
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "retry_failed_collections") {
const taskId = String(body.taskId ?? "").trim();
if (!taskId) {
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
}
const task = await db
.prepare("SELECT task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务没有需要补采的公开数据" },
{ status: 400 },
);
}
2026-07-30 12:06:41 +08:00
await retryFailedCollections(
db,
taskId,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "backfill_account_profiles") {
const backfill = backfillAccountProfiles(
db,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
).catch(() => undefined);
runInBackground(backfill, "account profile backfill");
2026-07-30 12:06:41 +08:00
} else if (body.action === "set_public_account_ids") {
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
const normalized = items
.map((item) => {
const record =
item && typeof item === "object"
? (item as Record<string, unknown>)
: {};
return {
accountId: String(record.accountId ?? "").trim().slice(0, 80),
publicAccountId: String(record.publicAccountId ?? "")
.trim()
.slice(0, 80),
};
})
.filter(
(item) =>
item.accountId &&
/^[\p{L}\p{N}._-]{2,80}$/u.test(item.publicAccountId),
);
if (normalized.length === 0) {
return Response.json(
{ error: "没有可回填的账号号值" },
{ status: 400 },
);
}
await db.batch(
normalized.map((item) =>
db
.prepare(
`UPDATE accounts
SET public_account_id = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(item.publicAccountId, item.accountId),
),
);
} else if (body.action === "manual_metrics") {
const distributionId = String(body.distributionId ?? "");
const exposure = Math.max(0, numberValue(body.exposure));
const views = Math.max(0, numberValue(body.views));
await db
.prepare(
`UPDATE distributions SET
exposure = ?,
views = ?,
ocr_status = 'manual',
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(exposure, views, distributionId)
.run();
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
return Response.json(await getDashboardData());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError
? error.status
: 500,
},
2026-07-30 12:06:41 +08:00
);
}
}