This commit is contained in:
+13
-10
@@ -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) {
|
||||
|
||||
+17
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user