20260917165839
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
-1
@@ -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 拼装:数据范围只收窄不放开 ================= */
|
||||
|
||||
Reference in new issue
Block a user