From d545ff1a7bfc9d6e9c78e77b665de7cbbdee564d Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:53:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=AE=B5=E8=AF=84=E6=89=93?= =?UTF-8?q?=E5=BC=80=E5=90=8E=E6=B2=A1=E6=9C=89=E8=AF=84=E8=AE=BA=EF=BC=9A?= =?UTF-8?q?=E6=89=BF=E8=BD=BD=E9=A1=B5=E9=9D=A2=E4=B8=A2=E5=A4=B1=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 段评气泡上的数字来自正文里的 ,但点开后的评论页跑在 「/api/reader/browser/page?id=..&s=..」这个承载地址上,页面看到的 location.search 只剩我们自己的 id/s,原地址的 item_id/para/source 全丢了。 评论页正是靠这几个参数去请求 /para_review,于是按缺参数请求,列表永远为空: 外面气泡显示 99,点开一条段评都没有。 承载地址改为把原地址的 query 原样拼在签名参数之后(页面看到的 search 与原站 一致);id/s 放最前,同名的原参数顶不掉我们的签名参数。 --- internal/service/reader/browser_panel.go | 32 +++++++++++-- internal/service/reader/browser_panel_test.go | 45 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index 4b2e0f2..6981954 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -729,8 +729,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string { return s.browserSign("asset|" + id + "|" + target) } -func (s *ReaderService) browserPageURL(id string) string { - return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id) +// browserPageURL 生成 iframe 承载地址。 +// +// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id¶&source +// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的 +// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去 +// 请求评论接口,气泡数照样显示,点开却一条段评都没有。 +// +// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。 +// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。 +func (s *ReaderService) browserPageURL(id, target string) string { + u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id) + if q := browserTargetQuery(target); q != "" { + u += "&" + q + } + return u +} + +// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。 +func browserTargetQuery(target string) string { + target = strings.TrimSpace(target) + if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") { + return "" + } + parsed, err := url.Parse(target) + if err != nil { + return "" + } + return parsed.RawQuery } // browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。 @@ -888,7 +914,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage { Title: e.request.Title, Mode: e.mode, Seq: e.seq, - PageURL: s.browserPageURL(e.id), + PageURL: s.browserPageURL(e.id, e.request.URL), Refetch: e.request.Refetch, SourceID: e.sourceID, TargetURL: target, diff --git a/internal/service/reader/browser_panel_test.go b/internal/service/reader/browser_panel_test.go index 6bcf0a0..0b4be3f 100644 --- a/internal/service/reader/browser_panel_test.go +++ b/internal/service/reader/browser_panel_test.go @@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) { } } +// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。 +// +// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。 +// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有, +// 点开却一条评论都没有。 +func TestBrowserPageURLKeepsTargetQuery(t *testing.T) { + svc, _ := newLoginTestService(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`comment page`)) + })) + defer srv.Close() + + // 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。 + target := srv.URL + "/get_para_review?item_id=abc¶=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy" + entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID, + browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen) + if err != nil { + t.Fatalf("登记承载页面失败: %v", err) + } + + page := svc.browserPageOf(entry) + u, err := url.Parse(page.PageURL) + if err != nil { + t.Fatalf("承载地址不合法: %q", page.PageURL) + } + q := u.Query() + if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" { + t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL) + } + // 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办 + if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) { + t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL) + } + if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil { + t.Fatalf("带业务参数后签名校验失败: %v", err) + } + // 没有 query 的地址(含 data: 页面)不该多出参数 + for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} { + if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 { + t.Fatalf("无 query 的地址被拼进了多余参数: %q", got) + } + } +} + // TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie // 抓取,否则「用户后台」在浏览器里永远是未登录状态。 func TestBrowserPanelInjectsSourceCookies(t *testing.T) {