From b0f1ab39b9ff58227c57bbc71fe920e6cf0c183c Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Sat, 3 Oct 2026 21:00:16 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95=EF=BC=8C?=
=?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=85=E8=AF=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/service/reader/reader.go | 118 +++++++++-
.../service/reader/reader_helpers_test.go | 27 +++
internal/service/reader/reader_test.go | 16 ++
internal/service/reader/rule/bridge.go | 4 +-
internal/service/reader/rule/browser.go | 4 +
internal/service/reader/rule/goja.go | 16 ++
internal/service/reader/rule/js_test.go | 34 +++
internal/service/reader/rule/jsobjects.go | 86 ++++---
.../service/reader/rule/jsobjects_test.go | 53 +++++
internal/service/reader/toc_dedupe_test.go | 210 ++++++++++++++++++
internal/service/token_refresh_rotation.go | 162 ++++++++++++++
.../service/token_refresh_rotation_test.go | 184 +++++++++++++++
internal/service/token_svc.go | 71 +++++-
internal/service/token_svc_pending_test.go | 41 +++-
web/src/api/client.ts | 46 ++--
web/src/api/reader.ts | 10 +-
web/src/api/refresh.ts | 81 ++++---
web/src/hooks/useWebSocket.ts | 21 +-
web/src/pages/reader/ReaderViewPage.tsx | 38 +++-
web/src/stores/auth.ts | 110 +++++----
web/src/utils/authRefresh.test.ts | 188 ++++++++++++++++
web/src/utils/authRefresh.ts | 148 ++++++++++++
22 files changed, 1513 insertions(+), 155 deletions(-)
create mode 100644 internal/service/reader/rule/jsobjects_test.go
create mode 100644 internal/service/reader/toc_dedupe_test.go
create mode 100644 internal/service/token_refresh_rotation.go
create mode 100644 internal/service/token_refresh_rotation_test.go
create mode 100644 web/src/utils/authRefresh.test.ts
create mode 100644 web/src/utils/authRefresh.ts
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index f7cc477..fb9903e 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -53,6 +53,12 @@ type ReaderService struct {
// limiter 单源限速(书源 concurrentRate)。
limiter *sourceRateLimiter
+
+ // tocFlightsMu / tocFlights 保护「同一本书正在抓目录」的单飞登记表:
+ // 换源、加入书架之后,服务端预热与阅读页会几乎同时来抓同一份目录
+ // (见 fetchTocDeduped)。
+ tocFlightsMu sync.Mutex
+ tocFlights map[string]*tocFlight
}
// NewReaderService 创建服务。
@@ -1164,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 信封):
@@ -1177,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 的「最新章节」)。
@@ -1715,9 +1798,32 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
// 归一成结构化锚点,正文文字原样保留(见 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
+ }
+}
+
// 正文里的块级标签边界:
、
、
这类只表达段落、没有文字的标签。
// 书源(如光遇聚合的 paraForAndroid)在段评开启时把正文拼成 正文
,
// 前端是纯文本渲染({text}),不折行的话读者看到的就是字面的 、
。
diff --git a/internal/service/reader/reader_helpers_test.go b/internal/service/reader/reader_helpers_test.go
index 65d66a6..a22fa6d 100644
--- a/internal/service/reader/reader_helpers_test.go
+++ b/internal/service/reader/reader_helpers_test.go
@@ -174,6 +174,33 @@ func TestNormalizeContentBlocks(t *testing.T) {
}
}
+// 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) {
diff --git a/internal/service/reader/reader_test.go b/internal/service/reader/reader_test.go
index 591b839..539f7fe 100644
--- a/internal/service/reader/reader_test.go
+++ b/internal/service/reader/reader_test.go
@@ -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) {
diff --git a/internal/service/reader/rule/bridge.go b/internal/service/reader/rule/bridge.go
index d3316eb..75a4955 100644
--- a/internal/service/reader/rule/bridge.go
+++ b/internal/service/reader/rule/bridge.go
@@ -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)
}
diff --git a/internal/service/reader/rule/browser.go b/internal/service/reader/rule/browser.go
index 48af819..f4f0967 100644
--- a/internal/service/reader/rule/browser.go
+++ b/internal/service/reader/rule/browser.go
@@ -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 {
diff --git a/internal/service/reader/rule/goja.go b/internal/service/reader/rule/goja.go
index 53345b1..4395d18 100644
--- a/internal/service/reader/rule/goja.go
+++ b/internal/service/reader/rule/goja.go
@@ -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()
diff --git a/internal/service/reader/rule/js_test.go b/internal/service/reader/rule/js_test.go
index 86a5dcc..241db85 100644
--- a/internal/service/reader/rule/js_test.go
+++ b/internal/service/reader/rule/js_test.go
@@ -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: / ) ──────────────────────────────────
func TestAnalyzeRuleJSEval(t *testing.T) {
diff --git a/internal/service/reader/rule/jsobjects.go b/internal/service/reader/rule/jsobjects.go
index edf2b6e..4082d8b 100644
--- a/internal/service/reader/rule/jsobjects.go
+++ b/internal/service/reader/rule/jsobjects.go
@@ -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
}
diff --git a/internal/service/reader/rule/jsobjects_test.go b/internal/service/reader/rule/jsobjects_test.go
new file mode 100644
index 0000000..6e0d03a
--- /dev/null
+++ b/internal/service/reader/rule/jsobjects_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/reader/toc_dedupe_test.go b/internal/service/reader/toc_dedupe_test.go
new file mode 100644
index 0000000..1928363
--- /dev/null
+++ b/internal/service/reader/toc_dedupe_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/service/token_refresh_rotation.go b/internal/service/token_refresh_rotation.go
new file mode 100644
index 0000000..0297cc7
--- /dev/null
+++ b/internal/service/token_refresh_rotation.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/service/token_refresh_rotation_test.go b/internal/service/token_refresh_rotation_test.go
new file mode 100644
index 0000000..ba7d992
--- /dev/null
+++ b/internal/service/token_refresh_rotation_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go
index 72c8969..faec6af 100644
--- a/internal/service/token_svc.go
+++ b/internal/service/token_svc.go
@@ -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)
}
diff --git a/internal/service/token_svc_pending_test.go b/internal/service/token_svc_pending_test.go
index 6060c4a..7e1719a 100644
--- a/internal/service/token_svc_pending_test.go
+++ b/internal/service/token_svc_pending_test.go
@@ -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)
}
}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 66f1a7b..4249d81 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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)
}
diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts
index 25de5a6..3031422 100644
--- a/web/src/api/reader.ts
+++ b/web/src/api/reader.ts
@@ -366,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(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
+ .get(`/reader/books/${id}/content`, {
+ params: { chapter },
+ timeout: LONG_REQUEST_TIMEOUT,
+ signal,
+ })
.then((r) => r.data),
// ── 替换净化规则 ──
diff --git a/web/src/api/refresh.ts b/web/src/api/refresh.ts
index 25eb4c8..624d94c 100644
--- a/web/src/api/refresh.ts
+++ b/web/src/api/refresh.ts
@@ -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 {
- 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 {
+ 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 {
- await api.post('/me/logout')
+ const body = (await resp.json().catch(() => null)) as
+ | { code?: number; message?: string; data?: Partial }
+ | 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 }
}
diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts
index 86eb023..27855ac 100644
--- a/web/src/hooks/useWebSocket.ts
+++ b/web/src/hooks/useWebSocket.ts
@@ -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
diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx
index d9206e4..f30d9a7 100644
--- a/web/src/pages/reader/ReaderViewPage.tsx
+++ b/web/src/pages/reader/ReaderViewPage.tsx
@@ -109,6 +109,8 @@ export default function ReaderViewPage() {
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState([])
const [reloadKey, setReloadKey] = useState(0)
+ // 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
+ const [contentReloadKey, setContentReloadKey] = useState(0)
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
const [browserPage, setBrowserPage] = useState(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
@@ -248,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
@@ -300,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) {
@@ -317,8 +336,9 @@ export default function ReaderViewPage() {
})()
return () => {
cancelled = true
+ ac.abort()
}
- }, [chapterIndex, book, chapters])
+ }, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
const relayout = useCallback(() => {
@@ -953,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"
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts
index 1ff8ea7..98dc1f5 100644
--- a/web/src/stores/auth.ts
+++ b/web/src/stores/auth.ts
@@ -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
+ /** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
tokenRefresh: () => Promise
}
+export const AUTH_STORAGE_KEY = 'mebox-auth'
+
+// 跨标签页互斥:多个标签页共享同一份 refresh token,
+// 只允许其中一个真正发起刷新。
+const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
+
export const useAuthStore = create()(
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()(
),
)
+// 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
diff --git a/web/src/utils/authRefresh.test.ts b/web/src/utils/authRefresh.test.ts
new file mode 100644
index 0000000..2b1e473
--- /dev/null
+++ b/web/src/utils/authRefresh.test.ts
@@ -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, 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((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
+ 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')
diff --git a/web/src/utils/authRefresh.ts b/web/src/utils/authRefresh.ts
new file mode 100644
index 0000000..732e0e6
--- /dev/null
+++ b/web/src/utils/authRefresh.ts
@@ -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(task: () => Promise): () => Promise {
+ let inflight: Promise | 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,
+): (task: () => Promise) => Promise {
+ return (task: () => Promise): Promise => {
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
+ if (!locks || typeof locks.request !== 'function') return task()
+ try {
+ return locks.request(lockName, () => task()) as Promise
+ } catch {
+ return task()
+ }
+ }
+}
+
+export interface RefreshCoordinatorDeps {
+ readAccessToken: () => string | null
+ readRefreshToken: () => string | null
+ applyTokens: (tokens: RefreshTokens) => void
+ clearSession: () => void
+ request: (refreshToken: string) => Promise
+ runExclusive: (task: () => Promise) => Promise
+ /** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
+ syncFromStorage: () => void | Promise
+}
+
+/**
+ * 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
+ * 结果只会是 'refreshed' | 'invalid' | 'transient'。
+ */
+export function createRefreshCoordinator(
+ deps: RefreshCoordinatorDeps,
+): () => Promise {
+ return createSingleFlightRunner(async (): Promise => {
+ 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
+ }
+ })
+ })
+}