From ad54040c076ec93f70704e80bc3eb93489a7d4ae Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 6 Mar 2026 20:45:51 +0800 Subject: [PATCH 1/9] feat(sora): add Google Drive storage backend with unified storage abstraction - Define SoraObjectStorage interface abstracting S3 and GDrive backends - Implement SoraGDriveStorage with OAuth2 and Service Account auth - Add SoraStorageRouter to route requests based on active profile provider - Add GDrive OAuth handler for authorization flow (start + callback) - Extend profile model with provider, auth_type, and GDrive-specific fields - Update frontend UI with provider selection, dynamic form fields, and i18n - Migrate API paths from /sora-s3 to /sora-storage (old paths preserved) - All existing S3 functionality remains backward compatible Co-Authored-By: Claude Opus 4.6 --- backend/cmd/server/wire_gen.go | 12 +- backend/go.mod | 34 +- backend/go.sum | 90 ++-- .../handler/admin/gdrive_oauth_handler.go | 121 ++++++ .../internal/handler/admin/setting_handler.go | 74 +++- backend/internal/handler/dto/settings.go | 12 +- backend/internal/handler/handler.go | 1 + .../internal/handler/sora_client_handler.go | 76 ++-- .../handler/sora_client_handler_test.go | 50 +-- backend/internal/handler/wire.go | 3 + backend/internal/server/routes/admin.go | 14 +- backend/internal/service/setting_service.go | 89 ++++ backend/internal/service/settings_view.go | 21 + backend/internal/service/sora_gdrive_oauth.go | 75 ++++ .../internal/service/sora_gdrive_storage.go | 352 +++++++++++++++ .../service/sora_gdrive_storage_test.go | 124 ++++++ backend/internal/service/sora_generation.go | 1 + .../service/sora_generation_service.go | 32 +- .../service/sora_generation_service_test.go | 2 +- .../internal/service/sora_object_storage.go | 37 ++ backend/internal/service/sora_s3_storage.go | 5 + .../internal/service/sora_storage_router.go | 129 ++++++ frontend/src/api/admin/settings.ts | 114 +++-- frontend/src/i18n/locales/en.ts | 56 ++- frontend/src/i18n/locales/zh.ts | 56 ++- .../src/views/admin/DataManagementView.vue | 411 ++++++++++++++---- 26 files changed, 1711 insertions(+), 280 deletions(-) create mode 100644 backend/internal/handler/admin/gdrive_oauth_handler.go create mode 100644 backend/internal/service/sora_gdrive_oauth.go create mode 100644 backend/internal/service/sora_gdrive_storage.go create mode 100644 backend/internal/service/sora_gdrive_storage_test.go create mode 100644 backend/internal/service/sora_object_storage.go create mode 100644 backend/internal/service/sora_storage_router.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 60bb17d51d..d3c5adc7dc 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -169,11 +169,15 @@ 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) - settingService.SetOnS3UpdateCallback(soraS3Storage.RefreshClient) + soraGDriveStorage := service.NewSoraGDriveStorage(settingService) + soraStorageRouter := service.NewSoraStorageRouter(settingService, soraS3Storage, soraGDriveStorage) + settingService.SetOnS3UpdateCallback(soraStorageRouter.RefreshAll) soraGenerationRepository := repository.NewSoraGenerationRepository(db) soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService) - soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService) + soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage) + soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService) + gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -199,7 +203,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository) scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, adminAPIKeyHandler, scheduledTestHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, gdriveOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, adminAPIKeyHandler, scheduledTestHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -208,7 +212,7 @@ 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, soraS3Storage, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService) + soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraStorageRouter, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService) soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) totpHandler := handler.NewTotpHandler(totpService) diff --git a/backend/go.mod b/backend/go.mod index d262199b7a..c982cdac0d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/DouDOU-start/go-sora2api v1.1.0 github.com/alitto/pond/v2 v2.6.2 + github.com/aws/aws-sdk-go-v2 v1.41.2 github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 @@ -21,6 +22,8 @@ require ( github.com/imroc/req/v3 v3.57.0 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkoukk/tiktoken-go v0.1.8 + github.com/pkoukk/tiktoken-go-loader v0.0.2 github.com/pquerna/otp v1.5.0 github.com/redis/go-redis/v9 v9.17.2 github.com/refraction-networking/utls v1.8.2 @@ -35,11 +38,11 @@ require ( github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 golang.org/x/crypto v0.48.0 - golang.org/x/net v0.49.0 + golang.org/x/net v0.50.0 + golang.org/x/oauth2 v0.35.0 golang.org/x/sync v0.19.0 golang.org/x/term v0.40.0 - google.golang.org/grpc v1.75.1 - google.golang.org/protobuf v1.36.10 + google.golang.org/api v0.269.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -47,13 +50,15 @@ require ( require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect @@ -110,7 +115,9 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/subcommands v1.2.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect @@ -141,8 +148,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkoukk/tiktoken-go v0.1.8 // indirect - github.com/pkoukk/tiktoken-go-loader v0.0.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/quic-go/qpack v0.6.0 // indirect @@ -168,11 +173,11 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -181,8 +186,9 @@ require ( golang.org/x/mod v0.32.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 101613876f..1d1157fe51 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,11 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= @@ -94,10 +100,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -173,6 +175,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -182,11 +186,16 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -202,8 +211,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -239,8 +246,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -274,8 +279,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -311,8 +314,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -331,8 +334,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -389,22 +390,24 @@ github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWB github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k= github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -429,8 +432,10 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -452,21 +457,26 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/internal/handler/admin/gdrive_oauth_handler.go b/backend/internal/handler/admin/gdrive_oauth_handler.go new file mode 100644 index 0000000000..66eb1224d5 --- /dev/null +++ b/backend/internal/handler/admin/gdrive_oauth_handler.go @@ -0,0 +1,121 @@ +package admin + +import ( + "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 +} + +// NewGDriveOAuthHandler 创建 GDrive OAuth Handler。 +func NewGDriveOAuthHandler(settingService *service.SettingService, gdriveOAuth *service.SoraGDriveOAuthService) *GDriveOAuthHandler { + return &GDriveOAuthHandler{ + settingService: settingService, + gdriveOAuth: gdriveOAuth, + } +} + +// 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 { + 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 授权成功", + }) +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 465973391a..b2d33956ce 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -993,6 +993,8 @@ 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, @@ -1004,6 +1006,13 @@ 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, } } @@ -1083,6 +1092,8 @@ 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"` @@ -1093,10 +1104,19 @@ 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"` @@ -1107,6 +1127,13 @@ 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 配置 @@ -1129,14 +1156,23 @@ func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) { response.BadRequest(c, "Profile ID is required") return } - if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil { - response.BadRequest(c, err.Error()) - 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 + } } 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, @@ -1147,6 +1183,13 @@ 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) @@ -1189,13 +1232,25 @@ func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) { response.ErrorFrom(c, service.ErrSoraS3ProfileNotFound) return } - if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil { - response.BadRequest(c, err.Error()) - 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 + } } 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, @@ -1206,6 +1261,13 @@ 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) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index c34c6de1f3..5a4ff21f23 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -128,11 +128,13 @@ type SoraS3Settings struct { DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"` } -// SoraS3Profile Sora S3 存储配置项 DTO(响应用,不含敏感字段) +// SoraS3Profile Sora 存储配置项 DTO(响应用,不含敏感字段) 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"` @@ -144,6 +146,14 @@ 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 3f1d73ca31..bded562f5e 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -16,6 +16,7 @@ type AdminHandlers struct { OpenAIOAuth *admin.OpenAIOAuthHandler GeminiOAuth *admin.GeminiOAuthHandler AntigravityOAuth *admin.AntigravityOAuthHandler + GDriveOAuth *admin.GDriveOAuthHandler Proxy *admin.ProxyHandler Redeem *admin.RedeemHandler Promo *admin.PromoHandler diff --git a/backend/internal/handler/sora_client_handler.go b/backend/internal/handler/sora_client_handler.go index 80acc83349..d41b10c5c1 100644 --- a/backend/internal/handler/sora_client_handler.go +++ b/backend/internal/handler/sora_client_handler.go @@ -31,7 +31,7 @@ const ( type SoraClientHandler struct { genService *service.SoraGenerationService quotaService *service.SoraQuotaService - s3Storage *service.SoraS3Storage + objectStorage service.SoraObjectStorage soraGatewayService *service.SoraGatewayService gatewayService *service.GatewayService mediaStorage *service.SoraMediaStorage @@ -48,7 +48,7 @@ type SoraClientHandler struct { func NewSoraClientHandler( genService *service.SoraGenerationService, quotaService *service.SoraQuotaService, - s3Storage *service.SoraS3Storage, + objectStorage service.SoraObjectStorage, soraGatewayService *service.SoraGatewayService, gatewayService *service.GatewayService, mediaStorage *service.SoraMediaStorage, @@ -57,7 +57,7 @@ func NewSoraClientHandler( return &SoraClientHandler{ genService: genService, quotaService: quotaService, - s3Storage: s3Storage, + objectStorage: objectStorage, soraGatewayService: soraGatewayService, gatewayService: gatewayService, mediaStorage: mediaStorage, @@ -291,11 +291,11 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID return } - // 三层降级存储:S3 → 本地 → 上游临时 URL + // 三层降级存储:对象存储 → 本地 → 上游临时 URL storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation(ctx, userID, mediaType, mediaURL, mediaURLs) usageAdded := false - if (storageType == service.SoraStorageTypeS3 || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil { + if (service.IsObjectStorageType(storageType) || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil { if err := h.quotaService.AddUsage(ctx, userID, fileSize); err != nil { h.cleanupStoredMedia(ctx, storageType, s3Keys, storedURLs) var quotaErr *service.QuotaExceededError @@ -346,19 +346,19 @@ func (h *SoraClientHandler) storeMediaWithDegradation( urls = []string{mediaURL} } - // 第一层:尝试 S3 - if h.s3Storage != nil && h.s3Storage.Enabled(ctx) { + // 第一层:尝试对象存储(S3 / Google Drive) + if h.objectStorage != nil && h.objectStorage.Enabled(ctx) { keys := make([]string, 0, len(urls)) var totalSize int64 allOK := true for _, u := range urls { - key, size, err := h.s3Storage.UploadFromURL(ctx, userID, u) + key, size, err := h.objectStorage.UploadFromURL(ctx, userID, u) if err != nil { - logger.LegacyPrintf("handler.sora_client", "[SoraClient] S3 上传失败 err=%v", err) + logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err) allOK = false // 清理已上传的文件 if len(keys) > 0 { - _ = h.s3Storage.DeleteObjects(ctx, keys) + _ = h.objectStorage.DeleteObjects(ctx, keys) } break } @@ -368,17 +368,17 @@ func (h *SoraClientHandler) storeMediaWithDegradation( if allOK && len(keys) > 0 { accessURLs := make([]string, 0, len(keys)) for _, key := range keys { - accessURL, err := h.s3Storage.GetAccessURL(ctx, key) + accessURL, err := h.objectStorage.GetAccessURL(ctx, key) if err != nil { - logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成 S3 访问 URL 失败 err=%v", err) - _ = h.s3Storage.DeleteObjects(ctx, keys) + logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成访问 URL 失败 type=%s err=%v", h.objectStorage.StorageType(), err) + _ = h.objectStorage.DeleteObjects(ctx, keys) allOK = false break } accessURLs = append(accessURLs, accessURL) } if allOK && len(accessURLs) > 0 { - return accessURLs[0], accessURLs, service.SoraStorageTypeS3, keys, totalSize + return accessURLs[0], accessURLs, h.objectStorage.StorageType(), keys, totalSize } } } @@ -678,7 +678,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { return } - if h.s3Storage == nil || !h.s3Storage.Enabled(c.Request.Context()) { + if h.objectStorage == nil || !h.objectStorage.Enabled(c.Request.Context()) { response.Error(c, http.StatusServiceUnavailable, "云存储未配置,请联系管理员") return } @@ -697,24 +697,24 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { var totalSize int64 for _, sourceURL := range sourceURLs { - objectKey, fileSize, uploadErr := h.s3Storage.UploadFromURL(c.Request.Context(), userID, sourceURL) + objectKey, fileSize, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL) if uploadErr != nil { if len(uploadedKeys) > 0 { - _ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys) + _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys) } var upstreamErr *service.UpstreamDownloadError if errors.As(uploadErr, &upstreamErr) && (upstreamErr.StatusCode == http.StatusForbidden || upstreamErr.StatusCode == http.StatusNotFound) { response.Error(c, http.StatusGone, "媒体链接已过期,无法保存") return } - response.Error(c, http.StatusInternalServerError, "上传到 S3 失败: "+uploadErr.Error()) + response.Error(c, http.StatusInternalServerError, "上传到存储失败: "+uploadErr.Error()) return } - accessURL, err := h.s3Storage.GetAccessURL(c.Request.Context(), objectKey) + accessURL, err := h.objectStorage.GetAccessURL(c.Request.Context(), objectKey) if err != nil { uploadedKeys = append(uploadedKeys, objectKey) - _ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys) - response.Error(c, http.StatusInternalServerError, "生成 S3 访问链接失败: "+err.Error()) + _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys) + response.Error(c, http.StatusInternalServerError, "生成访问链接失败: "+err.Error()) return } uploadedKeys = append(uploadedKeys, objectKey) @@ -725,7 +725,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { usageAdded := false if totalSize > 0 && h.quotaService != nil { if err := h.quotaService.AddUsage(c.Request.Context(), userID, totalSize); err != nil { - _ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys) + _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys) var quotaErr *service.QuotaExceededError if errors.As(err, "aErr) { response.Error(c, http.StatusTooManyRequests, "存储配额已满,请删除不需要的作品释放空间") @@ -742,11 +742,11 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { id, accessURLs[0], accessURLs, - service.SoraStorageTypeS3, + h.objectStorage.StorageType(), uploadedKeys, totalSize, ); err != nil { - _ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys) + _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys) if usageAdded && h.quotaService != nil { _ = h.quotaService.ReleaseUsage(c.Request.Context(), userID, totalSize) } @@ -755,7 +755,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { } response.Success(c, gin.H{ - "message": "已保存到 S3", + "message": "已保存到云存储", "object_key": uploadedKeys[0], "object_keys": uploadedKeys, }) @@ -764,28 +764,30 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) { // GetStorageStatus 返回存储状态。 // GET /api/v1/sora/storage-status func (h *SoraClientHandler) GetStorageStatus(c *gin.Context) { - s3Enabled := h.s3Storage != nil && h.s3Storage.Enabled(c.Request.Context()) - s3Healthy := false - if s3Enabled { - s3Healthy = h.s3Storage.IsHealthy(c.Request.Context()) + objectStorageEnabled := h.objectStorage != nil && h.objectStorage.Enabled(c.Request.Context()) + objectStorageHealthy := false + storageType := "" + if objectStorageEnabled { + objectStorageHealthy = h.objectStorage.IsHealthy(c.Request.Context()) + storageType = h.objectStorage.StorageType() } localEnabled := h.mediaStorage != nil && h.mediaStorage.Enabled() response.Success(c, gin.H{ - "s3_enabled": s3Enabled, - "s3_healthy": s3Healthy, + "s3_enabled": objectStorageEnabled, // 保留字段名向后兼容 + "s3_healthy": objectStorageHealthy, + "storage_type": storageType, "local_enabled": localEnabled, }) } func (h *SoraClientHandler) cleanupStoredMedia(ctx context.Context, storageType string, s3Keys []string, localPaths []string) { - switch storageType { - case service.SoraStorageTypeS3: - if h.s3Storage != nil && len(s3Keys) > 0 { - if err := h.s3Storage.DeleteObjects(ctx, s3Keys); err != nil { - logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理 S3 文件失败 keys=%v err=%v", s3Keys, err) + if service.IsObjectStorageType(storageType) { + if h.objectStorage != nil && len(s3Keys) > 0 { + if err := h.objectStorage.DeleteObjects(ctx, s3Keys); err != nil { + logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理存储文件失败 type=%s keys=%v err=%v", storageType, s3Keys, err) } } - case service.SoraStorageTypeLocal: + } else if storageType == service.SoraStorageTypeLocal { if h.mediaStorage != nil && len(localPaths) > 0 { if err := h.mediaStorage.DeleteByRelativePaths(localPaths); err != nil { logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理本地文件失败 paths=%v err=%v", localPaths, err) diff --git a/backend/internal/handler/sora_client_handler_test.go b/backend/internal/handler/sora_client_handler_test.go index d2a849b1cb..c8b7a5ff6e 100644 --- a/backend/internal/handler/sora_client_handler_test.go +++ b/backend/internal/handler/sora_client_handler_test.go @@ -1641,7 +1641,7 @@ func TestStoreMediaWithDegradation_S3SuccessSingleURL(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation( context.Background(), 1, "video", sourceServer.URL+"/v.mp4", nil, @@ -1663,7 +1663,7 @@ func TestStoreMediaWithDegradation_S3SuccessMultiURL(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} urls := []string{sourceServer.URL + "/a.mp4", sourceServer.URL + "/b.mp4"} storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation( @@ -1688,7 +1688,7 @@ func TestStoreMediaWithDegradation_S3DownloadFails(t *testing.T) { defer badSource.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} _, _, storageType, _, _ := h.storeMediaWithDegradation( context.Background(), 1, "video", badSource.URL+"/missing.mp4", nil, @@ -1703,7 +1703,7 @@ func TestStoreMediaWithDegradation_S3FailsSingleURL(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} _, _, storageType, s3Keys, _ := h.storeMediaWithDegradation( context.Background(), 1, "video", sourceServer.URL+"/v.mp4", nil, @@ -1720,7 +1720,7 @@ func TestStoreMediaWithDegradation_S3PartialFailureCleanup(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} urls := []string{sourceServer.URL + "/a.mp4", sourceServer.URL + "/b.mp4"} _, _, storageType, s3Keys, _ := h.storeMediaWithDegradation( @@ -1804,7 +1804,7 @@ func TestStoreMediaWithDegradation_S3FailsFallbackToLocal(t *testing.T) { } mediaStorage := service.NewSoraMediaStorage(cfg) h := &SoraClientHandler{ - s3Storage: s3Storage, + objectStorage: s3Storage, mediaStorage: mediaStorage, } @@ -1831,14 +1831,14 @@ func TestSaveToStorage_S3EnabledButUploadFails(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} h.SaveToStorage(c) require.Equal(t, http.StatusInternalServerError, rec.Code) resp := parseResponse(t, rec) - require.Contains(t, resp["message"], "S3") + require.Contains(t, resp["message"], "上传到存储失败") } func TestSaveToStorage_UpstreamURLExpired(t *testing.T) { @@ -1857,7 +1857,7 @@ func TestSaveToStorage_UpstreamURLExpired(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -1881,7 +1881,7 @@ func TestSaveToStorage_S3EnabledUploadSuccess(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -1889,7 +1889,7 @@ func TestSaveToStorage_S3EnabledUploadSuccess(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) resp := parseResponse(t, rec) data := resp["data"].(map[string]any) - require.Contains(t, data["message"], "S3") + require.Contains(t, data["message"], "已保存到云存储") require.NotEmpty(t, data["object_key"]) // 验证记录已更新为 S3 存储 require.Equal(t, service.SoraStorageTypeS3, repo.gens[1].StorageType) @@ -1913,7 +1913,7 @@ func TestSaveToStorage_S3EnabledUploadSuccess_MultiMediaURLs(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -1949,7 +1949,7 @@ func TestSaveToStorage_S3EnabledUploadSuccessWithQuota(t *testing.T) { SoraStorageUsedBytes: 0, } quotaService := service.NewSoraQuotaService(userRepo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage, quotaService: quotaService} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage, quotaService: quotaService} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -1975,7 +1975,7 @@ func TestSaveToStorage_S3UploadSuccessMarkCompletedFails(t *testing.T) { repo.updateErr = fmt.Errorf("db error") s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -1991,7 +1991,7 @@ func TestGetStorageStatus_S3EnabledNotHealthy(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} c, rec := makeGinContext("GET", "/api/v1/sora/storage-status", "", 0) h.GetStorageStatus(c) @@ -2007,7 +2007,7 @@ func TestGetStorageStatus_S3EnabledHealthy(t *testing.T) { defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} c, rec := makeGinContext("GET", "/api/v1/sora/storage-status", "", 0) h.GetStorageStatus(c) @@ -2447,7 +2447,7 @@ func TestProcessGeneration_FullSuccessWithS3(t *testing.T) { genService: genService, gatewayService: gatewayService, soraGatewayService: soraGatewayService, - s3Storage: s3Storage, + objectStorage: s3Storage, quotaService: quotaService, } @@ -2497,7 +2497,7 @@ func TestProcessGeneration_MarkCompletedFails(t *testing.T) { // ==================== cleanupStoredMedia 直接测试 ==================== func TestCleanupStoredMedia_S3Path(t *testing.T) { - // S3 清理路径:s3Storage 为 nil 时不 panic + // S3 清理路径:objectStorage 为 nil 时不 panic h := &SoraClientHandler{} // 不应 panic h.cleanupStoredMedia(context.Background(), service.SoraStorageTypeS3, []string{"key1"}, nil) @@ -2955,7 +2955,7 @@ func TestSaveToStorage_QuotaExceeded(t *testing.T) { SoraStorageUsedBytes: 10, } quotaService := service.NewSoraQuotaService(userRepo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage, quotaService: quotaService} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage, quotaService: quotaService} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -2983,7 +2983,7 @@ func TestSaveToStorage_QuotaNonQuotaError(t *testing.T) { // 用户不存在 → GetByID 失败 → AddUsage 返回普通 error userRepo := newStubUserRepoForHandler() quotaService := service.NewSoraQuotaService(userRepo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage, quotaService: quotaService} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage, quotaService: quotaService} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -3006,7 +3006,7 @@ func TestSaveToStorage_EmptyMediaURLs(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -3033,7 +3033,7 @@ func TestSaveToStorage_MultiURL_SecondUploadFails(t *testing.T) { } s3Storage := newS3StorageForHandler(fakeS3.URL) genService := service.NewSoraGenerationService(repo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -3066,7 +3066,7 @@ func TestSaveToStorage_MarkCompletedFailsWithQuotaRollback(t *testing.T) { SoraStorageUsedBytes: 0, } quotaService := service.NewSoraQuotaService(userRepo, nil, nil) - h := &SoraClientHandler{genService: genService, s3Storage: s3Storage, quotaService: quotaService} + h := &SoraClientHandler{genService: genService, objectStorage: s3Storage, quotaService: quotaService} c, rec := makeGinContext("POST", "/api/v1/sora/generations/1/save", "", 1) c.Params = gin.Params{{Key: "id", Value: "1"}} @@ -3080,7 +3080,7 @@ func TestCleanupStoredMedia_WithS3Storage_ActualDelete(t *testing.T) { fakeS3 := newFakeS3Server("ok") defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} h.cleanupStoredMedia(context.Background(), service.SoraStorageTypeS3, []string{"key1", "key2"}, nil) } @@ -3089,7 +3089,7 @@ func TestCleanupStoredMedia_S3DeleteFails_LogOnly(t *testing.T) { fakeS3 := newFakeS3Server("fail") defer fakeS3.Close() s3Storage := newS3StorageForHandler(fakeS3.URL) - h := &SoraClientHandler{s3Storage: s3Storage} + h := &SoraClientHandler{objectStorage: s3Storage} h.cleanupStoredMedia(context.Background(), service.SoraStorageTypeS3, []string{"key1"}, nil) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index d1e12e0366..9565ffbaea 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -19,6 +19,7 @@ func ProvideAdminHandlers( openaiOAuthHandler *admin.OpenAIOAuthHandler, geminiOAuthHandler *admin.GeminiOAuthHandler, antigravityOAuthHandler *admin.AntigravityOAuthHandler, + gdriveOAuthHandler *admin.GDriveOAuthHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, promoHandler *admin.PromoHandler, @@ -43,6 +44,7 @@ func ProvideAdminHandlers( OpenAIOAuth: openaiOAuthHandler, GeminiOAuth: geminiOAuthHandler, AntigravityOAuth: antigravityOAuthHandler, + GDriveOAuth: gdriveOAuthHandler, Proxy: proxyHandler, Redeem: redeemHandler, Promo: promoHandler, @@ -132,6 +134,7 @@ var ProviderSet = wire.NewSet( admin.NewOpenAIOAuthHandler, admin.NewGeminiOAuthHandler, admin.NewAntigravityOAuthHandler, + admin.NewGDriveOAuthHandler, admin.NewProxyHandler, admin.NewRedeemHandler, admin.NewPromoHandler, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 2e53feb36f..b3267cb660 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -392,7 +392,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 流超时处理配置 adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings) adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings) - // Sora S3 存储配置 + // Sora S3 存储配置(旧路由,保留兼容) adminSettings.GET("/sora-s3", h.Admin.Setting.GetSoraS3Settings) adminSettings.PUT("/sora-s3", h.Admin.Setting.UpdateSoraS3Settings) adminSettings.POST("/sora-s3/test", h.Admin.Setting.TestSoraS3Connection) @@ -401,6 +401,18 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.PUT("/sora-s3/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile) adminSettings.DELETE("/sora-s3/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile) adminSettings.POST("/sora-s3/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile) + // Sora 统一存储配置(新路由,指向相同 handler) + adminSettings.GET("/sora-storage", h.Admin.Setting.GetSoraS3Settings) + adminSettings.PUT("/sora-storage", h.Admin.Setting.UpdateSoraS3Settings) + adminSettings.POST("/sora-storage/test", h.Admin.Setting.TestSoraS3Connection) + adminSettings.GET("/sora-storage/profiles", h.Admin.Setting.ListSoraS3Profiles) + adminSettings.POST("/sora-storage/profiles", h.Admin.Setting.CreateSoraS3Profile) + 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) } } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 5bfec32e80..93736a732c 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -186,6 +186,49 @@ 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 @@ -1234,6 +1277,8 @@ 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"` @@ -1245,6 +1290,14 @@ 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 存储配置(兼容旧单配置语义:返回当前激活配置) @@ -1356,6 +1409,8 @@ 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), @@ -1367,6 +1422,13 @@ 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 == "" { @@ -1412,6 +1474,8 @@ 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) @@ -1424,6 +1488,19 @@ 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 @@ -1726,6 +1803,8 @@ 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, @@ -1738,6 +1817,16 @@ 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 6a1d62d826..ceca3a828a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -124,6 +124,8 @@ 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"` @@ -136,6 +138,25 @@ 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 new file mode 100644 index 0000000000..5fce55c74b --- /dev/null +++ b/backend/internal/service/sora_gdrive_oauth.go @@ -0,0 +1,75 @@ +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 { + settingService *SettingService +} + +// NewSoraGDriveOAuthService 创建 GDrive OAuth 服务。 +func NewSoraGDriveOAuthService(settingService *SettingService) *SoraGDriveOAuthService { + return &SoraGDriveOAuthService{ + settingService: settingService, + } +} + +// 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 new file mode 100644 index 0000000000..b43f34b2d9 --- /dev/null +++ b/backend/internal/service/sora_gdrive_storage.go @@ -0,0 +1,352 @@ +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) { + creds, err := google.CredentialsFromJSON(ctx, []byte(profile.ServiceAccountJSON), drive.DriveFileScope) + if err != nil { + return nil, fmt.Errorf("parse service account json: %w", err) + } + srv, err := drive.NewService(ctx, option.WithCredentials(creds)) + 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] 客户端缓存已清除") +} + +// 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 +} + +// 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, 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, 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 new file mode 100644 index 0000000000..e82f70c903 --- /dev/null +++ b/backend/internal/service/sora_gdrive_storage_test.go @@ -0,0 +1,124 @@ +//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_generation.go b/backend/internal/service/sora_generation.go index a704454b82..95b7c616da 100644 --- a/backend/internal/service/sora_generation.go +++ b/backend/internal/service/sora_generation.go @@ -37,6 +37,7 @@ const ( // Sora 存储类型常量 const ( SoraStorageTypeS3 = "s3" + SoraStorageTypeGDrive = "gdrive" SoraStorageTypeLocal = "local" SoraStorageTypeUpstream = "upstream" SoraStorageTypeNone = "none" diff --git a/backend/internal/service/sora_generation_service.go b/backend/internal/service/sora_generation_service.go index 22d5b51947..90bb74987c 100644 --- a/backend/internal/service/sora_generation_service.go +++ b/backend/internal/service/sora_generation_service.go @@ -35,21 +35,21 @@ type soraGenerationRepoConditionalUpdater interface { // SoraGenerationService 管理 Sora 客户端的生成记录 CRUD。 type SoraGenerationService struct { - genRepo SoraGenerationRepository - s3Storage *SoraS3Storage - quotaService *SoraQuotaService + genRepo SoraGenerationRepository + objectStorage SoraObjectStorage + quotaService *SoraQuotaService } // NewSoraGenerationService 创建生成记录服务。 func NewSoraGenerationService( genRepo SoraGenerationRepository, - s3Storage *SoraS3Storage, + objectStorage SoraObjectStorage, quotaService *SoraQuotaService, ) *SoraGenerationService { return &SoraGenerationService{ - genRepo: genRepo, - s3Storage: s3Storage, - quotaService: quotaService, + genRepo: genRepo, + objectStorage: objectStorage, + quotaService: quotaService, } } @@ -268,15 +268,15 @@ func (s *SoraGenerationService) Delete(ctx context.Context, id, userID int64) er return fmt.Errorf("无权删除此生成记录") } - // 清理 S3 文件 - if gen.StorageType == SoraStorageTypeS3 && len(gen.S3ObjectKeys) > 0 && s.s3Storage != nil { - if err := s.s3Storage.DeleteObjects(ctx, gen.S3ObjectKeys); err != nil { - logger.LegacyPrintf("service.sora_gen", "[SoraGen] S3 清理失败 id=%d err=%v", id, err) + // 清理存储文件(S3 / Google Drive) + if IsObjectStorageType(gen.StorageType) && len(gen.S3ObjectKeys) > 0 && s.objectStorage != nil { + if err := s.objectStorage.DeleteObjects(ctx, gen.S3ObjectKeys); err != nil { + logger.LegacyPrintf("service.sora_gen", "[SoraGen] 存储清理失败 id=%d type=%s err=%v", id, gen.StorageType, err) } } - // 释放配额(S3/本地均释放) - if gen.FileSizeBytes > 0 && (gen.StorageType == SoraStorageTypeS3 || gen.StorageType == SoraStorageTypeLocal) && s.quotaService != nil { + // 释放配额(对象存储/本地均释放) + if gen.FileSizeBytes > 0 && (IsObjectStorageType(gen.StorageType) || gen.StorageType == SoraStorageTypeLocal) && s.quotaService != nil { if err := s.quotaService.ReleaseUsage(ctx, userID, gen.FileSizeBytes); err != nil { logger.LegacyPrintf("service.sora_gen", "[SoraGen] 配额释放失败 id=%d err=%v", id, err) } @@ -290,9 +290,9 @@ func (s *SoraGenerationService) CountActiveByUser(ctx context.Context, userID in return s.genRepo.CountByUserAndStatus(ctx, userID, []string{SoraGenStatusPending, SoraGenStatusGenerating}) } -// ResolveMediaURLs 为 S3 记录动态生成预签名 URL。 +// ResolveMediaURLs 为对象存储记录动态生成访问 URL。 func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraGeneration) error { - if gen == nil || gen.StorageType != SoraStorageTypeS3 || s.s3Storage == nil { + if gen == nil || !IsObjectStorageType(gen.StorageType) || s.objectStorage == nil { return nil } if len(gen.S3ObjectKeys) == 0 { @@ -308,7 +308,7 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG wg.Add(1) go func(i int, objectKey string) { defer wg.Done() - url, err := s.s3Storage.GetAccessURL(ctx, objectKey) + url, err := s.objectStorage.GetAccessURL(ctx, objectKey) if err != nil { errMu.Lock() if firstErr == nil { diff --git a/backend/internal/service/sora_generation_service_test.go b/backend/internal/service/sora_generation_service_test.go index 46f322c82c..e5dd6bec1d 100644 --- a/backend/internal/service/sora_generation_service_test.go +++ b/backend/internal/service/sora_generation_service_test.go @@ -519,7 +519,7 @@ func TestDelete_S3Cleanup_NilS3(t *testing.T) { svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) - require.NoError(t, err) // s3Storage 为 nil,跳过清理 + require.NoError(t, err) // objectStorage 为 nil,跳过清理 } func TestDelete_QuotaRelease_NilQuota(t *testing.T) { diff --git a/backend/internal/service/sora_object_storage.go b/backend/internal/service/sora_object_storage.go new file mode 100644 index 0000000000..3c2f98197b --- /dev/null +++ b/backend/internal/service/sora_object_storage.go @@ -0,0 +1,37 @@ +package service + +import "context" + +// SoraObjectStorage 是 Sora 媒体文件的通用对象存储接口。 +// S3 和 Google Drive 等存储后端均实现此接口。 +type SoraObjectStorage interface { + // Enabled 返回存储是否已启用且配置有效。 + Enabled(ctx context.Context) bool + + // IsHealthy 返回存储健康状态(带短缓存)。 + IsHealthy(ctx context.Context) bool + + // TestConnection 测试存储连接。 + TestConnection(ctx context.Context) error + + // UploadFromURL 从上游 URL 下载并上传到存储。 + // 返回 object key(S3 key 或 GDrive file ID)、文件大小。 + UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, err error) + + // DeleteObjects 删除一组存储对象。 + DeleteObjects(ctx context.Context, objectKeys []string) error + + // GetAccessURL 获取存储文件的访问 URL。 + GetAccessURL(ctx context.Context, objectKey string) (string, error) + + // RefreshClient 清除缓存客户端,配置变更时调用。 + RefreshClient() + + // StorageType 返回存储类型标识("s3" / "gdrive")。 + StorageType() string +} + +// IsObjectStorageType 判断是否为对象存储类型(S3 或 Google Drive)。 +func IsObjectStorageType(t string) bool { + return t == SoraStorageTypeS3 || t == SoraStorageTypeGDrive +} diff --git a/backend/internal/service/sora_s3_storage.go b/backend/internal/service/sora_s3_storage.go index 4c57390515..d47a3000b2 100644 --- a/backend/internal/service/sora_s3_storage.go +++ b/backend/internal/service/sora_s3_storage.go @@ -380,6 +380,11 @@ func (s *SoraS3Storage) GeneratePresignedURL(ctx context.Context, objectKey stri return result.URL, nil } +// StorageType 返回存储类型标识。 +func (s *SoraS3Storage) StorageType() string { + return SoraStorageTypeS3 +} + // GetMediaType 从 object key 推断媒体类型(image/video)。 func GetMediaTypeFromKey(objectKey string) string { ext := strings.ToLower(path.Ext(objectKey)) diff --git a/backend/internal/service/sora_storage_router.go b/backend/internal/service/sora_storage_router.go new file mode 100644 index 0000000000..79c6779f07 --- /dev/null +++ b/backend/internal/service/sora_storage_router.go @@ -0,0 +1,129 @@ +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, 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 ad0564d9e0..7f152d0a96 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -273,7 +273,7 @@ export async function updateStreamTimeoutSettings( return data } -// ==================== Sora S3 Settings ==================== +// ==================== Sora Storage Settings ==================== export interface SoraS3Settings { enabled: boolean @@ -292,7 +292,9 @@ 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 @@ -303,6 +305,13 @@ 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 { @@ -328,30 +337,47 @@ export interface CreateSoraS3ProfileRequest { profile_id: string name: string set_active?: boolean + provider?: string enabled: boolean - endpoint: string - region: string - bucket: string - access_key_id: string + access_mode?: string + endpoint?: string + region?: string + bucket?: string + access_key_id?: string secret_access_key?: string - prefix: string - force_path_style: boolean - cdn_url: string - default_storage_quota_bytes: number + prefix?: string + 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 - endpoint: string - region: string - bucket: string - access_key_id: string + access_mode?: string + endpoint?: string + region?: string + bucket?: string + access_key_id?: string secret_access_key?: string - prefix: string - force_path_style: boolean - cdn_url: string - default_storage_quota_bytes: number + prefix?: string + 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 { @@ -368,44 +394,78 @@ 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-s3') + const { data } = await apiClient.get('/admin/settings/sora-storage') return data } export async function updateSoraS3Settings(settings: UpdateSoraS3SettingsRequest): Promise { - const { data } = await apiClient.put('/admin/settings/sora-s3', settings) + const { data } = await apiClient.put('/admin/settings/sora-storage', settings) return data } export async function testSoraS3Connection( settings: TestSoraS3ConnectionRequest ): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>('/admin/settings/sora-s3/test', settings) + const { data } = await apiClient.post<{ message: string }>('/admin/settings/sora-storage/test', settings) return data } export async function listSoraS3Profiles(): Promise { - const { data } = await apiClient.get('/admin/settings/sora-s3/profiles') + const { data } = await apiClient.get('/admin/settings/sora-storage/profiles') return data } export async function createSoraS3Profile(request: CreateSoraS3ProfileRequest): Promise { - const { data } = await apiClient.post('/admin/settings/sora-s3/profiles', request) + const { data } = await apiClient.post('/admin/settings/sora-storage/profiles', request) return data } export async function updateSoraS3Profile(profileID: string, request: UpdateSoraS3ProfileRequest): Promise { - const { data } = await apiClient.put(`/admin/settings/sora-s3/profiles/${profileID}`, request) + const { data } = await apiClient.put(`/admin/settings/sora-storage/profiles/${profileID}`, request) return data } export async function deleteSoraS3Profile(profileID: string): Promise { - await apiClient.delete(`/admin/settings/sora-s3/profiles/${profileID}`) + await apiClient.delete(`/admin/settings/sora-storage/profiles/${profileID}`) } export async function setActiveSoraS3Profile(profileID: string): Promise { - const { data } = await apiClient.post(`/admin/settings/sora-s3/profiles/${profileID}/activate`) + const { data } = await apiClient.post(`/admin/settings/sora-storage/profiles/${profileID}/activate`) + return data +} + +export async function startGDriveOAuth(request: StartGDriveOAuthRequest): 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 } @@ -426,7 +486,9 @@ export const settingsAPI = { createSoraS3Profile, updateSoraS3Profile, deleteSoraS3Profile, - setActiveSoraS3Profile + setActiveSoraS3Profile, + startGDriveOAuth, + exchangeGDriveOAuthCode } export default settingsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d055a4c836..362ddd5fcc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3894,40 +3894,43 @@ export default { usage: 'Usage: Add to request header - x-api-key: ' }, soraS3: { - title: 'Sora S3 Storage', - description: 'Manage multiple Sora S3 endpoints and switch the active profile', + title: 'Sora Storage', + description: 'Manage Sora media storage profiles with S3 and Google Drive support', newProfile: 'New Profile', reloadProfiles: 'Reload Profiles', - empty: 'No Sora S3 profiles yet, create one first', - createTitle: 'Create Sora S3 Profile', - editTitle: 'Edit Sora S3 Profile', + empty: 'No storage profiles yet, create one first', + createTitle: 'Create Storage Profile', + 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', saveProfile: 'Save Profile', activateProfile: 'Activate', - profileCreated: 'Sora S3 profile created', - profileSaved: 'Sora S3 profile saved', - profileDeleted: 'Sora S3 profile deleted', - profileActivated: 'Sora S3 active profile switched', + profileCreated: 'Storage profile created', + profileSaved: 'Storage profile saved', + profileDeleted: 'Storage profile deleted', + profileActivated: 'Active storage profile switched', profileIDRequired: 'Profile ID is required', profileNameRequired: 'Profile name is required', profileSelectRequired: 'Please select a profile first', endpointRequired: 'S3 endpoint is required when enabled', bucketRequired: 'Bucket is required when enabled', accessKeyRequired: 'Access Key ID is required when enabled', - deleteConfirm: 'Delete Sora S3 profile {profileID}?', + deleteConfirm: 'Delete storage profile {profileID}?', columns: { profile: 'Profile', + provider: 'Type', active: 'Active', endpoint: 'Endpoint', - bucket: 'Bucket', quota: 'Default Quota', updatedAt: 'Updated At', actions: 'Actions' }, - enabled: 'Enable S3 Storage', - enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage', + enabled: 'Enable Storage', + enabledHint: 'When enabled, Sora generated media files will be automatically uploaded', endpoint: 'S3 Endpoint', region: 'Region', bucket: 'Bucket', @@ -3936,16 +3939,33 @@ export default { secretAccessKey: 'Secret Access Key', secretConfigured: '(Configured, leave blank to keep)', cdnUrl: 'CDN URL', - cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL instead of presigned URLs', + cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL', forcePathStyle: 'Force Path Style', defaultQuota: 'Default Storage Quota', defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited', testConnection: 'Test Connection', testing: 'Testing...', - testSuccess: 'S3 connection test successful', - testFailed: 'S3 connection test failed', - saved: 'Sora S3 settings saved successfully', - saveFailed: 'Failed to save Sora S3 settings' + 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' + } }, streamTimeout: { title: 'Stream Timeout Handling', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ecaff13deb..7eefa89cc8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4054,40 +4054,43 @@ export default { usage: '使用方法:在请求头中添加 x-api-key: ' }, soraS3: { - title: 'Sora S3 存储配置', - description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置', + title: 'Sora 存储配置', + description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive', newProfile: '新建配置', reloadProfiles: '刷新列表', - empty: '暂无 Sora S3 配置,请先创建', - createTitle: '新建 Sora S3 配置', - editTitle: '编辑 Sora S3 配置', + empty: '暂无存储配置,请先创建', + createTitle: '新建存储配置', + editTitle: '编辑存储配置', + selectProvider: '选择存储类型', + providerS3Desc: 'S3 兼容对象存储', + providerGDriveDesc: 'Google Drive 云盘', profileID: '配置 ID', profileName: '配置名称', setActive: '创建后设为生效', saveProfile: '保存配置', activateProfile: '设为生效', - profileCreated: 'Sora S3 配置创建成功', - profileSaved: 'Sora S3 配置保存成功', - profileDeleted: 'Sora S3 配置删除成功', - profileActivated: 'Sora S3 生效配置已切换', + profileCreated: '存储配置创建成功', + profileSaved: '存储配置保存成功', + profileDeleted: '存储配置删除成功', + profileActivated: '生效配置已切换', profileIDRequired: '请填写配置 ID', profileNameRequired: '请填写配置名称', profileSelectRequired: '请先选择配置', endpointRequired: '启用时必须填写 S3 端点', bucketRequired: '启用时必须填写存储桶', accessKeyRequired: '启用时必须填写 Access Key ID', - deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?', + deleteConfirm: '确定删除存储配置 {profileID} 吗?', columns: { profile: '配置', + provider: '存储类型', active: '生效状态', endpoint: '端点', - bucket: '存储桶', quota: '默认配额', updatedAt: '更新时间', actions: '操作' }, - enabled: '启用 S3 存储', - enabledHint: '启用后,Sora 生成的媒体文件将自动上传到 S3 存储', + enabled: '启用存储', + enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储', endpoint: 'S3 端点', region: '区域', bucket: '存储桶', @@ -4096,16 +4099,33 @@ export default { secretAccessKey: 'Secret Access Key', secretConfigured: '(已配置,留空保持不变)', cdnUrl: 'CDN URL', - cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL', + cdnUrlHint: '可选,配置后使用 CDN URL 访问文件', forcePathStyle: '强制路径风格(Path Style)', defaultQuota: '默认存储配额', defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制', testConnection: '测试连接', testing: '测试中...', - testSuccess: 'S3 连接测试成功', - testFailed: 'S3 连接测试失败', - saved: 'Sora S3 设置保存成功', - saveFailed: '保存 Sora S3 设置失败' + 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 授权失败' + } }, streamTimeout: { title: '流超时处理', diff --git a/frontend/src/views/admin/DataManagementView.vue b/frontend/src/views/admin/DataManagementView.vue index 7c8b742e2b..c2d59bc4c9 100644 --- a/frontend/src/views/admin/DataManagementView.vue +++ b/frontend/src/views/admin/DataManagementView.vue @@ -26,9 +26,9 @@ {{ t('admin.settings.soraS3.columns.profile') }} + {{ t('admin.settings.soraS3.columns.provider') }} {{ t('admin.settings.soraS3.columns.active') }} {{ t('admin.settings.soraS3.columns.endpoint') }} - {{ t('admin.settings.soraS3.columns.bucket') }} {{ t('admin.settings.soraS3.columns.quota') }} {{ t('admin.settings.soraS3.columns.updatedAt') }} {{ t('admin.settings.soraS3.columns.actions') }} @@ -40,6 +40,14 @@
{{ profile.profile_id }}
{{ profile.name }}
+ + + {{ getProviderLabel(profile.provider) }} + + -
{{ profile.endpoint || '-' }}
-
{{ profile.region || '-' }}
+ + - {{ profile.bucket || '-' }} {{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }} {{ formatDate(profile.updated_at) }} @@ -91,6 +104,53 @@ + + + +
+
+ +
+
+

+ {{ t('admin.settings.soraS3.selectProvider') }} +

+
+ + +
+
+ +
+
+
+
+
+ +

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

- + @@ -563,6 +572,21 @@ async function testSoraProfileConnection() { } } +async function testGDriveStorageConnection() { + testingSoraProfile.value = true + try { + const result = await adminAPI.settings.testGDriveStorage() + const msg = result.status === 'ok' + ? t('admin.settings.soraS3.gdrive.testSuccess') + : t('admin.settings.soraS3.gdrive.testFailed') + appStore.showSuccess(msg) + } catch (error) { + appStore.showError((error as { message?: string })?.message || t('admin.settings.soraS3.gdrive.testFailed')) + } finally { + testingSoraProfile.value = false + } +} + async function startGDriveOAuth() { const form = soraProfileForm.value if (!form.client_id || !form.client_secret) { From 9e107fae734fc8318310af5820522b26b80edafb Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 6 Mar 2026 23:58:27 +0800 Subject: [PATCH 6/9] feat(sora): improve storage management page with split columns, test buttons, quota and video stats - Split compound "Profile" column into separate Profile ID and Name columns - Replace "Endpoint" column with "Storage Path" showing bucket/prefix or folder info - Add GDrive quota display (capacity/used) via new backend API endpoint - Add video count statistics per storage type via new backend API endpoint - Add inline test button per profile with 15s timeout and per-profile loading state - Backend: GetQuotaInfo, CountByStorageType, GetGDriveQuota, GetStorageVideoStats Co-Authored-By: Claude Opus 4.6 --- backend/cmd/server/wire_gen.go | 2 +- .../internal/handler/admin/setting_handler.go | 67 ++++++-- .../repository/sora_generation_repo.go | 19 +++ backend/internal/server/api_contract_test.go | 2 +- backend/internal/server/routes/admin.go | 3 + .../internal/service/sora_gdrive_storage.go | 25 +++ backend/internal/service/sora_generation.go | 1 + .../service/sora_generation_service.go | 19 +++ .../service/sora_generation_service_test.go | 19 +++ frontend/src/api/admin/settings.ts | 23 ++- frontend/src/i18n/locales/en.ts | 14 +- frontend/src/i18n/locales/zh.ts | 14 +- .../src/views/admin/DataManagementView.vue | 154 ++++++++++++++++-- 13 files changed, 336 insertions(+), 26 deletions(-) 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') }}