perf: optimize dashboard loading and production assets
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-19 15:34:11 +08:00
parent 12d78b4c86
commit 83f1bf82d6
17 changed files with 355 additions and 48 deletions
@@ -72,20 +72,23 @@ public class DashboardApplicationService {
@Transactional(readOnly = true)
public DashboardOverviewView overview(String requestedCurrency) {
authorizationService.requirePermission("dashboard:overview:view");
Set<String> permissions = authorizationService.activePermissions();
if (!permissions.contains("dashboard:overview:view")) {
authorizationService.requirePermission("dashboard:overview:view");
}
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String currency = normalizeCurrency(requestedCurrency);
boolean canProjects = authorizationService.hasPermission("project:project:view");
boolean canContracts = authorizationService.hasPermission("masterdata:contract:view");
boolean canReceipts = authorizationService.hasPermission("receivable:receipt:view");
boolean canPayments = authorizationService.hasPermission("payment:request:view");
boolean canInvoices = authorizationService.hasPermission("receivable:invoice:view");
boolean canWorkflow = authorizationService.hasPermission("workflow:task:view");
boolean canRisks = authorizationService.hasPermission("project:risk-flag:view");
boolean canArchives = authorizationService.hasPermission("archive:package:view");
boolean canFiles = authorizationService.hasPermission("archive:file:view");
boolean canProjects = permissions.contains("project:project:view");
boolean canContracts = permissions.contains("masterdata:contract:view");
boolean canReceipts = permissions.contains("receivable:receipt:view");
boolean canPayments = permissions.contains("payment:request:view");
boolean canInvoices = permissions.contains("receivable:invoice:view");
boolean canWorkflow = permissions.contains("workflow:task:view");
boolean canRisks = permissions.contains("project:risk-flag:view");
boolean canArchives = permissions.contains("archive:package:view");
boolean canFiles = permissions.contains("archive:file:view");
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
BigDecimal contractAmount = amount(canContracts,
@@ -10,8 +10,9 @@ import java.security.NoSuchAlgorithmException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -47,6 +48,17 @@ public class AuthorizationService {
}
}
/** Loads the active role's permissions once for request-level capability decisions. */
public Set<String> activePermissions() {
FinancePrincipal principal = identityContext.requirePrincipal();
if (principal.mustChangePassword()) {
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_PASSWORD_CHANGE_REQUIRED,
"首次登录必须先修改密码");
}
String role = identityContext.activeRole();
return role == null ? Set.of() : Set.copyOf(userAccountMapper.findPermissions(principal.userId(), role));
}
public void requireGlobalScope(String permissionCode) {
requireScope(permissionCode, null, null, null);
}
@@ -0,0 +1,43 @@
package com.kaidi.finance.shared.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Adds long-lived browser caching only to fingerprinted frontend assets. */
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class StaticAssetCacheFilter extends OncePerRequestFilter {
static final String IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isStaticAssetRequest(request)) {
// Set this before the response is committed. Spring Security keeps an existing cache
// policy instead of replacing it with its default no-store response headers.
response.setHeader(HttpHeaders.CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL);
}
filterChain.doFilter(request, response);
}
private boolean isStaticAssetRequest(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
return path.startsWith("/assets/");
}
}
@@ -2,6 +2,7 @@ package com.kaidi.setup;
import com.kaidi.finance.setup.SetupProperties;
import com.kaidi.finance.shared.web.SpaForwardFilter;
import com.kaidi.finance.shared.web.StaticAssetCacheFilter;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -30,7 +31,7 @@ import org.springframework.context.annotation.Import;
}
)
@EnableConfigurationProperties(SetupProperties.class)
@Import(SpaForwardFilter.class)
@Import({SpaForwardFilter.class, StaticAssetCacheFilter.class})
public class SetupApplication {
public static void main(String[] args) {
@@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.kaidi.finance.dashboard.api.DashboardMetricView;
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -52,7 +54,18 @@ class DashboardApplicationServiceTest {
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(authorization.hasPermission(anyString())).thenReturn(true);
when(authorization.activePermissions()).thenReturn(Set.of(
"dashboard:overview:view",
"project:project:view",
"masterdata:contract:view",
"receivable:receipt:view",
"payment:request:view",
"receivable:invoice:view",
"workflow:task:view",
"project:risk-flag:view",
"archive:package:view",
"archive:file:view"
));
when(workbenchMapper.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
@@ -99,19 +112,12 @@ class DashboardApplicationServiceTest {
assertEquals("12345.67", contract.value());
assertTrue(contract.available());
assertFalse(view.geography().available());
verify(authorization).activePermissions();
}
@Test
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
when(authorization.hasPermission("project:project:view")).thenReturn(false);
when(authorization.hasPermission("masterdata:contract:view")).thenReturn(false);
when(authorization.hasPermission("receivable:receipt:view")).thenReturn(false);
when(authorization.hasPermission("payment:request:view")).thenReturn(false);
when(authorization.hasPermission("receivable:invoice:view")).thenReturn(false);
when(authorization.hasPermission("workflow:task:view")).thenReturn(false);
when(authorization.hasPermission("project:risk-flag:view")).thenReturn(false);
when(authorization.hasPermission("archive:package:view")).thenReturn(false);
when(authorization.hasPermission("archive:file:view")).thenReturn(false);
when(authorization.activePermissions()).thenReturn(Set.of("dashboard:overview:view"));
var view = service.overview("CNY");
@@ -127,6 +133,7 @@ class DashboardApplicationServiceTest {
@Test
void requiresTheDedicatedDashboardPermission() {
when(authorization.activePermissions()).thenReturn(Set.of());
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
.when(authorization).requirePermission("dashboard:overview:view");
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
@@ -57,6 +58,10 @@ class SetupContextSmokeTest {
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
assertThat(asset.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isEqualTo("public, max-age=31536000, immutable");
assertThat(setupPage.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isNotEqualTo("public, max-age=31536000, immutable");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
@@ -0,0 +1,55 @@
package com.kaidi.finance.shared.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class StaticAssetCacheFilterTest {
private final StaticAssetCacheFilter filter = new StaticAssetCacheFilter();
@Test
void cachesFingerprintAssetsForOneYear() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/assets/index-a1b2c3.js");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(continued).isTrue();
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void supportsContextPathAndHeadRequests() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("HEAD", "/finance/assets/index-a1b2c3.css");
request.setContextPath("/finance");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
}
@Test
void doesNotCacheHtmlApiOrWriteRequests() throws Exception {
assertNotCached("GET", "/index.html");
assertNotCached("GET", "/api/v1/dashboard/overview");
assertNotCached("POST", "/assets/index-a1b2c3.js");
}
private void assertNotCached(String method, String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(method, uri);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull();
}
}