Skip to content

Commit c3877d0

Browse files
committed
fix(rank): 榜单返回当前 canonical URL,老/新 URL views 合并成一条
接上个 PR:rank API 以前返回的是 GA4 原始 pagePath,历史流量的老 URL (如 /docs/ai/multimodal/qwenvl)进榜单后前端点进去还得再走 301 才到新 URL,既多一跳,又让搜索引擎爬榜单页时把权重传给 redirect 源而不是 当前规范 URL。 现在 queryDocInfo 一并返回 canonical_path(始终来自 docs.path_current 归一化),getTopDocs 用 canonical 做聚合 key: - 命中 docs.path_current → match = canonical = 当前 URL - 命中 doc_paths.path → match = 老 URL,canonical = 当前 URL(JOIN docs 拿) - 老/新两条 GA4 记录指向同一篇 → views 合并成一条 测试:新增 mergesOldAndNewUrlIntoSingleCanonicalEntry 用例覆盖合并; 原 matchesHistoricalPathViaDocPaths 改断言 canonical 输出。 本地 analytics 套件 29/29 绿。
1 parent e7e8f7f commit c3877d0

2 files changed

Lines changed: 74 additions & 26 deletions

File tree

‎src/main/java/com/involutionhell/backend/analytics/service/AnalyticsService.java‎

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public AnalyticsService(Ga4ReportService ga4ReportService, JdbcTemplate jdbcTemp
2626
this.jdbcTemplate = jdbcTemplate;
2727
}
2828

29+
/** 一条 match_path 解析出来的当前标题 + 规范 URL,用 record 省一个内部类。 */
30+
private record DocInfo(String canonicalPath, String title) {}
31+
2932
@Cacheable(value = "topDocs", key = "#window + '_' + #limit")
3033
public List<TopDocDto> getTopDocs(String window, int limit) {
3134
// GA4 里一篇文章可能拆成 "?utm_source" / 带尾斜杠 / 有 anchor 等多条记录,
@@ -37,29 +40,41 @@ public List<TopDocDto> getTopDocs(String window, int limit) {
3740
return List.of();
3841
}
3942

40-
Map<String, Long> mergedViews = new LinkedHashMap<>();
43+
// GA4 原始 pagePath 按归一化后的 match key 累加 views
44+
Map<String, Long> viewsByMatchPath = new LinkedHashMap<>();
4145
for (Ga4ReportService.PathCount pc : pathCounts) {
4246
String normalized = normalizePath(pc.path());
4347
if (normalized.isEmpty()) continue;
44-
mergedViews.merge(normalized, pc.views(), Long::sum);
48+
viewsByMatchPath.merge(normalized, pc.views(), Long::sum);
4549
}
4650

47-
List<String> paths = new ArrayList<>(mergedViews.keySet());
48-
Map<String, String> pathToTitle = queryDocTitles(paths);
51+
List<String> paths = new ArrayList<>(viewsByMatchPath.keySet());
52+
Map<String, DocInfo> matchToDoc = queryDocInfo(paths);
53+
54+
// 把 GA4 里的老 URL / 新 URL 全部归并到当前 canonical 路径上,
55+
// 这样榜单点击直接 200,不需要再走一次 301 redirect,
56+
// 搜索引擎爬榜单页时也能把权重直接传给当前规范 URL。
57+
Map<String, Long> viewsByCanonical = new LinkedHashMap<>();
58+
Map<String, String> titleByCanonical = new LinkedHashMap<>();
59+
for (Map.Entry<String, Long> e : viewsByMatchPath.entrySet()) {
60+
DocInfo info = matchToDoc.get(e.getKey());
61+
if (info == null) continue;
62+
viewsByCanonical.merge(info.canonicalPath(), e.getValue(), Long::sum);
63+
titleByCanonical.putIfAbsent(info.canonicalPath(), info.title());
64+
}
4965

50-
return mergedViews.entrySet().stream()
51-
.filter(e -> pathToTitle.containsKey(e.getKey()))
66+
return viewsByCanonical.entrySet().stream()
5267
.sorted(Map.Entry.<String, Long>comparingByValue().reversed()
5368
.thenComparing(Map.Entry.comparingByKey()))
54-
.map(e -> new TopDocDto(e.getKey(), pathToTitle.get(e.getKey()), e.getValue()))
69+
.map(e -> new TopDocDto(e.getKey(), titleByCanonical.get(e.getKey()), e.getValue()))
5570
.limit(limit)
5671
.toList();
5772
}
5873

