From 91c6d9dfd9f4979738b5c18c77105d714ebf54b5 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Mon, 14 Sep 2026 16:47:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86token=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/strm.go | 30 +++++++---- internal/repository/strm_repository.go | 15 ++++++ internal/service/strm_account_test.go | 71 ++++++++++++++++++++++++++ internal/service/strm_service.go | 34 +++++++++--- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 6a5bf9a..9289d71 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc { return } now := time.Now() - acct.LastTestAt = &now if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil { + result := "" + ok := false if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil { - acct.LastTestResult = err.Error() - acct.LastTestOK = false + result = err.Error() } else { - acct.LastTestResult = "ok" - acct.LastTestOK = true + result = "ok" + ok = true } - } else { - acct = svc.Strm.TestStrmAccount(c.Request.Context(), id) - if acct == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) + acct.LastTestAt = &now + acct.LastTestResult = result + acct.LastTestOK = ok + if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil { + // 写库失败时仍返回本地测试结果;不要回退到整行 Update, + // 那会覆盖 TestConnection 期间可能刷新的账号配置。 + c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) return } + if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil { + acct = fresh + } c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) return } - _ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct) + acct = svc.Strm.TestStrmAccount(c.Request.Context(), id) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) + return + } c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) } } diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 2cfbb18..052abab 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -57,6 +57,21 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount }) } +// UpdateTestResult updates only connectivity-test metadata. Callers that touch +// account credentials must not use Update with a snapshot read before Ping: +// a 115 token refresh can persist new tokens while Ping is running, and writing +// the stale snapshot back would revoke the freshly rotated credentials. +func (r *StrmAccountRepository) UpdateTestResult(ctx context.Context, id string, at time.Time, result string, ok bool) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", id).Updates(map[string]any{ + "last_test_at": at, + "last_test_result": result, + "last_test_ok": ok, + "updated_at": time.Now(), + }).Error + }) +} + func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error { return withSQLiteBusyRetry(ctx, func() error { return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error diff --git a/internal/service/strm_account_test.go b/internal/service/strm_account_test.go index fb76034..298a6f6 100644 --- a/internal/service/strm_account_test.go +++ b/internal/service/strm_account_test.go @@ -10,6 +10,7 @@ import ( "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud" "github.com/truewhile/MeBox/internal/service/cloud115" ) @@ -156,6 +157,76 @@ func TestPersist115TokensKeepsSharedClient(t *testing.T) { } } +// TestTestStrmAccountKeepsTokensRefreshedDuringPing 回归测试: +// Ping 期间 115 客户端可能刷新并持久化 token。账号测试只能写 last_test_* +// 字段,不能再用请求开始时读取的旧 Config 整包覆盖,否则新 token 会被旧值 +// 覆盖,最终导致账号在下次重启后失效。 +func TestTestStrmAccountKeepsTokensRefreshedDuringPing(t *testing.T) { + svc := testStrmService(t) + ctx := context.Background() + acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{ + "app_id": "100195129", + "access_token": "at-old", + "refresh_token": "rt-old", + }) + if err != nil { + t.Fatalf("create account: %v", err) + } + + svc.providerMu.Lock() + svc.provider115Cache[acct.ID] = &refreshOnPing115Provider{svc: svc, accountID: acct.ID} + svc.providerMu.Unlock() + + got := svc.TestStrmAccount(ctx, acct.ID) + if got == nil { + t.Fatal("TestStrmAccount returned nil") + } + if !got.LastTestOK || got.LastTestResult != "ok" { + t.Fatalf("test result = (%v, %q), want ok", got.LastTestOK, got.LastTestResult) + } + gotCfg, err := svc.strmAccountConfig(got) + if err != nil { + t.Fatalf("decode returned config: %v", err) + } + if gotCfg["access_token"] != "at-new" || gotCfg["refresh_token"] != "rt-new" { + t.Fatalf("returned account lost refreshed tokens: %#v", gotCfg) + } + + fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID) + if err != nil || fresh == nil { + t.Fatalf("reload account: %v", err) + } + freshCfg, err := svc.strmAccountConfig(fresh) + if err != nil { + t.Fatalf("decode persisted config: %v", err) + } + if freshCfg["access_token"] != "at-new" || freshCfg["refresh_token"] != "rt-new" { + t.Fatalf("persisted account lost refreshed tokens: %#v", freshCfg) + } +} + +// refreshOnPing115Provider 模拟真实 115 客户端在 Ping 内完成 token 轮转并 +// 通过持久化回调写回新 token 的行为。 +type refreshOnPing115Provider struct { + svc *StrmService + accountID string +} + +func (p *refreshOnPing115Provider) Type() string { return model.StrmProvider115 } + +func (p *refreshOnPing115Provider) Ping(context.Context) error { + p.svc.persist115Tokens(p.accountID, "at-new", "rt-new") + return nil +} + +func (p *refreshOnPing115Provider) List(context.Context, string) ([]cloud.FileEntry, error) { + return nil, nil +} + +func (p *refreshOnPing115Provider) Resolve(context.Context, string) (*cloud.DirectLink, error) { + return nil, nil +} + func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) { ctx := context.Background() db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{}) diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index 1502693..febf3f6 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -449,19 +449,37 @@ func (s *StrmService) TestStrmAccount(ctx context.Context, id string) *model.Str return nil } now := time.Now() - acct.LastTestAt = &now + result := "" + ok := false provider, err := s.providerFor(ctx, acct) if err != nil { - acct.LastTestResult = err.Error() - acct.LastTestOK = false + result = err.Error() } else if err := provider.Ping(ctx); err != nil { - acct.LastTestResult = err.Error() - acct.LastTestOK = false + result = err.Error() } else { - acct.LastTestResult = "ok" - acct.LastTestOK = true + result = "ok" + ok = true } - _ = s.repo.StrmAccount.Update(ctx, acct) + // Ping 期间 115 客户端可能刷新 access/refresh token,并通过 + // OnTokenRefreshed 持久化新配置。这里只写测试结果字段,不能把请求开始时 + // 读取的旧 acct.Config 整包写回,否则会把刚轮转的 token 覆盖失效。 + updateErr := s.repo.StrmAccount.UpdateTestResult(ctx, id, now, result, ok) + if updateErr != nil && s.log != nil { + s.log.Warn("update strm account test result failed", zap.String("account_id", id), zap.Error(updateErr)) + } + // 重新读取,确保返回给前端的账号配置已经是 Ping 期间刷新后的版本。 + if fresh, err := s.repo.StrmAccount.FindByID(ctx, id); err == nil && fresh != nil { + if updateErr != nil { + // 写库失败时仍让本次响应展示刚完成测试的结果。 + fresh.LastTestAt = &now + fresh.LastTestResult = result + fresh.LastTestOK = ok + } + return fresh + } + acct.LastTestAt = &now + acct.LastTestResult = result + acct.LastTestOK = ok return acct }