@@ -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 ) {
0 commit comments