TTeamDesk 学习手册
本章目录 展开导航 ↓

25 · 异步 CSV 导出:一份 205 行文件怎样守住数据范围#

小陈筛选“登录问题”,当前页只显示 10 条,实际匹配 205 条。他想拿到全部结果;客户小林使用同一个按钮,却只能拿到自己的匹配工单。假如小陈排队后被降为客户,原来受理的任务还能继续读团队数据吗?

本章追踪提交、读取、保存、下载四段代码。这里“导出成功”必须同时包含正确数据范围、完整结果与有效授权,不能只以下载到一个 csv 扩展名的文件为准。

1. 提交筛选,不提交当前页数组#

工单工作台 的 exportTask.mutate 只交 status、priority、assignment、q 和 teamId。exportFiltersSchema 通过 listTicketsSchema.omit 去掉 page、pageSize。导出与 UI 分页大小没有直接关系。

在 任务服务 的 createExport 中,从团队锁追到成员检查、count、create:

ts
// 源码节选:任务保存字段
data: {
  teamId,
  ownerId: userId,
  kind: 'EXPORT',
  filters,
  scopeCreatorId: member.role === 'CUSTOMER' ? userId : null,
},

userId 来自会话。客户端不能通过输入 scopeCreatorId=null 让客户获得团队范围。filters 保存提交时的筛选,不会跟着用户后来修改 URL 改变。

同用户、同团队,满足“WAITING / RUNNING,或者创建不足一分钟”的 EXPORT 数量达到 3 就拒绝。OR 不是相加计数,刚完成的近期任务仍计入;一条很久没完成的等待任务也计入。团队锁防止并发提交绕过这次检查。这不是全站或跨团队的统一限流。

2. 用权限变化表读懂固定范围#

打开 processor.ts 的 exportMember,再看 ticketWhere:

提交时 / 执行时 scopeCreatorId 执行结果
客户,仍是客户 原 userId 只查本人
客户,后来升为客服 仍是原 userId 仍只查本人,不扩大旧任务
客服,仍是工作人员 null 查本团队匹配范围
客服,后来降为客户 null TaskRejected,不能执行旧团队范围
原所有者已被移出团队 无论是否固定本人 拒绝执行

ticketWhere 始终含 teamId,并按固定 scopeCreatorId 或当前客户身份增加 creatorId;再叠加状态、优先级、标题和分配筛选。assignment='mine' 用当前成员关系 ID,不是 User.id;与第 13 章保持一致。

“最初范围”和“当前资格”共同限制旧任务:升权不会放大,失去必要资格会停止。任务所有者不变,也没有由管理员代领别人的文件。

3. 205 行怎样分两批读完#

沿 EXPORT 分支找 count → while → findMany → progress。读取在同一个 RepeatableRead 事务内,timeout=60000:

ts
// 源码节选:每一批的读取
const batch = await tx.ticket.findMany({
  where: { ...where, number: { gt: cursor } },
  orderBy: { number: 'asc' },
  take: 200,
  include: {
    assignee: { include: { user: { select: { name: true } } } },
  },
});

首批 cursor=0,取 200 条;cursor 更新为最后一条的唯一 number;下一批取剩余 5 条;再一次查询为空结束。用稳定顺序和 number > cursor 继续,不按页面 offset 翻页,也不要求编号连续。

同一数据库快照使 count 与各批数据一致。它建立于 Worker 实际读取时,而不是用户点击时;排队期间新增的工单可能进入快照。读取事务开始后的变化不应让这次分页读到一半突然换集合。

进度公式是 min(95, round(rows / max(total, 1) * 95))。205 行正常会报告约 93、95,保存结果后才 100。它表示读取行数进展,不是经过时间占比;前端轮询可能跳过中间数字。

4. 查询分批,不代表文件无限大#

count 大于 5000 立即 TaskRejected,不偷偷截前 5000。每批拼接后按 UTF-8 字节检查是否超过 5 MiB;不是字符数,更不是浏览器看到的文件行数。

当前 csv 是进程内不断拼接的字符串,最终存 PostgreSQL.resultCsv。分批只限制单次查询行数,不让整份字符串变成恒定内存流。60 秒事务超时也是边界,不应靠把上限无限调大承诺海量导出。

读取完成后还有第二个写事务:取团队锁、重查所有者成员资格,再按 { id, run, executionId, status: 'RUNNING' } 保存正文、摘要、progress=100、expiresAt=当前时间+24小时,并清租约。

读文件与保存文件不是同一个长事务。可重复读解决“这次读的是哪批数据”,团队锁解决“发布时是否还有资格”,executionId 解决“哪名执行者还有权提交”。三者不能互相替代。

保存前的两道门:权限与租约#

