20260917165839

This commit is contained in:
oneao committed 2026-09-17 16:58:40 +08:00
1 parent 182ed86966
commit 38e1e66c3b
108 files changed
+2497 -1215

No files matched your search

@@ -56,15 +56,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
return;
}
if (!activeSessionRegistry.hasActiveSession(claims.orgId(), claims.userId())) {
writeUnauthorized(
response,
ApiResponse.AUTH_ERROR_CODE,
"未登录或登录已失效"
);
return;
}
// JWT 已通过签名和到期校验;重启后缺失的内存会话由首个有效请求恢复。
if (!activeSessionRegistry.isActive(
claims.orgId(),
claims.userId(),
@@ -1,6 +1,7 @@
package cn.g3soft.fmsapi.controller;
import cn.g3soft.fmsapi.service.ExportService;
import cn.g3soft.fmsapi.exception.BusinessException;
import cn.g3soft.fmsapi.utils.ApiResponse;
import cn.g3soft.fmsapi.utils.ParamUtils;
import jakarta.annotation.Resource;
@@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -87,7 +89,12 @@ public class ExportController {
private Integer readInteger(Map<String, Object> params, String field) {
Number number = ParamUtils.get(params, field, Number.class);
return number == null ? null : number.intValue();
if (number == null) return null;
try {
return new BigDecimal(number.toString()).intValueExact();
} catch (ArithmeticException | NumberFormatException exception) {
throw new BusinessException(field + " 必须是有效整数");
}
}
/** 缺省输出合计行 */
@@ -14,15 +14,17 @@ public class ActiveSessionRegistry {
activeSessions.put(new AccountKey(orgId, userId), sessionId);
}
/**
* 必须在 JWT 签名和有效期校验通过后调用。
* 重启后由首个有效请求恢复账号会话,不持久化;重启前的互踢历史也随之清空。
*/
public boolean isActive(String orgId, String userId, String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return false;
}
return sessionId.equals(activeSessions.get(new AccountKey(orgId, userId)));
}
public boolean hasActiveSession(String orgId, String userId) {
return activeSessions.containsKey(new AccountKey(orgId, userId));
// 原子登记:并发首次请求只能有一个会话胜出,也不会覆盖并发的新登录。
String activeSession = activeSessions.putIfAbsent(new AccountKey(orgId, userId), sessionId);
return activeSession == null || sessionId.equals(activeSession);
}
private record AccountKey(String orgId, String userId) {
@@ -156,6 +156,9 @@ public class ExportSchemaService {
) {
Set<String> selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns);
List<ExportNode> nodes = filterNodes(schema.nodes(), deniedFields, selected);
if (selected != null) {
nodes = orderNodes(nodes, selected);
}
if (nodes.isEmpty()) {
throw new BusinessException("没有可导出的列");
}
@@ -198,6 +201,45 @@ public class ExportSchemaService {
return kept;
}
/** 按用户选择的叶子顺序重建表头;跨组拖拽时分开不相邻的同组列,保留每列的分组路径。 */
private List<ExportNode> orderNodes(List<ExportNode> nodes, Set<String> selected) {
Map<String, List<ExportNode>> paths = new LinkedHashMap<>();
collectPaths(nodes, List.of(), paths);
List<ExportNode> ordered = new ArrayList<>();
List<ExportNode> previous = List.of();
List<List<ExportNode>> levels = new ArrayList<>();
levels.add(ordered);
for (String field : selected) {
List<ExportNode> path = paths.get(field);
if (path == null) continue;
int shared = 0;
while (shared < path.size() - 1 && shared < previous.size() - 1
&& path.get(shared) == previous.get(shared)) {
shared++;
}
while (levels.size() > shared + 1) levels.remove(levels.size() - 1);
for (int depth = shared; depth < path.size() - 1; depth++) {
List<ExportNode> children = new ArrayList<>();
levels.get(depth).add(new ExportNode(path.get(depth).title(), null, children));
levels.add(children);
}
levels.get(path.size() - 1).add(path.get(path.size() - 1));
previous = path;
}
return ordered;
}
private void collectPaths(
List<ExportNode> nodes, List<ExportNode> parents, Map<String, List<ExportNode>> paths
) {
for (ExportNode node : nodes) {
List<ExportNode> path = new ArrayList<>(parents);
path.add(node);
if (node.isGroup()) collectPaths(node.children(), path, paths);
else if (node.column() != null) paths.putIfAbsent(node.column().field(), path);
}
}
/* ================= 元数据读取 ================= */
private Map<String, String> loadFieldCaptions(Connection connection, String moduleId) throws SQLException {
@@ -263,7 +305,7 @@ public class ExportSchemaService {
continue;
}
String field = text(node.path("field").asString(null));
if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches()) {
if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches() || !captions.containsKey(field)) {
continue;
}
// 与列表一致:visible=false 的节点不出现在列表,也不出现在导出
@@ -214,8 +214,8 @@ public class ExportService {
? loadTotals(
prepared,
whereParameters,
isRanged(request) ? request.rangeStart() : null,
isRanged(request) ? request.rangeEnd() : null
isRanged(request) ? request.rangeStart().longValue() : null,
isRanged(request) ? request.rangeEnd().longValue() : null
)
: List.of();
@@ -267,9 +267,12 @@ public class ExportService {
private boolean isVolumed(ExportRequest request) {
Integer volumeSize = request.volumeSize();
if (volumeSize == null || volumeSize <= 0) {
if (volumeSize == null) {
return false;
}
if (volumeSize <= 0) {
throw new BusinessException("每卷行数必须大于 0");
}
if (isRanged(request)) {
throw new BusinessException("分卷导出与按区间导出不能同时使用");
}
@@ -321,9 +324,12 @@ public class ExportService {
? schema.orderSql()
: requestedOrderBy.trim();
if (orderBy == null || orderBy.isBlank()) {
return null;
orderBy = schema.keyField() == null ? null : schema.keyField() + " ASC";
} else if (schema.keyField() != null) {
// 相同排序值以主键打破平局,明细、区间合计与各卷使用同一行顺序。
orderBy += ", " + schema.keyField() + " ASC";
}
return dbUtils.buildSafeOrderBy(orderBy);
return orderBy == null ? null : dbUtils.buildSafeOrderBy(orderBy);
}
String buildSelectSql(
@@ -370,10 +376,10 @@ public class ExportService {
private boolean isRanged(ExportRequest request) {
Integer start = request.rangeStart();
Integer end = request.rangeEnd();
if (start == null || end == null) {
if (start == null && end == null) {
return false;
}
if (start < 1 || end < start) {
if (start == null || end == null || start < 1 || end < start) {
throw new BusinessException("导出区间不正确");
}
return true;
@@ -387,8 +393,8 @@ public class ExportService {
private List<BigDecimal> loadTotals(
PreparedExport prepared,
List<Object> whereParameters,
Integer rangeStart,
Integer rangeEnd
Long rangeStart,
Long rangeEnd
) {
List<ExportColumn> columns = prepared.columns();
List<Integer> indexes = summableIndexes(columns);
@@ -412,16 +418,15 @@ public class ExportService {
statement.setObject(index++, parameter);
}
if (ranged) {
statement.setLong(index++, (long) rangeStart - 1);
statement.setLong(index, (long) (rangeEnd - rangeStart + 1));
// ROW_NUMBER 从 1 开始,BETWEEN 接收闭区间端点,不是 OFFSET/FETCH 的偏移和条数。
statement.setLong(index++, rangeStart);
statement.setLong(index, rangeEnd);
}
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);
}
totals.set(indexes.get(position), value == null ? BigDecimal.ZERO : value);
}
}
}
@@ -530,20 +535,24 @@ public class ExportService {
dbUtils.validateReadOnlySql(selectSql);
long volumeCount = total == 0 ? 1 : (total + volumeSize - 1) / volumeSize;
// 第一卷查询、合计和渲染成功后才开始响应,首卷失败仍能返回业务错误。
byte[] firstVolume = renderVolume(prepared, request, selectSql, sheetName, 0,
(int) Math.min(volumeSize, total));
applyResponseHeader(response, prepared.schema().moduleName(), "zip", "application/zip");
ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8);
try {
try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8)) {
for (long index = 0; index < volumeCount; index++) {
long offset = index * volumeSize;
int count = (int) Math.min(volumeSize, Math.max(0, total - offset));
byte[] content = index == 0 ? firstVolume
: renderVolume(prepared, request, selectSql, sheetName, offset, count);
zip.putNextEntry(new ZipEntry(volumeEntryName(prepared.schema().moduleName(), offset, count)));
zip.write(renderVolume(prepared, request, selectSql, sheetName, offset, count));
zip.write(content);
firstVolume = null;
zip.closeEntry();
}
zip.finish();
} finally {
response.flushBuffer();
}
response.flushBuffer();
}
/** 单卷内容渲染到内存:卷大小已限定行数,因此内存有界 */
@@ -558,7 +567,7 @@ public class ExportService {
List<Object> whereParameters = bindWhereParameters(new ArrayList<>(), request);
// 该卷的合计:与明细同一个行范围,口径与其它导出方式一致
List<BigDecimal> totals = request.withSummary() && count > 0
? loadTotals(prepared, whereParameters, (int) (offset + 1), (int) (offset + count))
? loadTotals(prepared, whereParameters, offset + 1, offset + count)
: List.of();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
if (count <= 0) {
@@ -5,6 +5,7 @@ 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 org.apache.poi.ss.util.WorkbookUtil;
import java.io.IOException;
import java.io.OutputStream;
@@ -60,7 +61,7 @@ final class ExportWorkbookWriter {
.registerWriteHandler(styleHandler)
.build();
try {
WriteSheet sheet = FesodSheet.writerSheet(sheetName).build();
WriteSheet sheet = FesodSheet.writerSheet(WorkbookUtil.createSafeSheetName(sheetName)).build();
int rowCount = 0;
List<List<Object>> batch = new ArrayList<>(BATCH_SIZE);
@@ -0,0 +1,124 @@
package cn.g3soft.fmsapi.config;
import cn.g3soft.fmsapi.database.OrgContext;
import cn.g3soft.fmsapi.service.ActiveSessionRegistry;
import cn.g3soft.fmsapi.utils.JwtUtils;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import tools.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class JwtAuthFilterTests {
private static final String SECRET = "restart-test-signing-key-at-least-32-bytes";
private final ObjectMapper objectMapper = new ObjectMapper();
private ActiveSessionRegistry sessions;
@BeforeEach
void setUp() {
sessions = new ActiveSessionRegistry();
}
private JwtUtils newJwtUtils() {
AuthProperties properties = new AuthProperties();
properties.setJwtSecret(SECRET);
JwtUtils jwt = new JwtUtils();
ReflectionTestUtils.setField(jwt, "properties", properties);
jwt.init();
return jwt;
}
private JwtAuthFilter newFilter() {
JwtAuthFilter filter = new JwtAuthFilter();
ReflectionTestUtils.setField(filter, "jwtUtils", newJwtUtils());
ReflectionTestUtils.setField(filter, "activeSessionRegistry", sessions);
ReflectionTestUtils.setField(filter, "objectMapper", objectMapper);
return filter;
}
private MockHttpServletRequest request(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/data/load");
request.setServletPath("/data/load");
request.addHeader("Authorization", "Bearer " + token);
return request;
}
@Test
void 重建签名校验器和过滤器后原Token仍然通过校验() throws Exception {
String token = newJwtUtils().generateToken("user", "G3HD", "session-a");
sessions.activate("G3HD", "user", "session-a");
sessions = new ActiveSessionRegistry();
FilterChain chain = mock(FilterChain.class);
var request = request(token);
var response = new MockHttpServletResponse();
doAnswer(invocation -> {
assertThat(OrgContext.requireOrgId()).isEqualTo("G3HD");
assertThat(OrgContext.requireUserId()).isEqualTo("user");
return null;
}).when(chain).doFilter(request, response);
newFilter().doFilter(request, response, chain);
verify(chain).doFilter(request, response);
assertThat(response.getContentAsString()).isEmpty();
assertThat(OrgContext.getOrgId()).isNull();
assertThat(sessions.isActive("G3HD", "user", "session-a")).isTrue();
assertThat(sessions.isActive("G3HD", "user", "session-other")).isFalse();
}
@Test
void 后登录替换会话后旧Token返回互踢错误() throws Exception {
String token = newJwtUtils().generateToken("user", "G3HD", "session-a");
sessions.activate("G3HD", "user", "session-b");
var request = request(token);
var response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
newFilter().doFilter(request, response, chain);
assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(40101);
verify(chain, never()).doFilter(request, response);
}
@Test
void 无效签名不能恢复内存会话() throws Exception {
String invalid = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD")
.expiration(Date.from(Instant.now().plusSeconds(60)))
.signWith(Keys.hmacShaKeyFor((SECRET + "-wrong").getBytes(StandardCharsets.UTF_8))).compact();
var response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
newFilter().doFilter(request(invalid), response, chain);
assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401);
verifyNoInteractions(chain);
assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue();
}
@Test
void 过期Token不因重启恢复会话而延长有效期() throws Exception {
String expired = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD")
.expiration(Date.from(Instant.now().minusSeconds(60)))
.signWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8))).compact();
var response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
newFilter().doFilter(request(expired), response, chain);
assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401);
verifyNoInteractions(chain);
assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue();
}
}
@@ -2,6 +2,10 @@ package cn.g3soft.fmsapi.service;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
class ActiveSessionRegistryTests {
@@ -10,12 +14,9 @@ class ActiveSessionRegistryTests {
@Test
void newerLoginReplacesPreviousSessionForSameAccount() {
assertThat(registry.hasActiveSession("G3HD", "USER_A")).isFalse();
registry.activate("G3HD", "USER_A", "session-a");
registry.activate("G3HD", "USER_A", "session-b");
assertThat(registry.hasActiveSession("G3HD", "USER_A")).isTrue();
assertThat(registry.isActive("G3HD", "USER_A", "session-a")).isFalse();
assertThat(registry.isActive("G3HD", "USER_A", "session-b")).isTrue();
}
@@ -28,4 +29,46 @@ class ActiveSessionRegistryTests {
assertThat(registry.isActive("G3HD", "USER_A", "g3hd-session")).isTrue();
assertThat(registry.isActive("OTHER", "USER_A", "other-session")).isTrue();
}
@Test
void 重启后首次有效请求恢复会话而新登录仍能顶掉它() {
registry.activate("G3HD", "USER_A", "session-a");
ActiveSessionRegistry restarted = new ActiveSessionRegistry();
assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue();
assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue();
restarted.activate("G3HD", "USER_A", "session-b");
assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isFalse();
assertThat(restarted.isActive("G3HD", "USER_A", "session-b")).isTrue();
}
@Test
void 空会话不能占用重启后的账号() {
assertThat(registry.isActive("G3HD", "USER_A", null)).isFalse();
assertThat(registry.isActive("G3HD", "USER_A", " ")).isFalse();
assertThat(registry.isActive("G3HD", "USER_A", "valid-session")).isTrue();
}
@Test
void 并发首次恢复只有一个不同会话能成功() throws Exception {
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newFixedThreadPool(2)) {
var first = executor.submit(() -> {
start.await();
return registry.isActive("G3HD", "USER_A", "session-a");
});
var second = executor.submit(() -> {
start.await();
return registry.isActive("G3HD", "USER_A", "session-b");
});
start.countDown();
boolean firstAccepted = first.get(5, TimeUnit.SECONDS);
boolean secondAccepted = second.get(5, TimeUnit.SECONDS);
assertThat(firstAccepted).isNotEqualTo(secondAccepted);
String winner = firstAccepted ? "session-a" : "session-b";
String loser = firstAccepted ? "session-b" : "session-a";
assertThat(registry.isActive("G3HD", "USER_A", winner)).isTrue();
assertThat(registry.isActive("G3HD", "USER_A", loser)).isFalse();
}
}
}
@@ -9,6 +9,8 @@ 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 org.springframework.test.util.ReflectionTestUtils;
import cn.g3soft.fmsapi.utils.DbUtils;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
@@ -28,6 +30,9 @@ import javax.sql.DataSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 导出端到端(连真实库,只读)。验证「元数据 → SQL → POI 流 → 可解析的 xlsx」整条链路,
@@ -203,7 +208,7 @@ class ExportServiceIntegrationTests {
);
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, 0);
statement.setLong(1, 1);
statement.setLong(2, 3);
try (ResultSet resultSet = statement.executeQuery()) {
resultSet.next();
@@ -223,6 +228,79 @@ class ExportServiceIntegrationTests {
}
}
@Test
void 当页区间勾选及各分卷合计与文件内明细一致() throws Exception {
OrgContext.setOrgId("G3HD");
OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT);
try {
// 只替换内存里的导出配置,真实 SQL Server 查询与工作簿写出照常执行,不改数据库配置。
ExportSchemaService.ExportSchema schema = new ExportSchemaService.ExportSchema(
MODULE_WITH_DATA, "合计回归", MODULE_WITH_DATA, "b_id", "b_xh ASC",
List.of(
new ExportSchemaService.ExportNode(null,
new ExportSchemaService.ExportColumn("b_name", "名称", 16, ""), null),
new ExportSchemaService.ExportNode(null,
new ExportSchemaService.ExportColumn("b_xh", "序号求和", 16, "sum"), null),
new ExportSchemaService.ExportNode(null,
new ExportSchemaService.ExportColumn("b_id", "记录计数", 16, "count"), null)));
ExportSchemaService schemas = mock(ExportSchemaService.class);
when(schemas.load(MODULE_WITH_DATA)).thenReturn(schema);
when(schemas.filter(any(), any(), any())).thenCallRealMethod();
ExportService service = new ExportService();
ReflectionTestUtils.setField(service, "dataSource", dataSource);
ReflectionTestUtils.setField(service, "dbUtils", new DbUtils());
ReflectionTestUtils.setField(service, "sqlPermissionService", mock(SqlPermissionService.class));
ReflectionTestUtils.setField(service, "exportSchemaService", schemas);
List<Object> keys = new ArrayList<>();
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT TOP 5 [b_id] FROM [b_con_type] ORDER BY [b_id]")) {
while (result.next()) keys.add(result.getObject(1));
}
assertThat(keys).hasSize(5);
for (Integer[] range : new Integer[][]{{null, null}, {1, 1}, {3, 4}, {5, 5}, {8, 9}}) {
MockHttpServletResponse response = new MockHttpServletResponse();
service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, "b_id IS NOT NULL",
null, keys, null, null, range[0], range[1], null, true), response);
int expectedRows = range[0] == null ? 5 : Math.max(0, Math.min(5, range[1]) - range[0] + 1);
assertSummaryMatchesDetails(response.getContentAsByteArray(), expectedRows);
}
MockHttpServletResponse response = new MockHttpServletResponse();
service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, null, null, keys,
null, null, null, null, 2, true), response);
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(response.getContentAsByteArray()))) {
for (int expectedRows : List.of(2, 2, 1)) {
assertThat(zip.getNextEntry()).isNotNull();
assertSummaryMatchesDetails(zip.readAllBytes(), expectedRows);
}
assertThat(zip.getNextEntry()).isNull();
}
} finally {
OrgContext.clear();
}
}
private void assertSummaryMatchesDetails(byte[] bytes, int expectedRows) throws Exception {
try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getLastRowNum()).isEqualTo(expectedRows + 1);
double sum = 0;
for (int row = 1; row <= expectedRows; row++) {
var cell = sheet.getRow(row).getCell(1);
if (cell != null && cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
sum += cell.getNumericCellValue();
}
}
var summary = sheet.getRow(expectedRows + 1);
assertThat(summary.getCell(0).getStringCellValue()).isEqualTo("合计");
assertThat(summary.getCell(1).getNumericCellValue()).isEqualTo(sum);
assertThat(summary.getCell(2).getNumericCellValue()).isEqualTo(expectedRows);
}
}
@Test
void 总合计接口在没有配合计列时返回空() {
OrgContext.setOrgId("G3HD");
@@ -113,9 +113,33 @@ class ExportServiceTests {
@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();
assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_xh] DESC, [b_id] ASC");
assertThat(service.resolveOrderSql(schema, "b_name ASC")).isEqualTo("[b_name] ASC, [b_id] ASC");
assertThat(service.resolveOrderSql(schema("", List.of(leaf("b_name"))), null)).isEqualTo("[b_id] ASC");
}
@Test
void 不完整或逆序区间不能退化成全部导出() {
ExportSchema schema = schema(null, List.of(leaf("b_name")));
for (ExportService.ExportRequest request : List.of(
request(null, null, null, null, 3, null),
request(null, null, null, null, null, 4),
request(null, null, null, null, 4, 3))) {
assertThatThrownBy(() -> service.buildSelectSql(schema, schema.columns(), null, null, request))
.isInstanceOf(BusinessException.class).hasMessageContaining("区间");
}
}
@Test
void 导出列采用选择顺序且跨组拖拽后保留各列分组和权限() {
ExportSchema schema = schema(null, List.of(
new ExportNode("A", null, List.of(leaf("b_a"), leaf("b_b"))),
leaf("b_c"), leaf("b_denied")));
ExportSchema result = new ExportSchemaService().filter(schema, java.util.Set.of("b_denied"),
List.of("b_b", "b_c", "b_a", "b_denied", "b_unknown", "b_b"));
assertThat(result.columns()).extracting(ExportColumn::field).containsExactly("b_b", "b_c", "b_a");
assertThat(ExportHeadBuilder.headOf(result.nodes())).containsExactly(
List.of("A", "b_b"), List.of("b_c"), List.of("A", "b_a"));
}
/* ================= WHERE 拼装:数据范围只收窄不放开 ================= */