5974
/**
6075
* 归一化 GA4 pagePath:只做 query / anchor / 尾斜杠清洗,不再做任何 IA 路径重写。
6176
* 历史 IA(比如 2026-04-19 重组前的 /docs/ai/* / /docs/CommunityShare/*)要靠 DB
62-
* 里的 doc_paths 行来命中,见 {@link #queryDocTitles}。
77+
* 里的 doc_paths 行来命中,见 {@link #queryDocInfo}。
6378
* 对外暴露为 package-private 便于单元测试。
6479
*/
6580
String normalizePath(String path) {
@@ -77,13 +92,13 @@ String normalizePath(String path) {
7792
}
7893

7994
/**
80-
* 查询 docs 表,把 GA4 返回的 pagePath 批量映射成标题。
95+
* 查询 docs / doc_paths,把 GA4 归一化后的 pagePath 映射到 <当前规范 URL, 标题>。
8196
*
8297
* <p>这里做的事:把 docs.path_current(当前文件路径)和 doc_paths.path(历史文件路径)
8398
* 一起纳入候选,用同一套 PostgreSQL 正则去掉 {@code ^app} 前缀与 {@code (/index)?\.(mdx|md)$}
84-
* 后缀后与 GA4 的 pagePath 对齐。这样 2026-04-19 IA 重组之前的老 URL
85-
* (比如 /docs/ai/multimodal/qwenvl)能通过 doc_paths 命中到当前 docs 行,
86-
* 30D / ALL 窗口的历史流量不丢。
99+
* 后缀后与 GA4 的 pagePath 对齐。每行无论从哪侧命中,都带上这一 doc 的 current 规范 URL
100+
* (总是基于 docs.path_current 生成),上层用 canonical 做展示和排序 key,GA4 里的老
101+
* 路径和新路径就能合并成同一条榜单记录。
87102
*
88103
* <p>前提:{@code doc_paths} 里要有对应的老路径。前端 scripts/backfill-contributors.mjs
89104
* 每次跑都会 upsert"当前文件"路径(只增不减),加上
@@ -97,40 +112,53 @@ String normalizePath(String path) {
97112
* <p>查询失败直接抛 {@link IllegalStateException},由全局异常处理器返回 500,
98113
* 不再返回空 Map 导致上层 containsKey 过滤把整个榜单静默清空。
99114
*/
100-
private Map<String, String> queryDocTitles(List<String> paths) {
115+
private Map<String, DocInfo> queryDocInfo(List<String> paths) {
101116
if (paths.isEmpty()) return Map.of();
102117

103118
try {
104-
// UNION ALL:同一个 doc 既能被 path_current 命中、也能被 doc_paths 里任一历史
105-
// 路径命中;多行会被下面 Collectors.toMap 的 merge 函数收敛成一条(保留任一 title)。
119+
// UNION ALL:一行 = 一个 (match_path, canonical_path, title) 候选。
120+
// - 从 docs 过来的:match 和 canonical 都是 path_current 归一化,指自己
121+
// - 从 doc_paths 过来的:match 是历史路径归一化,canonical 仍然是 path_current
122+
// 归一化(通过 JOIN docs 拿),所以老 URL 最终落到当前 URL 上
106123
String sql = """
107-
SELECT normalized AS path_current, title
124+
SELECT match_path, canonical_path, title
108125
FROM (
109-
SELECT d.title,
126+
SELECT regexp_replace(
127+
regexp_replace(d.path_current, '^app', ''),
128+
'(/index)?\\.(mdx|md)$', ''
129+
) AS match_path,
110130
regexp_replace(
111131
regexp_replace(d.path_current, '^app', ''),
112132
'(/index)?\\.(mdx|md)$', ''
113-
) AS normalized
133+
) AS canonical_path,
134+
d.title
114135
FROM docs d
115136
WHERE d.path_current IS NOT NULL
116137
UNION ALL
117-
SELECT d.title,
118-
regexp_replace(
138+
SELECT regexp_replace(
119139
regexp_replace(dp.path, '^app', ''),
120140
'(/index)?\\.(mdx|md)$', ''
121-
) AS normalized
141+
) AS match_path,
142+
regexp_replace(
143+
regexp_replace(d.path_current, '^app', ''),
144+
'(/index)?\\.(mdx|md)$', ''
145+
) AS canonical_path,
146+
d.title
122147
FROM doc_paths dp
123148
JOIN docs d ON d.id = dp.doc_id
149+
WHERE d.path_current IS NOT NULL
124150
) t
125-
WHERE normalized = ANY(?)
151+
WHERE match_path = ANY(?)
126152
""";
127153
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
128154
sql,
129155
(Object) paths.toArray(new String[0])
130156
);
131157
return rows.stream().collect(Collectors.toMap(
132-
r -> (String) r.get("path_current"),
133-
r -> (String) r.get("title"),
158+
r -> (String) r.get("match_path"),
159+
r -> new DocInfo((String) r.get("canonical_path"), (String) r.get("title")),
160+
// 同一个 match_path 理论上只会出现一次(docs 当前路径 + doc_paths 历史
161+
// 路径不会撞);万一撞了(脏数据),保留先到的那条
134162
(a, b) -> a
135163
));
136164
} catch (Exception e) {

‎src/test/java/com/involutionhell/backend/analytics/service/AnalyticsServiceGetTopDocsIntegrationTests.java‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ void matchesCurrentPathAfterRegexpNormalization() {
102102
}
103103

104104
@Test
105-
void matchesHistoricalPathViaDocPaths() {
105+
void historicalPathResolvesToCurrentCanonicalUrl() {
106106
// docs 表里存的是 IA 重组后的新路径
107107
insertDoc("qwenvl", "app/docs/learn/ai/multimodal/qwenvl/index.mdx", "QwenVL 多模态");
108108
// doc_paths 里留有 IA 重组前的老路径(seed SQL 或 backfill 写入)
@@ -116,11 +116,31 @@ void matchesHistoricalPathViaDocPaths() {
116116
List<TopDocDto> result = analyticsService.getTopDocs("all", 20);
117117

118118
assertThat(result).hasSize(1);
119-
assertThat(result.get(0).path()).isEqualTo("/docs/ai/multimodal/qwenvl");
119+
// 榜单对外暴露的是当前 canonical URL,避免前端点进去再走一次 301
120+
assertThat(result.get(0).path()).isEqualTo("/docs/learn/ai/multimodal/qwenvl");
120121
assertThat(result.get(0).title()).isEqualTo("QwenVL 多模态");
121122
assertThat(result.get(0).views()).isEqualTo(1200L);
122123
}
123124

125+
@Test
126+
void mergesOldAndNewUrlIntoSingleCanonicalEntry() {
127+
insertDoc("qwenvl", "app/docs/learn/ai/multimodal/qwenvl/index.mdx", "QwenVL 多模态");
128+
insertDocPath("qwenvl", "app/docs/ai/multimodal/qwenvl/index.mdx");
129+
130+
// IA 重组的那段时间 GA4 同时积累了老 URL 和新 URL 的访问
131+
when(ga4ReportService.fetchTopPaths(anyString(), anyInt())).thenReturn(List.of(
132+
new Ga4ReportService.PathCount("/docs/ai/multimodal/qwenvl", 800),
133+
new Ga4ReportService.PathCount("/docs/learn/ai/multimodal/qwenvl", 200)
134+
));
135+
136+
List<TopDocDto> result = analyticsService.getTopDocs("all", 20);
137+
138+
// 榜单里应只出现一条 canonical URL,views 合并
139+
assertThat(result).hasSize(1);
140+
assertThat(result.get(0).path()).isEqualTo("/docs/learn/ai/multimodal/qwenvl");
141+
assertThat(result.get(0).views()).isEqualTo(1000L);
142+
}
143+
124144
@Test
125145
void stripsQueryAndAnchorBeforeMatching() {
126146
insertDoc("intro", "app/docs/learn/ai/intro.mdx", "AI 入门");

0 commit comments

Comments
 (0)