Files
workspace/code/fms/FMS导出功能设计与进度.md
T
2026-09-16 23:15:06 +08:00

24 KiB
Raw Blame History

FMS 导出功能 · 设计与进度

状态:P0 + 批量导出已完成并验证通过;剩余见第 6 节。 最后更新:2026-09-16


0. 一句话现状

导出:列表工具栏的图标按钮,一键走人 —— 列与条件完全沿用列表,后端按模块元数据推导导出列、 套字段权限与数据范围、流式生成 xlsx 返回下载。

批量导出:「更多操作 → 批量导出」打开弹窗,可选列(并能保存成个人偏好)、 按条数区间导出、或每 N 条分卷打包成 zip。条件沿用列表当前条件,弹窗里不重做一套条件表单。

合计行与多级表头:后端已支持(读 view schema 的 summary 与 type:'group'), 但没有任何模块配置过,所以界面上还看不到(用户已明确「以后再说」)。

⚠️ 需要澄清一点(与最初讨论不一致):「导出」按钮导的不是当前页,而是当前查询条件下的全部行。 后端只在传了 rangeStart / rangeEnd 时才加 OFFSET/FETCH,不传就是不限行数。 集成测试已断言「导出行数 == 模块视图总行数」(b_con_type 8 行 → 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. 设计决策与理由

  1. 后端是唯一出口,且导出列由后端自己读元数据推导。 前端只传 moduleId。理由是字段权限(s_user_field_power.b_export)只能在后端执行, 若信任前端传来的列清单,改一下 payload 就能导出工资列。副产品是「普通导出与列表一致」 成了同源推导的必然结果,而不是两边各自实现再祈祷一致。

  2. ExportSchemaService 与列表用同一套还原顺序(s_module → s_field(b_canuse=1、 标识符合法)→ s_module_schema.view 按文档顺序取叶子、递归展开分组 → 过滤 visible=false 与未知字段)。前端对应实现是 schemaRender.js 的 buildListConfig。

  3. 所有校验必须在写 response.getOutputStream() 之前完成。 一旦开始写流就不能再改 header 和状态码,失败只会给用户一个损坏文件且看不到错误。 ExportService.export() 的顺序是:参数校验 → 动作权限 → 列推导 + 字段裁剪 → 数据范围 → SQL 校验 → 合计 → executeQuery() → 才设响应头并开始写流。 注意游标是「先查后设头」:这样 SQL 本身报错(列名写错、语法问题)仍然能返回 JSON; 代价是一旦开始写流,中途的数据库异常无法再以 JSON 回传 —— 流式导出的固有权衡。 集成测试专门验证了这条(模块不存在时响应体为空)。

  4. 流式:JDBC TYPE_FORWARD_ONLY + setFetchSize(500) 配 Fesod 分批 write(底层是 SXSSF, 内存占用与总行数无关;每批 1000 行)。合计用独立聚合 SQL(SUM() / COUNT()), 不把明细拉到内存 reduce。

  5. 用 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 兼容类名),所以旧资料可以直接参考。

  6. 「先不做行数上限」的替代保护:全局同时最多 2 个导出任务、同一用户同时 1 个、 语句走 Druid 超时、流式写。若将来要加硬上限,位置在 ExportService.export() 开头的校验段。

  7. 多级表头交给 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 是既有基线(断言 Druid getConnectionErrorRetryAttempts()==0,实测 1,与本次改动无关)。
  • 前端改动文件 oxlint 0 error。既有基线失败:fms-module-list-page.spec.js 3 项 (断言的 .filter-drawer / .filter-row__* 在 src/ 里已不存在)、 module-pref.spec.js 2 项(断言的默认布局「非 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(建议按此顺序)

  1. 普通导出语义 已改为当前页:无勾选时导出当前页(前端补传 rangeStart/rangeEnd), 合计行即当页合计;有勾选时仍导出勾选行。批量导出(高级导出)按所选区间/分卷聚合合计。

  2. 合计行与分组表头的配置入口 已完成:模块管理 → 列表配置 → 批量编辑已支持 「合计」列(无 / 求和 / 计数),写回 s_module_schema.view 的列节点 summary; 列表页底部已显示两行合计(该页合计 + 总合计),导出文件也按范围输出对应合计行。 分组表头本来就能在画布里配,不需要新做,只要配了就会生效。

  3. 批量导出弹窗 已完成(见第 7 节)。后端 columns / rangeStart / rangeEnd / volumeSize 与「保存导出列」都已落地并验证。

  4. 前端按 canPower(moduleId, 'export') 隐藏导出入口

    • 现在没做,无权限用户会看到入口、点了才报错。后端已拦截,属于体验问题。
    • 做之前要确认 canPower 对「数据模块编码」的判定是否符合预期 (canPower 内部先查 canAccess(scopeCode),而页面路由的 meta.moduleId 与 dataModule.b_id 未必是同一个编码,存在把有权限的用户也隐藏掉的风险)。
  5. 字段权限的前端缺口:列表目前只用 b_query 过滤查询条件, 没有用 b_view 过滤列(FmsModuleListPage 里 activeFields = fields 是全量)。 设计文档 14.3 要求 b_view=0 的字段在列表/表单/查询/导出中统一移除。 导出这一侧已经做了,列表那侧没做,于是会出现「列表能看到某列、导出里没有」的不一致。 当前 s_user_field_power 是空表所以无感,但应该补。

  6. 批量导出的条件编辑:目前弹窗里条件只读(显示「沿用列表当前条件」)。 若要在弹窗内改条件,正确做法是复用现有高级查询面板,而不是另写一套字段表单 —— 见第 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 行为,不是笔误):
    1. 0 行导出会生成「没有任何工作表」的空 xlsx —— Fesod 只在 write 被调用时才创建工作表, 所以最后一批即使为空也必须调用一次。已修,并加了「0 行仍出表头」的用例。
    2. 合计行的「合计」标签原来由 POI 版本写在首列;换成 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。
  • 既有测试基线失败:后端 OrgDataSourceFactoryTests 1 项;前端 fms-module-list-page.spec.js 3 项(断言的 .filter-drawer / .filter-row__* 在 src/ 里已不存在)。