Nuxt全栈实现Excel批量导入:模板生成、校验解析与幂等去重实战

发布时间:2026/9/14 6:38:45
Nuxt全栈实现Excel批量导入:模板生成、校验解析与幂等去重实战 做后台系统的朋友大概都接过这么一句话需求能不能给个模板我填好了批量传上来。听起来三分钟的事真动手做起来你会发现一个「下载–填写–上传」的闭环里藏着文件编码、单元格类型、内存占用、幂等控制、部署平台体积上限等一堆边界问题任何一环没处理好用户看到的都是模板打不开传上去少了两百条中文全是问号。这篇文章就是把这套闭环在 Nuxt 全栈开发场景下完整走一遍从前端按钮一直讲到 Nitro 服务端解析落库覆盖模板生成、鉴权下载、行级校验、错误回执、幂等去重和部署踩坑。不管你是刚接手第一个批量导入功能的初级开发还是想重新梳理这套流程的老手都能直接拿去抄结构、抄代码、抄排查思路。1. 这个闭环为什么值得当成一个独立模块来做1.1 文件流转是批量业务绕不开的一条低带宽通道系统里绝大多数入口都是表单一次一条服务端只要关心单条数据是否合法。一旦数据量上到几百上千条用户就不愿意在页面上一条条敲了——他们手上本来就有一份 Excel可能是从别的系统导出的可能是同事发过来的也可能是在离线环境里整理的。这时候 Excel 就成了一条低带宽通道它把界面交互降级成文件交换牺牲了即时校验换来的是操作效率。这条通道的特性决定了它跟普通表单完全不是一回事。文件是先离线、后上线的用户在填写期间你完全不可控文件会被复制、转发、改后缀名、被 WPS 另存为一个 xlsx 文件里可能有隐藏工作表、合并单元格、公式列、条件格式甚至有人在里面顺手记了别的笔记。你在页面上做的那套前端校验在这里统统失效。所以我一般建议把这类功能单独抽成一个模块叫它批量导入也好叫数据交换也好独立目录、独立路由、独立测试用例。别把它塞进某个业务页面里当作一个附属功能因为它牵扯到的技术面文件 IO、流式响应、异步任务、幂等跟普通 CRUD 差别太大混在一起以后没人敢改。1.2 三段契约模板、校验、回执把这个闭环拆开看其实是三段契约在接力。第一段是模板契约服务端生成的这份文件规定了列的顺序、列名、数据类型、必填项、枚举取值、日期格式、行数上限。第二段是校验契约解析时按什么规则判定一行合法哪些问题能自动修比如去空格、统一日期格式哪些必须打回。第三段是回执契约用户传完以后拿到什么反馈是只给一个成功 380 条失败 20 条还是给一份带错误原因的原表回执。这三段里最容易做坏的是第三段。很多系统上传完只弹一个 toast用户根本不知道哪几行错了只能靠肉眼比对体验极差。我的做法永远是生成一份错误回执文件把原始数据原样带回来末尾追加若干列错误说明用户改完直接再传一次——注意这一步让下载–填写–上传变成了一个可以循环的环而不是一次性动作。回执文件本身就是下一轮的模板这是整套设计里最值钱的一个细节。提示回执文件要保留用户原始填写的每一列包括那些你没用上的列。用户会拿着回执继续在 Excel 里改你把列删了他会一脸茫然。1.3 用 Nuxt 一套代码吃下前后端的边界选 Nuxt 做这件事的核心原因是它把前后端放进了同一个工程。server/api目录下的文件就是接口app/下面就是页面模板生成、文件解析、页面渲染可以共用同一份类型定义和校验规则。这在批量导入这个场景里非常关键列名、枚举值、最大值这些约束前端要在模板里写成数据验证后端要在解析时再校验一遍如果两边分别维护改一次字段要动两个仓库早晚不同步。先讲清楚一个版本细节因为很多人卡在这。Nuxt 3.14 之后提供了shared/目录里面的代码前端和服务端都能自动导入特别适合放导入相关的常量、类型和 zod schema。如果你的项目版本更早那就建一个普通.ts文件服务端用相对路径或~~/别名去引别放在server/utils里那个目录只有服务端能访问。// shared/schemas/employee-import.ts import { z } from zod export const TEMPLATE_VERSION employee-v3 export const DEPARTMENT_ENUM [研发, 市场, 财务, 人力] as const export const employeeRowSchema z.object({ name: z.string().trim().min(1, 姓名不能为空).max(20, 姓名过长), code: z.string().regex(/^\d{6,20}$/, 工号必须是 6-20 位数字), department: z.enum(DEPARTMENT_ENUM, { message: 部门不在可选范围内 }), hiredAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 入职日期格式应为 YYYY-MM-DD), }) export type EmployeeRow z.infertypeof employeeRowSchema这份 schema 会在三个地方被用到生成模板时推导列顺序前端做首屏预览时做一次预检服务端解析时做权威校验。一处定义三处消费这就是全栈同仓最实在的收益。2. 模板生成Nitro 服务端产出的文件必须能被填对2.1 Excel 和 CSV 的取舍我按三个场景给结论先说选型。Excelxlsx和 CSV 不是谁替代谁的关系它们各自解决不同的问题选错了后面全是坑。维度xlsxcsv单元格类型有类型可强制文本/日期全是文本Excel 打开时会自作聪明猜类型数据验证下拉支持不支持多工作表支持不支持体积较大即便空表也有几十 KB极小服务端生成依赖需要 ExcelJS 之类的库手写拼接即可用户端兼容需要装 Office/WPS 或在线表格任何文本编辑器都能开我的结论很直接面向业务人员批量填写的用 xlsx面向系统对接、机器对机器传输的用 csv字段里包含身份证号、订单号、银行卡号这类长数字的坚决用 xlsx 并把列格式锁成文本。最后这条是血泪教训后面第 4 章会展开讲。2.2 流式输出与 Content-Disposition 的文件名编码用 ExcelJS 在 Nitro 里生成一个 xlsx核心代码不超过二十行但有两个地方特别容易写错。// server/api/template/employee.get.ts import ExcelJS from exceljs import { DEPARTMENT_ENUM, TEMPLATE_VERSION } from ~~/shared/schemas/employee-import export default defineEventHandler(async (event) { const wb new ExcelJS.Workbook() wb.creator internal-tools const ws wb.addWorksheet(员工导入) ws.columns [ { header: 姓名, key: name, width: 16 }, { header: 工号, key: code, width: 22 }, { header: 部门, key: department, width: 14 }, { header: 入职日期, key: hiredAt, width: 16 }, ] ws.getRow(1).font { bold: true } // 工号列锁成文本避免长数字被科学计数法吃掉 ws.getColumn(code).numFmt ws.getColumn(hiredAt).numFmt yyyy-mm-dd // 部门列加下拉减少脏数据 for (let i 2; i 500; i) { ws.getCell(C${i}).dataValidation { type: list, allowBlank: false, formulae: [${DEPARTMENT_ENUM.join(,)}], } } const buffer await wb.xlsx.writeBuffer() setHeader(event, Content-Type, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) setHeader(event, Content-Disposition, attachment; filenameemployee-template.xlsx; filename*UTF-8${encodeURIComponent(员工导入模板.xlsx)}) setHeader(event, X-Template-Version, TEMPLATE_VERSION) return buffer })第一个坑在Content-Disposition。HTTP 头里只能出现 ASCII 字符中文文件名直接写进去会被浏览器或中间代理截断用户下到的文件可能叫employee-template.xlsx也可能叫一堆乱码。标准做法是同时给两个字段filename放一个 ASCII 兜底名filename*用UTF-8加百分号编码承载真正的中文名。现代浏览器优先认filename*老浏览器退回到filename两边都不难看。第二个坑是内存。writeBuffer()会把整个工作簿在内存里构建完再返回模板文件通常只有几十 KB 到几百 KB完全没问题。但如果哪天要做导出十万行数据就得换成workbook.xlsx.write(stream)配合sendStream(event, stream)边写边推否则单个请求就能把 Node 进程的内存顶上去。2.3 把校验前置进表格单元格格式与数据验证模板不只是一张空白表它是一份可以执行的说明书。用户打开模板的那一刻你能给的引导越多后面解析时的脏数据就越少。列宽要够别让用户自己拖表头加粗并把必填列标出来下拉列用dataValidation做成真正的下拉用户就不能瞎填日期列设numFmt yyyy-mm-dd视觉上有引导。还有一招很好用在模板里加第二个工作表叫填写说明把字段规则、示例值、常见错误列清楚。这个 sheet 在解析时直接跳过就行。const help wb.addWorksheet(填写说明) help.columns [ { header: 列名, key: col, width: 14 }, { header: 是否必填, key: required, width: 12 }, { header: 规则, key: rule, width: 60 }, ] help.addRow({ col: 姓名, required: 是, rule: 2-20 个字符不要带空格 }) help.addRow({ col: 工号, required: 是, rule: 6-20 位数字列格式已锁为文本 })另一个容易忽略的点是示例行。给一行假的示例数据用户照着改最快。但示例行必须在解析时能被识别并跳过常见做法是加一个是否删除本行示例的提示或者干脆不写示例行只写在说明 sheet 里。我个人偏向不写示例行因为总有人忘了删最后在系统里多出一条叫张三的员工。2.4 模板版本号让过期模板自己现形模板一旦发出去就收不回来了。用户电脑里存的三年前的模板随时可能被翻出来上传。如果这三年里你改过列顺序、改过枚举值用户传上来的文件就会莫名其妙地报错而用户完全不知道问题出在哪。解决办法是给模板打版本号并且让这个版本号能被打回来。具体两步生成时把版本号写到隐藏工作表的一个单元格里同时放进响应头X-Template-Version上传时前端把当前页面认知的版本号一起带上服务端优先读文件里埋的那个版本号做比对。// 生成时埋点 const meta wb.addWorksheet(_meta, { state: veryHidden }) meta.getCell(A1).value TEMPLATE_VERSION// 解析时校验 const metaSheet wb.getWorksheet(_meta) const fileVersion metaSheet?.getCell(A1).value?.toString() if (fileVersion ! TEMPLATE_VERSION) { return { ok: false, code: TEMPLATE_EXPIRED, message: 你使用的是旧版模板${fileVersion ?? 未知}请重新下载最新模板后再填写, } }state: veryHidden比hidden更彻底用户在 Excel 的取消隐藏菜单里都看不到这张表不会误删。版本不匹配时不要给一堆字段级错误直接整单打回并引导重新下载这是体验上最省事的选择。3. 前端下载一个 a 标签解决不了的事3.1 带鉴权的下载为什么不能用 location.href最朴素的下载写法是a href/api/template/employee download。如果你的接口是公开的这确实够了。但真实项目里模板接口通常要走登录态而登录态一般放在Authorization头里或者需要过一层网关校验。裸的a标签发的是一个普通导航请求你没法给它加自定义请求头。有人会想用window.location.href url绕过去这更糟一旦服务端返回 401浏览器会直接跳到一个 JSON 错误页用户一脸懵。还有人在 URL 后面拼 token 参数这会让 token 进到浏览器历史记录和服务器访问日志里是明确的安全隐患。正确姿势是用$fetch发起一个带鉴权的请求把响应体当二进制读回来再在浏览器本地生成一个临时 URL 触发下载。3.2 Blob 下载的完整写法与内存回收// app/composables/useDownload.ts export function useFileDownload() { async function download(url: string, fallbackName: string) { const res await $fetch.raw(url, { responseType: blob, // 你的鉴权方式在这里体现cookie 会自动带上token 方案就自己加 header }) const blob res._data as Blob const objectUrl URL.createObjectURL(blob) const a document.createElement(a) a.href objectUrl a.download parseFilename(res.headers.get(content-disposition), fallbackName) a.style.display none document.body.appendChild(a) a.click() a.remove() // 立刻 revoke 在 Safari 上会偶发下载失败延后一点更稳 setTimeout(() URL.revokeObjectURL(objectUrl), 2000) } return { download } } function parseFilename(cd: string | null, fallback: string) { if (!cd) return fallback const star /filename\*UTF-8([^;])/i.exec(cd) if (star?.[1]) return decodeURIComponent(star[1]) const plain /filename?([^;])?/i.exec(cd) return plain?.[1] ?? fallback }这里有三个细节值得单独说。第一$fetch的responseType: blob是必须的不加的话 ofetch 会尝试把二进制当 JSON 解析直接抛错。第二Content-Disposition的解析要优先匹配filename*因为中文名在那里而且取到的是百分号编码必须decodeURIComponent。第三URL.createObjectURL创建的引用会一直占着内存直到你主动释放如果用户在一个页面里反复点下载不回收就是实打实的内存泄漏。延后 2 秒释放是为了兼容 Safari 上偶发的下载中断。3.3 进度、失败重试与分块下载的适用边界下载模板这种几百 KB 的文件其实不需要进度条。真正需要进度反馈的是导出大数据量文件的场景而那种场景下$fetch会把整个响应体缓存在内存里浏览器端同样吃不消。这时候要么改用流式下载fetchReadableStream配合showSaveFilePicker但兼容性有限要么把大导出改成异步任务提交任务后返回一个 taskId后台慢慢生成生成完写到对象存储前端轮询到完成后再拿一个签名链接下载。我的经验阈值是单次下载体积小于 5MB 直接走 Blob大于 20MB 一律走异步任务加对象存储中间地带看用户网络质量内网项目直接 Blob 就行。别为了一个 300KB 的模板上流式下载复杂度换不来任何收益。失败重试这块也要有基本处理。下载失败最常见的原因是登录态过期返回 401此时不该弹网络错误而应该提示登录已过期请重新登录后重试。这个判断可以在$fetch的onResponseError里统一做。4. 填写阶段把九成错误挡在用户桌面上4.1 长数字被 Excel 吃掉的连锁反应这是整个闭环里最经典的坑没有之一。用户把工号100000000000000001粘贴进 ExcelExcel 默认按数值处理超过 15 位有效数字后直接截断成100000000000000000并且显示为1E17这样的科学计数法。用户看到的是一串乱码你以为他填错了其实是你模板的列格式没锁。防御要分三层做。第一层是模板层面即上面说的ws.getColumn(code).numFmt 把整列预设为文本格式用户在这个列里输入长数字不会被转换。第二层是说明层面在填写说明里写清楚工号请勿直接从网页复制粘贴建议先在记事本里过一遍。第三层是服务端层面的兜底解析时如果发现某个应该是字符串的单元格拿到的是数字类型并且数值大于Number.MAX_SAFE_INTEGER直接判定为该行数据精度已丢失请重新填写而不是硬着头皮存进去。function readCodeCell(cell: ExcelJS.Cell): { ok: boolean; value: string; reason?: string } { const v cell.value if (typeof v number) { if (!Number.isSafeInteger(v)) { return { ok: false, value: , reason: 工号精度已丢失请把该列设为文本格式后重新填写 } } return { ok: true, value: String(v) } } if (typeof v string) return { ok: true, value: v.trim() } return { ok: false, value: , reason: 工号必须是文本或数字 } }这个兜底很重要。很多系统在这里选择静默接受结果就是数据库里躺着几百条永远查不到的错工号等到对账的时候才发现那时候数据已经没法回溯了。4.2 日期、枚举与联动列的约束设计日期是第二高频的出问题字段。用户在 Excel 里可能有三种填法真正的日期格式单元格底层是序列号、文本格式的2024-01-01、以及各种本地化写法2024/1/1、1/1/2024。你不可能全支持所以要在模板里用numFmt把列固定成日期格式同时在说明里只认一种写法。解析时统一归一化成YYYY-MM-DD字符串再入库不要直接把 Date 对象丢给数据库理由在第 7 章会详细讲。枚举列尽量都做成 Excel 下拉虽然用户还是可以通过粘贴绕过但绝大多数人会顺着下拉走。有一种情况下拉会失效用户从另一个文件复制了一整列数据过来粘贴会覆盖数据验证规则。所以枚举校验在服务端必须重做一遍不能因为模板里有下拉就放松。联动列比如先选省份再选城市在 Excel 里实现成本很高需要定义名称加INDIRECT公式。我的建议是能在服务端表达的约束就别往 Excel 里塞Excel 的公式对普通用户来说是个黑盒出错时你很难解释。把联动逻辑放在上传后的校验里报错信息写清楚城市与省份不匹配用户改起来反而更快。4.3 空行、隐藏工作表、合并单元格的处理约定用户在填表过程中会在中间留下空行这在 Excel 里看起来毫无痕迹但解析时必须处理。ws.eachRow()默认会跳过完全空的行但看起来空、实际上有格式的行不会被跳过——比如用户选中整行按了删除键单元格值为空但样式还在。判断行是否有效要用关键字段是否全为空而不是单元格个数是否为零。function isBlankRow(row: ExcelJS.Row, keyIndexes: number[]) { return keyIndexes.every(i { const v row.getCell(i).value return v null || v undefined || String(v).trim() }) }合并单元格要特别小心。row.getCell(n).value在合并区域内只有左上角那个格有值其他格返回null。如果用户把部门那列做了纵向合并下面几行的部门会全部读到空。处理办法有两种一是解析前先遍历ws.mergeCells记录合并区间取值时向上查找二是干脆不鼓励合并在说明里写明。我一般选第一种因为用户的习惯改不了代码多做一点比反复沟通便宜。隐藏工作表默认不解析但要注意有些模板工具会生成一张隐藏的xl/worksheets/sheet2.xmlExcelJS 能正常枚举到用wb.worksheets遍历时记得按名字取你要的那张别用索引索引会随文件不同而变化。5. 上传解析Nitro 里读 multipart 的真实能力边界5.1 readMultipartFormData 能做什么、不能做什么Nitro 提供了readMultipartFormData(event)这个便捷方法它把整个请求体解析成一个MultiPartData[]数组每个元素带name、data、filename、type。上手极快一个 find 就能拿到文件。const parts await readMultipartFormData(event) const filePart parts?.find(p p.name file)但它的边界必须提前知道它会把整个文件读进内存没有流式能力也没有内置的大小限制。也就是说如果有人上传一个 500MB 的文件你的 Node 进程会直接吃掉 500MB 内存几个并发就能把服务打挂。所以务必手动加限制const MAX_SIZE 15 * 1024 * 1024 if (!filePart || !filePart.filename) { throw createError({ statusCode: 400, statusMessage: 未收到文件 }) } if (filePart.data.length MAX_SIZE) { throw createError({ statusCode: 413, statusMessage: 文件超过 15MB 上限 }) }真要支持大文件就得绕过这个便捷方法直接拿到底层请求流用busboy之类的流式解析器边读边写临时文件。这个改动量不小除非业务真的需要否则我更倾向于把限制设在 10-15MB 并用前端提前拦截——xlsx 格式本身压缩率很高一万行数据通常也就 1-2MB。5.2 解析流水线的四步读表、映射、校验、落库解析逻辑建议拆成四个独立函数每一层职责单一方便单测。第一步读表负责把 Excel 的物理结构转成一个朴素的二维对象数组顺带处理合并单元格、富文本、公式结果这些脏东西。function normalizeCell(v: ExcelJS.CellValue): string | number | Date | null { if (v null || v undefined) return null if (v instanceof Date) return v if (typeof v object) { if (richText in v) return v.richText.map(t t.text).join() if (text in v hyperlink in v) return (v as any).text if (result in v) return (v as any).result ?? null if (error in v) return null } return v as string | number }富文本特别容易漏。用户在单元格里对部分文字做了加粗或标红Excel 底层就是richText结构直接取cell.value会拿到一个对象丢进 zod 校验必然失败而错误信息会显示成[object Object]排查起来很费劲。公式单元格同理必须取result而不是公式本身。第二步映射把列序号映射成字段名。别硬编码getCell(1)对应姓名要用表头文本匹配这样列顺序变了也能容错。第三步校验逐行跑 zod schema收集每一行的错误注意这里不要parse而是用safeParseparse遇到第一个错误就抛了你会丢掉后面所有行的信息。const results rawRows.map(row { const parsed employeeRowSchema.safeParse(row.mapped) if (!parsed.success) { return { rowNumber: row.rowNumber, raw: row.mapped, errors: parsed.error.issues.map(i ${i.path.join(.)}${i.message}), } } return { rowNumber: row.rowNumber, data: parsed.data } })第四步落库只把合法行写进去并且用事务包起来。合法行分块写每块 500 条避免单条 SQL 太长。await prisma.$transaction(async (tx) { for (const chunk of chunkArray(validRows, 500)) { await tx.employee.createMany({ data: chunk, skipDuplicates: true }) } await tx.importBatch.update({ where: { id: batchId }, data: { status: SUCCESS, successCount: validRows.length, failCount: errorRows.length }, }) })注意skipDuplicates是 Postgres 和 MySQL 的特性SQLite 和 SQL Server 不支持用 SQLite 的话得自己先查一遍再筛。5.3 大文件与部署平台的体积红线本地开发跑得好好的一上线就 413这是新手最常撞的墙。原因在于请求体大小限制是多层的每一层都可能拦你。层级限制项常见默认值调整方式浏览器无硬限制但会占内存-前端提前校验file.sizeNginxclient_max_body_size1M改成20m同时看client_body_timeout云平台函数请求体上限4.5MB 左右无法调整必须改架构Nitro依赖底层运行时无内置上限自行校验如果部署在带函数体积限制的平台上15MB 的上传是根本不可能的。这时候只有两条路一是把文件切成 2-3MB 的分片依次上传服务端按uploadId和分片序号拼装二是前端直传对象存储服务端只发一个预签名 URL文件不经过你的应用服务器。第二条路更干净也是我现在默认推荐的方案代价是需要引入一套对象存储。前端提前拦截这一招成本最低效果最好。用户选中文件后先判断后缀和大小不合格直接提示连请求都不发。function precheck(file: File) { const okExt /\.(xlsx|xls)$/i.test(file.name) if (!okExt) return 只支持 .xlsx 或 .xls 文件 if (file.size 15 * 1024 * 1024) return 文件不能超过 15MB if (file.size 0) return 文件内容为空 return null }5.4 错误回执文件把闭环再接一段上传接口的返回值要有结构不能只给个成功失败。我一般返回这样一份结构{ batchId: batch_20240521_8f3a, total: 400, success: 380, failed: 20, errors: [ { row: 7, messages: [工号必须是 6-20 位数字] }, { row: 15, messages: [部门不在可选范围内] }, ], receiptUrl: /api/import/batch/batch_20240521_8f3a/receipt }errors只返回前 50 条用于页面展示完整错误放在回执文件里。回执文件的生成可以复用第 2 章的模板生成代码只不过把原始数据加回去末尾追加一列错误原因并把错误行整行标红底。const receipt wb.addWorksheet(错误明细) receipt.columns [...originalColumns, { header: 错误原因, key: reason, width: 50 }] receipt.addRow({ ...raw, reason: messages.join() }) receipt.getRow(receipt.rowCount).fill { type: pattern, pattern: solid, fgColor: { argb: FFFFE0E0 }, }回执文件不落盘也行用一个带签名的短效链接按需生成即可避免在服务器上堆积一堆一次性文件。但如果错误量大、生成耗时长就值得做成异步任务并把结果存到对象存储链接有效期设 24 小时过期自动清理。6. 幂等与并发连点两次上传到底会怎样6.1 超时重试与部分写入的真实故障模型先描述一个真实发生过的故障。用户上传一个 800 行的文件网络慢前端设了 30 秒超时。第 28 秒服务端还在写库前端超时了提示上传失败请重试。用户点了重试第二次请求发出去此时第一条请求其实已经写完了。结果就是 800 条数据进了两遍或者数据库里出现了两批相同工号的记录。这个故障模型有两个特征前端认为的失败和服务端认为的失败不是一回事写入本身可能是部分成功的。应对方法是不让前端有重试即重放的机会具体就是引入幂等键。6.2 幂等键的构造方式与唯一索引兜底幂等键的核心思路是让同一次业务动作只产生一个唯一标识。构造方式通常是用户 ID 模板版本 文件内容哈希。import { createHash } from node:crypto const fileHash createHash(sha256).update(filePart.data).digest(hex) const idempotencyKey createHash(sha256) .update(${userId}:${templateVersion}:${fileHash}) .digest(hex)用文件内容的哈希有个额外好处用户如果只是改了文件名重新上传哈希不变直接返回上次的结果不会重复导入。但要注意一点如果用户在两次上传之间改了内容哈希就变了这时候应该允许导入——但同一个工号会撞唯一索引所以数据库层要有兜底。model Employee { id String id default(cuid()) tenantId String code String name String unique([tenantId, code]) }有了这个唯一索引createMany({ skipDuplicates: true })就能自动跳过重复行。这样一来即使幂等键失效最坏情况也只是某些行被跳过而不是数据被写脏。唯一索引是最后一道防线任何批量导入系统都应该有。处理幂等命中的逻辑要写在最前面越早返回越省资源const existing await prisma.importBatch.findUnique({ where: { idempotencyKey } }) if (existing?.status SUCCESS) { return { batchId: existing.id, duplicated: true, ...existing.summary } } if (existing?.status RUNNING) { throw createError({ statusCode: 409, statusMessage: 上一次导入还在处理中请稍后再试 }) }6.3 并发导入的资源挤占与排队策略单机 Node 服务同时处理多个 Excel 解析请求内存和 CPU 都会被吃掉。ExcelJS 解析本身是 CPU 密集型的一个 1MB 的文件解析大约要几百毫秒10MB 的可能要几秒。如果十个人同时传事件循环会被长时间占用整个服务响应变慢。简单的做法是加一层并发控制限制同时处理的导入任务数。// server/utils/importQueue.ts const MAX_CONCURRENT 2 let running 0 const queue: Array() void [] export async function withImportSlotT(fn: () PromiseT): PromiseT { if (running MAX_CONCURRENT) { await new Promisevoid(resolve queue.push(resolve)) } running try { return await fn() } finally { running-- queue.shift()?.() } }这个队列是进程内的多实例部署时每个实例各有一份控制的是单实例负载已经够用了。再往上就要引外部队列比如把任务丢进 Redis由 worker 消费那是另一个量级的复杂度只有导入量真的很大时才值得上。7. 实测踩坑与排查链路7.1 中文乱码BOM、charset 与响应头三处都要看乱码的排查要从文件从哪来、经过谁、被谁打开这条链路走一遍。如果用户说打开模板是乱码先分清是 xlsx 还是 csv。xlsx 是二进制格式理论上不会有编码问题出问题多半是文件名乱码见 2.2 节的filename*。csv 则是纯文本编码问题高发。Excel 打开 CSV 时默认按系统区域编码解析简体中文 Windows 上是 GBK如果文件是 UTF-8 无 BOM中文必然乱码。标准解法是在文件开头写入 UTF-8 BOMconst BOM \uFEFF const content BOM rows.map(r r.map(csvEscape).join(,)).join(\r\n) setHeader(event, Content-Type, text/csv; charsetutf-8) return contentcsvEscape也不能省字段里出现逗号、换行、双引号时必须用双引号包裹并把内部双引号转义成两个function csvEscape(v: unknown) { const s v null || v undefined ? : String(v) return /[,\r\n]/.test(s) ? ${s.replace(//g, )} : s }排查顺序建议是先用文本编辑器VS Code、Notepad看文件的十六进制开头是不是EF BB BF确认 BOM 在不在再看响应头的 charset 是不是 utf-8最后看用户是不是用 Excel 双击打开的双击遵循系统区域设置从 Excel 内部数据-从文本导入才让你选编码。把这三步走完乱码问题基本一次定位。7.2 日期差一天的完整定位过程有个bug我印象很深用户在 Excel 里填2024-03-01导入后数据库里是2024-02-29。这个问题的排查链路值得完整走一遍。第一步看数据库字段类型。如果字段是date类型不含时区存2024-03-01就应该显示2024-03-01如果是timestamptz那就要看时区偏移。第二步在解析处打日志同时输出 UTC 和本地时间const v cell.value // 期望是 Date if (v instanceof Date) { console.log(raw, v.toISOString(), utc, v.getUTCDate(), local, v.getDate()) }结果通常是raw 2024-03-01T00:00:00.000Z utc 1 local 1说明 ExcelJS 给出的 Date 是 UTC 零点的本身没问题。问题出在后面的转换如果某处用了dayjs(v).utcOffset(8).format(YYYY-MM-DD)东八区加 8 小时会变成2024-03-01T08:00:0008:00日期还是 1 号没问题。但如果用了dayjs(v).utcOffset(-8)之类的减法或者数据库连接配置了非本地时区就会退到 2 月 29 日。第三步确认 ORM 的行为。把 Date 对象直接传给date类型的列时不同 ORM 的处理方式不一样有的会先转成本地时间字符串再截取日期部分这一步最容易出错。最终我采用的做法是彻底避开 Date 对象解析阶段就把日期归一化成YYYY-MM-DD字符串入库时也存字符串字段类型用date。function toDateOnly(v: unknown): string | null { if (v instanceof Date) { const y v.getUTCFullYear() const m String(v.getUTCMonth() 1).padStart(2, 0) const d String(v.getUTCDate()).padStart(2, 0) return ${y}-${m}-${d} } if (typeof v number) { // 用户手填的数值被 Excel 当成序列号 const ms Date.UTC(1899, 11, 30) v * 86400000 return toDateOnly(new Date(ms)) } if (typeof v string) { const m /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(v.trim()) if (m) { return ${m[1]}-${m[2].padStart(2, 0)}-${m[3].padStart(2, 0)} } return null // 明确返回 null 让上层报错不要瞎猜 } return null }注意最后那个return null。像01/02/2024这种写法你永远不知道是 1 月 2 日还是 2 月 1 日猜错了比报错更可怕。宁可让用户重填也不要赌一个歧义格式。7.3 413 报错要分层查别一上来就改代码遇到 413很多人的第一反应是去翻 Nitro 的配置看有没有 body limit 参数折腾半天没找到因为这条路本身就走偏了。正确的做法是按请求经过的每一层依次排除。先在浏览器开发者工具的 Network 面板里看这个请求的 Response Headers如果返回头里带Server: nginx说明是 Nginx 拦的去改client_max_body_sizeserver { client_max_body_size 20m; client_body_timeout 60s; location /api/import/ { proxy_pass http://127.0.0.1:3000; proxy_request_buffering off; # 大文件时避免先落盘再转发 } }如果部署在带函数体积限制的平台上那就不是配置问题而是架构问题前面 5.3 节说的分片上传或直传对象存储是唯一出路。如果本地开发环境也复现了那才需要看应用层检查是不是自己在代码里手动抛了 413。排查的时候还有个技巧先用一个 1MB 的小文件试再逐步增大到 5MB、10MB找到刚好失败的那个临界值。临界值能直接告诉你限制来自哪一层——4-5MB 左右失败基本就是函数平台的限制1MB 左右失败基本是 Nginx 默认值几十 MB 才失败那才是你自己代码写的限制。最后再分享一个小技巧关于这个闭环本身的。我把模板生成、解析、回执这三个能力都做成了独立的 composable 和 server util然后在测试环境里加了一个自检按钮它会自动生成一份模板、程序化填入 50 行包含各种边界数据超长字段、特殊字符、空行、日期边界值的内容、再走一遍上传解析最后把错误报告和我预期的对比。这个自检跑一次大概两秒但它帮我提前发现了至少三次升级 ExcelJS 后行为变化的问题。带文件读写的功能光靠单元测试很难覆盖真实文件的复杂度用一个可以反复运行的端到端自检来兜底是我在这个模块上花得最值的一笔时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询