| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107 |
- 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:
- *
- * <ul>
- * <li>/api/** 未匹配的路径 → 404 JSON {"error": "Not found"};</li>
- * <li>静态文件存在 → 发文件(按扩展名猜 Content-Type);</li>
- * <li>根路径 / → index.html(前端无路径路由、仅 query 传参);</li>
- * <li>合法 URL/API 之外的未知路径一律 404 JSON,不返回 wildcard 200(安全收敛)。</li>
- * </ul>
- *
- * <p>/api/v1/** 由各 RestController 精确注册,优先级高于本兜底映射;
- * 非 GET 方法对齐 Flask @app.route 的默认行为返回 405。</p>
- */
- @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);
- }
- }
|