Files
koc-loop/app/api/recovery-export/route.ts
ABAPPLO 8f1ebf7697 feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列
- 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移
- 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts
- 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口
- 工作台项目进度卡与未绑定 banner 排版修复
- 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
2026-09-17 15:21:24 +08:00

499 lines
15 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 type { StoredObjectBody } from "../../../lib/object-store";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope";
import { parseStoredDate } from "../../../lib/date-utils";
import {
downloadFeishuMedia,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
ensureSchema,
getRawDb,
getUploadBucket,
} from "../../../lib/mvp-db";
import {
buildRecoveryWorkbook,
type RecoveryWorkbookImage,
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
const env = getRuntimeEnv();
type StoredAsset = {
index: number;
key: string;
fileToken?: string;
width?: number | null;
height?: number | null;
};
type ExportRow = {
content_id: string;
source_row: number | null;
title: string;
body: string;
image_assets: string;
distribution_id: string | null;
publish_url: string | null;
publish_time: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
exposure: number | null;
views: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
collection_status: string | null;
collection_status_description: string | null;
collection_updated_at: string | null;
updated_at: string | null;
account_nickname: string | null;
partner_name: string | null;
claimant_name: string | null;
};
type ImageReference = StoredAsset & {
description: string;
};
function parseAssets(value: string) {
try {
const parsed = JSON.parse(value || "[]") as StoredAsset[];
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(asset) =>
Number.isInteger(Number(asset.index)) &&
Number(asset.index) > 0 &&
typeof asset.key === "string" &&
asset.key.startsWith("content-assets/"),
)
.map((asset) => ({ ...asset, index: Number(asset.index) }))
.sort((left, right) => left.index - right.index);
} catch {
return [];
}
}
function latestMetrics(row: ExportRow) {
const legacyDay =
row.d7_likes !== null
? 7
: row.d5_likes !== null
? 5
: row.d2_likes !== null
? 2
: null;
const legacyLikes =
legacyDay === 7
? row.d7_likes
: legacyDay === 5
? row.d5_likes
: legacyDay === 2
? row.d2_likes
: null;
const legacyComments =
legacyDay === 7
? row.d7_comments
: legacyDay === 5
? row.d5_comments
: legacyDay === 2
? row.d2_comments
: null;
const legacyCollects =
legacyDay === 7
? row.d7_collects
: legacyDay === 5
? row.d5_collects
: legacyDay === 2
? row.d2_collects
: null;
const likes = row.latest_likes ?? legacyLikes;
const comments = row.latest_comments ?? legacyComments;
const collects = row.latest_collects ?? legacyCollects;
const shares = row.latest_shares;
return {
likes,
comments,
collects,
shares,
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + (shares ?? 0),
};
}
function formatExportDate(value: string | null) {
if (!value) return "";
const date = parseStoredDate(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
function collectionLabel(row: ExportRow) {
const labels: Record<string, string> = {
pending: "待设置",
scheduled: "已计划",
collecting: "采集中",
success: "采集成功",
failed: "采集失败",
};
const status = row.collection_status || (row.latest_likes !== null ? "success" : "pending");
const label = labels[status] || status;
return row.collection_status_description
? `${label}:${row.collection_status_description}`
: label;
}
function contentTypeFromObject(object: StoredObjectBody) {
const headers = new Headers();
object.writeHttpMetadata(headers);
return headers.get("Content-Type") || "application/octet-stream";
}
async function loadImage(reference: ImageReference) {
const bucket = getUploadBucket();
let object = await bucket.get(reference.key);
if (!object && reference.fileToken) {
const media = await downloadFeishuMedia(
reference.fileToken,
env as unknown as FeishuBindings,
);
await bucket.put(reference.key, media.bytes, {
httpMetadata: { contentType: media.contentType },
customMetadata: { source: "feishu-api" },
});
object = await bucket.get(reference.key);
}
if (!object) return null;
return normalizeWorkbookImage({
bytes: new Uint8Array(await object.arrayBuffer()),
contentType: contentTypeFromObject(object),
width: reference.width,
height: reference.height,
description: reference.description,
} satisfies RecoveryWorkbookImage);
}
async function loadImages(references: ImageReference[]) {
const unique = new Map(references.map((reference) => [reference.key, reference]));
const queue = [...unique.values()];
const loaded = new Map<string, RecoveryWorkbookImage | null>();
const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
while (queue.length > 0) {
const reference = queue.shift();
if (!reference) return;
try {
loaded.set(reference.key, await loadImage(reference));
} catch {
loaded.set(reference.key, null);
}
}
});
await Promise.all(workers);
return loaded;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "KOC数据回收";
}
function exactArrayBuffer(bytes: Uint8Array) {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const token = url.searchParams.get("token")?.trim() || "";
const tokenPayload = token
? await consumeMcpExportToken(token, "recovery")
: null;
if (!tokenPayload && !(await isAdminRequest(request))) return adminForbidden();
await ensureSchema();
const taskId = tokenPayload
? String(tokenPayload.taskId ?? "").trim()
: url.searchParams.get("task")?.trim() || "";
if (!taskId) {
return Response.json({ error: "缺少导出任务" }, { status: 400 });
}
const db = getRawDb();
if (!tokenPayload) {
const scope = await getOwnerScope(request);
if (
scope.kind === "owner" &&
!(await isTaskOwner(db, taskId, scope.wecomUserId))
) {
return ownerForbidden();
}
}
const task = await db
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; name: string; brand: string; platform: string }>();
if (!task) {
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
}
const result = await db
.prepare(
`SELECT
c.id AS content_id,
c.source_row,
c.title,
c.body,
c.image_assets,
d.id AS distribution_id,
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.screenshot_key,
d.exposure,
d.views,
d.d2_likes,
d.d2_comments,
d.d2_collects,
d.d5_likes,
d.d5_comments,
d.d5_collects,
d.d7_likes,
d.d7_comments,
d.d7_collects,
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.latest_shares,
d.collection_status,
d.collection_status_description,
d.collection_updated_at,
d.updated_at,
a.nickname AS account_nickname,
p.name AS partner_name,
cl.claimant_name
FROM contents c
LEFT JOIN distributions d ON d.content_id = c.id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE c.task_id = ?
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
)
.bind(taskId)
.all<ExportRow>();
const rows = result.results;
const companionTaskRow = await db
.prepare(
"SELECT id FROM tasks WHERE related_task_id = ? AND task_type = 'screenshot_collect' LIMIT 1",
)
.bind(taskId)
.first<{ id: string }>();
let companionSubmittedByContent: Map<string, boolean> | null = null;
if (companionTaskRow) {
const companionRows = await db
.prepare(
`SELECT c.related_content_id, d.result_submitted_at
FROM distributions d
JOIN contents c ON c.id = d.content_id
WHERE d.task_id = ? AND c.related_content_id != ''`,
)
.bind(companionTaskRow.id)
.all<{
related_content_id: string;
result_submitted_at: string | null;
}>();
companionSubmittedByContent = new Map<string, boolean>();
for (const row of companionRows.results) {
companionSubmittedByContent.set(
row.related_content_id,
Boolean(row.result_submitted_at),
);
}
}
const assetsByContent = new Map(
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
);
const maxContentImages = Math.max(
0,
...[...assetsByContent.values()].flat().map((asset) => asset.index),
);
const references: ImageReference[] = [];
rows.forEach((row) => {
for (const asset of assetsByContent.get(row.content_id) ?? []) {
references.push({
...asset,
description: `${row.title} 原图${asset.index}`,
});
}
if (row.screenshot_key) {
references.push({
index: 0,
key: row.screenshot_key,
description: `${row.title} 创作者中心截图`,
});
}
if (row.publish_screenshot_key) {
references.push({
index: 0,
key: row.publish_screenshot_key,
description: `${row.title} 发布截图`,
});
}
});
const loadedImages = await loadImages(references);
const isDouyin = task.platform === "抖音";
const metricHeaders = isDouyin
? ["点赞", "收藏", "转发", "评论", "总互动"]
: ["点赞", "收藏", "评论", "总互动"];
const headers = [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
`${task.platform}昵称`,
"发布链接",
"发布时间",
...metricHeaders,
"曝光量-实际(第7天)",
"阅读量-实际(第7天)",
"数据分析截图(单篇笔记数据分析截图)",
"笔记截图",
"数据更新时间",
"采集状态",
"合作方",
"领取人微信号/手机号",
...(companionSubmittedByContent ? ["收录截图"] : []),
];
const originalImageStart = 3;
const accountColumn = originalImageStart + maxContentImages;
const creatorScreenshotColumn = accountColumn + (isDouyin ? 10 : 9);
const publishScreenshotColumn = creatorScreenshotColumn + 1;
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
const metrics = latestMetrics(row);
const contentAssets = assetsByContent.get(row.content_id) ?? [];
const originalImageCells = Array.from(
{ length: maxContentImages },
(_, index) => {
const asset = contentAssets.find((item) => item.index === index + 1);
return "";
},
);
const creatorImage = row.screenshot_key
? loadedImages.get(row.screenshot_key) ?? null
: null;
const publishImage = row.publish_screenshot_key
? loadedImages.get(row.publish_screenshot_key) ?? null
: null;
const cells: Array<string | number | null> = [
row.source_row ?? rowIndex + 1,
row.title,
row.body,
...originalImageCells,
row.account_nickname || "",
row.publish_url || "",
formatExportDate(row.publish_time),
metrics.likes,
metrics.collects,
...(isDouyin ? [metrics.shares] : []),
metrics.comments,
metrics.total,
row.exposure,
row.views,
"",
"",
formatExportDate(row.collection_updated_at || row.updated_at),
row.distribution_id ? collectionLabel(row) : "未领取",
row.partner_name || "",
row.claimant_name || "",
...(companionSubmittedByContent
? [
companionSubmittedByContent.has(row.content_id)
? companionSubmittedByContent.get(row.content_id)
? "已提交"
: "未提交"
: "—",
]
: []),
];
const images: RecoveryWorkbookRow["images"] = [];
for (const asset of contentAssets) {
const image = loadedImages.get(asset.key);
if (image) images.push({ column: originalImageStart + asset.index - 1, image });
}
if (creatorImage) {
images.push({ column: creatorScreenshotColumn, image: creatorImage });
}
if (publishImage) {
images.push({ column: publishScreenshotColumn, image: publishImage });
}
return { cells, images };
});
const columnWidths = [
12,
28,
56,
...Array.from({ length: maxContentImages }, () => 24),
18,
40,
20,
11,
11,
...(isDouyin ? [11] : []),
11,
11,
18,
18,
26,
24,
20,
24,
18,
22,
...(companionSubmittedByContent ? [12] : []),
];
const workbook = buildRecoveryWorkbook({
sheetName: `${task.name}-数据回收`,
headers,
columnWidths,
rows: workbookRows,
});
const fileName = `${safeFileName(task.name)}-数据回收.xlsx`;
return new Response(exactArrayBuffer(workbook), {
headers: {
"Cache-Control": "private, no-store",
"Content-Type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="koc-recovery.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}