2026-07-30 12:06:41 +08:00
|
|
|
|
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) {
|
2026-08-07 11:06:20 +08:00
|
|
|
|
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,
|
|
|
|
|
|
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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|