Skip to content

Commit d7cfc69

Browse files
committed
feat(docs): GET /api/docs/resolve 路径解析端点
新增 DocPathService + DocsResolveController,将任意输入路径 (含 /zh|en/ locale 前缀、历史重命名路径)解析为 canonical URL。 - normalize():strip locale 前缀、去 fragment、去尾斜杠 - SQL UNION:docs.path_current(前缀 content/)+ doc_paths.path(前缀 app/) - @Cacheable("doc-resolve"):key = normalize 后的路径,避免 /zh/ 和 /docs/ 重复缓存 - SaTokenConfigure 白名单加 /api/docs/resolve - application.properties cache-names 加 doc-resolve Smoke test 通过: /zh/docs/community/dev-tips/git101 → 301 /docs/community/dev-tips/git101 /docs/nonexistent/page → 404 无 satoken → 301(不是 401)
1 parent acad32e commit d7cfc69

4 files changed

Lines changed: 163 additions & 1 deletion

File tree

src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public void addInterceptors(InterceptorRegistry registry) {
5959
// 写接口(POST/PUT/DELETE)和 /mine 由方法级 @SaCheckLogin 守卫,无需在此放行。
6060
.notMatch("/api/posts/feed")
6161
.notMatch("/api/posts/*/*")
62+
// 文档路径解析:GET /api/docs/resolve?path=... 公开,无需登录
63+
.notMatch("/api/docs/resolve")
6264
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
6365
})).addPathPatterns("/**");
6466
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.involutionhell.backend.docs.controller;
2+
3+
import com.involutionhell.backend.docs.service.DocPathService;
4+
import org.springframework.http.HttpStatus;
5+
import org.springframework.http.ResponseEntity;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestParam;
8+
import org.springframework.web.bind.annotation.RestController;
9+
10+
import java.net.URI;
11+
import java.util.Optional;
12+
13+
/**
14+
* 文档路径解析端点。
15+
*
16+
* GET /api/docs/resolve?path=/zh/docs/community/dev-tips/git101
17+
* → 301 Location: /docs/community/dev-tips/git101 (canonical,无 locale)
18+
* → 404(路径不认识)
19+
*
20+
* canonical 不带 locale 前缀,前端 Block 3 负责拼 locale 后再跳转。
21+
* 公开端点,无需登录(已加入 SaTokenConfigure 白名单)。
22+
*/
23+
@RestController
24+
public class DocsResolveController {
25+
26+
private final DocPathService docPathService;
27+
28+
public DocsResolveController(DocPathService docPathService) {
29+
this.docPathService = docPathService;
30+
}
31+
32+
@GetMapping("/api/docs/resolve")
33+
public ResponseEntity<Void> resolve(@RequestParam String path) {
34+
Optional<String> canonical = docPathService.resolveCanonical(path);
35+
if (canonical.isPresent()) {
36+
return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
37+
.location(URI.create(canonical.get()))
38+
.build();
39+
}
40+
return ResponseEntity.notFound().build();
41+
}
42+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.involutionhell.backend.docs.service;
2+
3+
import org.springframework.cache.annotation.Cacheable;
4+
import org.springframework.jdbc.core.JdbcTemplate;
5+
import org.springframework.stereotype.Service;
6+
7+
import java.util.List;
8+
import java.util.Optional;
9+
10+
/**
11+
* 文档路径解析服务:将任意输入路径(含旧路径、带 locale 前缀、历史重命名路径)
12+
* 解析为 canonical URL(无 locale 前缀的 /docs/... 形式)。
13+
*
14+
* 数据来源:
15+
* - docs.path_current:当前路径(前缀 content/)
16+
* - doc_paths.path:历史路径(前缀 app/),通过 doc_id 关联 docs
17+
*
18+
* canonical 格式:/docs/... (无 locale,无后缀,无尾斜杠),
19+
* 由前端 Block 3 负责拼 locale 后再做最终跳转。
20+
*/
21+
@Service
22+
public class DocPathService {
23+
24+
private final JdbcTemplate jdbcTemplate;
25+
26+
public DocPathService(JdbcTemplate jdbcTemplate) {
27+
this.jdbcTemplate = jdbcTemplate;
28+
}
29+
30+
/**
31+
* 归一化输入路径:
32+
* - 去掉 URL fragment(# 后面)
33+
* - strip locale 前缀(/zh/ 或 /en/)
34+
* - 去尾斜杠
35+
*/
36+
String normalize(String path) {
37+
if (path == null) return null;
38+
// 去 fragment
39+
int h = path.indexOf('#');
40+
if (h >= 0) path = path.substring(0, h);
41+
// strip locale 前缀 /zh/ 或 /en/
42+
path = path.replaceFirst("^/(zh|en)/", "/");
43+
// 去尾斜杠(根路径 "/" 不动)
44+
if (path.length() > 1 && path.endsWith("/")) {
45+
path = path.substring(0, path.length() - 1);
46+
}
47+
return path;
48+
}
49+
50+
/**
51+
* 解析路径对应的 canonical URL。
52+
*
53+
* 查询逻辑(UNION):
54+
* 1. docs.path_current(前缀 content/)→ 当前路径同时作为 match_path 和 canonical_path
55+
* 2. doc_paths.path(前缀 app/)→ 历史路径作为 match_path,关联文档的 path_current 作为 canonical_path
56+
*
57+
* @Cacheable key 必须是 normalize() 之后的路径,
58+
* 保证 /zh/docs/... 和 /docs/... 命中同一条缓存。
59+
*
60+
* @param inputPath 原始输入路径(可能含 locale 前缀或历史路径)
61+
* @return canonical URL(如 /docs/community/dev-tips/git101),或 empty
62+
*/
63+
@Cacheable(value = "doc-resolve", key = "T(com.involutionhell.backend.docs.service.DocPathService).normalizeStatic(#inputPath)")
64+
public Optional<String> resolveCanonical(String inputPath) {
65+
String normalizedPath = normalize(inputPath);
66+
if (normalizedPath == null || normalizedPath.isBlank()) {
67+
return Optional.empty();
68+
}
69+
70+
// docs.path_current 前缀是 content/,doc_paths.path 前缀是 app/
71+
String sql = """
72+
SELECT canonical_path FROM (
73+
SELECT regexp_replace(
74+
regexp_replace(d.path_current, '^content', ''),
75+
'(/index)?\\.(mdx|md)$', ''
76+
) AS match_path,
77+
regexp_replace(
78+
regexp_replace(d.path_current, '^content', ''),
79+
'(/index)?\\.(mdx|md)$', ''
80+
) AS canonical_path
81+
FROM docs d
82+
WHERE d.path_current IS NOT NULL
83+
UNION ALL
84+
SELECT regexp_replace(
85+
regexp_replace(dp.path, '^app', ''),
86+
'(/index)?\\.(mdx|md)$', ''
87+
) AS match_path,
88+
regexp_replace(
89+
regexp_replace(d.path_current, '^content', ''),
90+
'(/index)?\\.(mdx|md)$', ''
91+
) AS canonical_path
92+
FROM doc_paths dp
93+
JOIN docs d ON d.id = dp.doc_id
94+
WHERE d.path_current IS NOT NULL
95+
) t
96+
WHERE match_path = ?
97+
LIMIT 1
98+
""";
99+
100+
List<String> results = jdbcTemplate.queryForList(sql, String.class, normalizedPath);
101+
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
102+
}
103+
104+
/**
105+
* 供 @Cacheable SpEL key 表达式调用的静态版本 normalize。
106+
* Spring Cache 的 T(...) 语法要求方法为 public static。
107+
*/
108+
public static String normalizeStatic(String path) {
109+
if (path == null) return "";
110+
int h = path.indexOf('#');
111+
if (h >= 0) path = path.substring(0, h);
112+
path = path.replaceFirst("^/(zh|en)/", "/");
113+
if (path.length() > 1 && path.endsWith("/")) {
114+
path = path.substring(0, path.length() - 1);
115+
}
116+
return path;
117+
}
118+
}

src/main/resources/application.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,6 @@ ga4.credentials-path=${GA4_CREDENTIALS_PATH:./ga4-sa-key.json}
8585
spring.cache.type=caffeine
8686
# 注册所有需要的缓存名(之前写了两次被覆盖,eventSummary 实际没注册导致缓存失效)
8787
# docHistory 用来缓存 GitHub commits API 结果,避免给每次文档页访问都打 GitHub 限流
88-
spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos,zoteroItems,leaderboard
88+
spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos,zoteroItems,leaderboard,doc-resolve
8989
# 统一 TTL 10 分钟 / 最多 200 key(docHistory 500 不同路径也用不满,leaderboard 单 key)
9090
spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=600s

0 commit comments

Comments
 (0)