Compare commits

..

4 Commits

Author SHA1 Message Date
truewhile b0f1ab39b9 优化登录,优化阅读 2026-10-03 21:00:16 +08:00
truewhile 5242d54a06 优化添加段评内容 2026-10-03 18:41:20 +08:00
truewhile e961969207 优化 2026-10-03 18:25:13 +08:00
truewhile b8b02cb3a7 优化 2026-10-03 14:21:07 +08:00
32 changed files with 2975 additions and 219 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
//
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
// 这里只钉住契约与安全边界,避免把测试绑到网络。
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(container)
post := func(body string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w.Code
}
cases := []struct {
name string
body string
}{
{"缺少 url", `{"book_id":"x"}`},
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
}
for _, c := range cases {
if code := post(c.body); code != http.StatusBadRequest {
t.Fatalf("%s:应 400,得到 %d", c.name, code)
}
}
}
+27
View File
@@ -40,6 +40,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
g.POST("/comments/open", readerOpenCommentHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
@@ -249,6 +252,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
BookID string `json:"book_id" binding:"required"`
URL string `json:"url" binding:"required"`
Title string `json:"title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
}
// readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
+30 -1
View File
@@ -64,6 +64,9 @@ type BrowserPage struct {
Title string `json:"title"`
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
Mode string `json:"mode"`
// Seq 登记序号(单调递增)。待办表可能同时存多个页面,前端按它取最新的
// 那个展示——不能依赖返回顺序,map 遍历是随机的。
Seq int64 `json:"seq"`
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
PageURL string `json:"page_url"`
// Refetch 见 rule.BrowserTask.Refetch。
@@ -83,8 +86,10 @@ type BrowserPageSnapshot struct {
}
// pendingBrowser 一个待用户完成的页面。
// seq 为登记序号(越大越新,见 ReaderService.browserSeq),供前端挑选最新页面。
type pendingBrowser struct {
id string
seq int64
sourceID string
sourceURL string
userID string
@@ -180,6 +185,8 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
s.browserPending = map[string]*pendingBrowser{}
}
s.pruneBrowsersLocked()
s.browserSeq++
entry.seq = s.browserSeq
s.browserPending[id] = entry
s.browserMu.Unlock()
@@ -716,6 +723,10 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
if e.sourceID != sourceID {
continue
}
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
if e.finished {
continue
}
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
if e.userID != "" && userID != "" && e.userID != userID {
continue
@@ -726,14 +737,20 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
}
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
//
// 交付后立刻把条目移出待办表:open 模式(java.startBrowser)没有别的清理
// 路径,留着会一直躺到 TTL 到期——光遇聚合的「❇️ 更新书源」就是这么在
// 15 分钟内反复串到其它按钮上的。
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
entry := s.lookupBrowser(id)
if entry == nil {
return errors.New("页面已过期或已完成")
}
// 注意锁序:browserMu 必须在 entry.mu 之外获取(pruneBrowsersLocked 是
// browserMu → entry.mu),这里不能持着 entry.mu 去等 browserMu。
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.finished {
entry.mu.Unlock()
return errors.New("该页面已完成")
}
if cancelled {
@@ -749,6 +766,9 @@ func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, c
if entry.done != nil {
close(entry.done)
}
entry.mu.Unlock()
s.dropBrowser(id)
return nil
}
@@ -785,6 +805,14 @@ func (s *ReaderService) pruneBrowsersLocked() {
}
}
// browserPageOf 生成前端展示用的描述(自行加锁)。
// registerBrowser 之后需要把页面直接回给调用方(如打开一条段评)时用它。
func (s *ReaderService) browserPageOf(e *pendingBrowser) BrowserPage {
s.browserMu.Lock()
defer s.browserMu.Unlock()
return s.pageOfLocked(e)
}
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
target := e.request.URL
@@ -795,6 +823,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
ID: e.id,
Title: e.request.Title,
Mode: e.mode,
Seq: e.seq,
PageURL: s.browserPageURL(e.id),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
@@ -390,3 +390,46 @@ func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
t.Fatal("未解除阻塞")
}
}
// TestBrowserPanelOpenPageDroppedAfterResolve open 模式(java.startBrowser)
// 的页面在用户关闭后必须立刻离开待办表。
//
// 回归:这条日志只删除 wait 模式(awaitBrowser 的 defer),open 模式没有清理
// 路径,会一直躺到 15 分钟 TTL 到期。前端轮询到它就又把旧页面弹出来——光遇
// 聚合点过「❇️ 更新书源」后,接下来点「书源设置」「段评设置」都会跳到书源更新页。
func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
t.Fatalf("登记 open 页面失败: %v", err)
}
old := waitPending(t, svc, sourceID)
if old.Mode != browserModeOpen {
t.Fatalf("模式 = %q,期望 open", old.Mode)
}
// 用户关掉页面
if err := svc.ResolveBrowser(old.ID, readerTestUserID, "", "", true); err != nil {
t.Fatalf("关闭页面失败: %v", err)
}
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
t.Fatalf("关闭后待办未清理: %+v", pages)
}
// 再点别的按钮:待办表里只应剩新页面,且序号更大
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
t.Fatalf("登记新页面失败: %v", err)
}
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
if len(pages) != 1 || pages[0].Title != "光遇书源设置" {
t.Fatalf("待办表应只剩新页面: %+v", pages)
}
if pages[0].Seq <= old.Seq {
t.Fatalf("新页面 seq 应更大: %d <= %d", pages[0].Seq, old.Seq)
}
}
+492
View File
@@ -0,0 +1,492 @@
package reader
import (
"context"
"encoding/base64"
"fmt"
"net"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:正文里的「段评」锚点解析(对齐 legado 的段评机制)。
//
// legado 侧段评有两种落在正文里的形态:
//
// 1. 段落里的 <comment ident="评论地址" count="评论数" />:宿主(苹果/源阅分支)
// 直接把它解析成段落旁的评论气泡;
// 2. 「改版」宿主不认 <comment>,改由书源在正文规则里把它换成一张内嵌 SVG 图片,
// 图片地址后面再跟一段 JSON 配置:
// <img src="data:image/svg+xml;base64,…{"click":"showCmt('评论地址','平台','段评')","style":"text"}">
// legado 识别的是地址后面的这份配置(ReadBookActivity.oldClickImg),点击时把
// 配置里的 click/js 当 JS 执行(showCmt 内部再调 java.showBrowser 打开评论页)。
//
// MeBox 没有 Android 的 Canvas 排版,正文按行渲染,因此这里把两种形态都归一成
// 「某一行的段评气泡」:只摘掉标记本身,正文文字一个字都不动,并把评论地址 / 评论数
// 结构化下发给前端;点击气泡时前端复用宿主浏览器(带书源 Cookie)打开评论地址。
//
// 约定:但凡无法确定评论地址的图片,一律原样保留交给既有链路,绝不猜一个地址出来。
// ContentComment 正文里的一条段评锚点(随正文一起下发给前端)。
type ContentComment struct {
// Line 段落行号:Content 按 "\n" 拆分后的 0-based 下标。
Line int `json:"line"`
// Count 评论数(0 表示未知:前端只画一个不带数字的气泡)。
Count int `json:"count"`
// URL 评论地址(书源 showCmt 的第一个参数),前端点击时交给宿主浏览器打开。
URL string `json:"url"`
// Label 评论类型:段评 / 本章说,用作气泡提示。
Label string `json:"label,omitempty"`
// Block 独占一行的整块评论(章末「本章说」),前端按居中整块渲染。
Block bool `json:"block,omitempty"`
}
// commentTagRe 匹配正文里的 <comment …> 标签。属性顺序不定、可能自闭合,
// 也可能带一个配对结束标签(`<comment …>…</comment>`),一并吃掉。
var commentTagRe = regexp.MustCompile(`(?is)<comment\b[^>]*?/?>(?:</comment>)?`)
// 下面三条属性正则都允许单/双引号两种写法;捕获组 1 是双引号内容,2 是单引号内容。
var (
commentIdentRe = regexp.MustCompile(`(?is)\bident\s*=\s*(?:"([^"]*)"|'([^']*)')`)
commentCountRe = regexp.MustCompile(`(?is)\bcount\s*=\s*(?:"([^"]*)"|'([^']*)')`)
commentOnPressRe = regexp.MustCompile(`(?is)\bonPress\s*=\s*(?:"([^"]*)"|'([^']*)')`)
)
// commentJSURLRe 从 showCmt / showReadingBrowser / startBrowserDp 的调用里取第一个字符串参数。
// 苹果分支的 <comment onPress="java.showReadingBrowser('地址',…)" /> 就靠它取地址。
// RE2 不支持反向引用,双/单引号两种写法拆成两个捕获组。
var commentJSURLRe = regexp.MustCompile(`(?is)(?:showCmt|showReadingBrowser|startBrowserDp)\s*\(\s*(?:"([^"]*)"|'([^']*)')`)
// imgParamRe 宽容地提取图片地址后面 JSON 配置里的键值对。
//
// 不用 encoding/json 解析:光遇聚合的「本章说」把配置拼成 {'type':'qtbzs',"click":"…",'style':'FULL'}
// (单双引号混用),严格 JSON 解析会失败,而它又是合法 JS 对象字面量。
var imgParamRe = regexp.MustCompile(`(?is)["']?(click|js|style|type|width)["']?\s*:\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')`)
// svgCountRe 取内嵌段评 SVG 里画在气泡上的数字(书源把评论数直接画进了 SVG 文本)。
var svgCountRe = regexp.MustCompile(`(?is)<text[^>]*>\s*([0-9]+)\s*\+?\s*</text>`)
// wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。
var wrapMarkupRe = regexp.MustCompile(`(?is)</?(?:p|div|br|hr|h[1-6]|article|dd|dl)\b[^>]*>`)
// extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。
//
// 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。
func extractContentComments(content string) (string, []ContentComment) {
if content == "" || (!strings.Contains(content, "<comment") && !strings.Contains(content, "<img")) {
return content, nil
}
lines := strings.Split(content, "\n")
out := make([]string, len(lines))
comments := make([]ContentComment, 0, 4)
changed := false
for i, line := range lines {
text, cs := extractLineComments(line, i)
out[i] = text
if len(cs) > 0 {
changed = true
comments = append(comments, cs...)
}
}
if !changed {
return content, nil
}
return strings.Join(out, "\n"), comments
}
// extractLineComments 处理一行:返回去掉段评标记后的文字与该行上的段评。
func extractLineComments(line string, lineNo int) (string, []ContentComment) {
type marker struct {
start, end int
comment ContentComment
}
var markers []marker
for _, m := range commentTagRe.FindAllStringIndex(line, -1) {
if c, ok := parseCommentTag(line[m[0]:m[1]], lineNo); ok {
markers = append(markers, marker{m[0], m[1], c})
}
}
for _, tag := range scanImgTags(line) {
if c, ok := parseCommentImage(tag.src, lineNo); ok {
markers = append(markers, marker{tag.start, tag.end, c})
}
}
if len(markers) == 0 {
return line, nil
}
sort.Slice(markers, func(i, j int) bool { return markers[i].start < markers[j].start })
comments := make([]ContentComment, 0, len(markers))
var b strings.Builder
prev := 0
for _, mk := range markers {
if mk.start < prev { // 与前一个标记重叠(理论上不会出现),跳过避免重复删除
continue
}
b.WriteString(line[prev:mk.start])
prev = mk.end
c := mk.comment
// 摘掉标记后整行只剩标签/空白 → 说明评论独占一行(章末「本章说」)
c.Block = isBlankMarkup(line[:mk.start] + line[mk.end:])
comments = append(comments, c)
}
b.WriteString(line[prev:])
return b.String(), comments
}
// imgTagMatch 一行里一个 <img> 标签的位置与 src 值。
type imgTagMatch struct {
start, end int
src string
}
// scanImgTags 扫描一行里的所有 <img> 标签,返回标签位置与 src 值。
//
// 不能用 <img[^>]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成
// <img src="data:…,{"click":"showCmt('…','…')"}"> —— 属性值里的 JSON 又用了双引号,
// 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号,
// 只把「花括号之外遇到的引号」当作属性值的结束。
func scanImgTags(line string) []imgTagMatch {
var out []imgTagMatch
lower := strings.ToLower(line)
for i := 0; i < len(line); {
idx := strings.Index(lower[i:], "<img")
if idx < 0 {
break
}
start := i + idx
src, end, ok := scanImgTagSrc(line, start)
if !ok {
i = start + 4
continue
}
out = append(out, imgTagMatch{start: start, end: end, src: src})
i = end
}
return out
}
// scanImgTagSrc 从 <img 起始位置扫描出 src 属性值,并返回标签结束下标(含 '>')。
func scanImgTagSrc(line string, start int) (string, int, bool) {
lower := strings.ToLower(line)
i := start + 4 // 跳过 "<img"
for {
si := strings.Index(lower[i:], "src")
if si < 0 {
return "", 0, false
}
si += i
q := si + 3
for q < len(line) && isHTMLSpace(line[q]) {
q++
}
// 必须是 src= 而不是 srcset= 之类的近似字段
if q >= len(line) || line[q] != '=' {
i = si + 3
continue
}
q++
for q < len(line) && isHTMLSpace(line[q]) {
q++
}
if q >= len(line) || (line[q] != '"' && line[q] != '\'') {
i = si + 3
continue
}
quote := line[q]
valStart := q + 1
depth := 0
j := valStart
for ; j < len(line); j++ {
c := line[j]
switch {
case c == '{':
depth++
case c == '}' && depth > 0:
depth--
case c == quote && depth == 0:
// 花括号之外的引号才是属性值的结束
gt := strings.IndexByte(line[j:], '>')
if gt < 0 {
return "", 0, false
}
return line[valStart:j], j + gt + 1, true
}
}
return "", 0, false
}
}
// isHTMLSpace 判断 HTML 属性之间的空白(含换行/制表)。
func isHTMLSpace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
// parseCommentTag 解析宿主侧的 <comment ident count /> 形式。
func parseCommentTag(tag string, lineNo int) (ContentComment, bool) {
rawURL := attrOf(commentIdentRe, tag)
if rawURL == "" {
rawURL = attrOf(commentOnPressRe, tag)
}
rawURL = extractCommentURL(rawURL)
if rawURL == "" {
return ContentComment{}, false
}
count, _ := strconv.Atoi(strings.TrimSpace(attrOf(commentCountRe, tag)))
return ContentComment{Line: lineNo, Count: count, URL: rawURL, Label: "段评"}, true
}
// parseCommentImage 解析「图片地址 + JSON 配置」形式的段评。
//
// 只把真正指向评论的气泡当段评:地址要么来自 showCmt/showReadingBrowser,
// 要么图片本身就是内嵌的段评 SVG。其它带 click 配置的图片保持原样,避免误伤。
func parseCommentImage(src string, lineNo int) (ContentComment, bool) {
src = strings.TrimSpace(src)
if src == "" {
return ContentComment{}, false
}
base, params := splitImgSrcParams(src)
if len(params) == 0 {
return ContentComment{}, false
}
action := params["click"]
if action == "" {
action = params["js"]
}
if action == "" {
return ContentComment{}, false
}
isCommentCall := strings.Contains(action, "showCmt") || strings.Contains(action, "showReadingBrowser")
if !isCommentCall && !strings.HasPrefix(base, "data:image/svg+xml") {
return ContentComment{}, false
}
rawURL := extractCommentURL(action)
if rawURL == "" {
return ContentComment{}, false
}
label := "段评"
if strings.Contains(action, "本章说") {
label = "本章说"
}
return ContentComment{
Line: lineNo,
Count: commentCountFromImage(base),
URL: rawURL,
Label: label,
}, true
}
// splitImgSrcParams 把图片地址拆成(地址, JSON 配置),没有配置时返回 (src, nil)。
//
// 对应 legado 的 AnalyzeUrl.paramPattern `\s*,\s*(?=\{)`:地址与配置之间用「逗号 + 花括号」分隔。
func splitImgSrcParams(src string) (string, map[string]string) {
for i := 0; i < len(src); i++ {
if src[i] != ',' {
continue
}
j := i + 1
for j < len(src) && (src[j] == ' ' || src[j] == '\t' || src[j] == '\n' || src[j] == '\r') {
j++
}
if j >= len(src) || src[j] != '{' {
continue
}
end := strings.LastIndexByte(src, '}')
if end <= j {
continue
}
if params := parseImgParams(src[j : end+1]); len(params) > 0 {
return strings.TrimSpace(src[:i]), params
}
}
return src, nil
}
// parseImgParams 宽容解析图片配置里的键值对(见 imgParamRe 的说明)。
func parseImgParams(raw string) map[string]string {
ms := imgParamRe.FindAllStringSubmatch(raw, -1)
if len(ms) == 0 {
return nil
}
out := make(map[string]string, len(ms))
for _, m := range ms {
val := m[2]
if val == "" {
val = m[3]
}
val = strings.ReplaceAll(val, `\"`, `"`)
val = strings.ReplaceAll(val, `\'`, `'`)
out[strings.ToLower(m[1])] = val
}
return out
}
// commentCountFromImage 从内嵌段评 SVG 里取评论数(书源把数字画进了 SVG 文本)。
// 取不到时返回 0,前端只画一个不带数字的气泡。
func commentCountFromImage(src string) int {
decoded, ok := decodeCommentDataURI(src)
if !ok {
return 0
}
m := svgCountRe.FindStringSubmatch(decoded)
if m == nil {
return 0
}
n, err := strconv.Atoi(m[1])
if err != nil || n < 0 {
return 0
}
return n
}
// decodeCommentDataURI 解出 data: URI 的文本内容(支持 base64 与明文两种)。
func decodeCommentDataURI(s string) (string, bool) {
if !strings.HasPrefix(s, "data:") {
return "", false
}
comma := strings.IndexByte(s, ',')
if comma < 0 {
return "", false
}
meta, payload := s[:comma], s[comma+1:]
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
b, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", false
}
return string(b), true
}
return payload, true
}
// extractCommentURL 从 showCmt/showReadingBrowser 调用或裸地址里取评论地址。
func extractCommentURL(js string) string {
js = strings.TrimSpace(js)
if js == "" {
return ""
}
if m := commentJSURLRe.FindStringSubmatch(js); m != nil {
return trimCommentURL(firstSubmatch(m))
}
if strings.HasPrefix(js, "http://") || strings.HasPrefix(js, "https://") {
return trimCommentURL(js)
}
return ""
}
// attrOf 取属性值,双引号优先、单引号兜底。
func attrOf(re *regexp.Regexp, s string) string {
m := re.FindStringSubmatch(s)
if m == nil {
return ""
}
return firstSubmatch(m)
}
// firstSubmatch 返回「双引号 / 单引号」两个捕获组里非空的那个。
func firstSubmatch(m []string) string {
if len(m) > 1 && m[1] != "" {
return m[1]
}
if len(m) > 2 {
return m[2]
}
return ""
}
// trimCommentURL 去空白并还原 HTML 实体。
// 苹果/源阅分支会把地址里的 & 转成 &amp;(paraForiOS),直接拿去请求会 404。
func trimCommentURL(raw string) string {
r := strings.NewReplacer(
"&quot;", `"`, "&#34;", `"`,
"&#39;", "'", "&apos;", "'",
"&lt;", "<", "&gt;", ">",
"&amp;", "&",
)
return strings.TrimSpace(r.Replace(raw))
}
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
func isBlankMarkup(s string) bool {
s = wrapMarkupRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&nbsp;", " ")
s = strings.ReplaceAll(s, "\u3000", " ")
return strings.TrimSpace(s) == ""
}
// ─── 打开段评(宿主浏览器承载) ─────────────────────────────────────────────
// OpenContentComment 在宿主浏览器里打开一条段评。
//
// 书源点击段评气泡时执行的是它自己的 showCmt(...):先 java.ajax 取评论页,
// 再 java.showBrowser 展示。MeBox 不跑书源 JS,而是把 showCmt 的第一个参数
// (评论地址)解析出来(见本文件),复用承载登录页的那套宿主浏览器打开:
// 服务端带书源 Cookie 抓取评论页,前端 iframe 展示。这样评论接口需要的登录态
// 仍然生效——前端直接 window.open 会以未登录身份访问。
//
// 返回的 BrowserPage 直接给前端渲染,不必走轮询(段评只需展示、无需回传 DOM)。
func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, rawURL, title string) (*BrowserPage, error) {
if s.repo == nil {
return nil, fmt.Errorf("阅读服务未就绪")
}
target, err := validateCommentURL(rawURL)
if err != nil {
return nil, err
}
book, err := s.repo.GetBook(ctx, bookID)
if err != nil || book == nil {
return nil, fmt.Errorf("书籍不存在或已移出书架")
}
sourceID := ""
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
sourceID = src.ID
}
if strings.TrimSpace(title) == "" {
title = "段评"
}
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
if err != nil {
return nil, err
}
page := s.browserPageOf(entry)
return &page, nil
}
// validateCommentURL 校验评论地址:只允许 http(s),并挡住内网地址。
//
// 段评地址来自书源正文,会带着书源登录态由服务端去抓;不做限制就等于给了一个
// 「以书源身份请求任意地址」的口子(SSRF)。
func validateCommentURL(raw string) (string, error) {
u := strings.TrimSpace(raw)
if u == "" {
return "", fmt.Errorf("评论地址为空")
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", fmt.Errorf("评论地址无效:%s", truncateForLog(u, 80))
}
if isPrivateHost(parsed.Hostname()) {
return "", fmt.Errorf("评论地址指向内网,已拒绝")
}
return u, nil
}
// isPrivateHost 判断主机名是否是回环/内网/链路本地地址。
func isPrivateHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if h == "" || h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
ip := net.ParseIP(h)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
}
+342
View File
@@ -0,0 +1,342 @@
package reader
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 段评解析单测:覆盖 legado 的两种正文形态(<comment /> 与「图片地址 + JSON 配置」),
// 以及「绝不能吞掉或破坏正文文字」这条硬约束。
// svgCommentSrc 按光遇聚合 createSvg 的写法拼一张带评论数气泡图:
// data:image/svg+xml;base64,<svg>,{"click":"showCmt('地址','平台','段评')","style":"text"}
//
// 注意第二个逗号后面直接跟 JSON,且 JSON 自身用双引号——它嵌在双引号属性里,
// 正是会打断普通正则的「非法 HTML」,解析必须扛得住。
func svgCommentSrc(t *testing.T, count int, click string, style string) string {
t.Helper()
svg := fmt.Sprintf(
`<svg xmlns='http://www.w3.org/2000/svg'><text x='1' y='2'>%d</text></svg>`, count)
b64 := base64.StdEncoding.EncodeToString([]byte(svg))
return fmt.Sprintf(`data:image/svg+xml;base64,%s,{"click":%q,"style":%q}`, b64, click, style)
}
func TestExtractContentCommentsLegacyTag(t *testing.T) {
content := `<p>第一段<comment ident="https://cdn.example.com/c/1" count="12" /></p>
<p>第二段</p>`
text, comments := extractContentComments(content)
if text != "<p>第一段</p>\n<p>第二段</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 0 || c.Count != 12 || c.URL != "https://cdn.example.com/c/1" || c.Label != "段评" {
t.Fatalf("解析结果不符:%+v", c)
}
if c.Block {
t.Fatalf("段落内的段评不应标记为整块:%+v", c)
}
}
func TestExtractContentCommentsIOSOnPress(t *testing.T) {
content := `<p>正文<comment count="7" onPress="java.showReadingBrowser('https://cdn.example.com/c/2?a=1&amp;b=2','番茄段评')"></comment></p>`
text, comments := extractContentComments(content)
if text != "<p>正文</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
// &amp; 必须还原成 &,否则请求评论接口会 404
if comments[0].URL != "https://cdn.example.com/c/2?a=1&b=2" || comments[0].Count != 7 {
t.Fatalf("解析结果不符:%+v", comments[0])
}
}
func TestExtractContentCommentsInlineImage(t *testing.T) {
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1&item_id=2','番茄','段评')", "text")
content := `<p>正文内容<img src="` + src + `"></p>
<p>下一段</p>`
text, comments := extractContentComments(content)
if text != "<p>正文内容</p>\n<p>下一段</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 0 || c.Count != 3 || c.Label != "段评" {
t.Fatalf("解析结果不符:%+v", c)
}
// 地址里的 & 原样保留(JSON 里的 & 不是实体)
if c.URL != "https://v1.example.com/get_review?book_id=1&item_id=2" {
t.Fatalf("评论地址 = %q", c.URL)
}
}
func TestExtractContentCommentsChapterEndBlock(t *testing.T) {
// 章末「本章说」:单独一行,配置用单引号键、双引号值(合法 JS 对象字面量,非法 JSON)
line := `<img src="https://v1.example.com/chapter_review_svg?book_id=1&amp;img=1,` +
`{'type':'qtbzs',` +
`"click":"showCmt('https://v1.example.com/get_review?book_id=1','番茄','本章说' )",` +
`'style':'FULL'}">`
content := "<p>正文最后一段</p>\n" + line
text, comments := extractContentComments(content)
if text != "<p>正文最后一段</p>\n" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 1 || c.Label != "本章说" || !c.Block {
t.Fatalf("解析结果不符:%+v", c)
}
if c.URL != "https://v1.example.com/get_review?book_id=1" {
t.Fatalf("评论地址 = %q", c.URL)
}
}
func TestExtractContentCommentsCountOver99(t *testing.T) {
// 书源把 >99 的评论数显示成 "99+",解析要取到 99
src := svgCommentSrc(t, 99, "showCmt('https://x.example.com/c','番茄','段评')", "text")
// 手工把文本改成 99+
src = strings.Replace(src, ">99<", ">99+<", 1)
_, comments := extractContentComments(`<p>x<img src="` + src + `"></p>`)
if len(comments) != 1 || comments[0].Count != 99 {
t.Fatalf("期望解析出 99,得到 %+v", comments)
}
}
// 没有任何段评标记时,正文必须逐字节不变(不能因为「看起来像 HTML」就动手)。
func TestExtractContentCommentsPreservesPlainContent(t *testing.T) {
content := `<p>普通段落</p>
<p>带插图的段落<img src="https://cdn.example.com/a.jpg"></p>
<p>带 onclick 图但非段评<img src="https://cdn.example.com/b.jpg,{'click':'openPhoto(1)'}"></p>`
text, comments := extractContentComments(content)
if text != content {
t.Fatalf("正文被改动:\n got %q\nwant %q", text, content)
}
if len(comments) != 0 {
t.Fatalf("不应解析出段评:%+v", comments)
}
}
func TestExtractContentCommentsEmpty(t *testing.T) {
if text, comments := extractContentComments(""); text != "" || comments != nil {
t.Fatalf("空正文应原样返回,得到 %q / %+v", text, comments)
}
if text, comments := extractContentComments("<p>只有普通正文</p>"); text != "<p>只有普通正文</p>" || comments != nil {
t.Fatalf("无标记正文应原样返回,得到 %q / %+v", text, comments)
}
}
func TestSplitImgSrcParams(t *testing.T) {
base, params := splitImgSrcParams(`data:image/svg+xml;base64,QUJD,{"click":"showCmt('u','x')","style":"text"}`)
if base != "data:image/svg+xml;base64,QUJD" {
t.Fatalf("base = %q", base)
}
if params["click"] != "showCmt('u','x')" || params["style"] != "text" {
t.Fatalf("params = %+v", params)
}
if base, params := splitImgSrcParams("https://x.example.com/a.jpg"); params != nil || base != "https://x.example.com/a.jpg" {
t.Fatalf("无配置时不应拆出参数:%q / %+v", base, params)
}
}
func TestValidateCommentURL(t *testing.T) {
bad := []string{
"", "not-a-url", "file:///etc/passwd", "javascript:alert(1)",
"http://localhost/c", "http://127.0.0.1/c", "http://192.168.1.1/c", "http://[::1]/c",
}
for _, u := range bad {
if _, err := validateCommentURL(u); err == nil {
t.Fatalf("%q 应被拒绝", u)
}
}
good := "https://v1.qingtian618.com/get_review?book_id=1&item_id=2"
if got, err := validateCommentURL(good); err != nil || got != good {
t.Fatalf("合法地址被拒:%q / %v", got, err)
}
}
// 端到端:书源正文里带段评标记时,GetContentForBook 应清掉标记、
// 下发结构化 Comments,且正文文字完好。
func TestGetContentForBookExtractsComments(t *testing.T) {
src := svgCommentSrc(t, 5, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
// 服务端按 hex 还原规则返回值里的正文;这里让正文规则直接返回一段放进 data 信封的 JSON。
pageJSON := fmt.Sprintf(`{"content":%q}`, "<p>第一段<img src=\""+src+"\"></p>\n<p>第二段</p>")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(pageJSON))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
srcJSON := fmt.Sprintf(`{
"bookSourceUrl": %q,
"bookSourceName": "段评测试源",
"bookSourceType": 0,
"ruleContent": { "content": "$.content" }
}`, srv.URL)
importTestSource(t, svc, srcJSON, srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "段评测试源",
BookURL: srv.URL + "/book/1",
Name: "测试书",
Type: 0,
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
}); err != nil {
t.Fatalf("写入章节失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("类型 = %q,期望 text", out.Type)
}
if strings.Contains(out.Content, "<img") || strings.Contains(out.Content, "showCmt") {
t.Fatalf("段评标记没有清干净:%q", out.Content)
}
// 块级标签折成换行后,段落文字一个字都不少(多余空行已压掉)。
if out.Content != "第一段\n第二段" {
t.Fatalf("正文被破坏:%q", out.Content)
}
if strings.Contains(out.Content, "<p>") || strings.Contains(out.Content, "</p>") {
t.Fatalf("块级标签没有折行:%q", out.Content)
}
if len(out.Comments) != 1 {
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
}
c := out.Comments[0]
if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
t.Fatalf("段评内容不符:%+v", c)
}
}
// setupTextChapterBook 搭一个「正文规则直接返回指定字符串」的文本源 + 一本书,
// 返回服务实例与书籍 ID,供正文链路回归测试复用。
func setupTextChapterBook(t *testing.T, content string) (*ReaderService, string) {
t.Helper()
pageJSON := fmt.Sprintf(`{"content":%q}`, content)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(pageJSON))
}))
t.Cleanup(srv.Close)
svc, _ := newLoginTestService(t)
ctx := t.Context()
srcJSON := fmt.Sprintf(`{
"bookSourceUrl": %q,
"bookSourceName": "正文链路测试源",
"bookSourceType": 0,
"ruleContent": { "content": "$.content" }
}`, srv.URL)
importTestSource(t, svc, srcJSON, srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "正文链路测试源",
BookURL: srv.URL + "/book/1",
Name: "测试书",
Type: 0,
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
}); err != nil {
t.Fatalf("写入章节失败: %v", err)
}
return svc, book.ID
}
// TestGetContentForBookKeepsTextWithStandaloneImage 回归问题 A:带 <p> 正文的章节
// 混入一行独立普通插图时,仍然必须是文本类型、正文一个字都不能丢
// (旧实现把 <p>正文</p> 当孤立标签跳过,整章被误判成图片章清空)。
func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
content := "<p>第一段正文</p>\n" +
"<p>第二段正文</p>\n" +
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("类型 = %q,期望 text(正文不能被当成图片章)", out.Type)
}
if !strings.Contains(out.Content, "第一段正文") || !strings.Contains(out.Content, "第二段正文") {
t.Fatalf("正文文字丢失:%q", out.Content)
}
if !strings.HasPrefix(out.Content, "第一段正文\n第二段正文\n"+imgMarkerPrefix) {
t.Fatalf("正文结构不符:%q", out.Content)
}
if len(out.Images) != 0 {
t.Fatalf("文本类型的 Images 应为空,得到 %v", out.Images)
}
}
// TestGetContentForBookStripsBlockTagsKeepsText 回归问题 B:段评开启时正文是
// <p>正文<img …,{click}> 形态,下发/显示的文本里不能残留块级标签,且文字一字不少。
func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
content := "<p>第一段正文</p>\n" +
"<p>第二段正文<img src=\"" + src + "\"></p>\n" +
"<p>第三段<br>折行后的文字</p>\n" +
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("类型 = %q,期望 text", out.Type)
}
t.Logf("最终下发/显示的正文 = %q", out.Content)
t.Logf("段评锚点 = %+v", out.Comments)
for _, tag := range []string{"<p>", "</p>", "<br>", "<img", "showCmt"} {
if strings.Contains(out.Content, tag) {
t.Fatalf("显示文本里残留了 %q:%q", tag, out.Content)
}
}
// 正文文字一字不少(含 <br> 折出来的那一段)。
for _, want := range []string{"第一段正文", "第二段正文", "折行后的文字"} {
if !strings.Contains(out.Content, want) {
t.Fatalf("正文文字丢失 %q:%q", want, out.Content)
}
}
if !strings.Contains(out.Content, imgMarkerPrefix) {
t.Fatalf("普通插图应保留成 %s 标记:%q", imgMarkerPrefix, out.Content)
}
if len(out.Comments) != 1 {
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
}
if out.Comments[0].URL != "https://v1.example.com/get_review?book_id=1" {
t.Fatalf("段评地址不符:%+v", out.Comments[0])
}
}
+253 -49
View File
@@ -13,6 +13,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
@@ -45,11 +46,19 @@ type ReaderService struct {
// browserMu / browserPending 保护「待用户完成的页面」表
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
// browserSeq 是页面登记的单调递增序号,供前端判断哪个页面最新。
browserMu sync.Mutex
browserPending map[string]*pendingBrowser
browserSeq int64
// limiter 单源限速(书源 concurrentRate)。
limiter *sourceRateLimiter
// tocFlightsMu / tocFlights 保护「同一本书正在抓目录」的单飞登记表:
// 换源、加入书架之后,服务端预热与阅读页会几乎同时来抓同一份目录
// (见 fetchTocDeduped)。
tocFlightsMu sync.Mutex
tocFlights map[string]*tocFlight
}
// NewReaderService 创建服务。
@@ -1161,6 +1170,87 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL,
if err != nil {
return nil, err
}
chapters, declared, err := s.fetchTocDeduped(ctx, userID, src, bs, bookURL, tocURL)
if err != nil {
return nil, err
}
// 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
// 顺便把末章标题与「最近更新」时间写回,供书架显示与排序。
s.applyTocMeta(ctx, userID, src.SourceURL, bookURL, declared, chapters)
return chapters, nil
}
// tocFetchTimeout 单飞抓目录的时间上限。与目录预热一致,并且与调用方的 ctx 脱钩:
// 共用的那一次抓取不该因为某一个调用方断开而半途而废。
const tocFetchTimeout = 60 * time.Second
// tocFlight 一次进行中的目录抓取,并发的调用方共享它的结果。
type tocFlight struct {
done chan struct{}
chapters []TocChapter
declared int
err error
}
// fetchTocDeduped 抓目录,但同一本书的并发抓取合并成一次网络请求。
//
// 换源、加入书架之后,服务端的目录预热(WarmUpBookChaptersAsync)与阅读页在章节
// 缓存为空时的 /api/reader/toc 会几乎同时到达:实测同一个请求打了两遍上游
// (目录 3.7s 与 11.7s),目录也因此被写了两遍。这里按「书源 + 书本地址」登记在飞
// 请求,后到的一方直接等前一方出结果,不再重复抓。
//
// key 里的 tocURL 取「规范化后」的值:调用方为空时按 book_url 处理(getTocFrom 的
// 语义),于是预热传空串、阅读页传 toc_url || book_url 这两种情况会落到同一个 key 上。
// 显式给了不同目录地址的调用方(详情页用详情里解析出的 tocUrl)不与之合并,避免把
// 一次抓取的结果当成另一份目录。
//
// key 不含 userID:目录本身是公开内容,只有写回书架那一步分用户(用发起抓取的那个
// 调用方的身份;没拿到写回的调用方下次会自己补一次详情,代价很小)。
func (s *ReaderService) fetchTocDeduped(
ctx context.Context, userID string, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string,
) ([]TocChapter, int, error) {
effectiveToc := strings.TrimSpace(tocURL)
if effectiveToc == "" {
effectiveToc = bookURL
}
key := firstNonEmpty(src.ID, src.SourceURL) + "\x00" + bookURL + "\x00" + effectiveToc
s.tocFlightsMu.Lock()
if s.tocFlights == nil {
s.tocFlights = map[string]*tocFlight{}
}
if f, ok := s.tocFlights[key]; ok {
s.tocFlightsMu.Unlock()
select {
case <-f.done:
return f.chapters, f.declared, f.err
case <-ctx.Done():
// 自己先不等了(浏览器断开/超时),共用的那次抓取照常跑完。
return nil, -1, ctx.Err()
}
}
f := &tocFlight{done: make(chan struct{})}
s.tocFlights[key] = f
s.tocFlightsMu.Unlock()
// 抓取与调用方的取消脱钩:两个调用方共用这份结果,谁先断开都不该让另一方拿到
//「context canceled」,也不能让预热在阅读页断开时白跑一半。
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tocFetchTimeout)
defer cancel()
f.chapters, f.declared, f.err = s.loadTocFromSource(fetchCtx, userID, src, bs, bookURL, tocURL)
close(f.done)
s.tocFlightsMu.Lock()
delete(s.tocFlights, key)
s.tocFlightsMu.Unlock()
return f.chapters, f.declared, f.err
}
// loadTocFromSource 真正抓一次目录,含「给的目录地址抓不到章节就回退详情规则」的兜底。
func (s *ReaderService) loadTocFromSource(
ctx context.Context, userID string, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string,
) ([]TocChapter, int, error) {
chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL)
if err != nil || len(chapters) == 0 {
// 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 gydetail 信封):
@@ -1174,14 +1264,10 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL,
}
}
if err != nil {
return nil, err
return nil, declared, err
}
}
// 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
// 顺便把末章标题与「最近更新」时间写回,供书架显示与排序。
s.applyTocMeta(ctx, userID, src.SourceURL, bookURL, declared, chapters)
return chapters, nil
return chapters, declared, nil
}
// latestChapterTitleOf 取目录里最后一个非卷章节的标题(对应 legado 的「最新章节」)。
@@ -1401,6 +1487,9 @@ type ChapterContent struct {
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
// Comments 正文里的段评锚点(<comment> 标记与带 click 配置的内嵌评论图,见 comment.go)。
// 只在文本类型下有意义;前端按 Line 把气泡挂到对应段落旁。
Comments []ContentComment `json:"comments,omitempty"`
// Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了),
// 首次播放需要等转码完成,之后命中缓存秒开。
Transcoding bool `json:"transcoding,omitempty"`
@@ -1702,10 +1791,68 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
out.ImageStyle = SPtr(cr.ImageStyle)
default:
out.Type = "text"
// 块级标签(<p>/</p>/<br>)折成换行:书源段评开启时正文就是这种 HTML 形态,
// 前端纯文本渲染,不折的话读者会看到字面标签。必须先折行再摘段评(行号会变)。
content = normalizeContentBlocks(content)
// 正文里的段评标记(<comment …/> 或「图片地址 + click 配置」的内嵌评论图)
// 归一成结构化锚点,正文文字原样保留(见 comment.go 的说明)。
out.Content, out.Comments = extractContentComments(content)
}
if chapterContentEmpty(out) {
if name := srcNameOf(src, bs); name != "" {
return nil, fmt.Errorf("正文为空:书源「%s」未返回内容,可稍后重试或换源", name)
}
return nil, fmt.Errorf("正文为空:书源未返回内容,可稍后重试或换源")
}
return out, nil
}
// chapterContentEmpty 判断正文是不是「什么都没取到」。
//
// 聚合类书源在自己的 request() 里把所有线路都试完后会返回空串(光遇聚合就是这么
// 写的),内容规则于是给出空正文。这种空结果以前当成功下发,前端渲染成一张白页
// 还会缓存下来,读者只能干等;这里改成明确报错,让页面提示「可重试 / 可换源」。
func chapterContentEmpty(out *ChapterContent) bool {
switch out.Type {
case "audio":
return len(out.Tracks) == 0
case "image":
return len(out.Images) == 0
default:
// 只挂段评、没有正文文字的行不算空:整章正文可能确实只有一个本章说气泡。
return strings.TrimSpace(out.Content) == "" && len(out.Comments) == 0
}
}
// 正文里的块级标签边界:<p>、</p>、<br> 这类只表达段落、没有文字的标签。
// 书源(如光遇聚合的 paraForAndroid)在段评开启时把正文拼成 <p>正文<comment/></p>,
// 前端是纯文本渲染({text}),不折行的话读者看到的就是字面的 <p>、</p>。
var (
contentBreakRe = regexp.MustCompile(`(?i)<\s*br\s*/?\s*>`)
contentBlockOpenRe = regexp.MustCompile(`(?i)<\s*(?:p|div|h[1-6]|li|tr|blockquote|section|article)\b[^>]*>`)
contentBlockCloseRe = regexp.MustCompile(`(?i)</\s*(?:p|div|h[1-6]|li|tr|blockquote|section|article)\s*>`)
contentBlankLineRe = regexp.MustCompile(`\n{2,}`)
)
// normalizeContentBlocks 把正文里的块级标签(<p></p>、<br> 等)按语义折成换行。
//
// 只处理段落边界的标签,绝不删除行内标签(普通插图 <img> 原样保留),
// 也不做任意 HTML 渲染——前端仍然只渲染纯文本 + [img] 标记。
// 必须在摘段评之前调用:折行会改变行号,段评锚点要落在折行后的正文上,
// 否则前端按行号挂气泡会错位。
func normalizeContentBlocks(content string) string {
if !strings.ContainsRune(content, '<') {
return content
}
content = contentBreakRe.ReplaceAllString(content, "\n")
content = contentBlockOpenRe.ReplaceAllString(content, "\n")
content = contentBlockCloseRe.ReplaceAllString(content, "\n")
// 相邻块边界(</p> 与下一行 <p>)会折出多余空行,压成一行;首尾空行一并去掉,
// 免得段评行号里混进无意义的空行。
content = contentBlankLineRe.ReplaceAllString(content, "\n")
return strings.Trim(content, "\n")
}
// rewriteContentImageMarkers 把网络文本正文里「整行就是 <img src="…">」的内容改写成
// [img]<地址> 标记行,前端据此渲染成图片。
//
@@ -1754,7 +1901,8 @@ func rewriteContentImageMarkers(content, baseURL string, proxy func(string) stri
// 一屏只看得到一张,桌面端也没法两页并排——正是「漫画没法双页铺开」的根因。
// 所以这里识别出来之后由调用方把类型改成 image,交给漫画阅读器。
//
// 只认「非空行全是标记」:混了正文的章节一律保持 text,绝不能把文字吃掉。
// 只认「非空行全是标记」且至少有一张图:混了正文的章节一律保持 text,绝不能把文字吃掉;
// 没有一张图标记的空/纯标签章节也不是图片章。
// 返回的地址已经是签名代理地址(调用点在此之前刚做过改写)。
func imageMarkersOnly(content string) ([]string, bool) {
var images []string
@@ -1777,30 +1925,47 @@ func imageMarkersOnly(content string) ([]string, bool) {
return images, len(images) > 0
}
// isHTMLTagOnly 判断整行是不是一个孤立的 HTML 标签(<div>、</div>、<br> 之类)。
// 即「<」开头、「>」结尾,且尖括号内是标签名的形状(字母开头,后面可带属性)。
// 标签名两侧的空白(源码里常见的 < div > 这类手写残渣)一并忽略。
// isHTMLTagOnly 判断整行是不是「没有可读文字的标签/空白」(<div>、</div>、< br > 之类)。
//
// 逐个跳过尖括号片段,只要尖括号外还剩非空白字符,就不是空行。
// 关键:<p>正文</p> 这类「标签里裹着正文」的行第一个 '>' 后面还有文字,
// 必须当正文放行——旧实现看到标签名后的第一个非字母字符('>')就返回 true,
// 于是整章正文被 imageMarkersOnly 当成孤立标签跳过、误判成图片章清空。
//
// 尖括号里的内容必须像标签(字母开头)才算数,避免把「3 < 5」这类普通文字行当成标签。
func isHTMLTagOnly(line string) bool {
if len(line) < 3 || line[0] != '<' || line[len(line)-1] != '>' {
if !strings.ContainsRune(line, '<') {
return false
}
inner := strings.TrimSpace(strings.TrimPrefix(line[1:len(line)-1], "/"))
for i := 0; i < len(line); {
if line[i] != '<' {
if !isHTMLSpace(line[i]) {
return false // 尖括号外还有可读文字
}
i++
continue
}
gt := strings.IndexByte(line[i:], '>')
if gt < 0 {
return false // 没闭合的 '<',当正文处理
}
if !looksLikeTag(line[i+1 : i+gt]) {
return false
}
i += gt + 1
}
return true
}
// looksLikeTag 判断尖括号里的内容是不是标签名(<div>、</div>、< br >),
// 而不是「<3」这类普通文字。允许标签名前后的空白与自闭合斜杠。
func looksLikeTag(inner string) bool {
inner = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(inner), "/"))
if inner == "" {
return false
}
for i, r := range inner {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
continue
case i == 0:
// 首字符不是字母:<3、<!-- 注释 --> 之类,一律不当标签
return false
default:
// 标签名之后(属性、空白、自闭合斜杠)都算标签
return true
}
}
return true
c := inner[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
// imageRefsInLine 取出一行正文里的图片地址。
@@ -2074,11 +2239,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b
// TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。
type TocRefreshResult struct {
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Failed int `json:"failed"` // 抓取或写入失败的书
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新)
Failed int `json:"failed"` // 抓取或写入失败的书
}
// tocRefreshStatus 单本书目录刷新的结果。
type tocRefreshStatus int
const (
// tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。
tocRefreshUnchanged tocRefreshStatus = iota
// tocRefreshUpdated 抓取成功且章数变多,有新章节。
tocRefreshUpdated
// tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。
tocRefreshFailed
)
// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。
const refreshTocConcurrency = 4
@@ -2087,6 +2265,8 @@ const refreshTocConcurrency = 4
// 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新
// latest_chapter_time。本地书籍与没有书源信息的书籍跳过。
// 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。
// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节),
// 混在一起会让人误以为书源坏了。
func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) {
books, err := s.repo.ListBooks(ctx, userID)
if err != nil {
@@ -2118,43 +2298,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To
defer func() { <-sem }()
bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout)
defer cancel()
if s.refreshBookToc(bookCtx, userID, b) {
mu.Lock()
res.Updated++
mu.Unlock()
return
}
status := s.refreshBookToc(bookCtx, userID, b)
mu.Lock()
res.Failed++
mu.Unlock()
defer mu.Unlock()
switch status {
case tocRefreshUpdated:
res.Updated++
case tocRefreshUnchanged:
res.Unchanged++
default:
res.Failed++
}
}(b)
}
wg.Wait()
return res, nil
}
// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool {
// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus {
before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID})
chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL)
if err != nil || len(chapters) == 0 {
if err != nil && s.log != nil {
s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err))
}
return false
if err != nil {
s.logTocRefreshFailure("reader: 更新目录失败", book, err)
return tocRefreshFailed
}
// 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。
// 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。
if len(chapters) == 0 {
s.logTocRefreshFailure("reader: 更新目录为空", book, nil)
return tocRefreshFailed
}
inputs := make([]ChapterInput, 0, len(chapters))
for _, ch := range chapters {
inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume})
}
if err := s.SaveChapters(ctx, book.ID, inputs); err != nil {
if s.log != nil {
s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err))
}
return false
s.logTocRefreshFailure("reader: 更新目录写入失败", book, err)
return tocRefreshFailed
}
beforeCount := before[book.ID]
return beforeCount > 0 && len(chapters) > beforeCount
// 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。
if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount {
return tocRefreshUpdated
}
return tocRefreshUnchanged
}
// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自
// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。
func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) {
if s.log == nil {
return
}
fields := []zap.Field{
zap.String("book", book.ID),
zap.String("name", book.Name),
zap.String("origin", book.OriginName),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.log.Warn(msg, fields...)
}
// ListReplaceRules 用户替换规则列表。
@@ -105,6 +105,13 @@ func TestImageMarkersOnly(t *testing.T) {
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
wantOK: false,
},
{
// 回归:<p>正文</p> 是「标签里裹着正文」,绝不能当成孤立标签跳过,
// 否则整章正文会被误判成图片章清空。
name: "标签里裹着正文的行",
content: imgMarkerPrefix + "/a.jpg\n<p>第一章正文</p>\n<p>第二章正文</p>\n",
wantOK: false,
},
}
for _, c := range cases {
got, ok := imageMarkersOnly(c.content)
@@ -127,6 +134,73 @@ func TestImageMarkersOnly(t *testing.T) {
}
}
// TestIsHTMLTagOnly 「没有可读文字的标签行」才算空行:标签里裹着正文的行必须放行,
// 否则 imageMarkersOnly 会把整章正文当图片丢掉。
func TestIsHTMLTagOnly(t *testing.T) {
blank := []string{"<div>", "</div>", "< br >", "<br/>", "<p></p>", "<hr />", "</p >", "<a> </a>", "<p>"}
for _, s := range blank {
if !isHTMLTagOnly(s) {
t.Errorf("%q 应视为没有可读文字的标签行", s)
}
}
text := []string{
"<p>正文</p>", "<p>正文</p></p>", "<div>文字</div>", "<p>第一段</p><p>第二段</p>",
"正文 <b>粗</b>", "3 < 5", "<3>", "a", "", " ",
}
for _, s := range text {
if isHTMLTagOnly(s) {
t.Errorf("%q 含可读文字/不是标签,不能当空行", s)
}
}
}
// TestNormalizeContentBlocks 块级标签折成换行、行内标签与文字原样保留。
func TestNormalizeContentBlocks(t *testing.T) {
cases := []struct{ in, want string }{
{"<p>第一段</p>\n<p>第二段</p>", "第一段\n第二段"},
{"<p>第一段<br>折行文字</p>", "第一段\n折行文字"},
{"<P CLASS='x'>大写带属性</P>", "大写带属性"},
{"<div>块</div><br/><br />", "块"},
// 行内插图原样保留(不能吃掉普通 <img>)
{`<p>正文<img src="https://cdn.example.com/a.jpg"></p>`, `正文<img src="https://cdn.example.com/a.jpg">`},
// 纯文本原样返回
{"第一段。\n第二段。", "第一段。\n第二段。"},
{"", ""},
}
for _, c := range cases {
if got := normalizeContentBlocks(c.in); got != c.want {
t.Errorf("normalizeContentBlocks(%q) = %q,期望 %q", c.in, got, c.want)
}
}
}
// TestChapterContentEmpty 判断「什么都没取到」的正文。
//
// 聚合类书源把所有线路试完会返回空串(光遇聚合的 request() 就是这么写的),
// 这种空结果不能再当成功下发(前端会渲染成白页并缓存下来)。
func TestChapterContentEmpty(t *testing.T) {
cases := []struct {
name string
in ChapterContent
want bool
}{
{"空正文", ChapterContent{Type: "text"}, true},
{"只有空白字符", ChapterContent{Type: "text", Content: " \n\t "}, true},
{"正常正文", ChapterContent{Type: "text", Content: "第一章 世界大变"}, false},
// 整章正文只有一个本章说气泡时正文文字为空,但不能算抓取失败。
{"只有段评气泡", ChapterContent{Type: "text", Comments: []ContentComment{{Line: 0, Count: 3}}}, false},
{"音频无音轨", ChapterContent{Type: "audio"}, true},
{"音频有音轨", ChapterContent{Type: "audio", Tracks: []string{"https://cdn.example.com/a.m4a"}}, false},
{"漫画无图", ChapterContent{Type: "image"}, true},
{"漫画有图", ChapterContent{Type: "image", Images: []string{"https://cdn.example.com/1.jpg"}}, false},
}
for _, c := range cases {
if got := chapterContentEmpty(&c.in); got != c.want {
t.Errorf("%s: chapterContentEmpty = %v,期望 %v", c.name, got, c.want)
}
}
}
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
func TestSearchCheckKeyWord(t *testing.T) {
+16
View File
@@ -213,6 +213,22 @@ func TestEndToEndSourceChain(t *testing.T) {
}
}
// TestGetContentForBookEmptyContentFails 书源一条内容都没返回时不能当成功下发。
//
// 回归:聚合类书源把七条线路全试完会返回空串(光遇聚合的 request() 就是这么写的),
// 以前这种空正文会当成功下发,前端渲染成一张白页并缓存下来,读者只能干等。
func TestGetContentForBookEmptyContentFails(t *testing.T) {
svc, bookID := setupTextChapterBook(t, "")
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
if err == nil {
t.Fatal("空正文应当报错,实际当成功下发了")
}
if !strings.Contains(err.Error(), "正文为空") {
t.Fatalf("错误信息 = %q,期望提示正文为空", err.Error())
}
}
// ─── 纯函数测试:导入识别 / 搜索合并 ────────────────────────────────────────
func TestParseSourcePayload(t *testing.T) {
+134
View File
@@ -0,0 +1,134 @@
package reader
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件:「更新目录」的结果分档回归测试。
//
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
// refreshTocForTest 跑一次「更新目录」并返回汇总。
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
t.Helper()
res, err := svc.RefreshBooksToc(ctx, "u1")
if err != nil {
t.Fatalf("更新目录失败: %v", err)
}
return res
}
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
(*tocHandler)(w, r)
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
}
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(e2eTocHTML))
})
srv := newRefreshTocServer(&tocHandler)
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "测试源",
BookURL: srv.URL + "/book/1",
TocURL: srv.URL + "/book/1/toc.html",
Name: "斗破苍穹",
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
res := refreshTocForTest(t, svc, ctx)
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
}
chapters, err := svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 2 {
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
}
// 2) 站点多出一章:这才算「有新章节」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(strings.Replace(
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
)))
}
res = refreshTocForTest(t, svc, ctx)
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
}
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
// 这种静默失败必须记失败,不能报成「已是最新」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
}
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
}
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
chapters, err = svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 3 {
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
}
// 4) 站点 404:抓取失败。
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 {
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
}
}
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
for _, b := range []*model.ReaderBook{local, sourceless} {
if err := svc.repo.CreateBook(ctx, b); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
}
res := refreshTocForTest(t, svc, ctx)
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
}
}
+2 -2
View File
@@ -53,7 +53,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
req.Headers[k] = fmt.Sprintf("%v", v)
}
}
body, _, _, err := r.cfg.Fetch(req)
body, _, _, err := r.fetch(req)
if err != nil {
bridgeErr(name, err)
}
@@ -93,7 +93,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
if req.Unsupported != nil {
bridgeErr(name, req.Unsupported)
}
respBody, finalURL, code, err := r.cfg.Fetch(req)
respBody, finalURL, code, err := r.fetch(req)
if err != nil {
bridgeErr(name, err)
}
+4
View File
@@ -98,6 +98,10 @@ func (r *JSRunner) openBrowser(req BrowserTask) error {
return nil
}
if r.cfg.Browser != nil {
// 宿主抓取待展示的页面同样是网络等待,不能吃掉 JS 执行预算
// (与 awaitBrowser 同一套暂停机制,见 pauseTimeout 的说明)。
resume := r.pauseTimeout()
defer resume()
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
}
if r.state != nil {
+16
View File
@@ -177,6 +177,22 @@ func (r *JSRunner) pauseTimeout() func() {
return g.Pause()
}
// fetch 执行一次桥接网络请求,等待期间暂停 JS 超时看门狗。
//
// 书源会把「线路重试」写进规则 JS:光遇聚合的 request() 会串行试 7 条线路,单条
// 线路最长可能等到客户端的 30s 超时。不暂停的话整条规则会被 10s 的 JS 超时打断,
// 而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是「线路
// 还在重试,接口已经 400」。java.startBrowserAwait 等待人工操作时用的是同一套暂停
// 机制。暂停只覆盖网络等待,纯 CPU 死循环仍然受 Timeout 约束。
func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
if r.cfg.Fetch == nil {
return "", "", 0, ErrJsUnsupported
}
resume := r.pauseTimeout()
defer resume()
return r.cfg.Fetch(req)
}
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
func NewJSRunner(cfg JSConfig) *JSRunner {
vm := goja.New()
+34
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
@@ -46,6 +47,39 @@ func TestJSTimeoutInterrupt(t *testing.T) {
}
}
// TestJSTimeoutPausedDuringFetch 网络等待期间不能被 JS 超时打断。
//
// 回归:书源把「线路重试」写在规则 JS 里(光遇聚合的 request() 串行试 7 条线路,
// 单条最长等到 HTTP 客户端超时)。看门狗不暂停的话,整条规则会被 10s 的 JS 超时
// 中断,而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是
// 「线路还在重试,接口已经 400」。
func TestJSTimeoutPausedDuringFetch(t *testing.T) {
r := NewJSRunner(JSConfig{
Timeout: 100 * time.Millisecond,
Fetch: func(req *Request) (string, string, int, error) {
time.Sleep(400 * time.Millisecond) // 远超过 JS 超时:模拟慢线路
return `{"content":"正文"}`, req.URL, 200, nil
},
})
ar := NewAnalyzeRule()
v, err := r.Run(ar, `(function(){
try { return java.ajax('https://slow.example.com/content'); }
catch (e) { return 'caught:' + e; }
})()`, nil, "")
if err != nil {
t.Fatalf("网络等待期间不应触发 JS 超时: %v", err)
}
if got := anyToString(v); !strings.Contains(got, "正文") {
t.Fatalf("ajax 返回值 = %q,期望上游正文", got)
}
// 暂停只覆盖网络等待:回到 JS 里的纯 CPU 死循环仍然要被超时打断。
_, err = r.Run(ar, `java.ajax('https://slow.example.com/content'); while(true){}`, nil, "")
if err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("网络等待之后的 CPU 死循环应当仍然超时,实际: %v", err)
}
}
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
func TestAnalyzeRuleJSEval(t *testing.T) {
+55 -31
View File
@@ -375,11 +375,20 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
}
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
//
// legado 的 cache 有两套存储:put/get/delete 落持久缓存(ACache),
// putMemory/getFromMemory 落进程内内存缓存。书源靠后者记录「这条段评点过几次」
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
var jsCache = struct {
mu sync.Mutex
m map[string]string
}{m: map[string]string{}}
mu sync.Mutex
m map[string]string
mem map[string]string
}{m: map[string]string{}, mem: map[string]string{}}
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
const jsCacheMaxEntries = 4096
func newCacheObject(vm *goja.Runtime) *goja.Object {
o := vm.NewObject()
@@ -388,37 +397,52 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
panic(vm.ToValue(err.Error()))
}
}
set("put", func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
val := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
val = call.Arguments[1].String()
// 两套存储共用同一份读写实现,只有落点不同。
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
val := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
val = call.Arguments[1].String()
}
jsCache.mu.Lock()
if len(*store) >= jsCacheMaxEntries {
*store = map[string]string{}
}
(*store)[key] = val
jsCache.mu.Unlock()
return vm.ToValue(val)
}
jsCache.mu.Lock()
if len(jsCache.m) >= 4096 {
jsCache.m = map[string]string{}
}
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
v, ok := (*store)[key]
jsCache.mu.Unlock()
if !ok {
return goja.Null()
}
return vm.ToValue(v)
}
jsCache.m[key] = val
jsCache.mu.Unlock()
return vm.ToValue(val)
})
set("get", func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
v, ok := jsCache.m[key]
jsCache.mu.Unlock()
if !ok {
}
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
delete(*store, key)
jsCache.mu.Unlock()
return goja.Null()
}
return vm.ToValue(v)
})
set("delete", func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
delete(jsCache.m, key)
jsCache.mu.Unlock()
return goja.Null()
})
}
set("put", putTo(&jsCache.m))
set("get", getFrom(&jsCache.m))
set("delete", deleteFrom(&jsCache.m))
// 内存缓存(legado Cache.getFromMemory / putMemory)
set("putMemory", putTo(&jsCache.mem))
set("getFromMemory", getFrom(&jsCache.mem))
return o
}
@@ -0,0 +1,53 @@
package rule
import "testing"
// TestCacheMemoryRoundTrip 内存缓存(cache.putMemory / cache.getFromMemory)要能读写。
//
// 回归:「cache 对象只有 put/get/delete」曾让光遇聚合的正文规则整条失败——
// paraForAndroid 每一段带段评的文字都会调 cache.putMemory(url, 0),缺了它就抛
// TypeError: Object has no member 'putMemory',正文接口直接 400。
func TestCacheMemoryRoundTrip(t *testing.T) {
r := NewJSRunner(JSConfig{})
ar := NewAnalyzeRule()
if _, err := r.Run(ar, `cache.putMemory('__test_mem', 3)`, nil, ""); err != nil {
t.Fatalf("cache.putMemory 失败: %v", err)
}
v, err := r.Run(ar, `String(cache.getFromMemory('__test_mem'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "3" {
t.Fatalf("getFromMemory = %q,期望 3", got)
}
// 缺省返回 null(书源会写 `cache.getFromMemory(k) || ''` 这类兜底)。
v, err = r.Run(ar, `String(cache.getFromMemory('__test_mem_missing'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("缺失键 = %q,期望 null", got)
}
// 持久缓存与内存缓存是两套存储(legado 的 put/get 走 ACache,putMemory 走内存),
// 互不串门,否则书源会读到本该过期的值。
if _, err := r.Run(ar, `cache.put('__test_persist', 'p')`, nil, ""); err != nil {
t.Fatalf("cache.put 失败: %v", err)
}
v, err = r.Run(ar, `String(cache.getFromMemory('__test_persist'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("getFromMemory 读到了持久缓存的值: %q", got)
}
v, err = r.Run(ar, `String(cache.get('__test_mem'))`, nil, "")
if err != nil {
t.Fatalf("cache.get 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
}
}
+210
View File
@@ -0,0 +1,210 @@
package reader
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// tocHitCounter 统计目录地址被上游请求的次数(目录抓取去重的回归用)。
type tocHitCounter struct {
mu sync.Mutex
hits map[string]int
}
func (c *tocHitCounter) add(path string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hits == nil {
c.hits = map[string]int{}
}
c.hits[path]++
}
func (c *tocHitCounter) count(path string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.hits[path]
}
// TestGetTocConcurrentCallsHitUpstreamOnce 同一本书的并发抓目录只打一次上游。
//
// 回归:换源、加入书架之后,服务端会预热目录(WarmUpBookChaptersAsync),阅读页在
// 章节缓存为空时又会自己抓一次 /api/reader/toc,两次几乎同时到达——实测同一份目录
// 被上游抓了两遍(3.7s 与 11.7s),章节也被写了两遍。
func TestGetTocConcurrentCallsHitUpstreamOnce(t *testing.T) {
counter := &tocHitCounter{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
counter.add(r.URL.Path)
// 抓取期间让后到的调用方一定落在同一次在飞请求上。
time.Sleep(150 * time.Millisecond)
_, _ = w.Write([]byte(e2eTocHTML))
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
tocURL := srv.URL + "/book/1/toc.html"
// 第 0 个调用方按 source_id 找源(阅读页/详情页的形态),其余按 source_url 找
// (服务端预热传的形态):两条入口都要落在同一次在飞请求上。
const callers = 3
var wg sync.WaitGroup
chapters := make([][]TocChapter, callers)
errs := make([]error, callers)
start := make(chan struct{})
for i := 0; i < callers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
id := ""
if i == 0 {
id = sourceID
}
chapters[i], errs[i] = svc.GetToc(context.Background(), "u1", id, srv.URL, book.BookURL, tocURL)
}(i)
}
close(start)
wg.Wait()
for i := 0; i < callers; i++ {
if errs[i] != nil {
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, errs[i])
}
if len(chapters[i]) != 2 {
t.Fatalf("第 %d 个调用方拿到 %d 章,期望 2", i, len(chapters[i]))
}
}
if n := counter.count("/book/1/toc.html"); n != 1 {
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
}
}
// TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget 预热传空 toc_url、阅读页传
// book_url(前端 `toc_url || book_url` 的兜底)指的是同一份目录,必须落在同一次抓取上。
func TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget(t *testing.T) {
counter := &tocHitCounter{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
counter.add(r.URL.Path)
time.Sleep(100 * time.Millisecond)
_, _ = w.Write([]byte(e2eTocHTML))
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := newTocFallbackBook(t, svc, srv.URL) // TocURL 留空,模拟还没写回目录地址
if book.TocURL != "" {
t.Fatalf("前置条件不成立:测试书的 toc_url 应为空,实际 %q", book.TocURL)
}
var wg sync.WaitGroup
errs := make([]error, 2)
start := make(chan struct{})
for i, tocURL := range []string{"", book.BookURL} { // 预热形态 / 阅读页兜底形态
wg.Add(1)
go func(i int, tocURL string) {
defer wg.Done()
<-start
_, errs[i] = svc.GetToc(context.Background(), "u1", "", srv.URL, book.BookURL, tocURL)
}(i, tocURL)
}
close(start)
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, err)
}
}
if n := counter.count("/book/1/toc.html"); n != 1 {
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
}
}
// TestGetTocDedupeKeepsDifferentBooksSeparate 不同的书不能被合并成一次抓取:
// 单飞的 key 里必须带上书本地址。
func TestGetTocDedupeKeepsDifferentBooksSeparate(t *testing.T) {
counter := &tocHitCounter{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasSuffix(r.URL.Path, "/toc.html"):
counter.add(r.URL.Path)
time.Sleep(50 * time.Millisecond)
_, _ = w.Write([]byte(e2eTocHTML))
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book1 := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
book2 := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "测试源",
BookURL: srv.URL + "/book/2",
Name: "第二本",
}
if err := svc.repo.CreateBook(t.Context(), book2); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
targets := []struct{ bookURL, tocURL string }{
{book1.BookURL, srv.URL + "/book/1/toc.html"},
{book2.BookURL, srv.URL + "/book/2/toc.html"},
}
var wg sync.WaitGroup
errs := make([]error, len(targets))
start := make(chan struct{})
for i, tg := range targets {
wg.Add(1)
go func(i int, tg struct{ bookURL, tocURL string }) {
defer wg.Done()
<-start
_, errs[i] = svc.GetToc(context.Background(), "u1", sourceID, srv.URL, tg.bookURL, tg.tocURL)
}(i, tg)
}
close(start)
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("第 %d 本书抓目录失败: %v", i+1, err)
}
}
for _, path := range []string{"/book/1/toc.html", "/book/2/toc.html"} {
if n := counter.count(path); n != 1 {
t.Fatalf("%s 被请求 %d 次,期望 1 次", path, n)
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package service — refresh token 轮换的并发保护与幂等复用。
//
// 背景:refresh token 是一次性凭证,刷新成功后旧 token 立即作废。但真实
// 客户端天然会并发使用同一个令牌:
//
// - 同一个标签页里,WebSocket 重连与 axios 401 拦截器会各发一次刷新;
// - 多个标签页共享同一份 localStorage 里的 refresh token;
// - 容器重启/部署后,多个页面同时重新加载并刷新。
//
// 此前第二个请求必然收到 401(revoked),前端把 401 当成会话彻底失效,
// 清掉本地令牌并跳登录页——外部表现就是「每次部署之后都要重新登录」。
//
// 本文件提供两层保护:
//
// 1. refreshFlights:同一个 token 的并发刷新共享同一次轮换结果;
// 2. rotations:轮换后的宽限期内重复提交同一个旧 token,返回同一次轮换
// 产生的令牌对(幂等),超过宽限期仍按已撤销处理。
package service
import (
"strings"
"time"
)
// refreshTokenReuseGrace 是 refresh token 轮换后的幂等复用宽限期。
//
// 取值只需覆盖「并发请求 + 客户端一次重试」的量级,越短越好:宽限期内
// 持有旧 token 的一方仍能换取同一个会话,超过则按 token 复用检测处理。
const refreshTokenReuseGrace = 60 * time.Second
// rotatedRefreshToken 保存一次轮换产生的令牌对,供宽限期内幂等复用。
//
// 这里必须保留 refresh token 明文——客户端要用它继续会话,而哈希无法反推。
// 条目只存在于内存、随进程结束消失,并在宽限期结束后回收,不落库。
type rotatedRefreshToken struct {
userID string
pair *TokenPair
expiresAt time.Time
}
// refreshFlight 是一次进行中的刷新,供并发请求共享结果。
type refreshFlight struct {
done chan struct{}
pair *TokenPair
err error
}
// currentTime 返回当前时间;测试可注入固定时间源。
func (s *TokenService) currentTime() time.Time {
if s == nil || s.now == nil {
return time.Now()
}
return s.now()
}
// startRefreshFlight 尝试成为某个 refresh token 的首个刷新者。
// 返回 (flight, true) 表示调用方负责执行刷新并调用 finishRefreshFlight;
// 返回 (flight, false) 表示已有刷新在途,需要等待它的结果。
func (s *TokenService) startRefreshFlight(tokenHash string) (*refreshFlight, bool) {
s.rotateMu.Lock()
defer s.rotateMu.Unlock()
if s.refreshFlights == nil {
s.refreshFlights = make(map[string]*refreshFlight)
}
if existing, ok := s.refreshFlights[tokenHash]; ok {
return existing, false
}
flight := &refreshFlight{done: make(chan struct{})}
s.refreshFlights[tokenHash] = flight
return flight, true
}
// finishRefreshFlight 发布刷新结果并唤醒等待者。
func (s *TokenService) finishRefreshFlight(tokenHash string, pair *TokenPair, err error) {
s.rotateMu.Lock()
flight, ok := s.refreshFlights[tokenHash]
if ok {
delete(s.refreshFlights, tokenHash)
flight.pair = pair
flight.err = err
}
s.rotateMu.Unlock()
if ok {
// 关闭前已写入结果,等待方读取到的是完整的 happens-before 结果。
close(flight.done)
}
}
// waitForRefreshFlight 等待并发刷新完成并返回它的结果。
func (s *TokenService) waitForRefreshFlight(flight *refreshFlight) (*TokenPair, error) {
if flight == nil {
return nil, ErrInvalidRefreshToken
}
<-flight.done
if flight.err != nil {
return nil, flight.err
}
if flight.pair == nil {
return nil, ErrInvalidRefreshToken
}
return flight.pair, nil
}
// rememberRotation 记录一次轮换的结果,并回收已过期的条目。
func (s *TokenService) rememberRotation(tokenHash, userID string, pair *TokenPair) {
if s == nil || pair == nil || strings.TrimSpace(tokenHash) == "" {
return
}
now := s.currentTime()
s.rotateMu.Lock()
defer s.rotateMu.Unlock()
if s.rotations == nil {
s.rotations = make(map[string]rotatedRefreshToken)
}
for hash, entry := range s.rotations {
if !now.Before(entry.expiresAt) {
delete(s.rotations, hash)
}
}
s.rotations[tokenHash] = rotatedRefreshToken{
userID: userID,
pair: pair,
expiresAt: now.Add(refreshTokenReuseGrace),
}
}
// reusedRotation 在宽限期内返回同一次轮换的令牌对。
func (s *TokenService) reusedRotation(tokenHash, userID string) (*TokenPair, bool) {
if s == nil {
return nil, false
}
now := s.currentTime()
s.rotateMu.Lock()
defer s.rotateMu.Unlock()
entry, ok := s.rotations[tokenHash]
if !ok {
return nil, false
}
if !now.Before(entry.expiresAt) {
delete(s.rotations, tokenHash)
return nil, false
}
if userID != "" && entry.userID != "" && entry.userID != userID {
return nil, false
}
return entry.pair, true
}
// forgetRotationsForUser 丢弃某个用户的复用条目。
// 显式登出/被踢下线必须立即生效,不能靠宽限期内的旧令牌继续换新。
func (s *TokenService) forgetRotationsForUser(userID string) {
if s == nil || strings.TrimSpace(userID) == "" {
return
}
s.rotateMu.Lock()
defer s.rotateMu.Unlock()
for hash, entry := range s.rotations {
if entry.userID == userID {
delete(s.rotations, hash)
}
}
}
@@ -0,0 +1,184 @@
package service
import (
"errors"
"sync"
"testing"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newRotationTestService(t *testing.T) (*TokenService, *repository.Container, *gorm.DB) {
t.Helper()
db := newServiceTestDB(t, &model.User{}, &model.RefreshToken{}, &model.Setting{})
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
return NewTokenService(cfg, zap.NewNop(), repos), repos, db
}
func seedRotationUser(t *testing.T, repos *repository.Container) *model.User {
t.Helper()
u := &model.User{Username: "race", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
if err := repos.User.Create(t.Context(), u); err != nil {
t.Fatal(err)
}
return u
}
// TestConcurrentRefreshSharesSingleRotation 复现真实客户端的并发刷新:
// WebSocket 重连与 401 拦截器、多个标签页会同时用同一个 refresh token 刷新。
// 之前第二个请求必然拿到 401 revoked,前端据此清空会话(部署后被迫重新登录)。
// 现在并发请求共享同一次轮换,全部成功且拿到同一对令牌。
func TestConcurrentRefreshSharesSingleRotation(t *testing.T) {
svc, repos, db := newRotationTestService(t)
u := seedRotationUser(t, repos)
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
const concurrency = 8
pairs := make([]*TokenPair, concurrency)
errs := make([]error, concurrency)
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-start
pairs[idx], errs[idx] = svc.Refresh(t.Context(), pair.RefreshToken)
}(i)
}
close(start)
wg.Wait()
for i := 0; i < concurrency; i++ {
if errs[i] != nil {
t.Fatalf("concurrent refresh %d failed: %v", i, errs[i])
}
if pairs[i].AccessToken != pairs[0].AccessToken || pairs[i].RefreshToken != pairs[0].RefreshToken {
t.Fatalf("concurrent refresh %d returned a different pair", i)
}
}
// 只应该产生一个新的活跃 refresh token,而不是每个请求各轮换一次。
var active int64
if err := db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ?", u.ID, false).
Count(&active).Error; err != nil {
t.Fatal(err)
}
if active != 1 {
t.Fatalf("active refresh tokens = %d, want 1", active)
}
}
// TestRefreshReuseAfterRotationReturnsSamePair 验证轮换完成后(并发窗口已
// 结束)重复提交同一个旧令牌仍然是幂等的,客户端重试不会掉登录。
func TestRefreshReuseAfterRotationReturnsSamePair(t *testing.T) {
svc, repos, _ := newRotationTestService(t)
u := seedRotationUser(t, repos)
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
first, err := svc.Refresh(t.Context(), pair.RefreshToken)
if err != nil {
t.Fatal(err)
}
reused, err := svc.Refresh(t.Context(), pair.RefreshToken)
if err != nil {
t.Fatalf("reuse within grace window: %v", err)
}
if reused.RefreshToken != first.RefreshToken || reused.AccessToken != first.AccessToken {
t.Fatal("reuse must return the pair issued by the first rotation")
}
// 复用的是新令牌,仍然可以继续轮换。
if _, err := svc.Refresh(t.Context(), reused.RefreshToken); err != nil {
t.Fatalf("refreshed token must stay usable: %v", err)
}
}
// TestRefreshRotationReuseIsPerToken 验证复用表不会跨令牌串号:
// 另一个令牌(例如被设备上限淘汰的那个)不会被误判为可复用。
func TestRefreshRotationReuseIsPerToken(t *testing.T) {
svc, repos, _ := newRotationTestService(t)
u := seedRotationUser(t, repos)
rotated, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
other, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); err != nil {
t.Fatal(err)
}
// 主动撤销另一个令牌(模拟登出/被踢下线),它不应享有复用宽限。
if err := repos.RefreshToken.Revoke(t.Context(), repository.HashToken(other.RefreshToken)); err != nil {
t.Fatal(err)
}
if _, err := svc.Refresh(t.Context(), other.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("revoked token error = %v, want ErrTokenRevoked", err)
}
}
// TestRefreshGraceWindowExpires 验证宽限期是有限的:超过之后旧令牌
// 依旧被拒绝,管理员重置密码/踢下线等撤销语义不受影响。
func TestRefreshGraceWindowExpires(t *testing.T) {
svc, repos, _ := newRotationTestService(t)
u := seedRotationUser(t, repos)
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
t.Fatal(err)
}
base := time.Now()
svc.now = func() time.Time { return base.Add(refreshTokenReuseGrace + time.Second) }
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("error after grace window = %v, want ErrTokenRevoked", err)
}
}
// TestRevokeAllDropsRotationReuse 验证登出/被踢下线立即生效:
// 复用宽限期不能成为旧令牌继续换新的后门。
func TestRevokeAllDropsRotationReuse(t *testing.T) {
svc, repos, _ := newRotationTestService(t)
u := seedRotationUser(t, repos)
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
rotated, err := svc.Refresh(t.Context(), pair.RefreshToken)
if err != nil {
t.Fatal(err)
}
if err := svc.RevokeAll(t.Context(), u.ID); err != nil {
t.Fatal(err)
}
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("old token error = %v, want ErrTokenRevoked", err)
}
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("rotated token error = %v, want ErrTokenRevoked", err)
}
}
+67 -4
View File
@@ -48,11 +48,31 @@ type TokenService struct {
// 因为 refresh token 从未落库而被判定无效,被强制踢回登录页,
// 表现就是「经常登录报错」。
delayedStores map[string]pendingRefreshToken
// now 是可替换的时间源(测试用);为 nil 时退回 time.Now。
now func() time.Time
// rotateMu 保护 refreshFlights / rotations。
rotateMu sync.Mutex
// refreshFlights 记录「正在进行的刷新」:同一个 refresh token 被并发
// 提交时,只有第一个请求去轮换,其余等待并共享同一个结果。
refreshFlights map[string]*refreshFlight
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
// 幂等复用(见 token_refresh_rotation.go)。
rotations map[string]rotatedRefreshToken
}
// NewTokenService 创建令牌服务实例。
func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
return &TokenService{cfg: cfg, log: log, repo: repo, delayedStores: make(map[string]pendingRefreshToken)}
return &TokenService{
cfg: cfg,
log: log,
repo: repo,
delayedStores: make(map[string]pendingRefreshToken),
now: time.Now,
refreshFlights: make(map[string]*refreshFlight),
rotations: make(map[string]rotatedRefreshToken),
}
}
// TokenPair 包含访问令牌和刷新令牌。
@@ -168,9 +188,32 @@ func (s *TokenService) generateRefreshToken() (string, error) {
}
// Refresh 使用 Refresh Token 轮换获取新的令牌对。
//
// refresh token 是一次性凭证,但真实客户端会并发使用同一个令牌(同一标签
// 页的 WebSocket 重连与 401 拦截器、多个标签页、容器重启后同时刷新的多个
// 页面)。因此这里做两件事:
// 1. 同一个 token 的并发刷新共享同一次轮换(single-flight);
// 2. 轮换后在宽限期内重复提交同一个 token,返回同一次轮换的令牌对。
//
// 二者共同保证「重复刷新不会把已经成功的会话打成 401 revoked」。
func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
tokenHash := repository.HashToken(refreshToken)
flight, leader := s.startRefreshFlight(tokenHash)
if !leader {
// 已有一次刷新在途:等它的结果,不再拿同一个一次性凭证轮换第二次。
return s.waitForRefreshFlight(flight)
}
pair, err := s.refreshOnce(ctx, tokenHash)
s.finishRefreshFlight(tokenHash, pair, err)
if err != nil {
return nil, err
}
return pair, nil
}
func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*TokenPair, error) {
// 查找 Refresh Token 记录
rt, err := s.repo.RefreshToken.FindByHash(ctx, tokenHash)
if err != nil {
@@ -180,7 +223,12 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
// 登录高峰/扫描写压力下,refresh token 可能还在后台补写队列里
// 没来得及落库。此时令牌对客户端而言是合法的,不能判无效。
pending, ok := s.pendingDelayedStore(tokenHash)
if !ok || time.Now().After(pending.ExpiresAt) {
if !ok || !s.currentTime().Before(pending.ExpiresAt) {
// 也可能是「刚轮换完但从未落库」的令牌(同上,行本身不存在),
// 宽限期内同样幂等复用。
if pair, reused := s.reusedRotation(tokenHash, ""); reused {
return pair, nil
}
return nil, ErrInvalidRefreshToken
}
rt = &model.RefreshToken{
@@ -192,6 +240,11 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
// 检查是否已撤销
if rt.Revoked {
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
// 客户端拿到 revoked 401 后清空整个会话。
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
return pair, nil
}
return nil, ErrTokenRevoked
}
@@ -211,7 +264,7 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
if !user.IsActive {
return nil, ErrUserInactive
}
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
if user.ExpiredAt != nil && s.currentTime().After(*user.ExpiredAt) {
return nil, ErrUserExpired
}
@@ -222,11 +275,21 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
s.untrackDelayedStore(rt.UserID, tokenHash)
// 签发新的令牌对
return s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
if err != nil {
return nil, err
}
// 必须在结束 flight 之前记住本次轮换:否则等待中的请求会在 flight 与
// 复用表之间的空档里查不到记录,把并发刷新误判为 revoked。
s.rememberRotation(tokenHash, user.ID, pair)
return pair, nil
}
// RevokeAll 撤销用户的所有 Refresh Token(用于登出)。
func (s *TokenService) RevokeAll(ctx context.Context, userID string) error {
// 复用条目必须一起丢弃:否则登出/被踢下线后的宽限期内,
// 旧令牌仍能换回一对有效令牌。
s.forgetRotationsForUser(userID)
return s.repo.RefreshToken.RevokeByUserID(ctx, userID)
}
+38 -3
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"testing"
"time"
@@ -44,12 +45,46 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" {
t.Fatalf("Refresh returned incomplete pair: %+v", pair)
}
// 轮换后旧令牌应从 pending 表移除,不能再次使用。
// 轮换后旧令牌应从 pending 表移除。
if _, still := svc.pendingDelayedStore(hash); still {
t.Fatal("rotated pending token still tracked")
}
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
t.Fatal("rotated pending token should not refresh twice")
// 宽限期内重复提交同一个旧令牌返回同一次轮换的结果(幂等),
// 而不是把并发/重试的客户端打成 401 并清空会话。
reused, err := svc.Refresh(t.Context(), refreshToken)
if err != nil {
t.Fatalf("reuse within grace window should be idempotent: %v", err)
}
if reused.AccessToken != pair.AccessToken || reused.RefreshToken != pair.RefreshToken {
t.Fatal("reuse should return the same rotated pair")
}
}
// TestRefreshRejectsRotatedTokenAfterGraceWindow 验证宽限期结束后,
// 旧令牌仍按已撤销处理(token 复用检测语义不变)。
func TestRefreshRejectsRotatedTokenAfterGraceWindow(t *testing.T) {
repos := newTokenTestRepo(t)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
svc := NewTokenService(cfg, zap.NewNop(), repos)
u := &model.User{Username: "u1", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
if err := repos.User.Create(t.Context(), u); err != nil {
t.Fatal(err)
}
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
if err != nil {
t.Fatal(err)
}
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
t.Fatalf("first refresh: %v", err)
}
base := time.Now()
svc.now = func() time.Time { return base.Add(2 * refreshTokenReuseGrace) }
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("expired reuse error = %v, want ErrTokenRevoked", err)
}
}
+23 -23
View File
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import type { RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and routes 401s back to the login page.
// requests and refreshes it on 401. Only an explicit server-side rejection of
// the refresh token sends the user back to the login page; transient failures
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
@@ -92,33 +95,30 @@ api.interceptors.response.use(
originalRequest._retry = true
isRefreshing = true
try {
const refreshed = await useAuthStore.getState().tokenRefresh()
if (refreshed) {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
isRefreshing = false
return api(originalRequest)
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
// 超时只是暂时不可用,保留本地令牌等下一次重试。
const outcome: RefreshOutcome = await useAuthStore
.getState()
.refreshSession()
.catch((): RefreshOutcome => 'transient')
isRefreshing = false
if (outcome === 'refreshed') {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
} catch (refreshError) {
isRefreshing = false
onTokenRefreshFailed(refreshError)
onTokenRefreshed(newToken || '')
return api(originalRequest)
}
onTokenRefreshFailed(err)
if (outcome === 'invalid') {
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(refreshError)
}
// Refresh failed, logout
isRefreshing = false
onTokenRefreshFailed(err)
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(err)
}
+36 -4
View File
@@ -118,10 +118,26 @@ export interface ReaderChapterContent {
tracks?: string[]
images?: string[]
image_style?: string
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
comments?: ReaderContentComment[]
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
transcoding?: boolean
}
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
export interface ReaderContentComment {
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
line: number
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
count: number
/** 评论地址(书源 showCmt 的第一个参数)。 */
url: string
/** 段评 / 本章说。 */
label?: string
/** 独占一行的整块评论(章末「本章说」)。 */
block?: boolean
}
export interface ReaderReplaceRule {
id: string
name: string
@@ -174,6 +190,8 @@ export interface ReaderBrowserPage {
title: string
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
mode: 'wait' | 'open'
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
seq: number
/** iframe 承载地址(同源,带签名)。 */
page_url: string
refetch: boolean
@@ -223,6 +241,9 @@ export const readerAPI = {
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
api.post('/reader/browser/result', body).then((r) => r.data),
// 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
openContentComment: (body: { book_id: string; url: string; title?: string }) =>
api.post<ReaderBrowserPage>('/reader/comments/open', body).then((r) => r.data),
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
browserXHR: (body: {
id: string
@@ -317,11 +338,16 @@ export const readerAPI = {
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
/**
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
*/
refreshBooksToc: () =>
api
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
'/reader/shelf/refresh-toc',
{},
longOpts,
)
.then((r) => r.data),
/**
* 换源:把书架里的书切到另一个书源。
@@ -340,9 +366,15 @@ export const readerAPI = {
.then((r) => r.data.chapters ?? []),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
bookContent: (id: string, chapter: number) =>
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
api
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
params: { chapter },
timeout: LONG_REQUEST_TIMEOUT,
signal,
})
.then((r) => r.data),
// ── 替换净化规则 ──
+50 -31
View File
@@ -1,37 +1,56 @@
// 令牌刷新 API 模块
import { api } from './client'
// 令牌刷新 API 模块。
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
// 刷新令牌请求/响应
export interface RefreshTokenRequest {
refresh_token: string
}
const REFRESH_ENDPOINT = '/api/auth/refresh'
export interface RefreshTokenResponse {
token: string
refresh_token: string
expires_in: number
token_type: string
}
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
// 不要用默认的长超时把页面卡在等待里。
const REFRESH_TIMEOUT_MS = 15_000
// 刷新访问令牌。
//
// 后端响应封装在 { code, message, data } 里,需解包 .data。
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
const resp = await api.post<{
code: number
message: string
data: RefreshTokenResponse
}>('/auth/refresh', { refresh_token: refreshToken })
const body = resp.data
if (!body || !body.data || !body.data.token) {
throw new Error(body?.message || 'refresh failed')
/**
* 用 refresh token 换取新的令牌对。
*
* 刻意使用 fetch 而不是共享的 axios 实例:
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
*
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
*/
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
let resp: Response
try {
resp = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ refresh_token: refreshToken }),
signal: controller.signal,
})
} catch {
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
throw new RefreshRequestError('refresh request failed')
} finally {
clearTimeout(timer)
}
return body.data
}
// 登出
export async function logout(): Promise<void> {
await api.post('/me/logout')
const body = (await resp.json().catch(() => null)) as
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
| null
if (!resp.ok) {
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
}
const token = body?.data?.token
const nextRefreshToken = body?.data?.refresh_token
if (!token || !nextRefreshToken) {
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
// 不要据此清空用户会话。
throw new RefreshRequestError('malformed refresh response')
}
return { token, refresh_token: nextRefreshToken }
}
+16 -5
View File
@@ -52,13 +52,24 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
let timer: number | undefined
let reconnectAttempts = 0
// 过期的 token 握手必然 401。先走刷新流程:成功会更新 token 并让本
// effect 重建连接;「暂时不可用」(部署/重启窗口的网络故障)时保留
// 会话继续退避重试;只有服务端明确判定凭证失效才会停止重连。
const refreshExpiredToken = () => {
void useAuthStore
.getState()
.tokenRefresh()
.then((refreshed) => {
if (closed || refreshed) return
if (!useAuthStore.getState().refreshToken) return
timer = window.setTimeout(open, SLOW_RECONNECT_INTERVAL)
})
}
const open = () => {
if (closed) return
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
// 让本 effect 重建连接,失败则清空会话停止重连。
if (isTokenExpired(token)) {
void useAuthStore.getState().tokenRefresh()
refreshExpiredToken()
return
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -82,7 +93,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
if (closed) return
// token 过期时不要继续退避重试,交给刷新流程处理。
if (isTokenExpired(token)) {
void useAuthStore.getState().tokenRefresh()
refreshExpiredToken()
return
}
reconnectAttempts += 1
+5 -1
View File
@@ -250,7 +250,11 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
if (res.total === 0) {
toast.success('没有需要更新的网络书籍', { id: toastId })
} else if (res.failed > 0) {
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
// 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档
const parts = [`${res.updated} 本有新章节`]
if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`)
parts.push(`${res.failed} 本失败,可在日志中查看书源报错`)
toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' })
} else {
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
}
+152 -22
View File
@@ -9,6 +9,7 @@ import {
LayoutList,
ListEnd,
Loader2,
MessageSquare,
Minus,
Moon,
Plus,
@@ -17,12 +18,21 @@ import {
} from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import {
readerAPI,
type ReaderBook,
type ReaderBrowserPage,
type ReaderChapter,
type ReaderChapterContent,
type ReaderContentComment,
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
@@ -63,6 +73,12 @@ const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
interface ReaderParagraphBlock {
text: string
comments: ReaderContentComment[]
}
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -93,6 +109,10 @@ export default function ReaderViewPage() {
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
const [reloadKey, setReloadKey] = useState(0)
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
const [contentReloadKey, setContentReloadKey] = useState(0)
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
const topBarRef = useRef<HTMLDivElement>(null)
const [menuInset, setMenuInset] = useState(0)
@@ -230,20 +250,33 @@ export default function ReaderViewPage() {
}
}, [bookId, reloadKey])
// 正文缓存的键带上书源标识(origin + book_url):换源后在途的旧源响应即使晚到,
// 也只会落在旧键上,不会被新源读到。聚合源的不同子源共用同一个 origin(子源写在
// book_url 里),所以不能只用 origin。
const contentCacheKey = useCallback(
(index: number) => `${book?.origin ?? ''}\u0000${book?.book_url ?? ''}\u0000${index}`,
[book?.origin, book?.book_url],
)
// ── 加载章节正文(带缓存与下一章预取) ──
useEffect(() => {
if (chapterIndex === null || !book || chapters.length === 0) return
const ch = chapters[chapterIndex]
if (!ch) return
let cancelled = false
// 换章/换源/离开页面时取消在途请求:慢源一章要等十几秒,不取消就会白等旧源的
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
const ac = new AbortController()
;(async () => {
setContent(null)
setLoadingStage('content')
try {
const cacheKey = String(chapterIndex)
const cacheKey = contentCacheKey(chapterIndex)
let ct = contentCache.current.get(cacheKey)
if (!ct) {
ct = await readerAPI.bookContent(book.id, chapterIndex)
ct = await readerAPI.bookContent(book.id, chapterIndex, ac.signal)
// 请求期间切了章/换了源:结果已作废,既不用也不必入缓存。
if (cancelled) return
contentCache.current.set(cacheKey, ct)
}
if (cancelled) return
@@ -282,10 +315,14 @@ export default function ReaderViewPage() {
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
if (!contentCache.current.has(String(chapterIndex + 1))) {
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
readerAPI
.bookContent(book.id, chapterIndex + 1)
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
.bookContent(book.id, chapterIndex + 1, ac.signal)
.then((c) => {
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
if (cancelled) return
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
})
.catch(() => undefined)
}
} catch (e) {
@@ -299,8 +336,9 @@ export default function ReaderViewPage() {
})()
return () => {
cancelled = true
ac.abort()
}
}, [chapterIndex, book, chapters])
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
const relayout = useCallback(() => {
@@ -715,15 +753,59 @@ export default function ReaderViewPage() {
},
}
})()
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
// 正文按行组织成「段落块」:保留原始行号(段评锚点按行号挂靠),
// 只丢掉既无文字又无段评的空行。
const blocks = useMemo<ReaderParagraphBlock[]>(() => {
const lines = (content ?? '').split('\n')
const byLine = new Map<number, ReaderContentComment[]>()
for (const c of media?.type === 'text' ? (media.comments ?? []) : []) {
const list = byLine.get(c.line)
if (list) list.push(c)
else byLine.set(c.line, [c])
}
const out: ReaderParagraphBlock[] = []
for (let i = 0; i < lines.length; i++) {
const text = lines[i].trim()
const comments = byLine.get(i) ?? []
if (text === '' && comments.length === 0) continue
out.push({ text, comments })
}
return out
}, [content, media])
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
const renderParagraph = (line: string, key: number) => {
if (line.startsWith(IMG_MARK)) {
// 打开段评:服务端带书源 Cookie 抓取评论页,这里用 iframe 承载。
// 前端直接 window.open 会以未登录身份访问,评论接口拿不到数据。
const openComment = useCallback(
async (comment: ReaderContentComment) => {
if (!book) return
if (!comment.url) {
toast('这条评论没有可用地址')
return
}
try {
const page = await readerAPI.openContentComment({
book_id: book.id,
url: comment.url,
title: comment.label || '段评',
})
setBrowserPage(page)
} catch (e) {
toast.error(
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '打开段评失败',
)
}
},
[book],
)
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
const { text, comments } = block
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
<img
src={line.slice(IMG_MARK.length)}
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
@@ -731,9 +813,44 @@ export default function ReaderViewPage() {
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
{line}
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: settings.paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
// 点气泡会顺带翻页或呼出菜单。
e.stopPropagation()
void openComment(c)
}}
title={c.label || '段评'}
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: theme.accent,
color: theme.accent,
fontSize: Math.max(10, settings.fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
}
@@ -785,8 +902,16 @@ export default function ReaderViewPage() {
) : settings.pageMode === 'page' ? (
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
不改高度也就不触发重新分页) */
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
不改高度也就不触发重新分页)。
relative + z-index + pointer-events-none:正文容器带 transform,
本身就形成一个层叠上下文,段评气泡的 z-index 只在容器内部生效,
会被后面的点击覆盖层压住。把整层抬到覆盖层之上并关闭指针事件,
只让气泡自己 pointer-events:auto,气泡可点、其余位置照样透给
覆盖层翻页/呼出菜单。 */
<div
className={`pointer-events-none relative z-[5] h-full px-4 py-2 ${MENU_SHIFT}`}
style={menuShiftStyle}
>
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
@@ -801,7 +926,7 @@ export default function ReaderViewPage() {
lineHeight: settings.lineHeight,
}}
>
{paragraphs.map((p, i) => renderParagraph(p, i))}
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div>
</div>
@@ -825,13 +950,13 @@ export default function ReaderViewPage() {
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
{paragraphs.map((p, i) => renderParagraph(p, i))}
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div>
)}
{/* 加载 / 错误 / 空内容态 */}
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
{(loadingStage !== null || (content !== null && blocks.length === 0)) && contentType === 'text' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
{loadingStage !== null ? (
<Loader2 className="animate-spin opacity-60" size={24} />
@@ -848,8 +973,10 @@ export default function ReaderViewPage() {
onClick={() => {
setError('')
if (chapterIndex !== null) {
contentCache.current.delete(String(chapterIndex))
setChapterIndex(chapterIndex)
contentCache.current.delete(contentCacheKey(chapterIndex))
// 用 reload 计数触发重取:把 chapterIndex 设成同一个值不会让
// effect 重跑,以前点「重试」只是把错误提示清掉了。
setContentReloadKey((v) => v + 1)
}
}}
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
@@ -865,7 +992,7 @@ export default function ReaderViewPage() {
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
type="button"
@@ -1172,6 +1299,9 @@ export default function ReaderViewPage() {
onClose={() => setSwitchOpen(false)}
/>
)}
{/* 段评评论页:复用书源页面的承载面板(带书源 Cookie 由服务端抓取) */}
{browserPage && <BrowserPanel page={browserPage} onClose={() => setBrowserPage(null)} />}
</div>
)
}
+10 -2
View File
@@ -70,8 +70,16 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
const pollBrowserPages = useCallback(async () => {
try {
const pages = await readerAPI.browserPending(sourceId)
const next = pages.find((p) => !handledPages.current.has(p.id))
if (next) setBrowserPage(next)
// 只取本次还没处理过的页面,并按 seq 取最新登记的那个。
// 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序
// 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上
// (光遇聚合「更新书源」残留后串页就是这么来的)。
const next = pages
.filter((p) => !handledPages.current.has(p.id))
.reduce<ReaderBrowserPage | null>((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null)
if (!next) return
// 已经在展示的页面不要被更旧的页面替换掉
setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next))
} catch {
// 轮询失败不打断正在执行的动作
}
+70 -40
View File
@@ -2,7 +2,12 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
import { refreshToken } from '../api/refresh'
import { requestRefreshTokens } from '../api/refresh'
import {
createBrowserExclusiveRunner,
createRefreshCoordinator,
type RefreshOutcome,
} from '../utils/authRefresh'
// Single source of truth for the authenticated user + JWT.
// Persisted to localStorage so a page reload does not drop the session.
@@ -16,50 +21,65 @@ interface AuthState {
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
logout: () => void
/**
* 刷新会话的唯一入口(同标签页 + 跨标签页收敛成一次请求)。
*
* 返回的 outcome 决定调用方怎么处理:
* - 'refreshed' 已拿到新令牌,可重试原请求;
* - 'invalid' 服务端明确判定凭证失效,本地会话已清空,需重新登录;
* - 'transient' 网络/服务暂时不可用,本地会话保留,稍后重试即可。
*/
refreshSession: () => Promise<RefreshOutcome>
/** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
tokenRefresh: () => Promise<boolean>
}
export const AUTH_STORAGE_KEY = 'mebox-auth'
// 跨标签页互斥:多个标签页共享同一份 refresh token,
// 只允许其中一个真正发起刷新。
const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
token: null,
refreshToken: null,
user: null,
tier: 'free',
setSession: (token, refreshToken, user) => set({
token,
refreshToken,
user,
tier: user.tier || 'free'
}),
setUser: (user) => set({ user, tier: user.tier || 'free' }),
setToken: (token) => set({ token }),
setRefreshToken: (refreshToken) => set({ refreshToken }),
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
tokenRefresh: async () => {
const rt = get().refreshToken
if (!rt) {
return false
}
try {
const resp = await refreshToken(rt)
set({
token: resp.token,
refreshToken: resp.refresh_token
})
return true
} catch {
// Refresh failed, need to logout
set({ token: null, refreshToken: null, user: null, tier: 'free' })
return false
}
},
}),
{
name: 'mebox-auth',
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
(set, get) => {
const refreshSession = createRefreshCoordinator({
readAccessToken: () => get().token,
readRefreshToken: () => get().refreshToken,
applyTokens: (tokens) =>
set({ token: tokens.token, refreshToken: tokens.refresh_token }),
clearSession: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
request: requestRefreshTokens,
runExclusive: runRefreshExclusive,
// 其他标签页刷新成功后会写回 localStorage;重新读进来,避免本标签页
// 继续持有已被服务端轮换作废的旧 refresh token。
syncFromStorage: () => useAuthStore.persist.rehydrate(),
})
return {
token: null,
refreshToken: null,
user: null,
tier: 'free',
setSession: (token, refreshToken, user) => set({
token,
refreshToken,
user,
tier: user.tier || 'free'
}),
setUser: (user) => set({ user, tier: user.tier || 'free' }),
setToken: (token) => set({ token }),
setRefreshToken: (refreshToken) => set({ refreshToken }),
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
refreshSession,
tokenRefresh: async () => (await refreshSession()) === 'refreshed',
}
},
{
name: AUTH_STORAGE_KEY,
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
user: state.user,
tier: state.tier
}),
@@ -67,6 +87,16 @@ export const useAuthStore = create<AuthState>()(
),
)
// storage 事件只在其他标签页写入时触发:登出/轮换后同步本地会话,
// 否则本标签页会继续用已失效的令牌发请求。
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key === AUTH_STORAGE_KEY) {
void useAuthStore.persist.rehydrate()
}
})
}
// Helper function to check if user is authenticated
export function isAuthenticated(): boolean {
return useAuthStore.getState().token !== null
+188
View File
@@ -0,0 +1,188 @@
// 刷新令牌并发保护与失败分类的回归测试。
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
// (对齐 utils/mediaVersion.test.ts 的写法)。
import {
RefreshRequestError,
createRefreshCoordinator,
createSingleFlightRunner,
refreshFailureOutcome,
type RefreshOutcome,
type RefreshTokens,
} from './authRefresh.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`authRefresh: ${name}`)
}
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
const got = await promise
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
}
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
check('429 is transient', refreshFailureOutcome(429) === 'transient')
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
{
let calls = 0
let release: (() => void) | null = null
const run = createSingleFlightRunner(async () => {
calls += 1
const current = calls
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
if (current === 1) {
await new Promise<void>((resolve) => {
release = resolve
})
}
return current
})
const first = run()
const second = run()
check('single flight shares one promise', first === second)
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
queueMicrotask(() => release?.())
check('single flight result', (await first) === 1)
check('single flight stayed single', calls === 1)
check('single flight re-runs after settle', (await run()) === 2)
}
// ── 协调器 ────────────────────────────────────────────────────────────────
function coordinatorHarness(overrides: {
refreshToken?: string | null
accessToken?: string | null
request?: (token: string) => Promise<RefreshTokens>
onApply?: (tokens: RefreshTokens) => void
onClear?: () => void
syncFromStorage?: () => void
}) {
const state = {
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
applied: [] as RefreshTokens[],
cleared: 0,
requests: [] as string[],
}
const refreshSession = createRefreshCoordinator({
readAccessToken: () => state.accessToken,
readRefreshToken: () => state.refreshToken,
applyTokens: (tokens) => {
state.applied.push(tokens)
state.accessToken = tokens.token
state.refreshToken = tokens.refresh_token
overrides.onApply?.(tokens)
},
clearSession: () => {
state.cleared += 1
state.accessToken = null
state.refreshToken = null
overrides.onClear?.()
},
request: async (token) => {
state.requests.push(token)
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
},
runExclusive: (task) => task(),
syncFromStorage: () => overrides.syncFromStorage?.(),
})
return { state, refreshSession }
}
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
{
const { state, refreshSession } = coordinatorHarness({})
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
check('concurrent refresh sends one request', state.requests.length === 1)
check('concurrent refresh applies once', state.applied.length === 1)
}
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('bad gateway', 502)
},
})
await expectOutcome('gateway error', refreshSession(), 'transient')
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
}
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('network down')
},
})
await expectOutcome('network error', refreshSession(), 'transient')
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
}
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
{
const { state, refreshSession } = coordinatorHarness({
request: async () => {
throw new RefreshRequestError('refresh token revoked', 401)
},
})
await expectOutcome('revoked token', refreshSession(), 'invalid')
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
}
// 没有 refresh token 时直接判定失效,不发请求。
{
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
check('missing refresh token sends no request', state.requests.length === 0)
}
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
{
const { state, refreshSession } = coordinatorHarness({
syncFromStorage: () => {
state.refreshToken = 'rt-other-tab'
state.accessToken = 'at-other-tab'
},
})
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
check('other tab rotation sends no request', state.requests.length === 0)
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
}
// 另一个标签页登出了:本标签页也应判定失效。
{
const { state, refreshSession } = coordinatorHarness({
syncFromStorage: () => {
state.refreshToken = null
state.accessToken = null
},
})
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
check('other tab logout sends no request', state.requests.length === 0)
}
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
{
let attempt = 0
const { state, refreshSession } = coordinatorHarness({
request: async () => {
attempt += 1
if (attempt === 1) throw new RefreshRequestError('network down')
return { token: 'at-retry', refresh_token: 'rt-retry' }
},
})
await expectOutcome('first transient attempt', refreshSession(), 'transient')
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
check('retry used the same token', state.requests[1] === 'rt-1')
check('retry applied new tokens', state.refreshToken === 'rt-retry')
}
console.log('authRefresh.test.ts ok')
+148
View File
@@ -0,0 +1,148 @@
// 刷新令牌的并发保护与失败分类。
//
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
// (对齐 utils/mediaVersion.test.ts 的写法)。
//
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
//
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
/** 一次刷新尝试的结果。 */
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
/** 刷新接口返回的令牌对。 */
export interface RefreshTokens {
token: string
refresh_token: string
}
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
export class RefreshRequestError extends Error {
readonly status?: number
constructor(message: string, status?: number) {
super(message)
this.name = 'RefreshRequestError'
this.status = status
}
}
/**
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
*
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
* 否则一次瞬时故障就会把用户永久踢到登录页。
*/
export function refreshFailureOutcome(status?: number): RefreshOutcome {
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
return 'transient'
}
if (status === 408 || status === 429 || status >= 500) {
return 'transient'
}
if (status === 400 || status === 401 || status === 403) {
return 'invalid'
}
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
return 'transient'
}
/** 从抛出的错误里取出 HTTP 状态码。 */
export function httpStatusOf(error: unknown): number | undefined {
if (error instanceof RefreshRequestError) return error.status
const status = (error as { status?: unknown } | null)?.status
return typeof status === 'number' ? status : undefined
}
/**
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
*
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
*/
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
let inflight: Promise<T> | null = null
return () => {
if (inflight) return inflight
inflight = Promise.resolve()
.then(task)
.finally(() => {
inflight = null
})
return inflight
}
}
/**
* 跨标签页互斥执行(Web Locks API)。
*
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
* 重新读取 localStorage,直接复用刚轮换出来的会话。
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
*/
export function createBrowserExclusiveRunner(
lockName: string,
): <T>(task: () => Promise<T>) => Promise<T> {
return <T>(task: () => Promise<T>): Promise<T> => {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
if (!locks || typeof locks.request !== 'function') return task()
try {
return locks.request(lockName, () => task()) as Promise<T>
} catch {
return task()
}
}
}
export interface RefreshCoordinatorDeps {
readAccessToken: () => string | null
readRefreshToken: () => string | null
applyTokens: (tokens: RefreshTokens) => void
clearSession: () => void
request: (refreshToken: string) => Promise<RefreshTokens>
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
syncFromStorage: () => void | Promise<void>
}
/**
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
*/
export function createRefreshCoordinator(
deps: RefreshCoordinatorDeps,
): () => Promise<RefreshOutcome> {
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
const tokenAtEntry = deps.readRefreshToken()
// 没有 refresh token 就无从恢复会话。
if (!tokenAtEntry) return 'invalid'
return deps.runExclusive(async () => {
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
await deps.syncFromStorage()
const current = deps.readRefreshToken()
if (!current) return 'invalid'
if (current !== tokenAtEntry) {
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
return deps.readAccessToken() ? 'refreshed' : 'invalid'
}
try {
deps.applyTokens(await deps.request(current))
return 'refreshed'
} catch (error) {
const outcome = refreshFailureOutcome(httpStatusOf(error))
if (outcome === 'invalid') deps.clearSession()
return outcome
}
})
})
}