Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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("/**");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> resolve(@RequestParam String path) {
Optional<String> canonical = docPathService.resolveCanonical(path);
if (canonical.isPresent()) {
return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
.location(URI.create(canonical.get()))
.build();
}
return ResponseEntity.notFound().build();
Comment on lines +32 to +40
}
}
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +36 to +45
}
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<String> resolveCanonical(String inputPath) {
String normalizedPath = normalize(inputPath);
if (normalizedPath == null || normalizedPath.isBlank()) {
return Optional.empty();
}
Comment on lines +63 to +68

// 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)$', ''
Comment on lines +70 to +75
) 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<String> 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;
}
}
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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