mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge branch 'worktree-feature/sora' into release/custom-0.1.92
This commit is contained in:
@@ -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)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage)
|
||||
soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraStorageRouter, soraQuotaService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage, soraGDriveStorage, soraGenerationService)
|
||||
soraGDriveOAuthService := service.NewSoraGDriveOAuthService(settingService)
|
||||
gdriveOAuthHandler := admin.NewGDriveOAuthHandler(settingService, soraGDriveOAuthService, soraGDriveStorage)
|
||||
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
@@ -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
@@ -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,148 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GDriveOAuthHandler 处理 Google Drive OAuth 授权流程。
|
||||
type GDriveOAuthHandler struct {
|
||||
settingService *service.SettingService
|
||||
gdriveOAuth *service.SoraGDriveOAuthService
|
||||
gdriveStorage *service.SoraGDriveStorage
|
||||
}
|
||||
|
||||
// NewGDriveOAuthHandler 创建 GDrive OAuth Handler。
|
||||
func NewGDriveOAuthHandler(settingService *service.SettingService, gdriveOAuth *service.SoraGDriveOAuthService, gdriveStorage *service.SoraGDriveStorage) *GDriveOAuthHandler {
|
||||
return &GDriveOAuthHandler{
|
||||
settingService: settingService,
|
||||
gdriveOAuth: gdriveOAuth,
|
||||
gdriveStorage: gdriveStorage,
|
||||
}
|
||||
}
|
||||
|
||||
// StartOAuthRequest 启动 OAuth 授权请求。
|
||||
type StartOAuthRequest struct {
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
ClientSecret string `json:"client_secret" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||
}
|
||||
|
||||
// StartOAuth 生成 Google OAuth 授权 URL。
|
||||
// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/start
|
||||
func (h *GDriveOAuthHandler) StartOAuth(c *gin.Context) {
|
||||
if h.gdriveOAuth == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var req StartOAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state, err := h.gdriveOAuth.GenerateAuthURL(req.ClientID, req.ClientSecret, req.RedirectURI)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "生成授权 URL 失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"auth_url": authURL,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthCallbackRequest OAuth 回调请求。
|
||||
type OAuthCallbackRequest struct {
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
ClientSecret string `json:"client_secret" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
ProfileID string `json:"profile_id"` // 要保存到的 profile ID(可选)
|
||||
}
|
||||
|
||||
// OAuthCallback 用授权码换取 refresh_token 并保存到 profile。
|
||||
// POST /api/v1/admin/settings/sora-storage/gdrive-oauth/callback
|
||||
func (h *GDriveOAuthHandler) OAuthCallback(c *gin.Context) {
|
||||
if h.gdriveOAuth == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "GDrive OAuth service not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var req OAuthCallbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := h.gdriveOAuth.ExchangeCode(c.Request.Context(), req.ClientID, req.ClientSecret, req.RedirectURI, req.Code)
|
||||
if err != nil {
|
||||
slog.Error("[GDriveOAuth] exchange failed",
|
||||
"client_id_len", len(req.ClientID),
|
||||
"client_secret_len", len(req.ClientSecret),
|
||||
"redirect_uri", req.RedirectURI,
|
||||
"code_len", len(req.Code),
|
||||
"error", err,
|
||||
)
|
||||
response.Error(c, http.StatusBadRequest, "换取 refresh_token 失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果指定了 profile_id,自动保存 refresh_token 到 profile
|
||||
if req.ProfileID != "" {
|
||||
profiles, err := h.settingService.ListSoraS3Profiles(c.Request.Context())
|
||||
if err == nil {
|
||||
for _, p := range profiles.Items {
|
||||
if p.ProfileID == req.ProfileID {
|
||||
_, _ = h.settingService.UpdateSoraS3Profile(c.Request.Context(), req.ProfileID, &service.SoraS3Profile{
|
||||
Name: p.Name,
|
||||
Provider: p.Provider,
|
||||
AccessMode: p.AccessMode,
|
||||
Enabled: p.Enabled,
|
||||
Endpoint: p.Endpoint,
|
||||
Region: p.Region,
|
||||
Bucket: p.Bucket,
|
||||
AccessKeyID: p.AccessKeyID,
|
||||
Prefix: p.Prefix,
|
||||
ForcePathStyle: p.ForcePathStyle,
|
||||
CDNURL: p.CDNURL,
|
||||
DefaultStorageQuotaBytes: p.DefaultStorageQuotaBytes,
|
||||
AuthType: p.AuthType,
|
||||
ClientID: p.ClientID,
|
||||
FolderID: p.FolderID,
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"refresh_token": refreshToken,
|
||||
"message": "OAuth 授权成功",
|
||||
})
|
||||
}
|
||||
|
||||
// TestGDriveStorage 测试 GDrive 存储的完整上传→下载→删除流程。
|
||||
// POST /api/v1/admin/settings/sora-storage/gdrive-test
|
||||
func (h *GDriveOAuthHandler) TestGDriveStorage(c *gin.Context) {
|
||||
if h.gdriveStorage == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "GDrive storage not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.gdriveStorage.TestFullCycle(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "GDrive 测试失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
@@ -37,21 +37,25 @@ func generateMenuItemID() (string, error) {
|
||||
|
||||
// SettingHandler 系统设置处理器
|
||||
type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
emailService *service.EmailService
|
||||
turnstileService *service.TurnstileService
|
||||
opsService *service.OpsService
|
||||
soraS3Storage *service.SoraS3Storage
|
||||
settingService *service.SettingService
|
||||
emailService *service.EmailService
|
||||
turnstileService *service.TurnstileService
|
||||
opsService *service.OpsService
|
||||
soraS3Storage *service.SoraS3Storage
|
||||
soraGDriveStorage *service.SoraGDriveStorage
|
||||
soraGenerationService *service.SoraGenerationService
|
||||
}
|
||||
|
||||
// NewSettingHandler 创建系统设置处理器
|
||||
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler {
|
||||
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage, soraGDriveStorage *service.SoraGDriveStorage, soraGenerationService *service.SoraGenerationService) *SettingHandler {
|
||||
return &SettingHandler{
|
||||
settingService: settingService,
|
||||
emailService: emailService,
|
||||
turnstileService: turnstileService,
|
||||
opsService: opsService,
|
||||
soraS3Storage: soraS3Storage,
|
||||
settingService: settingService,
|
||||
emailService: emailService,
|
||||
turnstileService: turnstileService,
|
||||
opsService: opsService,
|
||||
soraS3Storage: soraS3Storage,
|
||||
soraGDriveStorage: soraGDriveStorage,
|
||||
soraGenerationService: soraGenerationService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,6 +997,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 +1010,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 +1096,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 +1108,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 +1131,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 +1160,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 +1187,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 +1236,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 +1265,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)
|
||||
@@ -1394,3 +1460,44 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
|
||||
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetGDriveQuota 获取 Google Drive 配额信息。
|
||||
// GET /api/v1/admin/settings/sora-storage/gdrive-quota
|
||||
func (h *SettingHandler) GetGDriveQuota(c *gin.Context) {
|
||||
if h.soraGDriveStorage == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "GDrive storage not configured")
|
||||
return
|
||||
}
|
||||
quota, err := h.soraGDriveStorage.GetQuotaInfo(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, fmt.Sprintf("failed to get GDrive quota: %v", err))
|
||||
return
|
||||
}
|
||||
response.Success(c, quota)
|
||||
}
|
||||
|
||||
// GetStorageVideoStats 获取各存储类型的视频统计信息。
|
||||
// GET /api/v1/admin/settings/sora-storage/video-stats
|
||||
func (h *SettingHandler) GetStorageVideoStats(c *gin.Context) {
|
||||
if h.soraGenerationService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "generation service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
storageTypes := []string{service.SoraStorageTypeS3, service.SoraStorageTypeGDrive}
|
||||
result := make(map[string]*service.StorageVideoStats, len(storageTypes))
|
||||
|
||||
for _, st := range storageTypes {
|
||||
completed, inProgress, err := h.soraGenerationService.CountByStorageType(c.Request.Context(), st)
|
||||
if err != nil {
|
||||
log.Printf("[SettingHandler] CountByStorageType(%s) error: %v", st, err)
|
||||
continue
|
||||
}
|
||||
result[st] = &service.StorageVideoStats{
|
||||
Completed: completed,
|
||||
InProgress: inProgress,
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -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 配置列表响应
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
type SoraClientHandler struct {
|
||||
genService *service.SoraGenerationService
|
||||
quotaService *service.SoraQuotaService
|
||||
s3Storage *service.SoraS3Storage
|
||||
objectStorage service.SoraObjectStorage
|
||||
soraGatewayService *service.SoraGatewayService
|
||||
gatewayService *service.GatewayService
|
||||
mediaStorage *service.SoraMediaStorage
|
||||
@@ -48,7 +48,7 @@ type SoraClientHandler struct {
|
||||
func NewSoraClientHandler(
|
||||
genService *service.SoraGenerationService,
|
||||
quotaService *service.SoraQuotaService,
|
||||
s3Storage *service.SoraS3Storage,
|
||||
objectStorage service.SoraObjectStorage,
|
||||
soraGatewayService *service.SoraGatewayService,
|
||||
gatewayService *service.GatewayService,
|
||||
mediaStorage *service.SoraMediaStorage,
|
||||
@@ -57,7 +57,7 @@ func NewSoraClientHandler(
|
||||
return &SoraClientHandler{
|
||||
genService: genService,
|
||||
quotaService: quotaService,
|
||||
s3Storage: s3Storage,
|
||||
objectStorage: objectStorage,
|
||||
soraGatewayService: soraGatewayService,
|
||||
gatewayService: gatewayService,
|
||||
mediaStorage: mediaStorage,
|
||||
@@ -291,11 +291,11 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID
|
||||
return
|
||||
}
|
||||
|
||||
// 三层降级存储:S3 → 本地 → 上游临时 URL
|
||||
// 三层降级存储:对象存储 → 本地 → 上游临时 URL
|
||||
storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation(ctx, userID, mediaType, mediaURL, mediaURLs)
|
||||
|
||||
usageAdded := false
|
||||
if (storageType == service.SoraStorageTypeS3 || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil {
|
||||
if (service.IsObjectStorageType(storageType) || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil {
|
||||
if err := h.quotaService.AddUsage(ctx, userID, fileSize); err != nil {
|
||||
h.cleanupStoredMedia(ctx, storageType, s3Keys, storedURLs)
|
||||
var quotaErr *service.QuotaExceededError
|
||||
@@ -346,19 +346,19 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
urls = []string{mediaURL}
|
||||
}
|
||||
|
||||
// 第一层:尝试 S3
|
||||
if h.s3Storage != nil && h.s3Storage.Enabled(ctx) {
|
||||
// 第一层:尝试对象存储(S3 / Google Drive)
|
||||
if h.objectStorage != nil && h.objectStorage.Enabled(ctx) {
|
||||
keys := make([]string, 0, len(urls))
|
||||
var totalSize int64
|
||||
allOK := true
|
||||
for _, u := range urls {
|
||||
key, size, err := h.s3Storage.UploadFromURL(ctx, userID, u)
|
||||
key, size, err := h.objectStorage.UploadFromURL(ctx, userID, u)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] S3 上传失败 err=%v", err)
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err)
|
||||
allOK = false
|
||||
// 清理已上传的文件
|
||||
if len(keys) > 0 {
|
||||
_ = h.s3Storage.DeleteObjects(ctx, keys)
|
||||
_ = h.objectStorage.DeleteObjects(ctx, keys)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -368,17 +368,17 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
if allOK && len(keys) > 0 {
|
||||
accessURLs := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
accessURL, err := h.s3Storage.GetAccessURL(ctx, key)
|
||||
accessURL, err := h.objectStorage.GetAccessURL(ctx, key)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成 S3 访问 URL 失败 err=%v", err)
|
||||
_ = h.s3Storage.DeleteObjects(ctx, keys)
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成访问 URL 失败 type=%s err=%v", h.objectStorage.StorageType(), err)
|
||||
_ = h.objectStorage.DeleteObjects(ctx, keys)
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
accessURLs = append(accessURLs, accessURL)
|
||||
}
|
||||
if allOK && len(accessURLs) > 0 {
|
||||
return accessURLs[0], accessURLs, service.SoraStorageTypeS3, keys, totalSize
|
||||
return accessURLs[0], accessURLs, h.objectStorage.StorageType(), keys, totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +678,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.s3Storage == nil || !h.s3Storage.Enabled(c.Request.Context()) {
|
||||
if h.objectStorage == nil || !h.objectStorage.Enabled(c.Request.Context()) {
|
||||
response.Error(c, http.StatusServiceUnavailable, "云存储未配置,请联系管理员")
|
||||
return
|
||||
}
|
||||
@@ -697,24 +697,24 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
var totalSize int64
|
||||
|
||||
for _, sourceURL := range sourceURLs {
|
||||
objectKey, fileSize, uploadErr := h.s3Storage.UploadFromURL(c.Request.Context(), userID, sourceURL)
|
||||
objectKey, fileSize, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
|
||||
if uploadErr != nil {
|
||||
if len(uploadedKeys) > 0 {
|
||||
_ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
}
|
||||
var upstreamErr *service.UpstreamDownloadError
|
||||
if errors.As(uploadErr, &upstreamErr) && (upstreamErr.StatusCode == http.StatusForbidden || upstreamErr.StatusCode == http.StatusNotFound) {
|
||||
response.Error(c, http.StatusGone, "媒体链接已过期,无法保存")
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "上传到 S3 失败: "+uploadErr.Error())
|
||||
response.Error(c, http.StatusInternalServerError, "上传到存储失败: "+uploadErr.Error())
|
||||
return
|
||||
}
|
||||
accessURL, err := h.s3Storage.GetAccessURL(c.Request.Context(), objectKey)
|
||||
accessURL, err := h.objectStorage.GetAccessURL(c.Request.Context(), objectKey)
|
||||
if err != nil {
|
||||
uploadedKeys = append(uploadedKeys, objectKey)
|
||||
_ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
response.Error(c, http.StatusInternalServerError, "生成 S3 访问链接失败: "+err.Error())
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
response.Error(c, http.StatusInternalServerError, "生成访问链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
uploadedKeys = append(uploadedKeys, objectKey)
|
||||
@@ -725,7 +725,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
usageAdded := false
|
||||
if totalSize > 0 && h.quotaService != nil {
|
||||
if err := h.quotaService.AddUsage(c.Request.Context(), userID, totalSize); err != nil {
|
||||
_ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
var quotaErr *service.QuotaExceededError
|
||||
if errors.As(err, "aErr) {
|
||||
response.Error(c, http.StatusTooManyRequests, "存储配额已满,请删除不需要的作品释放空间")
|
||||
@@ -742,11 +742,11 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
id,
|
||||
accessURLs[0],
|
||||
accessURLs,
|
||||
service.SoraStorageTypeS3,
|
||||
h.objectStorage.StorageType(),
|
||||
uploadedKeys,
|
||||
totalSize,
|
||||
); err != nil {
|
||||
_ = h.s3Storage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
if usageAdded && h.quotaService != nil {
|
||||
_ = h.quotaService.ReleaseUsage(c.Request.Context(), userID, totalSize)
|
||||
}
|
||||
@@ -755,7 +755,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"message": "已保存到 S3",
|
||||
"message": "已保存到云存储",
|
||||
"object_key": uploadedKeys[0],
|
||||
"object_keys": uploadedKeys,
|
||||
})
|
||||
@@ -764,28 +764,30 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
// GetStorageStatus 返回存储状态。
|
||||
// GET /api/v1/sora/storage-status
|
||||
func (h *SoraClientHandler) GetStorageStatus(c *gin.Context) {
|
||||
s3Enabled := h.s3Storage != nil && h.s3Storage.Enabled(c.Request.Context())
|
||||
s3Healthy := false
|
||||
if s3Enabled {
|
||||
s3Healthy = h.s3Storage.IsHealthy(c.Request.Context())
|
||||
objectStorageEnabled := h.objectStorage != nil && h.objectStorage.Enabled(c.Request.Context())
|
||||
objectStorageHealthy := false
|
||||
storageType := ""
|
||||
if objectStorageEnabled {
|
||||
objectStorageHealthy = h.objectStorage.IsHealthy(c.Request.Context())
|
||||
storageType = h.objectStorage.StorageType()
|
||||
}
|
||||
localEnabled := h.mediaStorage != nil && h.mediaStorage.Enabled()
|
||||
response.Success(c, gin.H{
|
||||
"s3_enabled": s3Enabled,
|
||||
"s3_healthy": s3Healthy,
|
||||
"s3_enabled": objectStorageEnabled, // 保留字段名向后兼容
|
||||
"s3_healthy": objectStorageHealthy,
|
||||
"storage_type": storageType,
|
||||
"local_enabled": localEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SoraClientHandler) cleanupStoredMedia(ctx context.Context, storageType string, s3Keys []string, localPaths []string) {
|
||||
switch storageType {
|
||||
case service.SoraStorageTypeS3:
|
||||
if h.s3Storage != nil && len(s3Keys) > 0 {
|
||||
if err := h.s3Storage.DeleteObjects(ctx, s3Keys); err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理 S3 文件失败 keys=%v err=%v", s3Keys, err)
|
||||
if service.IsObjectStorageType(storageType) {
|
||||
if h.objectStorage != nil && len(s3Keys) > 0 {
|
||||
if err := h.objectStorage.DeleteObjects(ctx, s3Keys); err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理存储文件失败 type=%s keys=%v err=%v", storageType, s3Keys, err)
|
||||
}
|
||||
}
|
||||
case service.SoraStorageTypeLocal:
|
||||
} else if storageType == service.SoraStorageTypeLocal {
|
||||
if h.mediaStorage != nil && len(localPaths) > 0 {
|
||||
if err := h.mediaStorage.DeleteByRelativePaths(localPaths); err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理本地文件失败 paths=%v err=%v", localPaths, err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -417,3 +417,22 @@ func (r *soraGenerationRepository) CountByUserAndStatus(ctx context.Context, use
|
||||
err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountByStorageType 按存储类型和状态统计生成记录数。
|
||||
func (r *soraGenerationRepository) CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error) {
|
||||
if len(statuses) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(statuses))
|
||||
args := []any{storageType}
|
||||
for i, s := range statuses {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
||||
args = append(args, s)
|
||||
}
|
||||
|
||||
var count int64
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM sora_generations WHERE storage_type = $1 AND status IN (%s)", strings.Join(placeholders, ","))
|
||||
err := r.sql.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
@@ -647,7 +647,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
||||
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil)
|
||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil)
|
||||
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
jwtAuth := func(c *gin.Context) {
|
||||
|
||||
@@ -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,22 @@ 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)
|
||||
adminSettings.POST("/sora-storage/gdrive-test", h.Admin.GDriveOAuth.TestGDriveStorage)
|
||||
// Sora 存储统计
|
||||
adminSettings.GET("/sora-storage/gdrive-quota", h.Admin.Setting.GetGDriveQuota)
|
||||
adminSettings.GET("/sora-storage/video-stats", h.Admin.Setting.GetStorageVideoStats)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,437 @@
|
||||
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] 客户端缓存已清除")
|
||||
}
|
||||
|
||||
// GDriveQuotaInfo 包含 Google Drive 配额信息。
|
||||
type GDriveQuotaInfo struct {
|
||||
LimitBytes int64 `json:"limit_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
}
|
||||
|
||||
// TestConnection 测试 Google Drive 连接。
|
||||
func (s *SoraGDriveStorage) TestConnection(ctx context.Context) error {
|
||||
srv, _, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = srv.About.Get().Fields("storageQuota").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("gdrive About.Get failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuotaInfo 获取 Google Drive 配额信息(总量和已用量)。
|
||||
func (s *SoraGDriveStorage) GetQuotaInfo(ctx context.Context) (*GDriveQuotaInfo, error) {
|
||||
srv, _, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gdrive About.Get failed: %w", err)
|
||||
}
|
||||
if about.StorageQuota == nil {
|
||||
return nil, fmt.Errorf("storageQuota not available")
|
||||
}
|
||||
return &GDriveQuotaInfo{
|
||||
LimitBytes: about.StorageQuota.Limit,
|
||||
UsedBytes: about.StorageQuota.Usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestFullCycle 执行完整的上传→获取链接→删除测试。
|
||||
func (s *SoraGDriveStorage) TestFullCycle(ctx context.Context) (map[string]any, error) {
|
||||
srv, cfg, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init client: %w", err)
|
||||
}
|
||||
|
||||
result := map[string]any{}
|
||||
|
||||
// 1. 测试 API 连接
|
||||
about, err := srv.About.Get().Fields("storageQuota").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API connection failed: %w", err)
|
||||
}
|
||||
if about.StorageQuota != nil {
|
||||
result["quota_limit_bytes"] = about.StorageQuota.Limit
|
||||
result["quota_used_bytes"] = about.StorageQuota.Usage
|
||||
}
|
||||
|
||||
// 2. 上传测试文件
|
||||
testContent := "sub2api GDrive test file - " + time.Now().Format(time.RFC3339)
|
||||
fileMeta := &drive.File{
|
||||
Name: "sub2api_test_" + uuid.NewString()[:8] + ".txt",
|
||||
MimeType: "text/plain",
|
||||
}
|
||||
if cfg.FolderID != "" {
|
||||
fileMeta.Parents = []string{cfg.FolderID}
|
||||
}
|
||||
uploaded, err := srv.Files.Create(fileMeta).
|
||||
Media(strings.NewReader(testContent)).
|
||||
Fields("id,name,size,webViewLink").
|
||||
Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload test file failed: %w", err)
|
||||
}
|
||||
result["uploaded_file_id"] = uploaded.Id
|
||||
result["uploaded_file_name"] = uploaded.Name
|
||||
result["uploaded_file_size"] = uploaded.Size
|
||||
result["web_view_link"] = uploaded.WebViewLink
|
||||
|
||||
// 3. 获取访问链接
|
||||
accessURL, err := s.GetAccessURL(ctx, uploaded.Id)
|
||||
if err != nil {
|
||||
// 即使获取链接失败,仍尝试清理
|
||||
_ = srv.Files.Delete(uploaded.Id).Context(ctx).Do()
|
||||
return nil, fmt.Errorf("get access URL failed: %w", err)
|
||||
}
|
||||
result["access_url"] = accessURL
|
||||
|
||||
// 4. 删除测试文件
|
||||
if err := srv.Files.Delete(uploaded.Id).Context(ctx).Do(); err != nil {
|
||||
result["delete_warning"] = fmt.Sprintf("delete failed (manual cleanup needed): %v", err)
|
||||
} else {
|
||||
result["deleted"] = true
|
||||
}
|
||||
|
||||
result["status"] = "ok"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsHealthy 返回 Google Drive 健康状态(带短缓存)。
|
||||
func (s *SoraGDriveStorage) IsHealthy(ctx context.Context) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
lastCheck := s.healthCheckedAt
|
||||
lastErr := s.healthErr
|
||||
ttl := s.healthTTL
|
||||
s.mu.RUnlock()
|
||||
|
||||
if ttl <= 0 {
|
||||
ttl = defaultGDriveHealthTTL
|
||||
}
|
||||
if !lastCheck.IsZero() && now.Sub(lastCheck) < ttl {
|
||||
return lastErr == nil
|
||||
}
|
||||
|
||||
err := s.TestConnection(ctx)
|
||||
s.mu.Lock()
|
||||
s.healthCheckedAt = time.Now()
|
||||
s.healthErr = err
|
||||
s.mu.Unlock()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// UploadFromURL 从上游 URL 下载并上传到 Google Drive。
|
||||
// 返回 Google Drive 文件 ID 作为 objectKey。
|
||||
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, 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"
|
||||
@@ -60,4 +61,5 @@ type SoraGenerationRepository interface {
|
||||
Delete(ctx context.Context, id int64) error
|
||||
List(ctx context.Context, params SoraGenerationListParams) ([]*SoraGeneration, int64, error)
|
||||
CountByUserAndStatus(ctx context.Context, userID int64, statuses []string) (int64, error)
|
||||
CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -330,3 +330,22 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StorageVideoStats 各存储类型的视频统计。
|
||||
type StorageVideoStats struct {
|
||||
Completed int64 `json:"completed"`
|
||||
InProgress int64 `json:"in_progress"`
|
||||
}
|
||||
|
||||
// CountByStorageType 按存储类型统计视频数量(completed 和 in_progress)。
|
||||
func (s *SoraGenerationService) CountByStorageType(ctx context.Context, storageType string) (completed, inProgress int64, err error) {
|
||||
completed, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusCompleted})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("count completed: %w", err)
|
||||
}
|
||||
inProgress, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusPending, SoraGenStatusGenerating})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("count in_progress: %w", err)
|
||||
}
|
||||
return completed, inProgress, nil
|
||||
}
|
||||
|
||||
@@ -115,6 +115,25 @@ func (r *stubGenRepo) CountByUserAndStatus(_ context.Context, userID int64, stat
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *stubGenRepo) CountByStorageType(_ context.Context, storageType string, statuses []string) (int64, error) {
|
||||
if r.countErr != nil {
|
||||
return 0, r.countErr
|
||||
}
|
||||
var count int64
|
||||
statusSet := make(map[string]struct{})
|
||||
for _, s := range statuses {
|
||||
statusSet[s] = struct{}{}
|
||||
}
|
||||
for _, gen := range r.gens {
|
||||
if gen.StorageType == storageType {
|
||||
if _, ok := statusSet[gen.Status]; ok {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ==================== Stub: UserRepository (用于 SoraQuotaService) ====================
|
||||
|
||||
var _ UserRepository = (*stubUserRepoForQuota)(nil)
|
||||
@@ -519,7 +538,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()
|
||||
}
|
||||
@@ -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,115 @@ 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
|
||||
}
|
||||
|
||||
export interface TestGDriveStorageResponse {
|
||||
status: string
|
||||
quota_limit_bytes?: number
|
||||
quota_used_bytes?: number
|
||||
uploaded_file_id?: string
|
||||
uploaded_file_name?: string
|
||||
uploaded_file_size?: number
|
||||
access_url?: string
|
||||
web_view_link?: string
|
||||
deleted?: boolean
|
||||
delete_warning?: string
|
||||
}
|
||||
|
||||
export async function testGDriveStorage(): Promise<TestGDriveStorageResponse> {
|
||||
const { data } = await apiClient.post<TestGDriveStorageResponse>('/admin/settings/sora-storage/gdrive-test')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface GDriveQuotaInfo {
|
||||
limit_bytes: number
|
||||
used_bytes: number
|
||||
}
|
||||
|
||||
export interface StorageVideoStats {
|
||||
[type: string]: { completed: number; in_progress: number }
|
||||
}
|
||||
|
||||
export async function getGDriveQuota(): Promise<GDriveQuotaInfo> {
|
||||
const { data } = await apiClient.get<GDriveQuotaInfo>('/admin/settings/sora-storage/gdrive-quota')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getStorageVideoStats(): Promise<StorageVideoStats> {
|
||||
const { data } = await apiClient.get<StorageVideoStats>('/admin/settings/sora-storage/video-stats')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -426,7 +523,12 @@ export const settingsAPI = {
|
||||
createSoraS3Profile,
|
||||
updateSoraS3Profile,
|
||||
deleteSoraS3Profile,
|
||||
setActiveSoraS3Profile
|
||||
setActiveSoraS3Profile,
|
||||
startGDriveOAuth,
|
||||
exchangeGDriveOAuthCode,
|
||||
testGDriveStorage,
|
||||
getGDriveQuota,
|
||||
getStorageVideoStats
|
||||
}
|
||||
|
||||
export default settingsAPI
|
||||
|
||||
@@ -3905,40 +3905,55 @@ 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',
|
||||
profileId: 'Profile ID',
|
||||
name: 'Name',
|
||||
provider: 'Type',
|
||||
active: 'Active',
|
||||
endpoint: 'Endpoint',
|
||||
bucket: 'Bucket',
|
||||
storagePath: 'Storage Path',
|
||||
capacityUsage: 'Capacity / Used',
|
||||
capacityUnlimited: 'Unlimited',
|
||||
videoCount: 'Videos',
|
||||
videoCompleted: 'completed',
|
||||
videoInProgress: 'in progress',
|
||||
quota: 'Default Quota',
|
||||
updatedAt: 'Updated At',
|
||||
actions: 'Actions'
|
||||
actions: 'Actions',
|
||||
rootFolder: 'Root folder',
|
||||
testInTable: 'Test',
|
||||
testingInTable: 'Testing...',
|
||||
testTimeout: 'Test timed out (15s)'
|
||||
},
|
||||
enabled: 'Enable 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',
|
||||
@@ -3947,16 +3962,38 @@ 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',
|
||||
closeWindow: 'This window will close automatically',
|
||||
processing: 'Processing authorization...',
|
||||
testStorage: 'Test Storage',
|
||||
testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)',
|
||||
testFailed: 'Google Drive storage test failed'
|
||||
}
|
||||
},
|
||||
streamTimeout: {
|
||||
title: 'Stream Timeout Handling',
|
||||
|
||||
@@ -4065,40 +4065,55 @@ 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: '配置',
|
||||
profileId: 'Profile ID',
|
||||
name: '名称',
|
||||
provider: '存储类型',
|
||||
active: '生效状态',
|
||||
endpoint: '端点',
|
||||
bucket: '存储桶',
|
||||
storagePath: '存储路径',
|
||||
capacityUsage: '容量 / 已用',
|
||||
capacityUnlimited: '无限制',
|
||||
videoCount: '视频数',
|
||||
videoCompleted: '完成',
|
||||
videoInProgress: '进行中',
|
||||
quota: '默认配额',
|
||||
updatedAt: '更新时间',
|
||||
actions: '操作'
|
||||
actions: '操作',
|
||||
rootFolder: '根目录',
|
||||
testInTable: '测试',
|
||||
testingInTable: '测试中...',
|
||||
testTimeout: '测试超时(15秒)'
|
||||
},
|
||||
enabled: '启用 S3 存储',
|
||||
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到 S3 存储',
|
||||
enabled: '启用存储',
|
||||
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储',
|
||||
endpoint: 'S3 端点',
|
||||
region: '区域',
|
||||
bucket: '存储桶',
|
||||
@@ -4107,16 +4122,38 @@ 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 授权失败',
|
||||
closeWindow: '此窗口将自动关闭',
|
||||
processing: '正在处理授权...',
|
||||
testStorage: '测试存储',
|
||||
testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)',
|
||||
testFailed: 'Google Drive 存储测试失败'
|
||||
}
|
||||
},
|
||||
streamTimeout: {
|
||||
title: '流超时处理',
|
||||
|
||||
@@ -225,6 +225,17 @@ const routes: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Admin OAuth Callback Routes ====================
|
||||
{
|
||||
path: '/admin/gdrive-oauth-callback',
|
||||
name: 'GDriveOAuthCallback',
|
||||
component: () => import('@/views/admin/GDriveOAuthCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Google Drive OAuth'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
{
|
||||
path: '/admin',
|
||||
|
||||
@@ -22,23 +22,33 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[1000px] text-sm">
|
||||
<table class="w-full min-w-[1300px] text-sm">
|
||||
<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.profileId') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.name') }}</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.storagePath') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.capacityUsage') }}</th>
|
||||
<th class="py-2 pr-4">{{ t('admin.settings.soraS3.columns.videoCount') }}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-top dark:border-dark-800">
|
||||
<tr v-for="profile in soraS3Profiles" :key="profile.profile_id" class="border-b border-gray-100 align-middle dark:border-dark-800">
|
||||
<td class="py-3 pr-4">
|
||||
<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 text-xs">{{ profile.name }}</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
|
||||
@@ -49,14 +59,56 @@
|
||||
</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.bucket, profile.prefix].filter(Boolean).join('/') || '-' }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>{{ profile.folder_id ? `folder: ${profile.folder_id}` : t('admin.settings.soraS3.columns.rootFolder') }}</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">
|
||||
<template v-if="(profile.provider || 's3') === 'gdrive'">
|
||||
<template v-if="gdriveQuota">
|
||||
<div>{{ formatBytes(gdriveQuota.used_bytes) }} / {{ formatBytes(gdriveQuota.limit_bytes) }}</div>
|
||||
</template>
|
||||
<template v-else-if="loadingGDriveQuota">
|
||||
<div class="text-gray-400">{{ t('common.loading') }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-gray-400">-</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-gray-400">{{ t('admin.settings.soraS3.columns.capacityUnlimited') }}</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-xs">
|
||||
<template v-if="videoStats && videoStats[profile.provider || 's3']">
|
||||
<div>{{ videoStats[profile.provider || 's3'].completed }} {{ t('admin.settings.soraS3.columns.videoCompleted') }}</div>
|
||||
<div v-if="videoStats[profile.provider || 's3'].in_progress > 0" class="mt-0.5 text-gray-500 dark:text-gray-400">
|
||||
{{ videoStats[profile.provider || 's3'].in_progress }} {{ t('admin.settings.soraS3.columns.videoInProgress') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="loadingVideoStats">
|
||||
<div class="text-gray-400">{{ t('common.loading') }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-gray-400">-</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">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-xs"
|
||||
:disabled="testingProfiles[profile.profile_id]
|
||||
|| ((profile.provider || 's3') === 'gdrive' && !profile.is_active)"
|
||||
@click="testProfileInTable(profile)"
|
||||
>
|
||||
{{ testingProfiles[profile.profile_id] ? t('admin.settings.soraS3.columns.testingInTable') : t('admin.settings.soraS3.columns.testInTable') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-xs" @click="editSoraProfile(profile.profile_id)">
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
@@ -81,7 +133,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="soraS3Profiles.length === 0">
|
||||
<td colspan="7" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<td colspan="9" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.soraS3.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -91,6 +143,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile drawer -->
|
||||
<Teleport to="body">
|
||||
<Transition name="dm-drawer-mask">
|
||||
<div
|
||||
@@ -108,6 +161,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 v-if="soraProfileForm.provider" 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"
|
||||
@@ -119,7 +175,34 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<!-- Provider selection (only when creating and not yet chosen) -->
|
||||
<div v-if="creatingSoraProfile && !soraProfileForm.provider" class="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.soraS3.selectProvider') }}
|
||||
</h4>
|
||||
<div class="grid w-full max-w-sm 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="selectProvider('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="selectProvider('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>
|
||||
|
||||
<!-- Profile form (shown after provider is selected) -->
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<!-- Common fields -->
|
||||
<input
|
||||
v-model="soraProfileForm.profile_id"
|
||||
class="input w-full"
|
||||
@@ -135,33 +218,83 @@
|
||||
<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')"
|
||||
/>
|
||||
<input v-model="soraProfileForm.cdn_url" class="input w-full" :placeholder="t('admin.settings.soraS3.cdnUrl')" />
|
||||
<div>
|
||||
|
||||
<!-- 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.number="soraProfileForm.default_storage_quota_gb"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
v-model="soraProfileForm.secret_access_key"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.settings.soraS3.defaultQuota')"
|
||||
:placeholder="soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
|
||||
/>
|
||||
<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 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')" />
|
||||
<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>
|
||||
@@ -169,13 +302,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
|
||||
<div v-if="soraProfileForm.provider" class="flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700">
|
||||
<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
|
||||
v-if="soraProfileForm.provider === 'gdrive' && !creatingSoraProfile"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="testingSoraProfile || !soraProfileForm.enabled"
|
||||
@click="testGDriveStorageConnection"
|
||||
>
|
||||
{{ testingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.gdrive.testStorage') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="savingSoraProfile" @click="saveSoraProfile">
|
||||
{{ savingSoraProfile ? t('common.loading') : t('admin.settings.soraS3.saveProfile') }}
|
||||
</button>
|
||||
@@ -187,10 +335,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import type { SoraS3Profile } from '@/api/admin/settings'
|
||||
import type { SoraS3Profile, GDriveQuotaInfo, StorageVideoStats } from '@/api/admin/settings'
|
||||
import { adminAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
@@ -204,15 +352,23 @@ const activatingSoraProfile = ref(false)
|
||||
const deletingSoraProfile = ref(false)
|
||||
const creatingSoraProfile = ref(false)
|
||||
const soraProfileDrawerOpen = ref(false)
|
||||
const startingOAuth = ref(false)
|
||||
const loadingGDriveQuota = ref(false)
|
||||
const loadingVideoStats = ref(false)
|
||||
const testingProfiles: Record<string, boolean> = reactive({})
|
||||
const gdriveQuota = ref<GDriveQuotaInfo | null>(null)
|
||||
const videoStats = ref<StorageVideoStats | null>(null)
|
||||
|
||||
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 +377,31 @@ 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
|
||||
@@ -241,6 +417,9 @@ async function loadSoraS3Profiles() {
|
||||
}
|
||||
syncSoraProfileFormWithSelection()
|
||||
}
|
||||
// Async load quota and video stats (non-blocking)
|
||||
loadGDriveQuotaIfNeeded()
|
||||
loadVideoStats()
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
@@ -248,13 +427,90 @@ async function loadSoraS3Profiles() {
|
||||
}
|
||||
}
|
||||
|
||||
const testProfileTimeoutMs = 15000
|
||||
|
||||
async function testProfileInTable(profile: SoraS3Profile) {
|
||||
const pid = profile.profile_id
|
||||
if (testingProfiles[pid]) return
|
||||
|
||||
testingProfiles[pid] = true
|
||||
const provider = profile.provider || 's3'
|
||||
|
||||
try {
|
||||
if (provider === 'gdrive') {
|
||||
const result = await Promise.race([
|
||||
adminAPI.settings.testGDriveStorage(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
|
||||
])
|
||||
const msg = result.status === 'ok'
|
||||
? t('admin.settings.soraS3.gdrive.testSuccess')
|
||||
: t('admin.settings.soraS3.gdrive.testFailed')
|
||||
appStore.showSuccess(msg)
|
||||
} else {
|
||||
await Promise.race([
|
||||
adminAPI.settings.testSoraS3Connection({
|
||||
profile_id: pid,
|
||||
enabled: profile.enabled,
|
||||
endpoint: profile.endpoint || '',
|
||||
region: profile.region || '',
|
||||
bucket: profile.bucket || '',
|
||||
access_key_id: profile.access_key_id || '',
|
||||
prefix: profile.prefix || '',
|
||||
force_path_style: Boolean(profile.force_path_style),
|
||||
cdn_url: profile.cdn_url || '',
|
||||
default_storage_quota_bytes: profile.default_storage_quota_bytes || 0
|
||||
}),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(t('admin.settings.soraS3.columns.testTimeout'))), testProfileTimeoutMs))
|
||||
])
|
||||
appStore.showSuccess(t('admin.settings.soraS3.testSuccess'))
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
|
||||
} finally {
|
||||
testingProfiles[pid] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGDriveQuotaIfNeeded() {
|
||||
const hasGDrive = soraS3Profiles.value.some(
|
||||
(p) => (p.provider || 's3') === 'gdrive' && p.is_active
|
||||
)
|
||||
if (!hasGDrive) {
|
||||
gdriveQuota.value = null
|
||||
return
|
||||
}
|
||||
loadingGDriveQuota.value = true
|
||||
try {
|
||||
gdriveQuota.value = await adminAPI.settings.getGDriveQuota()
|
||||
} catch {
|
||||
gdriveQuota.value = null
|
||||
} finally {
|
||||
loadingGDriveQuota.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVideoStats() {
|
||||
loadingVideoStats.value = true
|
||||
try {
|
||||
videoStats.value = await adminAPI.settings.getStorageVideoStats()
|
||||
} catch {
|
||||
videoStats.value = null
|
||||
} finally {
|
||||
loadingVideoStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreateSoraProfile() {
|
||||
creatingSoraProfile.value = true
|
||||
selectedSoraProfileID.value = ''
|
||||
soraProfileForm.value = newDefaultSoraS3ProfileForm()
|
||||
soraProfileForm.value = newDefaultProfileForm('')
|
||||
soraProfileDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function selectProvider(provider: string) {
|
||||
soraProfileForm.value = newDefaultProfileForm(provider)
|
||||
}
|
||||
|
||||
function editSoraProfile(profileID: string) {
|
||||
selectedSoraProfileID.value = profileID
|
||||
creatingSoraProfile.value = false
|
||||
@@ -272,11 +528,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 +541,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
|
||||
}
|
||||
@@ -302,39 +560,66 @@ async function saveSoraProfile() {
|
||||
savingSoraProfile.value = true
|
||||
try {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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'))
|
||||
}
|
||||
@@ -359,8 +644,7 @@ async function testSoraProfileConnection() {
|
||||
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)
|
||||
cdn_url: soraProfileForm.value.cdn_url
|
||||
})
|
||||
appStore.showSuccess(result.message || t('admin.settings.soraS3.testSuccess'))
|
||||
} catch (error) {
|
||||
@@ -370,6 +654,82 @@ async function testSoraProfileConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testGDriveStorageConnection() {
|
||||
testingSoraProfile.value = true
|
||||
try {
|
||||
const result = await adminAPI.settings.testGDriveStorage()
|
||||
const msg = result.status === 'ok'
|
||||
? t('admin.settings.soraS3.gdrive.testSuccess')
|
||||
: t('admin.settings.soraS3.gdrive.testFailed')
|
||||
appStore.showSuccess(msg)
|
||||
} catch (error) {
|
||||
appStore.showError((error as { message?: string })?.message || t('admin.settings.soraS3.gdrive.testFailed'))
|
||||
} finally {
|
||||
testingSoraProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startGDriveOAuth() {
|
||||
const form = soraProfileForm.value
|
||||
if (!form.client_id || !form.client_secret) {
|
||||
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 +763,42 @@ 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'
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let idx = 0
|
||||
let val = bytes
|
||||
while (val >= 1024 && idx < units.length - 1) {
|
||||
val /= 1024
|
||||
idx++
|
||||
}
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`
|
||||
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
|
||||
}
|
||||
|
||||
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 +806,26 @@ 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,
|
||||
cdn_url: '',
|
||||
default_storage_quota_gb: 0
|
||||
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: ''
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +835,16 @@ 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),
|
||||
cdn_url: profile.cdn_url || '',
|
||||
default_storage_quota_gb: Number((quotaBytes / (1024 * 1024 * 1024)).toFixed(2))
|
||||
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 || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
|
||||
<div class="w-full max-w-md text-center">
|
||||
<div v-if="error" class="card p-8">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.soraS3.gdrive.oauthFailed') }}</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ error }}</p>
|
||||
<p class="mt-4 text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.soraS3.gdrive.closeWindow') }}</p>
|
||||
</div>
|
||||
<div v-else-if="sent" class="card p-8">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.soraS3.gdrive.oauthSuccess') }}</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.closeWindow') }}</p>
|
||||
</div>
|
||||
<div v-else class="card p-8">
|
||||
<div class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.soraS3.gdrive.processing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const error = ref('')
|
||||
const sent = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const code = route.query.code as string
|
||||
const state = route.query.state as string
|
||||
const errorParam = route.query.error as string
|
||||
const errorDesc = route.query.error_description as string
|
||||
|
||||
if (errorParam) {
|
||||
error.value = errorDesc || errorParam
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
error.value = 'No authorization code received'
|
||||
return
|
||||
}
|
||||
|
||||
// Send code back to opener window via postMessage
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(
|
||||
{ type: 'gdrive_oauth_callback', code, state },
|
||||
window.location.origin
|
||||
)
|
||||
sent.value = true
|
||||
// Auto-close after a short delay
|
||||
setTimeout(() => window.close(), 1500)
|
||||
} else {
|
||||
error.value = 'Cannot communicate with the parent window. Please close this window and try again.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user