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 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-06 20:45:51 +08:00
co-authored by Claude Opus 4.6
parent a9ecd7bcc6
commit ad54040c07
26 changed files with 1711 additions and 280 deletions
+8 -4
View File
@@ -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)
+20 -14
View File
@@ -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
+50 -40
View File
@@ -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=
@@ -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 授权成功",
})
}
@@ -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)
+11 -1
View File
@@ -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 配置列表响应
+1
View File
@@ -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
+39 -37
View File
@@ -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, &quotaErr) {
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)
@@ -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)
}
+3
View File
@@ -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,
+13 -1
View File
@@ -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)
}
}
@@ -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{
+21
View File
@@ -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 多配置列表
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -37,6 +37,7 @@ const (
// Sora 存储类型常量
const (
SoraStorageTypeS3 = "s3"
SoraStorageTypeGDrive = "gdrive"
SoraStorageTypeLocal = "local"
SoraStorageTypeUpstream = "upstream"
SoraStorageTypeNone = "none"
@@ -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 {
@@ -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) {
@@ -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
}
@@ -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))
@@ -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()
}
+88 -26
View File
@@ -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<SoraS3Settings> {
const { data } = await apiClient.get<SoraS3Settings>('/admin/settings/sora-s3')
const { data } = await apiClient.get<SoraS3Settings>('/admin/settings/sora-storage')
return data
}
export async function updateSoraS3Settings(settings: UpdateSoraS3SettingsRequest): Promise<SoraS3Settings> {
const { data } = await apiClient.put<SoraS3Settings>('/admin/settings/sora-s3', settings)
const { data } = await apiClient.put<SoraS3Settings>('/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<ListSoraS3ProfilesResponse> {
const { data } = await apiClient.get<ListSoraS3ProfilesResponse>('/admin/settings/sora-s3/profiles')
const { data } = await apiClient.get<ListSoraS3ProfilesResponse>('/admin/settings/sora-storage/profiles')
return data
}
export async function createSoraS3Profile(request: CreateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>('/admin/settings/sora-s3/profiles', request)
const { data } = await apiClient.post<SoraS3Profile>('/admin/settings/sora-storage/profiles', request)
return data
}
export async function updateSoraS3Profile(profileID: string, request: UpdateSoraS3ProfileRequest): Promise<SoraS3Profile> {
const { data } = await apiClient.put<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}`, request)
const { data } = await apiClient.put<SoraS3Profile>(`/admin/settings/sora-storage/profiles/${profileID}`, request)
return data
}
export async function deleteSoraS3Profile(profileID: string): Promise<void> {
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<SoraS3Profile> {
const { data } = await apiClient.post<SoraS3Profile>(`/admin/settings/sora-s3/profiles/${profileID}/activate`)
const { data } = await apiClient.post<SoraS3Profile>(`/admin/settings/sora-storage/profiles/${profileID}/activate`)
return data
}
export async function startGDriveOAuth(request: StartGDriveOAuthRequest): Promise<StartGDriveOAuthResponse> {
const { data } = await apiClient.post<StartGDriveOAuthResponse>('/admin/settings/sora-storage/gdrive-oauth/start', request)
return data
}
export async function exchangeGDriveOAuthCode(request: ExchangeGDriveOAuthCodeRequest): Promise<ExchangeGDriveOAuthCodeResponse> {
const { data } = await apiClient.post<ExchangeGDriveOAuthCodeResponse>('/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
+38 -18
View File
@@ -3894,40 +3894,43 @@ export default {
usage: 'Usage: Add to request header - x-api-key: <your-admin-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',
+38 -18
View File
@@ -4054,40 +4054,43 @@ export default {
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-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: '流超时处理',
+338 -73
View File
@@ -26,9 +26,9 @@
<thead>
<tr class="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.profile') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.provider') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.active') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.endpoint') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.bucket') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.quota') }}</th>
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.updatedAt') }}</th>
<th class="py-2">{{ t('admin.settings.soraS3.columns.actions') }}</th>
@@ -40,6 +40,14 @@
<div class="font-mono text-xs">{{ profile.profile_id }}</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{{ profile.name }}</div>
</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
:class="getProviderBadgeClass(profile.provider)"
>
{{ getProviderLabel(profile.provider) }}
</span>
</td>
<td class="py-3 pr-4">
<span
class="rounded px-2 py-0.5 text-xs"
@@ -49,10 +57,15 @@
</span>
</td>
<td class="py-3 pr-4 text-xs">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
<template v-if="(profile.provider || 's3') === 's3'">
<div>{{ profile.endpoint || '-' }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.region || '-' }}</div>
</template>
<template v-else>
<div>Google Drive</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">{{ profile.auth_type === 'service_account' ? t('admin.settings.soraS3.gdrive.serviceAccount') : 'OAuth2' }}</div>
</template>
</td>
<td class="py-3 pr-4 text-xs">{{ profile.bucket || '-' }}</td>
<td class="py-3 pr-4 text-xs">{{ formatStorageQuotaGB(profile.default_storage_quota_bytes) }}</td>
<td class="py-3 pr-4 text-xs">{{ formatDate(profile.updated_at) }}</td>
<td class="py-3 text-xs">
@@ -91,6 +104,53 @@
</div>
</div>
<!-- Provider selection modal (when creating new profile) -->
<Teleport to="body">
<Transition name="dm-drawer-mask">
<div
v-if="providerSelectOpen"
class="fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
@click="providerSelectOpen = false"
></div>
</Transition>
<Transition name="dm-drawer-panel">
<div
v-if="providerSelectOpen"
class="fixed inset-0 z-[55] flex items-center justify-center"
>
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900">
<h4 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.soraS3.selectProvider') }}
</h4>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="flex flex-col items-center gap-2 rounded-lg border-2 border-gray-200 p-4 transition hover:border-blue-400 hover:bg-blue-50 dark:border-dark-600 dark:hover:border-blue-500 dark:hover:bg-dark-800"
@click="confirmProviderSelect('s3')"
>
<span class="text-2xl">S3</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.settings.soraS3.providerS3Desc') }}</span>
</button>
<button
type="button"
class="flex flex-col items-center gap-2 rounded-lg border-2 border-gray-200 p-4 transition hover:border-blue-400 hover:bg-blue-50 dark:border-dark-600 dark:hover:border-blue-500 dark:hover:bg-dark-800"
@click="confirmProviderSelect('gdrive')"
>
<span class="text-2xl">GDrive</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.settings.soraS3.providerGDriveDesc') }}</span>
</button>
</div>
<div class="mt-4 flex justify-end">
<button type="button" class="btn btn-secondary btn-sm" @click="providerSelectOpen = false">
{{ t('common.cancel') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Profile drawer -->
<Teleport to="body">
<Transition name="dm-drawer-mask">
<div
@@ -108,6 +168,9 @@
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ creatingSoraProfile ? t('admin.settings.soraS3.createTitle') : t('admin.settings.soraS3.editTitle') }}
<span class="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-600 dark:bg-dark-800 dark:text-gray-400">
{{ getProviderLabel(soraProfileForm.provider) }}
</span>
</h4>
<button
type="button"
@@ -120,6 +183,7 @@
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<!-- Common fields -->
<input
v-model="soraProfileForm.profile_id"
class="input w-full"
@@ -135,17 +199,82 @@
<input v-model="soraProfileForm.enabled" type="checkbox" />
<span>{{ t('admin.settings.soraS3.enabled') }}</span>
</label>
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<!-- S3-specific fields -->
<template v-if="soraProfileForm.provider === 's3'">
<input v-model="soraProfileForm.endpoint" class="input w-full" :placeholder="t('admin.settings.soraS3.endpoint')" />
<input v-model="soraProfileForm.region" class="input w-full" :placeholder="t('admin.settings.soraS3.region')" />
<input v-model="soraProfileForm.bucket" class="input w-full" :placeholder="t('admin.settings.soraS3.bucket')" />
<input v-model="soraProfileForm.prefix" class="input w-full" :placeholder="t('admin.settings.soraS3.prefix')" />
<input v-model="soraProfileForm.access_key_id" class="input w-full" :placeholder="t('admin.settings.soraS3.accessKeyId')" />
<input
v-model="soraProfileForm.secret_access_key"
type="password"
class="input w-full"
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
</template>
<!-- GDrive-specific fields -->
<template v-if="soraProfileForm.provider === 'gdrive'">
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.soraS3.gdrive.authType') }}
</label>
<select v-model="soraProfileForm.auth_type" class="input w-full">
<option value="oauth2">OAuth2</option>
<option value="service_account">{{ t('admin.settings.soraS3.gdrive.serviceAccount') }}</option>
</select>
</div>
<template v-if="soraProfileForm.auth_type === 'oauth2'">
<input v-model="soraProfileForm.client_id" class="input w-full" :placeholder="t('admin.settings.soraS3.gdrive.clientId')" />
<input
v-model="soraProfileForm.client_secret"
type="password"
class="input w-full"
:placeholder="soraProfileForm.client_secret_configured ? t('admin.settings.soraS3.gdrive.clientSecretConfigured') : t('admin.settings.soraS3.gdrive.clientSecret')"
/>
<div class="md:col-span-2">
<input
v-model="soraProfileForm.refresh_token"
type="password"
class="input w-full"
:placeholder="soraProfileForm.refresh_token_configured ? t('admin.settings.soraS3.gdrive.refreshTokenConfigured') : t('admin.settings.soraS3.gdrive.refreshToken')"
/>
<div class="mt-2 flex items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-xs"
:disabled="!soraProfileForm.client_id || !soraProfileForm.client_secret || startingOAuth"
@click="startGDriveOAuth"
>
{{ startingOAuth ? t('common.loading') : t('admin.settings.soraS3.gdrive.authorize') }}
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.authorizeHint') }}</p>
</div>
</div>
</template>
<template v-if="soraProfileForm.auth_type === 'service_account'">
<div class="md:col-span-2">
<textarea
v-model="soraProfileForm.service_account_json"
class="input w-full"
rows="6"
:placeholder="soraProfileForm.service_account_configured ? t('admin.settings.soraS3.gdrive.serviceAccountConfigured') : t('admin.settings.soraS3.gdrive.serviceAccountJson')"
></textarea>
</div>
</template>
<input v-model="soraProfileForm.folder_id" class="input w-full md:col-span-2" :placeholder="t('admin.settings.soraS3.gdrive.folderId')" />
</template>
<!-- Common bottom fields -->
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
<div>
<input
@@ -158,10 +287,6 @@
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.defaultQuotaHint') }}</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="soraProfileForm.force_path_style" type="checkbox" />
<span>{{ t('admin.settings.soraS3.forcePathStyle') }}</span>
</label>
<label v-if="creatingSoraProfile" class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="soraProfileForm.set_active" type="checkbox" />
<span>{{ t('admin.settings.soraS3.setActive') }}</span>
@@ -173,7 +298,13 @@
<button type="button" class="btn btn-secondary btn-sm" @click="closeSoraProfileDrawer">
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingSoraProfile || !soraProfileForm.enabled" @click="testSoraProfileConnection">
<button
v-if="soraProfileForm.provider === 's3'"
type="button"
class="btn btn-secondary btn-sm"
:disabled="testingSoraProfile || !soraProfileForm.enabled"
@click="testSoraProfileConnection"
>
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
@@ -204,15 +335,19 @@ const activatingSoraProfile = ref(false)
const deletingSoraProfile = ref(false)
const creatingSoraProfile = ref(false)
const soraProfileDrawerOpen = ref(false)
const providerSelectOpen = ref(false)
const startingOAuth = ref(false)
const soraS3Profiles = ref<SoraS3Profile[]>([])
const selectedSoraProfileID = ref('')
type SoraS3ProfileForm = {
type SoraStorageProfileForm = {
profile_id: string
name: string
set_active: boolean
provider: string
enabled: boolean
// S3 fields
endpoint: string
region: string
bucket: string
@@ -221,11 +356,32 @@ type SoraS3ProfileForm = {
secret_access_key_configured: boolean
prefix: string
force_path_style: boolean
// GDrive fields
auth_type: string
client_id: string
client_secret: string
client_secret_configured: boolean
refresh_token: string
refresh_token_configured: boolean
service_account_json: string
service_account_configured: boolean
folder_id: string
// Common
cdn_url: string
default_storage_quota_gb: number
}
const soraProfileForm = ref<SoraS3ProfileForm>(newDefaultSoraS3ProfileForm())
const soraProfileForm = ref<SoraStorageProfileForm>(newDefaultProfileForm('s3'))
function getProviderLabel(provider?: string): string {
return (provider || 's3') === 'gdrive' ? 'Google Drive' : 'S3'
}
function getProviderBadgeClass(provider?: string): string {
return (provider || 's3') === 'gdrive'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
}
async function loadSoraS3Profiles() {
loadingSoraProfiles.value = true
@@ -249,9 +405,14 @@ async function loadSoraS3Profiles() {
}
function startCreateSoraProfile() {
providerSelectOpen.value = true
}
function confirmProviderSelect(provider: string) {
providerSelectOpen.value = false
creatingSoraProfile.value = true
selectedSoraProfileID.value = ''
soraProfileForm.value = newDefaultSoraS3ProfileForm()
soraProfileForm.value = newDefaultProfileForm(provider)
soraProfileDrawerOpen.value = true
}
@@ -272,11 +433,12 @@ function closeSoraProfileDrawer() {
}
async function saveSoraProfile() {
if (!soraProfileForm.value.name.trim()) {
const form = soraProfileForm.value
if (!form.name.trim()) {
appStore.showError(t('admin.settings.soraS3.profileNameRequired'))
return
}
if (creatingSoraProfile.value && !soraProfileForm.value.profile_id.trim()) {
if (creatingSoraProfile.value && !form.profile_id.trim()) {
appStore.showError(t('admin.settings.soraS3.profileIDRequired'))
return
}
@@ -284,16 +446,17 @@ async function saveSoraProfile() {
appStore.showError(t('admin.settings.soraS3.profileSelectRequired'))
return
}
if (soraProfileForm.value.enabled) {
if (!soraProfileForm.value.endpoint.trim()) {
// S3-specific validation
if (form.provider === 's3' && form.enabled) {
if (!form.endpoint.trim()) {
appStore.showError(t('admin.settings.soraS3.endpointRequired'))
return
}
if (!soraProfileForm.value.bucket.trim()) {
if (!form.bucket.trim()) {
appStore.showError(t('admin.settings.soraS3.bucketRequired'))
return
}
if (!soraProfileForm.value.access_key_id.trim()) {
if (!form.access_key_id.trim()) {
appStore.showError(t('admin.settings.soraS3.accessKeyRequired'))
return
}
@@ -301,40 +464,70 @@ async function saveSoraProfile() {
savingSoraProfile.value = true
try {
const quotaBytes = Math.round((form.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
if (creatingSoraProfile.value) {
const created = await adminAPI.settings.createSoraS3Profile({
profile_id: soraProfileForm.value.profile_id.trim(),
name: soraProfileForm.value.name.trim(),
set_active: soraProfileForm.value.set_active,
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
const request: Record<string, unknown> = {
profile_id: form.profile_id.trim(),
name: form.name.trim(),
set_active: form.set_active,
provider: form.provider,
enabled: form.enabled,
cdn_url: form.cdn_url,
default_storage_quota_bytes: quotaBytes
}
if (form.provider === 's3') {
Object.assign(request, {
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style
})
} else {
Object.assign(request, {
auth_type: form.auth_type,
client_id: form.client_id,
client_secret: form.client_secret || undefined,
refresh_token: form.refresh_token || undefined,
service_account_json: form.service_account_json || undefined,
folder_id: form.folder_id
})
}
const created = await adminAPI.settings.createSoraS3Profile(request as never)
selectedSoraProfileID.value = created.profile_id
creatingSoraProfile.value = false
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileCreated'))
} else {
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, {
name: soraProfileForm.value.name.trim(),
enabled: soraProfileForm.value.enabled,
endpoint: soraProfileForm.value.endpoint,
region: soraProfileForm.value.region,
bucket: soraProfileForm.value.bucket,
access_key_id: soraProfileForm.value.access_key_id,
secret_access_key: soraProfileForm.value.secret_access_key || undefined,
prefix: soraProfileForm.value.prefix,
force_path_style: soraProfileForm.value.force_path_style,
cdn_url: soraProfileForm.value.cdn_url,
default_storage_quota_bytes: Math.round((soraProfileForm.value.default_storage_quota_gb || 0) * 1024 * 1024 * 1024)
})
const request: Record<string, unknown> = {
name: form.name.trim(),
enabled: form.enabled,
cdn_url: form.cdn_url,
default_storage_quota_bytes: quotaBytes
}
if (form.provider === 's3') {
Object.assign(request, {
endpoint: form.endpoint,
region: form.region,
bucket: form.bucket,
access_key_id: form.access_key_id,
secret_access_key: form.secret_access_key || undefined,
prefix: form.prefix,
force_path_style: form.force_path_style
})
} else {
Object.assign(request, {
auth_type: form.auth_type,
client_id: form.client_id,
client_secret: form.client_secret || undefined,
refresh_token: form.refresh_token || undefined,
service_account_json: form.service_account_json || undefined,
folder_id: form.folder_id
})
}
await adminAPI.settings.updateSoraS3Profile(selectedSoraProfileID.value, request as never)
soraProfileDrawerOpen.value = false
appStore.showSuccess(t('admin.settings.soraS3.profileSaved'))
}
@@ -370,6 +563,67 @@ async function testSoraProfileConnection() {
}
}
async function startGDriveOAuth() {
const form = soraProfileForm.value
if (!form.client_id || !form.client_secret) {
appStore.showError(t('admin.settings.soraS3.gdrive.oauthFieldsRequired'))
return
}
startingOAuth.value = true
try {
const redirectUri = `${window.location.origin}/admin/gdrive-oauth-callback`
const result = await adminAPI.settings.startGDriveOAuth({
client_id: form.client_id,
client_secret: form.client_secret,
redirect_uri: redirectUri
})
// Store state for callback verification
sessionStorage.setItem('gdrive_oauth_state', result.state)
sessionStorage.setItem('gdrive_oauth_client_id', form.client_id)
sessionStorage.setItem('gdrive_oauth_client_secret', form.client_secret)
sessionStorage.setItem('gdrive_oauth_redirect_uri', redirectUri)
sessionStorage.setItem('gdrive_oauth_profile_id', creatingSoraProfile.value ? '' : selectedSoraProfileID.value)
// Open Google auth in new window
const authWindow = window.open(result.auth_url, 'gdrive_oauth', 'width=600,height=700')
// Listen for callback message
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type !== 'gdrive_oauth_callback') return
window.removeEventListener('message', handleMessage)
if (authWindow) authWindow.close()
const code = event.data.code
if (!code) {
appStore.showError(t('admin.settings.soraS3.gdrive.oauthFailed'))
return
}
try {
const exchangeResult = await adminAPI.settings.exchangeGDriveOAuthCode({
client_id: form.client_id,
client_secret: form.client_secret,
redirect_uri: redirectUri,
code,
profile_id: creatingSoraProfile.value ? undefined : selectedSoraProfileID.value
})
form.refresh_token = exchangeResult.refresh_token
form.refresh_token_configured = true
appStore.showSuccess(exchangeResult.message || t('admin.settings.soraS3.gdrive.oauthSuccess'))
} catch (err) {
appStore.showError((err as { message?: string })?.message || t('admin.settings.soraS3.gdrive.oauthFailed'))
}
}
window.addEventListener('message', handleMessage)
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
startingOAuth.value = false
}
}
async function activateSoraProfile(profileID: string) {
activatingSoraProfile.value = true
try {
@@ -403,43 +657,36 @@ async function removeSoraProfile(profileID: string) {
}
function formatDate(value?: string): string {
if (!value) {
return '-'
}
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function formatStorageQuotaGB(bytes: number): string {
if (!bytes || bytes <= 0) {
return '0 GB'
}
if (!bytes || bytes <= 0) return '0 GB'
const gb = bytes / (1024 * 1024 * 1024)
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
}
function pickPreferredSoraProfileID(): string {
const active = soraS3Profiles.value.find((item) => item.is_active)
if (active) {
return active.profile_id
}
if (active) return active.profile_id
return soraS3Profiles.value[0]?.profile_id || ''
}
function syncSoraProfileFormWithSelection() {
const profile = soraS3Profiles.value.find((item) => item.profile_id === selectedSoraProfileID.value)
soraProfileForm.value = newDefaultSoraS3ProfileForm(profile)
soraProfileForm.value = newDefaultProfileForm(profile?.provider || 's3', profile)
}
function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm {
function newDefaultProfileForm(provider: string, profile?: SoraS3Profile): SoraStorageProfileForm {
if (!profile) {
return {
profile_id: '',
name: '',
set_active: false,
provider,
enabled: false,
endpoint: '',
region: '',
@@ -447,19 +694,28 @@ function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm
access_key_id: '',
secret_access_key: '',
secret_access_key_configured: false,
prefix: 'sora/',
prefix: provider === 's3' ? 'sora/' : '',
force_path_style: false,
auth_type: 'oauth2',
client_id: '',
client_secret: '',
client_secret_configured: false,
refresh_token: '',
refresh_token_configured: false,
service_account_json: '',
service_account_configured: false,
folder_id: '',
cdn_url: '',
default_storage_quota_gb: 0
}
}
const quotaBytes = profile.default_storage_quota_bytes || 0
return {
profile_id: profile.profile_id,
name: profile.name,
set_active: false,
provider: profile.provider || 's3',
enabled: profile.enabled,
endpoint: profile.endpoint || '',
region: profile.region || '',
@@ -469,6 +725,15 @@ function newDefaultSoraS3ProfileForm(profile?: SoraS3Profile): SoraS3ProfileForm
secret_access_key_configured: Boolean(profile.secret_access_key_configured),
prefix: profile.prefix || '',
force_path_style: Boolean(profile.force_path_style),
auth_type: profile.auth_type || 'oauth2',
client_id: profile.client_id || '',
client_secret: '',
client_secret_configured: Boolean(profile.client_secret_configured),
refresh_token: '',
refresh_token_configured: Boolean(profile.refresh_token_configured),
service_account_json: '',
service_account_configured: Boolean(profile.service_account_configured),
folder_id: profile.folder_id || '',
cdn_url: profile.cdn_url || '',
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
}