package com.dms.web; import jakarta.servlet.http.HttpServletRequest; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.MediaTypeFactory; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; import java.util.Map; /** * 前端 SPA 托管,逐行对齐 app.py 的 serve_spa: * * * *

/api/v1/** 由各 RestController 精确注册,优先级高于本兜底映射; * 非 GET 方法对齐 Flask @app.route 的默认行为返回 405。

*/ @Controller public class SpaController { private final FrontendAssets frontendAssets; public SpaController(FrontendAssets frontendAssets) { this.frontendAssets = frontendAssets; } @GetMapping("/**") @ResponseBody public ResponseEntity spa(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); if (!contextPath.isEmpty() && uri.startsWith(contextPath)) { uri = uri.substring(contextPath.length()); } String relative = decodePercent(uri.startsWith("/") ? uri.substring(1) : uri); // 对齐:api/ 前缀的未知路径一律 404 JSON,不落入 index.html if (relative.startsWith("api/")) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_JSON) .body(Map.of("error", "无权限访问")); } Resource file = frontendAssets.resolveFile(relative); if (file != null) { MediaType mediaType = MediaTypeFactory.getMediaType(file.getFilename()) .orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(mediaType).body(file); } Resource index = frontendAssets.indexResource(); if (index != null && relative.isEmpty()) { return ResponseEntity.ok() .contentType(MediaTypeFactory.getMediaType(INDEX_NAME) .orElse(MediaType.TEXT_HTML)) .body(index); } // 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径; // 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性, // 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」) return ResponseEntity.status(HttpStatus.NOT_FOUND) .contentType(MediaType.APPLICATION_JSON) .body(Map.of("error", "无权限访问")); } private static final String INDEX_NAME = "index.html"; /** * 仅解码 %XX(对齐 Werkzeug 路径解码)。 * 不用 URLDecoder:它会把 + 解成空格(query 语义),路径中非法。 */ private static String decodePercent(String value) { if (value.indexOf('%') < 0) { return value; } ByteArrayOutputStream bytes = new ByteArrayOutputStream(); int i = 0; while (i < value.length()) { char c = value.charAt(i); if (c == '%' && i + 2 < value.length()) { int high = Character.digit(value.charAt(i + 1), 16); int low = Character.digit(value.charAt(i + 2), 16); if (high >= 0 && low >= 0) { bytes.write((high << 4) | low); i += 3; continue; } } byte[] raw = String.valueOf(c).getBytes(StandardCharsets.UTF_8); bytes.write(raw, 0, raw.length); i++; } return new String(bytes.toByteArray(), StandardCharsets.UTF_8); } }