diff --git a/README.md b/README.md index 55f751b..7afa7dd 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ ## 功能特性 -- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive、Google Drive 和本地存储 -- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响 +- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive、Google Drive 和本地存储 +- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响 - **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片 - **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本 - **短视频模式** — 一键切换抖音风格,沉浸刷片 diff --git a/backend/README.md b/backend/README.md index 06a4d66..f8d96cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ 视频聚合站的 Go 后端。提供三件事: -1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / OneDrive / Google Drive / 本地存储) +1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / 光鸭网盘 / OneDrive / Google Drive / 本地存储) 2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成 3. REST API(前台)+ 管理后台 + 直链代理 4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力 @@ -20,6 +20,7 @@ internal/ p115/ 115(壳子 + SheltonZhu/115driver) pikpak/ PikPak(自己实现,参考 OpenList pikpak) wopan/ 联通网盘(壳子 + OpenListTeam/wopan-sdk-go) + guangyapan/ 光鸭网盘(参考 AList GuangYaPan) onedrive/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口) googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理) localstorage/ 本地目录扫描(服务器已有视频目录) @@ -108,6 +109,7 @@ go run ./cmd/server 后端 9192 | p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) | | pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) | | wopan | `access_token`、`refresh_token`,可选 `family_id` | +| guangyapan | 推荐后台扫码登录自动写入 `access_token`、`refresh_token`;也可手工填写 token;可选 `root_path` | | onedrive | `refresh_token` | | googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` | | localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos`) | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ae823b7..812f5b2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/video-site/backend/internal/config" "github.com/video-site/backend/internal/drives" "github.com/video-site/backend/internal/drives/googledrive" + "github.com/video-site/backend/internal/drives/guangyapan" "github.com/video-site/backend/internal/drives/localstorage" "github.com/video-site/backend/internal/drives/localupload" "github.com/video-site/backend/internal/drives/onedrive" @@ -355,10 +356,10 @@ type App struct { // 全站主题("dark" | "pink" | "sky"),从 DB 读 theme string // 显式指定的 spider91 上传目标 drive ID。 - // 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan drive。 + // 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan/guangyapan drive。 spider91UploadDriveID string - // spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)。 + // spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)。 spider91Migrator spider91MigrationRunner // nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。 @@ -401,8 +402,9 @@ type App struct { } type driveScanProgress struct { - Scanned int - Added int + Scanned int + Added int + CooldownUntil time.Time } type driveUploadProgress struct { @@ -479,7 +481,7 @@ func (a *App) loadTheme(ctx context.Context) { } // Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。 -// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan drive 时才迁移上传。 +// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan/guangyapan drive 时才迁移上传。 func (a *App) Spider91UploadDriveID() string { a.mu.Lock() explicit := a.spider91UploadDriveID @@ -496,7 +498,7 @@ func (a *App) Spider91UploadDriveID() string { // SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。 // 接受空字符串(本地保存不上传)。 -// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan 的 drive 会返回错误。 +// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan / guangyapan 的 drive 会返回错误。 func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error { driveID = strings.TrimSpace(driveID) if driveID != "" { @@ -505,7 +507,7 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro return fmt.Errorf("drive %q not found", driveID) } if !isSpider91UploadKind(d.Kind()) { - return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive or wopan can be spider91 upload target", driveID, d.Kind()) + return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive, wopan or guangyapan can be spider91 upload target", driveID, d.Kind()) } } a.mu.Lock() @@ -538,7 +540,7 @@ func formatOptionalRFC3339(t time.Time) string { // isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。 // 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。 func isSpider91UploadKind(kind string) bool { - return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan" + return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan" || kind == guangyapan.Kind } // loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。 @@ -595,17 +597,25 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses { a.transcodeMu.Unlock() out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)+len(transcodeWorkers)) + now := time.Now() for id, running := range scanningDrives { if !running { continue } progress := scanProgresses[id] + state := "scanning" + if progress.CooldownUntil.After(now) { + state = "cooling" + } status := out[id] status.Scan = api.GenerationStatus{ - State: "scanning", + State: state, ScannedCount: progress.Scanned, AddedCount: progress.Added, } + if !progress.CooldownUntil.IsZero() { + status.Scan.CooldownUntil = progress.CooldownUntil.Format(time.RFC3339) + } out[id] = status } for id, worker := range previewWorkers { @@ -961,6 +971,33 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error { _ = a.cat.UpsertDrive(ctx, d) }, }) + case guangyapan.Kind: + drv = guangyapan.New(guangyapan.Config{ + ID: d.ID, + RootID: d.RootID, + RootPath: d.Credentials["root_path"], + PhoneNumber: d.Credentials["phone_number"], + CaptchaToken: d.Credentials["captcha_token"], + SendCode: parseBoolDefault(strings.TrimSpace(d.Credentials["send_code"]), false), + VerifyCode: d.Credentials["verify_code"], + VerificationID: d.Credentials["verification_id"], + AccessToken: d.Credentials["access_token"], + RefreshToken: d.Credentials["refresh_token"], + ClientID: d.Credentials["client_id"], + DeviceID: d.Credentials["device_id"], + PageSize: parseIntDefault(strings.TrimSpace(d.Credentials["page_size"]), 100), + OrderBy: parseIntDefault(strings.TrimSpace(d.Credentials["order_by"]), 3), + SortType: parseIntDefault(strings.TrimSpace(d.Credentials["sort_type"]), 1), + OnCredentialsUpdate: func(updated map[string]string) { + if d.Credentials == nil { + d.Credentials = make(map[string]string) + } + for k, v := range updated { + d.Credentials[k] = v + } + _ = a.cat.UpsertDrive(ctx, d) + }, + }) case "onedrive": drv = onedrive.New(onedrive.Config{ ID: d.ID, @@ -1081,7 +1118,7 @@ func generationCooldownForDrive(drv drives.Drive) time.Duration { return 0 } switch strings.ToLower(drv.Kind()) { - case "wopan": + case "wopan", "guangyapan": return 10 * time.Minute } return 0 @@ -1107,7 +1144,7 @@ func fingerprintConfigForDrive(drv drives.Drive) fingerprint.Config { return cfg } switch strings.ToLower(drv.Kind()) { - case "p115", "p123", "onedrive", "wopan": + case "p115", "p123", "onedrive", "wopan", "guangyapan": cfg.RateLimitCooldown = 10 * time.Minute case "pikpak": cfg.RateLimitCooldown = 5 * time.Minute @@ -1439,11 +1476,77 @@ func (a *App) updateDriveScanProgress(driveID string, scanned, added int) { if a.scanProgress == nil { a.scanProgress = make(map[string]driveScanProgress) } - a.scanProgress[driveID] = driveScanProgress{Scanned: scanned, Added: added} + progress := a.scanProgress[driveID] + progress.Scanned = scanned + progress.Added = added + a.scanProgress[driveID] = progress } a.scanQueueMu.Unlock() } +func (a *App) updateDriveScanCooldown(driveID string, until time.Time) { + driveID = strings.TrimSpace(driveID) + if driveID == "" { + return + } + a.scanQueueMu.Lock() + if a.scanQueued[driveID] { + if a.scanProgress == nil { + a.scanProgress = make(map[string]driveScanProgress) + } + progress := a.scanProgress[driveID] + progress.CooldownUntil = until + a.scanProgress[driveID] = progress + } + a.scanQueueMu.Unlock() +} + +func (a *App) pauseDriveScanForRateLimit(ctx context.Context, driveID string, drv drives.Drive, err error) bool { + wait, ok := drives.RateLimitRetryAfter(err) + if !ok { + return false + } + if wait <= 0 { + wait = scanCooldownForDrive(drv) + } + if wait <= 0 { + wait = 5 * time.Minute + } + until := time.Now().Add(wait) + a.updateDriveScanCooldown(driveID, until) + log.Printf("[scan] drive=%s rate limited; cooling until=%s wait=%s: %v", driveID, until.Format(time.RFC3339), wait, err) + if !sleepDriveScanCooldown(ctx, wait) { + log.Printf("[scan] drive=%s cooldown canceled: %v", driveID, ctx.Err()) + } + return true +} + +func scanCooldownForDrive(drv drives.Drive) time.Duration { + if drv == nil { + return 5 * time.Minute + } + switch strings.ToLower(drv.Kind()) { + case "guangyapan": + return 10 * time.Minute + default: + return 5 * time.Minute + } +} + +func sleepDriveScanCooldown(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + func (a *App) driveHasActiveWork(driveID string) bool { driveID = strings.TrimSpace(driveID) if driveID == "" { @@ -1908,6 +2011,8 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) { if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { log.Printf("[scan] drive=%s canceled: %v", driveID, err) + } else if a.pauseDriveScanForRateLimit(ctx, driveID, drv, err) { + return } else { log.Printf("[scan] drive=%s error: %v", driveID, err) } @@ -3365,3 +3470,14 @@ func parseBoolDefault(raw string, def bool) bool { } return v } + +func parseIntDefault(raw string, def int) int { + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil { + return def + } + return v +} diff --git a/backend/cmd/server/main_spider91_test.go b/backend/cmd/server/main_spider91_test.go index ddeaaf7..8cfcd6b 100644 --- a/backend/cmd/server/main_spider91_test.go +++ b/backend/cmd/server/main_spider91_test.go @@ -41,6 +41,7 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) { reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"}) reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"}) reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"}) + reg.Set("guangyapan-one", &spider91UploadTargetFakeDrive{id: "guangyapan-one", kind: "guangyapan"}) app := &App{registry: reg} if got := app.Spider91UploadDriveID(); got != "" { @@ -67,6 +68,11 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) { t.Fatalf("explicit wopan upload target = %q, want wopan-one", got) } + app.spider91UploadDriveID = "guangyapan-one" + if got := app.Spider91UploadDriveID(); got != "guangyapan-one" { + t.Fatalf("explicit guangyapan upload target = %q, want guangyapan-one", got) + } + app.spider91UploadDriveID = "missing" if got := app.Spider91UploadDriveID(); got != "" { t.Fatalf("missing upload target = %q, want empty", got) diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index 1ebbc4e..3969c0a 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -391,6 +391,37 @@ func TestDriveGenerationStatusIncludesScanState(t *testing.T) { } } +func TestDriveGenerationStatusIncludesScanCooldown(t *testing.T) { + until := time.Now().Add(time.Hour).Round(time.Second) + app := &App{ + scanQueued: map[string]bool{"drive-id": true}, + scanProgress: map[string]driveScanProgress{ + "drive-id": {Scanned: 12, Added: 3, CooldownUntil: until}, + }, + } + + status := app.driveGenerationStatuses()["drive-id"].Scan + if status.State != "cooling" { + t.Fatalf("scan status = %#v, want cooling", status) + } + if status.CooldownUntil != until.Format(time.RFC3339) { + t.Fatalf("cooldown until = %q, want %q", status.CooldownUntil, until.Format(time.RFC3339)) + } +} + +func TestGuangYaPanGenerationCooldowns(t *testing.T) { + drv := &serverFakeKindDrive{id: "gy", kind: "guangyapan"} + if got := generationCooldownForDrive(drv); got != 10*time.Minute { + t.Fatalf("generation cooldown = %s, want 10m", got) + } + if got := fingerprintConfigForDrive(drv).RateLimitCooldown; got != 10*time.Minute { + t.Fatalf("fingerprint cooldown = %s, want 10m", got) + } + if got := scanCooldownForDrive(drv); got != 10*time.Minute { + t.Fatalf("scan cooldown = %s, want 10m", got) + } +} + func TestRunSpider91MigrationAfterManualCrawlRequiresConfiguredUploadTarget(t *testing.T) { ctx := context.Background() registry := proxy.NewRegistry() diff --git a/backend/config.example.yaml b/backend/config.example.yaml index c1bea7f..e6278ae 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -56,7 +56,7 @@ preview: width: 480 # 盘列表。上线后请通过管理后台添加,本文件可留空。 -# kind 支持 quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage。 +# kind 支持 quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage。 # OneDrive 示例: # - id: "my-onedrive" # kind: "onedrive" @@ -76,6 +76,17 @@ preview: # # use_online_api: "false" # # client_id: "..." # # client_secret: "..." +# 光鸭网盘示例: +# - id: "my-guangyapan" +# kind: "guangyapan" +# name: "我的光鸭网盘" +# # 留空表示光鸭网盘根目录;也可以填写光鸭目录 fileId +# root_id: "" +# params: +# # 推荐在后台使用扫码登录自动写入 access_token / refresh_token。 +# refresh_token: "..." +# # 可选:按路径解析扫描根目录,优先于 root_id +# # root_path: "影视/电影" # 本地存储示例: # - id: "local-media" # kind: "localstorage" diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 616ec07..cec70e2 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -21,6 +21,7 @@ import ( "github.com/video-site/backend/internal/auth" "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives/guangyapan" "github.com/video-site/backend/internal/drives/p123" "github.com/video-site/backend/internal/drives/scriptcrawler" "github.com/video-site/backend/internal/drives/spider91" @@ -48,24 +49,24 @@ type AdminServer struct { // LocalPreviewDir is the local directory that stores generated preview videos and thumbs. LocalPreviewDir string // Hooks:外层注入实际执行者 - OnDriveSaved func(driveID string) error - OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error) - OnDriveRemoved func(driveID string) - OnScanRequested func(driveID string) bool - OnStopDriveTasks func(driveID string) bool - OnStopAllTasks func() int - OnRegenPreview func(videoID string) - OnRegenAllPreviews func() - OnRegenFailedPreviews func(driveID string) - OnRegenFailedThumbnails func(driveID string) - OnRegenFailedFingerprints func(driveID string) + OnDriveSaved func(driveID string) error + OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error) + OnDriveRemoved func(driveID string) + OnScanRequested func(driveID string) bool + OnStopDriveTasks func(driveID string) bool + OnStopAllTasks func() int + OnRegenPreview func(videoID string) + OnRegenAllPreviews func() + OnRegenFailedPreviews func(driveID string) + OnRegenFailedThumbnails func(driveID string) + OnRegenFailedFingerprints func(driveID string) // OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。 // 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发; // 处理完候选列表后任务自然结束。 OnStartDriveTranscode func(driveID string) (bool, string) // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 - OnStopDriveTranscode func(driveID string) bool - OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) + OnStopDriveTranscode func(driveID string) bool + OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); @@ -74,7 +75,7 @@ type AdminServer struct { // Theme 读写("dark" | "pink" | "sky") GetTheme func() string SetTheme func(theme string) error - // Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘 上传目标 drive ID 读写 + // Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘/光鸭网盘 上传目标 drive ID 读写 GetSpider91UploadDriveID func() string SetSpider91UploadDriveID func(driveID string) error // OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 + @@ -94,6 +95,9 @@ type AdminServer struct { // 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。 WopanQRAPIBaseURL string WopanQRHTTPClient *http.Client + // 光鸭网盘扫码登录接口测试注入;生产留空走官方 account.guangyapan.com。 + GuangYaPanAccountBaseURL string + GuangYaPanHTTPClient *http.Client } const ( @@ -167,6 +171,8 @@ func (a *AdminServer) Register(r chi.Router) { r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus) r.Post("/drives/wopan/qr", a.handleWopanQRStart) r.Get("/drives/wopan/qr/{uuid}", a.handleWopanQRStatus) + r.Post("/drives/guangyapan/qr", a.handleGuangYaPanQRStart) + r.Get("/drives/guangyapan/qr/status", a.handleGuangYaPanQRStatus) r.Delete("/drives/{id}", a.handleDeleteDrive) r.Post("/drives/{id}/rescan", a.handleRescan) r.Post("/drives/{id}/tasks/stop", a.handleStopDriveTasks) @@ -471,11 +477,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { SkipDirIDs []string `json:"skipDirIds"` // LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。 // 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。 - Spider91Proxy string `json:"spider91Proxy,omitempty"` - LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` - GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` + Spider91Proxy string `json:"spider91Proxy,omitempty"` + LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` + GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` // STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。 - STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` + STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` @@ -620,9 +626,9 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) return } body.Credentials = credentials - } else if body.Kind == "googledrive" || body.Kind == "localstorage" { - // 按键合并、空值沿用旧值:localstorage 编辑表单里 path 留空表示不改, - // 但 strm_allow_outside_root 开关每次都会带值,必须逐键合并而不是整体替换。 + } else if body.Kind == "googledrive" || body.Kind == "localstorage" || body.Kind == "guangyapan" { + // 按键合并、空值沿用旧值:这些网盘的编辑表单允许只改某几个字段, + // 其它 token / 路径 / 开关字段应保留旧值。 body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials) } else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 { body.Credentials = existing.Credentials @@ -931,7 +937,7 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st return fmt.Errorf("上传目标网盘 %q 不存在", driveID) } if !isCrawlerUploadTargetKind(d.Kind) { - return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘", driveID, d.Kind) + return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘、光鸭网盘", driveID, d.Kind) } return nil } @@ -1395,7 +1401,7 @@ func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool { } // mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值, -// 空值/缺失的键沿用旧值。googledrive 和 localstorage 的编辑表单都依赖 +// 空值/缺失的键沿用旧值。googledrive、localstorage 和 guangyapan 的编辑表单都依赖 // 这个语义(留空 = 不修改)。 func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { merged := map[string]string{} @@ -1696,6 +1702,38 @@ func (a *AdminServer) handleWopanQRStatus(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, status) } +func (a *AdminServer) guangYaPanQRClient() *guangyapan.QRClient { + return guangyapan.NewQRClient(guangyapan.QRConfig{ + AccountBaseURL: a.GuangYaPanAccountBaseURL, + HTTPClient: a.GuangYaPanHTTPClient, + }) +} + +func (a *AdminServer) handleGuangYaPanQRStart(w http.ResponseWriter, r *http.Request) { + session, err := a.guangYaPanQRClient().Generate(r.Context()) + if err != nil { + writeErr(w, http.StatusBadGateway, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, session) +} + +func (a *AdminServer) handleGuangYaPanQRStatus(w http.ResponseWriter, r *http.Request) { + deviceCode := r.URL.Query().Get("deviceCode") + if strings.TrimSpace(deviceCode) == "" { + http.Error(w, "deviceCode is required", http.StatusBadRequest) + return + } + status, err := a.guangYaPanQRClient().Poll(r.Context(), deviceCode) + if err != nil { + writeErr(w, http.StatusBadGateway, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, status) +} + // handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。 // 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。 // 流水线已在跑或已排队时,Runner 会拒绝重复触发。 diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index 4583588..2ef7e28 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -1704,6 +1704,94 @@ func TestHandleWopanQRStatus(t *testing.T) { } } +func TestHandleGuangYaPanQRStart(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/auth/device/code" { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["scope"] != "user" { + t.Fatalf("scope = %#v, want user", body["scope"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "device-1", + "verification_uri_complete": "https://account.guangyapan.example/device?code=abc", + "interval": 5, + "expires_in": 300, + }) + })) + t.Cleanup(upstream.Close) + + req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/guangyapan/qr", nil) + rr := httptest.NewRecorder() + (&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStart(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got struct { + DeviceCode string `json:"deviceCode"` + QRCodeURL string `json:"qrCodeUrl"` + QRImageDataURL string `json:"qrImageDataUrl"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.DeviceCode != "device-1" || got.QRCodeURL != "https://account.guangyapan.example/device?code=abc" { + t.Fatalf("response = %#v", got) + } + if !strings.HasPrefix(got.QRImageDataURL, "data:image/png;base64,") { + t.Fatalf("qr image = %q", got.QRImageDataURL) + } +} + +func TestHandleGuangYaPanQRStatus(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/auth/token" { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["device_code"] != "device-1" { + t.Fatalf("device_code = %#v, want device-1", body["device_code"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-1", + "refresh_token": "refresh-1", + "token_type": "Bearer", + }) + })) + t.Cleanup(upstream.Close) + + req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/guangyapan/qr/status?deviceCode=device-1", nil) + rr := httptest.NewRecorder() + (&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStatus(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got struct { + State string `json:"state"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.State != "success" || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" { + t.Fatalf("response = %#v", got) + } +} + func TestHandleTestCrawlerScriptRunsImportedScript(t *testing.T) { if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 is required for crawler script dry-run") diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 6b1c227..a5d5b40 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -1068,6 +1068,8 @@ func driveKindLabel(kind string) string { return "PikPak" case "wopan": return "联通网盘" + case "guangyapan": + return "光鸭网盘" case "onedrive": return "OneDrive" case "googledrive": diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index 00dd549..e2a3dfe 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -1992,7 +1992,7 @@ func normalizeDriveRootFields(d *Drive) { func normalizeDriveRootID(kind, rootID string) string { rootID = strings.TrimSpace(rootID) switch kind { - case "pikpak": + case "pikpak", "guangyapan": if rootID == "0" { return "" } diff --git a/backend/internal/catalog/drives_test.go b/backend/internal/catalog/drives_test.go index 57535e5..67be673 100644 --- a/backend/internal/catalog/drives_test.go +++ b/backend/internal/catalog/drives_test.go @@ -58,6 +58,7 @@ func TestUpsertDriveDefaultsRootIDByKind(t *testing.T) { }{ {id: "p115", kind: "p115", want: "0"}, {id: "pikpak", kind: "pikpak", want: ""}, + {id: "guangyapan", kind: "guangyapan", want: ""}, {id: "onedrive", kind: "onedrive", want: "root"}, {id: "googledrive", kind: "googledrive", want: "root"}, {id: "localstorage", kind: "localstorage", want: "/"}, diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index bc4ce69..540dd0b 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -114,7 +114,7 @@ CREATE INDEX IF NOT EXISTS idx_crawler_seen_sources_drive -- 网盘账户 CREATE TABLE IF NOT EXISTS drives ( id TEXT PRIMARY KEY, - kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage / spider91 + kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage / spider91 name TEXT NOT NULL, root_id TEXT NOT NULL DEFAULT '0', scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index 534861d..c0557e6 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -124,6 +124,9 @@ CREATE TABLE IF NOT EXISTS deleted_videos ( if err := c.reconcileThumbnailStatusOnce(ctx); err != nil { return err } + if err := c.requeueSkippedPreviews(ctx); err != nil { + return err + } if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil { return err } @@ -296,6 +299,24 @@ UPDATE videos return nil } +func (c *Catalog) requeueSkippedPreviews(ctx context.Context) error { + res, err := c.db.ExecContext(ctx, ` +UPDATE videos + SET preview_file_id = '', + preview_local = '', + preview_status = 'pending', + updated_at = ? + WHERE COALESCE(preview_status, 'pending') = 'skipped' +`, time.Now().UnixMilli()) + if err != nil { + return fmt.Errorf("requeue skipped previews: %w", err) + } + if affected, err := res.RowsAffected(); err == nil && affected > 0 { + log.Printf("[catalog] requeued %d skipped preview(s) for generation", affected) + } + return nil +} + func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error { // 把 OneDrive 过期的 mediap.svc.ms thumb URL 清空,让 worker 重新抽帧生成本地封面。 // 同步把 thumbnail_status 重置为 'pending':清空后 url 是空的,本应进 worker 重做, diff --git a/backend/internal/catalog/tags_test.go b/backend/internal/catalog/tags_test.go index 10210ad..0228d85 100644 --- a/backend/internal/catalog/tags_test.go +++ b/backend/internal/catalog/tags_test.go @@ -1539,6 +1539,70 @@ func TestReconcileThumbnailStatusOnce(t *testing.T) { } } +func TestRequeueSkippedPreviews(t *testing.T) { + ctx := context.Background() + cat, err := Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { cat.Close() }) + + now := time.Now() + cases := []struct { + id string + status string + local string + fileID string + wantStatus string + wantLocal string + wantFileID string + }{ + {"preview-skipped", "skipped", "/tmp/old-preview.mp4", "old-preview-file", "pending", "", ""}, + {"preview-ready", "ready", "/tmp/ready-preview.mp4", "ready-preview-file", "ready", "/tmp/ready-preview.mp4", "ready-preview-file"}, + {"preview-failed", "failed", "/tmp/failed-preview.mp4", "failed-preview-file", "failed", "/tmp/failed-preview.mp4", "failed-preview-file"}, + } + for _, c := range cases { + if err := cat.UpsertVideo(ctx, &Video{ + ID: c.id, DriveID: "d", FileID: "source-" + c.id, Title: c.id, + PreviewStatus: c.status, PreviewLocal: c.local, PreviewFileID: c.fileID, + PublishedAt: now, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed %s: %v", c.id, err) + } + } + + if err := cat.requeueSkippedPreviews(ctx); err != nil { + t.Fatalf("requeue skipped previews: %v", err) + } + if err := cat.requeueSkippedPreviews(ctx); err != nil { + t.Fatalf("second requeue skipped previews: %v", err) + } + + for _, c := range cases { + got, err := cat.GetVideo(ctx, c.id) + if err != nil { + t.Fatalf("get %s: %v", c.id, err) + } + if got.PreviewStatus != c.wantStatus { + t.Errorf("%s: preview status = %q, want %q", c.id, got.PreviewStatus, c.wantStatus) + } + if got.PreviewLocal != c.wantLocal { + t.Errorf("%s: preview local = %q, want %q", c.id, got.PreviewLocal, c.wantLocal) + } + if got.PreviewFileID != c.wantFileID { + t.Errorf("%s: preview file id = %q, want %q", c.id, got.PreviewFileID, c.wantFileID) + } + } + + pending, err := cat.ListVideosByPreviewStatus(ctx, "d", "pending", 0) + if err != nil { + t.Fatalf("list pending previews: %v", err) + } + if len(pending) != 1 || pending[0].ID != "preview-skipped" { + t.Fatalf("pending previews = %#v, want only preview-skipped", pending) + } +} + // TestUpsertVideoSyncsThumbnailStatus 验证 scanner 创建/补回视频时 // thumbnail_status 跟随 thumbnail_url 自动设。这是历史 bug 的修复回归测试 —— // 之前 UpsertVideo 的 SQL 不带 thumbnail_status 列,所有新视频都依赖 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 74a4295..cd0a2f0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -207,7 +207,7 @@ type Nightly struct { // 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 type Drive struct { ID string `yaml:"id"` - Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage + Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage Name string `yaml:"name"` RootID string `yaml:"root_id"` Params map[string]string `yaml:"params,omitempty"` diff --git a/backend/internal/drives/googledrive/driver.go b/backend/internal/drives/googledrive/driver.go index 1454dfc..2ab1baa 100644 --- a/backend/internal/drives/googledrive/driver.go +++ b/backend/internal/drives/googledrive/driver.go @@ -647,7 +647,7 @@ func isGoogleUploadHTTPRateLimit(status int, header http.Header, body []byte, ap if isGoogleRateLimit(nil, apiErr) { return true } - return googleLimitText(string(body)) + return false } func googleUploadRateLimitError(status int, header http.Header, body []byte, message string) error { @@ -910,7 +910,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool { return true } for _, e := range body.Errors { - if googleLimitReason(e.Reason) || googleLimitText(e.Message) { + if googleLimitReason(e.Reason) { return true } domain := compactGoogleLimitText(e.Domain) @@ -918,7 +918,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool { return true } } - return googleLimitText(body.Message) + return false } func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool { @@ -930,9 +930,7 @@ func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool { return true } } - return googleLimitText(out.Text) || - googleLimitText(out.Error) || - googleLimitText(out.ErrorDescription) + return googleLimitReason(out.Error) } func googleLimitReason(reason string) bool { @@ -953,31 +951,6 @@ func googleLimitReason(reason string) bool { } } -func googleLimitText(text string) bool { - text = strings.ToLower(strings.TrimSpace(text)) - if text == "" { - return false - } - compact := compactGoogleLimitText(text) - if strings.Contains(compact, "ratelimitexceeded") || - strings.Contains(compact, "userratelimitexceeded") || - strings.Contains(compact, "dailylimitexceeded") || - strings.Contains(compact, "downloadquotaexceeded") || - strings.Contains(compact, "sharingratelimitexceeded") || - strings.Contains(compact, "quotaexceeded") || - strings.Contains(compact, "toomanyrequests") { - return true - } - return strings.Contains(text, "rate limit") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "quota exceeded") || - strings.Contains(text, "download quota") || - strings.Contains(text, "sharing rate") || - strings.Contains(text, "daily limit") || - strings.Contains(text, "user rate") || - strings.Contains(text, "usage limit") -} - func compactGoogleLimitText(text string) string { text = strings.ToLower(strings.TrimSpace(text)) replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "") diff --git a/backend/internal/drives/guangyapan/driver.go b/backend/internal/drives/guangyapan/driver.go new file mode 100644 index 0000000..511872a --- /dev/null +++ b/backend/internal/drives/guangyapan/driver.go @@ -0,0 +1,1130 @@ +package guangyapan + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/go-resty/resty/v2" + + "github.com/video-site/backend/internal/drives" +) + +const ( + Kind = "guangyapan" + + defaultAccountBaseURL = "https://account.guangyapan.com" + defaultAPIBaseURL = "https://api.guangyapan.com" + defaultClientID = "aMe-8VSlkrbQXpUR" + defaultPageSize = 100 +) + +type Driver struct { + id string + rootID string + rootPath string + phoneNumber string + captchaToken string + sendCode bool + verifyCode string + verificationID string + accessToken string + refreshToken string + clientID string + deviceID string + pageSize int + orderBy int + sortType int + accountBaseURL string + apiBaseURL string + accountClient *resty.Client + apiClient *resty.Client + + onCredentialsUpdate func(map[string]string) + + fileMu sync.RWMutex + files map[string]drives.Entry +} + +type Config struct { + ID string + RootID string + RootPath string + PhoneNumber string + CaptchaToken string + SendCode bool + VerifyCode string + VerificationID string + AccessToken string + RefreshToken string + ClientID string + DeviceID string + PageSize int + OrderBy int + SortType int + AccountBaseURL string + APIBaseURL string + + OnCredentialsUpdate func(map[string]string) +} + +func New(c Config) *Driver { + rootID := strings.TrimSpace(c.RootID) + if rootID == "0" { + rootID = "" + } + clientID := strings.TrimSpace(c.ClientID) + if clientID == "" { + clientID = defaultClientID + } + deviceID := normalizeDeviceID(c.DeviceID) + if deviceID == "" { + deviceID = randomDeviceID() + } + pageSize := c.PageSize + if pageSize <= 0 { + pageSize = defaultPageSize + } + orderBy := c.OrderBy + if orderBy < 0 { + orderBy = 3 + } + sortType := c.SortType + if sortType != 0 && sortType != 1 { + sortType = 1 + } + accountBaseURL := strings.TrimRight(strings.TrimSpace(c.AccountBaseURL), "/") + if accountBaseURL == "" { + accountBaseURL = defaultAccountBaseURL + } + apiBaseURL := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/") + if apiBaseURL == "" { + apiBaseURL = defaultAPIBaseURL + } + d := &Driver{ + id: strings.TrimSpace(c.ID), + rootID: rootID, + rootPath: strings.TrimSpace(c.RootPath), + phoneNumber: strings.TrimSpace(c.PhoneNumber), + captchaToken: strings.TrimSpace(c.CaptchaToken), + sendCode: c.SendCode, + verifyCode: strings.TrimSpace(c.VerifyCode), + verificationID: strings.TrimSpace(c.VerificationID), + accessToken: normalizeAccessToken(c.AccessToken), + refreshToken: strings.TrimSpace(c.RefreshToken), + clientID: clientID, + deviceID: deviceID, + pageSize: pageSize, + orderBy: orderBy, + sortType: sortType, + accountBaseURL: accountBaseURL, + apiBaseURL: apiBaseURL, + onCredentialsUpdate: c.OnCredentialsUpdate, + files: make(map[string]drives.Entry), + } + d.accountClient = d.newAccountClient() + d.apiClient = d.newAPIClient() + return d +} + +func (d *Driver) Kind() string { return Kind } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +func (d *Driver) Init(ctx context.Context) error { + d.saveCredentials() + + if d.accessToken != "" { + if err := d.validateToken(ctx); err == nil { + return d.prepareRootFolder(ctx) + } + d.accessToken = "" + } + if d.refreshToken != "" { + if err := d.refresh(ctx); err == nil { + if err := d.validateToken(ctx); err == nil { + return d.prepareRootFolder(ctx) + } + } + } + if d.phoneNumber != "" && d.verifyCode != "" { + if err := d.loginBySMSCode(ctx); err != nil { + return err + } + if err := d.validateToken(ctx); err != nil { + return err + } + return d.prepareRootFolder(ctx) + } + if d.phoneNumber != "" && d.sendCode { + if err := d.prepareSMSCode(ctx); err != nil { + return err + } + return errors.New("光鸭验证码已发送,请填写 verify_code 后再次保存") + } + return errors.New("guangyapan init: provide access_token / refresh_token, or use QR login in admin") +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + return d.list(ctx, dirID, true) +} + +func (d *Driver) list(ctx context.Context, dirID string, applyDefaultRoot bool) ([]drives.Entry, error) { + if applyDefaultRoot && strings.TrimSpace(dirID) == "" { + dirID = d.rootID + } + if err := d.ensureAccessToken(ctx); err != nil { + return nil, err + } + out := make([]drives.Entry, 0, d.pageSize) + for pageNo := 0; ; pageNo++ { + var resp listResp + if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", map[string]any{ + "parentId": dirID, + "page": pageNo, + "pageSize": d.pageSize, + "orderBy": d.orderBy, + "sortType": d.sortType, + "fileTypes": []int{}, + }, &resp); err != nil { + return nil, err + } + for _, item := range resp.Data.List { + entry := fileItemToEntry(item, dirID) + out = append(out, entry) + d.remember(entry) + } + if len(resp.Data.List) < d.pageSize { + return out, nil + } + if resp.Data.Total > 0 && len(out) >= resp.Data.Total { + return out, nil + } + } +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + d.fileMu.RLock() + entry, ok := d.files[fileID] + d.fileMu.RUnlock() + if !ok { + return nil, drives.ErrNotSupported + } + return &entry, nil +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + if strings.TrimSpace(fileID) == "" { + return nil, errors.New("guangyapan stream: empty file id") + } + if err := d.ensureAccessToken(ctx); err != nil { + return nil, err + } + var resp downloadResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{ + "fileId": fileID, + }, &resp); err != nil { + return nil, err + } + u := strings.TrimSpace(resp.Data.SignedURL) + if u == "" { + u = strings.TrimSpace(resp.Data.DownloadURL) + } + if u == "" { + return nil, errors.New("guangyapan stream: empty download url") + } + return &drives.StreamLink{URL: u, Headers: http.Header{}, Expires: time.Now().Add(10 * time.Minute)}, nil +} + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + if err := d.ensureAccessToken(ctx); err != nil { + return "", err + } + parentID = strings.TrimSpace(parentID) + if parentID == "" { + parentID = d.rootID + } + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("guangyapan upload: empty file name") + } + if r == nil { + return "", errors.New("guangyapan upload: nil reader") + } + if size < 0 { + return "", errors.New("guangyapan upload: invalid file size") + } + token, code, err := d.getUploadToken(ctx, parentID, name, size) + if err != nil { + return "", err + } + taskID := strings.TrimSpace(token.TaskID) + if code == 156 { + return d.waitUploadTaskInfo(ctx, taskID) + } + if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" { + return "", errors.New("guangyapan upload: incomplete upload token") + } + + client, err := oss.New(normalizeOSSEndpoint(token.EndPoint, token.BucketName), token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken)) + if err != nil { + return "", fmt.Errorf("guangyapan upload: create oss client: %w", err) + } + bucket, err := client.Bucket(token.BucketName) + if err != nil { + return "", fmt.Errorf("guangyapan upload: create oss bucket: %w", err) + } + if size == 0 { + if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil { + return "", err + } + } else if err := multipartUploadToOSS(ctx, bucket, token.ObjectPath, r, size); err != nil { + return "", err + } + fileID, err := d.waitUploadTaskInfo(ctx, taskID) + if err != nil { + return "", err + } + d.remember(drives.Entry{ID: fileID, ParentID: parentID, Name: name, Size: size}) + return fileID, nil +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + if err := d.ensureAccessToken(ctx); err != nil { + return "", err + } + clean := strings.Trim(strings.ReplaceAll(strings.TrimSpace(pathFromRoot), "\\", "/"), "/") + if clean == "" { + return d.rootID, nil + } + parentID := d.rootID + for _, name := range strings.Split(clean, "/") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + childID, err := d.findChildFolderID(ctx, parentID, name) + if err == nil { + parentID = childID + continue + } + created, err := d.createDir(ctx, parentID, name) + if err != nil { + return "", err + } + parentID = created + } + return parentID, nil +} + +func (d *Driver) Remove(ctx context.Context, fileID string) error { + if err := d.ensureAccessToken(ctx); err != nil { + return err + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return errors.New("guangyapan remove: empty file id") + } + var resp deleteResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{ + "fileIds": []string{fileID}, + }, &resp); err != nil { + return err + } + if !successMessage(resp.Msg) { + return fmt.Errorf("guangyapan remove: %s", strings.TrimSpace(resp.Msg)) + } + if taskID := strings.TrimSpace(resp.Data.TaskID); taskID != "" { + return d.waitTaskDone(ctx, taskID) + } + return nil +} + +func (d *Driver) Rename(ctx context.Context, fileID, newName string) error { + if err := d.ensureAccessToken(ctx); err != nil { + return err + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return errors.New("guangyapan rename: empty file id") + } + newName = strings.TrimSpace(newName) + if newName == "" { + return errors.New("guangyapan rename: empty new name") + } + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{ + "fileId": fileID, + "newName": newName, + }, &resp); err != nil { + return err + } + if !successMessage(resp.Msg) { + return fmt.Errorf("guangyapan rename: %s", strings.TrimSpace(resp.Msg)) + } + return nil +} + +func (d *Driver) prepareRootFolder(ctx context.Context) error { + if d.rootPath == "" { + return nil + } + rootID, err := d.resolveFolderPath(ctx, d.rootPath) + if err != nil { + return err + } + d.rootID = rootID + return nil +} + +func (d *Driver) resolveFolderPath(ctx context.Context, rootPath string) (string, error) { + clean := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/") + if clean == "" { + return "", nil + } + parentID := "" + for _, name := range strings.Split(clean, "/") { + if name == "" { + continue + } + childID, err := d.findChildFolderID(ctx, parentID, name) + if err != nil { + return "", err + } + parentID = childID + } + return parentID, nil +} + +func (d *Driver) findChildFolderID(ctx context.Context, parentID, name string) (string, error) { + entries, err := d.list(ctx, parentID, false) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir && entry.Name == name { + return entry.ID, nil + } + } + if parentID == "" { + return "", fmt.Errorf("guangyapan folder %q not found under /", name) + } + return "", fmt.Errorf("guangyapan folder %q not found under parent %s", name, parentID) +} + +func (d *Driver) createDir(ctx context.Context, parentID, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("guangyapan create dir: empty name") + } + var resp createDirResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{ + "parentId": parentID, + "dirName": name, + }, &resp); err != nil { + return "", err + } + if !successMessage(resp.Msg) { + return "", fmt.Errorf("guangyapan create dir: %s", strings.TrimSpace(resp.Msg)) + } + id := strings.TrimSpace(resp.Data.FileID) + if id == "" { + return "", errors.New("guangyapan create dir: empty file id") + } + d.remember(drives.Entry{ID: id, ParentID: parentID, Name: name, IsDir: true}) + return id, nil +} + +func (d *Driver) ensureAccessToken(ctx context.Context) error { + if strings.TrimSpace(d.accessToken) != "" { + return nil + } + if strings.TrimSpace(d.refreshToken) != "" { + return d.refresh(ctx) + } + if d.phoneNumber != "" && d.verifyCode != "" { + return d.loginBySMSCode(ctx) + } + return errors.New("guangyapan auth: access token is empty; use QR login in admin or provide refresh_token") +} + +func (d *Driver) validateToken(ctx context.Context) error { + var out userMeResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetResult(&out). + Get("/v1/user/me") + if err != nil { + return err + } + if resp.IsError() { + return fmt.Errorf("guangyapan validate token: status=%d body=%s", resp.StatusCode(), resp.String()) + } + if strings.TrimSpace(out.Sub) == "" { + return errors.New("guangyapan validate token: empty user sub") + } + return nil +} + +func (d *Driver) refresh(ctx context.Context) error { + if strings.TrimSpace(d.refreshToken) == "" { + return errors.New("guangyapan refresh: refresh_token is empty") + } + var out tokenResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": d.clientID, + "grant_type": "refresh_token", + "refresh_token": d.refreshToken, + }). + SetResult(&out). + Post("/v1/auth/token") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" { + return fmt.Errorf("guangyapan refresh: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.accessToken = strings.TrimSpace(out.AccessToken) + if strings.TrimSpace(out.RefreshToken) != "" { + d.refreshToken = strings.TrimSpace(out.RefreshToken) + } + d.saveCredentials() + return nil +} + +func (d *Driver) loginBySMSCode(ctx context.Context) error { + verificationID := strings.TrimSpace(d.verificationID) + if verificationID == "" { + var err error + verificationID, err = d.requestVerificationID(ctx) + if err != nil { + return err + } + } + + var step2 verifyResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "verification_id": verificationID, + "verification_code": d.verifyCode, + "client_id": d.clientID, + }). + SetResult(&step2). + Post("/v1/auth/verification/verify") + if err != nil { + return err + } + if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" { + return fmt.Errorf("guangyapan verify code: %s", accountErr(step2.ErrorDesc, step2.Error, resp)) + } + + var out tokenResp + resp, err = d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "verification_code": d.verifyCode, + "verification_token": step2.VerificationToken, + "username": normalizePhoneE164(d.phoneNumber), + "client_id": d.clientID, + }). + SetResult(&out). + Post("/v1/auth/signin") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" { + return fmt.Errorf("guangyapan signin: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.accessToken = strings.TrimSpace(out.AccessToken) + d.refreshToken = strings.TrimSpace(out.RefreshToken) + d.verificationID = "" + d.verifyCode = "" + d.sendCode = false + d.saveCredentials() + return nil +} + +func (d *Driver) prepareSMSCode(ctx context.Context) error { + d.verificationID = "" + if err := d.ensureCaptchaToken(ctx, false); err != nil { + return err + } + id, err := d.requestVerificationID(ctx) + if err != nil { + return err + } + d.verificationID = id + d.sendCode = false + d.saveCredentials() + return nil +} + +func (d *Driver) requestVerificationID(ctx context.Context) (string, error) { + if d.captchaToken != "" { + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + } + var out verificationResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "phone_number": normalizePhoneE164(d.phoneNumber), + "target": "ANY", + "client_id": d.clientID, + }). + SetResult(&out). + Post("/v1/auth/verification") + if err != nil { + return "", err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.VerificationID) == "" { + if strings.Contains(out.Error, "captcha_invalid") || strings.Contains(out.ErrorDesc, "captcha_token expired") { + if err := d.ensureCaptchaToken(ctx, true); err == nil { + return d.requestVerificationID(ctx) + } + } + return "", fmt.Errorf("guangyapan request verification: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + return strings.TrimSpace(out.VerificationID), nil +} + +func (d *Driver) ensureCaptchaToken(ctx context.Context, force bool) error { + if !force && d.captchaToken != "" { + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + return nil + } + var out captchaInitResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": d.clientID, + "action": "POST:/v1/auth/verification", + "device_id": d.deviceID, + "meta": map[string]any{ + "username": normalizePhoneE164(d.phoneNumber), + "phone_number": normalizePhoneE164(d.phoneNumber), + "VERIFICATION_PHONE": normalizePhoneE164(d.phoneNumber), + }, + }). + SetResult(&out). + Post("/v1/shield/captcha/init") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" { + return fmt.Errorf("guangyapan captcha init: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.captchaToken = strings.TrimSpace(out.CaptchaToken) + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + d.saveCredentials() + return nil +} + +func (d *Driver) postAPI(ctx context.Context, p string, body any, out any) error { + if strings.TrimSpace(d.accessToken) == "" { + return errors.New("guangyapan api: access token is empty") + } + resp, err := d.apiClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetBody(body). + SetResult(out). + Post(p) + if err != nil { + return err + } + if resp.StatusCode() == http.StatusUnauthorized || resp.StatusCode() == http.StatusForbidden { + if strings.TrimSpace(d.refreshToken) == "" { + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return fmt.Errorf("guangyapan api: status=%d body=%s", resp.StatusCode(), resp.String()) + } + if err := d.refresh(ctx); err != nil { + return err + } + resp, err = d.apiClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetBody(body). + SetResult(out). + Post(p) + if err != nil { + return err + } + } + if resp.IsError() { + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return fmt.Errorf("guangyapan api: status=%d body=%s", resp.StatusCode(), resp.String()) + } + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return nil +} + +func guangYaPanResponseCodeMsg(resp *resty.Response, out any) (int, string) { + if resp != nil { + body := resp.Body() + if len(body) > 0 { + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &env); err == nil && (env.Code != 0 || strings.TrimSpace(env.Msg) != "") { + return env.Code, strings.TrimSpace(env.Msg) + } + if resp.IsError() { + return 0, strings.TrimSpace(resp.String()) + } + } + } + if code, msg, ok := guangYaPanCodeMsgFromValue(out); ok { + return code, msg + } + if resp != nil && resp.IsError() { + return 0, strings.TrimSpace(resp.String()) + } + return 0, "" +} + +func guangYaPanCodeMsgFromValue(v any) (int, string, bool) { + rv := reflect.ValueOf(v) + for rv.IsValid() && rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return 0, "", false + } + rv = rv.Elem() + } + if !rv.IsValid() || rv.Kind() != reflect.Struct { + return 0, "", false + } + codeField := rv.FieldByName("Code") + msgField := rv.FieldByName("Msg") + if !codeField.IsValid() && !msgField.IsValid() { + return 0, "", false + } + code := 0 + if codeField.IsValid() && codeField.CanInt() { + code = int(codeField.Int()) + } + msg := "" + if msgField.IsValid() && msgField.Kind() == reflect.String { + msg = strings.TrimSpace(msgField.String()) + } + return code, msg, true +} + +func guangYaPanLooksRateLimited(status int, code int, _ string) bool { + if status == http.StatusTooManyRequests || code == http.StatusTooManyRequests { + return true + } + switch status { + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509: + return true + } + return false +} + +func guangYaPanRateLimitError(step, retryAfter string, status int, code int, message string) error { + message = strings.TrimSpace(message) + if message == "" { + message = "guangyapan api rate limited" + } + if len(message) > 1024 { + message = message[:1024] + "...(truncated)" + } + return &drives.RateLimitError{ + Provider: Kind, + RetryAfter: parseRetryAfterHeader(retryAfter), + Err: fmt.Errorf("guangyapan api rate limited: step=%s status=%d code=%d msg=%s", step, status, code, message), + } +} + +func parseRetryAfterHeader(raw string) time.Duration { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if when, err := http.ParseTime(raw); err == nil { + d := time.Until(when) + if d > 0 { + return d + } + } + return 0 +} + +func (d *Driver) waitTaskDone(ctx context.Context, taskID string) error { + const ( + maxTry = 30 + interval = 300 * time.Millisecond + ) + for i := 0; i < maxTry; i++ { + var out taskStatusResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_task_status", map[string]any{"taskId": taskID}, &out); err != nil { + return err + } + if !successMessage(out.Msg) { + return fmt.Errorf("guangyapan task status: %s", strings.TrimSpace(out.Msg)) + } + switch out.Data.Status { + case 2: + return nil + case -1, 3: + return fmt.Errorf("guangyapan task %s failed with status=%d", taskID, out.Data.Status) + } + if i == maxTry-1 { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + } + return fmt.Errorf("guangyapan task %s timeout", taskID) +} + +func (d *Driver) getUploadToken(ctx context.Context, parentID, name string, size int64) (*uploadTokenData, int, error) { + var out uploadTokenResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_center_token", map[string]any{ + "capacity": 2, + "name": name, + "parentId": parentID, + "res": map[string]any{"fileSize": size}, + }, &out); err != nil { + return nil, 0, err + } + if strings.TrimSpace(out.Msg) != "" && !successMessage(out.Msg) { + return nil, out.Code, fmt.Errorf("guangyapan upload token: %s", strings.TrimSpace(out.Msg)) + } + if out.Data.TaskID == "" { + return nil, out.Code, errors.New("guangyapan upload token: empty task id") + } + if out.Data.AccessKeyID == "" { + out.Data.AccessKeyID = out.Data.Creds.AccessKeyID + } + if out.Data.SecretAccessKey == "" { + out.Data.SecretAccessKey = out.Data.Creds.SecretAccessKey + } + if out.Data.SessionToken == "" { + out.Data.SessionToken = out.Data.Creds.SessionToken + } + if strings.TrimSpace(out.Data.EndPoint) == "" { + out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint) + } + if strings.TrimSpace(out.Data.EndPoint) != "" && !strings.HasPrefix(out.Data.EndPoint, "http://") && !strings.HasPrefix(out.Data.EndPoint, "https://") { + if strings.TrimSpace(out.Data.FullEndPoint) != "" { + out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint) + } else if strings.TrimSpace(out.Data.BucketName) != "" { + host := strings.TrimSpace(out.Data.EndPoint) + prefix := strings.TrimSpace(out.Data.BucketName) + "." + if strings.HasPrefix(host, prefix) { + out.Data.EndPoint = "https://" + host + } else { + out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.BucketName) + "." + host + } + } else { + out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.EndPoint) + } + } + return &out.Data, out.Code, nil +} + +func (d *Driver) waitUploadTaskInfo(ctx context.Context, taskID string) (string, error) { + const ( + maxTry = 300 + interval = time.Second + ) + for i := 0; i < maxTry; i++ { + var out taskInfoResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_info_by_task_id", map[string]any{"taskId": taskID}, &out); err != nil { + return "", err + } + if out.Data.FileID != "" { + return out.Data.FileID, nil + } + switch out.Code { + case 0, 145, 146, 147, 155, 163: + default: + if strings.TrimSpace(out.Msg) != "" { + return "", fmt.Errorf("guangyapan upload task failed: code=%d msg=%s", out.Code, strings.TrimSpace(out.Msg)) + } + } + if i == maxTry-1 { + break + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(interval): + } + } + return "", fmt.Errorf("guangyapan upload task %s timeout", taskID) +} + +func multipartUploadToOSS(ctx context.Context, bucket *oss.Bucket, objectPath string, r io.Reader, size int64) error { + partSize := calcUploadPartSize(size) + upload, err := bucket.InitiateMultipartUpload(objectPath, oss.Sequential()) + if err != nil { + return err + } + partCount := int((size + partSize - 1) / partSize) + parts := make([]oss.UploadPart, 0, partCount) + uploaded := int64(0) + partNumber := 1 + for uploaded < size { + if err := ctx.Err(); err != nil { + return err + } + cur := partSize + if left := size - uploaded; left < cur { + cur = left + } + part, err := bucket.UploadPart(upload, &contextReader{ctx: ctx, r: io.LimitReader(r, cur)}, cur, partNumber) + if err != nil { + return err + } + parts = append(parts, part) + uploaded += cur + partNumber++ + } + _, err = bucket.CompleteMultipartUpload(upload, parts) + return err +} + +type contextReader struct { + ctx context.Context + r io.Reader +} + +func (r *contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} + +func calcUploadPartSize(size int64) int64 { + const mb = int64(1024 * 1024) + const gb = int64(1024 * 1024 * 1024) + switch { + case size <= 100*mb: + return mb + case size <= 16*gb: + return 2 * mb + case size <= 160*gb: + return 4 * mb + default: + return 8 * mb + } +} + +func (d *Driver) newAccountClient() *resty.Client { + client := resty.New(). + SetTimeout(30*time.Second). + SetBaseURL(d.accountBaseURL). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Content-Type", "application/json"). + SetHeader("X-Device-Model", "chrome%2F147.0.0.0"). + SetHeader("X-Device-Name", "PC-Chrome"). + SetHeader("X-Device-Sign", "wdi10."+d.deviceID+"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"). + SetHeader("X-Net-Work-Type", "NONE"). + SetHeader("X-OS-Version", "MacIntel"). + SetHeader("X-Platform-Version", "1"). + SetHeader("X-Protocol-Version", "301"). + SetHeader("X-Provider-Name", "NONE"). + SetHeader("X-SDK-Version", "9.0.2"). + SetHeader("X-Client-Id", d.clientID). + SetHeader("X-Client-Version", "0.0.1"). + SetHeader("X-Device-Id", d.deviceID) + if d.captchaToken != "" { + client.SetHeader("X-Captcha-Token", d.captchaToken) + } + return client +} + +func (d *Driver) newAPIClient() *resty.Client { + return resty.New(). + SetTimeout(30*time.Second). + SetBaseURL(d.apiBaseURL). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Content-Type", "application/json"). + SetHeader("Did", d.deviceID). + SetHeader("Dt", "4") +} + +func (d *Driver) saveCredentials() { + if d.onCredentialsUpdate == nil { + return + } + d.onCredentialsUpdate(map[string]string{ + "access_token": d.accessToken, + "refresh_token": d.refreshToken, + "captcha_token": d.captchaToken, + "device_id": d.deviceID, + "client_id": d.clientID, + "verification_id": d.verificationID, + "verify_code": d.verifyCode, + "send_code": strconv.FormatBool(d.sendCode), + }) +} + +func (d *Driver) remember(entry drives.Entry) { + if entry.ID == "" { + return + } + d.fileMu.Lock() + d.files[entry.ID] = entry + d.fileMu.Unlock() +} + +func fileItemToEntry(item fileItem, parentID string) drives.Entry { + if item.ParentID != "" { + parentID = item.ParentID + } + return drives.Entry{ + ID: item.FileID, + Name: item.FileName, + Size: item.FileSize, + IsDir: item.ResType == 2, + ParentID: parentID, + ModTime: unixOrZero(item.UTime), + } +} + +func successMessage(msg string) bool { + return strings.EqualFold(strings.TrimSpace(msg), "success") +} + +func accountErr(desc, short string, resp *resty.Response) string { + msg := strings.TrimSpace(desc) + if msg == "" { + msg = strings.TrimSpace(short) + } + if msg == "" && resp != nil { + msg = strings.TrimSpace(resp.String()) + } + if msg == "" && resp != nil { + msg = fmt.Sprintf("status=%d", resp.StatusCode()) + } + if msg == "" { + msg = "unknown error" + } + return msg +} + +func normalizeAccessToken(v string) string { + v = strings.TrimSpace(v) + if strings.HasPrefix(strings.ToLower(v), "bearer ") { + return strings.TrimSpace(v[7:]) + } + return v +} + +func normalizeCaptchaUsername(phone string) string { + p := strings.TrimSpace(phone) + p = strings.ReplaceAll(p, " ", "") + p = strings.TrimPrefix(p, "+") + b := make([]rune, 0, len(p)) + for _, ch := range p { + if ch >= '0' && ch <= '9' { + b = append(b, ch) + } + } + digits := string(b) + if strings.HasPrefix(digits, "86") && len(digits) > 11 { + digits = digits[2:] + } + return digits +} + +func normalizePhoneE164(phone string) string { + p := strings.TrimSpace(phone) + if p == "" { + return "" + } + p = strings.ReplaceAll(p, " ", "") + if strings.HasPrefix(p, "+") { + if strings.HasPrefix(p, "+86") && len(p) > 3 { + return "+86 " + strings.TrimPrefix(p, "+86") + } + return p + } + digits := normalizeCaptchaUsername(p) + if len(digits) == 11 { + return "+86 " + digits + } + return p +} + +func normalizeDeviceID(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + v = strings.ReplaceAll(v, "-", "") + if len(v) != 32 { + return "" + } + for _, ch := range v { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') { + return "" + } + } + return v +} + +func randomDeviceID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "0123456789abcdef0123456789abcdef" + } + return hex.EncodeToString(b) +} + +func normalizeOSSEndpoint(endpoint, bucket string) string { + ep := strings.TrimSpace(endpoint) + if ep == "" { + return ep + } + if !strings.HasPrefix(ep, "http://") && !strings.HasPrefix(ep, "https://") { + ep = "https://" + ep + } + u, err := url.Parse(ep) + if err != nil || u.Host == "" { + return ep + } + prefix := strings.TrimSpace(bucket) + if prefix != "" && strings.HasPrefix(u.Host, prefix+".") { + u.Host = strings.TrimPrefix(u.Host, prefix+".") + } + return u.String() +} + +var _ drives.Drive = (*Driver)(nil) +var _ drives.Remover = (*Driver)(nil) diff --git a/backend/internal/drives/guangyapan/driver_test.go b/backend/internal/drives/guangyapan/driver_test.go new file mode 100644 index 0000000..e4dcf8a --- /dev/null +++ b/backend/internal/drives/guangyapan/driver_test.go @@ -0,0 +1,300 @@ +package guangyapan + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/video-site/backend/internal/drives" +) + +func TestDriverRefreshListAndStream(t *testing.T) { + var refreshed bool + var listedRoot bool + updates := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/auth/token": + refreshed = true + writeTestJSON(w, map[string]any{ + "access_token": "new-access", + "refresh_token": "new-refresh", + }) + case "/v1/user/me": + if got := r.Header.Get("Authorization"); got != "Bearer new-access" { + t.Fatalf("auth header = %q, want new access token", got) + } + writeTestJSON(w, map[string]any{"sub": "user-1"}) + case "/userres/v1/file/get_file_list": + if got := r.Header.Get("Authorization"); got != "Bearer new-access" { + t.Fatalf("api auth header = %q, want new access token", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode list body: %v", err) + } + if body["parentId"] != "" { + t.Fatalf("parentId = %#v, want root empty string", body["parentId"]) + } + listedRoot = true + writeTestJSON(w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "total": 2, + "list": []map[string]any{ + {"fileId": "dir-1", "parentId": "", "fileName": "Movies", "resType": 2}, + {"fileId": "file-1", "parentId": "", "fileName": "clip.mp4", "fileSize": 123, "resType": 1, "utime": 1700000000}, + }, + }, + }) + case "/nd.bizuserres.s/v1/get_res_download_url": + writeTestJSON(w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{"signedURL": "https://cdn.example.test/clip.mp4"}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + RefreshToken: "old-refresh", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + OnCredentialsUpdate: func(values map[string]string) { + for k, v := range values { + updates[k] = v + } + }, + }) + if err := d.Init(context.Background()); err != nil { + t.Fatalf("init: %v", err) + } + if !refreshed { + t.Fatal("refresh token endpoint was not called") + } + if updates["access_token"] != "new-access" || updates["refresh_token"] != "new-refresh" { + t.Fatalf("updates = %#v, want refreshed tokens", updates) + } + + entries, err := d.List(context.Background(), "") + if err != nil { + t.Fatalf("list: %v", err) + } + if !listedRoot || len(entries) != 2 { + t.Fatalf("listedRoot=%v entries=%#v", listedRoot, entries) + } + if !entries[0].IsDir || entries[1].ID != "file-1" || entries[1].Size != 123 { + t.Fatalf("entries = %#v", entries) + } + + link, err := d.StreamURL(context.Background(), "file-1") + if err != nil { + t.Fatalf("stream url: %v", err) + } + if link.URL != "https://cdn.example.test/clip.mp4" { + t.Fatalf("stream url = %q", link.URL) + } +} + +func TestDriverResolvesRootPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/user/me": + writeTestJSON(w, map[string]any{"sub": "user-1"}) + case "/userres/v1/file/get_file_list": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode list body: %v", err) + } + parent, _ := body["parentId"].(string) + switch parent { + case "": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "folder-a", "parentId": "", "fileName": "影视", "resType": 2}, + })) + case "folder-a": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "folder-b", "parentId": "folder-a", "fileName": "电影", "resType": 2}, + })) + case "folder-b": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "file-1", "parentId": "folder-b", "fileName": "movie.mp4", "fileSize": 456, "resType": 1}, + })) + default: + t.Fatalf("unexpected parent %q", parent) + } + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + RootID: "configured-root", + RootPath: "影视/电影", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + if err := d.Init(context.Background()); err != nil { + t.Fatalf("init: %v", err) + } + if d.RootID() != "folder-b" { + t.Fatalf("root id = %q, want folder-b", d.RootID()) + } + entries, err := d.List(context.Background(), "") + if err != nil { + t.Fatalf("list resolved root: %v", err) + } + if len(entries) != 1 || entries[0].ID != "file-1" { + t.Fatalf("entries = %#v", entries) + } +} + +func TestDriverSendSMSCodeUpdatesVerificationState(t *testing.T) { + updates := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/shield/captcha/init": + writeTestJSON(w, map[string]any{"captcha_token": "captcha-1"}) + case "/v1/auth/verification": + writeTestJSON(w, map[string]any{"verification_id": "verify-1"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + PhoneNumber: "13800000000", + SendCode: true, + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + OnCredentialsUpdate: func(values map[string]string) { + for k, v := range values { + updates[k] = v + } + }, + }) + err := d.Init(context.Background()) + if err == nil || !strings.Contains(err.Error(), "验证码已发送") { + t.Fatalf("init err = %v, want verification prompt", err) + } + if updates["captcha_token"] != "captcha-1" || updates["verification_id"] != "verify-1" || updates["send_code"] != "false" { + t.Fatalf("updates = %#v, want sms state saved", updates) + } + if updates["device_id"] == "" { + t.Fatalf("updates = %#v, want generated device id saved", updates) + } +} + +func TestListHTTP429ReturnsRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后重试"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want rate limit error") + } + var rateLimit *drives.RateLimitError + if !errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want RateLimitError", err) + } + if rateLimit.RetryAfter != 2*time.Minute { + t.Fatalf("retry after = %s, want 2m", rateLimit.RetryAfter) + } +} + +func TestListCode429ReturnsRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后再试"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want rate limit error") + } + var rateLimit *drives.RateLimitError + if !errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want RateLimitError", err) + } +} + +func TestListInvalidToken403DoesNotReturnRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.WriteHeader(http.StatusForbidden) + writeTestJSON(w, map[string]any{"code": 401, "msg": "invalid access token"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want auth error") + } + var rateLimit *drives.RateLimitError + if errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want non-rate-limit error", err) + } +} + +func listTestResponse(items []map[string]any) map[string]any { + return map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "total": len(items), + "list": items, + }, + } +} + +func writeTestJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + panic(err) + } +} diff --git a/backend/internal/drives/guangyapan/qr.go b/backend/internal/drives/guangyapan/qr.go new file mode 100644 index 0000000..d0d1798 --- /dev/null +++ b/backend/internal/drives/guangyapan/qr.go @@ -0,0 +1,244 @@ +package guangyapan + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/skip2/go-qrcode" +) + +const ( + defaultQRScope = "user" + deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + defaultQRUserAgent = "GuangYaPan-Login/1.0" +) + +type QRConfig struct { + AccountBaseURL string + HTTPClient *http.Client + Now func() time.Time +} + +type QRClient struct { + accountBaseURL string + client *resty.Client + now func() time.Time +} + +type QRCodeSession struct { + DeviceCode string `json:"deviceCode"` + QRCodeURL string `json:"qrCodeUrl"` + QRImageDataURL string `json:"qrImageDataUrl"` + IntervalSeconds int `json:"intervalSeconds"` + ExpiresAt string `json:"expiresAt,omitempty"` +} + +type QRCodeStatus struct { + State string `json:"state"` + StatusText string `json:"statusText"` + IntervalSeconds int `json:"intervalSeconds,omitempty"` + AccessToken string `json:"accessToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` + TokenType string `json:"tokenType,omitempty"` + ExpiresIn int64 `json:"expiresIn,omitempty"` +} + +type deviceCodeResp struct { + DeviceCode string `json:"device_code"` + VerificationURIComplete string `json:"verification_uri_complete"` + ShortURIComplete string `json:"short_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type deviceTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +func NewQRClient(c QRConfig) *QRClient { + accountBaseURL := strings.TrimRight(strings.TrimSpace(c.AccountBaseURL), "/") + if accountBaseURL == "" { + accountBaseURL = defaultAccountBaseURL + } + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 20 * time.Second} + } + now := c.Now + if now == nil { + now = time.Now + } + return &QRClient{ + accountBaseURL: accountBaseURL, + client: resty.NewWithClient(httpClient). + SetTimeout(20*time.Second). + SetBaseURL(accountBaseURL). + SetHeader("User-Agent", defaultQRUserAgent). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"), + now: now, + } +} + +func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) { + var out deviceCodeResp + var errOut deviceCodeResp + resp, err := c.client.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": defaultClientID, + "scope": defaultQRScope, + }). + SetResult(&out). + SetError(&errOut). + Post("/v1/auth/device/code") + if err != nil { + return QRCodeSession{}, err + } + if resp.IsError() || out.Error != "" { + if out.Error == "" { + out = errOut + } + return QRCodeSession{}, fmt.Errorf("guangyapan qr: %s", deviceAPIError(out.ErrorDesc, out.Error, resp)) + } + + deviceCode := strings.TrimSpace(out.DeviceCode) + if deviceCode == "" { + return QRCodeSession{}, errors.New("guangyapan qr: empty device_code") + } + qrURL := strings.TrimSpace(out.VerificationURIComplete) + if qrURL == "" { + qrURL = strings.TrimSpace(out.ShortURIComplete) + } + if qrURL == "" { + return QRCodeSession{}, errors.New("guangyapan qr: empty verification uri") + } + interval := out.Interval + if interval <= 0 { + interval = 5 + } + expiresIn := out.ExpiresIn + if expiresIn <= 0 { + expiresIn = 300 + } + png, err := qrcode.Encode(qrURL, qrcode.Medium, 220) + if err != nil { + return QRCodeSession{}, err + } + return QRCodeSession{ + DeviceCode: deviceCode, + QRCodeURL: qrURL, + QRImageDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), + IntervalSeconds: interval, + ExpiresAt: c.now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339), + }, nil +} + +func (c *QRClient) Poll(ctx context.Context, deviceCode string) (QRCodeStatus, error) { + deviceCode = strings.TrimSpace(deviceCode) + if deviceCode == "" { + return QRCodeStatus{}, errors.New("deviceCode is required") + } + + var out deviceTokenResp + var errOut deviceTokenResp + resp, err := c.client.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": defaultClientID, + "grant_type": deviceCodeGrantType, + "device_code": deviceCode, + }). + SetResult(&out). + SetError(&errOut). + Post("/v1/auth/token") + if err != nil { + return QRCodeStatus{}, err + } + if resp.IsError() && out.Error == "" { + out = errOut + } + if resp.IsError() && out.Error == "" { + _ = json.Unmarshal(resp.Body(), &out) + } + if out.Error != "" { + return qrStatusForDeviceError(out), nil + } + if resp.IsError() { + return QRCodeStatus{}, fmt.Errorf("guangyapan qr: status=%d body=%s", resp.StatusCode(), resp.String()) + } + access := strings.TrimSpace(out.AccessToken) + refresh := strings.TrimSpace(out.RefreshToken) + if access == "" || refresh == "" { + return QRCodeStatus{}, errors.New("guangyapan qr: login succeeded but token response is incomplete") + } + tokenType := strings.TrimSpace(out.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + return QRCodeStatus{ + State: "success", + StatusText: "登录成功", + AccessToken: access, + RefreshToken: refresh, + TokenType: tokenType, + ExpiresIn: out.ExpiresIn, + }, nil +} + +func qrStatusForDeviceError(out deviceTokenResp) QRCodeStatus { + errCode := strings.TrimSpace(out.Error) + switch errCode { + case "authorization_pending": + return QRCodeStatus{State: "pending", StatusText: "等待扫码确认"} + case "slow_down": + return QRCodeStatus{State: "pending", StatusText: "等待扫码确认,已降低查询频率", IntervalSeconds: 10} + case "expired_token": + return QRCodeStatus{State: "expired", StatusText: "二维码已过期"} + case "access_denied": + return QRCodeStatus{State: "denied", StatusText: "用户拒绝了授权"} + default: + msg := strings.TrimSpace(out.ErrorDesc) + if msg == "" { + msg = errCode + } + if msg == "" { + msg = "未知错误" + } + return QRCodeStatus{State: "error", StatusText: msg} + } +} + +func deviceAPIError(desc, short string, resp *resty.Response) string { + msg := strings.TrimSpace(desc) + if msg == "" { + msg = strings.TrimSpace(short) + } + if msg == "" && resp != nil { + msg = strings.TrimSpace(resp.String()) + } + if msg == "" && resp != nil { + msg = fmt.Sprintf("status=%d", resp.StatusCode()) + } + if msg == "" { + msg = "unknown error" + } + return msg +} diff --git a/backend/internal/drives/guangyapan/qr_test.go b/backend/internal/drives/guangyapan/qr_test.go new file mode 100644 index 0000000..e280a56 --- /dev/null +++ b/backend/internal/drives/guangyapan/qr_test.go @@ -0,0 +1,102 @@ +package guangyapan + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestQRClientGenerate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/device/code" { + t.Fatalf("path = %s, want device code endpoint", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["client_id"] != defaultClientID || body["scope"] != defaultQRScope { + t.Fatalf("body = %#v", body) + } + writeTestJSON(w, map[string]any{ + "device_code": "device-1", + "verification_uri_complete": "https://account.guangyapan.com/device?code=abc", + "interval": 7, + "expires_in": 180, + }) + })) + defer srv.Close() + + client := NewQRClient(QRConfig{ + AccountBaseURL: srv.URL, + Now: func() time.Time { return time.Unix(1700000000, 0) }, + }) + session, err := client.Generate(context.Background()) + if err != nil { + t.Fatalf("generate: %v", err) + } + if session.DeviceCode != "device-1" || session.QRCodeURL != "https://account.guangyapan.com/device?code=abc" { + t.Fatalf("session = %#v", session) + } + if session.IntervalSeconds != 7 { + t.Fatalf("interval = %d, want 7", session.IntervalSeconds) + } + if session.ExpiresAt != time.Unix(1700000180, 0).Format(time.RFC3339) { + t.Fatalf("expiresAt = %q", session.ExpiresAt) + } + if !strings.HasPrefix(session.QRImageDataURL, "data:image/png;base64,") { + t.Fatalf("qr image = %q", session.QRImageDataURL) + } +} + +func TestQRClientPollPendingAndSuccess(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/token" { + t.Fatalf("path = %s, want token endpoint", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["client_id"] != defaultClientID || + body["grant_type"] != deviceCodeGrantType || + body["device_code"] != "device-1" { + t.Fatalf("body = %#v", body) + } + calls++ + if calls == 1 { + w.WriteHeader(http.StatusBadRequest) + writeTestJSON(w, map[string]any{"error": "authorization_pending"}) + return + } + writeTestJSON(w, map[string]any{ + "access_token": "access-1", + "refresh_token": "refresh-1", + "token_type": "Bearer", + "expires_in": 7200, + }) + })) + defer srv.Close() + + client := NewQRClient(QRConfig{AccountBaseURL: srv.URL}) + pending, err := client.Poll(context.Background(), "device-1") + if err != nil { + t.Fatalf("poll pending: %v", err) + } + if pending.State != "pending" || pending.AccessToken != "" { + t.Fatalf("pending = %#v", pending) + } + + success, err := client.Poll(context.Background(), "device-1") + if err != nil { + t.Fatalf("poll success: %v", err) + } + if success.State != "success" || success.AccessToken != "access-1" || success.RefreshToken != "refresh-1" { + t.Fatalf("success = %#v", success) + } +} diff --git a/backend/internal/drives/guangyapan/types.go b/backend/internal/drives/guangyapan/types.go new file mode 100644 index 0000000..aa0f6e7 --- /dev/null +++ b/backend/internal/drives/guangyapan/types.go @@ -0,0 +1,129 @@ +package guangyapan + +import "time" + +type tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type verificationResp struct { + VerificationID string `json:"verification_id"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type captchaInitResp struct { + CaptchaToken string `json:"captcha_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type verifyResp struct { + VerificationToken string `json:"verification_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type userMeResp struct { + Sub string `json:"sub"` +} + +type listResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Total int `json:"total"` + List []fileItem `json:"list"` + } `json:"data"` +} + +type fileItem struct { + FileID string `json:"fileId"` + ParentID string `json:"parentId"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ResType int `json:"resType"` + CTime int64 `json:"ctime"` + UTime int64 `json:"utime"` +} + +type downloadResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + SignedURL string `json:"signedURL"` + DownloadURL string `json:"downloadUrl"` + } `json:"data"` +} + +type createDirResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + ResType int `json:"resType"` + CTime int64 `json:"ctime"` + UTime int64 `json:"utime"` + } `json:"data"` +} + +type deleteResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + TaskID string `json:"taskId"` + } `json:"data"` +} + +type taskStatusResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Status int `json:"status"` + } `json:"data"` +} + +type uploadTokenResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data uploadTokenData `json:"data"` +} + +type uploadTokenData struct { + TaskID string `json:"taskId"` + ObjectPath string `json:"objectPath"` + BucketName string `json:"bucketName"` + EndPoint string `json:"endPoint"` + FullEndPoint string `json:"fullEndPoint"` + AccessKeyID string `json:"accessKeyID"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Creds struct { + AccessKeyID string `json:"accessKeyID"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + } `json:"creds"` +} + +type taskInfoResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileID string `json:"fileId"` + } `json:"data"` +} + +func unixOrZero(v int64) time.Time { + if v <= 0 { + return time.Time{} + } + return time.Unix(v, 0) +} diff --git a/backend/internal/drives/iface.go b/backend/internal/drives/iface.go index 8a9d690..4d965e7 100644 --- a/backend/internal/drives/iface.go +++ b/backend/internal/drives/iface.go @@ -5,12 +5,14 @@ import ( "errors" "io" "net/http" + "strconv" + "strings" "time" ) // Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。 type Drive interface { - // Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage" + // Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "guangyapan" / "onedrive" / "googledrive" / "localstorage" Kind() string // ID 返回该盘在 catalog 中的唯一标识 @@ -119,3 +121,42 @@ func RateLimitRetryAfter(err error) (time.Duration, bool) { } return 0, false } + +// TextMentionsHTTPStatus only looks for explicit numeric HTTP status contexts +// in errors from tools that do not expose structured response metadata. +func TextMentionsHTTPStatus(text string, statuses ...int) bool { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return false + } + for _, status := range statuses { + if status <= 0 { + continue + } + code := strconv.Itoa(status) + if strings.HasPrefix(text, code+" ") || + strings.Contains(text, "status="+code) || + strings.Contains(text, "status: "+code) || + strings.Contains(text, "status "+code) || + strings.Contains(text, "status code "+code) || + strings.Contains(text, "http "+code) || + strings.Contains(text, "http status="+code) || + strings.Contains(text, "http status: "+code) || + strings.Contains(text, "http status "+code) || + strings.Contains(text, "server returned "+code) || + strings.Contains(text, "code="+code) || + strings.Contains(text, "code: "+code) || + strings.Contains(text, "error_code="+code) || + strings.Contains(text, "error_code: "+code) { + return true + } + } + return false +} + +func ErrorMentionsHTTPStatus(err error, statuses ...int) bool { + if err == nil { + return false + } + return TextMentionsHTTPStatus(err.Error(), statuses...) +} diff --git a/backend/internal/drives/iface_test.go b/backend/internal/drives/iface_test.go new file mode 100644 index 0000000..6c9d4c6 --- /dev/null +++ b/backend/internal/drives/iface_test.go @@ -0,0 +1,24 @@ +package drives + +import "testing" + +func TestTextMentionsHTTPStatus(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + {name: "status context", text: "request failed with status: 429 Too Many Requests", want: true}, + {name: "http context", text: "http 503 service unavailable", want: true}, + {name: "server returned context", text: "Server returned 403 Forbidden", want: true}, + {name: "message only", text: "操作频繁,请稍后重试", want: false}, + {name: "unrelated number", text: "generated 429 bytes", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := TextMentionsHTTPStatus(tc.text, 403, 429, 503); got != tc.want { + t.Fatalf("TextMentionsHTTPStatus(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } +} diff --git a/backend/internal/drives/onedrive/driver.go b/backend/internal/drives/onedrive/driver.go index a722368..1e9d5f6 100644 --- a/backend/internal/drives/onedrive/driver.go +++ b/backend/internal/drives/onedrive/driver.go @@ -594,8 +594,8 @@ func (d *Driver) refresh(ctx context.Context) error { return nil } -func isRateLimitResponse(res *resty.Response, code, message string) bool { - if isRateLimitCode(code) || isRateLimitMessage(message) { +func isRateLimitResponse(res *resty.Response, code, _ string) bool { + if isRateLimitCode(code) { return true } if res == nil { @@ -632,18 +632,6 @@ func isRateLimitCode(code string) bool { } } -func isRateLimitMessage(message string) bool { - text := strings.ToLower(strings.TrimSpace(message)) - if text == "" { - return false - } - return strings.Contains(text, "too many requests") || - strings.Contains(text, "throttl") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "activity limit") || - strings.Contains(text, "temporarily blocked") -} - func onedriveRateLimitError(res *resty.Response, message string) error { if strings.TrimSpace(message) == "" { message = "onedrive rate limited" diff --git a/backend/internal/drives/onedrive/driver_test.go b/backend/internal/drives/onedrive/driver_test.go index ed695df..312d262 100644 --- a/backend/internal/drives/onedrive/driver_test.go +++ b/backend/internal/drives/onedrive/driver_test.go @@ -214,7 +214,7 @@ func TestGraph429ReturnsRateLimitErrorWithRetryAfter(t *testing.T) { } } -func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) { +func TestGraphThrottleMessageDoesNotReturnRateLimitError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) @@ -238,11 +238,11 @@ func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) { _, err := d.StreamURL(context.Background(), "file-id") if err == nil { - t.Fatal("list succeeded, want rate limit error") + t.Fatal("list succeeded, want graph error") } var rateLimit *drives.RateLimitError - if !errors.As(err, &rateLimit) { - t.Fatalf("error = %T %[1]v, want RateLimitError", err) + if errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want non-rate-limit error", err) } } diff --git a/backend/internal/drives/p115/driver.go b/backend/internal/drives/p115/driver.go index eb67129..7cfaa53 100644 --- a/backend/internal/drives/p115/driver.go +++ b/backend/internal/drives/p115/driver.go @@ -87,7 +87,7 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) // p115ListCooldown 是列目录触发疑似风控错误时的冷却时长。 // // 历史上是 [30min × 3],3 次都失败就放弃;新策略改为 10 分钟无限重试 —— -// 只要错误仍属 transient(429 / 405 / WAF / blocked / 安全威胁 / unexpected), +// 只要错误仍属明确 HTTP transient 状态(429 / 405), // 就持续等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 115 // 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。 const p115ListCooldown = 10 * time.Minute @@ -156,17 +156,7 @@ func isTransient115UpstreamError(err error) bool { if err == nil { return false } - text := strings.ToLower(err.Error()) - return strings.Contains(text, "405") || - strings.Contains(text, "429") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "blocked") || - strings.Contains(text, "security") || - strings.Contains(text, "waf") || - strings.Contains(text, "unexpected error") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "安全威胁") + return drives.ErrorMentionsHTTPStatus(err, http.StatusMethodNotAllowed, http.StatusTooManyRequests) } // ListDirsOnly 只列指定目录的直接**子目录**,不返回文件条目。专为 admin 后台 diff --git a/backend/internal/drives/p115/driver_test.go b/backend/internal/drives/p115/driver_test.go index 13af5a8..0796cd4 100644 --- a/backend/internal/drives/p115/driver_test.go +++ b/backend/internal/drives/p115/driver_test.go @@ -22,8 +22,9 @@ func TestIsTransient115ListError(t *testing.T) { want bool }{ {name: "nil", err: nil, want: false}, - {name: "blocked html", err: errors.New(`