diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 18f5fc4ef6..9de22768bf 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -175,7 +175,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { soraGenerationRepository := repository.NewSoraGenerationRepository(db) soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService) soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService) - settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage) + settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage, soraGDriveStorage, soraGenerationService) soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService) gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService, soraGDriveStorage) opsHandler := admin.NewOpsHandler(opsService) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b2d33956ce..a779eb737a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -37,21 +37,25 @@ func generateMenuItemID() (string, error) { // SettingHandler 系统设置处理器 type SettingHandler struct { - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - opsService *service.OpsService - soraS3Storage *service.SoraS3Storage + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + opsService *service.OpsService + soraS3Storage *service.SoraS3Storage + soraGDriveStorage *service.SoraGDriveStorage + soraGenerationService *service.SoraGenerationService } // NewSettingHandler 创建系统设置处理器 -func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler { +func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage, soraGDriveStorage *service.SoraGDriveStorage, soraGenerationService *service.SoraGenerationService) *SettingHandler { return &SettingHandler{ - settingService: settingService, - emailService: emailService, - turnstileService: turnstileService, - opsService: opsService, - soraS3Storage: soraS3Storage, + settingService: settingService, + emailService: emailService, + turnstileService: turnstileService, + opsService: opsService, + soraS3Storage: soraS3Storage, + soraGDriveStorage: soraGDriveStorage, + soraGenerationService: soraGenerationService, } } @@ -1456,3 +1460,44 @@ 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/repository/sora_generation_repo.go b/backend/internal/repository/sora_generation_repo.go index aaf3cb2f54..7894cdb5e1 100644 --- a/backend/internal/repository/sora_generation_repo.go +++ b/backend/internal/repository/sora_generation_repo.go @@ -417,3 +417,22 @@ func (r *soraGenerationRepository) CountByUserAndStatus(ctx context.Context, use err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count) return count, err } + +// CountByStorageType 按存储类型和状态统计生成记录数。 +func (r *soraGenerationRepository) CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error) { + if len(statuses) == 0 { + return 0, nil + } + + placeholders := make([]string, len(statuses)) + args := []any{storageType} + for i, s := range statuses { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args = append(args, s) + } + + var count int64 + query := fmt.Sprintf("SELECT COUNT(*) FROM sora_generations WHERE storage_type = $1 AND status IN (%s)", strings.Join(placeholders, ",")) + err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count) + return count, err +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index b6915943fc..e6b2399c85 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -647,7 +647,7 @@ func newContractDeps(t *testing.T) *contractDeps { authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil) + adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil) adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) jwtAuth := func(c *gin.Context) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a00cb5849d..8898fac3ca 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -414,6 +414,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { 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/sora_gdrive_storage.go b/backend/internal/service/sora_gdrive_storage.go index 5b3c974211..d71abf3222 100644 --- a/backend/internal/service/sora_gdrive_storage.go +++ b/backend/internal/service/sora_gdrive_storage.go @@ -175,6 +175,12 @@ func (s *SoraGDriveStorage) RefreshClient() { 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) @@ -188,6 +194,25 @@ func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error { 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) diff --git a/backend/internal/service/sora_generation.go b/backend/internal/service/sora_generation.go index 95b7c616da..7323bb38ac 100644 --- a/backend/internal/service/sora_generation.go +++ b/backend/internal/service/sora_generation.go @@ -61,4 +61,5 @@ type SoraGenerationRepository interface { Delete(ctx context.Context, id int64) error List(ctx context.Context, params SoraGenerationListParams) ([]*SoraGeneration, int64, error) CountByUserAndStatus(ctx context.Context, userID int64, statuses []string) (int64, error) + CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error) } diff --git a/backend/internal/service/sora_generation_service.go b/backend/internal/service/sora_generation_service.go index 90bb74987c..1b5aa239a1 100644 --- a/backend/internal/service/sora_generation_service.go +++ b/backend/internal/service/sora_generation_service.go @@ -330,3 +330,22 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG return nil } + +// StorageVideoStats 各存储类型的视频统计。 +type StorageVideoStats struct { + Completed int64 `json:"completed"` + InProgress int64 `json:"in_progress"` +} + +// CountByStorageType 按存储类型统计视频数量(completed 和 in_progress)。 +func (s *SoraGenerationService) CountByStorageType(ctx context.Context, storageType string) (completed, inProgress int64, err error) { + completed, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusCompleted}) + if err != nil { + return 0, 0, fmt.Errorf("count completed: %w", err) + } + inProgress, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusPending, SoraGenStatusGenerating}) + if err != nil { + return 0, 0, fmt.Errorf("count in_progress: %w", err) + } + return completed, inProgress, nil +} diff --git a/backend/internal/service/sora_generation_service_test.go b/backend/internal/service/sora_generation_service_test.go index e5dd6bec1d..0bde211f6c 100644 --- a/backend/internal/service/sora_generation_service_test.go +++ b/backend/internal/service/sora_generation_service_test.go @@ -115,6 +115,25 @@ func (r *stubGenRepo) CountByUserAndStatus(_ context.Context, userID int64, stat return count, nil } +func (r *stubGenRepo) CountByStorageType(_ context.Context, storageType string, statuses []string) (int64, error) { + if r.countErr != nil { + return 0, r.countErr + } + var count int64 + statusSet := make(map[string]struct{}) + for _, s := range statuses { + statusSet[s] = struct{}{} + } + for _, gen := range r.gens { + if gen.StorageType == storageType { + if _, ok := statusSet[gen.Status]; ok { + count++ + } + } + } + return count, nil +} + // ==================== Stub: UserRepository (用于 SoraQuotaService) ==================== var _ UserRepository = (*stubUserRepoForQuota)(nil) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index e6650c89f9..50144ad461 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -487,6 +487,25 @@ export async function testGDriveStorage(): Promise { 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, @@ -507,7 +526,9 @@ export const settingsAPI = { setActiveSoraS3Profile, startGDriveOAuth, exchangeGDriveOAuthCode, - testGDriveStorage + testGDriveStorage, + getGDriveQuota, + getStorageVideoStats } export default settingsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 423e3973ac..4927a245d2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3932,12 +3932,24 @@ export default { deleteConfirm: 'Delete storage profile {profileID}?', columns: { profile: 'Profile', + profileId: 'Profile ID', + name: 'Name', provider: 'Type', active: 'Active', endpoint: 'Endpoint', + storagePath: 'Storage Path', + capacityUsage: 'Capacity / Used', + capacityUnlimited: 'Unlimited', + videoCount: 'Videos', + videoCompleted: 'completed', + videoInProgress: 'in progress', quota: 'Default Quota', updatedAt: 'Updated At', - actions: 'Actions' + actions: 'Actions', + rootFolder: 'Root folder', + testInTable: 'Test', + testingInTable: 'Testing...', + testTimeout: 'Test timed out (15s)' }, enabled: 'Enable Storage', enabledHint: 'When enabled, Sora generated media files will be automatically uploaded', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7840892525..c715d9ecea 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4092,12 +4092,24 @@ export default { deleteConfirm: '确定删除存储配置 {profileID} 吗?', columns: { profile: '配置', + profileId: 'Profile ID', + name: '名称', provider: '存储类型', active: '生效状态', endpoint: '端点', + storagePath: '存储路径', + capacityUsage: '容量 / 已用', + capacityUnlimited: '无限制', + videoCount: '视频数', + videoCompleted: '完成', + videoInProgress: '进行中', quota: '默认配额', updatedAt: '更新时间', - actions: '操作' + actions: '操作', + rootFolder: '根目录', + testInTable: '测试', + testingInTable: '测试中...', + testTimeout: '测试超时(15秒)' }, enabled: '启用存储', enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储', diff --git a/frontend/src/views/admin/DataManagementView.vue b/frontend/src/views/admin/DataManagementView.vue index bde24fbead..e981d2ab7d 100644 --- a/frontend/src/views/admin/DataManagementView.vue +++ b/frontend/src/views/admin/DataManagementView.vue @@ -22,13 +22,16 @@
- +
- + + - + + + @@ -38,8 +41,8 @@ + + + - @@ -327,10 +368,10 @@
{{ t('admin.settings.soraS3.columns.profile') }}{{ 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.endpoint') }}{{ t('admin.settings.soraS3.columns.storagePath') }}{{ t('admin.settings.soraS3.columns.capacityUsage') }}{{ t('admin.settings.soraS3.columns.videoCount') }} {{ t('admin.settings.soraS3.columns.quota') }} {{ t('admin.settings.soraS3.columns.updatedAt') }} {{ t('admin.settings.soraS3.columns.actions') }}
{{ profile.profile_id }}
-
{{ profile.name }}
{{ profile.name }} + + + + + + + {{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }} {{ formatDate(profile.updated_at) }}
+ @@ -94,7 +135,7 @@
+ {{ t('admin.settings.soraS3.empty') }}