20260916231505
This commit is contained in:
1 parent
de05498552
commit
182ed86966
126 files changed
+5900
-766
No files matched your search
@@ -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<String, Object> params,
|
||||
HttpServletResponse response
|
||||
) throws IOException {
|
||||
exportService.export(readRequest(params), response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 符合条件的行数(批量导出弹窗的「共 N 条」)。
|
||||
* 走与导出完全相同的权限链路,因此数字与真正导出的行数一致。
|
||||
*/
|
||||
@PostMapping("/count")
|
||||
public ApiResponse<Map<String, Object>> count(
|
||||
@RequestBody(required = false) Map<String, Object> params
|
||||
) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", exportService.count(readRequest(params)));
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 各合计列的聚合值(列表底部的「总合计」)。
|
||||
* 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。
|
||||
*/
|
||||
@PostMapping("/totals")
|
||||
public ApiResponse<Map<String, Object>> totals(
|
||||
@RequestBody(required = false) Map<String, Object> params
|
||||
) {
|
||||
return ApiResponse.success(exportService.totals(readRequest(params)));
|
||||
}
|
||||
|
||||
private ExportService.ExportRequest readRequest(Map<String, Object> 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<Object> readRowKeys(Map<String, Object> params) {
|
||||
List<Object> values = ParamUtils.getList(params, "rowKeys");
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private Integer readInteger(Map<String, Object> params, String field) {
|
||||
Number number = ParamUtils.get(params, field, Number.class);
|
||||
return number == null ? null : number.intValue();
|
||||
}
|
||||
|
||||
/** 缺省输出合计行 */
|
||||
private boolean readBoolean(Map<String, Object> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<List<String>>} 表头结构:外层是列,内层是该列自上而下的各层标题。
|
||||
*
|
||||
* 例:`费用 > 明细 > b_a` 这一列 → `["费用", "明细", "b_a"]`;顶层叶子 `b_d` → `["b_d"]`。
|
||||
* 各列内层长度可以不同(顶层叶子只有 1 层),Fesod 会按「底部对齐」补空行 ——
|
||||
* 也就是说列名一定落在表头最后一行,这正是多级表头的常规形态。
|
||||
*
|
||||
* 合并由 Fesod 的 `automaticMergeHead(true)` 负责,本类不生成任何合并区域
|
||||
* (原先手写合并的 ExportHeaderWriter 已删除)。
|
||||
*/
|
||||
final class ExportHeadBuilder {
|
||||
|
||||
private ExportHeadBuilder() {
|
||||
}
|
||||
|
||||
/** 表头结构:外层 = 叶子列(顺序与数据列一致),内层 = 该列自上而下的标题 */
|
||||
static List<List<String>> headOf(List<ExportNode> nodes) {
|
||||
List<List<String>> head = new ArrayList<>();
|
||||
collect(nodes, new ArrayList<>(), head);
|
||||
return head;
|
||||
}
|
||||
|
||||
private static void collect(List<ExportNode> nodes, List<String> path, List<List<String>> out) {
|
||||
for (ExportNode node : nodes) {
|
||||
if (node.isGroup()) {
|
||||
List<String> next = new ArrayList<>(path);
|
||||
next.add(node.title() == null ? "" : node.title());
|
||||
collect(node.children(), next, out);
|
||||
continue;
|
||||
}
|
||||
List<String> column = new ArrayList<>(path);
|
||||
column.add(node.column().caption());
|
||||
out.add(column);
|
||||
}
|
||||
}
|
||||
|
||||
/** 表头层数(最长列的层数):单行表头为 1,用于冻结窗格 */
|
||||
static int depth(List<ExportNode> 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<ExportNode> nodes) {
|
||||
int count = 0;
|
||||
for (ExportNode node : nodes) {
|
||||
count += node.isGroup() ? leafCount(node.children()) : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -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<ExportNode> children) {
|
||||
public boolean isGroup() {
|
||||
return children != null && !children.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个模块的导出定义 */
|
||||
public record ExportSchema(
|
||||
String moduleId,
|
||||
String moduleName,
|
||||
String viewTable,
|
||||
String keyField,
|
||||
String orderSql,
|
||||
List<ExportNode> nodes
|
||||
) {
|
||||
/** 按表头顺序摊平的叶子列 */
|
||||
public List<ExportColumn> columns() {
|
||||
List<ExportColumn> columns = new ArrayList<>();
|
||||
collectColumns(nodes, columns);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static void collectColumns(List<ExportNode> nodes, List<ExportColumn> 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<String, Object> 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<String, String> 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<ExportNode> 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<String> deniedFields,
|
||||
List<String> selectedColumns
|
||||
) {
|
||||
Set<String> selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns);
|
||||
List<ExportNode> 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<ExportNode> filterNodes(
|
||||
List<ExportNode> nodes,
|
||||
Set<String> deniedFields,
|
||||
Set<String> selected
|
||||
) {
|
||||
List<ExportNode> kept = new ArrayList<>();
|
||||
for (ExportNode node : nodes) {
|
||||
if (node.isGroup()) {
|
||||
List<ExportNode> 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<String, String> loadFieldCaptions(Connection connection, String moduleId) throws SQLException {
|
||||
Map<String, String> 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<JsonNode> 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<JsonNode> nodes = new ArrayList<>();
|
||||
for (JsonNode node : columns) {
|
||||
nodes.add(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private List<ExportNode> buildNodes(List<JsonNode> jsonNodes, Map<String, String> captions) {
|
||||
List<ExportNode> nodes = new ArrayList<>();
|
||||
for (JsonNode node : jsonNodes) {
|
||||
if (node == null || !node.isObject()) {
|
||||
continue;
|
||||
}
|
||||
if ("group".equals(node.path("type").asString(""))) {
|
||||
List<JsonNode> children = new ArrayList<>();
|
||||
JsonNode childNodes = node.path("children");
|
||||
if (childNodes != null && childNodes.isArray()) {
|
||||
for (JsonNode child : childNodes) {
|
||||
children.add(child);
|
||||
}
|
||||
}
|
||||
List<ExportNode> 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<String, Object> 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<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<Object> rowKeys,
|
||||
String keyField,
|
||||
List<String> columns,
|
||||
Integer rangeStart,
|
||||
Integer rangeEnd,
|
||||
Integer volumeSize,
|
||||
boolean withSummary
|
||||
) {
|
||||
}
|
||||
|
||||
/** 一次导出请求预处理后的产物:列、WHERE、ORDER 都已定稿 */
|
||||
private record PreparedExport(
|
||||
String userId,
|
||||
ExportSchema schema,
|
||||
List<ExportColumn> 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<String, Object> totals(ExportRequest request) {
|
||||
String userId = OrgContext.requireUserId();
|
||||
PreparedExport prepared = prepare(request, userId);
|
||||
List<Object> whereParameters = bindWhereParameters(new ArrayList<>(), request);
|
||||
List<BigDecimal> totals = loadTotals(prepared, whereParameters, null, null);
|
||||
|
||||
// loadTotals 用「空列表」表示没有可合计的列(导出侧据此决定不写合计行)
|
||||
List<ExportColumn> columns = prepared.columns();
|
||||
if (totals.size() != columns.size()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<String, Object> 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<Object> whereParameters = bindWhereParameters(new ArrayList<>(), request);
|
||||
List<Object> parameters = new ArrayList<>(whereParameters);
|
||||
if (isRanged(request)) {
|
||||
parameters.add((long) request.rangeStart() - 1);
|
||||
parameters.add((long) (request.rangeEnd() - request.rangeStart() + 1));
|
||||
}
|
||||
|
||||
// 合计口径 = 本次导出的数据范围(当前页 / 勾选行 / 区间),与列表底部的「该页合计」一致
|
||||
List<BigDecimal> 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<ExportColumn> 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<Object> bindWhereParameters(List<Object> 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<String> 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<ExportColumn> 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<ExportColumn> 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<BigDecimal> loadTotals(
|
||||
PreparedExport prepared,
|
||||
List<Object> whereParameters,
|
||||
Integer rangeStart,
|
||||
Integer rangeEnd
|
||||
) {
|
||||
List<ExportColumn> columns = prepared.columns();
|
||||
List<Integer> 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<BigDecimal> 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<Integer> summableIndexes(List<ExportColumn> columns) {
|
||||
List<Integer> 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<ExportColumn> columns,
|
||||
String whereSql,
|
||||
String orderSql
|
||||
) {
|
||||
List<String> 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<String> 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<Object> whereParameters = bindWhereParameters(new ArrayList<>(), request);
|
||||
// 该卷的合计:与明细同一个行范围,口径与其它导出方式一致
|
||||
List<BigDecimal> 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<Object> 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<ExportColumn> columns,
|
||||
String selectSql,
|
||||
List<Object> parameters,
|
||||
List<BigDecimal> 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<Object> next() {
|
||||
List<Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ExportColumn> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Object> next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodes 表头树(分组 + 叶子),用于生成多级表头
|
||||
* @param columns 叶子列(顺序与 {@link RowSource} 返回的行内元素一致),用于列宽
|
||||
* @param rows 明细行,元素个数应与 columns 一致
|
||||
* @param totals 合计行,元素个数应与 columns 一致;空列表表示不输出合计行
|
||||
* @return 写出的明细行数
|
||||
*/
|
||||
static int write(
|
||||
OutputStream outputStream,
|
||||
String sheetName,
|
||||
List<ExportNode> nodes,
|
||||
List<ExportColumn> columns,
|
||||
RowSource rows,
|
||||
List<BigDecimal> totals
|
||||
) throws IOException {
|
||||
List<List<String>> 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<List<Object>> 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<Object> buildTotalRow(int columnCount, List<BigDecimal> totals) {
|
||||
List<Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> deniedExportFields(String moduleId, String userId) {
|
||||
Set<String> 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<String, Object> 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<Map<String, Object>> rows = query(connection, sql, userId, moduleId, operation);
|
||||
List<Map<String, Object>> exact = new ArrayList<>();
|
||||
List<Map<String, Object>> wildcard = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
// 精确操作优先:命中精确规则时不再叠加 '*' 规则(14.4 判定规则 1)
|
||||
if (operation.equalsIgnoreCase(text(row.get("b_operation")))) {
|
||||
exact.add(row);
|
||||
} else {
|
||||
wildcard.add(row);
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> effective = exact.isEmpty() ? wildcard : exact;
|
||||
|
||||
String deptId = null;
|
||||
List<String> fragments = new ArrayList<>();
|
||||
for (Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> rows = new ArrayList<>();
|
||||
while (resultSet.next()) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("数据库标识符不能为空");
|
||||
|
||||
+270
@@ -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<String, Integer> 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<Integer> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ExportNode> nodes) {
|
||||
return new ExportSchema("m1", "测试模块", "v_test", "b_id", orderSql, nodes);
|
||||
}
|
||||
|
||||
private static ExportService.ExportRequest request(
|
||||
String searchCondition,
|
||||
String orderBy,
|
||||
List<Object> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> 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<ExportColumn> columns = List.of(column("b_name"));
|
||||
assertThat(ExportService.summableIndexes(columns)).isEmpty();
|
||||
}
|
||||
|
||||
/* ================= 表头结构(转成 Fesod 的 List<List<String>>) ================= */
|
||||
|
||||
@Test
|
||||
void 单行表头每列只有一个标题() {
|
||||
List<ExportNode> 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<ExportNode> 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<ExportNode> 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<ExportNode> 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);
|
||||
}
|
||||
}
|
||||
+211
@@ -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<List<Object>> rows) {
|
||||
var iterator = rows.iterator();
|
||||
return new ExportWorkbookWriter.RowSource() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return iterator.hasNext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Object> next() {
|
||||
return iterator.next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Workbook write(
|
||||
List<ExportNode> nodes,
|
||||
List<ExportColumn> columns,
|
||||
List<List<Object>> rows,
|
||||
List<BigDecimal> 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<ExportColumn> columns = List.of(column("b_name", 12), column("b_amount", 20));
|
||||
List<ExportNode> nodes = List.of(leaf("b_name"), leaf("b_amount"));
|
||||
List<BigDecimal> 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<ExportColumn> 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<ExportNode> 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<ExportColumn> 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<ExportNode> 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<ExportColumn> 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<ExportColumn> columns = List.of(column("b_name", 12));
|
||||
List<List<Object>> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user