diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 363c7d4ce2..8a745c8105 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -185,15 +185,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink) soraS3Storage := service.NewSoraS3Storage(settingService) - soraGDriveStorage := service.NewSoraGDriveStorage(settingService) - soraStorageRouter := service.NewSoraStorageRouter(settingService, soraS3Storage, soraGDriveStorage) - settingService.SetOnS3UpdateCallback(soraStorageRouter.RefreshAll) + settingService.SetOnS3UpdateCallback(soraS3Storage.RefreshClient) soraGenerationRepository := repository.NewSoraGenerationRepository(db) soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService) - soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService) - settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage, soraGDriveStorage, soraGenerationService) - soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService) - gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService, soraGDriveStorage) + soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService) + settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -221,7 +217,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository) scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) channelHandler := admin.NewChannelHandler(channelService, billingService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, gdriveOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -230,12 +226,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository) soraMediaStorage := service.ProvideSoraMediaStorage(configConfig) soraGatewayService := service.NewSoraGatewayService(soraSDKClient, rateLimitService, httpUpstream, configConfig) - soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraStorageRouter, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService) + soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraS3Storage, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService) soraTaskRepository := repository.NewSoraTaskRepository(db) soraTaskService := service.NewSoraTaskService(soraTaskRepository, accountRepository, soraSDKClient, httpUpstream) - soraTaskWorker := service.NewSoraTaskWorker(soraTaskService, accountRepository, soraStorageRouter, soraMediaStorage, 60*time.Second) + soraTaskWorker := service.NewSoraTaskWorker(soraTaskService, accountRepository, soraS3Storage, soraMediaStorage, 60*time.Second) soraTaskWorker.Start() - soraVideosHandler := handler.NewSoraVideosHandler(soraTaskService, gatewayService, soraStorageRouter, soraMediaStorage, soraGatewayService) + soraVideosHandler := handler.NewSoraVideosHandler(soraTaskService, gatewayService, soraS3Storage, soraMediaStorage, soraGatewayService) soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) totpHandler := handler.NewTotpHandler(totpService) diff --git a/backend/internal/handler/admin/gdrive_oauth_handler.go b/backend/internal/handler/admin/gdrive_oauth_handler.go deleted file mode 100644 index bdf549066c..0000000000 --- a/backend/internal/handler/admin/gdrive_oauth_handler.go +++ /dev/null @@ -1,148 +0,0 @@ -package admin - -import ( - "log/slog" - "net/http" - - "github.com/Wei-Shaw/sub2api/internal/pkg/response" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -// GDriveOAuthHandler 处理 Google Drive OAuth 授权流程。 -type GDriveOAuthHandler struct { - settingService *service.SettingService - gdriveOAuth *service.SoraGDriveOAuthService - gdriveStorage *service.SoraGDriveStorage -} - -// NewGDriveOAuthHandler 创建 GDrive OAuth Handler。 -func NewGDriveOAuthHandler(settingService *service.SettingService, gdriveOAuth *service.SoraGDriveOAuthService, gdriveStorage *service.SoraGDriveStorage) *GDriveOAuthHandler { - return &GDriveOAuthHandler{ - settingService: settingService, - gdriveOAuth: gdriveOAuth, - gdriveStorage: gdriveStorage, - } -} - -// StartOAuthRequest 启动 OAuth 授权请求。 -type StartOAuthRequest struct { - ClientID string `json:"client_id" binding:"required"` - ClientSecret string `json:"client_secret" binding:"required"` - RedirectURI string `json:"redirect_uri" binding:"required"` -} - -// StartOAuth 生成 Google OAuth 授权 URL。 -// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/start -func (h *GDriveOAuthHandler) StartOAuth(c *gin.Context) { - if h.gdriveOAuth == nil { - response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized") - return - } - - var req StartOAuthRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - authURL, state, err := h.gdriveOAuth.GenerateAuthURL(req.ClientID, req.ClientSecret, req.RedirectURI) - if err != nil { - response.Error(c, http.StatusInternalServerError, "生成授权 URL 失败: "+err.Error()) - return - } - - response.Success(c, gin.H{ - "auth_url": authURL, - "state": state, - }) -} - -// OAuthCallbackRequest OAuth 回调请求。 -type OAuthCallbackRequest struct { - ClientID string `json:"client_id" binding:"required"` - ClientSecret string `json:"client_secret" binding:"required"` - RedirectURI string `json:"redirect_uri" binding:"required"` - Code string `json:"code" binding:"required"` - ProfileID string `json:"profile_id"` // 要保存到的 profile ID(可选) -} - -// OAuthCallback 用授权码换取 refresh_token 并保存到 profile。 -// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/callback -func (h *GDriveOAuthHandler) OAuthCallback(c *gin.Context) { - if h.gdriveOAuth == nil { - response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized") - return - } - - var req OAuthCallbackRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - refreshToken, err := h.gdriveOAuth.ExchangeCode(c.Request.Context(), req.ClientID, req.ClientSecret, req.RedirectURI, req.Code) - if err != nil { - slog.Error("[GDriveOAuth] exchange failed", - "client_id_len", len(req.ClientID), - "client_secret_len", len(req.ClientSecret), - "redirect_uri", req.RedirectURI, - "code_len", len(req.Code), - "error", err, - ) - response.Error(c, http.StatusBadRequest, "换取 refresh_token 失败: "+err.Error()) - return - } - - // 如果指定了 profile_id,自动保存 refresh_token 到 profile - if req.ProfileID != "" { - profiles, err := h.settingService.ListSoraS3Profiles(c.Request.Context()) - if err == nil { - for _, p := range profiles.Items { - if p.ProfileID == req.ProfileID { - _, _ = h.settingService.UpdateSoraS3Profile(c.Request.Context(), req.ProfileID, &service.SoraS3Profile{ - Name: p.Name, - Provider: p.Provider, - AccessMode: p.AccessMode, - Enabled: p.Enabled, - Endpoint: p.Endpoint, - Region: p.Region, - Bucket: p.Bucket, - AccessKeyID: p.AccessKeyID, - Prefix: p.Prefix, - ForcePathStyle: p.ForcePathStyle, - CDNURL: p.CDNURL, - DefaultStorageQuotaBytes: p.DefaultStorageQuotaBytes, - AuthType: p.AuthType, - ClientID: p.ClientID, - FolderID: p.FolderID, - RefreshToken: refreshToken, - }) - break - } - } - } - } - - response.Success(c, gin.H{ - "refresh_token": refreshToken, - "message": "OAuth 授权成功", - }) -} - -// TestGDriveStorage 测试 GDrive 存储的完整上传→下载→删除流程。 -// POST /api/v1/admin/settings/sora-storage/gdrive-test -func (h *GDriveOAuthHandler) TestGDriveStorage(c *gin.Context) { - if h.gdriveStorage == nil { - response.Error(c, http.StatusInternalServerError, "GDrive storage not initialized") - return - } - - result, err := h.gdriveStorage.TestFullCycle(c.Request.Context()) - if err != nil { - response.Error(c, http.StatusBadRequest, "GDrive 测试失败: "+err.Error()) - return - } - - response.Success(c, result) -} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c5500ad440..580977617e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -37,25 +37,21 @@ func generateMenuItemID() (string, error) { // SettingHandler 系统设置处理器 type SettingHandler struct { - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - opsService *service.OpsService - soraS3Storage *service.SoraS3Storage - soraGDriveStorage *service.SoraGDriveStorage - soraGenerationService *service.SoraGenerationService + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + opsService *service.OpsService + soraS3Storage *service.SoraS3Storage } // NewSettingHandler 创建系统设置处理器 -func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage, soraGDriveStorage *service.SoraGDriveStorage, soraGenerationService *service.SoraGenerationService) *SettingHandler { +func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler { return &SettingHandler{ - settingService: settingService, - emailService: emailService, - turnstileService: turnstileService, - opsService: opsService, - soraS3Storage: soraS3Storage, - soraGDriveStorage: soraGDriveStorage, - soraGenerationService: soraGenerationService, + settingService: settingService, + emailService: emailService, + turnstileService: turnstileService, + opsService: opsService, + soraS3Storage: soraS3Storage, } } @@ -1234,8 +1230,6 @@ func toSoraS3ProfileDTO(profile service.SoraS3Profile) dto.SoraS3Profile { ProfileID: profile.ProfileID, Name: profile.Name, IsActive: profile.IsActive, - Provider: profile.GetProvider(), - AccessMode: profile.AccessMode, Enabled: profile.Enabled, Endpoint: profile.Endpoint, Region: profile.Region, @@ -1247,13 +1241,6 @@ func toSoraS3ProfileDTO(profile service.SoraS3Profile) dto.SoraS3Profile { CDNURL: profile.CDNURL, DefaultStorageQuotaBytes: profile.DefaultStorageQuotaBytes, UpdatedAt: profile.UpdatedAt, - // Google Drive 专属 - AuthType: profile.AuthType, - ClientID: profile.ClientID, - ClientSecretConfigured: profile.ClientSecretConfigured, - RefreshTokenConfigured: profile.RefreshTokenConfigured, - ServiceAccountConfigured: profile.ServiceAccountConfigured, - FolderID: profile.FolderID, } } @@ -1333,8 +1320,6 @@ type CreateSoraS3ProfileRequest struct { ProfileID string `json:"profile_id"` Name string `json:"name"` SetActive bool `json:"set_active"` - Provider string `json:"provider"` // "s3" / "gdrive" - AccessMode string `json:"access_mode"` // "direct" / "proxy" Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` @@ -1345,19 +1330,10 @@ type CreateSoraS3ProfileRequest struct { ForcePathStyle bool `json:"force_path_style"` CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` - // Google Drive 专属 - AuthType string `json:"auth_type,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - ServiceAccountJSON string `json:"service_account_json,omitempty"` - FolderID string `json:"folder_id,omitempty"` } type UpdateSoraS3ProfileRequest struct { Name string `json:"name"` - Provider string `json:"provider"` - AccessMode string `json:"access_mode"` Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` @@ -1368,13 +1344,6 @@ type UpdateSoraS3ProfileRequest struct { ForcePathStyle bool `json:"force_path_style"` CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` - // Google Drive 专属 - AuthType string `json:"auth_type,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - ServiceAccountJSON string `json:"service_account_json,omitempty"` - FolderID string `json:"folder_id,omitempty"` } // CreateSoraS3Profile 创建 Sora S3 配置 @@ -1397,23 +1366,15 @@ func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) { response.BadRequest(c, "Profile ID is required") return } - // S3 专属字段验证:仅当 provider 为 s3(或未指定)时校验 - provider := req.Provider - if provider == "" { - provider = "s3" - } - if provider == "s3" { - if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil { - response.BadRequest(c, err.Error()) - return - } + // S3 字段验证 + if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil { + response.BadRequest(c, err.Error()) + return } created, err := h.settingService.CreateSoraS3Profile(c.Request.Context(), &service.SoraS3Profile{ ProfileID: req.ProfileID, Name: req.Name, - Provider: req.Provider, - AccessMode: req.AccessMode, Enabled: req.Enabled, Endpoint: req.Endpoint, Region: req.Region, @@ -1424,13 +1385,6 @@ func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) { ForcePathStyle: req.ForcePathStyle, CDNURL: req.CDNURL, DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes, - // Google Drive 专属 - AuthType: req.AuthType, - ClientID: req.ClientID, - ClientSecret: req.ClientSecret, - RefreshToken: req.RefreshToken, - ServiceAccountJSON: req.ServiceAccountJSON, - FolderID: req.FolderID, }, req.SetActive) if err != nil { response.ErrorFrom(c, err) @@ -1473,25 +1427,14 @@ func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) { response.ErrorFrom(c, service.ErrSoraS3ProfileNotFound) return } - // S3 专属字段验证 - provider := req.Provider - if provider == "" && existing != nil { - provider = existing.GetProvider() - } - if provider == "" { - provider = "s3" - } - if provider == "s3" { - if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil { - response.BadRequest(c, err.Error()) - return - } + // S3 字段验证 + if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil { + response.BadRequest(c, err.Error()) + return } updated, updateErr := h.settingService.UpdateSoraS3Profile(c.Request.Context(), profileID, &service.SoraS3Profile{ Name: req.Name, - Provider: req.Provider, - AccessMode: req.AccessMode, Enabled: req.Enabled, Endpoint: req.Endpoint, Region: req.Region, @@ -1502,13 +1445,6 @@ func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) { ForcePathStyle: req.ForcePathStyle, CDNURL: req.CDNURL, DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes, - // Google Drive 专属 - AuthType: req.AuthType, - ClientID: req.ClientID, - ClientSecret: req.ClientSecret, - RefreshToken: req.RefreshToken, - ServiceAccountJSON: req.ServiceAccountJSON, - FolderID: req.FolderID, }) if updateErr != nil { response.ErrorFrom(c, updateErr) @@ -1845,44 +1781,3 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) { ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes, }) } - -// GetGDriveQuota 获取 Google Drive 配额信息。 -// GET /api/v1/admin/settings/sora-storage/gdrive-quota -func (h *SettingHandler) GetGDriveQuota(c *gin.Context) { - if h.soraGDriveStorage == nil { - response.Error(c, http.StatusServiceUnavailable, "GDrive storage not configured") - return - } - quota, err := h.soraGDriveStorage.GetQuotaInfo(c.Request.Context()) - if err != nil { - response.Error(c, http.StatusInternalServerError, fmt.Sprintf("failed to get GDrive quota: %v", err)) - return - } - response.Success(c, quota) -} - -// GetStorageVideoStats 获取各存储类型的视频统计信息。 -// GET /api/v1/admin/settings/sora-storage/video-stats -func (h *SettingHandler) GetStorageVideoStats(c *gin.Context) { - if h.soraGenerationService == nil { - response.Error(c, http.StatusServiceUnavailable, "generation service not configured") - return - } - - storageTypes := []string{service.SoraStorageTypeS3, service.SoraStorageTypeGDrive} - result := make(map[string]*service.StorageVideoStats, len(storageTypes)) - - for _, st := range storageTypes { - completed, inProgress, err := h.soraGenerationService.CountByStorageType(c.Request.Context(), st) - if err != nil { - log.Printf("[SettingHandler] CountByStorageType(%s) error: %v", st, err) - continue - } - result[st] = &service.StorageVideoStats{ - Completed: completed, - InProgress: inProgress, - } - } - - response.Success(c, result) -} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 9e206b9de5..dfcc75cec5 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -152,8 +152,6 @@ type SoraS3Profile struct { ProfileID string `json:"profile_id"` Name string `json:"name"` IsActive bool `json:"is_active"` - Provider string `json:"provider"` // "s3" / "gdrive" - AccessMode string `json:"access_mode"` // "direct" / "proxy" Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` @@ -165,14 +163,6 @@ type SoraS3Profile struct { CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` UpdatedAt string `json:"updated_at"` - - // --- Google Drive 专属 --- - AuthType string `json:"auth_type,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecretConfigured bool `json:"client_secret_configured"` - RefreshTokenConfigured bool `json:"refresh_token_configured"` - ServiceAccountConfigured bool `json:"service_account_configured"` - FolderID string `json:"folder_id,omitempty"` } // ListSoraS3ProfilesResponse Sora S3 配置列表响应 diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 9f4b163f33..125159e1b0 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -30,7 +30,6 @@ type AdminHandlers struct { TLSFingerprintProfile *admin.TLSFingerprintProfileHandler APIKey *admin.AdminAPIKeyHandler ScheduledTest *admin.ScheduledTestHandler - GDriveOAuth *admin.GDriveOAuthHandler Channel *admin.ChannelHandler } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 83e05157a1..6377a5c0f0 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -20,7 +20,6 @@ func ProvideAdminHandlers( openaiOAuthHandler *admin.OpenAIOAuthHandler, geminiOAuthHandler *admin.GeminiOAuthHandler, antigravityOAuthHandler *admin.AntigravityOAuthHandler, - gdriveOAuthHandler *admin.GDriveOAuthHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, promoHandler *admin.PromoHandler, @@ -61,7 +60,6 @@ func ProvideAdminHandlers( TLSFingerprintProfile: tlsFingerprintProfileHandler, APIKey: apiKeyHandler, ScheduledTest: scheduledTestHandler, - GDriveOAuth: gdriveOAuthHandler, Channel: channelHandler, } } @@ -156,7 +154,6 @@ var ProviderSet = wire.NewSet( admin.NewTLSFingerprintProfileHandler, admin.NewAdminAPIKeyHandler, admin.NewScheduledTestHandler, - admin.NewGDriveOAuthHandler, admin.NewChannelHandler, // AdminHandlers and Handlers constructors diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 021bade659..db78021ff0 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -442,13 +442,6 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.PUT("/sora-storage/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile) adminSettings.DELETE("/sora-storage/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile) adminSettings.POST("/sora-storage/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile) - // Google Drive OAuth - adminSettings.POST("/sora-storage/gdrive-oauth/start", h.Admin.GDriveOAuth.StartOAuth) - adminSettings.POST("/sora-storage/gdrive-oauth/callback", h.Admin.GDriveOAuth.OAuthCallback) - adminSettings.POST("/sora-storage/gdrive-test", h.Admin.GDriveOAuth.TestGDriveStorage) - // Sora 存储统计 - adminSettings.GET("/sora-storage/gdrive-quota", h.Admin.Setting.GetGDriveQuota) - adminSettings.GET("/sora-storage/video-stats", h.Admin.Setting.GetStorageVideoStats) } } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 64f6b8353a..1a24bad149 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -227,49 +227,6 @@ func (s *SettingService) SetOnS3UpdateCallback(callback func()) { s.onS3Update = callback } -// SetOnStorageUpdateCallback 设置存储配置变更时的回调函数(用于刷新所有存储客户端缓存)。 -// 替代 SetOnS3UpdateCallback,支持 S3 + GDrive 统一刷新。 -func (s *SettingService) SetOnStorageUpdateCallback(callback func()) { - s.onS3Update = callback -} - -// --- 统一存储 Profile 方法别名 --- - -// ListSoraStorageProfiles 获取 Sora 存储多配置列表(统一方法名)。 -func (s *SettingService) ListSoraStorageProfiles(ctx context.Context) (*SoraS3ProfileList, error) { - return s.ListSoraS3Profiles(ctx) -} - -// CreateSoraStorageProfile 创建 Sora 存储配置(统一方法名)。 -func (s *SettingService) CreateSoraStorageProfile(ctx context.Context, profile *SoraS3Profile, setActive bool) (*SoraS3Profile, error) { - return s.CreateSoraS3Profile(ctx, profile, setActive) -} - -// UpdateSoraStorageProfile 更新 Sora 存储配置(统一方法名)。 -func (s *SettingService) UpdateSoraStorageProfile(ctx context.Context, profileID string, profile *SoraS3Profile) (*SoraS3Profile, error) { - return s.UpdateSoraS3Profile(ctx, profileID, profile) -} - -// DeleteSoraStorageProfile 删除 Sora 存储配置(统一方法名)。 -func (s *SettingService) DeleteSoraStorageProfile(ctx context.Context, profileID string) error { - return s.DeleteSoraS3Profile(ctx, profileID) -} - -// SetActiveSoraStorageProfile 设置激活的 Sora 存储配置(统一方法名)。 -func (s *SettingService) SetActiveSoraStorageProfile(ctx context.Context, profileID string) (*SoraS3Profile, error) { - return s.SetActiveSoraS3Profile(ctx, profileID) -} - -// GetActiveStorageProfile 获取当前激活的存储配置 profile。 -func (s *SettingService) GetActiveStorageProfile(ctx context.Context) (*SoraS3Profile, error) { - profiles, err := s.ListSoraS3Profiles(ctx) - if err != nil { - return nil, err - } - active := pickActiveSoraS3Profile(profiles.Items, profiles.ActiveProfileID) - return active, nil -} - // SetVersion sets the application version for injection into public settings func (s *SettingService) SetVersion(version string) { s.version = version @@ -1635,8 +1592,6 @@ type soraS3ProfilesStore struct { type soraS3ProfileStoreItem struct { ProfileID string `json:"profile_id"` Name string `json:"name"` - Provider string `json:"provider,omitempty"` // "s3" / "gdrive",空值视为 "s3" - AccessMode string `json:"access_mode,omitempty"` // "direct" / "proxy",空值视为 "direct" Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` @@ -1648,14 +1603,6 @@ type soraS3ProfileStoreItem struct { CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` UpdatedAt string `json:"updated_at"` - - // --- Google Drive 专属 --- - AuthType string `json:"auth_type,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - ServiceAccountJSON string `json:"service_account_json,omitempty"` - FolderID string `json:"folder_id,omitempty"` } // GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置语义:返回当前激活配置) @@ -1767,8 +1714,6 @@ func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS store.Items = append(store.Items, soraS3ProfileStoreItem{ ProfileID: profileID, Name: name, - Provider: profile.Provider, - AccessMode: profile.AccessMode, Enabled: profile.Enabled, Endpoint: strings.TrimSpace(profile.Endpoint), Region: strings.TrimSpace(profile.Region), @@ -1780,13 +1725,6 @@ func (s *SettingService) CreateSoraS3Profile(ctx context.Context, profile *SoraS CDNURL: strings.TrimSpace(profile.CDNURL), DefaultStorageQuotaBytes: maxInt64(profile.DefaultStorageQuotaBytes, 0), UpdatedAt: now, - // Google Drive 专属 - AuthType: profile.AuthType, - ClientID: strings.TrimSpace(profile.ClientID), - ClientSecret: profile.ClientSecret, - RefreshToken: profile.RefreshToken, - ServiceAccountJSON: profile.ServiceAccountJSON, - FolderID: strings.TrimSpace(profile.FolderID), }) if setActive || store.ActiveProfileID == "" { @@ -1832,8 +1770,6 @@ func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID stri return nil, infraerrors.BadRequest("SORA_S3_PROFILE_NAME_REQUIRED", "name is required") } target.Name = name - target.Provider = profile.Provider - target.AccessMode = profile.AccessMode target.Enabled = profile.Enabled target.Endpoint = strings.TrimSpace(profile.Endpoint) target.Region = strings.TrimSpace(profile.Region) @@ -1846,19 +1782,6 @@ func (s *SettingService) UpdateSoraS3Profile(ctx context.Context, profileID stri if profile.SecretAccessKey != "" { target.SecretAccessKey = profile.SecretAccessKey } - // Google Drive 专属 - target.AuthType = profile.AuthType - target.ClientID = strings.TrimSpace(profile.ClientID) - if profile.ClientSecret != "" { - target.ClientSecret = profile.ClientSecret - } - if profile.RefreshToken != "" { - target.RefreshToken = profile.RefreshToken - } - if profile.ServiceAccountJSON != "" { - target.ServiceAccountJSON = profile.ServiceAccountJSON - } - target.FolderID = strings.TrimSpace(profile.FolderID) target.UpdatedAt = time.Now().UTC().Format(time.RFC3339) store.Items[targetIndex] = target @@ -2161,8 +2084,6 @@ func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList { ProfileID: item.ProfileID, Name: item.Name, IsActive: item.ProfileID == store.ActiveProfileID, - Provider: item.Provider, - AccessMode: item.AccessMode, Enabled: item.Enabled, Endpoint: item.Endpoint, Region: item.Region, @@ -2175,16 +2096,6 @@ func convertSoraS3ProfilesStore(store *soraS3ProfilesStore) *SoraS3ProfileList { CDNURL: item.CDNURL, DefaultStorageQuotaBytes: item.DefaultStorageQuotaBytes, UpdatedAt: item.UpdatedAt, - // Google Drive 专属 - AuthType: item.AuthType, - ClientID: item.ClientID, - ClientSecret: item.ClientSecret, - ClientSecretConfigured: item.ClientSecret != "", - RefreshToken: item.RefreshToken, - RefreshTokenConfigured: item.RefreshToken != "", - ServiceAccountJSON: item.ServiceAccountJSON, - ServiceAccountConfigured: item.ServiceAccountJSON != "", - FolderID: item.FolderID, }) } return &SoraS3ProfileList{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 580c107d9d..411939bb62 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -136,8 +136,6 @@ type SoraS3Profile struct { ProfileID string `json:"profile_id"` Name string `json:"name"` IsActive bool `json:"is_active"` - Provider string `json:"provider"` // "s3" / "gdrive",空值视为 "s3" - AccessMode string `json:"access_mode"` // "direct" / "proxy",空值视为 "direct" Enabled bool `json:"enabled"` Endpoint string `json:"endpoint"` Region string `json:"region"` @@ -150,25 +148,6 @@ type SoraS3Profile struct { CDNURL string `json:"cdn_url"` DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` UpdatedAt string `json:"updated_at"` - - // --- Google Drive 专属 --- - AuthType string `json:"auth_type,omitempty"` // "oauth2" / "service_account" - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"-"` - ClientSecretConfigured bool `json:"client_secret_configured"` - RefreshToken string `json:"-"` - RefreshTokenConfigured bool `json:"refresh_token_configured"` - ServiceAccountJSON string `json:"-"` - ServiceAccountConfigured bool `json:"service_account_configured"` - FolderID string `json:"folder_id,omitempty"` -} - -// GetProvider 返回 Provider,空值视为 "s3"。 -func (p *SoraS3Profile) GetProvider() string { - if p.Provider == "" { - return SoraStorageTypeS3 - } - return p.Provider } // SoraS3ProfileList Sora S3 多配置列表 diff --git a/backend/internal/service/sora_gdrive_oauth.go b/backend/internal/service/sora_gdrive_oauth.go deleted file mode 100644 index 61282053ca..0000000000 --- a/backend/internal/service/sora_gdrive_oauth.go +++ /dev/null @@ -1,71 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - "google.golang.org/api/drive/v3" -) - -// SoraGDriveOAuthService 处理 Google Drive OAuth2 授权流程。 -type SoraGDriveOAuthService struct{} - -// NewSoraGDriveOAuthService 创建 GDrive OAuth 服务。 -func NewSoraGDriveOAuthService(_ *SettingService) *SoraGDriveOAuthService { - return &SoraGDriveOAuthService{} -} - -// GenerateAuthURL 生成 Google OAuth 授权 URL。 -func (s *SoraGDriveOAuthService) GenerateAuthURL(clientID, clientSecret, redirectURI string) (authURL, state string, err error) { - if clientID == "" || clientSecret == "" || redirectURI == "" { - return "", "", fmt.Errorf("client_id, client_secret, redirect_uri are required") - } - - config := &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - Endpoint: google.Endpoint, - Scopes: []string{drive.DriveFileScope}, - RedirectURL: redirectURI, - } - - // 生成随机 state - stateBytes := make([]byte, 16) - if _, err := rand.Read(stateBytes); err != nil { - return "", "", fmt.Errorf("generate state: %w", err) - } - state = hex.EncodeToString(stateBytes) - - authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) - return authURL, state, nil -} - -// ExchangeCode 用授权码换取 refresh_token。 -func (s *SoraGDriveOAuthService) ExchangeCode(ctx context.Context, clientID, clientSecret, redirectURI, code string) (string, error) { - if code == "" { - return "", fmt.Errorf("authorization code is required") - } - - config := &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - Endpoint: google.Endpoint, - Scopes: []string{drive.DriveFileScope}, - RedirectURL: redirectURI, - } - - token, err := config.Exchange(ctx, code) - if err != nil { - return "", fmt.Errorf("exchange code: %w", err) - } - - if token.RefreshToken == "" { - return "", fmt.Errorf("no refresh_token received, please revoke app access and try again") - } - - return token.RefreshToken, nil -} diff --git a/backend/internal/service/sora_gdrive_storage.go b/backend/internal/service/sora_gdrive_storage.go deleted file mode 100644 index afbc3ea5c9..0000000000 --- a/backend/internal/service/sora_gdrive_storage.go +++ /dev/null @@ -1,433 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - "google.golang.org/api/drive/v3" - "google.golang.org/api/option" - - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" -) - -// SoraGDriveStorage 负责 Sora 媒体文件的 Google Drive 存储操作。 -type SoraGDriveStorage struct { - settingService *SettingService - - mu sync.RWMutex - srv *drive.Service - cfg *SoraS3Profile // 缓存当前 GDrive 配置 - healthCheckedAt time.Time - healthErr error - healthTTL time.Duration -} - -const defaultGDriveHealthTTL = 30 * time.Second - -// NewSoraGDriveStorage 创建 Google Drive 存储服务实例。 -func NewSoraGDriveStorage(settingService *SettingService) *SoraGDriveStorage { - return &SoraGDriveStorage{ - settingService: settingService, - healthTTL: defaultGDriveHealthTTL, - } -} - -// StorageType 返回存储类型标识。 -func (s *SoraGDriveStorage) StorageType() string { - return SoraStorageTypeGDrive -} - -// Enabled 返回 Google Drive 存储是否已启用。 -func (s *SoraGDriveStorage) Enabled(ctx context.Context) bool { - profile := s.getActiveGDriveProfile(ctx) - if profile == nil { - return false - } - return profile.Enabled && s.hasValidCredentials(profile) -} - -// getActiveGDriveProfile 获取当前激活的 GDrive 配置。 -func (s *SoraGDriveStorage) getActiveGDriveProfile(ctx context.Context) *SoraS3Profile { - if s.settingService == nil { - return nil - } - profile, err := s.settingService.GetActiveStorageProfile(ctx) - if err != nil || profile == nil { - return nil - } - if profile.GetProvider() != SoraStorageTypeGDrive { - return nil - } - return profile -} - -// hasValidCredentials 检查 GDrive 配置是否有有效凭证。 -func (s *SoraGDriveStorage) hasValidCredentials(profile *SoraS3Profile) bool { - switch profile.AuthType { - case "oauth2": - return profile.ClientID != "" && profile.ClientSecret != "" && profile.RefreshToken != "" - case "service_account": - return profile.ServiceAccountJSON != "" - default: - return false - } -} - -// getService 获取或初始化 Drive 服务(带缓存)。 -func (s *SoraGDriveStorage) getService(ctx context.Context) (*drive.Service, *SoraS3Profile, error) { - s.mu.RLock() - if s.srv != nil && s.cfg != nil { - srv, cfg := s.srv, s.cfg - s.mu.RUnlock() - return srv, cfg, nil - } - s.mu.RUnlock() - - return s.initService(ctx) -} - -func (s *SoraGDriveStorage) initService(ctx context.Context) (*drive.Service, *SoraS3Profile, error) { - s.mu.Lock() - defer s.mu.Unlock() - - // 双重检查 - if s.srv != nil && s.cfg != nil { - return s.srv, s.cfg, nil - } - - profile := s.getActiveGDriveProfile(ctx) - if profile == nil { - return nil, nil, fmt.Errorf("no active gdrive profile found") - } - if !profile.Enabled { - return nil, nil, fmt.Errorf("gdrive storage is disabled") - } - - srv, err := s.buildDriveService(ctx, profile) - if err != nil { - return nil, nil, fmt.Errorf("build gdrive service: %w", err) - } - - s.srv = srv - s.cfg = profile - logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 客户端已初始化 auth_type=%s folder_id=%s", profile.AuthType, profile.FolderID) - return srv, profile, nil -} - -// buildDriveService 根据认证类型创建 Google Drive 服务。 -func (s *SoraGDriveStorage) buildDriveService(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) { - switch profile.AuthType { - case "oauth2": - return s.buildOAuth2Service(ctx, profile) - case "service_account": - return s.buildServiceAccountService(ctx, profile) - default: - return nil, fmt.Errorf("unsupported auth_type: %s", profile.AuthType) - } -} - -func (s *SoraGDriveStorage) buildOAuth2Service(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) { - config := &oauth2.Config{ - ClientID: profile.ClientID, - ClientSecret: profile.ClientSecret, - Endpoint: google.Endpoint, - Scopes: []string{drive.DriveFileScope}, - } - token := &oauth2.Token{ - RefreshToken: profile.RefreshToken, - } - tokenSource := config.TokenSource(ctx, token) - srv, err := drive.NewService(ctx, option.WithTokenSource(tokenSource)) - if err != nil { - return nil, fmt.Errorf("create gdrive oauth2 service: %w", err) - } - return srv, nil -} - -func (s *SoraGDriveStorage) buildServiceAccountService(ctx context.Context, profile *SoraS3Profile) (*drive.Service, error) { - srv, err := drive.NewService(ctx, option.WithCredentialsJSON([]byte(profile.ServiceAccountJSON))) //nolint:staticcheck // SA1019: admin-controlled service account JSON, safe to use - if err != nil { - return nil, fmt.Errorf("create gdrive service account service: %w", err) - } - return srv, nil -} - -// RefreshClient 清除缓存的 Drive 客户端。 -func (s *SoraGDriveStorage) RefreshClient() { - s.mu.Lock() - defer s.mu.Unlock() - s.srv = nil - s.cfg = nil - s.healthCheckedAt = time.Time{} - s.healthErr = nil - logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 客户端缓存已清除") -} - -// GDriveQuotaInfo 包含 Google Drive 配额信息。 -type GDriveQuotaInfo struct { - LimitBytes int64 `json:"limit_bytes"` - UsedBytes int64 `json:"used_bytes"` -} - -// TestConnection 测试 Google Drive 连接。 -func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error { - srv, _, err := s.getService(ctx) - if err != nil { - return err - } - _, err = srv.About.Get().Fields("storageQuota").Context(ctx).Do() - if err != nil { - return fmt.Errorf("gdrive About.Get failed: %w", err) - } - return nil -} - -// GetQuotaInfo 获取 Google Drive 配额信息(总量和已用量)。 -func (s *SoraGDriveStorage) GetQuotaInfo(ctx context.Context) (*GDriveQuotaInfo, error) { - srv, _, err := s.getService(ctx) - if err != nil { - return nil, err - } - about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do() - if err != nil { - return nil, fmt.Errorf("gdrive About.Get failed: %w", err) - } - if about.StorageQuota == nil { - return nil, fmt.Errorf("storageQuota not available") - } - return &GDriveQuotaInfo{ - LimitBytes: about.StorageQuota.Limit, - UsedBytes: about.StorageQuota.Usage, - }, nil -} - -// TestFullCycle 执行完整的上传→获取链接→删除测试。 -func (s *SoraGDriveStorage) TestFullCycle(ctx context.Context) (map[string]any, error) { - srv, cfg, err := s.getService(ctx) - if err != nil { - return nil, fmt.Errorf("init client: %w", err) - } - - result := map[string]any{} - - // 1. 测试 API 连接 - about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do() - if err != nil { - return nil, fmt.Errorf("API connection failed: %w", err) - } - if about.StorageQuota != nil { - result["quota_limit_bytes"] = about.StorageQuota.Limit - result["quota_used_bytes"] = about.StorageQuota.Usage - } - - // 2. 上传测试文件 - testContent := "sub2api GDrive test file - " + time.Now().Format(time.RFC3339) - fileMeta := &drive.File{ - Name: "sub2api_test_" + uuid.NewString()[:8] + ".txt", - MimeType: "text/plain", - } - if cfg.FolderID != "" { - fileMeta.Parents = []string{cfg.FolderID} - } - uploaded, err := srv.Files.Create(fileMeta). - Media(strings.NewReader(testContent)). - Fields("id,name,size,webViewLink"). - Context(ctx).Do() - if err != nil { - return nil, fmt.Errorf("upload test file failed: %w", err) - } - result["uploaded_file_id"] = uploaded.Id - result["uploaded_file_name"] = uploaded.Name - result["uploaded_file_size"] = uploaded.Size - result["web_view_link"] = uploaded.WebViewLink - - // 3. 获取访问链接 - accessURL, err := s.GetAccessURL(ctx, uploaded.Id) - if err != nil { - // 即使获取链接失败,仍尝试清理 - _ = srv.Files.Delete(uploaded.Id).Context(ctx).Do() - return nil, fmt.Errorf("get access URL failed: %w", err) - } - result["access_url"] = accessURL - - // 4. 删除测试文件 - if err := srv.Files.Delete(uploaded.Id).Context(ctx).Do(); err != nil { - result["delete_warning"] = fmt.Sprintf("delete failed (manual cleanup needed): %v", err) - } else { - result["deleted"] = true - } - - result["status"] = "ok" - return result, nil -} - -// IsHealthy 返回 Google Drive 健康状态(带短缓存)。 -func (s *SoraGDriveStorage) IsHealthy(ctx context.Context) bool { - if s == nil { - return false - } - now := time.Now() - s.mu.RLock() - lastCheck := s.healthCheckedAt - lastErr := s.healthErr - ttl := s.healthTTL - s.mu.RUnlock() - - if ttl <= 0 { - ttl = defaultGDriveHealthTTL - } - if !lastCheck.IsZero() && now.Sub(lastCheck) < ttl { - return lastErr == nil - } - - err := s.TestConnection(ctx) - s.mu.Lock() - s.healthCheckedAt = time.Now() - s.healthErr = err - s.mu.Unlock() - return err == nil -} - -// UploadFromURL 从上游 URL 下载并上传到 Google Drive。 -// 返回 Google Drive 文件 ID 作为 objectKey、文件大小、存储类型。 -func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) { - srv, cfg, err := s.getService(ctx) - if err != nil { - return "", 0, "", err - } - - // 下载源文件 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) - if err != nil { - return "", 0, "", fmt.Errorf("create download request: %w", err) - } - httpClient := &http.Client{Timeout: 5 * time.Minute} - resp, err := httpClient.Do(req) - if err != nil { - return "", 0, "", fmt.Errorf("download from upstream: %w", err) - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode} - } - - // 推断文件扩展名和 MIME - ext := fileExtFromURL(sourceURL) - if ext == "" { - ext = fileExtFromContentType(resp.Header.Get("Content-Type")) - } - if ext == "" { - ext = ".bin" - } - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/octet-stream" - } - - // 生成文件名 - datePath := time.Now().Format("2006-01-02") - fileName := fmt.Sprintf("sora_%d_%s_%s%s", userID, datePath, uuid.NewString()[:8], ext) - - // 创建文件元数据 - fileMeta := &drive.File{ - Name: fileName, - MimeType: contentType, - } - if cfg.FolderID != "" { - fileMeta.Parents = []string{cfg.FolderID} - } - - // 使用 CountingReader 统计大小 - cr := &countingReader{Reader: resp.Body} - - // 上传到 Google Drive - created, err := srv.Files.Create(fileMeta). - Media(cr). - Fields("id, size"). - Context(ctx). - Do() - if err != nil { - return "", 0, "", fmt.Errorf("gdrive upload: %w", err) - } - - fileSize := cr.BytesRead - if created.Size > 0 { - fileSize = created.Size - } - - // 根据 access_mode 设置权限 - if cfg.AccessMode == "" || cfg.AccessMode == "direct" { - // 设为任何人可读 - _, permErr := srv.Permissions.Create(created.Id, &drive.Permission{ - Type: "anyone", - Role: "reader", - }).Context(ctx).Do() - if permErr != nil { - logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 设置公开权限失败 fileID=%s err=%v", created.Id, permErr) - } - } - - logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 上传完成 fileID=%s size=%d", created.Id, fileSize) - return created.Id, fileSize, SoraStorageTypeGDrive, nil -} - -// DeleteObjects 删除一组 Google Drive 文件。 -func (s *SoraGDriveStorage) DeleteObjects(ctx context.Context, objectKeys []string) error { - if len(objectKeys) == 0 { - return nil - } - - srv, _, err := s.getService(ctx) - if err != nil { - return err - } - - var lastErr error - for _, fileID := range objectKeys { - if err := srv.Files.Delete(fileID).Context(ctx).Do(); err != nil { - logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 删除失败 fileID=%s err=%v", fileID, err) - lastErr = err - } - } - return lastErr -} - -// GetAccessURL 获取 Google Drive 文件的访问 URL。 -func (s *SoraGDriveStorage) GetAccessURL(ctx context.Context, objectKey string) (string, error) { - _, cfg, err := s.getService(ctx) - if err != nil { - return "", err - } - - // CDN URL 优先 - if cfg.CDNURL != "" { - cdnBase := strings.TrimRight(cfg.CDNURL, "/") - return cdnBase + "/" + objectKey, nil - } - - // 默认使用 Google Drive 直链 - return fmt.Sprintf("https://drive.google.com/uc?export=download&id=%s", objectKey), nil -} - -// countingReader 包装 io.Reader 以统计读取的字节数。 -type countingReader struct { - Reader io.Reader - BytesRead int64 -} - -func (r *countingReader) Read(p []byte) (int, error) { - n, err := r.Reader.Read(p) - r.BytesRead += int64(n) - return n, err -} diff --git a/backend/internal/service/sora_gdrive_storage_test.go b/backend/internal/service/sora_gdrive_storage_test.go deleted file mode 100644 index e82f70c903..0000000000 --- a/backend/internal/service/sora_gdrive_storage_test.go +++ /dev/null @@ -1,124 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSoraGDriveStorage_StorageType(t *testing.T) { - s := NewSoraGDriveStorage(nil) - assert.Equal(t, SoraStorageTypeGDrive, s.StorageType()) -} - -func TestSoraGDriveStorage_EnabledWithNilSettingService(t *testing.T) { - s := NewSoraGDriveStorage(nil) - assert.False(t, s.Enabled(context.Background())) -} - -func TestSoraGDriveStorage_IsHealthyWithNilReceiver(t *testing.T) { - var s *SoraGDriveStorage - assert.False(t, s.IsHealthy(context.Background())) -} - -func TestSoraGDriveStorage_GetServiceWithoutProfile(t *testing.T) { - s := NewSoraGDriveStorage(nil) - _, _, err := s.getService(context.Background()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no active gdrive profile") -} - -func TestSoraGDriveStorage_DeleteObjectsEmpty(t *testing.T) { - s := NewSoraGDriveStorage(nil) - err := s.DeleteObjects(context.Background(), []string{}) - assert.NoError(t, err) -} - -func TestSoraGDriveStorage_RefreshClient(t *testing.T) { - s := NewSoraGDriveStorage(nil) - // 不应 panic - s.RefreshClient() - assert.Nil(t, s.srv) - assert.Nil(t, s.cfg) -} - -func TestSoraGDriveStorage_HasValidCredentials(t *testing.T) { - s := NewSoraGDriveStorage(nil) - - tests := []struct { - name string - profile *SoraS3Profile - want bool - }{ - { - name: "oauth2 with all fields", - profile: &SoraS3Profile{ - AuthType: "oauth2", - ClientID: "id", - ClientSecret: "secret", - RefreshToken: "token", - }, - want: true, - }, - { - name: "oauth2 missing refresh token", - profile: &SoraS3Profile{ - AuthType: "oauth2", - ClientID: "id", - ClientSecret: "secret", - }, - want: false, - }, - { - name: "service_account with json", - profile: &SoraS3Profile{ - AuthType: "service_account", - ServiceAccountJSON: `{"type":"service_account"}`, - }, - want: true, - }, - { - name: "service_account without json", - profile: &SoraS3Profile{ - AuthType: "service_account", - }, - want: false, - }, - { - name: "unknown auth type", - profile: &SoraS3Profile{ - AuthType: "unknown", - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := s.hasValidCredentials(tt.profile) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestSoraStorageRouter_DefaultsToS3(t *testing.T) { - s3 := NewSoraS3Storage(nil) - router := NewSoraStorageRouter(nil, s3, nil) - // settingService 为 nil,应返回 s3Storage - backend := router.activeBackend(context.Background()) - assert.Equal(t, s3, backend) -} - -func TestSoraStorageRouter_StorageType(t *testing.T) { - router := NewSoraStorageRouter(nil, nil, nil) - assert.Equal(t, SoraStorageTypeS3, router.StorageType()) -} - -func TestSoraStorageRouter_RefreshAllNoPanic(t *testing.T) { - router := NewSoraStorageRouter(nil, nil, nil) - // 不应 panic - router.RefreshAll() -} diff --git a/backend/internal/service/sora_object_storage.go b/backend/internal/service/sora_object_storage.go index f9e9a4b03e..b40a7bcd7e 100644 --- a/backend/internal/service/sora_object_storage.go +++ b/backend/internal/service/sora_object_storage.go @@ -3,7 +3,6 @@ package service import "context" // SoraObjectStorage 是 Sora 媒体文件的通用对象存储接口。 -// S3 和 Google Drive 等存储后端均实现此接口。 type SoraObjectStorage interface { // Enabled 返回存储是否已启用且配置有效。 Enabled(ctx context.Context) bool @@ -15,7 +14,7 @@ type SoraObjectStorage interface { TestConnection(ctx context.Context) error // UploadFromURL 从上游 URL 下载并上传到存储。 - // 返回 object key(S3 key 或 GDrive file ID)、文件大小、实际使用的存储类型。 + // 返回 object key、文件大小、实际使用的存储类型。 UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, storageType string, err error) // DeleteObjects 删除一组存储对象。 @@ -27,11 +26,11 @@ type SoraObjectStorage interface { // RefreshClient 清除缓存客户端,配置变更时调用。 RefreshClient() - // StorageType 返回存储类型标识("s3" / "gdrive")。 + // StorageType 返回存储类型标识。 StorageType() string } -// IsObjectStorageType 判断是否为对象存储类型(S3 或 Google Drive)。 +// IsObjectStorageType 判断是否为对象存储类型。 func IsObjectStorageType(t string) bool { - return t == SoraStorageTypeS3 || t == SoraStorageTypeGDrive + return t == SoraStorageTypeS3 } diff --git a/backend/internal/service/sora_storage_router.go b/backend/internal/service/sora_storage_router.go deleted file mode 100644 index 83e055f5df..0000000000 --- a/backend/internal/service/sora_storage_router.go +++ /dev/null @@ -1,129 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" -) - -// SoraStorageRouter 根据激活 profile 的 provider 字段路由到对应存储实现。 -// 实现 SoraObjectStorage 接口。 -type SoraStorageRouter struct { - settingService *SettingService - s3Storage *SoraS3Storage - gdriveStorage SoraObjectStorage // 可为 nil(GDrive 未实现时) -} - -// NewSoraStorageRouter 创建存储路由。 -func NewSoraStorageRouter( - settingService *SettingService, - s3Storage *SoraS3Storage, - gdriveStorage SoraObjectStorage, -) *SoraStorageRouter { - return &SoraStorageRouter{ - settingService: settingService, - s3Storage: s3Storage, - gdriveStorage: gdriveStorage, - } -} - -// activeBackend 返回当前激活 profile 对应的存储后端。 -func (r *SoraStorageRouter) activeBackend(ctx context.Context) SoraObjectStorage { - if r.settingService == nil { - return r.s3Storage // 默认 S3 - } - - profile, err := r.settingService.GetActiveStorageProfile(ctx) - if err != nil || profile == nil { - return r.s3Storage // 默认 S3 - } - - switch profile.GetProvider() { - case SoraStorageTypeGDrive: - if r.gdriveStorage != nil { - return r.gdriveStorage - } - logger.LegacyPrintf("service.storage_router", "[StorageRouter] GDrive 后端未初始化,降级到 S3") - return r.s3Storage - default: - return r.s3Storage - } -} - -func (r *SoraStorageRouter) Enabled(ctx context.Context) bool { - backend := r.activeBackend(ctx) - if backend == nil { - return false - } - return backend.Enabled(ctx) -} - -func (r *SoraStorageRouter) IsHealthy(ctx context.Context) bool { - backend := r.activeBackend(ctx) - if backend == nil { - return false - } - return backend.IsHealthy(ctx) -} - -func (r *SoraStorageRouter) TestConnection(ctx context.Context) error { - backend := r.activeBackend(ctx) - if backend == nil { - return fmt.Errorf("no storage backend available") - } - return backend.TestConnection(ctx) -} - -func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) { - backend := r.activeBackend(ctx) - if backend == nil { - return "", 0, "", fmt.Errorf("no storage backend available") - } - return backend.UploadFromURL(ctx, userID, sourceURL) -} - -func (r *SoraStorageRouter) DeleteObjects(ctx context.Context, objectKeys []string) error { - backend := r.activeBackend(ctx) - if backend == nil { - return fmt.Errorf("no storage backend available") - } - return backend.DeleteObjects(ctx, objectKeys) -} - -func (r *SoraStorageRouter) GetAccessURL(ctx context.Context, objectKey string) (string, error) { - backend := r.activeBackend(ctx) - if backend == nil { - return "", fmt.Errorf("no storage backend available") - } - return backend.GetAccessURL(ctx, objectKey) -} - -func (r *SoraStorageRouter) RefreshClient() { - if r.s3Storage != nil { - r.s3Storage.RefreshClient() - } - if r.gdriveStorage != nil { - r.gdriveStorage.RefreshClient() - } -} - -// RefreshAll 刷新所有后端客户端(用作配置变更回调)。 -func (r *SoraStorageRouter) RefreshAll() { - r.RefreshClient() -} - -func (r *SoraStorageRouter) StorageType() string { - // 不带 context 的方法,返回默认值 - // 真实的 StorageType 在 activeBackend 中动态确定 - return SoraStorageTypeS3 -} - -// StorageTypeWithContext 返回当前激活后端的存储类型。 -func (r *SoraStorageRouter) StorageTypeWithContext(ctx context.Context) string { - backend := r.activeBackend(ctx) - if backend == nil { - return SoraStorageTypeS3 - } - return backend.StorageType() -} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 16b99ba9c1..a7f05e33f9 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -413,9 +413,7 @@ export interface SoraS3Profile { profile_id: string name: string is_active: boolean - provider: string // "s3" | "gdrive" enabled: boolean - access_mode: string // "direct" | "proxy" endpoint: string region: string bucket: string @@ -426,13 +424,6 @@ export interface SoraS3Profile { cdn_url: string default_storage_quota_bytes: number updated_at: string - // Google Drive fields - auth_type: string // "oauth2" | "service_account" - client_id: string - client_secret_configured: boolean - refresh_token_configured: boolean - service_account_configured: boolean - folder_id: string } export interface ListSoraS3ProfilesResponse { @@ -458,9 +449,7 @@ export interface CreateSoraS3ProfileRequest { profile_id: string name: string set_active?: boolean - provider?: string enabled: boolean - access_mode?: string endpoint?: string region?: string bucket?: string @@ -470,19 +459,11 @@ export interface CreateSoraS3ProfileRequest { force_path_style?: boolean cdn_url?: string default_storage_quota_bytes?: number - // Google Drive fields - auth_type?: string - client_id?: string - client_secret?: string - refresh_token?: string - service_account_json?: string - folder_id?: string } export interface UpdateSoraS3ProfileRequest { name: string enabled: boolean - access_mode?: string endpoint?: string region?: string bucket?: string @@ -492,13 +473,6 @@ export interface UpdateSoraS3ProfileRequest { force_path_style?: boolean cdn_url?: string default_storage_quota_bytes?: number - // Google Drive fields - auth_type?: string - client_id?: string - client_secret?: string - refresh_token?: string - service_account_json?: string - folder_id?: string } export interface TestSoraS3ConnectionRequest { @@ -515,30 +489,6 @@ export interface TestSoraS3ConnectionRequest { default_storage_quota_bytes?: number } -export interface StartGDriveOAuthRequest { - client_id: string - client_secret: string - redirect_uri: string -} - -export interface StartGDriveOAuthResponse { - auth_url: string - state: string -} - -export interface ExchangeGDriveOAuthCodeRequest { - client_id: string - client_secret: string - redirect_uri: string - code: string - profile_id?: string -} - -export interface ExchangeGDriveOAuthCodeResponse { - refresh_token: string - message: string -} - export async function getSoraS3Settings(): Promise { const { data } = await apiClient.get('/admin/settings/sora-storage') return data @@ -580,53 +530,6 @@ export async function setActiveSoraS3Profile(profileID: string): Promise { - const { data } = await apiClient.post('/admin/settings/sora-storage/gdrive-oauth/start', request) - return data -} - -export async function exchangeGDriveOAuthCode(request: ExchangeGDriveOAuthCodeRequest): Promise { - const { data } = await apiClient.post('/admin/settings/sora-storage/gdrive-oauth/callback', request) - return data -} - -export interface TestGDriveStorageResponse { - status: string - quota_limit_bytes?: number - quota_used_bytes?: number - uploaded_file_id?: string - uploaded_file_name?: string - uploaded_file_size?: number - access_url?: string - web_view_link?: string - deleted?: boolean - delete_warning?: string -} - -export async function testGDriveStorage(): Promise { - const { data } = await apiClient.post('/admin/settings/sora-storage/gdrive-test') - return data -} - -export interface GDriveQuotaInfo { - limit_bytes: number - used_bytes: number -} - -export interface StorageVideoStats { - [type: string]: { completed: number; in_progress: number } -} - -export async function getGDriveQuota(): Promise { - const { data } = await apiClient.get('/admin/settings/sora-storage/gdrive-quota') - return data -} - -export async function getStorageVideoStats(): Promise { - const { data } = await apiClient.get('/admin/settings/sora-storage/video-stats') - return data -} - export const settingsAPI = { getSettings, updateSettings, @@ -650,12 +553,7 @@ export const settingsAPI = { createSoraS3Profile, updateSoraS3Profile, deleteSoraS3Profile, - setActiveSoraS3Profile, - startGDriveOAuth, - exchangeGDriveOAuthCode, - testGDriveStorage, - getGDriveQuota, - getStorageVideoStats + setActiveSoraS3Profile } export default settingsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 710b9d60f6..017f46f27b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4483,7 +4483,7 @@ export default { }, soraS3: { title: 'Sora Storage', - description: 'Manage Sora media storage profiles with S3 and Google Drive support', + description: 'Manage Sora media storage profiles with S3 support', newProfile: 'New Profile', reloadProfiles: 'Reload Profiles', empty: 'No storage profiles yet, create one first', @@ -4491,7 +4491,6 @@ export default { editTitle: 'Edit Storage Profile', selectProvider: 'Select Storage Type', providerS3Desc: 'S3-compatible object storage', - providerGDriveDesc: 'Google Drive cloud storage', profileID: 'Profile ID', profileName: 'Profile Name', setActive: 'Set as active after creation', @@ -4549,29 +4548,7 @@ export default { testSuccess: 'Connection test successful', testFailed: 'Connection test failed', saved: 'Storage settings saved successfully', - saveFailed: 'Failed to save storage settings', - gdrive: { - authType: 'Authentication Method', - serviceAccount: 'Service Account', - clientId: 'Client ID', - clientSecret: 'Client Secret', - clientSecretConfigured: '(Configured, leave blank to keep)', - refreshToken: 'Refresh Token', - refreshTokenConfigured: '(Configured, leave blank to keep)', - serviceAccountJson: 'Service Account JSON', - serviceAccountConfigured: '(Configured, leave blank to keep)', - folderId: 'Folder ID (optional)', - authorize: 'Authorize Google Drive', - authorizeHint: 'Get Refresh Token via OAuth2', - oauthFieldsRequired: 'Please fill in Client ID and Client Secret first', - oauthSuccess: 'Google Drive authorization successful', - oauthFailed: 'Google Drive authorization failed', - closeWindow: 'This window will close automatically', - processing: 'Processing authorization...', - testStorage: 'Test Storage', - testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)', - testFailed: 'Google Drive storage test failed' - } + saveFailed: 'Failed to save storage settings' }, overloadCooldown: { title: '529 Overload Cooldown', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8c2e10de7e..2d22521dce 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4647,7 +4647,7 @@ export default { }, soraS3: { title: 'Sora 存储配置', - description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive', + description: '以多配置列表管理 Sora 媒体存储,支持 S3', newProfile: '新建配置', reloadProfiles: '刷新列表', empty: '暂无存储配置,请先创建', @@ -4655,7 +4655,6 @@ export default { editTitle: '编辑存储配置', selectProvider: '选择存储类型', providerS3Desc: 'S3 兼容对象存储', - providerGDriveDesc: 'Google Drive 云盘', profileID: '配置 ID', profileName: '配置名称', setActive: '创建后设为生效', @@ -4713,29 +4712,7 @@ export default { testSuccess: '连接测试成功', testFailed: '连接测试失败', saved: '存储设置保存成功', - saveFailed: '保存存储设置失败', - gdrive: { - authType: '认证方式', - serviceAccount: '服务账号', - clientId: 'Client ID', - clientSecret: 'Client Secret', - clientSecretConfigured: '(已配置,留空保持不变)', - refreshToken: 'Refresh Token', - refreshTokenConfigured: '(已配置,留空保持不变)', - serviceAccountJson: '服务账号 JSON', - serviceAccountConfigured: '(已配置,留空保持不变)', - folderId: 'Folder ID(可选)', - authorize: '授权 Google Drive', - authorizeHint: '通过 OAuth2 获取 Refresh Token', - oauthFieldsRequired: '请先填写 Client ID 和 Client Secret', - oauthSuccess: 'Google Drive 授权成功', - oauthFailed: 'Google Drive 授权失败', - closeWindow: '此窗口将自动关闭', - processing: '正在处理授权...', - testStorage: '测试存储', - testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)', - testFailed: 'Google Drive 存储测试失败' - } + saveFailed: '保存存储设置失败' }, overloadCooldown: { title: '529 过载冷却', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ee236ca79c..4c0f0a4889 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -225,17 +225,6 @@ const routes: RouteRecordRaw[] = [ } }, - // ==================== Admin OAuth Callback Routes ==================== - { - path: '/admin/gdrive-oauth-callback', - name: 'GDriveOAuthCallback', - component: () => import('@/views/admin/GDriveOAuthCallbackView.vue'), - meta: { - requiresAuth: false, - title: 'Google Drive OAuth' - } - }, - // ==================== Admin Routes ==================== { path: '/admin', diff --git a/frontend/src/views/admin/DataManagementView.vue b/frontend/src/views/admin/DataManagementView.vue index 29e4458b97..ebb2caa63a 100644 --- a/frontend/src/views/admin/DataManagementView.vue +++ b/frontend/src/views/admin/DataManagementView.vue @@ -21,16 +21,13 @@
- +
- - - @@ -41,14 +38,6 @@
{{ profile.profile_id }}
- - - - @@ -160,9 +113,6 @@

{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }} - - {{ getProviderLabel(soraProfileForm.provider) }} -

- -
- - - -
+
{{ t('admin.settings.soraS3.enabled') }} - - - - - + + + + + + + + @@ -301,12 +168,11 @@
-
+
- @@ -335,7 +192,7 @@
{{ t('admin.settings.soraS3.columns.profileId') }} {{ t('admin.settings.soraS3.columns.name') }}{{ t('admin.settings.soraS3.columns.provider') }} {{ t('admin.settings.soraS3.columns.active') }} {{ t('admin.settings.soraS3.columns.storagePath') }}{{ t('admin.settings.soraS3.columns.capacityUsage') }}{{ t('admin.settings.soraS3.columns.videoCount') }} {{ t('admin.settings.soraS3.columns.updatedAt') }} {{ t('admin.settings.soraS3.columns.actions') }}
{{ profile.name }} - - {{ getProviderLabel(profile.provider) }} - - - - - - - - - - - +
{{ profile.endpoint || '-' }}
+
{{ [profile.bucket, profile.prefix].filter(Boolean).join('/') || '-' }}
{{ formatDate(profile.updated_at) }} @@ -102,8 +56,7 @@
+ {{ t('admin.settings.soraS3.empty') }}