diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java index 4a400bb..cc9fdc7 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -59,6 +59,8 @@ public void addInterceptors(InterceptorRegistry registry) { // 写接口(POST/PUT/DELETE)和 /mine 由方法级 @SaCheckLogin 守卫,无需在此放行。 .notMatch("/api/posts/feed") .notMatch("/api/posts/*/*") + // 文档路径解析:GET /api/docs/resolve?path=... 公开,无需登录 + .notMatch("/api/docs/resolve") .check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException })).addPathPatterns("/**"); } diff --git a/src/main/java/com/involutionhell/backend/docs/controller/DocsResolveController.java b/src/main/java/com/involutionhell/backend/docs/controller/DocsResolveController.java new file mode 100644 index 0000000..2d2e4d8 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/docs/controller/DocsResolveController.java @@ -0,0 +1,42 @@ +package com.involutionhell.backend.docs.controller; + +import com.involutionhell.backend.docs.service.DocPathService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.Optional; + +/** + * 文档路径解析端点。 + * + * GET /api/docs/resolve?path=/zh/docs/community/dev-tips/git101 + * → 301 Location: /docs/community/dev-tips/git101 (canonical,无 locale) + * → 404(路径不认识) + * + * canonical 不带 locale 前缀,前端 Block 3 负责拼 locale 后再跳转。 + * 公开端点,无需登录(已加入 SaTokenConfigure 白名单)。 + */ +@RestController +public class DocsResolveController { + + private final DocPathService docPathService; + + public DocsResolveController(DocPathService docPathService) { + this.docPathService = docPathService; + } + + @GetMapping("/api/docs/resolve") + public ResponseEntity resolve(@RequestParam String path) { + Optional canonical = docPathService.resolveCanonical(path); + if (canonical.isPresent()) { + return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY) + .location(URI.create(canonical.get())) + .build(); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/src/main/java/com/involutionhell/backend/docs/service/DocPathService.java b/src/main/java/com/involutionhell/backend/docs/service/DocPathService.java new file mode 100644 index 0000000..8f83371 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/docs/service/DocPathService.java @@ -0,0 +1,118 @@ +package com.involutionhell.backend.docs.service; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 文档路径解析服务:将任意输入路径(含旧路径、带 locale 前缀、历史重命名路径) + * 解析为 canonical URL(无 locale 前缀的 /docs/... 形式)。 + * + * 数据来源: + * - docs.path_current:当前路径(前缀 content/) + * - doc_paths.path:历史路径(前缀 app/),通过 doc_id 关联 docs + * + * canonical 格式:/docs/... (无 locale,无后缀,无尾斜杠), + * 由前端 Block 3 负责拼 locale 后再做最终跳转。 + */ +@Service +public class DocPathService { + + private final JdbcTemplate jdbcTemplate; + + public DocPathService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** + * 归一化输入路径: + * - 去掉 URL fragment(# 后面) + * - strip locale 前缀(/zh/ 或 /en/) + * - 去尾斜杠 + */ + String normalize(String path) { + if (path == null) return null; + // 去 fragment + int h = path.indexOf('#'); + if (h >= 0) path = path.substring(0, h); + // strip locale 前缀 /zh/ 或 /en/ + path = path.replaceFirst("^/(zh|en)/", "/"); + // 去尾斜杠(根路径 "/" 不动) + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return path; + } + + /** + * 解析路径对应的 canonical URL。 + * + * 查询逻辑(UNION): + * 1. docs.path_current(前缀 content/)→ 当前路径同时作为 match_path 和 canonical_path + * 2. doc_paths.path(前缀 app/)→ 历史路径作为 match_path,关联文档的 path_current 作为 canonical_path + * + * @Cacheable key 必须是 normalize() 之后的路径, + * 保证 /zh/docs/... 和 /docs/... 命中同一条缓存。 + * + * @param inputPath 原始输入路径(可能含 locale 前缀或历史路径) + * @return canonical URL(如 /docs/community/dev-tips/git101),或 empty + */ + @Cacheable(value = "doc-resolve", key = "T(com.involutionhell.backend.docs.service.DocPathService).normalizeStatic(#inputPath)") + public Optional resolveCanonical(String inputPath) { + String normalizedPath = normalize(inputPath); + if (normalizedPath == null || normalizedPath.isBlank()) { + return Optional.empty(); + } + + // docs.path_current 前缀是 content/,doc_paths.path 前缀是 app/ + String sql = """ + SELECT canonical_path FROM ( + SELECT regexp_replace( + regexp_replace(d.path_current, '^content', ''), + '(/index)?\\.(mdx|md)$', '' + ) AS match_path, + regexp_replace( + regexp_replace(d.path_current, '^content', ''), + '(/index)?\\.(mdx|md)$', '' + ) AS canonical_path + FROM docs d + WHERE d.path_current IS NOT NULL + UNION ALL + SELECT regexp_replace( + regexp_replace(dp.path, '^app', ''), + '(/index)?\\.(mdx|md)$', '' + ) AS match_path, + regexp_replace( + regexp_replace(d.path_current, '^content', ''), + '(/index)?\\.(mdx|md)$', '' + ) AS canonical_path + FROM doc_paths dp + JOIN docs d ON d.id = dp.doc_id + WHERE d.path_current IS NOT NULL + ) t + WHERE match_path = ? + LIMIT 1 + """; + + List results = jdbcTemplate.queryForList(sql, String.class, normalizedPath); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + /** + * 供 @Cacheable SpEL key 表达式调用的静态版本 normalize。 + * Spring Cache 的 T(...) 语法要求方法为 public static。 + */ + public static String normalizeStatic(String path) { + if (path == null) return ""; + int h = path.indexOf('#'); + if (h >= 0) path = path.substring(0, h); + path = path.replaceFirst("^/(zh|en)/", "/"); + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return path; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e742ab1..c636205 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -85,6 +85,6 @@ ga4.credentials-path=${GA4_CREDENTIALS_PATH:./ga4-sa-key.json} spring.cache.type=caffeine # 注册所有需要的缓存名(之前写了两次被覆盖,eventSummary 实际没注册导致缓存失效) # docHistory 用来缓存 GitHub commits API 结果,避免给每次文档页访问都打 GitHub 限流 -spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos,zoteroItems,leaderboard +spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos,zoteroItems,leaderboard,doc-resolve # 统一 TTL 10 分钟 / 最多 200 key(docHistory 500 不同路径也用不满,leaderboard 单 key) spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=600s