Add KOC resource filters and export
This commit is contained in:
189
app/api/resources-export/route.ts
Normal file
189
app/api/resources-export/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { parseStoredDate } from "../../../lib/date-utils";
|
||||
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
||||
import {
|
||||
buildRecoveryWorkbook,
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
const body = (await request.json()) as { accountIds?: unknown };
|
||||
if (!Array.isArray(body.accountIds)) {
|
||||
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
|
||||
}
|
||||
const accountIds = [
|
||||
...new Set(
|
||||
body.accountIds
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 5000);
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user