Skip to content

Commit e7e8f7f

Browse files
authored
fix(rank): 用 doc_paths 接手 IA 重组历史路径,干掉 PATH_REWRITES (#17)
* fix(rank): 用 doc_paths 承载 IA 重组前的历史路径,干掉 PATH_REWRITES 2026-04-19 IA 重组(6684884)之后,/rank 的 30D / ALL 窗口几乎空: GA4 里残留的老 pagePath(/docs/ai/* 之类)在 AnalyticsService 里只能 JOIN docs.path_current,对不上新路径全被过滤掉。 之前用 PATH_REWRITES 硬编码前缀表临时救了一下,但这坨表和前端 next.config.mjs 的 redirect 是两份真相,下次 IA 再动又得两边同步。 这次改成: - queryDocTitles SQL UNION 进 doc_paths 表,历史路径天然命中 - 新增 2026-04-22-seed-ia-reorg-doc-paths.sql,CTE + ROW_NUMBER 按 最长前缀匹配,把 13 条 wildcard 的老路径一次性灌进 doc_paths - normalizePath 瘦身到只做 GA4 query / anchor / 尾斜杠清洗 - 加集成测试 AnalyticsServiceGetTopDocsIntegrationTests:H2 PG 模式 下演练 UNION SQL,覆盖当前路径 / 历史路径 / GA4 清洗 / 脏路径过滤 - dev doc backend/docs/analytics-historical-paths.md 记录下次 IA 重组要走的流程 生产部署要先跑一次迁移 SQL 再发后端,否则历史 URL 还是对不上: docker exec -i involution-postgres psql -U neondb_owner -d involution_hell \ < backend/docs/migrations/2026-04-22-seed-ia-reorg-doc-paths.sql Leetcode 拼音 slug 是另一个独立 issue,需要 docs 表加 public_url 列存 Fumadocs 渲染后的最终 URL,这次不做。 * fix(migrations): 显式写 now() 到 doc_paths.updated_at 生产 schema 里 doc_paths.updated_at 是 NOT NULL 但没 DB-level default (Prisma @updatedat 只在应用层维护),原生 INSERT 不填直接违反约束 跑挂了。改成 INSERT 时显式写 now(),本地 H2 和生产 PG 都 OK。
1 parent 8625f17 commit e7e8f7f

6 files changed

Lines changed: 470 additions & 13 deletions

File tree

docs/analytics-historical-paths.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Analytics · 历史路径匹配(doc_paths)
2+
3+
> 2026-04-22 起,`AnalyticsService.getTopDocs``doc_paths` 表承载 IA 重组前的历史路径,
4+
> 不再在 Java 代码里维护硬编码的 `PATH_REWRITES` 前缀表。
5+
6+
## 问题来源
7+
8+
`/rank?tab=hot&window=all` 和 30D 榜单在 2026-04-19 那次 IA 重组之后几乎为空:
9+
10+
```
11+
commit 6684884 feat(ia): reorganize docs → learn / community / career / projects
12+
app/docs/ai/* → app/docs/learn/ai/*
13+
app/docs/CommunityShare/* → app/docs/community/*
14+
app/docs/jobs/interview-prep/* → app/docs/career/interview-prep/*
15+
app/docs/computer-science/* → app/docs/learn/cs/*
16+
app/docs/all-projects/* → app/docs/projects/*
17+
...
18+
```
19+
20+
- `docs.path_current``frontend/scripts/backfill-contributors.mjs` 刷新,
21+
重组后写的是新路径(比如 `app/docs/learn/ai/multimodal/qwenvl/index.mdx`)。
22+
- GA4 存的是**真实访问发生时**`pagePath`。30D / ALL 窗口里绝大多数历史流量
23+
用的还是老 URL(`/docs/ai/multimodal/qwenvl`)。
24+
- `AnalyticsService.queryDocTitles` 本来只从 `docs.path_current` 做 JOIN,
25+
所以老 URL 一条都对不上 → 过滤完榜单几乎空。
26+
27+
## 解法
28+
29+
**一句话:让 `doc_paths` 做 URL 别名表,SQL 里 UNION 进来。**
30+
31+
```sql
32+
SELECT d.title, regexp_replace(regexp_replace(d.path_current, '^app', ''),
33+
'(/index)?\.(mdx|md)$', '') AS normalized
34+
FROM docs d
35+
WHERE d.path_current IS NOT NULL
36+
UNION ALL
37+
SELECT d.title, regexp_replace(regexp_replace(dp.path, '^app', ''),
38+
'(/index)?\.(mdx|md)$', '') AS normalized
39+
FROM doc_paths dp JOIN docs d ON d.id = dp.doc_id
40+
```
41+
42+
`doc_paths` 在重组前后都会被 `backfill-contributors.mjs``upsertDocPath` 追加
43+
(只增不删),理论上已经记下了每次的当前路径;但如果 DB 是重组之后才从备份恢复
44+
/ 迁移过来的(比如 Neon → 自建 PG),老路径就漏了,需要一次性回填。
45+
46+
## 一次性回填脚本
47+
48+
[`backend/docs/migrations/2026-04-22-seed-ia-reorg-doc-paths.sql`](./migrations/2026-04-22-seed-ia-reorg-doc-paths.sql)
49+
用 CTE + `ROW_NUMBER` 按最长前缀匹配,给每个移动过的 doc 写一条老路径。
50+
51+
执行方式:
52+
53+
```bash
54+
docker exec -i involution-postgres psql -U neondb_owner -d involution_hell \
55+
< backend/docs/migrations/2026-04-22-seed-ia-reorg-doc-paths.sql
56+
```
57+
58+
幂等的,反复跑安全。
59+
60+
## 下次 IA 重组要做什么
61+
62+
1. 前端像往常一样改 `next.config.mjs` 加前缀 redirect、移动 `app/docs/**` 文件。
63+
2. 跑一次 `backfill-contributors.mjs`——新路径自动进 `doc_paths`
64+
3. **把新一条 `('app/docs/<新前缀>', 'app/docs/<旧前缀>')` 加到新的迁移 SQL
65+
并在生产执行一次**,把旧路径补进 `doc_paths`,覆盖重组前的存量流量。
66+
67+
这比之前往 `AnalyticsService.PATH_REWRITES` 硬编码一行然后重新构建部署后端要轻,
68+
也不必两端同步:前端 redirect + SQL 一次性灌 doc_paths 就够了。
69+
70+
## 已知局限
71+
72+
- **Leetcode 拼音 slug**`app/docs/career/interview-prep/leetcode/*.md` 的文件名
73+
仍是中文(如"平衡二叉树.md"),URL 会被 `lib/source.ts` 转成拼音
74+
`ping-heng-er-cha-shu`)。这种情况下 GA4 拿到的 pagePath(拼音)和 docs
75+
表里的 path(中文)本来就对不上,UNION 了 doc_paths 也救不了。
76+
修需要单独开 issue:让 `docs` 里多存一个 `public_url` 列,由前端 sync 时把
77+
Fumadocs 渲染后的最终 slug 写进去。
78+
- **点状 redirect**`next.config.mjs` 里约 34 条单文件 301(swanlab / 若干
79+
cpp_backend 重命名)流量都很小,没有对应的 `doc_paths` 回填。需要时手动
80+
`INSERT INTO doc_paths (doc_id, path) VALUES (...);`
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
-- ---------------------------------------------------------------------------
2+
-- One-off 数据迁移:把 2026-04-19 IA 重组(commit 6684884)之前的旧文件路径
3+
-- 补齐进 doc_paths,让榜单 / rank 接口在 30D / ALL 窗口能命中 GA4 里的历史 pagePath。
4+
--
5+
-- 为什么需要:
6+
-- scripts/backfill-contributors.mjs 只对"当前文件"做 upsertDocPath,
7+
-- 如果某一轮 backfill 是在 IA 重组之后才跑起来(例如 Neon→自建 PG 迁移后首次跑),
8+
-- 老前缀的 doc_paths 行就丢了,GA4 里残留的 /docs/ai/* 之类 pagePath 就永远 join 不上。
9+
--
10+
-- 为什么只覆盖前缀 wildcard,不覆盖 next.config.mjs 里的点状 redirect:
11+
-- 点状 redirect(swanlab / 部分 cpp_backend 重命名)单文件流量很小,漏掉一两条
12+
-- 不影响榜单完整性;前缀型 wildcard 覆盖的老路径才是 30D / ALL 窗口真正的"大头"。
13+
--
14+
-- Leetcode 仍有已知局限:
15+
-- app/docs/career/interview-prep/leetcode/*.md 文件名仍是中文(如"平衡二叉树.md"),
16+
-- 但 URL 会被 lib/source.ts 转成拼音 slug。因此 GA4 命中的 pagePath(拼音)
17+
-- 与 docs.path_current(中文文件名)本就无法直接 join,这是后续独立 issue。
18+
--
19+
-- 幂等性:
20+
-- INSERT ... ON CONFLICT (doc_id, path) DO NOTHING;反复跑安全。
21+
--
22+
-- 使用方式(一次性执行,不走 /docker-entrypoint-initdb.d 自动流程):
23+
-- docker exec -i involution-postgres psql -U neondb_owner -d involution_hell \
24+
-- < backend/docs/migrations/2026-04-22-seed-ia-reorg-doc-paths.sql
25+
--
26+
-- 本地 dev 新拉 docker 起 pg 时不需要跑——docs 表是空的,跑了也是 no-op;
27+
-- 等 scripts/backfill-contributors.mjs 灌完数据再跑即可。
28+
-- ---------------------------------------------------------------------------
29+
30+
-- 用 ROW_NUMBER 按 new_prefix 长度取最长前缀,避免 /career/interview-prep/leetcode/
31+
-- 同时被 /career/interview-prep/ 规则命中,多插一条错误的 jobs/interview-prep/leetcode/ 别名。
32+
WITH ia_reorg_aliases(new_prefix, old_prefix) AS (
33+
VALUES
34+
-- CommunityShare 拆分到 career / community / learn
35+
('app/docs/career/interview-prep/leetcode/', 'app/docs/CommunityShare/Leetcode/'),
36+
('app/docs/community/language/', 'app/docs/CommunityShare/Language/'),
37+
('app/docs/community/life/', 'app/docs/CommunityShare/Life/'),
38+
('app/docs/community/mental-health/', 'app/docs/CommunityShare/MentalHealth/'),
39+
('app/docs/community/dev-tips/', 'app/docs/CommunityShare/Geek/'),
40+
('app/docs/community/tools/', 'app/docs/CommunityShare/Amazing-AI-Tools/'),
41+
('app/docs/learn/ai/reinforcement-learning/', 'app/docs/CommunityShare/Personal-Study-Notes/Reinforcement-Learning/'),
42+
('app/docs/learn/ai/foundation-models/rag/', 'app/docs/CommunityShare/RAG/'),
43+
-- 顶层目录重命名
44+
('app/docs/projects/', 'app/docs/all-projects/'),
45+
('app/docs/learn/ai/', 'app/docs/ai/'),
46+
('app/docs/learn/cs/', 'app/docs/computer-science/'),
47+
-- jobs → career
48+
('app/docs/career/interview-prep/', 'app/docs/jobs/interview-prep/'),
49+
('app/docs/career/events/', 'app/docs/jobs/event-keynote/')
50+
),
51+
ranked_matches AS (
52+
SELECT d.id AS doc_id,
53+
a.old_prefix || substring(d.path_current FROM length(a.new_prefix) + 1)
54+
AS old_path,
55+
ROW_NUMBER() OVER (
56+
PARTITION BY d.id
57+
ORDER BY length(a.new_prefix) DESC
58+
) AS rn
59+
FROM docs d
60+
JOIN ia_reorg_aliases a ON d.path_current LIKE a.new_prefix || '%'
61+
WHERE d.path_current IS NOT NULL
62+
)
63+
-- updated_at 在生产 schema 里是 NOT NULL 但没 DB-level default(Prisma @updatedAt
64+
-- 是应用层维护,原生 INSERT 不会填),这里显式写 now() 兜底。
65+
INSERT INTO doc_paths (doc_id, path, created_at, updated_at)
66+
SELECT doc_id, old_path, now(), now()
67+
FROM ranked_matches
68+
WHERE rn = 1
69+
ON CONFLICT (doc_id, path) DO NOTHING;

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

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import org.springframework.jdbc.core.JdbcTemplate;
88
import org.springframework.stereotype.Service;
99

10+
import java.util.ArrayList;
11+
import java.util.LinkedHashMap;
1012
import java.util.List;
1113
import java.util.Map;
1214
import java.util.stream.Collectors;
@@ -26,49 +28,99 @@ public AnalyticsService(Ga4ReportService ga4ReportService, JdbcTemplate jdbcTemp
2628

2729
@Cacheable(value = "topDocs", key = "#window + '_' + #limit")
2830
public List<TopDocDto> getTopDocs(String window, int limit) {
29-
// 多取一些给过滤留余量:首页、/docs 以外的页面、父目录导航页(docs 表没对应记录)都会被剔掉
31+
// GA4 里一篇文章可能拆成 "?utm_source" / 带尾斜杠 / 有 anchor 等多条记录,
32+
// 所以拉一点余量再按归一化后的 path 合并,保证榜单里 views 是同一篇的累加值。
3033
int fetchSize = Math.min(Math.max(limit * 3, 30), 100);
3134
List<Ga4ReportService.PathCount> pathCounts = ga4ReportService.fetchTopPaths(window, fetchSize);
3235

3336
if (pathCounts.isEmpty()) {
3437
return List.of();
3538
}
3639

37-
List<String> paths = pathCounts.stream().map(Ga4ReportService.PathCount::path).toList();
40+
Map<String, Long> mergedViews = new LinkedHashMap<>();
41+
for (Ga4ReportService.PathCount pc : pathCounts) {
42+
String normalized = normalizePath(pc.path());
43+
if (normalized.isEmpty()) continue;
44+
mergedViews.merge(normalized, pc.views(), Long::sum);
45+
}
3846

39-
// 批量查 docs 表把 path 映射成标题;没匹配到的视为非文档页,直接剔除
47+
List<String> paths = new ArrayList<>(mergedViews.keySet());
4048
Map<String, String> pathToTitle = queryDocTitles(paths);
4149

42-
return pathCounts.stream()
43-
.filter(pc -> pathToTitle.containsKey(pc.path()))
44-
.map(pc -> new TopDocDto(pc.path(), pathToTitle.get(pc.path()), pc.views()))
50+
return mergedViews.entrySet().stream()
51+
.filter(e -> pathToTitle.containsKey(e.getKey()))
52+
.sorted(Map.Entry.<String, Long>comparingByValue().reversed()
53+
.thenComparing(Map.Entry.comparingByKey()))
54+
.map(e -> new TopDocDto(e.getKey(), pathToTitle.get(e.getKey()), e.getValue()))
4555
.limit(limit)
4656
.toList();
4757
}
4858

59+
/**
60+
* 归一化 GA4 pagePath:只做 query / anchor / 尾斜杠清洗,不再做任何 IA 路径重写。
61+
* 历史 IA(比如 2026-04-19 重组前的 /docs/ai/* / /docs/CommunityShare/*)要靠 DB
62+
* 里的 doc_paths 行来命中,见 {@link #queryDocTitles}。
63+
* 对外暴露为 package-private 便于单元测试。
64+
*/
65+
String normalizePath(String path) {
66+
if (path == null || path.isEmpty()) return "";
67+
// GA4 可能把 ?utm_source=... / #section 拆成独立 pagePath,拆分后 views 分散到多条
68+
int q = path.indexOf('?');
69+
if (q >= 0) path = path.substring(0, q);
70+
int h = path.indexOf('#');
71+
if (h >= 0) path = path.substring(0, h);
72+
// 去掉尾部斜杠:docs.path_current / doc_paths.path 正则归一化后都不带尾斜杠
73+
if (path.length() > 1 && path.endsWith("/")) {
74+
path = path.substring(0, path.length() - 1);
75+
}
76+
return path;
77+
}
78+
4979
/**
5080
* 查询 docs 表,把 GA4 返回的 pagePath 批量映射成标题。
5181
*
52-
* GA4 pagePath 形如 /docs/ai/multimodal/qwenvl
53-
* docs.path_current 形如 app/docs/ai/multimodal/qwenvl/index.mdx 或 app/docs/.../xxx.mdx
54-
* 用 PostgreSQL 正则归一化 path_current 为 URL 形式后再匹配。
82+
* <p>这里做的事:把 docs.path_current(当前文件路径)和 doc_paths.path(历史文件路径)
83+
* 一起纳入候选,用同一套 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 窗口的历史流量不丢。
5587
*
56-
* 查询失败直接抛 {@link IllegalStateException},由全局异常处理器返回 500,
88+
* <p>前提:{@code doc_paths} 里要有对应的老路径。前端 scripts/backfill-contributors.mjs
89+
* 每次跑都会 upsert"当前文件"路径(只增不减),加上
90+
* {@code backend/docs/migrations/2026-04-22-seed-ia-reorg-doc-paths.sql} 一次性回填的
91+
* IA 重组前前缀别名,两者一起覆盖了绝大部分历史流量。
92+
*
93+
* <p>GA4 pagePath 形如 {@code /docs/ai/multimodal/qwenvl};
94+
* path_current / doc_paths.path 形如 {@code app/docs/ai/multimodal/qwenvl/index.mdx}
95+
* 或 {@code app/docs/.../xxx.mdx}。
96+
*
97+
* <p>查询失败直接抛 {@link IllegalStateException},由全局异常处理器返回 500,
5798
* 不再返回空 Map 导致上层 containsKey 过滤把整个榜单静默清空。
5899
*/
59100
private Map<String, String> queryDocTitles(List<String> paths) {
60101
if (paths.isEmpty()) return Map.of();
61102

62103
try {
104+
// UNION ALL:同一个 doc 既能被 path_current 命中、也能被 doc_paths 里任一历史
105+
// 路径命中;多行会被下面 Collectors.toMap 的 merge 函数收敛成一条(保留任一 title)。
63106
String sql = """
64107
SELECT normalized AS path_current, title
65108
FROM (
66-
SELECT title,
109+
SELECT d.title,
110+
regexp_replace(
111+
regexp_replace(d.path_current, '^app', ''),
112+
'(/index)?\\.(mdx|md)$', ''
113+
) AS normalized
114+
FROM docs d
115+
WHERE d.path_current IS NOT NULL
116+
UNION ALL
117+
SELECT d.title,
67118
regexp_replace(
68-
regexp_replace(path_current, '^app', ''),
119+
regexp_replace(dp.path, '^app', ''),
69120
'(/index)?\\.(mdx|md)$', ''
70121
) AS normalized
71-
FROM docs
122+
FROM doc_paths dp
123+
JOIN docs d ON d.id = dp.doc_id
72124
) t
73125
WHERE normalized = ANY(?)
74126
""";

0 commit comments

Comments
 (0)