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

386 lines
12 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 { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
const env = getRuntimeEnv();
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
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";
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();
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,
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();
if (!name || !brand || !dueAt) {
return Response.json(
{ error: "请补全任务名称、品牌和截止日期" },
{ status: 400 },
);
}
await createDistributionTask(
{
feishuUrl: String(body.feishuUrl ?? "").trim(),
name,
brand,
dueAt,
},
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(),
});
} 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}`,
)
.bind(taskId)
.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 === "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 = ?")
.bind(taskId)
.first<{ id: string; task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要设置数据采集计划" },
{ status: 400 },
);
}
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执行`,
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");
} 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 },
);
}
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 },
);
}
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");
} 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,
},
);
}
}