mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bfdd75d47 | |||
| 1a3248f4bb | |||
| 0be7f8b97e | |||
| 6984158f24 | |||
| d545ff1a7b | |||
| 227e0a9197 | |||
| e85df5fb6b | |||
| 6656f9c398 | |||
| dacfec7492 | |||
| b0f1ab39b9 |
@@ -249,4 +249,5 @@
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
|
||||
@@ -295,12 +295,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
@@ -362,13 +362,15 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
|
||||
Page int `json:"page"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -56,7 +56,12 @@ func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
src := &model.ReaderBookSource{}
|
||||
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
|
||||
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
|
||||
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
|
||||
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此再也存不进去。这里必须硬删。
|
||||
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
|
||||
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
|
||||
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
@@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
if existing.DeletedAt.Valid {
|
||||
// 历史行被软删过:连 deleted_at 一起写回,把它复活
|
||||
st.DeletedAt = gorm.DeletedAt{}
|
||||
return r.db.WithContext(ctx).Unscoped().Save(st).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
|
||||
//
|
||||
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
|
||||
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
|
||||
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://d.example.com"
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 重新导入同 URL 的书源,再存一次状态
|
||||
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
|
||||
t.Fatalf("状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
|
||||
//
|
||||
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
|
||||
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
|
||||
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://e.example.com"
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
|
||||
if err := repo.db.Exec(
|
||||
"update reader_source_states set deleted_at = ? where source_url = ?",
|
||||
time.Now(), url).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
|
||||
t.Fatalf("复活后状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
|
||||
return hosts
|
||||
}
|
||||
|
||||
// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。
|
||||
func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount {
|
||||
host = normalizeHostKey(host)
|
||||
if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range accounts {
|
||||
lines, _, err := r.LinesOf(&accounts[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if normalizeHostKey(u.Hostname()) == host {
|
||||
return &accounts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。
|
||||
func normalizeHostKey(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前
|
||||
// api_key。host 不属于任何已配置账号时返回 ok=false。
|
||||
func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", false
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil || strings.TrimSpace(cfg.Token) == "" {
|
||||
return "", false
|
||||
}
|
||||
return cfg.Token, true
|
||||
}
|
||||
|
||||
// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号
|
||||
// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录
|
||||
// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。
|
||||
func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", errors.New("远程 Emby 服务不可用")
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host)
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key")
|
||||
}
|
||||
cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.Token, nil
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && attempt == 0 {
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
|
||||
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
|
||||
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
|
||||
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
|
||||
// 新 token 覆盖 api_key。
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
|
||||
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
|
||||
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
|
||||
// 凭据,也避免每个后续请求都重新登录一次。
|
||||
previous := cfg.Token
|
||||
cfg.Token = ""
|
||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||
return fmt.Errorf("认证重试失败: %w", err)
|
||||
}
|
||||
if q != nil {
|
||||
q.Set("api_key", cfg.Token)
|
||||
}
|
||||
master.Token = cfg.Token
|
||||
master.RemoteUserID = cfg.RemoteUserID
|
||||
if cfg.Token != "" && cfg.Token != previous {
|
||||
_ = r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status >= 300 {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" {
|
||||
t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test")
|
||||
if !ok || token != "fake-token" {
|
||||
t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
// The account carries only an api_key — there is nothing to re-authenticate with.
|
||||
if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil {
|
||||
t.Fatal("expected an error when the account has no username/password to re-authenticate with")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」:
|
||||
// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) {
|
||||
const fresh = "fresh-token"
|
||||
logins := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/emby/Users/AuthenticateByName" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logins++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-refresh"},
|
||||
Name: "refresh-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" {
|
||||
t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok)
|
||||
}
|
||||
|
||||
token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if token != fresh {
|
||||
t.Fatalf("refreshed token = %q, want %q", token, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1", logins)
|
||||
}
|
||||
|
||||
// 新 token 必须落库,否则每个请求都要重新登录一次。
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh {
|
||||
t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -65,6 +66,16 @@ type ImageProxy struct {
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
|
||||
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
|
||||
// credential for configured remote-Emby mounts. Image URLs are minted from
|
||||
// whatever token the account had when the payload was built, so a URL can
|
||||
// outlive its api_key; these hooks re-supply the current one and force a
|
||||
// re-login when the mount rejects it.
|
||||
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
|
||||
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
|
||||
remoteEmbyTokenMu sync.Mutex
|
||||
remoteEmbyTokenCache map[string]remoteEmbyImageToken
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
||||
|
||||
// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败
|
||||
// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。
|
||||
var errImageProxyUnauthorized = errors.New("upstream rejected image credentials")
|
||||
|
||||
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
|
||||
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
||||
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
|
||||
@@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
}
|
||||
|
||||
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if lastErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(lastErr, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
// 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着
|
||||
// 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发
|
||||
// 占位图、等人工去改账号配置。
|
||||
if errors.Is(lastErr, errImageProxyUnauthorized) {
|
||||
token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host)
|
||||
if refreshErr != nil {
|
||||
logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr)
|
||||
} else if token != "" {
|
||||
retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if retryErr == nil {
|
||||
return retried, nil
|
||||
}
|
||||
lastErr = retryErr
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
|
||||
@@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
|
||||
}
|
||||
|
||||
// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。
|
||||
// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把
|
||||
// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。
|
||||
func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
|
||||
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
|
||||
// upstream image. A poster can appear in the hero, a shelf and the detail page
|
||||
// at the same time; without this guard every resize variant may fetch the same
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求
|
||||
// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。
|
||||
const remoteEmbyImageTokenTTL = 60 * time.Second
|
||||
|
||||
type remoteEmbyImageToken struct {
|
||||
token string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。
|
||||
// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以
|
||||
// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.refreshRemoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。
|
||||
// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会
|
||||
// 白白查一次账号表。
|
||||
func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return ""
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL {
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
return cached.token
|
||||
}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
|
||||
token, ok := p.remoteEmbyTokenFn(ctx, host)
|
||||
if !ok {
|
||||
token = ""
|
||||
}
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
return token
|
||||
}
|
||||
|
||||
func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) {
|
||||
if p == nil || strings.TrimSpace(host) == "" {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if p.remoteEmbyTokenCache == nil {
|
||||
p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8)
|
||||
}
|
||||
p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或
|
||||
// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。
|
||||
func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := p.refreshRemoteEmbyTokenFn(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby
|
||||
// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再
|
||||
// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。
|
||||
func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil {
|
||||
return
|
||||
}
|
||||
token := p.remoteEmbyTokenForHost(ctx, host)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
q := req.URL.Query()
|
||||
q.Set("api_key", token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的
|
||||
// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值,
|
||||
// 否则挂载 Emby 的封面会一直 401、下发占位图。
|
||||
func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) {
|
||||
const current = "current-token"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != current {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return current, true
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景:
|
||||
// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。
|
||||
func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) {
|
||||
const (
|
||||
stale = "stale-token"
|
||||
fresh = "fresh-token"
|
||||
)
|
||||
var mu sync.Mutex
|
||||
refreshes := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != fresh {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return stale, true
|
||||
})
|
||||
proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) {
|
||||
mu.Lock()
|
||||
refreshes++
|
||||
mu.Unlock()
|
||||
return fresh, nil
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes())
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := refreshes
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("refreshes = %d, want exactly 1 re-auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的
|
||||
// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。
|
||||
func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) {
|
||||
var gotHeader string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("X-Emby-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/img/cover.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotHeader != "" {
|
||||
t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
|
||||
p.applyRemoteEmbyAuth(ctx, req, host)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return remoteImageFetchResult{}, errImageProxyUnauthorized
|
||||
}
|
||||
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:段落评论接口(/para_review)的分页适配。
|
||||
//
|
||||
// 上游聚合站自带的评论页按 page=N 翻页(滑到底请求 page=2、page=3…),但它的列表
|
||||
// 接口早就改成了游标分页:响应里给 next_cursor,请求时要带 cursor=。接口不认识
|
||||
// page,于是每次「加载更多」都原样返回第一页——一条新评论都加不进去,has_more 又
|
||||
// 始终是 true,评论页就一遍遍触发加载更多、反复闪骨架屏。用户看到的就是「段评往
|
||||
// 下滑一闪一闪」,其实评论一条没多。
|
||||
//
|
||||
// 评论页是上游 HTML,改不了,于是在代理层做最小适配:
|
||||
// - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
|
||||
// - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
|
||||
// 免得页面在「加载更多 → 没有新内容」之间空转。
|
||||
//
|
||||
// 只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求;对不上就原样透传,
|
||||
// 不影响别的书源。
|
||||
|
||||
// commentPageState 一个评论列表(同一段落 + 同一排序)的翻页状态。
|
||||
type commentPageState struct {
|
||||
// cursor 上一页响应给的游标:下一页请求用它代替 page。
|
||||
cursor string
|
||||
// seen 已经下发给页面的 comment_id:整页重复时据此收尾。
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// commentPager 承载页面里评论接口的翻页适配器(每个承载页面一份)。
|
||||
type commentPager struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*commentPageState
|
||||
}
|
||||
|
||||
func newCommentPager() *commentPager {
|
||||
return &commentPager{states: map[string]*commentPageState{}}
|
||||
}
|
||||
|
||||
// rewriteRequest 按需把 page=N 改写成 cursor=<上一页游标>。
|
||||
//
|
||||
// 返回改写后的地址与状态键(空串表示这个请求不需要适配,调用方也不必观察响应)。
|
||||
func (p *commentPager) rewriteRequest(target string) (string, string) {
|
||||
if p == nil {
|
||||
return target, ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || !isCommentListPath(parsed.Path) {
|
||||
return target, ""
|
||||
}
|
||||
query := parsed.Query()
|
||||
pageRaw := strings.TrimSpace(query.Get("page"))
|
||||
if pageRaw == "" || strings.TrimSpace(query.Get("cursor")) != "" {
|
||||
return target, ""
|
||||
}
|
||||
page, err := strconv.Atoi(pageRaw)
|
||||
if err != nil || page < 1 {
|
||||
return target, ""
|
||||
}
|
||||
key := commentPageKey(parsed.Path, query)
|
||||
if page == 1 {
|
||||
// 重新加载 / 换了排序:游标与去重表都从头来。
|
||||
p.mu.Lock()
|
||||
delete(p.states, key)
|
||||
p.mu.Unlock()
|
||||
return target, key
|
||||
}
|
||||
p.mu.Lock()
|
||||
cursor := ""
|
||||
if st := p.states[key]; st != nil {
|
||||
cursor = st.cursor
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if cursor == "" {
|
||||
// 不是从第一页翻过来的(没有游标可用):原样透传,仍然观察响应,
|
||||
// 靠下面的「整页重复」判断让页面停下来。
|
||||
return target, key
|
||||
}
|
||||
query.Set("cursor", cursor)
|
||||
query.Del("page")
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), key
|
||||
}
|
||||
|
||||
// observeResponse 观察评论列表响应:记下游标,并在整页重复时把 has_more 改成 false。
|
||||
func (p *commentPager) observeResponse(key, body string) string {
|
||||
if p == nil || key == "" {
|
||||
return body
|
||||
}
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return body
|
||||
}
|
||||
var env commentListEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return body
|
||||
}
|
||||
if env.Data.Comments == nil && env.Data.HasMore == nil {
|
||||
return body
|
||||
}
|
||||
ids := make([]string, 0, len(env.Data.Comments))
|
||||
for _, c := range env.Data.Comments {
|
||||
if id := strings.TrimSpace(c.CommentID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.states == nil {
|
||||
p.states = map[string]*commentPageState{}
|
||||
}
|
||||
st := p.states[key]
|
||||
if st == nil {
|
||||
st = &commentPageState{seen: map[string]struct{}{}}
|
||||
p.states[key] = st
|
||||
}
|
||||
repeated := len(ids) > 0
|
||||
for _, id := range ids {
|
||||
if _, ok := st.seen[id]; !ok {
|
||||
repeated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if repeated && env.Data.HasMore != nil && *env.Data.HasMore {
|
||||
// 整页都是老评论:接口不认 page,再翻下去也只会拿到同一页。
|
||||
// 改掉 has_more,让页面显示「没有更多了」,别再反复拉取闪骨架屏。
|
||||
if replaced, ok := setFirstJSONBoolFalse(trimmed, "has_more"); ok {
|
||||
return replaced
|
||||
}
|
||||
return body
|
||||
}
|
||||
for _, id := range ids {
|
||||
st.seen[id] = struct{}{}
|
||||
}
|
||||
if env.Data.HasMore != nil && !*env.Data.HasMore {
|
||||
// 已经是最后一页:游标链到头,清掉它,页面万一再要一页也别拿旧游标
|
||||
// 去请求(那种请求会落在下面的「整页重复」判断上收尾)。
|
||||
st.cursor = ""
|
||||
return body
|
||||
}
|
||||
// 游标只在拿到新内容时前进:请求失败/重复时页面会重试同一页,
|
||||
// 游标也跟着重试同一个。
|
||||
if cursor := rawJSONScalar(env.Data.Cursor); cursor != "" {
|
||||
st.cursor = cursor
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// commentListEnvelope 评论列表响应里我们关心的字段。
|
||||
type commentListEnvelope struct {
|
||||
Data struct {
|
||||
Comments []struct {
|
||||
CommentID string `json:"comment_id"`
|
||||
} `json:"comments"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Cursor json.RawMessage `json:"next_cursor"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// isCommentListPath 判断是否是段落评论列表接口(/para_review)。
|
||||
func isCommentListPath(path string) bool {
|
||||
p := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(path)), "/")
|
||||
return strings.HasSuffix(p, "/para_review")
|
||||
}
|
||||
|
||||
// commentPageKey 生成翻页状态键:接口路径 + 除 page/cursor 之外的查询参数。
|
||||
// 同一段落的不同排序(sort_by)各自一套状态,互不干扰。
|
||||
func commentPageKey(path string, query url.Values) string {
|
||||
rest := make(url.Values, len(query))
|
||||
for k, v := range query {
|
||||
if k == "page" || k == "cursor" {
|
||||
continue
|
||||
}
|
||||
rest[k] = v
|
||||
}
|
||||
keys := make([]string, 0, len(rest))
|
||||
for k := range rest {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteString(path)
|
||||
for _, k := range keys {
|
||||
for _, v := range rest[k] {
|
||||
b.WriteString("|" + k + "=" + v)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rawJSONScalar 取一个 JSON 标量的字面量(游标是毫秒时间戳,可能被上游写成数字)。
|
||||
func rawJSONScalar(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
var out string
|
||||
if json.Unmarshal(raw, &out) == nil {
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setFirstJSONBoolFalse 把 JSON 里第一个 `"key":true` 改成 `"key":false`。
|
||||
//
|
||||
// 只做这一处等长替换,不重新序列化整份响应:评论里的 user_id、时间戳动辄十几位,
|
||||
// 走一遍 float64 会变成科学计数法,页面再解析就废了。
|
||||
func setFirstJSONBoolFalse(body, key string) (string, bool) {
|
||||
token := `"` + key + `"`
|
||||
idx := strings.Index(body, token)
|
||||
if idx < 0 {
|
||||
return body, false
|
||||
}
|
||||
i := idx + len(token)
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i >= len(body) || body[i] != ':' {
|
||||
return body, false
|
||||
}
|
||||
i++
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if !strings.HasPrefix(body[i:], "true") {
|
||||
return body, false
|
||||
}
|
||||
return body[:i] + "false" + body[i+len("true"):], true
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:段落评论翻页适配(browser_comment_page.go)的测试。
|
||||
//
|
||||
// 覆盖两件事:
|
||||
// 1. page=N(N>1)被换成上一页响应给的 cursor;
|
||||
// 2. 整页重复(接口不认 page 时的表现)时把 has_more 改成 false,页面才会收尾。
|
||||
|
||||
const commentParaBase = "https://cmt.example.com/para_review?item_id=X¶=1&source=QQ"
|
||||
|
||||
func TestCommentPagerRewritesPageToCursor(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
|
||||
// 第一页:原样请求,响应里的游标被记下
|
||||
target, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
if key == "" {
|
||||
t.Fatal("段落评论接口应被识别")
|
||||
}
|
||||
if target != commentParaBase+"&page=1" {
|
||||
t.Fatalf("第一页不该改写: %q", target)
|
||||
}
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
|
||||
// 第二页:改成带 cursor 的请求,page 不再出现
|
||||
target, key2 := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if key2 != key {
|
||||
t.Fatalf("同一段落的状态键应一致: %q vs %q", key2, key)
|
||||
}
|
||||
if !strings.Contains(target, "cursor=1500000000000") || strings.Contains(target, "page=") {
|
||||
t.Fatalf("第二页应换成 cursor 请求: %q", target)
|
||||
}
|
||||
|
||||
// 拿到新评论:游标前进到下一页
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=3")
|
||||
if !strings.Contains(target, "cursor=1400000000000") {
|
||||
t.Fatalf("第三页应使用新的游标: %q", target)
|
||||
}
|
||||
|
||||
// 重新加载(page=1)会把整轮状态清掉:没有游标就原样透传
|
||||
p.rewriteRequest(commentParaBase + "&page=1")
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if strings.Contains(target, "cursor=") {
|
||||
t.Fatalf("重置后不该还带着旧游标: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerStopsOnRepeatedPage(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
_, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
first := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
if got := p.observeResponse(key, first); got != first {
|
||||
t.Fatalf("第一页不该被改写: %q", got)
|
||||
}
|
||||
|
||||
// 同一页又被返回一次(接口忽略 page):改 has_more,让页面显示「没有更多了」
|
||||
repeat := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
got := p.observeResponse(key, repeat)
|
||||
if !strings.Contains(got, `"has_more":false`) {
|
||||
t.Fatalf("重复页应把 has_more 改成 false: %q", got)
|
||||
}
|
||||
// 只动 has_more 一处,其它字段(尤其是大整数)保持原样
|
||||
if strings.Replace(got, `"has_more":false`, `"has_more":true`, 1) != first {
|
||||
t.Fatalf("除 has_more 外不应改动响应: %q", got)
|
||||
}
|
||||
// 重复页不再推进游标
|
||||
target, _ := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if !strings.Contains(target, "cursor=1500000000000") {
|
||||
t.Fatalf("游标不该被重复页顶掉: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerIgnoresOtherRequests(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
cases := []string{
|
||||
"https://cmt.example.com/book/detail?page=2", // 不是评论接口
|
||||
"https://cmt.example.com/para_review?item_id=X", // 没有 page
|
||||
"https://cmt.example.com/para_review?item_id=X&cursor=1", // 已经在用游标
|
||||
"https://cmt.example.com/para_review?item_id=X&page=0", // page 非法
|
||||
"https://cmt.example.com/para_review?item_id=X&sort_by=1&", // 没有 page
|
||||
}
|
||||
for _, target := range cases {
|
||||
got, key := p.rewriteRequest(target)
|
||||
if got != target || key != "" {
|
||||
t.Fatalf("不该改写的请求被处理了: %q -> %q (key=%q)", target, got, key)
|
||||
}
|
||||
}
|
||||
// 非 JSON 响应原样返回
|
||||
if got := p.observeResponse("k", "<html></html>"); got != "<html></html>" {
|
||||
t.Fatalf("非 JSON 响应被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserXHRCommentPagerFollowsCursor 端到端:页面按 page 翻页,
|
||||
// 代理层换成 cursor,接口给出新评论,最后一页重复时收尾。
|
||||
func TestBrowserXHRCommentPagerFollowsCursor(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
seenCursors := []string{}
|
||||
seenPages := []string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/page":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, `<html><body>comment page</body></html>`)
|
||||
case "/para_review":
|
||||
q := r.URL.Query()
|
||||
mu.Lock()
|
||||
seenCursors = append(seenCursors, q.Get("cursor"))
|
||||
seenPages = append(seenPages, q.Get("page"))
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// 上游只认 cursor:不认识 page,靠 next_cursor 一页页往前。
|
||||
switch q.Get("cursor") {
|
||||
case "":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
case "1500000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
case "1400000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":false,"next_cursor":""}}`)
|
||||
default:
|
||||
// 兜底分支:上游把同一页又给了一遍(页面的 page 循环就会走到这里)
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":true,"next_cursor":"1300000000000"}}`)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: srv.URL + "/page", Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
call := func(page string) string {
|
||||
t.Helper()
|
||||
res, err := svc.ProxyBrowserXHR(t.Context(), entry.id, http.MethodGet,
|
||||
srv.URL+"/para_review?item_id=X¶=1&source=QQ&page="+page, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
return res.Body
|
||||
}
|
||||
|
||||
if body := call("1"); !strings.Contains(body, `"has_more":true`) || !strings.Contains(body, "c1") {
|
||||
t.Fatalf("第一页响应异常: %q", body)
|
||||
}
|
||||
if body := call("2"); !strings.Contains(body, "c2") {
|
||||
t.Fatalf("第二页应拿到下一页评论: %q", body)
|
||||
}
|
||||
if body := call("3"); !strings.Contains(body, "c3") || !strings.Contains(body, `"has_more":false`) {
|
||||
t.Fatalf("第三页应是真正的最后一页: %q", body)
|
||||
}
|
||||
// 页面不知道已经到底,还可能再要一页:上游又把最后一页给了一遍,
|
||||
// 适配层要把 has_more 改成 false,页面才会显示「没有更多了」并停下。
|
||||
last := call("4")
|
||||
if !strings.Contains(last, `"has_more":false`) {
|
||||
t.Fatalf("整页重复时应收尾(has_more=false): %q", last)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if strings.Join(seenPages, ",") != "1,,,4" {
|
||||
t.Fatalf("page 参数只应出现在没有游标可用的请求上: %v", seenPages)
|
||||
}
|
||||
if strings.Join(seenCursors, ",") != ",1500000000000,1400000000000," {
|
||||
t.Fatalf("游标传递不符预期: %v", seenCursors)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal([]byte(last), &env); err != nil {
|
||||
t.Fatalf("收尾后的响应不是合法 JSON: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,18 @@ type pendingBrowser struct {
|
||||
sourceURL string
|
||||
userID string
|
||||
request rule.BrowserTask
|
||||
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
|
||||
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
|
||||
// 书源会话就能读到登录凭证。
|
||||
cookies browserCookieTarget
|
||||
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||
html string
|
||||
finalURL string
|
||||
mode string
|
||||
expires time.Time
|
||||
// comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor,
|
||||
// 见 browser_comment_page.go)。
|
||||
comments *commentPager
|
||||
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
@@ -113,21 +120,56 @@ type browserHost struct {
|
||||
sourceURL string
|
||||
sourceID string
|
||||
userID string
|
||||
// state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。
|
||||
// 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把
|
||||
// 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。
|
||||
state *sourceState
|
||||
capture bool
|
||||
}
|
||||
|
||||
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。
|
||||
func (h *browserHost) cookieTarget() browserCookieTarget {
|
||||
if !h.capture {
|
||||
return browserCookieTarget{}
|
||||
}
|
||||
return browserCookieTarget{state: h.state}
|
||||
}
|
||||
|
||||
// browserCookieTarget 面板链路要回写 Cookie 的目标会话。
|
||||
type browserCookieTarget struct {
|
||||
state *sourceState
|
||||
}
|
||||
|
||||
// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。
|
||||
// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。
|
||||
// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。
|
||||
func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) {
|
||||
if t.state == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
sink := cookieSinkFrom(ctx)
|
||||
if sink == nil {
|
||||
sink = &cookieSink{}
|
||||
}
|
||||
if resp.Request != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
}
|
||||
sink.apply(t.state)
|
||||
}
|
||||
|
||||
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait)
|
||||
if err != nil {
|
||||
return rule.BrowserResult{}, err
|
||||
}
|
||||
@@ -151,16 +193,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u
|
||||
}
|
||||
|
||||
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerBrowser 准备页面内容并登记待办。
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||
id := newBrowserID()
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,11 +215,13 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
cookies: cookies,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
expires: time.Now().Add(browserPageTTL),
|
||||
done: make(chan struct{}),
|
||||
comments: newCommentPager(),
|
||||
}
|
||||
|
||||
s.browserMu.Lock()
|
||||
@@ -319,7 +363,7 @@ func jsStringEscape(s string) string {
|
||||
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) {
|
||||
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
@@ -331,10 +375,16 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||
}
|
||||
// 空响应没有可展示的内容:以前照样包一层空 <pre> 交给 iframe,面板就是一片白,
|
||||
// 用户看不出是地址有问题还是自己没登录。这里直接报错(如段评地址参数被改写、
|
||||
// 上游按缺参数返回空 body 的情形)。
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", "", fmt.Errorf("目标地址没有返回任何内容: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
|
||||
if !strings.Contains(strings.ToLower(contentType), "html") {
|
||||
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
|
||||
@@ -355,8 +405,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ
|
||||
}
|
||||
|
||||
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -364,16 +414,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target
|
||||
}
|
||||
|
||||
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
|
||||
// 返回 (contentType, status, body, error)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应
|
||||
// Set-Cookie 写回(页面可能靠资源响应续期会话)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return "", 0, nil, errors.New("页面已过期,请重新打开")
|
||||
}
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target)
|
||||
return contentType, status, data, err
|
||||
}
|
||||
|
||||
// requestBrowserResource 带书源凭据请求一个外部地址。
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。
|
||||
if cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
state := s.newSourceState(reqCtx, sourceURL)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
@@ -419,6 +478,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
// 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。
|
||||
cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
@@ -454,8 +515,20 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
// 段落评论接口:评论页按 page=N 翻页,而接口只认 cursor(见
|
||||
// browser_comment_page.go)。改写发生在补书源凭据之前,Cookie/Referer
|
||||
// 仍然按目标站点选取。
|
||||
pagerKey := ""
|
||||
if entry.comments != nil {
|
||||
target, pagerKey = entry.comments.rewriteRequest(target)
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的
|
||||
// Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。
|
||||
if entry.cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -506,6 +579,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
return nil, err
|
||||
}
|
||||
data = helper.DecompressBody(resp, data)
|
||||
// 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。
|
||||
entry.cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
@@ -513,6 +588,9 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
|
||||
if isTextualContent(contentType) {
|
||||
out.Body = string(data)
|
||||
if entry.comments != nil && pagerKey != "" {
|
||||
out.Body = entry.comments.observeResponse(pagerKey, out.Body)
|
||||
}
|
||||
} else {
|
||||
out.Body = base64.StdEncoding.EncodeToString(data)
|
||||
out.Base64 = true
|
||||
@@ -665,8 +743,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string {
|
||||
return s.browserSign("asset|" + id + "|" + target)
|
||||
}
|
||||
|
||||
func (s *ReaderService) browserPageURL(id string) string {
|
||||
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
// browserPageURL 生成 iframe 承载地址。
|
||||
//
|
||||
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id¶&source
|
||||
// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的
|
||||
// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去
|
||||
// 请求评论接口,气泡数照样显示,点开却一条段评都没有。
|
||||
//
|
||||
// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。
|
||||
// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。
|
||||
func (s *ReaderService) browserPageURL(id, target string) string {
|
||||
u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
if q := browserTargetQuery(target); q != "" {
|
||||
u += "&" + q
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。
|
||||
func browserTargetQuery(target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.RawQuery
|
||||
}
|
||||
|
||||
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
|
||||
@@ -824,7 +928,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
Title: e.request.Title,
|
||||
Mode: e.mode,
|
||||
Seq: e.seq,
|
||||
PageURL: s.browserPageURL(e.id),
|
||||
PageURL: s.browserPageURL(e.id, e.request.URL),
|
||||
Refetch: e.request.Refetch,
|
||||
SourceID: e.sourceID,
|
||||
TargetURL: target,
|
||||
|
||||
@@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。
|
||||
//
|
||||
// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。
|
||||
// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有,
|
||||
// 点开却一条评论都没有。
|
||||
func TestBrowserPageURLKeepsTargetQuery(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body>comment page</body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。
|
||||
target := srv.URL + "/get_para_review?item_id=abc¶=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy"
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
|
||||
page := svc.browserPageOf(entry)
|
||||
u, err := url.Parse(page.PageURL)
|
||||
if err != nil {
|
||||
t.Fatalf("承载地址不合法: %q", page.PageURL)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" {
|
||||
t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL)
|
||||
}
|
||||
// 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办
|
||||
if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) {
|
||||
t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL)
|
||||
}
|
||||
if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil {
|
||||
t.Fatalf("带业务参数后签名校验失败: %v", err)
|
||||
}
|
||||
// 没有 query 的地址(含 data: 页面)不该多出参数
|
||||
for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} {
|
||||
if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 {
|
||||
t.Fatalf("无 query 的地址被拼进了多余参数: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
|
||||
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
|
||||
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
@@ -201,7 +246,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
st.flush()
|
||||
|
||||
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -266,7 +311,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
if gotSourceURL != srv.URL {
|
||||
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
|
||||
}
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
|
||||
if err != nil {
|
||||
t.Fatalf("拉取资源失败: %v", err)
|
||||
}
|
||||
@@ -313,7 +358,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) {
|
||||
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||
st.flush()
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -403,7 +448,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||
|
||||
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
|
||||
t.Fatalf("登记 open 页面失败: %v", err)
|
||||
}
|
||||
@@ -421,7 +466,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// 再点别的按钮:待办表里只应剩新页面,且序号更大
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
|
||||
t.Fatalf("登记新页面失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -451,6 +451,8 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
|
||||
title = "段评"
|
||||
}
|
||||
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
|
||||
// 段评是「只展示」的页面,没有登录会话可回写 Cookie。
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。
|
||||
//
|
||||
// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里
|
||||
// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的
|
||||
// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档,
|
||||
// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。
|
||||
|
||||
// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302,
|
||||
// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」
|
||||
// 能被分开断言。
|
||||
func cookieCaptureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, "/done", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>面板</body></html>`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie
|
||||
// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。
|
||||
func TestExecuteCapturesRedirectHopCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got)
|
||||
}
|
||||
// 最终响应的 Cookie 按该跳地址归档。
|
||||
if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") {
|
||||
t.Fatalf("最终响应的 Cookie 没有被收下: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时
|
||||
// 主链路不应自动累积 Cookie。
|
||||
func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
if got := state.GetCookie(srv.URL); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经
|
||||
// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。
|
||||
// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。
|
||||
func TestProxyBrowserXHRCapturesCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceURL = "https://api.example.com"
|
||||
const sourceID = "panel-source-1"
|
||||
state := svc.newSourceState(ctx, sourceURL)
|
||||
|
||||
// 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。
|
||||
if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{state: state},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
|
||||
// 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。
|
||||
if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板请求的凭证没有写回会话: %q", got)
|
||||
}
|
||||
|
||||
// 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。
|
||||
state.flush()
|
||||
reloaded := svc.newSourceState(ctx, sourceURL)
|
||||
if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板拿到的凭证没有落库: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板)
|
||||
// 代理请求照常工作,只是不做 Cookie 累积。
|
||||
func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceID = "panel-source-2"
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求应照常成功: %v", err)
|
||||
}
|
||||
if res.Status != http.StatusOK {
|
||||
t.Fatalf("代理状态 = %d", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。
|
||||
func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil {
|
||||
t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie")
|
||||
}
|
||||
if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st {
|
||||
t.Fatal("开启时应把会话交给面板回写 Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的,
|
||||
// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。
|
||||
func TestSourceStateCookieConcurrency(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
url := "https://example.com/"
|
||||
st.SetCookie(url, "k=1")
|
||||
_ = st.GetCookie(url)
|
||||
_ = st.snapshotCookies()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") {
|
||||
t.Fatalf("并发写入后应能读到 Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:把响应下发的 Set-Cookie 收进书源会话。
|
||||
//
|
||||
// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。
|
||||
// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带
|
||||
// Set-Cookie,再跳到业务域名)这三个做法都会漏:
|
||||
//
|
||||
// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到;
|
||||
// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域
|
||||
// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶,
|
||||
// 之后 api.bilibili.com 的请求取不到这份 Cookie;
|
||||
// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库,
|
||||
// 页面在面板里「登录成功」但书源会话始终是空的。
|
||||
//
|
||||
// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie,
|
||||
// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。
|
||||
|
||||
// capturedCookie 一条待写入会话的 Cookie 及其归属地址。
|
||||
type capturedCookie struct {
|
||||
// url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点),
|
||||
// 否则用下发它的那一跳的请求地址。
|
||||
url string
|
||||
cookie string
|
||||
}
|
||||
|
||||
// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。
|
||||
// 并发安全:面板里的多个资源/接口请求会同时走这里。
|
||||
type cookieSink struct {
|
||||
mu sync.Mutex
|
||||
items []capturedCookie
|
||||
}
|
||||
|
||||
// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。
|
||||
func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) {
|
||||
if s == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
items := make([]capturedCookie, 0, len(resp.Cookies()))
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck == nil || ck.Name == "" {
|
||||
continue
|
||||
}
|
||||
target := ""
|
||||
if d := strings.TrimSpace(ck.Domain); d != "" {
|
||||
target = strings.TrimPrefix(d, ".")
|
||||
} else if reqURL != nil {
|
||||
target = reqURL.String()
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items = append(s.items, items...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain 取出并清空已收集的 Cookie。
|
||||
func (s *cookieSink) drain() []capturedCookie {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.items
|
||||
s.items = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。
|
||||
func (s *cookieSink) apply(state *sourceState) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
items := s.drain()
|
||||
if state == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
state.SetCookie(it.url, it.cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ──────────────
|
||||
|
||||
type cookieSinkKey struct{}
|
||||
|
||||
// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。
|
||||
func withCookieSink(ctx context.Context, sink *cookieSink) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, cookieSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。
|
||||
func cookieSinkFrom(ctx context.Context) *cookieSink {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink)
|
||||
return sink
|
||||
}
|
||||
|
||||
// captureRedirectCookies 作为 http.Client.CheckRedirect:
|
||||
// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。
|
||||
//
|
||||
// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致
|
||||
// (10 次),所以装上它不改变既有链路的行为。
|
||||
func captureRedirectCookies(req *http.Request, via []*http.Request) error {
|
||||
if req != nil {
|
||||
if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil {
|
||||
// via 的最后一跳才是产生 req.Response 的那个请求。
|
||||
prev := req
|
||||
if n := len(via); n > 0 {
|
||||
prev = via[n-1]
|
||||
}
|
||||
sink.add(prev.URL, req.Response)
|
||||
}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。
|
||||
// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。
|
||||
func enableCookieCapture(c *http.Client) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.CheckRedirect = captureRedirectCookies
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil)
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -53,15 +53,24 @@ type ReaderService struct {
|
||||
|
||||
// limiter 单源限速(书源 concurrentRate)。
|
||||
limiter *sourceRateLimiter
|
||||
|
||||
// tocFlightsMu / tocFlights 保护「同一本书正在抓目录」的单飞登记表:
|
||||
// 换源、加入书架之后,服务端预热与阅读页会几乎同时来抓同一份目录
|
||||
// (见 fetchTocDeduped)。
|
||||
tocFlightsMu sync.Mutex
|
||||
tocFlights map[string]*tocFlight
|
||||
}
|
||||
|
||||
// NewReaderService 创建服务。
|
||||
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
||||
client := helper.NewSiteHTTPClient(30, true)
|
||||
// 逐跳收集 Set-Cookie:登录源常把凭证放在跳转链中间那一跳(见 cookies.go)。
|
||||
enableCookieCapture(client)
|
||||
return &ReaderService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
http: client,
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
limiter: newSourceRateLimiter(),
|
||||
}
|
||||
@@ -313,12 +322,13 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
}
|
||||
}
|
||||
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
|
||||
buildRequest := func() (*http.Request, error) {
|
||||
// reqCtx 每次尝试单独传入,好让 Set-Cookie 只从「最终那次尝试」收集。
|
||||
buildRequest := func(reqCtx context.Context) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = strings.NewReader(req.Body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, req.Method, target, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -355,9 +365,18 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
var (
|
||||
resp *http.Response
|
||||
data []byte
|
||||
// sink 收集这次请求(含重定向各跳)下发的 Set-Cookie,
|
||||
// 只在需要回写会话且书源允许自动累积时才挂。
|
||||
sink *cookieSink
|
||||
)
|
||||
for attempt := 1; ; attempt++ {
|
||||
httpReq, err := buildRequest()
|
||||
sink = nil
|
||||
reqCtx := ctx
|
||||
if state != nil && captureCookies {
|
||||
sink = &cookieSink{}
|
||||
reqCtx = withCookieSink(ctx, sink)
|
||||
}
|
||||
httpReq, err := buildRequest(reqCtx)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
@@ -399,15 +418,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
body = string(data)
|
||||
}
|
||||
finalURL := resp.Request.URL.String()
|
||||
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)
|
||||
var cookieStrs []string
|
||||
for _, ck := range resp.Cookies() {
|
||||
cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value)
|
||||
}
|
||||
if len(cookieStrs) > 0 && state != nil && captureCookies {
|
||||
for _, ck := range cookieStrs {
|
||||
state.SetCookie(finalURL, ck)
|
||||
}
|
||||
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)。
|
||||
// sink 里已含重定向各跳的 Cookie,这里再把最终响应补进去,
|
||||
// 归属域按 Cookie 自己的 Domain 优先、否则按该跳地址(见 cookies.go)。
|
||||
if sink != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
sink.apply(state)
|
||||
}
|
||||
// bodyJs 二次处理
|
||||
if req.BodyJsFn != nil {
|
||||
@@ -600,6 +616,11 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
|
||||
sourceURL: sess.srcURL(),
|
||||
sourceID: sess.srcID(),
|
||||
userID: sess.userID,
|
||||
// state 交出去,让面板里页面自己发起的请求也能把 Set-Cookie
|
||||
// 写回这次会话(见 browser_panel.go / cookies.go)。
|
||||
state: sess.state,
|
||||
// capture 对应 enabledCookieJar:关掉时面板同样不自动累积 Cookie。
|
||||
capture: sess.captureCookies(),
|
||||
}
|
||||
}
|
||||
return rule.NewJSRunner(rule.JSConfig{
|
||||
@@ -745,7 +766,14 @@ type searchHit struct {
|
||||
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
|
||||
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
|
||||
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
|
||||
//
|
||||
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
|
||||
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
|
||||
// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -769,7 +797,7 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri
|
||||
g.Go(func() error {
|
||||
gctxSrc, cancel := context.WithTimeout(gctx, perSourceTimeout)
|
||||
defer cancel()
|
||||
books, err := s.searchInSource(gctxSrc, &src, nil, key, 1)
|
||||
books, err := s.searchInSource(gctxSrc, &src, nil, key, page)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -1164,6 +1192,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 +1286,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 +1820,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) {
|
||||
|
||||
@@ -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,6 +2,7 @@ package rule
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PaesslerAG/jsonpath"
|
||||
@@ -450,10 +451,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
|
||||
if result == nil {
|
||||
result = ""
|
||||
}
|
||||
str := resultString(result)
|
||||
if strings.Contains(str, "&") {
|
||||
str = html.UnescapeString(str)
|
||||
}
|
||||
str := unescapeRuleEntities(resultString(result))
|
||||
if isUrl {
|
||||
if strings.TrimSpace(str) == "" {
|
||||
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
|
||||
@@ -597,6 +595,49 @@ func applyReplaceRegex(result string, r ResolvedSourceRule) string {
|
||||
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
|
||||
}
|
||||
|
||||
// entityRefRe 匹配一个 HTML 字符引用:命名实体(&、 )、十进制(')、
|
||||
// 十六进制(')。分号可选——旧式简写( 、&)在 HTML 文本里同样成立。
|
||||
var entityRefRe = regexp.MustCompile(`&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?`)
|
||||
|
||||
// unescapeRuleEntities 还原规则结果里的 HTML 实体(`&` → `&`、`'` → `'`)。
|
||||
//
|
||||
// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
|
||||
// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
|
||||
// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
|
||||
// `…?item_id=X¶=1&source=QQ阅读` 会变成 `…?item_id=X¶=1&source=QQ阅读`,
|
||||
// 上游按缺参数处理返回空页面,段评面板就只剩一片空白(见 reader/comment.go)。
|
||||
func unescapeRuleEntities(s string) string {
|
||||
if !strings.Contains(s, "&") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, loc := range entityRefRe.FindAllStringIndex(s, -1) {
|
||||
match := s[loc[0]:loc[1]]
|
||||
if !strings.HasSuffix(match, ";") && loc[1] < len(s) && isEntityStopByte(s[loc[1]]) {
|
||||
continue
|
||||
}
|
||||
decoded := html.UnescapeString(match)
|
||||
if decoded == match {
|
||||
continue // 不是已知实体,原样保留
|
||||
}
|
||||
b.WriteString(s[last:loc[0]])
|
||||
b.WriteString(decoded)
|
||||
last = loc[1]
|
||||
}
|
||||
if last == 0 {
|
||||
return s
|
||||
}
|
||||
b.WriteString(s[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isEntityStopByte 判断旧式简写实体后面这个字符是否让它失去实体语义
|
||||
// (HTML5 规定不带分号的引用后跟 = 或字母数字时按普通文本处理)。
|
||||
func isEntityStopByte(c byte) bool {
|
||||
return c == '=' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// resultString 对应 Kotlin 的 result.toString()。
|
||||
func resultString(result any) string {
|
||||
switch t := result.(type) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 ¶ 吃成
|
||||
// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
|
||||
// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
|
||||
func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "段评地址里的 ¶ 不能被当成实体",
|
||||
in: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
want: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
},
|
||||
{
|
||||
name: "¬ 开头的参数名同样不能被吃掉",
|
||||
in: "https://example.com/api?book=1¬ify=1",
|
||||
want: "https://example.com/api?book=1¬ify=1",
|
||||
},
|
||||
{
|
||||
name: "带分号的实体照旧还原",
|
||||
in: "url?a=1&b=2&c=3",
|
||||
want: "url?a=1&b=2&c=3",
|
||||
},
|
||||
{
|
||||
name: "数字实体照旧还原",
|
||||
in: "'引号' 'x' ©",
|
||||
want: "'引号' 'x' ©",
|
||||
},
|
||||
{
|
||||
name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
|
||||
in: "第一段 第二段© 2026×",
|
||||
want: "第一段\u00a0第二段© 2026×",
|
||||
},
|
||||
{
|
||||
name: "不是实体的 & 原样保留",
|
||||
in: "a & b &foo; c",
|
||||
want: "a & b &foo; c",
|
||||
},
|
||||
{
|
||||
name: "不含 & 的字符串直接放行",
|
||||
in: "魔法大陆。",
|
||||
want: "魔法大陆。",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := unescapeRuleEntities(c.in); got != c.want {
|
||||
t.Fatalf("unescapeRuleEntities(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:走 GetString 的规则结果同样不能把段评地址里的 ¶ 吃掉
|
||||
// (光遇聚合的正文规则是 <js>…</js>$.content 形态,最终取 $.content)。
|
||||
func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
|
||||
const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ¶=11&source=QQ%E9%98%85%E8%AF%BB"
|
||||
payload := `{"content":"<p>正文<comment ident=\"` + url + `\" count=\"39\" /></p>"}`
|
||||
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(payload, "https://v1.qingtian618.com")
|
||||
got, err := a.GetString("$.content", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "¶=11") {
|
||||
t.Fatalf("段评地址里的 ¶=11 被改写了: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "\u00b6") {
|
||||
t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索分页。
|
||||
//
|
||||
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
|
||||
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
|
||||
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
|
||||
|
||||
func pagedSearchSourceJSON(serverURL string) string {
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": "分页源",
|
||||
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestSearchUsesRequestedPage(t *testing.T) {
|
||||
var gotPage string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPage = r.URL.Query().Get("page")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{"data": []map[string]string{{
|
||||
"book_name": fmt.Sprintf("第%s页的书", gotPage),
|
||||
"author": "作者",
|
||||
"book_url": "/book/" + gotPage,
|
||||
}}}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
|
||||
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{2, "2"},
|
||||
{5, "5"},
|
||||
{0, "1"}, // 非法页码退回第 1 页
|
||||
{-3, "1"}, // 负数同理
|
||||
} {
|
||||
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
|
||||
}
|
||||
if gotPage != tc.want {
|
||||
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
|
||||
}
|
||||
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
|
||||
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
|
||||
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
|
||||
func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
|
||||
|
||||
books, _, err := svc.Search(t.Context(), "书", nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "单页书" {
|
||||
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||
out, _ := json.Marshal(src)
|
||||
_ = prepareLoginSource(t, svc, string(out))
|
||||
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil)
|
||||
books, _, err = svc.Search(t.Context(), "书", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
@@ -23,8 +24,12 @@ type sourceState struct {
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]string // domain → "k=v; k=v"
|
||||
loaded bool
|
||||
// cookies / cookieMu:domain → "k=v; k=v"。
|
||||
// cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写
|
||||
// Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。
|
||||
cookies map[string]string
|
||||
cookieMu sync.Mutex
|
||||
loaded bool
|
||||
|
||||
toasts []string
|
||||
browsers []rule.BrowserRequest
|
||||
@@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) {
|
||||
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||
|
||||
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
return st.getCookieKeyLocked(rawURL, key)
|
||||
}
|
||||
|
||||
// getCookieKeyLocked 的调用方需持有 cookieMu。
|
||||
func (st *sourceState) getCookieKeyLocked(rawURL, key string) string {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
@@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||
if merged == st.cookies[domain] {
|
||||
return
|
||||
@@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if _, ok := st.cookies[domain]; !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,12 +213,14 @@ func (st *sourceState) flush() {
|
||||
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
cookiesJSON := ""
|
||||
if len(st.cookies) > 0 {
|
||||
if b, err := json.Marshal(st.cookies); err == nil {
|
||||
cookiesJSON = string(b)
|
||||
}
|
||||
}
|
||||
st.cookieMu.Unlock()
|
||||
rec := &model.ReaderSourceState{
|
||||
SourceURL: st.sourceURL,
|
||||
Variable: st.variable,
|
||||
@@ -221,6 +239,8 @@ func (st *sourceState) flush() {
|
||||
|
||||
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||
func (st *sourceState) snapshotCookies() map[string]string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
out := make(map[string]string, len(st.cookies))
|
||||
for d, c := range st.cookies {
|
||||
out[d] = c
|
||||
@@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string {
|
||||
|
||||
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||
func (st *sourceState) clearCookies() {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if len(st.cookies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,15 @@ func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
|
||||
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthProvider(func(ctx context.Context, host string) (string, bool) {
|
||||
return b.c.EmbyRemote.RemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthRefresher(func(ctx context.Context, host string) (string, error) {
|
||||
return b.c.EmbyRemote.RefreshRemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,12 +45,46 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
|
||||
if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" {
|
||||
t.Fatalf("Refresh returned incomplete pair: %+v", pair)
|
||||
}
|
||||
// 轮换后旧令牌应从 pending 表移除,不能再次使用。
|
||||
// 轮换后旧令牌应从 pending 表移除。
|
||||
if _, still := svc.pendingDelayedStore(hash); still {
|
||||
t.Fatal("rotated pending token still tracked")
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
|
||||
t.Fatal("rotated pending token should not refresh twice")
|
||||
// 宽限期内重复提交同一个旧令牌返回同一次轮换的结果(幂等),
|
||||
// 而不是把并发/重试的客户端打成 401 并清空会话。
|
||||
reused, err := svc.Refresh(t.Context(), refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse within grace window should be idempotent: %v", err)
|
||||
}
|
||||
if reused.AccessToken != pair.AccessToken || reused.RefreshToken != pair.RefreshToken {
|
||||
t.Fatal("reuse should return the same rotated pair")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRejectsRotatedTokenAfterGraceWindow 验证宽限期结束后,
|
||||
// 旧令牌仍按已撤销处理(token 复用检测语义不变)。
|
||||
func TestRefreshRejectsRotatedTokenAfterGraceWindow(t *testing.T) {
|
||||
repos := newTokenTestRepo(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
svc := NewTokenService(cfg, zap.NewNop(), repos)
|
||||
|
||||
u := &model.User{Username: "u1", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
|
||||
t.Fatalf("first refresh: %v", err)
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
svc.now = func() time.Time { return base.Add(2 * refreshTokenReuseGrace) }
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("expired reuse error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-23
@@ -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)
|
||||
}
|
||||
|
||||
+11
-4
@@ -268,11 +268,12 @@ export const readerAPI = {
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
search: (key: string, sourceIds: string[] = []) =>
|
||||
// page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。
|
||||
search: (key: string, sourceIds: string[] = [], page = 1) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key, source_ids: sourceIds },
|
||||
{ key, source_ids: sourceIds, page },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
@@ -366,9 +367,15 @@ export const readerAPI = {
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
|
||||
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
|
||||
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
|
||||
api
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
|
||||
params: { chapter },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
signal,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
|
||||
+50
-31
@@ -1,37 +1,56 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
// 令牌刷新 API 模块。
|
||||
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
const REFRESH_ENDPOINT = '/api/auth/refresh'
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
|
||||
// 不要用默认的长超时把页面卡在等待里。
|
||||
const REFRESH_TIMEOUT_MS = 15_000
|
||||
|
||||
// 刷新访问令牌。
|
||||
//
|
||||
// 后端响应封装在 { code, message, data } 里,需解包 .data。
|
||||
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
|
||||
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: RefreshTokenResponse
|
||||
}>('/auth/refresh', { refresh_token: refreshToken })
|
||||
const body = resp.data
|
||||
if (!body || !body.data || !body.data.token) {
|
||||
throw new Error(body?.message || 'refresh failed')
|
||||
/**
|
||||
* 用 refresh token 换取新的令牌对。
|
||||
*
|
||||
* 刻意使用 fetch 而不是共享的 axios 实例:
|
||||
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
|
||||
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
|
||||
*
|
||||
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
|
||||
*/
|
||||
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
|
||||
throw new RefreshRequestError('refresh request failed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
|
||||
| null
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
|
||||
}
|
||||
|
||||
const token = body?.data?.token
|
||||
const nextRefreshToken = body?.data?.refresh_token
|
||||
if (!token || !nextRefreshToken) {
|
||||
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
|
||||
// 不要据此清空用户会话。
|
||||
throw new RefreshRequestError('malformed refresh response')
|
||||
}
|
||||
return { token, refresh_token: nextRefreshToken }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type LoadMoreSentinelProps = {
|
||||
/** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */
|
||||
error?: string
|
||||
onRetry?: () => void
|
||||
label?: string
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调
|
||||
* onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。
|
||||
*
|
||||
* 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会
|
||||
* 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空
|
||||
* 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。
|
||||
*/
|
||||
export function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
error = '',
|
||||
onRetry,
|
||||
label = '加载更多',
|
||||
loadingLabel = '加载中…',
|
||||
}: LoadMoreSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore || error) return
|
||||
// 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore, error])
|
||||
|
||||
if (!hasMore) return null
|
||||
|
||||
const showRetry = !!error && !!onRetry
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
|
||||
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={showRetry ? onRetry : onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? loadingLabel : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,10 +41,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
const card = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
@@ -120,26 +120,34 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact
|
||||
? 'truncate text-xs'
|
||||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||||
{media.video_codec && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
@@ -151,8 +159,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
<div className="group relative block h-full w-full">
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
@@ -166,8 +174,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
<div className="group relative block h-full">
|
||||
<Link to={href} state={linkState} className="block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -178,7 +186,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
<Link to={href} state={linkState} className="group block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
// 手机上单卡只有约 100px 宽,间距收到 gap-3 给标题和徽标多留一点宽度。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
'grid grid-cols-3 gap-3 sm:gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
@@ -105,7 +106,7 @@ export function VirtualMediaGrid({
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-3 sm:pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { LoadMoreSentinel } from '../components/LoadMoreSentinel'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid'
|
||||
import type { Media } from '../types'
|
||||
@@ -67,57 +68,11 @@ export function LibraryMediaSections({
|
||||
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
|
||||
)}
|
||||
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
<LoadMoreSentinel hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore) return
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore])
|
||||
|
||||
if (!hasMore) return null
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, R
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { pickChapterIndex } from './bookDetailModel'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
|
||||
@@ -86,7 +87,16 @@ export default function ReaderBookPage() {
|
||||
|
||||
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
|
||||
|
||||
const startReading = async () => {
|
||||
/**
|
||||
* 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。
|
||||
*/
|
||||
const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise<number> => {
|
||||
const cached = await readerAPI.listChapters(bookID).catch(() => [])
|
||||
return pickChapterIndex(cached, chapter)
|
||||
}
|
||||
|
||||
// chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。
|
||||
const startReading = async (chapter?: ReaderTocChapter) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
let book = shelfBook
|
||||
@@ -105,7 +115,9 @@ export default function ReaderBookPage() {
|
||||
cover_url: cover,
|
||||
})
|
||||
}
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
// 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。
|
||||
const index = chapter ? await resolveChapterIndex(book.id, chapter) : null
|
||||
navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
|
||||
} finally {
|
||||
@@ -273,11 +285,11 @@ export default function ReaderBookPage() {
|
||||
<Trash2 size={13} className="mr-1 inline" /> 移出书架
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
|
||||
<button type="button" onClick={() => startReading()} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
|
||||
<BookOpen size={13} className="mr-1 inline" /> 加入书架
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
|
||||
<button type="button" onClick={() => startReading()} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
|
||||
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
|
||||
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
|
||||
</button>
|
||||
@@ -318,6 +330,16 @@ export default function ReaderBookPage() {
|
||||
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
|
||||
{c.is_volume ? (
|
||||
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
|
||||
) : c.url ? (
|
||||
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => startReading(c)}
|
||||
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{c.title}
|
||||
</button>
|
||||
) : (
|
||||
c.title
|
||||
)}
|
||||
|
||||
@@ -4,26 +4,26 @@ import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||
import { LoadMoreSentinel } from '../../components/LoadMoreSentinel'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from '../../utils/searchBooks'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
// 结果按页取:滚到底自动请求下一页并增量合并,直到某一页不再带来新书。
|
||||
|
||||
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
|
||||
function shelfBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
/** 单次搜索最多翻到第几页。书源分页规则千奇百怪,个别源会一直返回新条目,
|
||||
* 不设上限时用户一直往下滚就会一直发请求(每次都要把所有源再搜一遍)。 */
|
||||
const MAX_SEARCH_PAGES = 20
|
||||
|
||||
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
/** 这本搜索结果是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(shelfBookKey(book.name, book.author))) return true
|
||||
if (shelf.has(searchBookKey(book.name, book.author))) return true
|
||||
if (book.book_url && shelf.has(book.book_url)) return true
|
||||
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
|
||||
}
|
||||
@@ -39,6 +39,16 @@ export default function ReaderSearchPage() {
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
const [showSkipped, setShowSkipped] = useState(false)
|
||||
const [adding, setAdding] = useState('')
|
||||
// 滚动加载:page 是已加载到的页码;hasMore 为 false 表示到底了(或还没搜过)。
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [moreError, setMoreError] = useState('')
|
||||
// loadMore 的在途标记(见 loadMore 里的说明),比 loadingMore 状态更早生效。
|
||||
const loadingMoreRef = useRef(false)
|
||||
// 每次新搜索 +1:在飞的 loadMore 回来时若已经换了关键词(searchSeq 已变),
|
||||
// 就把那一页结果丢掉,避免旧关键词的第 N 页被并进新列表。
|
||||
const searchSeq = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||
@@ -69,7 +79,7 @@ export default function ReaderSearchPage() {
|
||||
.then((books) => {
|
||||
const keys = new Set<string>()
|
||||
for (const b of books) {
|
||||
keys.add(shelfBookKey(b.name, b.author))
|
||||
keys.add(searchBookKey(b.name, b.author))
|
||||
if (b.book_url) keys.add(b.book_url)
|
||||
}
|
||||
setShelfKeys(keys)
|
||||
@@ -89,17 +99,26 @@ export default function ReaderSearchPage() {
|
||||
const doSearch = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || searching) return
|
||||
searchSeq.current++
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
setLoadingMore(false)
|
||||
setMoreError('')
|
||||
try {
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds)
|
||||
setBooks(res.books ?? [])
|
||||
const res = await readerAPI.search(kw, scopeIds, 1)
|
||||
const list = res.books ?? []
|
||||
setBooks(list)
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
setSkipped(res.skipped ?? [])
|
||||
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
|
||||
setPage(1)
|
||||
// 首页一条都没有 → 后面也不会有;有结果就先假定还有下一页。
|
||||
// 书源是否支持分页只有它自己知道({{page}} 也可能藏在 URL 的 <js> 里),
|
||||
// 所以这里不预判:多花一次「第 2 页」请求,由 loadMore 发现这一页没带来新书时收口。
|
||||
setHasMore(list.length > 0)
|
||||
if (list.length === 0) toast.error('所有书源都没有找到结果')
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
|
||||
toast.error(msg)
|
||||
@@ -108,6 +127,47 @@ export default function ReaderSearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底加载下一页。每页都会把范围内的书源重新搜一遍(后端按页下发),
|
||||
// 因此这里要注意两件事:
|
||||
// 1. 结果必须增量合并而不是替换——不支持 {{page}} 的源会在每一页重复返回首页结果;
|
||||
// 2. 合并后没有新书(重复内容或该页为空)就说明到底了,否则用户会一直滚一直转圈。
|
||||
const loadMore = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || !hasMore || loadingMore || searching || books === null) return
|
||||
// loadingMore 是渲染后才生效的状态:同一帧里观察器可能回调两次,用 ref 兜住,
|
||||
// 避免把同一页请求两次(每次翻页都要把所有源再搜一遍,重复请求不便宜)。
|
||||
if (loadingMoreRef.current) return
|
||||
loadingMoreRef.current = true
|
||||
const next = page + 1
|
||||
if (next > MAX_SEARCH_PAGES) {
|
||||
setHasMore(false)
|
||||
loadingMoreRef.current = false
|
||||
return
|
||||
}
|
||||
setLoadingMore(true)
|
||||
setMoreError('')
|
||||
const seq = searchSeq.current
|
||||
try {
|
||||
const res = await readerAPI.search(kw, scopeIds, next)
|
||||
// 期间用户又发起了一次搜索:这一页属于旧关键词,直接丢弃。
|
||||
if (searchSeq.current !== seq) return
|
||||
const incoming = res.books ?? []
|
||||
const merged = mergeSearchBooks(books, incoming)
|
||||
setBooks(merged)
|
||||
setSkipped((prev) => mergeSearchSkipped(prev, res.skipped ?? []))
|
||||
setPage(next)
|
||||
if (merged.length === books.length) setHasMore(false)
|
||||
} catch (e) {
|
||||
if (searchSeq.current !== seq) return
|
||||
// 不弹 toast 打断浏览:把失败原因挂在哨兵上,改由用户点重试。
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载下一页失败'
|
||||
setMoreError(msg)
|
||||
} finally {
|
||||
loadingMoreRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
|
||||
const autoSearchedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -127,7 +187,7 @@ export default function ReaderSearchPage() {
|
||||
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||
setShelfKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(shelfBookKey(book.name, book.author))
|
||||
next.add(searchBookKey(book.name, book.author))
|
||||
if (origin.book_url) next.add(origin.book_url)
|
||||
return next
|
||||
})
|
||||
@@ -277,6 +337,19 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */}
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMore}
|
||||
error={moreError}
|
||||
onRetry={loadMore}
|
||||
loadingLabel={`正在搜索第 ${page + 1} 页…`}
|
||||
/>
|
||||
{!hasMore && !moreError && visibleBooks.length > 0 && (
|
||||
<p className="py-2 text-center text-2xs text-[var(--app-subtle)]">已经到底了</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
<button
|
||||
|
||||
@@ -109,6 +109,8 @@ export default function ReaderViewPage() {
|
||||
const [switchLoading, setSwitchLoading] = useState(false)
|
||||
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0)
|
||||
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
|
||||
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
@@ -218,7 +220,10 @@ export default function ReaderViewPage() {
|
||||
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||
idx = b.dur_chapter_index
|
||||
}
|
||||
pendingPosRef.current = b.dur_chapter_pos ?? 0
|
||||
// 位置只在「接着上次那一章读」时恢复:URL 显式跳到别的章时,b.dur_chapter_pos
|
||||
// 属于另一章,套到新章上会从中间开始(音频表现为一进去就从错的时间点续播)。
|
||||
const resumingSavedChapter = !explicitChapter || idx === b.dur_chapter_index
|
||||
pendingPosRef.current = resumingSavedChapter ? (b.dur_chapter_pos ?? 0) : 0
|
||||
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
|
||||
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
|
||||
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
|
||||
@@ -248,20 +253,37 @@ 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 () => {
|
||||
// 每次重新取正文都先清掉上一次的报错。换源时的典型情形:服务端章节缓存刚被清空、
|
||||
// 而本地 chapters 还是旧源的,这一次请求必然失败(「章节缓存为空」);等新源目录
|
||||
// 落地后正文 effect 会再来一次并且成功,但不清这里的话,旧错误会一直压在正文上。
|
||||
setError('')
|
||||
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 +322,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 +343,9 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
ac.abort()
|
||||
}
|
||||
}, [chapterIndex, book, chapters])
|
||||
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
|
||||
|
||||
// ── 分页排版(CSS 多栏 + 平移) ──
|
||||
const relayout = useCallback(() => {
|
||||
@@ -533,6 +560,12 @@ export default function ReaderViewPage() {
|
||||
await readerAPI.switchOrigin(book.id, origin)
|
||||
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
|
||||
contentCache.current.clear()
|
||||
// 换源后服务端已清空章节缓存,而本地的 chapters 还是旧源的目录:不清理的话,
|
||||
// 重新加载书籍期间(listBooks 已返回、新目录还没抓回来)正文 effect 会拿旧目录
|
||||
// 去打新源,撞上「章节缓存为空」。这里先把正文/目录清空,等新源目录到位再取。
|
||||
setError('')
|
||||
setContent(null)
|
||||
setChapters([])
|
||||
setReloadKey((v) => v + 1)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
|
||||
@@ -953,8 +986,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"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader.ts'
|
||||
import { pickChapterIndex } from './bookDetailModel.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`bookDetailModel: ${name}`)
|
||||
}
|
||||
|
||||
function cached(index: number, url: string): ReaderChapter {
|
||||
return { index, title: `第${index}章`, url, is_volume: false }
|
||||
}
|
||||
|
||||
function toc(index: number, url: string, over: Partial<ReaderTocChapter> = {}): ReaderTocChapter {
|
||||
return { index, title: `第${index}章`, url, is_volume: false, update_time: '', ...over }
|
||||
}
|
||||
|
||||
// 目录一致(正常情况):按 url 定位到的序号与现抓序号相同
|
||||
{
|
||||
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
|
||||
check('same url keeps the same index', pickChapterIndex(list, toc(2, 'https://s/3')) === 2)
|
||||
}
|
||||
|
||||
// 站点在开头插了新章:同一章在现抓目录里是 2,在缓存目录里是 1——必须用缓存里的 1,
|
||||
// 照搬现抓的 2 会跳到下一章。
|
||||
{
|
||||
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
|
||||
check('inserted chapter maps back to the cached index', pickChapterIndex(list, toc(2, 'https://s/2')) === 1)
|
||||
}
|
||||
|
||||
// 缓存目录里没有这一章(站点改版、或两边取的不是同一份目录):退回现抓的序号
|
||||
{
|
||||
const list = [cached(0, 'https://s/1')]
|
||||
check('unknown url falls back to the fetched index', pickChapterIndex(list, toc(7, 'https://s/7')) === 7)
|
||||
}
|
||||
|
||||
// 缓存为空(第一次打开这本书):退回现抓序号,阅读器随后会把这份目录存下来
|
||||
{
|
||||
check('empty cache falls back to the fetched index', pickChapterIndex([], toc(5, 'https://s/5')) === 5)
|
||||
}
|
||||
|
||||
// 章节没有 url(拿不到地址):退回现抓序号,不做 url 匹配
|
||||
{
|
||||
const list = [cached(0, ''), cached(1, '')]
|
||||
check('chapter without url falls back to the fetched index', pickChapterIndex(list, toc(1, '')) === 1)
|
||||
}
|
||||
|
||||
console.log('bookDetailModel.test.ts ok')
|
||||
@@ -0,0 +1,20 @@
|
||||
// 书籍详情页点目录跳读的纯逻辑(不依赖 React,便于单测)。
|
||||
|
||||
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader'
|
||||
|
||||
/**
|
||||
* 把详情页点中的章节换算成阅读器要的序号。
|
||||
*
|
||||
* 阅读器打开一本书时优先用库里缓存的目录(`/books/:id/chapters`),而详情页显示的
|
||||
* 目录是现抓的:站点更新过目录(插入或调整过章节)时两者会错位,直接照搬现抓目录
|
||||
* 里的序号会跳到别的章。url 是章节的稳定标识,所以先按 url 在缓存目录里定位;
|
||||
* 找不到(缓存为空,或两边取的根本不是同一份目录)才退回现抓的序号——缓存为空时
|
||||
* 阅读器本来就会把这次现抓的目录存下来,序号自然对得上。
|
||||
*/
|
||||
export function pickChapterIndex(cached: ReaderChapter[], chapter: ReaderTocChapter): number {
|
||||
if (chapter.url) {
|
||||
const hit = cached.findIndex((c) => c.url === chapter.url)
|
||||
if (hit >= 0) return hit
|
||||
}
|
||||
return chapter.index
|
||||
}
|
||||
@@ -64,6 +64,14 @@ class ReaderAudioEngine {
|
||||
private detaching = false
|
||||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||||
private loadToken = 0
|
||||
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
|
||||
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
|
||||
/** 预取在途的章序号,避免同一章重复请求。 */
|
||||
private prefetching = new Set<number>()
|
||||
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
|
||||
private warmer: HTMLAudioElement | null = null
|
||||
/** warmer 当前装载的音轨,避免重复赋值。 */
|
||||
private warmedTrack = ''
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
@@ -93,7 +101,13 @@ class ReaderAudioEngine {
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
// 换书:预取的音轨属于上一本,全部作废
|
||||
if (s.bookId !== input.bookId) this.clearPrefetch()
|
||||
this.applyChapter(input)
|
||||
}
|
||||
|
||||
/** 真正把一章装上播放器(同步部分):预取命中的续播也走这里。 */
|
||||
private applyChapter(input: LoadChapterInput): void {
|
||||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||||
useReaderAudioStore.setState({
|
||||
bookId: input.bookId,
|
||||
@@ -128,6 +142,9 @@ class ReaderAudioEngine {
|
||||
}
|
||||
|
||||
void this.attachSource(input.track)
|
||||
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null) this.prefetchChapter(nextIndex)
|
||||
}
|
||||
|
||||
play(): void {
|
||||
@@ -222,6 +239,7 @@ class ReaderAudioEngine {
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.detachSource()
|
||||
this.clearPrefetch()
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||||
@@ -242,12 +260,22 @@ class ReaderAudioEngine {
|
||||
|
||||
private async attachSource(src: string): Promise<void> {
|
||||
const token = ++this.loadToken
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
|
||||
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
|
||||
// 「无用户手势」拦下——表现正是切章卡住、解锁后必须手动点播放。直接覆盖 src,
|
||||
// 元素始终持有媒体,会话才接得上。
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
if (!src) {
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.setSessionMetadata(null)
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
|
||||
if (!this.audio.paused) this.audio.pause()
|
||||
this.setSessionMetadata(src)
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||||
@@ -270,6 +298,7 @@ class ReaderAudioEngine {
|
||||
this.audio.src = src
|
||||
}
|
||||
|
||||
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
@@ -279,9 +308,117 @@ class ReaderAudioEngine {
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
// ─── 预取与续播 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 提前解析某章的音轨并预热字节。息屏时 ended 与 play() 之间不能有网络空档,
|
||||
* 否则系统收走音频会话、标签页被冻结,浏览器就拒绝自动播放了。
|
||||
*/
|
||||
private prefetchChapter(index: number): void {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
if (this.prefetched.has(index) || this.prefetching.has(index)) return
|
||||
// 反复跳章时旧的预取会变成垃圾,做个上限兜底
|
||||
if (this.prefetched.size > 32) this.prefetched.clear()
|
||||
this.prefetching.add(index)
|
||||
const forBook = bookId
|
||||
readerAPI
|
||||
.bookContent(bookId, index)
|
||||
.then((ct) => {
|
||||
// 期间换了书:结果作废
|
||||
if (useReaderAudioStore.getState().bookId !== forBook) return
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) return
|
||||
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false })
|
||||
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
|
||||
// 避免用户中途停下时白下整章的流量。
|
||||
if (ct.transcoding) this.warmNextBytes(track)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => this.prefetching.delete(index))
|
||||
}
|
||||
|
||||
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
|
||||
private warmNextBytes(track: string): void {
|
||||
// HLS 交给 hls.js 按需拉流,二次下载没有意义
|
||||
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
|
||||
this.warmedTrack = track
|
||||
if (!this.warmer) {
|
||||
const a = new Audio()
|
||||
a.preload = 'auto'
|
||||
this.warmer = a
|
||||
}
|
||||
this.warmer.src = track
|
||||
}
|
||||
|
||||
/** 本章快播完时把下一章的字节预热起来(30 秒足够开头缓冲)。 */
|
||||
private maybeWarmNext(): void {
|
||||
const audio = this.audio
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return
|
||||
if (audio.duration - audio.currentTime > 30) return
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex === null) return
|
||||
const hit = this.prefetched.get(nextIndex)
|
||||
if (hit) this.warmNextBytes(hit.track)
|
||||
}
|
||||
|
||||
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
|
||||
private playPrefetched(index: number): boolean {
|
||||
const hit = this.prefetched.get(index)
|
||||
if (!hit) return false
|
||||
this.prefetched.delete(index)
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.applyChapter({
|
||||
bookId: s.bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters: s.chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos: 0,
|
||||
})
|
||||
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
|
||||
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
|
||||
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
|
||||
if (!hit.track.includes('.m3u8')) this.play()
|
||||
return true
|
||||
}
|
||||
|
||||
private clearPrefetch(): void {
|
||||
this.prefetched.clear()
|
||||
this.prefetching.clear()
|
||||
this.warmedTrack = ''
|
||||
if (this.warmer) {
|
||||
this.warmer.removeAttribute('src')
|
||||
this.warmer.load()
|
||||
}
|
||||
}
|
||||
|
||||
private async goToChapter(index: number, initialPos: number): Promise<void> {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
// 命中预取:直接装,不等网络(片尾自动续播大多走这里)
|
||||
const hit = this.prefetched.get(index)
|
||||
if (hit) {
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.loadChapter({
|
||||
bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos,
|
||||
})
|
||||
return
|
||||
}
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(bookId, index)
|
||||
@@ -344,6 +481,10 @@ class ReaderAudioEngine {
|
||||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||||
this.commitProgress()
|
||||
if (useReaderAudioStore.getState().hasNext) {
|
||||
// 预取命中:在 ended 的同一次事件里同步接上下一章。息屏时这段「零空档」
|
||||
// 很关键——空太久系统会收走音频会话、冻结标签页,浏览器随后拒绝自动播放。
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null && this.playPrefetched(nextIndex)) return
|
||||
this.next()
|
||||
return
|
||||
}
|
||||
@@ -389,6 +530,7 @@ class ReaderAudioEngine {
|
||||
this.checkCreditsEnd()
|
||||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||||
this.checkTimer()
|
||||
this.maybeWarmNext()
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
|
||||
+70
-40
@@ -2,7 +2,12 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
import { refreshToken } from '../api/refresh'
|
||||
import { requestRefreshTokens } from '../api/refresh'
|
||||
import {
|
||||
createBrowserExclusiveRunner,
|
||||
createRefreshCoordinator,
|
||||
type RefreshOutcome,
|
||||
} from '../utils/authRefresh'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
@@ -16,50 +21,65 @@ interface AuthState {
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
logout: () => void
|
||||
/**
|
||||
* 刷新会话的唯一入口(同标签页 + 跨标签页收敛成一次请求)。
|
||||
*
|
||||
* 返回的 outcome 决定调用方怎么处理:
|
||||
* - 'refreshed' 已拿到新令牌,可重试原请求;
|
||||
* - 'invalid' 服务端明确判定凭证失效,本地会话已清空,需重新登录;
|
||||
* - 'transient' 网络/服务暂时不可用,本地会话保留,稍后重试即可。
|
||||
*/
|
||||
refreshSession: () => Promise<RefreshOutcome>
|
||||
/** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
|
||||
tokenRefresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export const AUTH_STORAGE_KEY = 'mebox-auth'
|
||||
|
||||
// 跨标签页互斥:多个标签页共享同一份 refresh token,
|
||||
// 只允许其中一个真正发起刷新。
|
||||
const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
tokenRefresh: async () => {
|
||||
const rt = get().refreshToken
|
||||
if (!rt) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resp = await refreshToken(rt)
|
||||
set({
|
||||
token: resp.token,
|
||||
refreshToken: resp.refresh_token
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Refresh failed, need to logout
|
||||
set({ token: null, refreshToken: null, user: null, tier: 'free' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mebox-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
(set, get) => {
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => get().token,
|
||||
readRefreshToken: () => get().refreshToken,
|
||||
applyTokens: (tokens) =>
|
||||
set({ token: tokens.token, refreshToken: tokens.refresh_token }),
|
||||
clearSession: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
request: requestRefreshTokens,
|
||||
runExclusive: runRefreshExclusive,
|
||||
// 其他标签页刷新成功后会写回 localStorage;重新读进来,避免本标签页
|
||||
// 继续持有已被服务端轮换作废的旧 refresh token。
|
||||
syncFromStorage: () => useAuthStore.persist.rehydrate(),
|
||||
})
|
||||
|
||||
return {
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
refreshSession,
|
||||
tokenRefresh: async () => (await refreshSession()) === 'refreshed',
|
||||
}
|
||||
},
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
tier: state.tier
|
||||
}),
|
||||
@@ -67,6 +87,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
),
|
||||
)
|
||||
|
||||
// storage 事件只在其他标签页写入时触发:登出/轮换后同步本地会话,
|
||||
// 否则本标签页会继续用已失效的令牌发请求。
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === AUTH_STORAGE_KEY) {
|
||||
void useAuthStore.persist.rehydrate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if user is authenticated
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().token !== null
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 刷新令牌并发保护与失败分类的回归测试。
|
||||
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
} from './authRefresh.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`authRefresh: ${name}`)
|
||||
}
|
||||
|
||||
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
|
||||
const got = await promise
|
||||
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
|
||||
}
|
||||
|
||||
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
|
||||
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
|
||||
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
|
||||
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
|
||||
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
|
||||
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
|
||||
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
|
||||
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
let release: (() => void) | null = null
|
||||
const run = createSingleFlightRunner(async () => {
|
||||
calls += 1
|
||||
const current = calls
|
||||
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
|
||||
if (current === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
const first = run()
|
||||
const second = run()
|
||||
check('single flight shares one promise', first === second)
|
||||
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
|
||||
queueMicrotask(() => release?.())
|
||||
check('single flight result', (await first) === 1)
|
||||
check('single flight stayed single', calls === 1)
|
||||
check('single flight re-runs after settle', (await run()) === 2)
|
||||
}
|
||||
|
||||
// ── 协调器 ────────────────────────────────────────────────────────────────
|
||||
function coordinatorHarness(overrides: {
|
||||
refreshToken?: string | null
|
||||
accessToken?: string | null
|
||||
request?: (token: string) => Promise<RefreshTokens>
|
||||
onApply?: (tokens: RefreshTokens) => void
|
||||
onClear?: () => void
|
||||
syncFromStorage?: () => void
|
||||
}) {
|
||||
const state = {
|
||||
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
|
||||
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
|
||||
applied: [] as RefreshTokens[],
|
||||
cleared: 0,
|
||||
requests: [] as string[],
|
||||
}
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => state.accessToken,
|
||||
readRefreshToken: () => state.refreshToken,
|
||||
applyTokens: (tokens) => {
|
||||
state.applied.push(tokens)
|
||||
state.accessToken = tokens.token
|
||||
state.refreshToken = tokens.refresh_token
|
||||
overrides.onApply?.(tokens)
|
||||
},
|
||||
clearSession: () => {
|
||||
state.cleared += 1
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
overrides.onClear?.()
|
||||
},
|
||||
request: async (token) => {
|
||||
state.requests.push(token)
|
||||
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
|
||||
},
|
||||
runExclusive: (task) => task(),
|
||||
syncFromStorage: () => overrides.syncFromStorage?.(),
|
||||
})
|
||||
return { state, refreshSession }
|
||||
}
|
||||
|
||||
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({})
|
||||
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
|
||||
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
|
||||
check('concurrent refresh sends one request', state.requests.length === 1)
|
||||
check('concurrent refresh applies once', state.applied.length === 1)
|
||||
}
|
||||
|
||||
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('bad gateway', 502)
|
||||
},
|
||||
})
|
||||
await expectOutcome('gateway error', refreshSession(), 'transient')
|
||||
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('network down')
|
||||
},
|
||||
})
|
||||
await expectOutcome('network error', refreshSession(), 'transient')
|
||||
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
|
||||
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('refresh token revoked', 401)
|
||||
},
|
||||
})
|
||||
await expectOutcome('revoked token', refreshSession(), 'invalid')
|
||||
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
|
||||
}
|
||||
|
||||
// 没有 refresh token 时直接判定失效,不发请求。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
|
||||
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
|
||||
check('missing refresh token sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = 'rt-other-tab'
|
||||
state.accessToken = 'at-other-tab'
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
|
||||
check('other tab rotation sends no request', state.requests.length === 0)
|
||||
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
|
||||
}
|
||||
|
||||
// 另一个标签页登出了:本标签页也应判定失效。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = null
|
||||
state.accessToken = null
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
|
||||
check('other tab logout sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
|
||||
{
|
||||
let attempt = 0
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw new RefreshRequestError('network down')
|
||||
return { token: 'at-retry', refresh_token: 'rt-retry' }
|
||||
},
|
||||
})
|
||||
await expectOutcome('first transient attempt', refreshSession(), 'transient')
|
||||
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
|
||||
check('retry used the same token', state.requests[1] === 'rt-1')
|
||||
check('retry applied new tokens', state.refreshToken === 'rt-retry')
|
||||
}
|
||||
|
||||
console.log('authRefresh.test.ts ok')
|
||||
@@ -0,0 +1,148 @@
|
||||
// 刷新令牌的并发保护与失败分类。
|
||||
//
|
||||
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
|
||||
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
|
||||
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
|
||||
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
|
||||
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
|
||||
//
|
||||
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
|
||||
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
|
||||
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
|
||||
export class RefreshRequestError extends Error {
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'RefreshRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
|
||||
*
|
||||
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
|
||||
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
|
||||
* 否则一次瞬时故障就会把用户永久踢到登录页。
|
||||
*/
|
||||
export function refreshFailureOutcome(status?: number): RefreshOutcome {
|
||||
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 400 || status === 401 || status === 403) {
|
||||
return 'invalid'
|
||||
}
|
||||
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
|
||||
return 'transient'
|
||||
}
|
||||
|
||||
/** 从抛出的错误里取出 HTTP 状态码。 */
|
||||
export function httpStatusOf(error: unknown): number | undefined {
|
||||
if (error instanceof RefreshRequestError) return error.status
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
|
||||
*
|
||||
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
|
||||
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
|
||||
*/
|
||||
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
|
||||
let inflight: Promise<T> | null = null
|
||||
return () => {
|
||||
if (inflight) return inflight
|
||||
inflight = Promise.resolve()
|
||||
.then(task)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨标签页互斥执行(Web Locks API)。
|
||||
*
|
||||
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
|
||||
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
|
||||
* 重新读取 localStorage,直接复用刚轮换出来的会话。
|
||||
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
|
||||
*/
|
||||
export function createBrowserExclusiveRunner(
|
||||
lockName: string,
|
||||
): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
return <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
||||
if (!locks || typeof locks.request !== 'function') return task()
|
||||
try {
|
||||
return locks.request(lockName, () => task()) as Promise<T>
|
||||
} catch {
|
||||
return task()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
applyTokens: (tokens: RefreshTokens) => void
|
||||
clearSession: () => void
|
||||
request: (refreshToken: string) => Promise<RefreshTokens>
|
||||
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
|
||||
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
|
||||
syncFromStorage: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
|
||||
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
|
||||
*/
|
||||
export function createRefreshCoordinator(
|
||||
deps: RefreshCoordinatorDeps,
|
||||
): () => Promise<RefreshOutcome> {
|
||||
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
|
||||
const tokenAtEntry = deps.readRefreshToken()
|
||||
// 没有 refresh token 就无从恢复会话。
|
||||
if (!tokenAtEntry) return 'invalid'
|
||||
|
||||
return deps.runExclusive(async () => {
|
||||
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
|
||||
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
|
||||
await deps.syncFromStorage()
|
||||
const current = deps.readRefreshToken()
|
||||
if (!current) return 'invalid'
|
||||
if (current !== tokenAtEntry) {
|
||||
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
|
||||
return deps.readAccessToken() ? 'refreshed' : 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
deps.applyTokens(await deps.request(current))
|
||||
return 'refreshed'
|
||||
} catch (error) {
|
||||
const outcome = refreshFailureOutcome(httpStatusOf(error))
|
||||
if (outcome === 'invalid') deps.clearSession()
|
||||
return outcome
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReaderSearchBook, ReaderSearchOrigin } from '../api/reader.ts'
|
||||
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from './searchBooks.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`searchBooks: ${name}`)
|
||||
}
|
||||
|
||||
function origin(sourceId: string, bookUrl: string, originName = sourceId): ReaderSearchOrigin {
|
||||
return { source_id: sourceId, origin: `https://${sourceId}.example.com`, origin_name: originName, origin_type: 0, book_url: bookUrl, latest_chapter: '' }
|
||||
}
|
||||
|
||||
function book(over: Partial<ReaderSearchBook> & { name: string; author: string }): ReaderSearchBook {
|
||||
return {
|
||||
kind: '',
|
||||
word_count: '',
|
||||
latest_chapter: '',
|
||||
intro: '',
|
||||
cover_url: '',
|
||||
book_url: '',
|
||||
origins: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
// 聚合键:作者缺省时只用书名(和后端 name|author 的拼法一致)
|
||||
check('key uses name and author', searchBookKey('全球高武', '懒人') === '全球高武|懒人')
|
||||
check('key falls back to name without author', searchBookKey('全球高武', '') === '全球高武')
|
||||
check('key trims blanks', searchBookKey(' 全球高武 ', ' 懒人 ') === '全球高武|懒人')
|
||||
check('key treats blank author as missing', searchBookKey('全球高武', ' ') === '全球高武')
|
||||
|
||||
// 跨页去重:不支持分页的源在每一页都返回首页结果,不能重复追加一条书
|
||||
{
|
||||
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: 'http://c/1.jpg' })]
|
||||
const page2 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('duplicate across pages is merged', merged.length === 1)
|
||||
check('duplicate origin is not re-added', merged[0].origins.length === 1)
|
||||
check('existing fields survive the merge', merged[0].cover_url === 'http://c/1.jpg')
|
||||
}
|
||||
|
||||
// 新一页的书接在已有书后面,顺序不倒
|
||||
{
|
||||
const page1 = [book({ name: '甲', author: '作者', origins: [origin('a', '/a')] })]
|
||||
const page2 = [book({ name: '乙', author: '作者', origins: [origin('a', '/b')] })]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('new page appends', merged.length === 2)
|
||||
check('first page keeps its position', merged[0].name === '甲')
|
||||
check('second page follows', merged[1].name === '乙')
|
||||
}
|
||||
|
||||
// 同书多源:书源并进同一项(决定「N 源可换」),且不会补齐覆盖已有的有效值
|
||||
{
|
||||
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: '', intro: '第一页的简介' })]
|
||||
const page2 = [
|
||||
book({
|
||||
name: '全球高武',
|
||||
author: '懒人',
|
||||
origins: [origin('b', '/2')],
|
||||
cover_url: 'http://c/2.jpg',
|
||||
intro: '第二页的简介',
|
||||
kind: '玄幻',
|
||||
}),
|
||||
]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('same book merges origins', merged[0].origins.length === 2)
|
||||
check('missing cover is filled', merged[0].cover_url === 'http://c/2.jpg')
|
||||
check('missing kind is filled', merged[0].kind === '玄幻')
|
||||
check('existing intro is not overwritten', merged[0].intro === '第一页的简介')
|
||||
}
|
||||
|
||||
// 不同作者的同名书不能被合并成一条
|
||||
{
|
||||
const merged = mergeSearchBooks(
|
||||
[book({ name: '同名书', author: '甲', origins: [origin('a', '/1')] })],
|
||||
[book({ name: '同名书', author: '乙', origins: [origin('a', '/2')] })],
|
||||
)
|
||||
check('same name different author stays separate', merged.length === 2)
|
||||
}
|
||||
|
||||
// 无名条目没有聚合键,直接丢弃(后端也会跳过 Name 为空的项)
|
||||
{
|
||||
const merged = mergeSearchBooks([], [book({ name: ' ', author: '作者' })])
|
||||
check('nameless item is dropped', merged.length === 0)
|
||||
}
|
||||
|
||||
// 不原地改动上一份状态:prev 的数组与对象都不能被写坏
|
||||
{
|
||||
const prev = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
|
||||
const snapshot = JSON.stringify(prev)
|
||||
mergeSearchBooks(prev, [book({ name: '全球高武', author: '懒人', origins: [origin('b', '/2')] })])
|
||||
check('prev state is not mutated', JSON.stringify(prev) === snapshot)
|
||||
}
|
||||
|
||||
// 失败书源按 source_id 去重,原因取最新一页的
|
||||
{
|
||||
const merged = mergeSearchSkipped(
|
||||
[{ source_id: 'a', origin_name: '源A', reason: '超时' }],
|
||||
[
|
||||
{ source_id: 'a', origin_name: '源A', reason: '限流' },
|
||||
{ source_id: 'b', origin_name: '源B', reason: '解析失败' },
|
||||
],
|
||||
)
|
||||
check('skipped is deduped by source', merged.length === 2)
|
||||
check('skipped reason comes from the newest page', merged[0].reason === '限流')
|
||||
check('new failed source is appended', merged[1].source_id === 'b')
|
||||
check('empty incoming keeps the same array', mergeSearchSkipped(merged, []) === merged)
|
||||
}
|
||||
|
||||
console.log('searchBooks.test.ts ok')
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReaderSearchBook, ReaderSearchOrigin, ReaderSearchSkipped } from '../api/reader'
|
||||
|
||||
/**
|
||||
* 同名同作者的聚合键——与后端 mergeSearchResults 的 `name|author` 一致:
|
||||
* 同一本书多源命中时合并成一行;作者缺省时只用书名(否则作者为空的源会被拆成两条)。
|
||||
*/
|
||||
export function searchBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
|
||||
/** 两条命中书源是否指向同一个源的同一个地址(跨页重复返回时用于去重)。 */
|
||||
function sameOrigin(a: ReaderSearchOrigin, b: ReaderSearchOrigin): boolean {
|
||||
return a.source_id === b.source_id && a.book_url === b.book_url
|
||||
}
|
||||
|
||||
/** 补齐缺失字段(对应后端 fillMissingSearchBookFields):先到的空值不能挡掉后面源的有效值。 */
|
||||
function fillMissing(dst: ReaderSearchBook, src: ReaderSearchBook): void {
|
||||
if (!dst.cover_url) dst.cover_url = src.cover_url
|
||||
if (!dst.intro) dst.intro = src.intro
|
||||
if (!dst.kind) dst.kind = src.kind
|
||||
if (!dst.word_count) dst.word_count = src.word_count
|
||||
if (!dst.latest_chapter) dst.latest_chapter = src.latest_chapter
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新一页的搜索结果并入已加载的列表(滚动加载用)。
|
||||
*
|
||||
* 每一页都是后端独立聚合出来的,跨页去重只能在前端做:书源 searchUrl 里没有
|
||||
* {{page}} 时它会重复返回首页结果,同一个源的同一本书就可能在每一页都出现,
|
||||
* 直接 append 会让列表堆满重复行。这里按 searchBookKey 合并成一项:
|
||||
* 并书源(决定「N 源可换」)、补缺失字段;顺序保持「先到的在前」,
|
||||
* 于是第 2 页的新书接在第 1 页后面。
|
||||
*
|
||||
* 返回新数组与新对象,调用方可以直接交给 setState,不必担心原地改动上一份状态。
|
||||
*/
|
||||
export function mergeSearchBooks(prev: ReaderSearchBook[], incoming: ReaderSearchBook[]): ReaderSearchBook[] {
|
||||
const out = prev.map((b) => ({ ...b, origins: [...b.origins] }))
|
||||
const byKey = new Map<string, ReaderSearchBook>()
|
||||
for (const b of out) byKey.set(searchBookKey(b.name, b.author), b)
|
||||
|
||||
for (const b of incoming) {
|
||||
// 无名条目没有可用的聚合键(后端也会跳过 Name 为空的项),直接丢弃。
|
||||
if (!b.name.trim()) continue
|
||||
const key = searchBookKey(b.name, b.author)
|
||||
const existing = byKey.get(key)
|
||||
if (!existing) {
|
||||
const copy: ReaderSearchBook = { ...b, origins: [...b.origins] }
|
||||
out.push(copy)
|
||||
byKey.set(key, copy)
|
||||
continue
|
||||
}
|
||||
fillMissing(existing, b)
|
||||
for (const origin of b.origins) {
|
||||
if (!existing.origins.some((o) => sameOrigin(o, origin))) existing.origins.push(origin)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并搜索失败的书源列表(滚动加载时每页失败的源可能不同)。
|
||||
* 按 source_id 去重,原因是「任何一页失败过就算失败」——排查页面关心的是哪些源
|
||||
* 有问题,而不是它这一页恰好成功;同一个源在多页里原因不同时取最新一页的。
|
||||
*/
|
||||
export function mergeSearchSkipped(
|
||||
prev: ReaderSearchSkipped[],
|
||||
incoming: ReaderSearchSkipped[],
|
||||
): ReaderSearchSkipped[] {
|
||||
if (incoming.length === 0) return prev
|
||||
const out = [...prev]
|
||||
const index = new Map(out.map((s, i) => [s.source_id, i]))
|
||||
for (const s of incoming) {
|
||||
const at = index.get(s.source_id)
|
||||
if (at === undefined) {
|
||||
index.set(s.source_id, out.length)
|
||||
out.push(s)
|
||||
} else {
|
||||
out[at] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Vendored
+6
@@ -1,6 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
future: {
|
||||
// hover 样式只对真正能悬停的设备(鼠标/触控板)生效。触屏点击会让浏览器
|
||||
// 保留 :hover 状态,导致海报墙上的卡片被放大、上移、盖上「立即观影」遮罩,
|
||||
// 看起来像是整行错位、海报显示不全。
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user