SpaController.java 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. package com.dms.web;
  2. import jakarta.servlet.http.HttpServletRequest;
  3. import java.io.ByteArrayOutputStream;
  4. import java.nio.charset.StandardCharsets;
  5. import org.springframework.core.io.Resource;
  6. import org.springframework.http.HttpStatus;
  7. import org.springframework.http.MediaType;
  8. import org.springframework.http.ResponseEntity;
  9. import org.springframework.http.MediaTypeFactory;
  10. import org.springframework.stereotype.Controller;
  11. import org.springframework.web.bind.annotation.GetMapping;
  12. import org.springframework.web.bind.annotation.ResponseBody;
  13. import java.util.Map;
  14. /**
  15. * 前端 SPA 托管,逐行对齐 app.py 的 serve_spa:
  16. *
  17. * <ul>
  18. * <li>/api/** 未匹配的路径 → 404 JSON {"error": "Not found"};</li>
  19. * <li>静态文件存在 → 发文件(按扩展名猜 Content-Type);</li>
  20. * <li>根路径 / → index.html(前端无路径路由、仅 query 传参);</li>
  21. * <li>合法 URL/API 之外的未知路径一律 404 JSON,不返回 wildcard 200(安全收敛)。</li>
  22. * </ul>
  23. *
  24. * <p>/api/v1/** 由各 RestController 精确注册,优先级高于本兜底映射;
  25. * 非 GET 方法对齐 Flask @app.route 的默认行为返回 405。</p>
  26. */
  27. @Controller
  28. public class SpaController {
  29. private final FrontendAssets frontendAssets;
  30. public SpaController(FrontendAssets frontendAssets) {
  31. this.frontendAssets = frontendAssets;
  32. }
  33. @GetMapping("/**")
  34. @ResponseBody
  35. public ResponseEntity<?> spa(HttpServletRequest request) {
  36. String uri = request.getRequestURI();
  37. String contextPath = request.getContextPath();
  38. if (!contextPath.isEmpty() && uri.startsWith(contextPath)) {
  39. uri = uri.substring(contextPath.length());
  40. }
  41. String relative = decodePercent(uri.startsWith("/") ? uri.substring(1) : uri);
  42. // 对齐:api/ 前缀的未知路径一律 404 JSON,不落入 index.html
  43. if (relative.startsWith("api/")) {
  44. return ResponseEntity.status(HttpStatus.NOT_FOUND)
  45. .contentType(MediaType.APPLICATION_JSON)
  46. .body(Map.of("error", "无权限访问"));
  47. }
  48. Resource file = frontendAssets.resolveFile(relative);
  49. if (file != null) {
  50. MediaType mediaType = MediaTypeFactory.getMediaType(file.getFilename())
  51. .orElse(MediaType.APPLICATION_OCTET_STREAM);
  52. return ResponseEntity.ok().contentType(mediaType).body(file);
  53. }
  54. Resource index = frontendAssets.indexResource();
  55. if (index != null && relative.isEmpty()) {
  56. return ResponseEntity.ok()
  57. .contentType(MediaTypeFactory.getMediaType(INDEX_NAME)
  58. .orElse(MediaType.TEXT_HTML))
  59. .body(index);
  60. }
  61. // 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径;
  62. // 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性,
  63. // 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」)
  64. return ResponseEntity.status(HttpStatus.NOT_FOUND)
  65. .contentType(MediaType.APPLICATION_JSON)
  66. .body(Map.of("error", "无权限访问"));
  67. }
  68. private static final String INDEX_NAME = "index.html";
  69. /**
  70. * 仅解码 %XX(对齐 Werkzeug 路径解码)。
  71. * 不用 URLDecoder:它会把 + 解成空格(query 语义),路径中非法。
  72. */
  73. private static String decodePercent(String value) {
  74. if (value.indexOf('%') < 0) {
  75. return value;
  76. }
  77. ByteArrayOutputStream bytes = new ByteArrayOutputStream();
  78. int i = 0;
  79. while (i < value.length()) {
  80. char c = value.charAt(i);
  81. if (c == '%' && i + 2 < value.length()) {
  82. int high = Character.digit(value.charAt(i + 1), 16);
  83. int low = Character.digit(value.charAt(i + 2), 16);
  84. if (high >= 0 && low >= 0) {
  85. bytes.write((high << 4) | low);
  86. i += 3;
  87. continue;
  88. }
  89. }
  90. byte[] raw = String.valueOf(c).getBytes(StandardCharsets.UTF_8);
  91. bytes.write(raw, 0, raw.length);
  92. i++;
  93. }
  94. return new String(bytes.toByteArray(), StandardCharsets.UTF_8);
  95. }
  96. }