第一道门在团队锁内重查 Membership。若读取时是客服,保存前已降为客户或移出团队,抛 TaskRejected;自己仍持有租约时任务变 FAILED,不保存正文、摘要或有效期。读取进度可能仍是 95,这不代表已经发布文件。

如果导出先拿到团队锁,成员服务的降权必须等待提交后才能生效。这时先合法保存、后合法降权,数据库可以保留已完成文件;下载入口会再次授权并拒绝后续下载。不能把“后来被降权”理解为必须倒改已经完成的任务。

第二道门是提交时按租约更新,并检查匹配行数:

ts
// 源码节选:正文、摘要、期限和完成状态在同一次条件更新中写入。
const completed = await tx.backgroundTask.updateMany({
  where: { ...lease, status: 'RUNNING' },
  data: {
    status: 'COMPLETED',
    progress: 100,
    resultCsv: csv,
    resultSummary: `已导出 ${rows} 条工单`,
    expiresAt: new Date(Date.now() + 86400000),
    executionId: null,
  },
});
if (completed.count !== 1) throw new LeaseLost();

旧实现条件更新虽然能挡住旧 CSV 覆盖,却没有检查 0 行,仍会向队列报告 100%。现在失去租约就退出,不能发布旧文件,也不能把新执行者标为失败。进度更新同样用 LeaseLost;catch 中处理普通错误时,也先检查自己的失败状态更新是否真的匹配租约,匹配 0 行就安静返回。具体交错见 第 26 章。

事务提交后的 job.updateProgress(100) 属于队列汇报,数据库与 Redis 不是同一事务。如果这一步失败,账本已 COMPLETED,catch 的 RUNNING 条件匹配 0 行,保留文件并返回;页面仍按账本判断完成。文件保存事务内抛错则不同:正文、期限与完成状态一起回滚,任务回到 WAITING 后才能重试。

5. CSV 结构转义和公式防护要分开看#

csv.ts 的核心函数:

ts
// 源码节选
export function csvCell(value: unknown) {
  let text = value == null ? '' : String(value);
  if (/^\s*[=+@\-]/.test(text) || /^[\t\r\n]/.test(text)) text = `'${text}`;
  return `"${text.replaceAll('"', '""')}"`;
}

先处理危险首字符,再把内部双引号翻倍并包外层引号。三个例子各在解决不同问题:

原字段 输出的单元格文本 目的
甲,乙 "甲,乙" 逗号不拆成两列
"内容" """内容""" 引号作为内容保留
=1+1 "'=1+1" 降低表格软件把内容当公式解释的风险

csvLine 用逗号连接、CRLF 结束记录,文件开头有 UTF-8 BOM。字段内部可以带换行,所以用 split('\n').length 验收条数会误算;应使用 CSV 解析器或用已知唯一标记核对。本项目的纯文本保护不等于对所有表格软件、所有导入设置的绝对安全保证。

6. 下载入口还要授权一次#

打开 下载 Route Handler,按 getSession → taskIdSchema → downloadExport → Response 阅读。未登录 401、无效 UUID 404;服务错误映射为权限 403、其他可预期不可用 404、内部错误 500。

downloadExport 先按 id、kind=EXPORT、ownerId=当前用户查任务,再检查当前成员、团队范围任务是否被降权、状态是否完成、expiresAt 和正文是否可用。管理员访问小林的链接仍会因不是所有者而 NOT_FOUND。

成功响应使用 CSV Content-Type、attachment 文件名、private/no-store 和 nosniff。页面 canDownload 是展示条件,不能替代入口授权;过期清理尚未执行时,接口也会根据 expiresAt 拒绝。

拓展阅读撤销下载权能撤回已下载副本吗?

不能。服务器只能限制后续请求,无法抹掉用户本地已经拿到的数据。因此生成前的范围设计、生成后的有效期和下载资格都必要,不能把最后一道检查当作万能补救。

7. 四类故障怎样定位#

现象 先核对的证据 不应采取的“修复”
文件只有 10 条 原筛选、客户归属、任务 ID、实际匹配数 直接移除 creatorId 条件
排队后显示权限变化 所有者当前 Membership 与 scopeCreatorId 把旧任务强行标完成
下载按钮还在,点击 404 / 403 最新会话、有效期、正文、成员变化 改成公开 URL
中文长度不大却超 5 MiB UTF-8 byteLength、整份内容 用 JS 字符数替代字节检查

若下载时错误与旧页面按钮不一致,页面可能尚未轮询到权限或期限变化。以服务实际判断为准,再检查前端刷新。

8. 动手验证与源码中的真实证据#

手工在学习团队准备带唯一标题标记的多页数据,筛选后导出;核对编号、标题、状态与权限,不只看文件能否打开。再换另一个账号访问同一下载地址,检查被拒绝;原所有者恢复登录后重试有效文件,区分认证问题与文件问题。

