优化登录,优化阅读

This commit is contained in:
truewhile
2026-10-03 21:00:16 +08:00
parent 5242d54a06
commit b0f1ab39b9
22 changed files with 1513 additions and 155 deletions
+112 -6
View File
@@ -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
}
}
// 正文里的块级标签边界:<p>、</p>、<br> 这类只表达段落、没有文字的标签。
// 书源(如光遇聚合的 paraForAndroid)在段评开启时把正文拼成 <p>正文<comment/></p>,
// 前端是纯文本渲染({text}),不折行的话读者看到的就是字面的 <p>、</p>。
@@ -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) {
+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) {
+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)
}
}