24 KiB
FMS 导出功能 · 设计与进度
状态:P0 + 批量导出已完成并验证通过;剩余见第 6 节。 最后更新:2026-09-16
0. 一句话现状
导出:列表工具栏的图标按钮,一键走人 —— 列与条件完全沿用列表,后端按模块元数据推导导出列、 套字段权限与数据范围、流式生成 xlsx 返回下载。
批量导出:「更多操作 → 批量导出」打开弹窗,可选列(并能保存成个人偏好)、 按条数区间导出、或每 N 条分卷打包成 zip。条件沿用列表当前条件,弹窗里不重做一套条件表单。
合计行与多级表头:后端已支持(读 view schema 的 summary 与 type:'group'),
但没有任何模块配置过,所以界面上还看不到(用户已明确「以后再说」)。
⚠️ 需要澄清一点(与最初讨论不一致):「导出」按钮导的不是当前页,而是当前查询条件下的全部行。 后端只在传了
rangeStart/rangeEnd时才加OFFSET/FETCH,不传就是不限行数。 集成测试已断言「导出行数 == 模块视图总行数」(b_con_type8 行 → 8 行数据)。 于是「批量导出」的差异在选列 / 区间 / 分卷 / 保存导出列 / 条数预览,数据范围上两者一样。 若希望普通导出只导当前页,前端补传当前页区间即可(后端参数已就绪),是一行改动 —— 待定,见第 6.1 节。
1. 已完成(P0)
1.1 后端
| 文件 | 职责 |
|---|---|
fms-api/.../service/SqlPermissionService.java |
后端 SQL 权限出口,三层:动作权限 / 字段白名单 / 数据范围。这是全项目第一个实现,后续列表查询接入时复用同一套判定 |
fms-api/.../service/ExportSchemaService.java |
按 moduleId 读 s_module + s_field + s_module_schema.view,还原导出列与表头树 |
fms-api/.../service/ExportService.java |
拼 SQL、SQL 层聚合合计、JDBC 游标 + Fesod 流式写出、并发闸门 |
fms-api/.../service/ExportWorkbookWriter.java |
表头 + 明细(分批)+ 合计行 → xlsx;行来源抽象成 RowSource,所以能脱离数据库单测 |
fms-api/.../service/ExportHeadBuilder.java |
view schema 表头树 → Fesod 的 List<List<String>> 表头结构 |
fms-api/.../service/ExportStyleHandler.java |
表头 / 数据行 / 合计行三种样式 + 逐列列宽(CellWriteHandler) |
fms-api/.../controller/ExportController.java |
POST /api/export/list(文件流)与 POST /api/export/count(行数) |
src/test/.../ExportServiceTests.java |
14 项:SQL 拼装、权限条件组合、合计 SQL、表头结构 |
src/test/.../ExportWorkbookWriterTests.java |
6 项:表头 / 明细 / 合计 → xlsx 的写出(含分组表头与多批行数) |
src/test/.../ExportServiceIntegrationTests.java |
5 项:连真实库端到端导出、计数、分卷 zip |
改动:
pom.xml新增org.apache.fesod:fesod-sheet:2.0.2-incubating(Apache Fesod Incubating,EasyExcel 的 Apache 续作);DbUtils新增public String buildSafeOrderBy(String)(原私有buildPageOrderBy的薄包装,导出复用它做排序校验,不另写一套)。
1.2 前端
| 文件 | 说明 |
|---|---|
fms-vue/src/services/exportService.js |
新增。exportListApi(blob 下载 + filename* 解析 + 错误 JSON 识别)、countExportApi |
fms-vue/src/services/http.js |
响应拦截器加 blob 直通分支(下载类响应需要读 headers) |
fms-vue/src/components/fms-module-list/FmsExportDialog.vue |
新增。批量导出弹窗:选列 / 三种导出方式 / 保存导出列 |
fms-vue/src/components/fms-module-list/FmsModuleListPage.vue |
工具栏「导出」图标按钮 + handleExport();抽出 buildSearchCondition() 供取数与导出共用;「更多操作 → 批量导出」接线;个人导出列的读写 |
fms-vue/src/components/fms-module-list/useModulePref.js |
loadModulePref / saveModulePref 加 schemaType 参数(默认仍是 query),供「我的导出列」复用 |
fms-vue/tests/unit/fms-export-dialog.spec.js |
新增。8 项:默认全选、取消勾选后的列、区间 / 分卷参数、非法输入不发起请求、保存 / 恢复导出列 |
2. 接口契约
POST /api/export/list → 文件流(xlsx;分卷时是 zip)
POST /api/export/count → ApiResponse<{ total }>
两个接口吃同一套参数,count 忽略导出方式相关参数。
| 参数 | 必填 | 说明 |
|---|---|---|
moduleId |
✅ | 模块编码。后端据此读元数据与权限,不接受前端传列清单 |
searchCondition |
查询条件(不含 WHERE),与列表当前条件一致;后端再 AND 数据范围 | |
orderBy |
排序子句;缺省用模块默认排序 s_module.b_order_sql |
|
rowKeys |
勾选行主键值数组;非空时只导出这些行(用 ? 占位绑定,不拼字面量) |
|
keyField |
勾选行主键列;缺省用 s_module.b_key_field |
|
columns |
勾选导出的字段;省略 = 沿用列表全部列(批量导出用) | |
rangeStart / rangeEnd |
按条数区间(1 基,闭区间),两者同时给出才生效,走 OFFSET/FETCH。不传即导出全部行 |
|
volumeSize |
分卷行数:> 0 时返回 zip(每卷一个 xlsx,命名 {模块名}_{起}-{止}.xlsx)。与区间互斥,逐卷不输出合计行 |
|
withSummary |
是否输出合计行,默认 true |
响应:
- 成功:文件流,
Content-Type为application/vnd...spreadsheetml.sheet(分卷时application/zip), 文件名在Content-Disposition: attachment; filename*=UTF-8''{urlencoded}; - 失败:HTTP 200 + JSON 业务错误(
{code, message, data})。前端必须按content-type区分, 否则用户会下到一个内容其实是 JSON 的.xlsx。
POST /api/export/count 单独存在(而不是复用列表分页的 total)的原因是:
它必须走与导出完全相同的权限链路(动作权限 + 字段裁剪 + 数据范围),
列表分页的 total 不受数据范围约束,配了数据范围时两者会对不上。
3. 设计决策与理由
-
后端是唯一出口,且导出列由后端自己读元数据推导。 前端只传
moduleId。理由是字段权限(s_user_field_power.b_export)只能在后端执行, 若信任前端传来的列清单,改一下 payload 就能导出工资列。副产品是「普通导出与列表一致」 成了同源推导的必然结果,而不是两边各自实现再祈祷一致。 -
ExportSchemaService与列表用同一套还原顺序(s_module→s_field(b_canuse=1、 标识符合法)→s_module_schema.view按文档顺序取叶子、递归展开分组 → 过滤visible=false与未知字段)。前端对应实现是schemaRender.js的buildListConfig。 -
所有校验必须在写
response.getOutputStream()之前完成。 一旦开始写流就不能再改 header 和状态码,失败只会给用户一个损坏文件且看不到错误。ExportService.export()的顺序是:参数校验 → 动作权限 → 列推导 + 字段裁剪 → 数据范围 → SQL 校验 → 合计 →executeQuery()→ 才设响应头并开始写流。 注意游标是「先查后设头」:这样 SQL 本身报错(列名写错、语法问题)仍然能返回 JSON; 代价是一旦开始写流,中途的数据库异常无法再以 JSON 回传 —— 流式导出的固有权衡。 集成测试专门验证了这条(模块不存在时响应体为空)。 -
流式:JDBC
TYPE_FORWARD_ONLY+setFetchSize(500)配 Fesod 分批write(底层是 SXSSF, 内存占用与总行数无关;每批 1000 行)。合计用独立聚合 SQL(SUM()/COUNT()), 不把明细拉到内存 reduce。 -
用 Apache Fesod (Incubating)(
org.apache.fesod:fesod-sheet:2.0.2-incubating,EasyExcel 的 Apache 续作, 自带 POI 5.5.1 / Commons CSV / Ehcache,不要再单独引 POI)。 最初一版是自己用 POI SXSSF 手写,后按需求换成 Fesod:多级表头合并、样式、列宽都有现成能力, 少维护一层。注意 Fesod 的 API 与 EasyExcel 一致(入口类FesodSheet,同时保留了EasyExcel/FastExcel兼容类名),所以旧资料可以直接参考。 -
「先不做行数上限」的替代保护:全局同时最多 2 个导出任务、同一用户同时 1 个、 语句走 Druid 超时、流式写。若将来要加硬上限,位置在
ExportService.export()开头的校验段。 -
多级表头交给 Fesod:
ExportHeadBuilder只把表头树转成List<List<String>>(外层 = 叶子列,内层 = 该列自上而下的标题),合并由automaticMergeHead(true)负责。 实测各列内层长度可以不同(顶层叶子只有 1 层),Fesod 会底部对齐补空 —— 列名一定落在表头最后一行,分组标题按相邻同值横向合并,正是要的形态。 (第一版自己手写合并区域时踩过「分组横向 + 叶子纵向区域重叠」的坑,换库后这段代码直接删掉了。)
4. 怎么验证
4.1 本机编译 / 测试(注意:start.cmd 是坏的)
fms-api/start.cmd 里的 JAVA_HOME=D:\devtool\jdk\jdk-21 与
MAVEN_HOME=D:\devtool\maven\apache-maven-3.8.9 两个路径都不存在
(实际是 D:\devtool\jdk\jdk21 与 D:\devtool\maven)。
bin/mvn 这个 shell 脚本在 Git Bash 下会报「找不到或无法加载主类
org.codehaus.plexus.classworlds.launcher.Launcher」。
可行的做法是直接用 JDK 拉起 Maven 的 classworlds launcher:
cd /d/workspace/code/fms/fms-api
/d/devtool/jdk/jdk21/bin/java \
-Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" \
-Dmaven.home="D:/devtool/maven" \
-Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \
-cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \
org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=ExportServiceTests test
- 本地仓库是
D:\data\maven(settings.xml改过localRepository),不是~/.m2; - 新依赖首次要联网,去掉
-o; - 输出中文乱码时加
grep -a。
4.2 本次验证结果
ExportServiceTests:14 passed。ExportWorkbookWriterTests:6 passed。覆盖单行表头、分组表头与顶层叶子混排、三层表头逐级合并、 列宽换算、0 行导出仍出表头、2500 行跨批次全部写出。ExportServiceIntegrationTests:5 passed。导出b_con_type(箱型管理,8 行)→ 生成的字节流能被 POI 解析、sheet 名正确、表头与数据行齐全且行数等于视图总行数; 勾选 1 行 → 结果只有 1 行数据;计数与视图总行数一致、条件参与计数; 分卷(每卷 3 行)→ zip 内 3 个文件、行数 3/3/2;模块不存在 → 抛业务异常且响应体为空。- 前端
tests/unit/fms-export-dialog.spec.js:8 passed(默认全选、取消勾选后的列、 区间 / 分卷参数、非法区间与空选不发请求、保存 / 恢复导出列)。 - 后端全量 54 项,唯一失败
OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry是既有基线(断言 DruidgetConnectionErrorRetryAttempts()==0,实测 1,与本次改动无关)。 - 前端改动文件 oxlint 0 error。既有基线失败:
fms-module-list-page.spec.js3 项 (断言的.filter-drawer/.filter-row__*在src/里已不存在)、module-pref.spec.js2 项(断言的默认布局「非 quick 字段进 advanced」与实现的 「非 hidden 即 quick」不一致,测试未跟上实现)。
5. 数据库现状(决定了「为什么现在看不到合计行」)
| 表 | 行数 | 影响 |
|---|---|---|
s_power |
2(都是 s_i18n_type) |
除超管外,任何模块都没有 action.*.export 权限点 → 非超管导出会被拒(「没有导出权限」) |
s_user_power |
0 | 同上,白名单语义下未授权即拒绝 |
s_user_field_power |
0 | 无记录 = 可查看/可查询/可导出(14.3 默认行为),当前无字段被裁剪 |
s_user_data_power |
0 | 无生效规则 = 无数据范围限制 |
s_user_module_pref |
0 | 个人层覆盖(P1 的「保存导出列」会写这里,b_schema_type='export') |
s_module_schema |
view 71 / edit 68 / query 19 | 没有任何 view schema 配了 summary 或 type:'group' → 合计行与分组表头不会出现 |
6. 未完成
6.1 P1(建议按此顺序)
-
普通导出语义已改为当前页:无勾选时导出当前页(前端补传rangeStart/rangeEnd), 合计行即当页合计;有勾选时仍导出勾选行。批量导出(高级导出)按所选区间/分卷聚合合计。 -
合计行与分组表头的配置入口已完成:模块管理 → 列表配置 → 批量编辑已支持 「合计」列(无 / 求和 / 计数),写回s_module_schema.view的列节点summary; 列表页底部已显示两行合计(该页合计 + 总合计),导出文件也按范围输出对应合计行。 分组表头本来就能在画布里配,不需要新做,只要配了就会生效。 -
批量导出弹窗已完成(见第 7 节)。后端columns/rangeStart/rangeEnd/volumeSize与「保存导出列」都已落地并验证。 -
前端按
canPower(moduleId, 'export')隐藏导出入口- 现在没做,无权限用户会看到入口、点了才报错。后端已拦截,属于体验问题。
- 做之前要确认
canPower对「数据模块编码」的判定是否符合预期 (canPower内部先查canAccess(scopeCode),而页面路由的meta.moduleId与dataModule.b_id未必是同一个编码,存在把有权限的用户也隐藏掉的风险)。
-
字段权限的前端缺口:列表目前只用
b_query过滤查询条件, 没有用b_view过滤列(FmsModuleListPage里activeFields = fields是全量)。 设计文档 14.3 要求b_view=0的字段在列表/表单/查询/导出中统一移除。 导出这一侧已经做了,列表那侧没做,于是会出现「列表能看到某列、导出里没有」的不一致。 当前s_user_field_power是空表所以无感,但应该补。 -
批量导出的条件编辑:目前弹窗里条件只读(显示「沿用列表当前条件」)。 若要在弹窗内改条件,正确做法是复用现有高级查询面板,而不是另写一套字段表单 —— 见第 7 节。
6.2 P2
- CSV 导出(
format参数目前只认 xlsx,未实现 CSV); - 分卷 zip(
volumeSize未实现,接口里也没有这个参数); - 模板导出(旧系统有
s_exceltpl/s_exceltpl_sub那套自定义 Excel 模板,业务真需要再单独立项)。
6.3 已知口径 / 取舍
- 列宽换算:
view的width是像素,Excel 列宽单位是字符数,换算为px / 8, 夹在[8, 60]之间,无配置时兜底 16。 - 单元格格式:目前不读
view节点的format(如money),数字按 Excel 原生数值写入, 没有设置数字格式。要按业务格式显示需要后续补。 - 表头文案:取
s_field.b_name,未做多语言(前端列表是走 i18n store 的)。 导出跟当前界面语言一致需要后续补。 - 勾选行跨页:
rowKeys是主键值数组,跨页勾选可用;但一次勾太多会撑大IN (...)的占位数量, 极端情况下应改成分批或临时表。
7. 批量导出弹窗(已实现)
入口:列表工具栏「更多操作 → 批量导出」。组件 components/fms-module-list/FmsExportDialog.vue。
参考 D:\workspace\code\container-ocr\ocr-vue 的 high-download-dialog.vue,但做了几处调整:
- 界面刻意极简:只留「导出列」「导出方式」两块 + 底部「共 N 条 / 重置 / 下载」。 不放任何说明性文字(模式说明、条件说明、分组表头与合计行的说明全部删掉)—— 说明写在本文件与代码注释里,不占用界面。
- 导出列是竖排列表,可拖拽排序(用项目已有的
vuedraggable,与设计编辑器同一套库)。 显示顺序即导出顺序。有个人保存的列时:已保存的按保存顺序排前面并勾上,其余列补在后面且不勾选。 - 条件区不另写一套字段表单:弹窗里条件只读,要改条件先在列表上改。
新系统字段是元数据驱动的,
ocr-vue那种硬编码几个字段的做法不可能覆盖; 将来若要支持弹窗内改条件,应复用现有高级查询面板,而不是第三套 UI。 - 顶部「共 N 条数据」调
POST /api/export/count,与导出走同一条权限链路(不是列表分页的 total); - 导出方式用
Segmented三选一:全部 / 按区间 / 分卷(每 N 条一个文件,后端打包 zip)。 布局是「标题 + Segmented 同一行,参数行单独一行、落在 Segmented 下方并左对齐」:.export-dialog__mode是两列 grid(左标题 / 右控件),三个 grid 项各居自己所在行, 所以标题不会被参数行拉低,区间与分卷的参数出现位置也完全一致。 底部主按钮文案是导出(不是「下载」)。
列区只有两个动作:
- 全选 —— 候选集就是列表可见列,所以「全选」等价于「回到列表列」,顺手清掉个人覆盖;
- 保存配置 —— 把当前勾选与顺序存成个人偏好。
导出列列表限高 340px 内滚动(超过就出现滚动条,弹窗本身不会被撑高)。
个人导出列存 s_user_module_pref(b_schema_type='export',JSON { schemaVersion, columns: [字段编码] },
数组顺序即导出顺序),沿用《FMS新系统核心表结构设计》第 7 节的「个人层只存增量、
恢复默认即删除覆盖」规则。
分组表头与合计行不放进弹窗,它们属于 view schema(列表与导出共用)。
分卷导出逐卷不输出合计行(逐卷合计没有业务意义)。
与旧系统 g3hd 的关键差别:那边是「前端按 10000 行切段 → N 次请求 → 前端 JSZip 打包」,
这里是「一次请求,后端切卷打包」。前端不用维护 N 次往返的进度与失败重试,
也不用同时持有 N 个 blob。代价是单卷内容在内存里渲染(受 volumeSize 约束,所以是有界内存,
不是流式),卷大小要设得合理。
8. 附:本次顺带确认的元数据事实
s_module.b_id是模块编码,b_view_table是查询视图名,b_key_field是主键列,b_order_sql是默认排序(裸 SQL,如b_xh ASC, b_id ASC)。s_module_schema.view的 JSON 形状:{"schemaVersion":1,"columns":[{"field":"b_name","width":150}, {"type":"group","title":"基本信息","children":[...]}]};列节点可选visible/summary。- 权限编码格式:
menu.{菜单}/module.{模块}/action.{模块}.{动作}, 由s_power.b_object_type+b_object_id+b_action派生(ModulePowerPanel.powerCodeOf)。 - 旧系统参考实现里不要照搬的三处:
select identity(int,1,1),* into #tmptj取行号(并发导出会撑爆 tempdb,改用ROW_NUMBER()或OFFSET/FETCH); 前端 ExcelJS 生成(新系统列表是分页的,只能导当前 20 条);g3hd的handleDownloadZip存的是mx_mapfilename却revokeObjectURL(file.url),blob URL 从未释放。
9. 实施过程中踩到的坑(供后续参考)
9.1 环境(最耗时的部分)
fms-api/start.cmd里的JAVA_HOME/MAVEN_HOME两个路径都不存在,照抄必失败;bin/mvn在 Git Bash 下报 classworlds 找不到;从 Bash 调cmd.exe被安全策略拦; 另一个 shell 工具的输出不回流(只能写文件再读)。最终靠 JDK 直拉 classworlds launcher 跑通(见 4.1)。- 本地 Maven 仓库是
D:\data\maven而不是~/.m2,查「依赖有没有缓存」时别找错地方。 - 后端 8088 上跑的是改动前的实例,没有导出接口。想走 HTTP 端到端验证就得重启它(会打断
正在开发的人),所以改用
@SpringBootTest直连真实库做集成测试 —— 这条路反而更好,可重复。 - 想开浏览器截图验证前端,但登不进去:
g3soft空密码返回 401,密码非空串。 没有为了验证去读用户密码,改成挂载组件断言 DOM 结构。
9.2 技术选型
- 第一版为了控制表头合并、逐列列宽、合计行样式三处,绕开 EasyExcel 4.0.x 的
CellWriteHandler签名变动,直接用 POI SXSSF 手写;后按要求改为 Apache Fesod (Incubating) 2.0.2(EasyExcel 的 Apache 续作)。 迁移代价很小:表头合并改用automaticMergeHead(true),手写的ExportHeaderWriter直接删除; 样式与列宽收进一个CellWriteHandler。 - 选型时不要靠记忆猜 API:Fesod 的类名/包名与 EasyExcel 不同(
org.apache.fesod.sheet.*), 用jar tf+javap直接看下载下来的 jar 最省事。
9.3 实现
- 首次编译 7 个类型错误:
Map<String,Object>.get()返回Object,而工具方法只接受String。 - 排序校验的位置一开始找错了:
buildSelectSql信任传入的orderSql(真正的校验在resolveOrderSql里)。测试改成打真正的校验口,顺带把resolveOrderSql从 private 放开为包级可见。 - 手写合并的那一版,多级表头规则我第一版测试预期就是错的:以为叶子要纵向合并到表头最后一行,
实际上两级表头里叶子已经在最后一行、再合并就会和分组的横向合并区域重叠
(POI 会抛异常或静默丢内容)。换成 Fesod 后这类问题不再需要自己保证,
但表头结构测试仍然保留(
ExportHeadBuilder的路径展开 + 写出后的表头位置断言)。 - 换库时被自己的测试抓到两个真问题(这两条都是 Fesod 行为,不是笔误):
- 0 行导出会生成「没有任何工作表」的空 xlsx —— Fesod 只在
write被调用时才创建工作表, 所以最后一批即使为空也必须调用一次。已修,并加了「0 行仍出表头」的用例。 - 合计行的「合计」标签原来由 POI 版本写在首列;换成 Fesod 后合计行是拼出来的数据行, 需要显式构造:标签落在第一个没有聚合值的列(正常就是首列),所有列都有聚合值时不加标签。
- 0 行导出会生成「没有任何工作表」的空 xlsx —— Fesod 只在
- 写测试时又被自己抓到一个错:集成测试里用
b_id = N'绝不存在的值'构造「查不到」的条件, 结果 SQL Server 报Error converting data type nvarchar to bigint——b_con_type.b_id是 bigint(新系统业务表用雪花 ID),拿字符串去比会转换失败。 教训:测试里构造条件前先确认列的真实类型,别照抄字典表(s_module.b_id是 varchar)的习惯。 - 二进制响应不要调
response.setCharacterEncoding:会让 content-type 变成application/zip;charset=UTF-8,前端按content-type判断文件类型时会受影响。
9.4 数据与权限现实
s_power只有 2 行(都是s_i18n_type),s_user_field_power/s_user_data_power都是空表。 也就是说这套权限体系实际上还没启用,导出成了第一个真正执行它的功能 —— 因此除了g3soft, 其他账号现在都会被拒。这不是 bug,是白名单语义下的必然结果。- 没有任何模块的 view schema 配过
summary或type:'group',所以合计行与多级表头写完也看不到。
9.5 顺带发现的既有问题(不是本次引入)
- 列表只用
b_query过滤查询条件,没有用b_view过滤列,与导出的裁剪口径不一致(见 6.1 第 4 项)。 fms-api/target/被 git 跟踪,编译后一堆.class与 surefire 报告会显示为 modified。- 既有测试基线失败:后端
OrgDataSourceFactoryTests1 项;前端fms-module-list-page.spec.js3 项(断言的.filter-drawer/.filter-row__*在src/里已不存在)。