diff --git a/code/fms/.tmp-probe/P5.java b/code/fms/.tmp-probe/P5.java new file mode 100644 index 00000000..77653560 --- /dev/null +++ b/code/fms/.tmp-probe/P5.java @@ -0,0 +1,20 @@ +import java.sql.*; +public class P5 { + public static void main(String[] a) throws Exception { + String url="jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; + try (Connection c=DriverManager.getConnection(url,"sa","zhang520..")) { + System.out.println("--- 配了 summary 的 view schema ---"); + try (Statement s=c.createStatement(); ResultSet r=s.executeQuery( + "SELECT b_module_id, b_schema_json FROM s_module_schema WHERE b_schema_type='view' AND b_schema_json LIKE '%summary%'")) { + int n=0; + while (r.next() && n++<8) System.out.println(r.getString(1)+" => "+r.getString(2)); + if (n==0) System.out.println("(没有任何模块配了 summary)"); + } + System.out.println("--- 含 type:group 的 view schema 数 ---"); + try (Statement s=c.createStatement(); ResultSet r=s.executeQuery( + "SELECT COUNT_BIG(1) FROM s_module_schema WHERE b_schema_type='view' AND b_schema_json LIKE '%\"group\"%'")) { + r.next(); System.out.println(r.getLong(1)); + } + } + } +} diff --git a/code/fms/.tmp-probe/Probe4.java b/code/fms/.tmp-probe/Probe4.java new file mode 100644 index 00000000..d8a09cfa --- /dev/null +++ b/code/fms/.tmp-probe/Probe4.java @@ -0,0 +1,51 @@ +import java.sql.*; + +/** 一次性探针:复现 COUNT_BIG 报错。用完即删。 */ +public class Probe4 { + public static void main(String[] args) throws Exception { + String url = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; + try (Connection conn = DriverManager.getConnection(url, "sa", "zhang520..")) { + + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT_BIG(1) FROM [b_con_type]")) { + rs.next(); + System.out.println("Statement 无别名: " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("Statement 无别名 失败: " + e.getMessage()); + } + + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT_BIG(1) AS total FROM [b_con_type]")) { + rs.next(); + System.out.println("Statement 有别名: " + rs.getLong(1) + " / getLong(\"total\")=" + rs.getLong("total")); + } catch (SQLException e) { + System.out.println("Statement 有别名 失败: " + e.getMessage()); + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT_BIG(1) FROM [b_con_type]"); + ResultSet rs = ps.executeQuery()) { + rs.next(); + System.out.println("PreparedStatement 无别名 getLong(1): " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("PreparedStatement 无别名 失败: " + e.getMessage()); + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT_BIG(1) AS total FROM [b_con_type]"); + ResultSet rs = ps.executeQuery()) { + rs.next(); + System.out.println("PreparedStatement 有别名 getLong(1): " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("PreparedStatement 有别名 失败: " + e.getMessage()); + } + + System.out.println("列类型: " + columnType(conn, "SELECT COUNT_BIG(1) FROM [b_con_type]")); + } + } + + private static String columnType(Connection conn, String sql) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { + ResultSetMetaData md = rs.getMetaData(); + return md.getColumnLabel(1) + " / " + md.getColumnTypeName(1) + " / type=" + md.getColumnType(1); + } + } +} diff --git a/code/fms/.workbuddy-ai/memory/2026-09-16.md b/code/fms/.workbuddy-ai/memory/2026-09-16.md index bf148d02..dfc04551 100644 --- a/code/fms/.workbuddy-ai/memory/2026-09-16.md +++ b/code/fms/.workbuddy-ai/memory/2026-09-16.md @@ -141,3 +141,352 @@ **验证**:`pnpm lint` 无新增问题(唯一 error 是既有 `createCellRenderer.old.js` 的 `no-unused-vars`,与本改动无关);`pnpm build --outDir dist-verify --emptyOutDir` 通过。 + +## 表单设计器「数据视图」栅格宽度改为普通数字输入 + +**需求**:模块管理 → 表单设计 → 数据,`栅格宽度` 列不要下拉、不要「整行 24/24」文案,直接显示数字。 + +**改动**(`components/design-editor/FormDesignEditor.vue`): +- `formDataColumns()` 里 `b_colspan` 的 `editor` 由 + `{ type:'select', options: () => COLSPAN_OPTIONS }` 改为 + `{ type:'input', inputType:'number', onChange: (v, row) => { row.b_colspan = clamp(1..24) } }`, + 列宽 120 → 100。仅做边界收敛,不做文案换算。 +- 删掉 `form/useFormGrid.js` 的 `COLSPAN_OPTIONS` 导出(唯一使用点已移除)及对应 import。 + +**注意**:`设计`视图属性面板(`FormInspector.vue`)的栅格宽度早就是普通 Input, +**数据视图**这一列是最后残留的下拉。`列表配置`(TableDesignEditor,mode=list)的 +数据视图只有「列宽 b_width」,本来就是数字输入,无同类问题。 + +**联动行为未改**:设计视图改栅格宽度仍会压缩同行其它字段(`onColspanChange`), +这是保证逻辑行与视觉行一致的必要逻辑;数据视图改值不触发联动。 + +**验证**:改动文件 oxlint 0 error;`tests/unit/{form-edit-panel-mount,module-table-edit-panel-dnd,module-list-config-panel-dnd,schema-render}.spec.js` 25 passed。 + +## 设计器视图切换改名:「数据」→「批量编辑」 + +**背景**:用户问「数据这部分这样显示合适,还是放 modal」。结论是**保持内联切换**, +modal 只适合短事务,而这里是 10 列批量编辑网格;`ui/modal/modal.vue` 默认宽 520px +且自带全屏按钮,本身就说明装不下。真痛点其实是命名 —— 「数据」会被理解成录入业务数据。 + +**改名**:`VIEW_OPTIONS` 第二项 `数据` → `批量编辑`(`value` 仍是 `'data'`,不动状态机)。 +只改第二项,「设计」保留,减少肌肉记忆成本。没选「属性表」是因为会和设计视图右侧 +「属性」面板撞概念。 + +**落地**: +- `FormDesignEditor.vue` / `TableDesignEditor.vue` 的 `VIEW_OPTIONS` 各加 `title`, + 并用 `Segmented` 的 `#label` 插槽渲染 ``(照抄同文件 `EDIT_MODE_OPTIONS` 写法)。 + `Segmented` 插槽传的是 `option.raw`(原始对象),`segmented-item-text` 这个类**没有任何 CSS**, + 所以自定义 span 与默认渲染等价,只是多了 title。 +- 同步注释里的「数据视图」→「批量编辑视图」:两个编辑器 + `EditDataView.vue` + `schemaModel.js`。 + UI 上的「返回设计视图」按钮文案保留。 + +**结论(后续别再提 modal)**:内联切换的另一个硬理由是 `selectedNodeKey ↔ activeKey` 已互通, +切视图不丢选中字段;modal 会打断「同一上下文两副镜头」。若哪天真要画布同时可见, +正确解法是照 `components/fms-module-list/FmsQuerySettingsDrawer.vue` 用 Drawer, +但要把「改完即写草稿」改成「草稿+保存」,代价大于收益。 + +**验证**:design-editor 目录 oxlint 0 error;5 个相关测试文件 30 passed。 +`module-list-components.spec.js` 的 2 个失败是既有基线(高级查询行间连接符,测试只 import +fms-module-list 组件,与本次改动无关)。`oxfmt --check` 报 EditDataView/schemaModel/useFormGrid +有格式问题,实测是 **CRLF vs LF 的行尾差异**(`diff --strip-trailing-cr` 完全相同),非本次引入。 + +## 列表配置的视图切换器移到左边(与表单设计对齐) + +**现象**:两个编辑器都用 `mm-edit__head` 三段式(left/center/right),但只有 +`TableDesignEditor` 真的有三段 —— `left` 空占位、`center` 放视图切换、`right` 放按钮, +`left`/`right` 都是 `flex: 1 1 auto`,于是切换器被挤到**中间**。 +`FormDesignEditor` 只有 `center` + `right` 两段,切换器实际贴在**最左**。 +所以同一个控件在两个页签位置不一致。 + +**改法**:把 `TableDesignEditor` 的 `mm-edit__view-switch` 移进 `mm-edit__head-left` +(放在 `编辑方式` 之前,保证两个页签的**最左控件是同一个**),删掉空的 `head-center` div +及其 CSS。`FormDesignEditor` 不动。 + +**验证方式(可复用)**:临时写 `tests/unit/.tmp-head-check.spec.js`,用 `mount` 挂 +`ModuleListConfigPanel` / `ModuleEditPanel`,断言 `.mm-edit__head-left` 内含 +`.mm-edit__view-switch` 且 `.mm-edit__head-center` 不存在,并 `console.log(head.html())` +肉眼核对结构,跑完删除。比开浏览器登录(`g3soft` 密码非空串,空串登录返回 401)快得多。 +注意:用 `#label` 插槽渲染的 span **没有** `segmented-item-text` 类,选择器要写 +`.segmented-item-label > span`(写 `.mm-edit__view-switch span` 会把外层 label span 也算进去, +每个标签命中两次)。 + +**遗留**:`FormDesignEditor.vue` 里那个 `mm-edit__head-center` 类名已名不副实(它是 +第一个 flex 子元素,视觉上就是左),改名属可选清理。 + +### 补:FormDesignEditor 的 `mm-edit__head-center` 改名(上条遗留已清) + +模板类名 `mm-edit__head-center` → `mm-edit__head-left`,删掉原 `.mm-edit__head-center` +CSS(`flex: none` + 居中),改由既有的 `.mm-edit__head-left`(`flex: 1 1 auto` + +`justify-content: flex-start`)接管;同时改掉上面那段「左右两翼挤出中间位、保证居中」 +的过期注释。**两个编辑器的 header 骨架现在完全一致**: +`head-left`(视图切换) + `head-right`(操作按钮),用临时挂载测试断言过 `LIST === FORM`。 + +## 导出 / 批量导出:参考实现调研(讨论阶段,尚未定案) + +**参考实现 `D:/workspace/code/g3hd/frontend` 有两条完全不同的导出路径:** + +1. **前端生成**(`src/utils/excel.js` 的 `ExportVxeTableExcel` / `ExportExcel`): + 纯浏览器 ExcelJS。数据源是 VXE 表格内存 —— `getCheckboxRecords()` 有勾选就用勾选, + 否则 `getFullData()`。支持多级表头(手算 merge)、列宽、displayFormat、页脚合计。 + **无任何权限校验**(数据已在浏览器里)。 +2. **后端生成**(`src/components/g3-excel-export/index.vue` + 后端 `ExcelController`): + 「导出所有页」两步向导。步骤1 列配置表存旧表 **`s_columnexcel`**(per view + userid: + col_FieldName/col_Caption/col_Width/col_Visible/ShowSummary);步骤2 按 `limit=10000` + 把 total 切段,逐段 `POST /excel/exportAll`(EasyExcel),前端 JSZip 打包 zip。 + 后端 SQL 用 `select identity(int,1,1),* into #tmptj from view ... where indexid between a and b` + 拿行号做区间切片。 + +**旧实现的坑(别照搬)**: +- `handleDownloadZip` 存的是 `mx_mapfilename`,释放时却 `revokeObjectURL(file.url)` → blob URL 从未释放。 +- 列清单由前端传后端照单全收 → 字段权限形同虚设。 +- `#tmptj` 临时表并发导出会让 tempdb 膨胀。 + +**新系统现状(已核实)**: +- 前端**无**任何导出代码;列表页 `FmsModuleListPage.vue` 工具栏已有「更多操作」Dropdown + (刷新数据 / 恢复栏位设置 / 保存栏位设置),导出入口可挂这里。 +- 后端 **无 POI / EasyExcel 依赖**(pom 里没有)。 +- **后端完全没有权限过滤**:`s_user_field_power` / `s_user_data_power` / `s_power` 在 + fms-api 里**零命中**。目前只有 JWT 鉴权 + 机构数据源路由,任何登录用户都能直接 + POST `/api/data/loaddata` 取全表。→ 导出会是第一个把「后端权限出口」变成刚需的功能。 +- 设计文档**已定义**:`s_user_field_power.b_export`(字段级导出权限)、`b_view=0` 的字段 + 在导出中统一移除(14.3 节);开发规范第 10 条要求字段可见/可查询/可导出权限必须在 + 后端 SQL 落实。`export` 已是标准动作权限点(`ModulePowerPanel.ACTION_NAMES`), + 编码格式 `action.{moduleId}.export`。 +- 配置载体现成:`s_module_schema.view`(系统默认,columns 已含 field/visible/width/format, + 支持 `type:'group'` 分组表头)+ `s_user_module_pref.view`(个人增量覆盖)。 + **不需要新建 s_columnexcel 之类的表。** + +**推荐方向(待用户拍板)**:后端生成 xlsx(一次请求 + 流式游标 + SXSSF), +导出列/合计复用 view schema,前置补 `SqlPermissionBuilder`(动作权限 + 字段白名单 + +数据范围 AND)。详见当次回复。 + +### 导出设计:用户已明确的四条约束 + +1. 「批量导出」= **全量导出**,单模块,不需要多模块同时导。 +2. **普通导出必须与列表一致**(列/条件/排序沿用列表);**高级导出**可选列并**保存导出列**。 +3. **先不做行数上限**。 +4. 合计行 + 多级表头**都必须**。 + +### 第二个参考实现:`D:/workspace/code/container-ocr` + +- 前端 `ocr-vue/src/views/container/high-download-dialog.vue`(652 行)「高级下载」: + 弹窗**自带独立查询条件**(上传日期区间/箱号/操作员,条件为空即全部)、顶部节流 300ms + 实时 `COUNT(*)` 显示「共 N 条」、模式三选一(全部导出 / 按区间导出 / 分页拆分导出)、 + 重置 + 下载、JSZip + file-saver 打包 zip。 +- 后端 `ocr-springboot` `POST /data/range`(`DataListController` → `DbUtils.range`): + `SELECT FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) AS row_num, t.* FROM view t + WHERE 1=1 AND ) sub WHERE sub.row_num BETWEEN ? AND ?`,**1 基闭区间**。 + **比 g3hd 的 `#tmptj` 临时表干净,可直接借鉴**;无 ORDER BY 时用 `ORDER BY (SELECT NULL)` 兜底。 + 注意它把 `validateFinalSql` 里的非法关键字检查**注释掉了**,别跟着抄。 + +### 我给出的方案(待确认) + +- 两个入口:普通导出(不弹窗,列/条件沿用列表,范围=勾选行或当前页);批量导出(弹窗,参考 ocr-vue 形态 + 选列 + 保存列 + 三模式)。 +- **后端是唯一出口,且后端自己按 moduleId 读 `s_module_schema.view` + `s_user_module_pref` 推导导出列**,不信任前端传的列清单(否则字段权限形同虚设)。这同时天然保证「与列表一致」。 +- 保存导出列:`s_user_module_pref` 加 `b_schema_type = 'export'`,零新表。 +- 合计行/分组表头来自 view schema(列节点加 `summary`,group 节点映射 EasyExcel 的 `List>` head)。 +- 不上限的替代保护:全局导出并发闸门 + 每用户 1 个 + 语句超时 + 流式写 + 引导用分卷模式。 +- 接口 `POST /api/export/list`(moduleId / searchCondition / orderBy / rowKeys+keyField / columns? / rangeStart+rangeEnd? / volumeSize? / withSummary / format)。 + +## 导出 P0 落地(后端权限出口 + 导出接口 + 前端导出按钮) + +用户确认了三个设计问题后开工:导出范围规则(有勾选导勾选、否则当前条件全部)、 +保存导出列存 `s_user_module_pref`(`b_schema_type='export'`,P1 用)、高级导出条件区复用现有查询面板。 + +**新增文件** +- `fms-api/.../service/SqlPermissionService.java` —— 三层权限(动作/字段/数据范围), + 是「后端 SQL 权限出口」的第一个实现,后续列表查询复用。 +- `fms-api/.../service/ExportSchemaService.java` —— 按 moduleId 读 s_module + s_field + + s_module_schema.view,按**与列表相同的顺序**还原导出列(含 group 节点 → 多级表头、summary → 合计)。 +- `fms-api/.../service/ExportService.java` —— 拼 SQL(条件 + 数据范围 + 勾选行全 AND)、 + SQL 层聚合合计、JDBC 游标 + POI SXSSF 流式写;全局并发 2 / 每用户 1。 +- `fms-api/.../service/ExportHeaderWriter.java` —— 多级表头:**分组只横向合并、叶子只纵向合并**, + 靠「分组层级必浅于叶子」保证合并区域永不重叠。 +- `fms-api/.../controller/ExportController.java` —— `POST /export/list`。 +- 测试:`ExportServiceTests`(14 项,SQL 拼装 + 合并规则)、`ExportServiceIntegrationTests`(3 项,连真实库)。 + +**修改** +- `pom.xml` 加 `poi-ooxml:5.2.5`;`DbUtils` 暴露 `buildSafeOrderBy`(原私有 buildPageOrderBy 的薄包装)。 +- 前端 `services/exportService.js`(blob + `filename*` 解析 + 错误 JSON 识别)、 + `services/http.js` 响应拦截器加 blob 直通、`FmsModuleListPage` 抽出 `buildSearchCondition()` + 供取数与导出共用(这才是「与列表一致」的落地方式),工具栏加「导出」按钮。 + +**写测试时的两个发现(实现是对的,是我的预期错了)** +1. 两级表头里叶子已在最后一行,**不该**再纵向合并 —— 否则会与分组的横向合并区域重叠。 +2. 叶子标题写在自己所在层级并向下合并,不是写在第一行。 + +**验证** +- 后端:`ExportServiceTests` 14 passed、`ExportServiceIntegrationTests` 3 passed + (真实库导出 → xlsx 可被 POI 解析、表头/数据行正确、勾选行只出 1 行、模块不存在时响应体为空即未写流)。 +- 全量后端 46 项,仅 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` 失败(既有基线)。 +- 前端:改动文件 oxlint 0 error;`fms-module-list-page` 3 个失败均为引用已不存在的 + `.filter-drawer` 的过期断言(既有基线)。 + +**未做(留给 P1)**:批量导出弹窗(条件区/条数/三模式/选列/保存列)、前端按 `canPower` 隐藏导出按钮、 +合计与分组表头在模块管理里的配置入口(现在没有任何模块配 `summary` / `group`,所以线上看不到合计行)。 + +## 导出功能交接文档 + +新建仓库根 **`FMS导出功能设计与进度.md`**(与其它设计文档同级,226 行 / 9 节), +内容:现状一句话 → 已完成文件清单 → 接口契约 → 设计决策与理由 → 怎么验证(含本机 mvn 的可行命令) +→ 数据库现状(解释为什么看不到合计行)→ 未完成(P1/P2 + 已知口径取舍)→ 批量导出弹窗设计 → 附元数据事实。 + +**用户明确**:合计行与分组表头**以后再说**,所以 P1 第一项(合计/分组的配置入口)暂缓, +下一步应优先批量导出弹窗(后端 `columns` / `rangeStart` / `rangeEnd` 参数已实现,弹窗可直接对接)。 + +### 澄清:普通导出现在就是「全量」,不是当前页 + +用户问「批量导出还没做吗」,核对代码时发现一个**与最初讨论不一致的事实**: +`ExportService.buildSelectSql` 只在传了 `rangeStart`/`rangeEnd` 时才加 `OFFSET/FETCH`, +不传就是不限行数 → 前端「导出」按钮(不传区间)实际导的是**当前查询条件下的全部行**。 +已把 `ExportServiceIntegrationTests` 的断言从 `getLastRowNum() >= 1` 收紧为 +`== rowCountOfModule()`(`b_con_type` 8 行 → 导出 8 行数据),跑通确认。 + +**影响**:「批量导出」剩下的差异只有 选列 / 区间 / 分卷 / 保存导出列 / 条数预览, +数据范围与普通导出相同。两种走法待产品定: +(a) 保留现状(一键全量,批量导出靠选列分卷区分);(b) 普通导出改当前页(前端补传当前页区间,后端已支持)。 +已写进 `FMS导出功能设计与进度.md` 第 0 节与 6.1 第 0 项,**未定之前不要动**。 + +同时给文档补了 **第 9 节「实施过程中踩到的坑」**:环境(start.cmd 路径错 / mvn 起不来 / 8088 是旧实例 / +登不进浏览器)、选型(EasyExcel→POI 的原因)、实现(排序校验找错口、多级表头合并规则第一版想错)、 +数据权限现实(权限表全空、无模块配 summary/group)、顺带发现的既有问题。 + +## Excel 库换成 Apache Fesod (Incubating) + +**需求**:把第一版的 POI SXSSF 换成 Apache Fesod(EasyExcel → FastExcel → Apache Fesod 的续作)。 + +**坐标**(`org.apache.fesod:fesod-sheet:2.0.2-incubating`,支持 jdk8–jdk25): +自带 POI 5.5.1 / commons-csv / ehcache,**不要再单独引 POI**。包名 `org.apache.fesod.sheet.*`, +入口类 `FesodSheet`(同时保留 `EasyExcel` / `FastExcel` 兼容类名),API 与 EasyExcel 一致。 +**查 API 不要靠记忆**:`jar tf` + `javap` 直接看 D:/data/maven 里下下来的 jar 最快。 + +**改动** +- 删 `ExportHeaderWriter.java`(手写合并区域那段),新增 `ExportHeadBuilder`(表头树 → + `List>`)、`ExportStyleHandler`(`CellWriteHandler`:表头/数据/合计三样式 + 逐列列宽)、 + `ExportWorkbookWriter`(表头 + 分批明细 + 合计行 → xlsx,行来源抽象成 `RowSource`, + 所以能脱离数据库单测)。 +- `ExportService`:写出层重写;`applyValue` → `normalizeValue`(时间落文本、其余保留原类型); + 游标改成「先 `executeQuery()` 再设响应头」,这样 SQL 报错仍能返回 JSON。 +- 多级表头合并交给 `automaticMergeHead(true)`;`ExportServiceTests` 里那 4 个合并区域断言 + 换成表头结构断言;新增 `ExportWorkbookWriterTests`(6 项)。 + +**被自己的测试抓到的两个 Fesod 行为坑(已修)** +1. **0 行导出会生成「没有任何工作表」的空 xlsx** —— Fesod 只在 `write` 被调用时才创建工作表, + 所以最后一批即使为空也必须调一次。 +2. 合计行是拼出来的数据行,「合计」标签要显式构造(落在第一个无聚合值的列)。 + +**验证**:导出相关 23 项全过(14 + 6 + 3,含连真实库的端到端);后端全量 52 项, +唯一失败仍是既有基线 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`。 +文档 `FMS导出功能设计与进度.md` 已同步(§1.1 文件清单 / §3.5 §3.7 决策 / §4.2 计数 / §9.2 §9.3 踩坑)。 + +## 批量导出弹窗(P1 第 2 项)完成 + +**需求**:导出按钮改成只显示图标;然后做批量导出功能。 + +**后端新增** +- `POST /api/export/count` → `{total}`。**单独做而不是复用列表分页的 total**,因为它必须走 + 与导出完全相同的权限链路(动作权限 + 字段裁剪 + 数据范围),列表分页的 total 不受数据范围约束。 +- `volumeSize` 分卷:一次请求后端切卷打包 zip(每卷一个 xlsx,命名 `{模块名}_{起}-{止}.xlsx`), + 与区间互斥、逐卷不输出合计。`ExportRequest` 加了 `volumeSize`(现在 10 个字段)。 + 与旧系统 `g3hd`「前端切段 N 次请求 + 前端 JSZip」的做法不同 —— 前端不用管进度与重试。 +- `ExportService` 重构出 `prepare()`(权限 + 列推导 + WHERE/ORDER 共用)与 + `countRows()`;`buildSelectSql` 加 `paged` 参数(区间与分卷共用 OFFSET/FETCH)。 + +**前端新增/改动** +- `FmsExportDialog.vue`:选列(默认全选或已保存的列,可拖拽排序)+ 三种方式(全部/区间/分卷)+ 保存配置。 + **条件只读**(沿用列表条件),不另写一套条件表单。 +- `useModulePref.js`:`loadModulePref`/`saveModulePref` 加 `schemaType` 参数(默认 `query`), + 供「我的导出列」复用;个人导出列存 `s_user_module_pref` 的 `b_schema_type='export'`, + 「恢复列表列」= 删除该行覆盖。 +- `FmsModuleListPage`:导出按钮改纯图标(`class="list-toolbar__icon"`,间距回到 4px); + 「更多操作」加「批量导出」;抽出 `exportColumns`(列表可见列 + 字段名称)传给弹窗。 +- 测试 `tests/unit/fms-export-dialog.spec.js`(8 项,一次通过)。 + +**踩坑(都写进文档 §9.3 了)** +- 测试里用 `b_id = N'绝不存在的值'` 构造「查不到」,SQL Server 报 + `Error converting data type nvarchar to bigint` —— **`b_con_type.b_id` 是 bigint**(业务表用雪花 ID), + 不是字典表那种 varchar。教训:构造测试条件前先确认列的真实类型。 +- 二进制响应不要调 `response.setCharacterEncoding`,否则 content-type 变成 + `application/zip;charset=UTF-8`。 + +**验证**:后端 54 项(唯一失败仍是既有基线 `OrgDataSourceFactoryTests`); +导出相关 25 项全过(14 + 6 + 5,含真实库的导出/计数/分卷);前端弹窗 8 项全过。 +既有前端基线失败:`fms-module-list-page.spec.js` 3 项(`.filter-drawer` 已不存在)、 +`module-pref.spec.js` 2 项(默认布局断言与实现不一致,测试未跟上)。 + +### 批量导出弹窗改版(用户反馈「看起来很乱」) + +**改动**(`FmsExportDialog.vue` 重写) +- **删掉全部说明性文字**:模式说明(单个文件 / 只导第 N 到 M 条 / 每 N 条一个文件打包 zip)、 + 条件说明(沿用列表当前条件)、底部那段「分组表头与合计行按列表配置生成…」全删。 + 顶部原来那条 summary 也去掉,条数挪到 footer 左侧。 +- **导出列改竖排列表 + 可拖拽排序**(`vuedraggable`,项目已有依赖,设计编辑器同款; + `handle=".export-dialog__handle"` + GripVertical 图标)。显示顺序即导出顺序。 +- **动作从 4 个减到 2 个**:「全选」「保存为我的导出列」。 + 关键洞察:候选集就是列表可见列,所以**「全选」等价于原来的「恢复列表列」**(顺手清个人覆盖); + 「反选」删掉。 +- **导出方式用 `Segmented`** 替代三个带说明的 radio;参数行只在需要时出现,且去掉「第/条」等冗余字。 +- 个人保存的列:已保存的按保存顺序排前面并勾上,其余列补在后面(不勾选),这样用户还能勾回来。 + +**踩到的坑**:`ui/modal` 的 `footer` prop 为 false 时**整个 footer 容器(含 `#footer` 插槽)都不渲染**, +不是「隐藏默认按钮、保留插槽」。用 `#footer` 自定义按钮时必须让 `footer` 保持默认 true。 +(测试里表现为 `footerButtons()[1]` undefined。) + +**验证**:`tests/unit/fms-export-dialog.spec.js` 8 项全过(新增「保存顺序优先 + 未保存列补在后面」 +的断言;「全选」断言 emit 空数组)。**拖拽手势本身没法在 jsdom 里模拟** —— 只验证了 +「渲染顺序 = 数组顺序 → 导出列顺序」这条链路,SortableJS 的实际拖动依赖真浏览器, +但它与设计编辑器用的是同一个库同一套写法。 + +### 批量导出弹窗:导出方式参数行对齐 + +用户反馈「分卷的也出现在下面」。原因是参数行原来作为 inline span 跟在 `Segmented` 后面, +靠 flex-wrap 决定换不换行 —— 区间参数短、常留在行内,分卷参数长(「每 5000 条一个文件,共 2 个」) +才换行,两种模式位置不一致。 + +改法:`导出方式` 改成「左标题 + 右控件列」的 grid,控件列里 `Segmented` 与参数行纵向排列, +于是**区间与分卷的参数都固定单独占一行、与 Segmented 同列对齐**。 +纯布局改动,`tests/unit/fms-export-dialog.spec.js` 8 项仍全过(选择器 `.export-dialog__params input` 未变)。 + +### 批量导出弹窗:导出方式布局再修 + 按钮文案 + +用户要求:① 导出方式 label 与 Segmented 同一行;② 填写的参数单独一行;③ 主按钮文案「下载」→「导出」。 + +上一版把 Segmented 与参数行放进了一个「控件列」div(flex column),再用 `align-items: center` +对齐 grid —— 结果标题是相对**整个控件列**(两行高)垂直居中的,看起来就掉到两行中间了。 +改成**三个 grid 项平铺**:标题(row1 col1)、Segmented(row1 col2)、参数行(row2 col2), +各居自己所在行,标题自然与 Segmented 同行,参数行单独一行且与 Segmented 左对齐(无需硬编码缩进)。 +`tests/unit/fms-export-dialog.spec.js` 补了一条结构断言锁住这个布局(9 项全过)。 + +### 批量导出弹窗:Segmented 不要 100% 宽 + +`导出方式` 的两列 grid 里,Segmented 作为 grid 项默认 `justify-self: stretch`, +被拉伸到整列宽(看起来占满一行)。加 `.export-dialog__modes { justify-self: start }` 按内容宽度靠左; +参数行同理(`display: inline-flex` 在 grid 里也会被拉伸),一并加 `justify-self: start`。 +**通用提醒**:grid 里的 `display:flex/inline-flex` 子元素默认都会被拉伸,想按内容宽度必须显式 `justify-self: start`。 + +### 批量导出弹窗:按钮改名 + 列区加高 + +- 「保存为我的导出列」→「**保存配置**」(4 个字,与旁边的「全选」等宽,动作区更紧凑)。 + 测试里的按钮文案查找同步改了。 +- 导出列列表 `max-height` 220px → **340px**(约 10 行)。 + +### 列表合计:该页合计 + 总合计(table footer 两行) + +1. **schemaRender.js**:`buildListConfig` 透传 `node.summary`(`sum`/`count`)。 +2. **FmsTable.vue**:新增 `footerData` / `footerConfig` props 传给 `StkTable`; + 加 `:deep(.stk-footer) td` 样式(上边框 + 背景 + 加粗)。 +3. **FmsModuleTable.vue**: + - 新增 `grandTotals` prop(后端 `/export/totals` 返回的全量聚合)。 + - `computed footerData`:按 `listConfig.summary` 从 `dataSource` 实时算「该页合计」; + 第一列显示标签("该页合计"/"总合计"),其余列按字段类型格式化(money 保留 2 位)。 +4. **FmsModuleListPage.vue**: + - `fetchData` 成功后异步调 `loadTotalsApi` 拿总合计。 + - 普通导出(toolbar 按钮)改为**仅导出当前页**(无勾选时补传 `rangeStart/rangeEnd`), + 使「导出合计 = 当页合计」自洽;勾选行时仍导勾选行。 +5. **exportService.js**:新增 `loadTotalsApi`(调 `POST /export/totals`);顺手修了 `countExportApi` + 里未定义 `post` 的 bug(改为 `http.post`)。 +6. **后端**:`ExportService` 已在此前改好(区间合计走 `ROW_NUMBER` 子查询、新增 `totals()` 方法、 + `ExportController` 新增 `/export/totals` endpoint)。 + +测试:前端 lint 0 error,export 对话框 9 项全过,fms-table-virtual 11 项全过; +后端 ExportService*Tests 24 项全过;全项目基线 13 失败未增加。 diff --git a/code/fms/.workbuddy-ai/memory/MEMORY.md b/code/fms/.workbuddy-ai/memory/MEMORY.md index f23b9060..a18a4f6a 100644 --- a/code/fms/.workbuddy-ai/memory/MEMORY.md +++ b/code/fms/.workbuddy-ai/memory/MEMORY.md @@ -137,3 +137,59 @@ 复合主键用逗号分隔(如 `s_field` 传 `b_module_id,b_field`)。执行顺序 delete → update → insert。 - 模块元数据(`s_module` / `s_field` / `s_module_schema`)**不由后端接口下发**, 前端从本地 schema JSON 解析,所以改元数据后无需重启后端。 + +## 编译 / 测试 fms-api(本机 mvn 入口全是坏的,用这条) + +`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`),照抄必失败。 +`/d/devtool/maven/bin/mvn`(shell 脚本)在 Git Bash 下报 +`找不到或无法加载主类 org.codehaus.plexus.classworlds.launcher.Launcher`; +从 Bash 调 `cmd.exe` 被安全策略拦;另一个 shell 工具的输出不回流(得写文件再读)。 + +**唯一可行做法:直接用 JDK 拉起 Maven 的 classworlds launcher。** + +```bash +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=XxxTests test +``` + +- 本地仓库是 **`D:\data\maven`**(settings.xml 里改过 localRepository),不是 `~/.m2`。 +- 新依赖要联网首次下载,去掉 `-o` 即可(`repo1.maven.org` 可达)。 +- 中文乱码时输出里加 `grep -a`。 +- `target/` 被 git 跟踪,编译后会有一堆 `.class` / surefire 报告显示为 modified,属正常噪音。 + +**既有基线失败(不是自己引入的,别去修)**: +- `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` + (断言 Druid `getConnectionErrorRetryAttempts()==0`,实测 1)。 +- 前端 `tests/unit/fms-module-list-page.spec.js` 里所有引用 `.filter-drawer` / + `.filter-row__*` 的用例 —— 这些类名在 `src/` 里**已不存在**,是高级查询面板改版后没更新的过期断言。 + +## 导出功能(P0 已落地) + +- 入口:`POST /api/export/list`(`ExportController`),**返回文件流**,不是 ApiResponse。 +- 分层:`SqlPermissionService`(动作权限 + 字段白名单 + 数据范围)→ `ExportSchemaService` + (按 moduleId 读 s_module + s_field + s_module_schema.view,**与列表同源推导导出列**)→ + `ExportService`(拼 SQL、SQL 层聚合合计、JDBC 游标 + Fesod 流式写)→ + `ExportWorkbookWriter`(表头/明细/合计 → xlsx)→ `ExportHeadBuilder`(表头树 → `List>`) + + `ExportStyleHandler`(三种样式 + 逐列列宽)。 +- 关键约束:**导出列不由前端传**,否则字段权限(`s_user_field_power.b_export`)形同虚设; + **所有校验必须在写 `getOutputStream()` 之前完成**,且游标要「先 `executeQuery()` 再设响应头」, + 否则 SQL 报错时用户拿到的是半截文件而不是 JSON 错误。 +- 依赖:**`org.apache.fesod:fesod-sheet:2.0.2-incubating`**(Apache Fesod Incubating = EasyExcel 的 + Apache 续作,包名 `org.apache.fesod.sheet.*`,入口类 `FesodSheet`,API 与 EasyExcel 一致, + 自带 POI 5.5.1,**不要再单独引 POI**)。多级表头合并用 `automaticMergeHead(true)`, + 各列标题层数可不同(Fesod 底部对齐补空,列名落在最后一行)。 + **两个 Fesod 行为坑**:① 0 行导出必须仍调用一次 `write`(哪怕传空集合),否则生成的 xlsx + **没有任何工作表**;② 合计行是拼出来的数据行,「合计」标签要自己构造(落在第一个无聚合值的列)。 +- 合计行只在 view schema 的列节点带 `summary: sum|count` 时才出现;**目前没有任何模块配了它**, + 所以线上看不到合计行(多级表头同理,没有模块配 `type:group`)。 +- 前端:`services/exportService.js` + `FmsModuleListPage` 工具栏「导出」按钮; + `services/http.js` 的响应拦截器加了 blob 直通分支(下载类响应要能读 headers)。 +- 权限现状:`s_power` 只有 2 行(`s_i18n_type`),`s_user_field_power` / `s_user_data_power` + 都是空表。非超管走白名单语义会被拒(「没有导出权限」),**g3soft 提权可直接用**。 diff --git a/code/fms/FMS导出功能设计与进度.md b/code/fms/FMS导出功能设计与进度.md new file mode 100644 index 00000000..945f6155 --- /dev/null +++ b/code/fms/FMS导出功能设计与进度.md @@ -0,0 +1,353 @@ +# 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>` 表头结构 | +| `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>` + (外层 = 叶子列,内层 = 该列自上而下的标题),合并由 `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: + +```bash +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(建议按此顺序) + +0. ~~**普通导出语义**~~ **已改为当前页**:无勾选时导出当前页(前端补传 `rangeStart/rangeEnd`), + 合计行即当页合计;有勾选时仍导出勾选行。批量导出(高级导出)按所选区间/分卷聚合合计。 + +1. ~~**合计行与分组表头的配置入口**~~ **已完成**:模块管理 → 列表配置 → 批量编辑已支持 + 「合计」列(无 / 求和 / 计数),写回 `s_module_schema.view` 的列节点 `summary`; + 列表页底部已显示两行合计(该页合计 + 总合计),导出文件也按范围输出对应合计行。 + 分组表头本来就能在画布里配,不需要新做,只要配了就会生效。 + +2. ~~**批量导出弹窗**~~ **已完成**(见第 7 节)。后端 `columns` / `rangeStart` / `rangeEnd` / + `volumeSize` 与「保存导出列」都已落地并验证。 + +3. **前端按 `canPower(moduleId, 'export')` 隐藏导出入口** + - 现在没做,无权限用户会看到入口、点了才报错。后端已拦截,属于体验问题。 + - 做之前要确认 `canPower` 对「数据模块编码」的判定是否符合预期 + (`canPower` 内部先查 `canAccess(scopeCode)`,而页面路由的 `meta.moduleId` 与 + `dataModule.b_id` 未必是同一个编码,存在把有权限的用户也隐藏掉的风险)。 + +4. **字段权限的前端缺口**:列表目前只用 `b_query` 过滤查询条件, + **没有用 `b_view` 过滤列**(`FmsModuleListPage` 里 `activeFields = fields` 是全量)。 + 设计文档 14.3 要求 `b_view=0` 的字段在列表/表单/查询/导出中统一移除。 + 导出这一侧已经做了,列表那侧没做,于是会出现「列表能看到某列、导出里没有」的不一致。 + 当前 `s_user_field_power` 是空表所以无感,但应该补。 + +5. **批量导出的条件编辑**:目前弹窗里条件只读(显示「沿用列表当前条件」)。 + 若要在弹窗内改条件,正确做法是复用现有高级查询面板,而不是另写一套字段表单 —— 见第 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.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/` 里已不存在)。 diff --git a/code/fms/fms-api/pom.xml b/code/fms/fms-api/pom.xml index be08cccf..75cd6661 100644 --- a/code/fms/fms-api/pom.xml +++ b/code/fms/fms-api/pom.xml @@ -77,6 +77,14 @@ 1.6.1 + + + org.apache.fesod + fesod-sheet + 2.0.2-incubating + + org.springframework.boot spring-boot-devtools diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java new file mode 100644 index 00000000..eb6c5979 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java @@ -0,0 +1,104 @@ +package cn.g3soft.fmsapi.controller; + +import cn.g3soft.fmsapi.service.ExportService; +import cn.g3soft.fmsapi.utils.ApiResponse; +import cn.g3soft.fmsapi.utils.ParamUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 列表导出。返回文件流而不是 ApiResponse;校验失败时在写流之前抛出, + * 由 GlobalExceptionHandler 以 HTTP 200 + JSON 业务错误返回(前端按 content-type 区分)。 + */ +@RestController +@RequestMapping("/export") +public class ExportController { + + @Resource + private ExportService exportService; + + @PostMapping("/list") + public void exportList( + @RequestBody(required = false) Map params, + HttpServletResponse response + ) throws IOException { + exportService.export(readRequest(params), response); + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 走与导出完全相同的权限链路,因此数字与真正导出的行数一致。 + */ + @PostMapping("/count") + public ApiResponse> count( + @RequestBody(required = false) Map params + ) { + Map result = new LinkedHashMap<>(); + result.put("total", exportService.count(readRequest(params))); + return ApiResponse.success(result); + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。 + */ + @PostMapping("/totals") + public ApiResponse> totals( + @RequestBody(required = false) Map params + ) { + return ApiResponse.success(exportService.totals(readRequest(params))); + } + + private ExportService.ExportRequest readRequest(Map params) { + return new ExportService.ExportRequest( + ParamUtils.getRequiredString(params, "moduleId"), + ParamUtils.getString(params, "searchCondition"), + ParamUtils.getString(params, "orderBy"), + readRowKeys(params), + ParamUtils.getString(params, "keyField"), + ParamUtils.getList(params, "columns"), + readInteger(params, "rangeStart"), + readInteger(params, "rangeEnd"), + readInteger(params, "volumeSize"), + readBoolean(params, "withSummary") + ); + } + + /** + * 勾选行主键。JSON 数字可能被解析成 Integer/Long/Double,这里统一按原值透传给 + * PreparedStatement,不做字符串化(避免数字主键变成字符串后索引失效)。 + */ + private List readRowKeys(Map params) { + List values = ParamUtils.getList(params, "rowKeys"); + if (values == null) { + return null; + } + return List.copyOf(values); + } + + private Integer readInteger(Map params, String field) { + Number number = ParamUtils.get(params, field, Number.class); + return number == null ? null : number.intValue(); + } + + /** 缺省输出合计行 */ + private boolean readBoolean(Map params, String field) { + Object value = params == null ? null : params.get(field); + if (value == null) { + return true; + } + if (value instanceof Boolean flag) { + return flag; + } + return Boolean.parseBoolean(String.valueOf(value)); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java new file mode 100644 index 00000000..ce611b90 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java @@ -0,0 +1,64 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * 把 view schema 的表头树(分组节点 + 叶子列)转成 Fesod 需要的 + * {@code List>} 表头结构:外层是列,内层是该列自上而下的各层标题。 + * + * 例:`费用 > 明细 > b_a` 这一列 → `["费用", "明细", "b_a"]`;顶层叶子 `b_d` → `["b_d"]`。 + * 各列内层长度可以不同(顶层叶子只有 1 层),Fesod 会按「底部对齐」补空行 —— + * 也就是说列名一定落在表头最后一行,这正是多级表头的常规形态。 + * + * 合并由 Fesod 的 `automaticMergeHead(true)` 负责,本类不生成任何合并区域 + * (原先手写合并的 ExportHeaderWriter 已删除)。 + */ +final class ExportHeadBuilder { + + private ExportHeadBuilder() { + } + + /** 表头结构:外层 = 叶子列(顺序与数据列一致),内层 = 该列自上而下的标题 */ + static List> headOf(List nodes) { + List> head = new ArrayList<>(); + collect(nodes, new ArrayList<>(), head); + return head; + } + + private static void collect(List nodes, List path, List> out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + List next = new ArrayList<>(path); + next.add(node.title() == null ? "" : node.title()); + collect(node.children(), next, out); + continue; + } + List column = new ArrayList<>(path); + column.add(node.column().caption()); + out.add(column); + } + } + + /** 表头层数(最长列的层数):单行表头为 1,用于冻结窗格 */ + static int depth(List nodes) { + int depth = 1; + for (ExportNode node : nodes) { + if (node.isGroup()) { + depth = Math.max(depth, 1 + depth(node.children())); + } + } + return depth; + } + + /** 叶子列数,应与数据列数一致 */ + static int leafCount(List nodes) { + int count = 0; + for (ExportNode node : nodes) { + count += node.isGroup() ? leafCount(node.children()) : 1; + } + return count; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java new file mode 100644 index 00000000..2276c8eb --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java @@ -0,0 +1,343 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 导出列来源:按模块编码读元数据,产出与列表**同一份**列定义。 + * + * 列表(FmsModuleListPage)的列是这样来的:s_module 拿查询视图 → s_field 拿启用字段与名称 + * → s_module_schema.view JSON 按文档顺序取叶子节点(分组节点递归展开)→ 过滤 visible=false + * 与未知字段。本服务照同一顺序还原,保证「普通导出与列表一致」不是两边各自实现, + * 而是同源推导;差别只在导出额外套一层字段权限(b_view / b_export)。 + * + * 列上的 summary(sum / count)与分组节点一起从 view JSON 读取,供合计行与多级表头使用。 + */ +@Service +public class ExportSchemaService { + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + /** 无配置列宽时的兜底字符宽度(Excel 列宽单位是字符数) */ + private static final int DEFAULT_COLUMN_WIDTH = 16; + private static final double PIXELS_PER_CHARACTER = 8.0; + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + @Resource + private ObjectMapper objectMapper; + + /** 导出列定义 */ + public record ExportColumn(String field, String caption, int width, String summary) { + public boolean summable() { + return "sum".equals(summary) || "count".equals(summary); + } + } + + /** 导出表头节点:children 非空即分组,column 非空即叶子列 */ + public record ExportNode(String title, ExportColumn column, List children) { + public boolean isGroup() { + return children != null && !children.isEmpty(); + } + } + + /** 一个模块的导出定义 */ + public record ExportSchema( + String moduleId, + String moduleName, + String viewTable, + String keyField, + String orderSql, + List nodes + ) { + /** 按表头顺序摊平的叶子列 */ + public List columns() { + List columns = new ArrayList<>(); + collectColumns(nodes, columns); + return columns; + } + + private static void collectColumns(List nodes, List out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + collectColumns(node.children(), out); + } else if (node.column() != null) { + out.add(node.column()); + } + } + } + } + + /** + * 读取模块的导出定义(未做字段权限过滤,由 {@link #filter} 负责裁剪)。 + */ + public ExportSchema load(String moduleId) { + String id = requireText(moduleId, "moduleId"); + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + Map module = firstRow( + connection, + "SELECT [b_id], [b_name], [b_module_type], [b_view_table], [b_key_field], [b_order_sql] " + + "FROM [s_module] WHERE [b_id] = ? AND [b_canuse] = 1", + id + ); + if (module == null) { + throw new BusinessException("模块不存在或已停用: " + id); + } + String moduleType = text(module.get("b_module_type")); + if (!"data".equals(moduleType) && !"virtual".equals(moduleType)) { + throw new BusinessException("模块不是数据模块,无法导出: " + id); + } + String viewTable = text(module.get("b_view_table")); + if (viewTable.isBlank()) { + throw new BusinessException("模块未配置查询视图,无法导出: " + id); + } + dbUtils.validateIdentifier(viewTable); + + Map captions = loadFieldCaptions(connection, id); + String schemaJson = firstString( + connection, + "SELECT [b_schema_json] FROM [s_module_schema] " + + "WHERE [b_module_id] = ? AND [b_schema_type] = 'view' AND [b_canuse] = 1", + id + ); + List nodes = buildNodes(parseColumns(schemaJson), captions); + + String keyField = text(module.get("b_key_field")); + if (!keyField.isBlank()) { + dbUtils.validateIdentifier(keyField); + } + return new ExportSchema( + id, + text(module.get("b_name")), + viewTable, + keyField.isBlank() ? null : keyField, + text(module.get("b_order_sql")).isBlank() ? null : text(module.get("b_order_sql")), + nodes + ); + } catch (SQLException exception) { + throw new BusinessException("导出配置读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 按字段权限与调用方勾选的列裁剪表头树:叶子被移除后空分组一并丢弃。 + * + * @param deniedFields 不允许导出的字段(b_view = 0 或 b_export = 0) + * @param selectedColumns 调用方勾选的字段编码;null 表示沿用列表全部列 + */ + public ExportSchema filter( + ExportSchema schema, + Set deniedFields, + List selectedColumns + ) { + Set selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns); + List nodes = filterNodes(schema.nodes(), deniedFields, selected); + if (nodes.isEmpty()) { + throw new BusinessException("没有可导出的列"); + } + return new ExportSchema( + schema.moduleId(), + schema.moduleName(), + schema.viewTable(), + schema.keyField(), + schema.orderSql(), + nodes + ); + } + + private List filterNodes( + List nodes, + Set deniedFields, + Set selected + ) { + List kept = new ArrayList<>(); + for (ExportNode node : nodes) { + if (node.isGroup()) { + List children = filterNodes(node.children(), deniedFields, selected); + if (!children.isEmpty()) { + kept.add(new ExportNode(node.title(), null, children)); + } + continue; + } + ExportColumn column = node.column(); + if (column == null) { + continue; + } + if (deniedFields.contains(column.field())) { + continue; + } + if (selected != null && !selected.contains(column.field())) { + continue; + } + kept.add(node); + } + return kept; + } + + /* ================= 元数据读取 ================= */ + + private Map loadFieldCaptions(Connection connection, String moduleId) throws SQLException { + Map captions = new LinkedHashMap<>(); + try (PreparedStatement statement = connection.prepareStatement( + "SELECT [b_field], [b_name] FROM [s_field] " + + "WHERE [b_module_id] = ? AND [b_canuse] = 1 ORDER BY [b_xh], [b_field]")) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String field = text(resultSet.getString("b_field")); + // 与列表一致:字段编码必须能安全参与 SQL 才可用 + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + continue; + } + captions.put(field, text(resultSet.getString("b_name"))); + } + } + } + return captions; + } + + /** 解析 view JSON 的 columns 数组;缺失或非法时返回空列表(导出会因无列而报错) */ + private List parseColumns(String schemaJson) { + if (schemaJson == null || schemaJson.isBlank()) { + return List.of(); + } + JsonNode root; + try { + root = objectMapper.readTree(schemaJson); + } catch (RuntimeException exception) { + throw new BusinessException("模块列表配置解析失败"); + } + JsonNode columns = root == null ? null : root.path("columns"); + if (columns == null || !columns.isArray()) { + return List.of(); + } + List nodes = new ArrayList<>(); + for (JsonNode node : columns) { + nodes.add(node); + } + return nodes; + } + + private List buildNodes(List jsonNodes, Map captions) { + List nodes = new ArrayList<>(); + for (JsonNode node : jsonNodes) { + if (node == null || !node.isObject()) { + continue; + } + if ("group".equals(node.path("type").asString(""))) { + List children = new ArrayList<>(); + JsonNode childNodes = node.path("children"); + if (childNodes != null && childNodes.isArray()) { + for (JsonNode child : childNodes) { + children.add(child); + } + } + List childResult = buildNodes(children, captions); + if (!childResult.isEmpty()) { + nodes.add(new ExportNode(node.path("title").asString(""), null, childResult)); + } + continue; + } + String field = text(node.path("field").asString(null)); + if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches()) { + continue; + } + // 与列表一致:visible=false 的节点不出现在列表,也不出现在导出 + if (node.has("visible") && !node.path("visible").asBoolean(true)) { + continue; + } + String caption = captions.get(field); + if (caption == null || caption.isBlank()) { + caption = field; + } + nodes.add(new ExportNode( + null, + new ExportColumn( + field, + caption, + toCharacterWidth(node.path("width")), + text(node.path("summary").asString(null)) + ), + null + )); + } + return nodes; + } + + /** 列表配置里的宽度是像素;Excel 列宽单位是字符数,统一在这里换算 */ + private int toCharacterWidth(JsonNode widthNode) { + if (widthNode == null || widthNode.isMissingNode() || widthNode.isNull()) { + return DEFAULT_COLUMN_WIDTH; + } + double pixels = widthNode.asDouble(0); + if (pixels <= 0) { + return DEFAULT_COLUMN_WIDTH; + } + return (int) Math.min(60, Math.max(8, Math.round(pixels / PIXELS_PER_CHARACTER))); + } + + /* ================= 小工具 ================= */ + + private Map firstRow( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + int columnCount = resultSet.getMetaData().getColumnCount(); + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + return row; + } + } + } + + private String firstString(Connection connection, String sql, Object... parameters) throws SQLException { + Map row = firstRow(connection, sql, parameters); + return row == null ? null : text(row.values().iterator().next()); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java new file mode 100644 index 00000000..5bca728c --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java @@ -0,0 +1,761 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportSchema; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 列表导出(xlsx)。 + * + * 三条硬约束: + * 1. **后端是唯一出口**:导出列由后端按 moduleId 读元数据推导(ExportSchemaService), + * 不信任调用方传入的列清单,字段权限(b_view / b_export)在推导后统一裁剪; + * 2. **权限与校验必须先于写流**:一旦开始写 {@code response.getOutputStream()} 就不能再改 + * header 与状态码,失败只会给用户一个损坏的文件。因此本服务把参数校验、动作权限、 + * 数据范围、列推导、聚合全部放在 {@link #writeWorkbook} 之前完成; + * 3. **流式**:JDBC 游标(forward-only + fetchSize)配 POI SXSSF,内存与总行数无关。 + * + * 并发保护(暂不设行数上限的替代手段):全局同时最多 2 个导出,同一用户同时 1 个。 + */ +@Service +public class ExportService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** 明细查询的游标批量:SQL Server 驱动按此批量取行,避免一次拉全量 */ + private static final int FETCH_SIZE = 500; + /** 按行范围聚合时的行号列名:取名刻意生僻,避免与业务列冲突 */ + private static final String ROW_NUMBER_COLUMN = "__fms_row_number"; + + private static final Semaphore GLOBAL_SLOTS = new Semaphore(2); + private static final Set RUNNING_USERS = ConcurrentHashMap.newKeySet(); + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + @Resource + private SqlPermissionService sqlPermissionService; + + @Resource + private ExportSchemaService exportSchemaService; + + /** + * 导出请求。 + * + * @param moduleId 模块编码(必填),后端据此读元数据与权限 + * @param searchCondition 查询条件(不含 WHERE),与列表当前条件一致;后端会再 AND 数据范围 + * @param orderBy 排序子句;缺省用模块默认排序 s_module.b_order_sql + * @param rowKeys 勾选行主键值;非空时只导出这些行 + * @param keyField 勾选行主键列;缺省用模块默认主键 s_module.b_key_field + * @param columns 勾选导出的字段;null 表示沿用列表全部列 + * @param rangeStart 按条数区间的起始(1 基,闭区间);与 rangeEnd 同时给出才生效 + * @param rangeEnd 按条数区间的结束(1 基,闭区间) + * @param volumeSize 分卷行数:> 0 时返回 zip(每卷一个 xlsx),与 range 互斥 + * @param withSummary 是否输出合计行 + */ + public record ExportRequest( + String moduleId, + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + List columns, + Integer rangeStart, + Integer rangeEnd, + Integer volumeSize, + boolean withSummary + ) { + } + + /** 一次导出请求预处理后的产物:列、WHERE、ORDER 都已定稿 */ + private record PreparedExport( + String userId, + ExportSchema schema, + List columns, + String whereSql, + String orderSql + ) { + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 与导出走**同一条**权限链路(动作权限 + 字段裁剪 + 数据范围),所以显示的数字 + * 与真正导出的行数一致 —— 不能用列表分页的总数代替,那个口径不受数据范围约束。 + */ + public long count(ExportRequest request) { + String userId = OrgContext.requireUserId(); + return countRows(prepare(request, userId), request); + } + + private long countRows(PreparedExport prepared, ExportRequest request) { + String sql = "SELECT COUNT_BIG(1) FROM [" + prepared.schema().viewTable() + "]" + + (prepared.whereSql() == null ? "" : " WHERE " + prepared.whereSql()); + dbUtils.validateReadOnlySql(sql); + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bindWhereParameters(statement, request); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getLong(1); + } + } catch (SQLException exception) { + throw new BusinessException("行数统计失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * + * 与导出**共用** {@link #prepare} 与 {@link #loadTotals},所以这个数字与导出文件里的合计行 + * 必然一致 —— 前端自己算或用别的接口算都可能对不上(数据范围、字段权限、NULL 口径都会差)。 + * + * @return 字段编码 → 聚合值;没有配 summary 的列不出现在结果里 + */ + public Map totals(ExportRequest request) { + String userId = OrgContext.requireUserId(); + PreparedExport prepared = prepare(request, userId); + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List totals = loadTotals(prepared, whereParameters, null, null); + + // loadTotals 用「空列表」表示没有可合计的列(导出侧据此决定不写合计行) + List columns = prepared.columns(); + if (totals.size() != columns.size()) { + return Map.of(); + } + + Map result = new LinkedHashMap<>(); + for (int index = 0; index < columns.size(); index++) { + BigDecimal value = totals.get(index); + if (value != null) { + result.put(columns.get(index).field(), value); + } + } + return result; + } + + public void export(ExportRequest request, HttpServletResponse response) throws IOException { + String userId = OrgContext.requireUserId(); + requireText(request.moduleId(), "moduleId"); + + if (!RUNNING_USERS.add(userId)) { + throw new BusinessException("已有导出任务正在进行,请等待完成后再试"); + } + boolean globalSlotHeld = false; + try { + globalSlotHeld = GLOBAL_SLOTS.tryAcquire(); + if (!globalSlotHeld) { + throw new BusinessException("导出任务繁忙,请稍后重试"); + } + + // ---- 以下全部在写流之前完成:任何失败都还能返回 JSON 错误 ---- + PreparedExport prepared = prepare(request, userId); + if (isVolumed(request)) { + writeVolumes(response, prepared, request); + return; + } + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + request + ); + // 与查询同一条只读校验:必须以 select 开头、无分号、无注释、无写关键字 + dbUtils.validateReadOnlySql(selectSql); + + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List parameters = new ArrayList<>(whereParameters); + if (isRanged(request)) { + parameters.add((long) request.rangeStart() - 1); + parameters.add((long) (request.rangeEnd() - request.rangeStart() + 1)); + } + + // 合计口径 = 本次导出的数据范围(当前页 / 勾选行 / 区间),与列表底部的「该页合计」一致 + List totals = request.withSummary() + ? loadTotals( + prepared, + whereParameters, + isRanged(request) ? request.rangeStart() : null, + isRanged(request) ? request.rangeEnd() : null + ) + : List.of(); + + writeWorkbook(response, prepared.schema(), prepared.columns(), selectSql, parameters, totals); + } finally { + if (globalSlotHeld) { + GLOBAL_SLOTS.release(); + } + RUNNING_USERS.remove(userId); + } + } + + /** 动作权限 → 列推导 + 字段裁剪 → WHERE / ORDER,导出与计数共用这一条链路 */ + private PreparedExport prepare(ExportRequest request, String userId) { + String moduleId = requireText(request.moduleId(), "moduleId"); + sqlPermissionService.requireAction(moduleId, userId, "export"); + ExportSchema schema = exportSchemaService.filter( + exportSchemaService.load(moduleId), + sqlPermissionService.deniedExportFields(moduleId, userId), + request.columns() + ); + List columns = schema.columns(); + return new PreparedExport( + userId, + schema, + columns, + buildWhereClause(moduleId, userId, schema, request), + resolveOrderSql(schema, request.orderBy()) + ); + } + + /** WHERE 子句的绑定值:勾选行主键(数据范围是拼进去的 SQL 片段,不带参数) */ + private int bindWhereParameters(PreparedStatement statement, ExportRequest request) throws SQLException { + int index = 1; + if (request.rowKeys() != null) { + for (Object key : request.rowKeys()) { + statement.setObject(index++, key); + } + } + return index; + } + + private List bindWhereParameters(List parameters, ExportRequest request) { + if (request.rowKeys() != null) { + parameters.addAll(request.rowKeys()); + } + return parameters; + } + + private boolean isVolumed(ExportRequest request) { + Integer volumeSize = request.volumeSize(); + if (volumeSize == null || volumeSize <= 0) { + return false; + } + if (isRanged(request)) { + throw new BusinessException("分卷导出与按区间导出不能同时使用"); + } + return true; + } + + /* ================= SQL 拼装 ================= */ + + /** + * WHERE 子句:调用方条件 + 数据范围 + 勾选行,三者一律 AND。 + * 数据范围只能收窄调用方条件,不能被其覆盖(开发规范 通用原则 10)。 + */ + String buildWhereClause( + String moduleId, + String userId, + ExportSchema schema, + ExportRequest request + ) { + List conditions = new ArrayList<>(); + String searchCondition = request.searchCondition(); + if (searchCondition != null && !searchCondition.isBlank()) { + conditions.add("(" + searchCondition.trim() + ")"); + } + String dataScope = sqlPermissionService.buildDataScopeCondition(moduleId, userId, "export"); + if (dataScope != null) { + conditions.add(dataScope); + } + if (request.rowKeys() != null && !request.rowKeys().isEmpty()) { + String keyField = resolveKeyField(schema, request.keyField()); + String placeholders = String.join(", ", Collections.nCopies(request.rowKeys().size(), "?")); + conditions.add("[" + keyField + "] IN (" + placeholders + ")"); + } + return conditions.isEmpty() ? null : String.join(" AND ", conditions); + } + + private String resolveKeyField(ExportSchema schema, String requestedKeyField) { + String keyField = requestedKeyField == null || requestedKeyField.isBlank() + ? schema.keyField() + : requestedKeyField.trim(); + if (keyField == null || keyField.isBlank()) { + throw new BusinessException("模块未配置主键字段,无法按勾选行导出"); + } + dbUtils.validateIdentifier(keyField); + return keyField; + } + + String resolveOrderSql(ExportSchema schema, String requestedOrderBy) { + String orderBy = requestedOrderBy == null || requestedOrderBy.isBlank() + ? schema.orderSql() + : requestedOrderBy.trim(); + if (orderBy == null || orderBy.isBlank()) { + return null; + } + return dbUtils.buildSafeOrderBy(orderBy); + } + + String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + ExportRequest request + ) { + return buildSelectSql(schema, columns, whereSql, orderSql, isRanged(request)); + } + + /** @param paged true 时追加 OFFSET/FETCH(按区间导出与分卷导出都需要) */ + private String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + boolean paged + ) { + String columnSql = columns.stream() + .map(column -> "[" + column.field() + "]") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(() -> new BusinessException("没有可导出的列")); + + StringBuilder sql = new StringBuilder("SELECT ") + .append(columnSql) + .append(" FROM [") + .append(schema.viewTable()) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + if (paged) { + // OFFSET/FETCH 必须有 ORDER BY;无排序配置时用常量排序占位(与分页同一取舍) + sql.append(" ORDER BY ").append(orderSql == null ? "(SELECT NULL)" : orderSql); + sql.append(" OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"); + } else if (orderSql != null) { + sql.append(" ORDER BY ").append(orderSql); + } + return sql.toString(); + } + + private boolean isRanged(ExportRequest request) { + Integer start = request.rangeStart(); + Integer end = request.rangeEnd(); + if (start == null || end == null) { + return false; + } + if (start < 1 || end < start) { + throw new BusinessException("导出区间不正确"); + } + return true; + } + + /** + * 合计行:在 SQL 层聚合(开发规范 通用原则 4),不把明细拉到内存再算。 + * 口径统一为「**导出数据的合计**」:不传范围就是全量合计,传了范围(当前页 / 区间 / 单卷) + * 就是该范围的合计 —— 这样表格底部的「该页合计」与导出文件的合计行必然一致。 + */ + private List loadTotals( + PreparedExport prepared, + List whereParameters, + Integer rangeStart, + Integer rangeEnd + ) { + List columns = prepared.columns(); + List indexes = summableIndexes(columns); + if (indexes.isEmpty()) { + return List.of(); + } + boolean ranged = rangeStart != null && rangeEnd != null; + String sql = buildTotalsSql( + prepared.schema().viewTable(), + columns, + prepared.whereSql(), + ranged ? orderByOrDefault(prepared.orderSql()) : null + ); + dbUtils.validateReadOnlySql(sql); + + List totals = new ArrayList<>(Collections.nCopies(columns.size(), null)); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object parameter : whereParameters) { + statement.setObject(index++, parameter); + } + if (ranged) { + statement.setLong(index++, (long) rangeStart - 1); + statement.setLong(index, (long) (rangeEnd - rangeStart + 1)); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + for (int position = 0; position < indexes.size(); position++) { + BigDecimal value = resultSet.getBigDecimal(position + 1); + if (value != null) { + totals.set(indexes.get(position), value); + } + } + } + } + } catch (SQLException exception) { + throw new BusinessException("合计计算失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return totals; + } + + private String orderByOrDefault(String orderSql) { + return orderSql == null ? "(SELECT NULL)" : orderSql; + } + + /** 配了 summary 的列下标(sum → SUM,count → COUNT) */ + static List summableIndexes(List columns) { + List indexes = new ArrayList<>(); + for (int index = 0; index < columns.size(); index++) { + if (columns.get(index).summable()) { + indexes.add(index); + } + } + return indexes; + } + + /** 合计查询:与明细同一条 WHERE,逐列聚合。orderSql 非空表示要按行范围聚合 */ + static String buildTotalsSql( + String viewTable, + List columns, + String whereSql, + String orderSql + ) { + List expressions = new ArrayList<>(); + for (ExportColumn column : columns) { + if (!column.summable()) { + continue; + } + expressions.add(("count".equals(column.summary()) ? "COUNT" : "SUM") + + "([" + column.field() + "])"); + } + String aggregateSql = String.join(", ", expressions); + if (orderSql == null) { + StringBuilder sql = new StringBuilder("SELECT ") + .append(aggregateSql) + .append(" FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + return sql.toString(); + } + + // 按行范围聚合(当前页 / 区间 / 单卷):先给符合条件的行编号,再对范围内的行聚合。 + // 不能直接对明细求和后按比例折算,也不能把行拉到内存里算(开发规范 通用原则 4)。 + StringBuilder inner = new StringBuilder("SELECT "); + List innerColumns = new ArrayList<>(); + for (ExportColumn column : columns) { + if (column.summable()) { + innerColumns.add("[" + column.field() + "]"); + } + } + inner.append(String.join(", ", innerColumns)) + .append(innerColumns.isEmpty() ? "" : ", ") + .append("ROW_NUMBER() OVER (ORDER BY ") + .append(orderSql == null ? "(SELECT NULL)" : orderSql) + .append(") AS [") + .append(ROW_NUMBER_COLUMN) + .append("] FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + inner.append(" WHERE ").append(whereSql); + } + return "SELECT " + aggregateSql + " FROM (" + inner + ") AS [totals_source]" + + " WHERE [totals_source].[" + ROW_NUMBER_COLUMN + "] BETWEEN ? AND ?"; + } + + /* ================= 分卷导出(批量导出的一种方式) ================= */ + + /** + * 分卷导出:按 volumeSize 切成若干 xlsx,打包成一个 zip 直接下发。 + * + * 与旧系统 `g3hd` 的做法不同:那边是前端逐段请求、前端 JSZip 打包,N 次往返 + N 个临时 + * blob;这里一次请求由后端切卷,前端只负责保存文件。每一卷用 OFFSET/FETCH 单独查, + * 单卷内存受 volumeSize 约束(所以是「有界内存」而不是「流式」,卷大小要合理)。 + * + * 每卷输出**该卷的合计**(口径与其它导出方式统一:合计 = 该文件里数据的合计)。 + */ + private void writeVolumes( + HttpServletResponse response, + PreparedExport prepared, + ExportRequest request + ) throws IOException { + int volumeSize = request.volumeSize(); + long total = countRows(prepared, request); + String sheetName = sheetNameOf(prepared.schema()); + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + true + ); + dbUtils.validateReadOnlySql(selectSql); + + long volumeCount = total == 0 ? 1 : (total + volumeSize - 1) / volumeSize; + applyResponseHeader(response, prepared.schema().moduleName(), "zip", "application/zip"); + ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8); + try { + for (long index = 0; index < volumeCount; index++) { + long offset = index * volumeSize; + int count = (int) Math.min(volumeSize, Math.max(0, total - offset)); + zip.putNextEntry(new ZipEntry(volumeEntryName(prepared.schema().moduleName(), offset, count))); + zip.write(renderVolume(prepared, request, selectSql, sheetName, offset, count)); + zip.closeEntry(); + } + zip.finish(); + } finally { + response.flushBuffer(); + } + } + + /** 单卷内容渲染到内存:卷大小已限定行数,因此内存有界 */ + private byte[] renderVolume( + PreparedExport prepared, + ExportRequest request, + String selectSql, + String sheetName, + long offset, + int count + ) throws IOException { + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + // 该卷的合计:与明细同一个行范围,口径与其它导出方式一致 + List totals = request.withSummary() && count > 0 + ? loadTotals(prepared, whereParameters, (int) (offset + 1), (int) (offset + count)) + : List.of(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + if (count <= 0) { + // 0 行也要给出一份带表头的文件,否则用户拿到空 zip 会以为导出失败 + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + emptyRowSource(), + List.of() + ); + return buffer.toByteArray(); + } + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + int index = bindWhereParameters(statement, request); + statement.setLong(index++, offset); + statement.setInt(index, count); + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + new ResultSetRowSource(resultSet, prepared.columns().size()), + totals + ); + } + return buffer.toByteArray(); + } catch (SQLException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } catch (IOException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private ExportWorkbookWriter.RowSource emptyRowSource() { + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public List next() { + throw new IllegalStateException("没有下一行"); + } + }; + } + + private String volumeEntryName(String moduleName, long offset, int count) { + String base = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + return base + "_" + (offset + 1) + "-" + (offset + count) + ".xlsx"; + } + + private String sheetNameOf(ExportSchema schema) { + return schema.moduleName() == null || schema.moduleName().isBlank() + ? "数据" + : schema.moduleName(); + } + + /* ================= 写出 ================= */ + + /** + * 打开游标 → 设响应头 → 流式写出。 + * + * 顺序很关键:**查询失败要发生在设响应头之前**,否则用户拿到的是半截文件而不是 JSON 错误。 + * 因此这里是「先 executeQuery,再 applyResponseHeader,最后 write」;代价是一旦开始写流, + * 中途的数据库异常无法再以 JSON 形式回传(流式导出的固有权衡)。 + */ + private void writeWorkbook( + HttpServletResponse response, + ExportSchema schema, + List columns, + String selectSql, + List parameters, + List totals + ) throws IOException { + String sheetName = sheetNameOf(schema); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + applyResponseHeader(response, schema.moduleName()); + ExportWorkbookWriter.write( + response.getOutputStream(), + sheetName, + schema.nodes(), + columns, + new ResultSetRowSource(resultSet, columns.size()), + totals + ); + response.flushBuffer(); + } + } catch (SQLException exception) { + throw new BusinessException("导出查询失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** JDBC 游标行来源:边读边交给 Fesod,不把结果集攒进内存 */ + private record ResultSetRowSource(ResultSet resultSet, int columnCount) implements + ExportWorkbookWriter.RowSource { + + @Override + public boolean hasNext() { + try { + return resultSet.next(); + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + } + + @Override + public List next() { + List row = new ArrayList<>(columnCount); + try { + for (int index = 1; index <= columnCount; index++) { + row.add(normalizeValue(resultSet.getObject(index))); + } + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + return row; + } + } + + /** + * 单元格值归一化:时间列落成界面同款文本(与 DbUtils 读取 ResultSet 的口径一致), + * 其余保持原类型交给 Fesod 写出(数字仍是数字,Excel 里可继续参与计算)。 + */ + static Object normalizeValue(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().format(DATE_TIME_FORMATTER); + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate().format(DATE_FORMATTER); + } + if (value instanceof java.sql.Time time) { + return time.toLocalTime().format(TIME_FORMATTER); + } + if (value instanceof LocalDateTime dateTime) { + return dateTime.format(DATE_TIME_FORMATTER); + } + return value; + } + + /* ================= 响应 ================= */ + + private void applyResponseHeader(HttpServletResponse response, String moduleName) throws IOException { + applyResponseHeader( + response, + moduleName, + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + } + + private void applyResponseHeader( + HttpServletResponse response, + String moduleName, + String extension, + String contentType + ) throws IOException { + String baseName = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + String fileName = baseName + "_" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + "." + extension; + String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setStatus(HttpServletResponse.SC_OK); + // 二进制响应不设 charset:加了会让 content-type 变成 "application/zip;charset=UTF-8" + response.setContentType(contentType); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition"); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java new file mode 100644 index 00000000..b2b2117e --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java @@ -0,0 +1,131 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import org.apache.fesod.sheet.write.handler.CellWriteHandler; +import org.apache.fesod.sheet.write.handler.context.CellWriteHandlerContext; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.List; + +/** + * xlsx 单元格样式与列宽:表头(灰底加粗居中)、数据行、合计行(灰底加粗)三种, + * 外加按列表配置换算出来的逐列宽度。 + * + * 样式在 {@code afterCellDispose} 里统一落地(Fesod 最后一个单元格钩子),因此一定覆盖 + * 库的默认样式。合计行只能**事后标记**:数据是流式写出的,写之前不知道总行数, + * 所以由 {@link ExportWorkbookWriter} 在写出合计行之前调用 {@link #markTotalRow(int)}。 + */ +final class ExportStyleHandler implements CellWriteHandler { + + /** Excel 列宽单位是「字符数」× 256 */ + private static final int WIDTH_UNIT = 256; + + private final int[] columnWidths; + private final boolean[] widthApplied; + + private CellStyle headerStyle; + private CellStyle dataStyle; + private CellStyle totalStyle; + private int totalRowIndex = -1; + + ExportStyleHandler(List columns) { + this.columnWidths = new int[columns.size()]; + this.widthApplied = new boolean[columns.size()]; + for (int index = 0; index < columns.size(); index++) { + columnWidths[index] = columns.get(index).width(); + } + } + + /** 合计行的行号(表头层数 + 明细行数);写合计行之前调用 */ + void markTotalRow(int rowIndex) { + this.totalRowIndex = rowIndex; + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + Cell cell = context.getCell(); + if (cell == null) { + return; + } + applyColumnWidth(context, cell); + cell.setCellStyle(styleOf(context)); + } + + private void applyColumnWidth(CellWriteHandlerContext context, Cell cell) { + Integer columnIndex = cell.getColumnIndex(); + if (columnIndex == null || columnIndex < 0 || columnIndex >= columnWidths.length) { + return; + } + if (widthApplied[columnIndex]) { + return; + } + Sheet sheet = context.getWriteSheetHolder().getSheet(); + sheet.setColumnWidth(columnIndex, columnWidths[columnIndex] * WIDTH_UNIT); + widthApplied[columnIndex] = true; + } + + private CellStyle styleOf(CellWriteHandlerContext context) { + Workbook workbook = context.getWriteSheetHolder().getSheet().getWorkbook(); + if (Boolean.TRUE.equals(context.getHead())) { + if (headerStyle == null) { + headerStyle = createHeaderStyle(workbook); + } + return headerStyle; + } + Integer rowIndex = context.getRowIndex(); + if (totalRowIndex >= 0 && rowIndex != null && rowIndex == totalRowIndex) { + if (totalStyle == null) { + totalStyle = createTotalStyle(workbook); + } + return totalStyle; + } + if (dataStyle == null) { + dataStyle = createDataStyle(workbook); + } + return dataStyle; + } + + private CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setAlignment(HorizontalAlignment.CENTER); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createTotalStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createDataStyle(Workbook workbook) { + return baseStyle(workbook); + } + + private CellStyle baseStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java new file mode 100644 index 00000000..38b836e4 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java @@ -0,0 +1,110 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; +import org.apache.fesod.sheet.ExcelWriter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.write.metadata.WriteSheet; + +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 把表头、明细行、合计行写成一个 xlsx(Apache Fesod / 原 EasyExcel)。 + * + * 行来源抽象成 {@link RowSource},因此本类不关心数据是 JDBC 游标还是内存列表: + * 生产走游标 + 分批 write(Fesod 底层是 SXSSF,内存与总行数无关),测试直接喂内存行, + * 于是「多级表头 + 数据行 + 合计行」这条最容易出错的路径可以脱离数据库验证。 + */ +final class ExportWorkbookWriter { + + /** 每批交给 Fesod 的行数:批太大失去流式意义,太小写入次数过多 */ + private static final int BATCH_SIZE = 1000; + + private ExportWorkbookWriter() { + } + + /** 行来源:生产实现是 JDBC ResultSet,测试实现是内存列表 */ + interface RowSource { + boolean hasNext(); + + List next(); + } + + /** + * @param nodes 表头树(分组 + 叶子),用于生成多级表头 + * @param columns 叶子列(顺序与 {@link RowSource} 返回的行内元素一致),用于列宽 + * @param rows 明细行,元素个数应与 columns 一致 + * @param totals 合计行,元素个数应与 columns 一致;空列表表示不输出合计行 + * @return 写出的明细行数 + */ + static int write( + OutputStream outputStream, + String sheetName, + List nodes, + List columns, + RowSource rows, + List totals + ) throws IOException { + List> head = ExportHeadBuilder.headOf(nodes); + ExportStyleHandler styleHandler = new ExportStyleHandler(columns); + + ExcelWriter writer = FesodSheet.write(outputStream) + .head(head) + // 相邻同值表头自动合并(分组横向、叶子纵向由库按表头结构推导) + .automaticMergeHead(true) + .registerWriteHandler(styleHandler) + .build(); + try { + WriteSheet sheet = FesodSheet.writerSheet(sheetName).build(); + + int rowCount = 0; + List> batch = new ArrayList<>(BATCH_SIZE); + while (rows.hasNext()) { + batch.add(rows.next()); + rowCount++; + if (batch.size() >= BATCH_SIZE) { + writer.write(batch, sheet); + batch = new ArrayList<>(BATCH_SIZE); + } + } + // 最后一批一定要调一次(可能为空):Fesod 只在 write 被调用时才真正创建工作表, + // 0 行导出若不调用会得到一个「没有任何工作表」的空 xlsx + writer.write(batch, sheet); + + if (totals != null && !totals.isEmpty()) { + // 合计行只能事后标记:流式写出时事先不知道总行数 + styleHandler.markTotalRow(ExportHeadBuilder.depth(nodes) + rowCount); + writer.write(Collections.singletonList(buildTotalRow(columns.size(), totals)), sheet); + } + return rowCount; + } finally { + writer.finish(); + } + } + + /** + * 合计行:各列写聚合值,「合计」标签落在**第一个没有聚合值的列**(正常就是首列, + * 首列一般是编码/名称这类不可求和的列)。若所有列都有聚合值,则不加标签。 + */ + private static List buildTotalRow(int columnCount, List totals) { + List row = new ArrayList<>(columnCount); + boolean labelled = false; + for (int index = 0; index < columnCount; index++) { + BigDecimal total = index < totals.size() ? totals.get(index) : null; + if (total != null) { + row.add(total); + } else if (!labelled) { + row.add("合计"); + labelled = true; + } else { + row.add(null); + } + } + return row; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java new file mode 100644 index 00000000..6899e95c --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java @@ -0,0 +1,298 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 后端 SQL 权限出口(开发规范 通用原则 10)。 + * + * 前端隐藏菜单/按钮/字段只是体验,真正生效的过滤必须在后端拼进 SQL。本服务是这一层 + * 的唯一实现,供导出使用;后续列表/查询接入时复用同一套判定,不再各写一份。 + * + * 三层,与《FMS新系统核心表结构设计》第 14 节一一对应: + * 1. 动作权限(14.1 / 14.2):s_power(action.{模块}.{动作})+ s_user_power,白名单语义; + * 2. 字段权限(14.3):s_user_field_power 的 b_view / b_export,无记录按模块默认(可看可导出); + * 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND。 + */ +@Service +public class SqlPermissionService { + + /** 管理员账号:与前端 permission store 的提权口径保持一致 */ + public static final String ADMIN_ACCOUNT = "g3soft"; + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern WRITE_KEYWORD_PATTERN = Pattern.compile( + "(?i)\\b(insert|update|delete|merge|drop|truncate|alter|create|exec|execute|grant|revoke|backup|restore|dbcc|kill|use|into)\\b" + ); + private static final Pattern SQL_COMMENT_PATTERN = Pattern.compile("--|/\\*|\\*/"); + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + /** 是否管理员账号(提权:跳过动作权限校验) */ + public boolean isAdministrator(String userId) { + return ADMIN_ACCOUNT.equalsIgnoreCase(String.valueOf(userId).trim()); + } + + /** + * 动作权限校验。白名单语义:s_power 中不存在该权限点、或用户未被授权、 + * 或任一侧 b_canuse = 0 时一律拒绝。 + */ + public void requireAction(String moduleId, String userId, String action) { + if (isAdministrator(userId)) { + return; + } + String sql = "SELECT TOP 1 p.[b_id] FROM [s_power] p " + + "JOIN [s_user_power] up ON up.[b_power_id] = p.[b_id] AND up.[b_canuse] = 1 " + + "WHERE p.[b_object_type] = 'module' AND p.[b_object_id] = ? AND p.[b_action] = ? " + + "AND p.[b_canuse] = 1 AND up.[b_user_id] = ?"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + if (query(connection, sql, moduleId, action, userId).isEmpty()) { + throw new BusinessException("没有导出权限"); + } + } catch (SQLException exception) { + throw new BusinessException("权限校验失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 不允许导出的字段集合:b_view = 0(在列表/表单/查询/导出中统一移除)或 b_export = 0。 + * 无记录视为可导出,与 14.3 的模块默认行为一致。 + */ + public Set deniedExportFields(String moduleId, String userId) { + Set denied = new LinkedHashSet<>(); + String sql = "SELECT [b_field_id], [b_view], [b_export] FROM [s_user_field_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + for (Map row : query(connection, sql, userId, moduleId)) { + String field = String.valueOf(row.get("b_field_id")); + if (number(row.get("b_view")) == 0 || number(row.get("b_export")) == 0) { + denied.add(field); + } + } + } catch (SQLException exception) { + throw new BusinessException("字段权限读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return denied; + } + + /** + * 数据范围条件(不含 WHERE 关键字,形如 `([b_inputuser_id] = N'u001')`)。 + * 无生效规则返回 null,表示该模块无数据范围限制。 + */ + public String buildDataScopeCondition(String moduleId, String userId, String operation) { + String sql = "SELECT [b_operation], [b_scope_type], [b_scope_field], [b_condition_sql] " + + "FROM [s_user_data_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1 " + + "AND ([b_operation] = ? OR [b_operation] = '*') " + + "ORDER BY [b_xh]"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + List> rows = query(connection, sql, userId, moduleId, operation); + List> exact = new ArrayList<>(); + List> wildcard = new ArrayList<>(); + for (Map row : rows) { + // 精确操作优先:命中精确规则时不再叠加 '*' 规则(14.4 判定规则 1) + if (operation.equalsIgnoreCase(text(row.get("b_operation")))) { + exact.add(row); + } else { + wildcard.add(row); + } + } + List> effective = exact.isEmpty() ? wildcard : exact; + + String deptId = null; + List fragments = new ArrayList<>(); + for (Map row : effective) { + String scopeType = text(row.get("b_scope_type")).toLowerCase(Locale.ROOT); + String scopeField = text(row.get("b_scope_field")); + switch (scopeType) { + case "all", "" -> { + // 全部数据:不追加条件 + } + case "self" -> { + String field = requireScopeField(scopeField); + fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(userId)); + } + case "dept", "dept_tree" -> { + String field = requireScopeField(scopeField); + if (deptId == null) { + deptId = loadDeptId(connection, userId); + } + // 未归属部门时该规则不生效(14.4) + if (deptId == null || deptId.isBlank()) { + break; + } + if ("dept".equals(scopeType)) { + fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(deptId)); + } else { + String deptPath = loadDeptPath(connection, deptId); + if (deptPath == null || deptPath.isBlank()) { + break; + } + fragments.add(quote(field) + " IN (SELECT [b_id] FROM [b_dept] WHERE [b_path] LIKE " + + dbUtils.toSqlStringLiteral(deptPath + "%") + ")"); + } + } + case "custom" -> { + String fragment = substitutePlaceholders(text(row.get("b_condition_sql")), userId, deptId); + if (!fragment.isBlank()) { + validateConditionFragment(fragment); + fragments.add("(" + fragment + ")"); + } + } + default -> throw new BusinessException("不支持的数据范围类型: " + scopeType); + } + } + if (fragments.isEmpty()) { + return null; + } + return "(" + String.join(" OR ", fragments) + ")"; + } catch (SQLException exception) { + throw new BusinessException("数据范围读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private String requireScopeField(String scopeField) { + if (scopeField == null || scopeField.isBlank()) { + throw new BusinessException("数据范围规则未配置判断字段"); + } + return scopeField; + } + + private String loadDeptId(Connection connection, String userId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_dept_id] FROM [b_user] WHERE [b_id] = ?", + userId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_dept_id")); + } + + private String loadDeptPath(Connection connection, String deptId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_path] FROM [b_dept] WHERE [b_id] = ?", + deptId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_path")); + } + + /** b_condition_sql 只由管理员配置,运行时仅替换受控占位符(14.5) */ + private String substitutePlaceholders(String conditionSql, String userId, String deptId) { + if (conditionSql == null) { + return ""; + } + return conditionSql.trim() + .replace("{userId}", userId) + .replace("{deptId}", deptId == null ? "" : deptId); + } + + /** + * 条件片段校验:与 DbUtils.validateReadOnlySql 同一套口径,但允许不以 select 开头 + * (片段是 WHERE 里的布尔表达式)。 + */ + private void validateConditionFragment(String fragment) { + if (fragment.contains(";")) { + throw new BusinessException("数据范围条件不允许包含分号"); + } + String structure = removeStringLiterals(fragment); + if (SQL_COMMENT_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件不允许包含注释"); + } + if (WRITE_KEYWORD_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件包含非只读关键字"); + } + } + + /** 剔除字符串字面量,避免字面量里的关键字/分号被误判(与 DbUtils 同一做法) */ + private String removeStringLiterals(String sql) { + StringBuilder result = new StringBuilder(sql.length()); + boolean inString = false; + for (int index = 0; index < sql.length(); index++) { + char character = sql.charAt(index); + if (character != '\'') { + result.append(inString ? ' ' : character); + continue; + } + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + result.append(" "); + index++; + continue; + } + inString = !inString; + result.append(' '); + } + if (inString) { + throw new BusinessException("数据范围条件字符串未闭合"); + } + return result.toString(); + } + + private String quote(String field) { + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + throw new BusinessException("非法数据库标识符: " + field); + } + return "[" + field + "]"; + } + + private static int number(Object value) { + return value instanceof Number num ? num.intValue() : 1; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private List> query( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + int columnCount = resultSet.getMetaData().getColumnCount(); + List> rows = new ArrayList<>(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + rows.add(row); + } + return rows; + } + } + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java index 337d5fdf..55216d02 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java @@ -562,6 +562,15 @@ public class DbUtils { .collect(Collectors.joining(", ")); } + /** + * 对外暴露的排序子句构建:校验并加引号,格式与分页/查询完全一致 + * (`field [ASC|DESC]` 或 `CASE ... END`,逗号分隔多列)。 + * 导出等需要自己拼 SQL 的场景复用它,避免另写一套校验。 + */ + public String buildSafeOrderBy(String orderBy) { + return buildPageOrderBy(orderBy); + } + public String quoteQualifiedIdentifier(String identifier) { if (!hasText(identifier)) { throw new BusinessException("数据库标识符不能为空"); diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java new file mode 100644 index 00000000..1075a2fa --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java @@ -0,0 +1,270 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.exception.BusinessException; +import jakarta.annotation.Resource; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.PreparedStatement; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 导出端到端(连真实库,只读)。验证「元数据 → SQL → POI 流 → 可解析的 xlsx」整条链路, + * 以及校验失败发生在写流之前(还能返回 JSON 而不是半个损坏文件)。 + * + * 用管理员账号跑,动作权限提权、字段权限与数据范围表为空即无过滤,因此本用例只验证链路本身; + * 权限裁剪的规则由 ExportServiceTests 与 SqlPermissionService 的判定逻辑覆盖。 + */ +@SpringBootTest +class ExportServiceIntegrationTests { + + /** 探针确认过有数据的模块:箱型管理,视图 b_con_type,主键 b_id */ + private static final String MODULE_WITH_DATA = "b_con_type"; + + @Resource + private ExportService exportService; + + @Resource + private DataSource dataSource; + + @Test + void 导出真实模块得到可解析的xlsx() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, null, true + ), + response + ); + + assertThat(response.getContentType()) + .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + assertThat(response.getHeader("Content-Disposition")) + .contains("attachment") + .contains("filename*=UTF-8''"); + + byte[] bytes = response.getContentAsByteArray(); + assertThat(bytes).isNotEmpty(); + + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(workbook.getNumberOfSheets()).isEqualTo(1); + assertThat(sheet.getSheetName()).isNotBlank(); + + // 该模块未配分组表头,第一行即列名;至少一列 + assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isNotBlank(); + // 不传区间时后端不加 OFFSET/FETCH,导出的是当前条件下的**全部行**(不是当前页) + assertThat(sheet.getLastRowNum()).isEqualTo(rowCountOfModule()); + assertThat(sheet.getRow(1).getCell(0)).isNotNull(); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 勾选行导出只包含选中的行() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + String firstKey = firstKeyOfModule(); + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, java.util.List.of(firstKey), "b_id", + null, null, null, null, true + ), + response + ); + + try (Workbook workbook = new XSSFWorkbook( + new ByteArrayInputStream(response.getContentAsByteArray()))) { + Sheet sheet = workbook.getSheetAt(0); + // 单行表头 + 1 行数据 + assertThat(sheet.getLastRowNum()).isEqualTo(1); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 模块不存在时在写流之前报错() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + assertThatThrownBy(() -> exportService.export( + new ExportService.ExportRequest( + "不存在的模块", null, null, null, null, null, null, null, null, true + ), + response + )).isInstanceOf(BusinessException.class) + .hasMessageContaining("模块"); + // 关键:报错时响应体仍是空的,前端能拿到 JSON 错误而不是损坏的 xlsx + assertThat(response.getContentAsByteArray()).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 计数与导出走同一权限链路() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + long total = exportService.count(request(null)); + assertThat(total).isEqualTo(rowCountOfModule()); + + // 条件同样参与计数(b_id 非空即全部) + assertThat(exportService.count(request("b_id IS NOT NULL"))).isEqualTo(total); + // 匹配不到任何行的条件返回 0,而不是报错。 + // 注意 b_con_type.b_id 是 bigint(新系统业务表用雪花 ID),不能拿字符串字面量去比 + assertThat(exportService.count(request("b_id = -1"))).isZero(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 分卷导出返回zip且每卷行数正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + int total = rowCountOfModule(); + int volumeSize = 3; + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export(volumeRequest(volumeSize), response); + + assertThat(response.getContentType()).isEqualTo("application/zip"); + assertThat(response.getHeader("Content-Disposition")).contains(".zip"); + + Map rowsByEntry = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + byte[] content = zip.readAllBytes(); + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + // 表头 1 行,其余为数据行 + rowsByEntry.put(entry.getName(), workbook.getSheetAt(0).getLastRowNum()); + } + } + } + + int expectedVolumes = (total + volumeSize - 1) / volumeSize; + assertThat(rowsByEntry).hasSize(expectedVolumes); + // 每卷行数:前 n-1 卷是 volumeSize,最后一卷是余数 + List expectedRowsPerVolume = new ArrayList<>(); + for (long remaining = total; remaining > 0; remaining -= volumeSize) { + expectedRowsPerVolume.add((int) Math.min(volumeSize, remaining)); + } + assertThat(rowsByEntry.values()).containsExactlyElementsOf(expectedRowsPerVolume); + } finally { + OrgContext.clear(); + } + } + + @Test + void 按行范围聚合的合计SQL在真实库上可执行且数值正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 「当前页 / 区间 / 单卷」的合计都靠这段 SQL:字符串对不代表能在 SQL Server 上跑通 + String sql = ExportService.buildTotalsSql( + MODULE_WITH_DATA, + List.of(new ExportSchemaService.ExportColumn("b_id", "ID", 16, "sum")), + null, + "[b_id] ASC" + ); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, 0); + statement.setLong(2, 3); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + BigDecimal ranged = resultSet.getBigDecimal(1); + // 期望值:同一视图按同一排序取前 3 行直接求和 + try (Statement plain = connection.createStatement(); + ResultSet expected = plain.executeQuery( + "SELECT SUM([b_id]) FROM (SELECT TOP 3 [b_id] FROM [b_con_type] " + + "ORDER BY [b_id] ASC) AS [expected_source]")) { + expected.next(); + assertThat(ranged).isEqualByComparingTo(expected.getBigDecimal(1)); + } + } + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 总合计接口在没有配合计列时返回空() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 目前没有任何模块配 summary,所以结果为空 —— 验证整条链路不会因为「没有可合计列」出错 + assertThat(exportService.totals(request(null))).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + private ExportService.ExportRequest request(String searchCondition) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, searchCondition, null, null, null, null, null, null, null, true + ); + } + + private ExportService.ExportRequest volumeRequest(int volumeSize) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, volumeSize, true + ); + } + + private String firstKeyOfModule() throws Exception { + // 走应用自己的机构数据源(OrgContext 已在用例内设好),不在测试里写死连接串 + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT TOP 1 [b_id] FROM [b_con_type] ORDER BY [b_id]")) { + resultSet.next(); + return resultSet.getString(1); + } + } + + /** 模块视图的总行数,用于断言「不传区间 = 导出全部行」 */ + private int rowCountOfModule() throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT_BIG(1) FROM [b_con_type]")) { + resultSet.next(); + return resultSet.getInt(1); + } + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java new file mode 100644 index 00000000..c9d7bd00 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java @@ -0,0 +1,290 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportSchema; +import cn.g3soft.fmsapi.utils.DbUtils; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 导出 SQL 拼装与表头结构(纯逻辑,不连库)。 + * 表头 → xlsx 的写出验证见 ExportWorkbookWriterTests,连库端到端见 ExportServiceIntegrationTests。 + */ +class ExportServiceTests { + + private final ExportService service = new ExportService(); + + ExportServiceTests() { + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + } + + private static ExportColumn column(String field) { + return new ExportColumn(field, field, 16, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field), null); + } + + private static ExportSchema schema(String orderSql, List nodes) { + return new ExportSchema("m1", "测试模块", "v_test", "b_id", orderSql, nodes); + } + + private static ExportService.ExportRequest request( + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + Integer rangeStart, + Integer rangeEnd + ) { + return new ExportService.ExportRequest( + "m1", searchCondition, orderBy, rowKeys, keyField, null, rangeStart, rangeEnd, null, true + ); + } + + /* ================= SELECT 拼装 ================= */ + + @Test + void 默认无条件时只拼表名与列() { + ExportSchema schema = schema(null, List.of(leaf("b_name"), leaf("b_bz"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name], [b_bz] FROM [v_test]"); + } + + @Test + void 条件与排序都参与拼装且标识符被加引号() { + ExportSchema schema = schema("b_xh ASC", List.of(leaf("b_name"))); + // 排序子句由 resolveOrderSql 经 DbUtils.buildSafeOrderBy 校验加引号后传入 + String sql = service.buildSelectSql( + schema, + schema.columns(), + "(b_canuse = 1)", + "[b_xh] ASC", + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name] FROM [v_test] WHERE (b_canuse = 1) ORDER BY [b_xh] ASC"); + } + + @Test + void 区间导出走OFFSET_FETCH且无排序时用常量占位() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, 101, 200) + ); + + assertThat(sql).isEqualTo( + "SELECT [b_name] FROM [v_test] ORDER BY (SELECT NULL) OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + ); + } + + @Test + void 排序子句非法时被拒绝() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + // 排序必须经 resolveOrderSql 校验(导出主流程就是这条路径),不能把原始串直接塞进 SQL + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name; drop table x")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name b_xh")) + .isInstanceOf(BusinessException.class); + } + + @Test + void 排序缺省时回退模块默认排序且加引号() { + ExportSchema schema = schema("b_xh DESC", List.of(leaf("b_name"))); + assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_xh] DESC"); + assertThat(service.resolveOrderSql(schema, "b_name ASC")).isEqualTo("[b_name] ASC"); + assertThat(service.resolveOrderSql(schema("", List.of(leaf("b_name"))), null)).isNull(); + } + + /* ================= WHERE 拼装:数据范围只收窄不放开 ================= */ + + @Test + void 调用方条件与数据范围按AND组合() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")) + .thenReturn("([b_inputuser_id] = N'u001')"); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request("b_status = N'正常'", null, null, null, null, null) + ); + + assertThat(where).isEqualTo("(b_status = N'正常') AND ([b_inputuser_id] = N'u001')"); + } + + @Test + void 勾选行用参数占位而不是字面量拼接() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, List.of(1L, 2L, 3L), null, null, null) + ); + + assertThat(where).isEqualTo("[b_id] IN (?, ?, ?)"); + } + + @Test + void 无任何条件时不生成WHERE子句() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + assertThat(service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, null, null, null, null) + )).isNull(); + } + + /* ================= 合计 ================= */ + + @Test + void 只有配了summary的列参与合计() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum"), + new ExportColumn("b_count", "笔数", 16, "count") + ); + + assertThat(ExportService.summableIndexes(columns)).containsExactly(1, 2); + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", null)) + .isEqualTo("SELECT SUM([b_amount]), COUNT([b_count]) FROM [v_test] WHERE (b_canuse = 1)"); + } + + @Test + void 按行范围聚合时用行号子查询限定范围() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum") + ); + + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] DESC")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY [b_xh] DESC) AS [__fms_row_number] " + + "FROM [v_test] WHERE (b_canuse = 1)" + + ") AS [totals_source] WHERE [totals_source].[__fms_row_number] BETWEEN ? AND ?"); + } + + @Test + void 无排序配置时按行范围聚合用常量排序占位() { + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + + assertThat(ExportService.buildTotalsSql("v_test", columns, null, "(SELECT NULL)")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS [__fms_row_number] " + + "FROM [v_test]) AS [totals_source] " + + "WHERE [totals_source].[__fms_row_number] BETWEEN ? AND ?"); + } + + @Test + void 合计SQL能通过只读校验() { + // 行号子查询里出现了 ROW_NUMBER / AS,必须仍被 validateReadOnlySql 接受 + ExportService service = new ExportService(); + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + String sql = ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] ASC"); + + assertThatCode(() -> new DbUtils().validateReadOnlySql(sql)).doesNotThrowAnyException(); + } + + @Test + void 没有可合计列时不生成合计SQL() { + List columns = List.of(column("b_name")); + assertThat(ExportService.summableIndexes(columns)).isEmpty(); + } + + /* ================= 表头结构(转成 Fesod 的 List>) ================= */ + + @Test + void 单行表头每列只有一个标题() { + List nodes = List.of(leaf("b_name"), leaf("b_bz")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("b_name"), + List.of("b_bz") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(1); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(2); + } + + @Test + void 两级表头按分组路径展开且列名在最后() { + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + ExportNode groupB = new ExportNode("其他", null, List.of(leaf("b_xh"))); + List nodes = List.of(groupA, groupB); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("其他", "b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(3); + } + + @Test + void 顶层叶子与分组混排时叶子路径更短() { + // 各列内层长度不同是允许的:Fesod 会底部对齐补空行,列名仍落在最后一行 + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(groupA, leaf("b_xh")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + } + + @Test + void 三层表头路径完整且分组标题可为空() { + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + ExportNode untitled = new ExportNode(null, null, List.of(leaf("b_e"))); + List nodes = List.of(outer, leaf("b_d"), untitled); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("费用", "明细", "b_a"), + List.of("费用", "明细", "b_b"), + List.of("费用", "b_c"), + List.of("b_d"), + List.of("", "b_e") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(3); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(5); + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java new file mode 100644 index 00000000..ac6d0860 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java @@ -0,0 +1,211 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 表头 + 明细 + 合计 → xlsx 的写出验证(Apache Fesod,不连库)。 + * + * 这里覆盖的是最容易出错、又没法用真实模块验证的部分:**分组表头与顶层叶子混排**时, + * 各列的标题层数不同,必须仍然「列名落在表头最后一行、分组标题横向合并」。 + * 库里 71 份 view schema 都没有配过分组,所以这条路径只能靠本用例守住。 + */ +class ExportWorkbookWriterTests { + + private static ExportColumn column(String field, int width) { + return new ExportColumn(field, field, width, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field, 12), null); + } + + private static ExportWorkbookWriter.RowSource rowsOf(List> rows) { + var iterator = rows.iterator(); + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public List next() { + return iterator.next(); + } + }; + } + + private static Workbook write( + List nodes, + List columns, + List> rows, + List totals + ) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ExportWorkbookWriter.write(out, "测试表", nodes, columns, rowsOf(rows), totals); + return new XSSFWorkbook(new ByteArrayInputStream(out.toByteArray())); + } + + private static String text(Sheet sheet, int rowIndex, int columnIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null || row.getCell(columnIndex) == null) { + return null; + } + return row.getCell(columnIndex).getStringCellValue(); + } + + @Test + void 单行表头加明细与合计行() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + List nodes = List.of(leaf("b_name"), leaf("b_amount")); + List totals = new ArrayList<>(); + totals.add(null); + totals.add(new BigDecimal("300.50")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", 100.5), List.of("乙", 200)), + totals + )) { + assertThat(workbook.getSheetName(0)).isEqualTo("测试表"); + Sheet sheet = workbook.getSheetAt(0); + + // 表头 1 行 + 明细 2 行 + 合计 1 行 + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_amount"); + assertThat(text(sheet, 1, 0)).isEqualTo("甲"); + assertThat(sheet.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(100.5); + // 合计行:首列是「合计」标签,数值列是聚合结果 + assertThat(text(sheet, 3, 0)).isEqualTo("合计"); + assertThat(sheet.getRow(3).getCell(1).getNumericCellValue()).isEqualTo(300.50); + } + } + + @Test + void 分组表头横向合并且列名落在最后一行() throws Exception { + List columns = List.of( + column("b_name", 12), + column("b_bz", 12), + column("b_xh", 8) + ); + ExportNode group = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(group, leaf("b_xh")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", "备注一", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + // 两行表头 + 一行明细 + assertThat(sheet.getLastRowNum()).isEqualTo(2); + assertThat(text(sheet, 0, 0)).isEqualTo("基本信息"); + // 列名一律落在表头最后一行:分组下的两列与顶层叶子都一样 + assertThat(text(sheet, 1, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 1, 1)).isEqualTo("b_bz"); + assertThat(text(sheet, 1, 2)).isEqualTo("b_xh"); + // 分组标题横向合并跨两列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 1)); + } + } + + @Test + void 三层表头分组逐级合并() throws Exception { + List columns = List.of( + column("b_a", 12), + column("b_b", 12), + column("b_c", 12), + column("b_d", 12) + ); + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + List nodes = List.of(outer, leaf("b_d")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of(1, 2, 3, 4)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("费用"); + assertThat(text(sheet, 1, 0)).isEqualTo("明细"); + assertThat(text(sheet, 2, 0)).isEqualTo("b_a"); + assertThat(text(sheet, 2, 1)).isEqualTo("b_b"); + assertThat(text(sheet, 2, 2)).isEqualTo("b_c"); + assertThat(text(sheet, 2, 3)).isEqualTo("b_d"); + // 费用跨 3 列、明细跨 2 列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 2)); + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(1, 1, 0, 1)); + } + } + + @Test + void 列宽按列表配置换算后写入() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_amount")), + columns, + List.of(List.of("甲", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + // POI 的列宽单位是 1/256 字符 + assertThat(sheet.getColumnWidth(0)).isEqualTo(12 * 256); + assertThat(sheet.getColumnWidth(1)).isEqualTo(20 * 256); + } + } + + @Test + void 无数据时仍写出表头() throws Exception { + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_bz")), + List.of(column("b_name", 12), column("b_bz", 12)), + List.of(), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum()).isZero(); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_bz"); + } + } + + @Test + void 超过一批的行数也能全部写出() throws Exception { + List columns = List.of(column("b_name", 12)); + List> rows = new ArrayList<>(); + for (int index = 0; index < 2500; index++) { + rows.add(List.of("行" + index)); + } + + try (Workbook workbook = write(List.of(leaf("b_name")), columns, rows, List.of())) { + Sheet sheet = workbook.getSheetAt(0); + // 1 行表头 + 2500 行明细(跨越多个写入批次) + assertThat(sheet.getLastRowNum()).isEqualTo(2500); + assertThat(text(sheet, 1, 0)).isEqualTo("行0"); + assertThat(text(sheet, 2500, 0)).isEqualTo("行2499"); + } + } +} diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class new file mode 100644 index 00000000..2f422279 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class new file mode 100644 index 00000000..75cc612f Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportColumn.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportColumn.class new file mode 100644 index 00000000..003cae85 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportColumn.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class new file mode 100644 index 00000000..3bda96ff Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class new file mode 100644 index 00000000..b56a010c Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class new file mode 100644 index 00000000..629014ed Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class new file mode 100644 index 00000000..63d2f86f Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class new file mode 100644 index 00000000..7f568c97 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class new file mode 100644 index 00000000..30b1e5ca Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class new file mode 100644 index 00000000..e1a81752 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class new file mode 100644 index 00000000..fbc3f3d6 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class new file mode 100644 index 00000000..d93ba655 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter$RowSource.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter$RowSource.class new file mode 100644 index 00000000..0addead4 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter$RowSource.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class new file mode 100644 index 00000000..f012bec0 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class new file mode 100644 index 00000000..38d3fc21 Binary files /dev/null and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class index 74c543a5..4d0c2298 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class index 07ff753a..e47bf3ef 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class index 16ae1430..d64adc0c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class index 0ffa5aca..669b7f6d 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class differ diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index eb1e4f6a..45a05b89 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,15 +1,19 @@ cn\g3soft\fmsapi\service\LoginLogService.class cn\g3soft\fmsapi\controller\FileController.class cn\g3soft\fmsapi\utils\ParamUtils.class +cn\g3soft\fmsapi\service\ExportService$PreparedExport.class cn\g3soft\fmsapi\utils\JwtUtils.class +cn\g3soft\fmsapi\service\ExportService$1.class cn\g3soft\fmsapi\database\OrgDataSourceManager$PoolEntry.class cn\g3soft\fmsapi\FmsApiApplication.class +cn\g3soft\fmsapi\service\ExportService$ExportRequest.class cn\g3soft\fmsapi\utils\DbUtils$TableMetadata.class cn\g3soft\fmsapi\utils\JwtUtils$TokenClaims.class cn\g3soft\fmsapi\utils\IpRegionSearcher.class cn\g3soft\fmsapi\service\DataSaveService.class cn\g3soft\fmsapi\service\DataService.class cn\g3soft\fmsapi\utils\snowflake\core\SnowWorkerM1.class +cn\g3soft\fmsapi\service\ExportService.class cn\g3soft\fmsapi\database\OrgDataSourceManager.class cn\g3soft\fmsapi\utils\ClientInfo.class cn\g3soft\fmsapi\utils\snowflake\contract\OverCostActionArg.class @@ -35,6 +39,7 @@ cn\g3soft\fmsapi\utils\DbUtils$ColumnMetadata.class cn\g3soft\fmsapi\controller\AuthController.class cn\g3soft\fmsapi\utils\snowflake\contract\IIdGenerator.class cn\g3soft\fmsapi\utils\snowflake\contract\ISnowWorker.class +cn\g3soft\fmsapi\service\ExportService$ResultSetRowSource.class cn\g3soft\fmsapi\utils\IpUtils.class cn\g3soft\fmsapi\utils\StringUtils.class cn\g3soft\fmsapi\utils\DbUtils.class diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index a7145b3a..aeb035af 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -4,6 +4,7 @@ D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\config\JacksonConfi D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\config\JwtAuthFilter.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\controller\AuthController.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\controller\DataController.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\controller\ExportController.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\controller\FileController.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\database\OrgContext.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\database\OrgDatabaseConfig.java @@ -22,8 +23,14 @@ D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ActiveSessi D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\AuthService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\DataSaveService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\DataService.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportHeadBuilder.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportSchemaService.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportService.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportStyleHandler.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportWorkbookWriter.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\FileService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\LoginLogService.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\SqlPermissionService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\utils\ApiResponse.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\utils\ClientInfo.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\utils\ClientInfoExtractor.java diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst index 4ffc47d6..b55b9e67 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst @@ -7,5 +7,8 @@ D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\FmsApiApplicationTe D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\ActiveSessionRegistryTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\AuthServiceTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\DataSaveServiceTests.java +D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\ExportServiceIntegrationTests.java +D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\ExportServiceTests.java +D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\service\ExportWorkbookWriterTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\utils\JwtUtilsTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\utils\snowflake\DefaultIdGeneratorTests.java diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-13-32_187.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-13-32_187.dumpstream new file mode 100644 index 00000000..58ed5ae7 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-13-32_187.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:13:32.377 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream new file mode 100644 index 00000000..ffb3613e --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:14:17.880 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream new file mode 100644 index 00000000..2c493149 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:14:56.290 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream new file mode 100644 index 00000000..75ac8f87 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:17:03.779 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream new file mode 100644 index 00000000..64d92f71 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:24:02.295 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream new file mode 100644 index 00000000..093f51cd --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:39:48.149 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream new file mode 100644 index 00000000..16b05aaf --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:15.395 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream new file mode 100644 index 00000000..122b080b --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:32.634 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream new file mode 100644 index 00000000..8d2c633c --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:45.605 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream new file mode 100644 index 00000000..153fa385 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:47:36.415 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream new file mode 100644 index 00000000..e6d3bca4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:48:17.017 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream new file mode 100644 index 00000000..80f301c4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:49:11.117 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream new file mode 100644 index 00000000..2d409ca2 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:01.012 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream new file mode 100644 index 00000000..096fd9ab --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:26.206 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream new file mode 100644 index 00000000..b951d672 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:44.966 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream new file mode 100644 index 00000000..936e7756 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:55:35.792 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream new file mode 100644 index 00000000..e061f525 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:57:13.078 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream new file mode 100644 index 00000000..fe8d49b7 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:57:47.155 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream new file mode 100644 index 00000000..33ff5b2a --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T23:13:41.132 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml index d19aa808..a7b442c8 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,12 +65,12 @@ - - + - + - + @@ -15,10 +15,9 @@ - + - - + @@ -31,7 +30,7 @@ - + @@ -43,7 +42,7 @@ - + @@ -52,7 +51,7 @@ - + @@ -60,10 +59,17 @@ - - + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml index 634cbddb..18296303 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,20 +59,20 @@ - - + - - - + - - + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml index f4bf1250..52dab7cc 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,5 +59,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml index 3132a095..2c57d76f 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,21 +59,21 @@ - - + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml index 46abc5a8..d5ae8809 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml @@ -1,66 +1,66 @@ - + - - + + - + - + - - + + - - + - + - - + + - + - - + + - - - - + + + + + - + - - + + - - + - - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml index f1aabc89..c292dc27 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,5 +65,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml index 2c1a0be0..5432d81b 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,11 +65,11 @@ - + - + - - + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml new file mode 100644 index 00000000..72589286 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml new file mode 100644 index 00000000..d9d6d427 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml new file mode 100644 index 00000000..349c35f3 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml index 36b5ae82..8f5e7b7f 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,5 +65,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml index 94f4f03f..08680a0b 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,10 +65,10 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt index 108aceb5..fb4294e5 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.FmsApiApplicationTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.599 s -- in cn.g3soft.fmsapi.FmsApiApplicationTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.896 s -- in cn.g3soft.fmsapi.FmsApiApplicationTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt index 89359787..73fe3d94 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt @@ -1,4 +1,11 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.253 s -- in cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.008 s <<< FAILURE! -- in cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests +cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: + +expected: 0 + but was: 1 + at cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry(OrgDataSourceFactoryTests.java:25) + diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt index 67f7736b..fdeedf0e 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDataSourceManagerTests ------------------------------------------------------------------------------- -Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.887 s -- in cn.g3soft.fmsapi.database.OrgDataSourceManagerTests +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.892 s -- in cn.g3soft.fmsapi.database.OrgDataSourceManagerTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt index f5ce0f90..ebab0ce3 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.129 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.158 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt index 169eb26e..882d8c85 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests ------------------------------------------------------------------------------- -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.124 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.166 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt index 0a5c1301..f4f70e80 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests ------------------------------------------------------------------------------- -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.531 s -- in cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.012 s -- in cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt index 053f3031..b22c6746 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.AuthServiceTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.194 s -- in cn.g3soft.fmsapi.service.AuthServiceTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.218 s -- in cn.g3soft.fmsapi.service.AuthServiceTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt index de615113..29719782 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.DataSaveServiceTests ------------------------------------------------------------------------------- -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.417 s -- in cn.g3soft.fmsapi.service.DataSaveServiceTests +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.445 s -- in cn.g3soft.fmsapi.service.DataSaveServiceTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt new file mode 100644 index 00000000..c4a192c3 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: cn.g3soft.fmsapi.service.ExportServiceIntegrationTests +------------------------------------------------------------------------------- +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.740 s -- in cn.g3soft.fmsapi.service.ExportServiceIntegrationTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt new file mode 100644 index 00000000..422b2b35 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: cn.g3soft.fmsapi.service.ExportServiceTests +------------------------------------------------------------------------------- +Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.171 s -- in cn.g3soft.fmsapi.service.ExportServiceTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt new file mode 100644 index 00000000..22f9eac4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: cn.g3soft.fmsapi.service.ExportWorkbookWriterTests +------------------------------------------------------------------------------- +Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.263 s -- in cn.g3soft.fmsapi.service.ExportWorkbookWriterTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt index 4d1f412c..630bc087 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.utils.JwtUtilsTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.091 s -- in cn.g3soft.fmsapi.utils.JwtUtilsTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.099 s -- in cn.g3soft.fmsapi.utils.JwtUtilsTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt index 4f3763ae..ed873c72 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests ------------------------------------------------------------------------------- -Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.594 s -- in cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests +Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.625 s -- in cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class new file mode 100644 index 00000000..f6314188 Binary files /dev/null and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class new file mode 100644 index 00000000..8f8dd232 Binary files /dev/null and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class new file mode 100644 index 00000000..843a79c3 Binary files /dev/null and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class new file mode 100644 index 00000000..b03b69d7 Binary files /dev/null and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class differ diff --git a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js index 167708a8..c17731d1 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js +++ b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js @@ -70,6 +70,7 @@ export function buildListConfig(viewSchema) { b_visible: node.visible === false ? 0 : 1, b_xh: (index + 1) * 10, options: node.options, + summary: node.summary, })) } diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue new file mode 100644 index 00000000..aca8bcd0 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 8936e134..3d595322 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -1,9 +1,10 @@ + + + + diff --git a/code/fms/fms-vue/src/components/ui/button/button.vue b/code/fms/fms-vue/src/components/ui/button/button.vue index 54ad1b6c..6b31f70d 100644 --- a/code/fms/fms-vue/src/components/ui/button/button.vue +++ b/code/fms/fms-vue/src/components/ui/button/button.vue @@ -5,17 +5,17 @@ import { Loader } from '@lucide/vue' /* ---------- props / emits / slots ---------- */ /** * 按钮视觉变体 - * @typedef {'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link'} ButtonType + * @typedef {'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link' | 'icon'} ButtonType */ const props = defineProps({ - /** 视觉变体 */ + /** 视觉变体;icon 为工具条 / 面板头部动作位的正方形描边图标按钮 */ type: { /** @type {ButtonType} */ type: String, default: 'primary', validator: (value) => - ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link'].includes(value), + ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link', 'icon'].includes(value), }, /** 禁用态 */ disabled: { diff --git a/code/fms/fms-vue/src/components/ui/button/index.scss b/code/fms/fms-vue/src/components/ui/button/index.scss index db32f3a5..4daca56c 100644 --- a/code/fms/fms-vue/src/components/ui/button/index.scss +++ b/code/fms/fms-vue/src/components/ui/button/index.scss @@ -134,6 +134,23 @@ text-decoration: underline; } +/* 图标按钮 —— 工具条 / 面板头部动作位的正方形描边按钮,不随文字缩放。 + * 必须写在 .button 之后:两处对 padding / border 的声明权重相同(0,1,0),靠源码顺序覆盖。 */ +.button-icon { + width: var(--fms-control-height); + height: var(--fms-control-height); + flex-shrink: 0; + padding: 0; + border-color: var(--fms-border); + background: var(--fms-card); + color: var(--fms-text-secondary); +} + +.button-icon:hover:not(:disabled) { + background: var(--fms-secondary); + color: var(--fms-text); +} + /* 禁用态 —— 参考 shadcn-vue:保留原配色,整体降透明度,悬停显示禁用光标 */ .button:disabled { opacity: 0.5; diff --git a/code/fms/fms-vue/src/components/ui/splitter/index.scss b/code/fms/fms-vue/src/components/ui/splitter/index.scss index 8787c09c..ac1f3a38 100644 --- a/code/fms/fms-vue/src/components/ui/splitter/index.scss +++ b/code/fms/fms-vue/src/components/ui/splitter/index.scss @@ -62,33 +62,6 @@ height: var(--fms-splitter-bar-size); } -/* 中央手柄:hover/聚焦/拖拽时高亮,提示可拖 */ -.fms-splitter-bar-grip { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: var(--fms-text-secondary); - border-radius: 2px; - opacity: 0; -} - -.fms-splitter-horizontal .fms-splitter-bar-grip { - width: var(--fms-splitter-bar-size); - height: var(--fms-splitter-handle-size); -} - -.fms-splitter-vertical .fms-splitter-bar-grip { - width: var(--fms-splitter-handle-size); - height: var(--fms-splitter-bar-size); -} - -.fms-splitter-bar:hover .fms-splitter-bar-grip, -.fms-splitter-bar-active .fms-splitter-bar-grip, -.fms-splitter-bar:focus-visible .fms-splitter-bar-grip { - opacity: 1; -} - .fms-splitter-bar:hover::before, .fms-splitter-bar-active::before, .fms-splitter-bar:focus-visible::before { @@ -96,27 +69,13 @@ } /* hover 模式(barOnHover):分割线平时隐藏,hover / 聚焦 / 拖拽时才显示。 - 线宽固定为 --fms-splitter-bar-size-hover(居中,略宽于命中区), - 命中区保持 6px 不变,拖拽热区不随之扩大 */ + 只表达「是否常显」这一件事 —— 线宽、中央手柄、面板是否滚动一律沿用默认行为, + 调用方需要不同的线宽 / 不要手柄时另行覆盖,不在这里替它们做决定。 */ .fms-splitter-bar-hover::before { background: var(--fms-text-secondary); opacity: 0; } -.fms-splitter-horizontal .fms-splitter-bar-hover::before { - width: var(--fms-splitter-bar-size-hover); - height: 100%; -} - -.fms-splitter-vertical .fms-splitter-bar-hover::before { - width: 100%; - height: var(--fms-splitter-bar-size-hover); -} - -.fms-splitter-bar-hover .fms-splitter-bar-grip { - display: none; -} - .fms-splitter-bar-hover:hover::before, .fms-splitter-bar-hover.fms-splitter-bar-active::before, .fms-splitter-bar-hover:focus-visible::before { diff --git a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue index 451af5b6..45cb036e 100644 --- a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue +++ b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue @@ -285,9 +285,7 @@ onBeforeUnmount(() => { :tabindex="canResize(index) ? 0 : undefined" @pointerdown="onBarPointerDown(index, $event)" @keydown="onBarKeydown(index, $event)" - > - - + > diff --git a/code/fms/fms-vue/src/layouts/DefaultLayout.vue b/code/fms/fms-vue/src/layouts/DefaultLayout.vue index a1b356a6..127ff249 100644 --- a/code/fms/fms-vue/src/layouts/DefaultLayout.vue +++ b/code/fms/fms-vue/src/layouts/DefaultLayout.vue @@ -187,6 +187,12 @@ provide('toggleSidebar', toggleSidebar) * ============================================================ */ /* ---- 布局骨架 ---- */ + +/* 侧栏与主区之间的灰缝宽度:由主区面板的左右 padding 产生(分隔条自身零宽,不占位)。 + * 因此分割线与命中区默认会落在灰缝左缘,需按「半个灰缝」右移才居中 —— + * 这里把灰缝宽度收成一个变量,供下面几处共用,避免以后调 padding 时漏改。 */ +$layout-gutter: 8px; + .fms-layout { display: flex; height: 100vh; @@ -221,14 +227,20 @@ provide('toggleSidebar', toggleSidebar) z-index: 1; } -/* 命中区:绝对定位在分隔条两侧各扩展 3px(共 6px),不占 flex 空间, +/* 视觉分割线:组件里用 left: 50% 把线居中于分隔条,而本处分隔条零宽、 + * 灰缝由主区面板 padding 产生,线会落在灰缝左缘 → 按半个灰缝右移,落在灰缝正中 */ +.fms-layout .fms-layout-splitter :deep(> .fms-splitter-bar)::before { + left: calc($layout-gutter / 2); +} + +/* 命中区:绝对定位在灰缝正中(分隔条位置两侧各半个命中区宽度),不占 flex 空间, * 且能正常触发 bar 的 pointerdown 拖拽 */ .fms-layout .fms-layout-splitter :deep(> .fms-splitter-bar)::after { content: ''; position: absolute; top: 0; bottom: 0; - left: calc(var(--fms-splitter-trigger-size) / -2); + left: calc($layout-gutter / 2 - var(--fms-splitter-trigger-size) / 2); width: var(--fms-splitter-trigger-size); } @@ -241,10 +253,10 @@ provide('toggleSidebar', toggleSidebar) transition: none; } -/* 主区面板:只留左右 8px padding(顶栏/内容区紧贴上边缘)。 - * 折叠态与展开态一致,保持 8px 左灰缝,避免侧栏折叠时与右侧紧贴 */ +/* 主区面板:左右各留一个灰缝宽度的 padding(顶栏/内容区紧贴上边缘)。 + * 折叠态与展开态一致,避免侧栏折叠时与右侧紧贴 */ .fms-main-panel { - padding: 0 8px; + padding: 0 $layout-gutter; } /* 覆盖 Splitter 面板默认 overflow: auto(scoped 同名规则特异性更高,需带 .fms-layout 提权), diff --git a/code/fms/fms-vue/src/layouts/components/AppTopbar.vue b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue index c9a8a422..8ddd3157 100644 --- a/code/fms/fms-vue/src/layouts/components/AppTopbar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue @@ -257,39 +257,28 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls box-shadow: none; } -/* 顶栏图标按钮:复用组件库 Button type="ghost"(透明底/无边框/hover 高亮)。 - 用 .fms-topbar 前缀提高特异性,覆盖 ghost 的横向 padding / 次要文字色 / 图标尺寸, - 使其保持 32×32 正方形、主文字色图标。hover 沿用 ghost 的 --fms-secondary。 */ +/* 顶栏图标按钮:复用组件库 Button type="ghost"。 + 透明底 / 圆角 / 字号 / 行高 / 光标 / 纵向布局 / 焦点环都已在 .button、.button-ghost 里, + 这里只保留顶栏自身的差异:32×32 方形、无横向内边距、主文字色(ghost 默认次要色)、 + 图标固定 16px。仍需 .fms-topbar 前缀提高特异性,才压得过 .button-ghost 的 padding / color + 与 .button 的 svg 尺寸。 */ .fms-topbar { .fms-icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; width: 32px; height: 32px; padding: 0; - border: none; - border-radius: var(--fms-control-radius); - background: transparent; color: var(--fms-text); - font-size: var(--fms-font-size); - line-height: var(--fms-line-height); - cursor: pointer; - /* 走动效 token:裸写时长会绕过 prefers-reduced-motion 的归零覆盖, - 且特异性高于 .button 时会把组件库的过渡整个顶掉 */ - transition: - background-color var(--fms-duration-base) var(--fms-ease-standard), - color var(--fms-duration-base) var(--fms-ease-standard); svg { width: 16px; height: 16px; } - &:focus-visible { - outline: 2px solid var(--fms-primary); - outline-offset: 2px; - } + /* 走动效 token:裸写时长会绕过 prefers-reduced-motion 的归零覆盖, + 且特异性高于 .button 时会把组件库的过渡整个顶掉 */ + transition: + background-color var(--fms-duration-base) var(--fms-ease-standard), + color var(--fms-duration-base) var(--fms-ease-standard); } } diff --git a/code/fms/fms-vue/src/services/exportService.js b/code/fms/fms-vue/src/services/exportService.js new file mode 100644 index 00000000..c8a4411a --- /dev/null +++ b/code/fms/fms-vue/src/services/exportService.js @@ -0,0 +1,133 @@ +import http from './http' + +/** + * 导出列表数据。 + * + * 契约(与后端 ExportController 一致): + * - 成功:响应体是文件流(xlsx,分卷时是 zip),文件名在 Content-Disposition 的 filename* 里; + * - 失败:后端在**写流之前**校验失败,返回 HTTP 200 + JSON 业务错误(code != 0)。 + * 因此这里必须先看 content-type 区分「文件」与「错误 JSON」,否则用户会下到一个 + * 内容其实是 JSON 的 .xlsx。 + */ + +/** 解析 Content-Disposition 中的文件名(兼容 filename*=UTF-8''xxx 与 filename="xxx") */ +export function parseFileName(disposition, fallback) { + const text = String(disposition || '') + const extended = text.match(/filename\*\s*=\s*([^;]+)/i) + if (extended?.[1]) { + const value = extended[1].trim().replace(/^UTF-8''/i, '') + try { + return decodeURIComponent(value) + } catch { + return value || fallback + } + } + const plain = text.match(/filename\s*=\s*"?([^";]+)"?/i) + return plain?.[1]?.trim() || fallback +} + +/** 把错误 JSON 读成可抛出的 Error */ +async function readErrorMessage(blob) { + try { + const text = await blob.text() + return JSON.parse(text)?.message || '导出失败' + } catch { + return '导出失败' + } +} + +function saveBlob(blob, fileName) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + // 延迟释放:立刻 revoke 在部分浏览器会让下载中断(旧系统遗漏了这一步,blob 常驻内存) + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +/** + * 导出列表数据。返回保存的文件名。 + * + * @param moduleId 模块编码(必填):后端据此推导导出列与字段权限,不接受前端传列清单 + * @param searchCondition 列表当前查询条件(不含 WHERE);后端会再 AND 数据范围 + * @param orderBy 排序子句;缺省用模块默认排序 + * @param rowKeys 勾选行主键值;非空时只导出这些行 + * @param keyField 勾选行主键列;缺省用模块默认主键 + * @param columns 勾选导出的字段;省略表示沿用列表全部列(批量导出用) + * @param rangeStart 按条数区间起始(1 基,闭区间);与 rangeEnd 同时给出才生效 + * @param rangeEnd 按条数区间结束 + * @param volumeSize 分卷行数:> 0 时返回 zip(每卷一个 xlsx),与区间互斥 + * @param withSummary 是否输出合计行,默认 true + */ +export async function exportListApi({ + moduleId, + searchCondition, + orderBy, + rowKeys, + keyField, + columns, + rangeStart, + rangeEnd, + volumeSize, + withSummary = true, +}) { + const response = await http.post( + '/export/list', + { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + columns: columns?.length ? columns : null, + rangeStart: rangeStart ?? null, + rangeEnd: rangeEnd ?? null, + volumeSize: volumeSize ?? null, + withSummary, + }, + { responseType: 'blob' }, + ) + + const blob = response.data + const contentType = String(response.headers?.['content-type'] || '') + if (contentType.includes('json')) { + throw new Error(await readErrorMessage(blob)) + } + + const fallback = contentType.includes('zip') ? '导出数据.zip' : '导出数据.xlsx' + const fileName = parseFileName(response.headers?.['content-disposition'], fallback) + saveBlob(blob, fileName) + return fileName +} + +/** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 与导出走同一条后端权限链路,所以数字与真正导出的行数一致 —— 不能用列表分页的 total 代替。 + * 返回 ApiResponse,调用方取 `res.data.total`。 + */ +export function countExportApi({ moduleId, searchCondition, orderBy, rowKeys, keyField }) { + return http.post('/export/count', { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + }) +} + +/** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。 + */ +export function loadTotalsApi({ moduleId, searchCondition, orderBy, rowKeys, keyField }) { + return http.post('/export/totals', { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + }) +} diff --git a/code/fms/fms-vue/src/services/http.js b/code/fms/fms-vue/src/services/http.js index 24c699e3..8631211b 100644 --- a/code/fms/fms-vue/src/services/http.js +++ b/code/fms/fms-vue/src/services/http.js @@ -51,6 +51,12 @@ http.interceptors.response.use( (response) => { const payload = response.data + // 文件下载(导出等):blob 响应没有 code 字段,原样透传响应本身, + // 调用方才能读到 headers(Content-Disposition 里的文件名、content-type 用于区分错误 JSON) + if (typeof Blob !== 'undefined' && payload instanceof Blob) { + return response + } + // 无 code 字段时按成功透传 if (payload?.code === undefined) { return { diff --git a/code/fms/fms-vue/src/styles/common.scss b/code/fms/fms-vue/src/styles/common.scss index d11244db..443d89f3 100644 --- a/code/fms/fms-vue/src/styles/common.scss +++ b/code/fms/fms-vue/src/styles/common.scss @@ -10,15 +10,21 @@ * 由 styles/index.css 全局引入,页面直接写 class,无需 scoped / :deep()。 * ============================================================ */ -/* ========== 一、左右分栏页面骨架 ========== - * 标准三层:.fms-page > .fms-page__body(放 Splitter)> .fms-page__main(右栏)。 - * 页面根靠 flex:1 在 DefaultLayout 的 .fms-content / .fms-page-wrap flex 链上撑满可视 - * 高度,不写 calc(100vh - xx);固定高度 + 内部滚动由各层自己的 overflow 承担。 */ +/* ========== 一、页面骨架 ========== + * 页面根固定用 .fms-page(撑满 + 裁切),内部按页面形态展开: + * - 左右分栏页:.fms-page__body(放 Splitter)> .fms-page__main(右栏主区); + * - 单纯列表页:内容直接放 .fms-page 里。 + * 高度走 flex 链:.fms-content / .fms-page-wrap 均已 flex:1 + min-height:0, + * 页面根本身只需 flex:1 + min-height:0(同 DefaultLayout 注释), + * 不写 height:100%、100vh 或 calc(100vh - xx) 魔数。 + * 裁切由页面骨架自己承担(侧栏折叠等过渡瞬间内容会短暂超宽,不裁切会闪出滚动条), + * 滚动交给内部容器(Splitter / 表格 / 内容卡)。 */ .fms-page { display: flex; flex-direction: column; flex: 1; min-height: 0; + overflow: hidden; } /* 左右分栏容器:左栏(树面板)+ 右栏(主区),由 Splitter 承担分栏 */ @@ -28,7 +34,8 @@ min-height: 0; } -/* 右栏主区:纵向堆叠「工具条 / 内容卡」,卡间 8px 灰缝(同 .fms-stack + gap) */ +/* 右栏主区:纵向堆叠「工具条 / 内容卡」,卡间 8px 灰缝。 + * height:100% 供 SplitterPanel(块级容器,高度由 splitter 拉伸确定)内的主区撑满。 */ .fms-page__main { display: flex; flex-direction: column; @@ -41,120 +48,12 @@ gap: 8px; } -/* ========== 二、撑满与堆叠容器 ========== */ - -/* 撑满父级:宽高 100% + 允许收缩(flex 子项下不会把父级撑破)。 - * 刻意不含 overflow:hidden —— 子元素带 box-shadow 时父级裁切会把阴影切掉, - * 需要裁切的场景叠加 .fms-stack 或就地写 overflow。 */ -.fms-fill { - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} - -/* 纵向堆叠:仅表达 flex 方向,撑满/裁切按需叠加 .fms-fill / .fms-stack */ -.fms-column { - display: flex; - flex-direction: column; -} - -/* 裁切溢出:页面根 / 分栏容器用。侧栏折叠等过渡瞬间内容宽度会短暂超出容器, - * 不裁切会把滚动条闪出来;横向滚动应由内部容器(表格 / splitter)自己处理。 */ -.fms-clip { - overflow: hidden; -} - -/* 纵向堆叠 + 撑满 + 裁切:内容卡容器(内部自带滚动)的通用形态 */ -.fms-stack { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -/* ========== 三、Splitter 视觉定制 ========== - * 面板不再留白,两张白卡紧贴;灰缝由分隔条自身的触发区宽度 - * (--fms-splitter-trigger-size)承担。常显线平时透明,hover / 拖拽 / 键盘聚焦时 - * 才显示整条细线 + 中央手柄。 - * 必须是独立修饰类 —— .fms-splitter 是 Splitter 组件自己的根类, - * 直接写会波及弹窗 / 抽屉 / 设计器里的所有分割条。 */ -.fms-splitter-clean { - > .fms-splitter-panel { - overflow: hidden; - } - - .fms-splitter-bar::before { - opacity: 0; - } - - .fms-splitter-bar:hover::before, - .fms-splitter-bar-active::before, - .fms-splitter-bar:focus-visible::before { - opacity: 1; - } -} - -/* ========== 四、左树面板(左右结构的左栏)========== - * 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔。 - * 内部纵向排列:头部(搜索 + 动作)→ 滚动区。 */ -.fms-tree-panel { - position: relative; - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - height: 100%; - min-height: 0; - padding: 8px; - background: var(--fms-card); - border-radius: var(--fms-control-radius); - box-shadow: var(--fms-shadow); - overflow: hidden; -} - -/* 头部条:搜索框撑满,动作按钮靠右且不参与压缩 */ -.fms-tree-panel__header { - display: flex; - align-items: center; - gap: 6px; - flex: none; -} - -.fms-tree-panel__search { - flex: 1; - min-width: 0; -} - -.fms-tree-panel__search-icon { - color: var(--fms-text-secondary); -} - -.fms-tree-panel__actions { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -/* 树滚动区 */ -.fms-tree-panel__scroll { - flex: 1; - min-height: 0; - overflow: auto; - - /* 树宽度跟随面板:内容更宽时横向滚动,而不是把卡片撑破 */ - .tree { - min-width: 100%; - } -} +/* ========== 二、通用部件 ========== */ /* 树节点名称:撑满剩余空间(.tree-node-content 是 flex 容器,需 flex:1 才有空间可撑), - * 超出单行省略。停用等状态色由使用方自己叠加。 */ + * 超出单行省略。停用等状态色由使用方自己叠加。 + * 由使用方写在自己的 #title 插槽内容里,无法随组件 scoped 生效,故留在全局。 + * 左树面板的骨架样式(白卡 / 头部 / 搜索框 / 滚动区)随 FmsTreePanel 组件走,见该组件。 */ .fms-tree-node-name { flex: 1; min-width: 0; @@ -163,8 +62,6 @@ white-space: nowrap; } -/* ========== 五、通用部件 ========== */ - /* 白卡三件套:灰底画布上的内容面。卡间用 gap/margin 留灰缝,不用 border 画分隔线 */ .fms-card { background: var(--fms-card); @@ -172,38 +69,6 @@ box-shadow: var(--fms-shadow); } -/* 描边图标按钮:树面板动作区里的工具位,正方形、不随文字缩放。 - * 必须嵌在 .fms-tree-panel__actions 下写:它挂在组件库 Button(type="ghost")上, - * 需要 (0,2,0) 才压得过 .button / .button-ghost 的 padding / border / 背景, - * 以及 (0,3,0) 的 ghost hover —— 与 AppTopbar 里 `.fms-topbar .fms-icon-btn` 同一做法。 */ -.fms-tree-panel__actions .fms-tool-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--fms-control-height); - height: var(--fms-control-height); - padding: 0; - border: 1px solid var(--fms-border); - border-radius: var(--fms-control-radius); - background: var(--fms-card); - color: var(--fms-text-secondary); - cursor: pointer; - transition: - border-color var(--fms-duration-base) var(--fms-ease-standard), - background var(--fms-duration-base) var(--fms-ease-standard), - color var(--fms-duration-base) var(--fms-ease-standard); - - &:hover:not(:disabled) { - background: var(--fms-secondary); - color: var(--fms-text); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - /* 卡片内工具条:白底 + 圆角、不投影(与内容卡同一平面,靠灰缝分隔) */ .fms-toolbar { display: flex; @@ -233,10 +98,3 @@ opacity: 0.4; } } - -/* 面板内区块标题 */ -.fms-pane-title { - font-size: var(--fms-font-size); - font-weight: 600; - color: var(--fms-text); -} diff --git a/code/fms/fms-vue/src/theme/tokens.css b/code/fms/fms-vue/src/theme/tokens.css index 8272e999..289158c4 100644 --- a/code/fms/fms-vue/src/theme/tokens.css +++ b/code/fms/fms-vue/src/theme/tokens.css @@ -122,7 +122,7 @@ --fms-drawer-body-padding: 12px 20px; --fms-drawer-footer-padding: 8px 20px; - /* 分割(splitter):命中区 / 视觉条 / hover 线宽 / 中央手柄(不随主题变化) */ + /* 分割(splitter):命中区 / 视觉条 / hover 线宽(不随主题变化) */ /* --fms-splitter-bar-size-hover 同时被 modal / drawer 缩放手柄的拖拽线引用 */ --fms-splitter-trigger-size: 10px; @@ -130,7 +130,6 @@ --fms-grid-gutter: 0px; --fms-splitter-bar-size: 3px; --fms-splitter-bar-size-hover: 4px; - --fms-splitter-handle-size: 20px; /* 分段控制器(segmented):轨道 padding / 项高 / 项内边距 / 字号 / 圆角(不随主题变化) */ --fms-segmented-track-padding: 2px; diff --git a/code/fms/fms-vue/src/views/base/othercompany/index.vue b/code/fms/fms-vue/src/views/base/othercompany/index.vue index 27be921e..a8a4f561 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/index.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/index.vue @@ -10,7 +10,7 @@ */ import { computed, ref, watch } from 'vue' import { useRouter } from 'vue-router' -import { Pencil, Plus, Search, Trash2 } from '@lucide/vue' +import { Pencil, Plus, Trash2 } from '@lucide/vue' import { Button, Form, @@ -23,7 +23,7 @@ import { } from '@/components/ui' import { FmsModuleListPage } from '@/components/fms-module-list' import { escapeSqlText } from '@/components/fms-module-common/queryUtils' -import FmsTree from '@/components/fms-tree/FmsTree.vue' +import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue' import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api' import { usePermissionStore } from '@/stores/permissions' import { @@ -440,58 +440,40 @@ void loadCategories()