Fix PikPak captcha recovery

This commit is contained in:
nianzhibai
2026-05-29 14:49:47 +08:00
parent f5c20f9594
commit c146ad50ed
4 changed files with 297 additions and 10 deletions
+3 -4
View File
@@ -199,9 +199,8 @@ func (d *Driver) refreshCaptchaToken(ctx context.Context, action string, meta ma
// refreshCaptchaTokenOnce 调 /v1/shield/captcha/init 申请新 captcha token。
//
// 如果 retry=true 且服务端返回 4002captcha_token expired,意味着 body 里
// 携带的 d.captchaToken 已经过期),就清空缓存的 captcha_token 后再调一次;
// 这次 body 里 captcha_token 为空,服务端会直接发一个新的。这覆盖
// 如果 retry=true 且服务端返回 captcha 失效错误(4002 或 9),就清空缓存的
// captcha_token 后再调一次;这次 body 里 captcha_token 为空,服务端会直接发一个新的。这覆盖
// driver 重启后 Init() 用持久化的旧 captcha_token 调 captcha init 失败的
// 场景。
func (d *Driver) refreshCaptchaTokenOnce(ctx context.Context, action string, meta map[string]string, retry bool) error {
@@ -230,7 +229,7 @@ func (d *Driver) refreshCaptchaTokenOnce(ctx context.Context, action string, met
return err
}
if e.isError() {
if retry && e.ErrorCode == 4002 && d.captchaToken != "" {
if retry && isCaptchaTokenRejectedCode(e.ErrorCode) && d.captchaToken != "" {
d.captchaToken = ""
return d.refreshCaptchaTokenOnce(ctx, action, meta, false)
}
@@ -96,6 +96,65 @@ func TestRefreshCaptchaTokenRecoversFrom4002(t *testing.T) {
}
}
// TestRefreshCaptchaTokenRecoversFrom9 覆盖 PikPak 返回 error_code=9
// captcha_invalid 的路径。这个错误和 4002 一样表示当前 captcha_token 已被拒绝;
// 重试 captcha/init 前必须先清空旧 token,否则服务端会继续拒绝。
func TestRefreshCaptchaTokenRecoversFrom9(t *testing.T) {
var calls int32
type bodyShape struct {
CaptchaToken string `json:"captcha_token"`
}
var (
firstBody bodyShape
secondBody bodyShape
)
mux := http.NewServeMux()
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
switch n {
case 1:
_ = json.NewDecoder(r.Body).Decode(&firstBody)
writeErrorJSON(w, `{
"error_code": 9,
"error": "captcha_invalid",
"error_description": "Verification code is invalid"
}`)
case 2:
_ = json.NewDecoder(r.Body).Decode(&secondBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"captcha_token": "fresh-captcha",
"expires_in": 300
}`))
default:
t.Errorf("unexpected captcha init call #%d", n)
}
})
server := httptest.NewServer(mux)
defer server.Close()
d := newTestDriver(t, server)
d.captchaToken = "expired-captcha"
if err := d.refreshCaptchaTokenAtLogin(context.Background(), "GET:/drive/v1/files", "user-1"); err != nil {
t.Fatalf("refreshCaptchaTokenAtLogin: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("captcha init called %d times, want 2", got)
}
if firstBody.CaptchaToken != "expired-captcha" {
t.Errorf("first body captcha_token = %q, want \"expired-captcha\"", firstBody.CaptchaToken)
}
if secondBody.CaptchaToken != "" {
t.Errorf("second body captcha_token = %q, want empty (cleared after error_code=9)", secondBody.CaptchaToken)
}
if d.captchaToken != "fresh-captcha" {
t.Errorf("d.captchaToken = %q, want \"fresh-captcha\"", d.captchaToken)
}
}
// TestRefreshCaptchaTokenDoesNotLoopOn4002WithEmptyToken 防止退化成无限重试:
// 如果调用方一开始 captchaToken 就是空,又遇上 4002,不应该再清空一次重试
// (清空后还是空,再发会拿到同样的错误),应该直接返回错误让上层处理。
@@ -121,6 +180,141 @@ func TestRefreshCaptchaTokenDoesNotLoopOn4002WithEmptyToken(t *testing.T) {
}
}
func TestInitWithRefreshTokenDoesNotSendPersistedCaptchaToken(t *testing.T) {
var captchaCalls int32
var captchaBody struct {
CaptchaToken string `json:"captcha_token"`
}
var persisted struct {
access, refresh, captcha string
calls int
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"sub": "user-1"
}`))
})
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&captchaCalls, 1)
_ = json.NewDecoder(r.Body).Decode(&captchaBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"captcha_token": "fresh-captcha",
"expires_in": 300
}`))
})
server := httptest.NewServer(mux)
defer server.Close()
d := newTestDriver(t, server)
d.captchaToken = "persisted-stale-captcha"
d.onTokenUpdate = func(access, refresh, captcha, deviceID string) {
persisted.access = access
persisted.refresh = refresh
persisted.captcha = captcha
persisted.calls++
}
if err := d.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
if got := atomic.LoadInt32(&captchaCalls); got != 1 {
t.Fatalf("captcha init calls = %d, want 1", got)
}
if captchaBody.CaptchaToken != "" {
t.Errorf("captcha init body captcha_token = %q, want empty", captchaBody.CaptchaToken)
}
if d.captchaToken != "fresh-captcha" {
t.Errorf("d.captchaToken = %q, want \"fresh-captcha\"", d.captchaToken)
}
if persisted.access != "fresh-access" || persisted.refresh != "fresh-refresh" || persisted.captcha != "fresh-captcha" {
t.Errorf("persisted tokens = (%q, %q, %q), want fresh values", persisted.access, persisted.refresh, persisted.captcha)
}
if persisted.calls < 2 {
t.Errorf("persist callback calls = %d, want at least 2 (clear stale + persist fresh)", persisted.calls)
}
}
func TestInitFallsBackToLoginWhenRefreshReturnsCaptchaInvalid(t *testing.T) {
var (
tokenCalls int32
captchaCalls int32
signinCalls int32
)
var signinBody struct {
CaptchaToken string `json:"captcha_token"`
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&tokenCalls, 1)
writeErrorJSON(w, `{
"error_code": 4002,
"error": "captcha_invalid",
"error_description": "Code(4002) - captcha_token expired"
}`)
})
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&captchaCalls, 1)
w.Header().Set("Content-Type", "application/json")
switch n {
case 1:
_, _ = w.Write([]byte(`{
"captcha_token": "login-captcha",
"expires_in": 300
}`))
case 2:
_, _ = w.Write([]byte(`{
"captcha_token": "files-captcha",
"expires_in": 300
}`))
default:
t.Errorf("unexpected captcha init call #%d", n)
}
})
mux.HandleFunc("/v1/auth/signin", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&signinCalls, 1)
_ = json.NewDecoder(r.Body).Decode(&signinBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "login-access",
"refresh_token": "login-refresh",
"sub": "user-1"
}`))
})
server := httptest.NewServer(mux)
defer server.Close()
d := newTestDriver(t, server)
d.captchaToken = "persisted-stale-captcha"
if err := d.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
t.Fatalf("token refresh calls = %d, want 1", got)
}
if got := atomic.LoadInt32(&signinCalls); got != 1 {
t.Fatalf("signin calls = %d, want 1", got)
}
if got := atomic.LoadInt32(&captchaCalls); got != 2 {
t.Fatalf("captcha init calls = %d, want 2 (login + post-login files action)", got)
}
if signinBody.CaptchaToken != "login-captcha" {
t.Errorf("signin captcha_token = %q, want \"login-captcha\"", signinBody.CaptchaToken)
}
if d.accessToken != "login-access" || d.refreshToken != "login-refresh" || d.captchaToken != "files-captcha" {
t.Errorf("driver tokens = (%q, %q, %q), want login/files tokens", d.accessToken, d.refreshToken, d.captchaToken)
}
}
// TestRequestOnceRecoversFrom4002OnAPICall 验证一个普通 API 调用收到 4002
// 时,requestOnce 会先清空 captchaToken、再走 captcha 刷新,最后用新 token
// 重试请求,最终成功返回。
@@ -196,6 +390,76 @@ func TestRequestOnceRecoversFrom4002OnAPICall(t *testing.T) {
}
}
// TestRequestOnceRecoversFrom9OnAPICall 验证普通 API 调用收到 error_code=9
// 时,会先清空旧 captchaToken,再刷新 captcha 并重试原请求。
func TestRequestOnceRecoversFrom9OnAPICall(t *testing.T) {
var (
filesCalls int32
captchaCalls int32
)
type capturedFiles struct {
captchaHeader string
}
var firstFiles, secondFiles capturedFiles
mux := http.NewServeMux()
mux.HandleFunc("/drive/v1/files", func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&filesCalls, 1)
switch n {
case 1:
firstFiles.captchaHeader = r.Header.Get("X-Captcha-Token")
writeErrorJSON(w, `{
"error_code": 9,
"error": "captcha_invalid",
"error_description": "Verification code is invalid"
}`)
case 2:
secondFiles.captchaHeader = r.Header.Get("X-Captcha-Token")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"files": [], "next_page_token": ""}`))
default:
t.Errorf("unexpected /drive/v1/files call #%d", n)
}
})
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&captchaCalls, 1)
var body struct {
CaptchaToken string `json:"captcha_token"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.CaptchaToken != "" {
t.Errorf("captcha init body captcha_token = %q, want empty (error_code=9 path should clear cache)", body.CaptchaToken)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"captcha_token": "fresh-captcha", "expires_in": 300}`))
})
server := httptest.NewServer(mux)
defer server.Close()
d := newTestDriver(t, server)
d.captchaToken = "expired-captcha"
if _, err := d.List(context.Background(), "any-parent"); err != nil {
t.Fatalf("List: %v", err)
}
if got := atomic.LoadInt32(&filesCalls); got != 2 {
t.Fatalf("/drive/v1/files calls = %d, want 2 (initial + retry)", got)
}
if got := atomic.LoadInt32(&captchaCalls); got != 1 {
t.Fatalf("captcha init calls = %d, want 1", got)
}
if firstFiles.captchaHeader != "expired-captcha" {
t.Errorf("first request X-Captcha-Token = %q, want \"expired-captcha\"", firstFiles.captchaHeader)
}
if secondFiles.captchaHeader != "fresh-captcha" {
t.Errorf("retry X-Captcha-Token = %q, want \"fresh-captcha\"", secondFiles.captchaHeader)
}
if d.captchaToken != "fresh-captcha" {
t.Errorf("d.captchaToken after recovery = %q, want \"fresh-captcha\"", d.captchaToken)
}
}
// TestRequestOnceDoesNotRetryTwiceOn4002 验证 4002 恢复路径只重试一次;
// 如果重试请求依然失败(哪怕是再来一个 4002),也不会再次进入恢复逻辑,
// 而是把错误返回出去,避免无限循环。
+25 -5
View File
@@ -121,9 +121,28 @@ func (d *Driver) ID() string { return d.id }
func (d *Driver) RootID() string { return d.rootID }
func (d *Driver) Init(ctx context.Context) error {
clearPersistedCaptcha := func() {
if d.captchaToken == "" {
return
}
d.captchaToken = ""
d.persistTokens()
}
if d.refreshToken != "" {
if err := d.refresh(ctx, d.refreshToken); err != nil {
return err
if !IsCaptchaError(err) || d.username == "" || d.password == "" {
return err
}
clearPersistedCaptcha()
if err := d.login(ctx); err != nil {
return fmt.Errorf("pikpak refresh captcha recovery login: %w", err)
}
} else {
// Persisted captcha tokens are short-lived. With a refresh token we can
// safely request a fresh captcha token after auth, and avoiding the
// stored value prevents known-stale tokens from poisoning startup.
clearPersistedCaptcha()
}
} else {
if err := d.login(ctx); err != nil {
@@ -408,14 +427,15 @@ func (d *Driver) requestOnce(ctx context.Context, url, method string, configure
// serialized. Once we hold the lock, if d.captchaToken has
// already moved past staleToken, another goroutine has refreshed
// it for us — we skip the refresh and just retry. Otherwise we
// clear the cached token (4002 means "the value in the body is
// expired"; sending it again will keep returning 4002) and ask
// /v1/shield/captcha/init for a fresh one.
// clear the cached token before asking /v1/shield/captcha/init
// for a fresh one. PikPak may report stale captcha as either
// 4002 or 9, and sending the rejected token into captcha init can
// keep returning captcha_invalid.
staleToken := d.captchaToken
d.captchaMu.Lock()
var refreshErr error
if d.captchaToken == staleToken {
if e.ErrorCode == 4002 {
if d.captchaToken != "" {
d.captchaToken = ""
}
refreshErr = d.refreshCaptchaTokenAtLogin(ctx, getAction(method, url), d.userID)
+5 -1
View File
@@ -59,6 +59,10 @@ func (e *errResp) Error() string {
return fmt.Sprintf("pikpak error_code=%d error=%s description=%s", e.ErrorCode, e.ErrorMsg, e.ErrorDescription)
}
func isCaptchaTokenRejectedCode(code int64) bool {
return code == 9 || code == 4002
}
// APIError is the public alias for the PikPak API error response. Callers
// outside this package (e.g. the spider91→PikPak migrator, tests) can either
// construct it for fakes or unwrap it via errors.As. Prefer IsCaptchaError
@@ -76,7 +80,7 @@ func IsCaptchaError(err error) bool {
}
var e *errResp
if errors.As(err, &e) {
return e != nil && (e.ErrorCode == 4002 || e.ErrorCode == 9)
return e != nil && isCaptchaTokenRejectedCode(e.ErrorCode)
}
return false
}