Files
koc-loop/app/api/resources-export/route.ts
ABAPPLO 582c26e57e feat: 在账号资源库中展示导入的标签字段
KOC 资源导入模板新增「标签」列,导入后写入 accounts.tags 并在
账号资源库面板以 badge 形式展示,导出时也带回标签列以保证往返一致。

- lib/resource-import.ts: 新增 tags 字段与 HEADER 别名(标签/账号标签/人设标签),
  新增 normalizeTags/mergeTags 工具,按顿号规整与合并
- 修复 xlsx 解析器对自闭合空 cell (<c r="G6"/>) 的错位 bug,否则
  行内出现空 cell 会让后续 cell 的列引用读到错误 body(如老茄子行标签丢失)
- db/schema.ts + lib/mvp-db.ts: accounts 表新增 tags VARCHAR(500)
- app/api/resources-import/route.ts: SELECT/INSERT/UPDATE/预览全部带上 tags
- app/api/resources-export/route.ts: 导出表加「标签」列
- app/admin-app.tsx + app/globals.css: 资源卡片新增「账号标签」区,
  导入预览行展示标签
- public/KOC资源导入模板.xlsx: 模板补「标签(选填)」列与填写说明
- mysql/0005_account_tags.sql: 线上 MySQL 迁移
- tests/resource-import.test.mjs: 补 tags 解析/合并用例
2026-08-18 16:39:38 +08:00

226 lines
6.6 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 { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { parseStoredDate } from "../../../lib/date-utils";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
buildRecoveryWorkbook,
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
type AccountRow = {
id: string;
platform: string;
public_account_id: string;
nickname: string;
profile_url: string;
ip_location: string;
followers: number;
post_count: number;
cooperation_source: string;
tags: string;
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;
}
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[]) {
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),
...(account.cooperation_source || "")
.split(/[、,;|]/)
.map((item) => item.trim())
.filter(Boolean),
]),
];
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("、"),
account.tags || "",
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,
28,
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 },
);
}
}
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));
}