205 行与上限实验由 任务集成测试 受控造数:205 行用例通过 createMany 建夹具、检查出现 0~95 的中间进度、按唯一标题统计 205 次;5001 行用例断言拒绝且 resultCsv=null。它们没有证明实时并发修改下的全部快照行为,也没有在该文件中构造 5 MiB 的边界测试。

客户范围用例同时用了标题筛选,其他客户的“秘密”本身也不匹配该标题。因此“不包含秘密”不能单独证明移除 creatorId 后一定会失败;更强夹具应让不同客户都有匹配标题。源码中的范围条件仍要直接审查,练习给出补强方法。

八条确定性测试分别证明什么#

任务集成测试 用 Promise 关卡和 Prisma 查询扩展暂停实际数据库调用,不在生产处理器里加入测试开关:

场景 暂停与操作 必须核对的结果
读取后、进度写入前接手 A 已拿到旧快照,B 的 E2 真正提交后暂停 B,再释放 A A 不抛永久失败、不报告进度;账本完整等于 E2 接手快照
最终授权后接手 A 已通过最终授权、仍持有团队锁,B 在锁外换成 E2 A 不报告 100,正文 / 摘要 / 期限仍为空;E2 不被改成 FAILED
读取中降为客户 A 的 95 进度回调暂停,通过成员服务降权后放行 FAILED、无文件、无期限,下载 FORBIDDEN
读取中移出团队 同一暂停点通过成员服务移除成员 与降权一样拒绝发布,不把已读取内容保存为文件
旧权限错误晚到 A 读到客户角色后暂停;合法恢复客服,B 接手 E2 A 的 TaskRejected 不再上报永久失败,不改变新租约;B 可完成
最终 UPDATE 后抛错 真正写入正文 / 期限 / 完成状态后,在事务返回前注入故障 全部回滚,WAITING、无文件;第二次执行 attempts=2 后才能下载
最终授权与提交共用锁 A 通过最终授权后暂停,另一连接发起降权 PostgreSQL 显示降权连接确实在等待锁;A 先完成,降权后拒绝下载
提交后汇报 100 失败 数据库已完成,队列进度回调抛错 保留 COMPLETED 和可下载结果;重复执行不刷新内容或期限

前两条用不同标题区分 A / B 的 CSV:A 读取“旧快照”,B 应发布“新快照”。先检查 B 尚未执行的中间态,再检查 B 的正文、摘要、状态、progress、executionId、lastError 和 expiresAt,避免两份相同文件掩盖覆盖错误。

锁测试为权限连接指定唯一 application_name,查询 pg_stat_activity 与 pg_blocking_pids,等到 PostgreSQL 报告真实阻塞才释放 A。它没有把“请求发出”当作“锁有效”,也不用 sleep 猜时间;4 秒超时只负责让缺失同步点及时失败。finally 会放行关卡、等待所有执行结束,并断开额外连接。

运行时先按第 27 章准备并迁移独立库:

bash
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts \
-t '导出并发接手|导出读取中|导出旧执行者|导出最终写入后|导出最终授权|导出已提交' --reporter=verbose

这八条直接调用 processor,覆盖数据库提交和模拟的队列汇报;第 26、27 章 另有真实 Worker 的自然锁过期接手、失败回调和上限耗尽测试。它们仍不代表 Redis 实际网络断线全过程已验证。客户升权仍保留原 scope 的规则也要单独看输入与查询条件,不能用这些交接测试替代所有权限组合。

练习 A:怎样设计更能发现隔离漏洞的夹具#

动手练习查看参考答案

参考答案:甲团队两个客户都创建匹配同一 q 的工单,乙团队也创建同样标题;客户导出只含本人,工作人员含甲团队两人但不含乙团队。先让这些数据在其他筛选上相同,才能验证是归属边界挡住了泄漏。

练习 B:如何验证读取过程中被降权#

动手练习查看参考答案

参考答案:本章的读取中降权用例在 95 进度回调暂停,通过真正的成员服务降权后继续,断言 FAILED 且正文 / 摘要 / 期限为空、下载拒绝。另一条最终提交锁测试覆盖相反顺序:提交先持锁,降权等待;提交成功后降权生效,文件账本保留,但后续下载被拒绝。两种顺序都应一致,不能把保存前撤权与保存后撤权混为一谈。

练习 C:文件已经生成,24 小时后清理没跑,还能下载吗#

动手练习查看参考答案

参考答案:不能。下载独立检查 expiresAt,不依赖清理是否及时;清理用于回收正文空间,账本继续保留。已经下载到本地的副本则不受这次服务器拒绝影响。

链路:提交固定筛选 → 身份、成员与数量校验 → 保存原始范围 → Worker 重查资格并按快照分批生成 → 团队锁内再授权和保存 → 轮询结果 → 下载入口再次授权与检查期限。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。