Files
koc-loop/app/api/action/route.ts
2026-07-30 12:06:41 +08:00

395 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 { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
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,
type FeishuSource,
} from "../../../lib/feishu-client";
export const runtime = "edge";
type ActionBody = {
action?: string;
[key: string]: unknown;
};
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
async function createTaskFromSource(
source: FeishuSource,
name: string,
brand: string,
dueAt: string,
) {
const db = getRawDb();
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
name,
brand,
source.rows.length,
dueAt,
source.url,
source.sheetId,
source.sheetName,
source.syncedAt,
shareToken,
)
.run();
try {
const contentStatements = source.rows.map((row) => {
const contentId = uid("content");
const imageAssets = row.images.map((image) => ({
...image,
key: `content-assets/${taskId}/${contentId}/${image.index}`,
}));
return db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
)
.bind(
contentId,
taskId,
row.title,
row.body,
JSON.stringify(imageAssets),
`飞书 · ${source.sheetName}`,
row.sourceRow,
);
});
for (let index = 0; index < contentStatements.length; index += 100) {
await db.batch(contentStatements.slice(index, index + 100));
}
await db
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
.bind(taskId)
.run();
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
}
export async function POST(request: Request) {
if (!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 },
);
}
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
);
await createTaskFromSource(source, name, brand, dueAt);
} 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 ?`,
)
.bind(taskId, quantity)
.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 === "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 FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
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}个采集日每日10:00执行`,
taskId,
),
]);
await createCollectionRunTasks(db, taskId, startDate, days);
const catchup = runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
taskId,
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(catchup);
} else {
await 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 });
}
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 });
}
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);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(backfill);
} else {
await 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.status : 500 },
);
}
}