2026-08-07 11:06:20 +08:00
|
|
|
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
2026-08-06 10:18:23 +08:00
|
|
|
import { parseStoredDate } from "../../../lib/date-utils";
|
|
|
|
|
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
|
|
|
|
import {
|
|
|
|
|
buildRecoveryWorkbook,
|
|
|
|
|
type RecoveryWorkbookRow,
|
|
|
|
|
} from "../../../lib/recovery-workbook";
|
2026-08-07 14:12:52 +08:00
|
|
|
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
2026-08-06 10:18:23 +08:00
|
|
|
|
|
|
|
|
export const runtime = "edge";
|
|
|
|
|
|
|
|
|
|
type AccountRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
platform: string;
|
|
|
|
|
public_account_id: string;
|
|
|
|
|
nickname: string;
|
|
|
|
|
profile_url: string;
|
|
|
|
|
ip_location: string;
|
|
|
|
|
followers: number;
|
|
|
|
|
post_count: number;
|
|
|
|
|
first_seen_at: string;
|
|
|
|
|
last_seen_at: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type CooperationRow = {
|
|
|
|
|
account_id: string;
|
|
|
|
|
partner_name: string;
|
|
|
|
|
delegation_bundle_id: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatExportDate(value: string) {
|
|
|
|
|
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 exactArrayBuffer(bytes: Uint8Array) {
|
|
|
|
|
const copy = new Uint8Array(bytes.byteLength);
|
|
|
|
|
copy.set(bytes);
|
|
|
|
|
return copy.buffer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 14:12:52 +08:00
|
|
|
function normalizeAccountIds(value: unknown) {
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
return [
|
|
|
|
|
...new Set(
|
|
|
|
|
value
|
|
|
|
|
.filter((item): item is string => typeof item === "string")
|
|
|
|
|
.map((item) => item.trim())
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
),
|
|
|
|
|
].slice(0, 5000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportAccounts(accountIds: string[]) {
|
2026-08-06 10:18:23 +08:00
|
|
|
try {
|
|
|
|
|
if (accountIds.length === 0) {
|
|
|
|
|
return Response.json({ error: "当前筛选结果为空" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ensureSchema();
|
|
|
|
|
const db = getRawDb();
|
|
|
|
|
const [accountResult, cooperationResult] = await Promise.all([
|
|
|
|
|
db.prepare("SELECT * FROM accounts ORDER BY last_seen_at DESC").all<AccountRow>(),
|
|
|
|
|
db
|
|
|
|
|
.prepare(
|
|
|
|
|
`SELECT
|
|
|
|
|
d.account_id,
|
|
|
|
|
p.name AS partner_name,
|
|
|
|
|
d.delegation_bundle_id
|
|
|
|
|
FROM distributions d
|
|
|
|
|
JOIN partners p ON p.id = d.partner_id
|
|
|
|
|
WHERE d.account_id IS NOT NULL`,
|
|
|
|
|
)
|
|
|
|
|
.all<CooperationRow>(),
|
|
|
|
|
]);
|
|
|
|
|
const accountIdSet = new Set(accountIds);
|
|
|
|
|
const order = new Map(accountIds.map((id, index) => [id, index]));
|
|
|
|
|
const accounts = accountResult.results
|
|
|
|
|
.filter((account) => accountIdSet.has(account.id))
|
|
|
|
|
.sort(
|
|
|
|
|
(left, right) =>
|
|
|
|
|
(order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
|
|
|
(order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
|
|
|
|
|
);
|
|
|
|
|
if (accounts.length === 0) {
|
|
|
|
|
return Response.json({ error: "没有找到可导出的账号" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cooperationByAccount = new Map<string, CooperationRow[]>();
|
|
|
|
|
for (const cooperation of cooperationResult.results) {
|
|
|
|
|
if (!accountIdSet.has(cooperation.account_id)) continue;
|
|
|
|
|
const current = cooperationByAccount.get(cooperation.account_id) ?? [];
|
|
|
|
|
current.push(cooperation);
|
|
|
|
|
cooperationByAccount.set(cooperation.account_id, current);
|
|
|
|
|
}
|
|
|
|
|
const headers = [
|
|
|
|
|
"序号",
|
|
|
|
|
"平台",
|
|
|
|
|
"账号名称",
|
|
|
|
|
"小红书号/抖音号",
|
|
|
|
|
"账号主页",
|
|
|
|
|
"IP地",
|
|
|
|
|
"粉丝数",
|
|
|
|
|
"合作发布数",
|
|
|
|
|
"历史合作来源",
|
|
|
|
|
"资源归属",
|
|
|
|
|
"首次合作时间",
|
|
|
|
|
"最近合作时间",
|
|
|
|
|
];
|
|
|
|
|
const rows: RecoveryWorkbookRow[] = accounts.map((account, index) => {
|
|
|
|
|
const cooperation = cooperationByAccount.get(account.id) ?? [];
|
|
|
|
|
const sources = [...new Set(cooperation.map((item) => item.partner_name))];
|
|
|
|
|
const partnerManagedOnly =
|
|
|
|
|
cooperation.some((item) => item.delegation_bundle_id) &&
|
|
|
|
|
cooperation.every((item) => item.delegation_bundle_id);
|
|
|
|
|
return {
|
|
|
|
|
cells: [
|
|
|
|
|
index + 1,
|
|
|
|
|
account.platform,
|
|
|
|
|
account.nickname,
|
|
|
|
|
account.public_account_id || "待识别",
|
|
|
|
|
account.profile_url || "",
|
|
|
|
|
account.ip_location || "待识别",
|
|
|
|
|
account.followers,
|
|
|
|
|
account.post_count,
|
|
|
|
|
sources.join("、"),
|
|
|
|
|
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
|
|
|
|
|
formatExportDate(account.first_seen_at),
|
|
|
|
|
formatExportDate(account.last_seen_at),
|
|
|
|
|
],
|
|
|
|
|
images: [],
|
|
|
|
|
hyperlinks: account.profile_url
|
|
|
|
|
? [{ column: 4, url: account.profile_url }]
|
|
|
|
|
: [],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const workbook = buildRecoveryWorkbook({
|
|
|
|
|
sheetName: "KOC资源库",
|
|
|
|
|
headers,
|
|
|
|
|
columnWidths: [
|
|
|
|
|
9,
|
|
|
|
|
12,
|
|
|
|
|
22,
|
|
|
|
|
22,
|
|
|
|
|
44,
|
|
|
|
|
14,
|
|
|
|
|
14,
|
|
|
|
|
14,
|
|
|
|
|
32,
|
|
|
|
|
22,
|
|
|
|
|
21,
|
|
|
|
|
21,
|
|
|
|
|
],
|
|
|
|
|
rows,
|
|
|
|
|
});
|
|
|
|
|
const date = new Intl.DateTimeFormat("zh-CN", {
|
|
|
|
|
timeZone: "Asia/Shanghai",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
})
|
|
|
|
|
.format(new Date())
|
|
|
|
|
.replace(/\D/g, "");
|
|
|
|
|
const fileName = `KOC资源库-${date}.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-resources.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
|
|
|
"X-Content-Type-Options": "nosniff",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ error: error instanceof Error ? error.message : "导出失败" },
|
|
|
|
|
{ status: 500 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-07 14:12:52 +08:00
|
|
|
|
|
|
|
|
export async function POST(request: Request) {
|
|
|
|
|
if (!(await isManagerRequest(request))) return managerForbidden();
|
|
|
|
|
try {
|
|
|
|
|
const body = (await request.json()) as { accountIds?: unknown };
|
|
|
|
|
if (!Array.isArray(body.accountIds)) {
|
|
|
|
|
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return exportAccounts(normalizeAccountIds(body.accountIds));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ error: error instanceof Error ? error.message : "导出失败" },
|
|
|
|
|
{ status: 500 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function GET(request: Request) {
|
|
|
|
|
const token = new URL(request.url).searchParams.get("token")?.trim() || "";
|
|
|
|
|
const payload = token
|
|
|
|
|
? await consumeMcpExportToken(token, "resources")
|
|
|
|
|
: null;
|
|
|
|
|
if (!payload) return managerForbidden();
|
|
|
|
|
return exportAccounts(normalizeAccountIds(payload.accountIds));
|
|
|
|
|
}
|