feat(media): 对象存储 - 新增腾讯云 COS 与历史凭据兼容
Co-authored-by: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/218
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 新增腾讯云 COS 私有对象存储,支持平台和个人存储配置、对象上传、Range 读取与短时签名 URL,并保留原有阿里云 OSS 链路。
|
||||
|
||||
## v1.0.49
|
||||
|
||||
- 创作首页与画布提示词引用图支持点击放大,并为新素材上传补充落库说明、进行中状态和完成反馈。
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
- **影视工作流**:结构化分镜脚本、角色卡、批量镜头节点、3D 导演台和控制图回写。
|
||||
- **任务与素材**:后端异步队列、任务日志、失败重试、素材库及登录后的后端同步。
|
||||
- **Agent 能力**:网页画布助手、本地 Canvas Agent、Codex App 插件和技能库。
|
||||
- **管理与安全**:用户与系统渠道、用量分析、私有 OSS、资源归属校验和敏感配置加密。
|
||||
- **管理与安全**:用户与系统渠道、用量分析、阿里云 OSS / 腾讯云 COS 私有对象存储、资源归属校验和敏感配置加密。
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -254,7 +254,7 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
|
||||
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
|
||||
- 媒体资源在启用 OSS 时保存到私有 OSS,否则保存到后端数据目录;删除业务记录不会自动清理 OSS 对象。
|
||||
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;删除业务记录不会自动清理远端对象。
|
||||
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
|
||||
|
||||
## 公网部署安全
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75
|
||||
github.com/volcengine/volc-sdk-golang v1.0.253
|
||||
golang.org/x/crypto v0.54.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
@@ -18,6 +19,7 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
@@ -26,6 +28,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -38,8 +41,10 @@ require (
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.48 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
|
||||
@@ -91,6 +91,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
@@ -166,6 +167,7 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -214,6 +216,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
@@ -226,6 +230,7 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -346,6 +351,8 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -353,6 +360,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
||||
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q=
|
||||
@@ -423,6 +432,7 @@ github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAt
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
@@ -455,6 +465,11 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
|
||||
@@ -491,7 +491,7 @@ func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationI
|
||||
func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requirePublicURL bool) error {
|
||||
if !strings.HasPrefix(media.StorageKey, "resource:") {
|
||||
if requirePublicURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") {
|
||||
return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到 OSS 或提供公网素材地址")
|
||||
return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到对象存储或提供公网素材地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1953,7 +1953,7 @@ func videoGenerationsMediaURL(media providerMedia) (string, error) {
|
||||
if isPublicMediaURL(value) || strings.HasPrefix(value, "data:") {
|
||||
return value, nil
|
||||
}
|
||||
return "", errors.New("NewAPI Video Generations 的参考素材需要公网 URL;私有素材请先保存到 OSS")
|
||||
return "", errors.New("NewAPI Video Generations 的参考素材需要公网 URL;私有素材请先保存到对象存储")
|
||||
}
|
||||
|
||||
func normalizeNewAPIChannel2Ratio(value string, modelName string) string {
|
||||
@@ -2108,7 +2108,7 @@ func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{
|
||||
func newAPIChannel1MediaURL(media providerMedia) (string, error) {
|
||||
value := strings.TrimSpace(media.URL)
|
||||
if !isPublicMediaURL(value) {
|
||||
return "", errors.New("NewAPI 媒体任务的参考素材必须使用公网 HTTP(S) URL,请启用 OSS 或提供公网素材地址")
|
||||
return "", errors.New("NewAPI 媒体任务的参考素材必须使用公网 HTTP(S) URL,请启用对象存储或提供公网素材地址")
|
||||
}
|
||||
if _, err := ValidateOutboundURL(value); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
@@ -26,6 +27,8 @@ import (
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
cos "github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
|
||||
const providerResourceURLTTL = 4 * time.Hour
|
||||
@@ -278,7 +281,7 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran
|
||||
return nil, err
|
||||
}
|
||||
if setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||||
return nil, errors.New("OSS 访问密钥不可用")
|
||||
return nil, errors.New("对象存储访问密钥不可用")
|
||||
}
|
||||
setting.Provider = firstNonEmpty(resource.Provider, setting.Provider)
|
||||
setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint)
|
||||
@@ -637,22 +640,41 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource)
|
||||
if err != nil {
|
||||
return ossSettingValue{}, err
|
||||
}
|
||||
setting.Provider = firstNonEmpty(resource.Provider, setting.Provider)
|
||||
setting, err = ossSettingForProvider(setting, firstNonEmpty(resource.Provider, setting.Provider))
|
||||
if err != nil {
|
||||
return ossSettingValue{}, err
|
||||
}
|
||||
setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint)
|
||||
setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket)
|
||||
if setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||||
return ossSettingValue{}, errors.New("OSS 访问密钥不可用")
|
||||
return ossSettingValue{}, errors.New("对象存储访问密钥不可用")
|
||||
}
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "" || provider == setting.Provider {
|
||||
return setting, nil
|
||||
}
|
||||
credentials, ok := setting.ArchivedCredentials[provider]
|
||||
if !ok || credentials.AccessKeyID == "" || credentials.AccessKeySecret == "" {
|
||||
return ossSettingValue{}, errors.New("历史对象存储访问密钥不可用")
|
||||
}
|
||||
setting.Provider = provider
|
||||
setting.AccessKeyID = credentials.AccessKeyID
|
||||
setting.AccessKeySecret = credentials.AccessKeySecret
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func validateActiveOSSSetting(setting ossSettingValue, disabledMessage string, incompleteMessage string) (ossSettingValue, error) {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
if !setting.Enabled {
|
||||
return ossSettingValue{}, BadAuthRequest(disabledMessage)
|
||||
}
|
||||
if setting.Provider != "aliyun" {
|
||||
return ossSettingValue{}, BadAuthRequest("暂时只支持阿里云 OSS")
|
||||
if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider {
|
||||
return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS 和腾讯云 COS")
|
||||
}
|
||||
if setting.Bucket == "" || setting.Endpoint == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||||
return ossSettingValue{}, BadAuthRequest(incompleteMessage)
|
||||
@@ -686,6 +708,14 @@ func ossObjectKey(setting ossSettingValue, userID string, kind string, fileName
|
||||
}
|
||||
|
||||
func putOSSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) {
|
||||
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
|
||||
return putCOSObject(setting, objectKey, mimeType, size, body)
|
||||
}
|
||||
return putAliyunOSSObject(setting, objectKey, mimeType, size, body)
|
||||
}
|
||||
|
||||
// 阿里云 OSS 继续沿用原有 V1 签名和请求路径,避免已有部署行为发生变化。
|
||||
func putAliyunOSSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) {
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
@@ -717,6 +747,13 @@ type ossObjectStream struct {
|
||||
}
|
||||
|
||||
func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
|
||||
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
|
||||
return getCOSObjectRange(setting, objectKey, rangeHeader)
|
||||
}
|
||||
return getAliyunOSSObjectRange(setting, objectKey, rangeHeader)
|
||||
}
|
||||
|
||||
func getAliyunOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
|
||||
req, err := newOSSRequest(http.MethodGet, setting, objectKey, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -758,6 +795,13 @@ func decimalDigits(value string) bool {
|
||||
}
|
||||
|
||||
func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
|
||||
return signedCOSObjectURL(setting, objectKey, expiresAt)
|
||||
}
|
||||
return signedAliyunOSSObjectURL(setting, objectKey, expiresAt)
|
||||
}
|
||||
|
||||
func signedAliyunOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
baseURL, err := ossBucketBaseURL(setting)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -782,6 +826,101 @@ func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt tim
|
||||
return baseURL.String(), nil
|
||||
}
|
||||
|
||||
func putCOSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) {
|
||||
client, err := newCOSClient(setting, 2*time.Minute)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
options := &cos.ObjectPutOptions{ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{ContentType: mimeType, ContentLength: size}}
|
||||
resp, err := client.Object.Put(context.Background(), objectKey, body, options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("COS 上传失败:%w", err)
|
||||
}
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
|
||||
}
|
||||
|
||||
func getCOSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
|
||||
client, err := newCOSClient(setting, 2*time.Minute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := &cos.ObjectGetOptions{Range: rangeHeader}
|
||||
resp, err := client.Object.Get(context.Background(), objectKey, options)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||
return &ossObjectStream{body: io.NopCloser(bytes.NewReader(nil)), statusCode: resp.StatusCode, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("COS 读取失败:%w", err)
|
||||
}
|
||||
return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil
|
||||
}
|
||||
|
||||
func signedCOSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" {
|
||||
return "", errors.New("COS 访问密钥不可用")
|
||||
}
|
||||
objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/")
|
||||
if objectKey == "" {
|
||||
return "", errors.New("COS 对象路径为空")
|
||||
}
|
||||
expires := time.Until(expiresAt)
|
||||
if expires <= 0 {
|
||||
return "", errors.New("COS 签名有效期必须晚于当前时间")
|
||||
}
|
||||
client, err := newCOSClient(setting, 2*time.Minute)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signedURL.String(), nil
|
||||
}
|
||||
|
||||
func newCOSClient(setting ossSettingValue, timeout time.Duration) (*cos.Client, error) {
|
||||
bucketURL, err := cosBucketBaseURL(setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := OutboundHTTPClient(timeout)
|
||||
httpClient.Transport = &cos.AuthorizationTransport{SecretID: setting.AccessKeyID, SecretKey: setting.AccessKeySecret, Transport: httpClient.Transport}
|
||||
return cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, httpClient), nil
|
||||
}
|
||||
|
||||
func cosBucketBaseURL(setting ossSettingValue) (*url.URL, error) {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
endpoint := strings.TrimRight(setting.Endpoint, "/")
|
||||
if endpoint == "" {
|
||||
return nil, errors.New("COS Endpoint 为空")
|
||||
}
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
||||
return nil, errors.New("COS Endpoint 格式不正确")
|
||||
}
|
||||
if setting.Bucket == "" {
|
||||
return nil, errors.New("COS Bucket 为空")
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if strings.HasSuffix(host, ".myqcloud.com") || strings.HasSuffix(host, ".tencentcos.cn") {
|
||||
if strings.HasPrefix(host, "cos.") || strings.HasPrefix(host, "cos-internal.") || strings.HasPrefix(host, "cos-website.") {
|
||||
parsed.Host = setting.Bucket + "." + parsed.Host
|
||||
} else if !strings.HasPrefix(host, strings.ToLower(setting.Bucket)+".") {
|
||||
return nil, errors.New("COS Endpoint 中的 Bucket 与配置不一致")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func newOSSRequest(method string, setting ossSettingValue, objectKey string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
baseURL, err := ossBucketBaseURL(setting)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"hash/crc64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -39,6 +42,155 @@ func TestSignedOSSObjectURLUsesExpiringQuerySignature(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedOSSObjectURLSupportsTencentCOS(t *testing.T) {
|
||||
value, err := signedOSSObjectURL(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Region: "ap-guangzhou", Bucket: "private-bucket-1250000000",
|
||||
AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test image.png", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("signedOSSObjectURL() error = %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if parsed.Host != "private-bucket-1250000000.cos.ap-guangzhou.myqcloud.com" || query.Get("q-sign-algorithm") != "sha1" || query.Get("q-ak") != "secret-id" || query.Get("q-signature") == "" {
|
||||
t.Fatalf("signed COS URL = %q", value)
|
||||
}
|
||||
if strings.Contains(value, "secret-key") {
|
||||
t.Fatalf("signed COS URL leaked secret key: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutOSSObjectSupportsTencentCOS(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
payload := []byte("cos upload payload")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/users/u-1/image/test.png" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "image/png" {
|
||||
t.Errorf("Content-Type = %q", r.Header.Get("Content-Type"))
|
||||
}
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if !strings.Contains(authorization, "q-sign-algorithm=sha1") || !strings.Contains(authorization, "q-ak=secret-id") {
|
||||
t.Errorf("Authorization = %q", authorization)
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if !bytes.Equal(data, payload) {
|
||||
t.Errorf("body = %q", data)
|
||||
}
|
||||
w.Header().Set("ETag", `"cos-etag"`)
|
||||
w.Header().Set("x-cos-hash-crc64ecma", strconv.FormatUint(crc64.Checksum(data, crc64.MakeTable(crc64.ECMA)), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
etag, err := putOSSObject(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000",
|
||||
AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test.png", "image/png", int64(len(payload)), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if etag != "cos-etag" {
|
||||
t.Fatalf("ETag = %q", etag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSSObjectRangeSupportsTencentCOS(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "bytes=0-3" {
|
||||
t.Errorf("Range = %q", r.Header.Get("Range"))
|
||||
}
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if !strings.Contains(authorization, "q-sign-algorithm=sha1") || !strings.Contains(authorization, "q-ak=secret-id") {
|
||||
t.Errorf("Authorization = %q", authorization)
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 0-3/7")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
stream, err := getOSSObjectRange(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000",
|
||||
AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test.png", "bytes=0-3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.body.Close()
|
||||
data, err := io.ReadAll(stream.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" {
|
||||
t.Fatalf("stream = %#v, data = %q", stream, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.T) {
|
||||
normalized := normalizeOSSSetting(ossSettingValue{Provider: tencentCOSProvider, Region: "ap-shanghai"})
|
||||
if normalized.Endpoint != "https://cos.ap-shanghai.myqcloud.com" {
|
||||
t.Fatalf("Endpoint = %q", normalized.Endpoint)
|
||||
}
|
||||
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
_, err := ossSettingFromRequest(OSSSettingRequest{
|
||||
Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id",
|
||||
}, ossSettingValue{Provider: aliyunOSSProvider, AccessKeySecret: "aliyun-secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") {
|
||||
t.Fatalf("ossSettingFromRequest() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformProviderSwitchKeepsHistoricalCredentials(t *testing.T) {
|
||||
current := ossSettingValue{Provider: aliyunOSSProvider, AccessKeyID: "aliyun-id", AccessKeySecret: "aliyun-secret"}
|
||||
next := archiveOSSProviderCredentials(ossSettingValue{Provider: tencentCOSProvider, AccessKeyID: "cos-id", AccessKeySecret: "cos-secret"}, current)
|
||||
historical, err := ossSettingForProvider(next, aliyunOSSProvider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historical.Provider != aliyunOSSProvider || historical.AccessKeyID != "aliyun-id" || historical.AccessKeySecret != "aliyun-secret" {
|
||||
t.Fatalf("historical setting = %#v", historical)
|
||||
}
|
||||
if _, ok := next.ArchivedCredentials[tencentCOSProvider]; ok {
|
||||
t.Fatalf("active provider credentials were archived: %#v", next.ArchivedCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchivedProviderCredentialsAreEncryptedAtRest(t *testing.T) {
|
||||
svc := &Service{dataDir: t.TempDir()}
|
||||
value := ossSettingValue{
|
||||
Provider: tencentCOSProvider, AccessKeyID: "cos-id", AccessKeySecret: "cos-secret",
|
||||
ArchivedCredentials: map[string]ossProviderCredentials{
|
||||
aliyunOSSProvider: {AccessKeyID: "aliyun-id", AccessKeySecret: "aliyun-secret"},
|
||||
},
|
||||
}
|
||||
stored, err := svc.encryptOSSSettingSecrets(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(stored.AccessKeySecret, encryptedSettingPrefix) || !strings.HasPrefix(stored.ArchivedCredentials[aliyunOSSProvider].AccessKeySecret, encryptedSettingPrefix) {
|
||||
t.Fatalf("stored credentials are not encrypted: %#v", stored)
|
||||
}
|
||||
if _, err := svc.decryptOSSSettingSecrets(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.AccessKeySecret != "cos-secret" || stored.ArchivedCredentials[aliyunOSSProvider].AccessKeySecret != "aliyun-secret" {
|
||||
t.Fatalf("decrypted credentials = %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectResourceURLChecksOwnershipAndSignsOSSResource(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
|
||||
@@ -22,6 +22,11 @@ import (
|
||||
const ossSettingKey = "oss"
|
||||
const encryptedSettingPrefix = "enc:v1:"
|
||||
|
||||
const (
|
||||
aliyunOSSProvider = "aliyun"
|
||||
tencentCOSProvider = "tencent"
|
||||
)
|
||||
|
||||
type OSSSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
@@ -59,6 +64,13 @@ type ossSettingValue struct {
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
PathPrefix string `json:"pathPrefix"`
|
||||
// 平台切换云厂商后仍需读取历史资源,因此仅归档非当前厂商的访问密钥。
|
||||
ArchivedCredentials map[string]ossProviderCredentials `json:"archivedCredentials,omitempty"`
|
||||
}
|
||||
|
||||
type ossProviderCredentials struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminOSSSetting(actor *model.User) (*PublicOSSSetting, error) {
|
||||
@@ -93,8 +105,8 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P
|
||||
return nil, fmt.Errorf("服务器访问地址无效:%w", err)
|
||||
}
|
||||
}
|
||||
stored := next
|
||||
stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret)
|
||||
next = archiveOSSProviderCredentials(next, currentValue)
|
||||
stored, err := s.encryptOSSSettingSecrets(next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,8 +153,7 @@ func (s *Service) UpdateUserOSSSetting(actor *model.User, req OSSSettingRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := next
|
||||
stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret)
|
||||
stored, err := s.encryptOSSSettingSecrets(next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,14 +184,12 @@ func (s *Service) readOSSSetting() (*model.SystemSetting, ossSettingValue, error
|
||||
return nil, ossSettingValue{}, errors.New("平台 OSS 配置格式无效")
|
||||
}
|
||||
}
|
||||
storedSecret := value.AccessKeySecret
|
||||
value.AccessKeySecret, err = s.decryptSettingSecret(value.AccessKeySecret)
|
||||
needsMigration, err := s.decryptOSSSettingSecrets(&value)
|
||||
if err != nil {
|
||||
return nil, ossSettingValue{}, err
|
||||
}
|
||||
if storedSecret != "" && !strings.HasPrefix(storedSecret, encryptedSettingPrefix) {
|
||||
migrated := value
|
||||
migrated.AccessKeySecret, err = s.encryptSettingSecret(value.AccessKeySecret)
|
||||
if needsMigration {
|
||||
migrated, err := s.encryptOSSSettingSecrets(value)
|
||||
if err != nil {
|
||||
return nil, ossSettingValue{}, err
|
||||
}
|
||||
@@ -224,13 +233,49 @@ func (s *Service) userOSSSettingValue(setting *model.UserOSSSetting) (ossSetting
|
||||
return ossSettingValue{}, errors.New("用户 OSS 配置格式无效")
|
||||
}
|
||||
}
|
||||
secret, err := s.decryptSettingSecret(value.AccessKeySecret)
|
||||
if _, err := s.decryptOSSSettingSecrets(&value); err != nil {
|
||||
return ossSettingValue{}, err
|
||||
}
|
||||
value.Enabled = setting.Enabled
|
||||
return normalizeOSSSetting(value), nil
|
||||
}
|
||||
|
||||
func (s *Service) encryptOSSSettingSecrets(value ossSettingValue) (ossSettingValue, error) {
|
||||
var err error
|
||||
value.AccessKeySecret, err = s.encryptSettingSecret(value.AccessKeySecret)
|
||||
if err != nil {
|
||||
return ossSettingValue{}, err
|
||||
}
|
||||
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
|
||||
for provider, credentials := range value.ArchivedCredentials {
|
||||
credentials.AccessKeySecret, err = s.encryptSettingSecret(credentials.AccessKeySecret)
|
||||
if err != nil {
|
||||
return ossSettingValue{}, err
|
||||
}
|
||||
value.ArchivedCredentials[provider] = credentials
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Service) decryptOSSSettingSecrets(value *ossSettingValue) (bool, error) {
|
||||
needsMigration := value.AccessKeySecret != "" && !strings.HasPrefix(value.AccessKeySecret, encryptedSettingPrefix)
|
||||
secret, err := s.decryptSettingSecret(value.AccessKeySecret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
value.AccessKeySecret = secret
|
||||
value.Enabled = setting.Enabled
|
||||
return normalizeOSSSetting(value), nil
|
||||
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
|
||||
for provider, credentials := range value.ArchivedCredentials {
|
||||
if credentials.AccessKeySecret != "" && !strings.HasPrefix(credentials.AccessKeySecret, encryptedSettingPrefix) {
|
||||
needsMigration = true
|
||||
}
|
||||
credentials.AccessKeySecret, err = s.decryptSettingSecret(credentials.AccessKeySecret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
value.ArchivedCredentials[provider] = credentials
|
||||
}
|
||||
return needsMigration, nil
|
||||
}
|
||||
|
||||
func (s *Service) encryptSettingSecret(value string) (string, error) {
|
||||
@@ -397,7 +442,7 @@ func (s *Service) decryptTaskSecrets(value interface{}) error {
|
||||
}
|
||||
|
||||
func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossSettingValue, error) {
|
||||
next := ossSettingValue{
|
||||
next := normalizeOSSSetting(ossSettingValue{
|
||||
Enabled: req.Enabled,
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
Region: strings.TrimSpace(req.Region),
|
||||
@@ -407,22 +452,24 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
|
||||
AccessKeySecret: strings.TrimSpace(req.AccessKeySecret),
|
||||
PublicBaseURL: strings.TrimRight(strings.TrimSpace(req.PublicBaseURL), "/"),
|
||||
PathPrefix: strings.Trim(strings.TrimSpace(req.PathPrefix), "/"),
|
||||
})
|
||||
if next.Provider != aliyunOSSProvider && next.Provider != tencentCOSProvider {
|
||||
return next, BadAuthRequest("仅支持阿里云 OSS 和腾讯云 COS")
|
||||
}
|
||||
if next.Provider == "" {
|
||||
next.Provider = "aliyun"
|
||||
}
|
||||
if next.Provider != "aliyun" {
|
||||
return next, BadAuthRequest("暂时只支持阿里云 OSS")
|
||||
}
|
||||
if next.AccessKeySecret == "" {
|
||||
current = normalizeOSSSetting(current)
|
||||
// 不同云厂商的密钥不能复用;只有继续使用同一厂商时,留空才表示保留原密钥。
|
||||
if next.AccessKeySecret == "" && next.Provider == current.Provider {
|
||||
next.AccessKeySecret = current.AccessKeySecret
|
||||
}
|
||||
if next.Enabled {
|
||||
if next.Bucket == "" {
|
||||
return next, BadAuthRequest("请填写 OSS Bucket")
|
||||
return next, BadAuthRequest("请填写对象存储 Bucket")
|
||||
}
|
||||
if next.Endpoint == "" {
|
||||
return next, BadAuthRequest("请填写 OSS Endpoint")
|
||||
if next.Provider == tencentCOSProvider {
|
||||
return next, BadAuthRequest("请填写腾讯云 COS Region 或 Endpoint")
|
||||
}
|
||||
return next, BadAuthRequest("请填写阿里云 OSS Endpoint")
|
||||
}
|
||||
if _, err := ValidateOutboundURL(next.Endpoint); err != nil {
|
||||
return next, err
|
||||
@@ -437,23 +484,53 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func archiveOSSProviderCredentials(next ossSettingValue, current ossSettingValue) ossSettingValue {
|
||||
next.ArchivedCredentials = cloneOSSProviderCredentials(current.ArchivedCredentials)
|
||||
if current.Provider != next.Provider && (current.AccessKeyID != "" || current.AccessKeySecret != "") {
|
||||
if next.ArchivedCredentials == nil {
|
||||
next.ArchivedCredentials = make(map[string]ossProviderCredentials)
|
||||
}
|
||||
next.ArchivedCredentials[current.Provider] = ossProviderCredentials{AccessKeyID: current.AccessKeyID, AccessKeySecret: current.AccessKeySecret}
|
||||
}
|
||||
delete(next.ArchivedCredentials, next.Provider)
|
||||
return next
|
||||
}
|
||||
|
||||
func cloneOSSProviderCredentials(source map[string]ossProviderCredentials) map[string]ossProviderCredentials {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]ossProviderCredentials, len(source))
|
||||
for provider, credentials := range source {
|
||||
cloned[strings.ToLower(strings.TrimSpace(provider))] = ossProviderCredentials{
|
||||
AccessKeyID: strings.TrimSpace(credentials.AccessKeyID),
|
||||
AccessKeySecret: strings.TrimSpace(credentials.AccessKeySecret),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeOSSSetting(value ossSettingValue) ossSettingValue {
|
||||
value.Provider = strings.TrimSpace(value.Provider)
|
||||
value.Provider = strings.ToLower(strings.TrimSpace(value.Provider))
|
||||
if value.Provider == "" {
|
||||
value.Provider = "aliyun"
|
||||
value.Provider = aliyunOSSProvider
|
||||
}
|
||||
value.Region = strings.TrimSpace(value.Region)
|
||||
value.Endpoint = strings.TrimRight(strings.TrimSpace(value.Endpoint), "/")
|
||||
if value.Provider == tencentCOSProvider && value.Endpoint == "" && value.Region != "" {
|
||||
value.Endpoint = "https://cos." + value.Region + ".myqcloud.com"
|
||||
}
|
||||
value.Bucket = strings.TrimSpace(value.Bucket)
|
||||
value.AccessKeyID = strings.TrimSpace(value.AccessKeyID)
|
||||
value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret)
|
||||
value.PublicBaseURL = strings.TrimRight(strings.TrimSpace(value.PublicBaseURL), "/")
|
||||
value.PathPrefix = strings.Trim(strings.TrimSpace(value.PathPrefix), "/")
|
||||
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultOSSSetting() ossSettingValue {
|
||||
return ossSettingValue{Provider: "aliyun"}
|
||||
return ossSettingValue{Provider: aliyunOSSProvider}
|
||||
}
|
||||
|
||||
func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) PublicOSSSetting {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type OSSFormValues = {
|
||||
enabled?: boolean;
|
||||
provider: "aliyun";
|
||||
provider: "aliyun" | "tencent";
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
bucket?: string;
|
||||
@@ -24,6 +24,11 @@ export function UserOSSSettingsForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savedAt = formatSavedAt(setting?.updatedAt);
|
||||
const provider = Form.useWatch("provider", form) || "aliyun";
|
||||
const isTencentCOS = provider === "tencent";
|
||||
const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret);
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
|
||||
|
||||
useEffect(() => {
|
||||
if (!actor?.id) return;
|
||||
@@ -35,7 +40,7 @@ export function UserOSSSettingsForm() {
|
||||
setSetting(data.setting);
|
||||
form.setFieldsValue(toFormValues(data.setting));
|
||||
})
|
||||
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人 OSS 配置失败"))
|
||||
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人对象存储配置失败"))
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -43,7 +48,7 @@ export function UserOSSSettingsForm() {
|
||||
}, [actor?.id, form, message]);
|
||||
|
||||
if (!actor) {
|
||||
return <div className="rounded-md border border-dashed border-border px-5 py-10 text-center text-sm text-foreground/55">登录后可配置个人 OSS。</div>;
|
||||
return <div className="rounded-md border border-dashed border-border px-5 py-10 text-center text-sm text-foreground/55">登录后可配置个人对象存储。</div>;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
@@ -62,9 +67,9 @@ export function UserOSSSettingsForm() {
|
||||
});
|
||||
setSetting(data.setting);
|
||||
form.setFieldsValue(toFormValues(data.setting));
|
||||
message.success(data.setting.enabled ? "个人 OSS 已启用,后续上传将优先使用该存储" : "个人 OSS 已停用,后续上传将使用平台存储");
|
||||
message.success(data.setting.enabled ? "个人对象存储已启用,后续上传将优先使用该存储" : "个人对象存储已停用,后续上传将使用平台存储");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存个人 OSS 配置失败");
|
||||
message.error(error instanceof Error ? error.message : "保存个人对象存储配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -76,9 +81,9 @@ export function UserOSSSettingsForm() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cloud className="size-4" />
|
||||
我的 OSS
|
||||
我的对象存储
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">启用后,新上传和新生成的媒体优先写入你的 OSS;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
|
||||
@@ -89,36 +94,41 @@ export function UserOSSSettingsForm() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Form.Item name="enabled" label="启用个人 OSS" valuePropName="checked" className="mb-3">
|
||||
<Form.Item name="enabled" label="启用个人对象存储" valuePropName="checked" className="mb-3">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
|
||||
<Select options={[{ label: "阿里云 OSS", value: "aliyun" }]} />
|
||||
<Select
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }]}
|
||||
onChange={(nextProvider: OSSFormValues["provider"]) => {
|
||||
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="region" label="Region" className="mb-3">
|
||||
<Input spellCheck={false} placeholder="oss-cn-hangzhou" />
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : "oss-cn-hangzhou"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint" className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder="https://oss-cn-hangzhou.aliyuncs.com" />
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket" className="mb-3">
|
||||
<Input spellCheck={false} placeholder="my-canvas-assets" />
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : "my-canvas-assets"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
|
||||
<Input spellCheck={false} placeholder="infinite-canvas" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessKeyId" label="AccessKey ID" className="mb-3 xl:col-span-1">
|
||||
<Input autoComplete="off" spellCheck={false} placeholder="阿里云 AccessKey ID" />
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel} className="mb-3 xl:col-span-1">
|
||||
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? "AccessKey Secret(留空保留)" : "AccessKey Secret"} className="mb-3 xl:col-span-2">
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={setting?.hasAccessKeySecret ? "留空保留已加密密钥" : "阿里云 AccessKey Secret"} />
|
||||
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(留空保留)` : accessKeySecretLabel} className="mb-3 xl:col-span-2">
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={hasCurrentProviderSecret ? "留空保留已加密密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
|
||||
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人 OSS 配置"}</span>
|
||||
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}</span>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存个人 OSS
|
||||
保存个人对象存储
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function resourceStorageLabel(storageKey?: string) {
|
||||
|
||||
export function resourceStorageTitle(storageKey?: string) {
|
||||
const location = resourceStorageLocation(storageKey);
|
||||
if (location === "oss") return "已上传到 OSS,并以账号资源同步";
|
||||
if (location === "local") return "保存在当前浏览器本地,通常是 OSS 未启用或上传失败后的降级";
|
||||
if (location === "oss") return "已上传到对象存储,并以账号资源同步";
|
||||
if (location === "local") return "保存在当前浏览器本地,通常是对象存储未启用或上传失败后的降级";
|
||||
return "还没有可同步的资源标识";
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
|
||||
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" /> },
|
||||
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type StorageMode = "local" | "oss";
|
||||
type StorageMode = "local" | "aliyun" | "tencent";
|
||||
type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
@@ -18,27 +18,34 @@ export default function StorageSettingsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<OSSFormValues>();
|
||||
const mode = Form.useWatch("mode", form) || "local";
|
||||
const isObjectStorage = mode !== "local";
|
||||
const isTencentCOS = mode === "tencent";
|
||||
const selectedProviderLabel = isTencentCOS ? "腾讯云 COS" : "阿里云 OSS";
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
|
||||
const hasCurrentProviderSecret = Boolean(setting && setting.provider === mode && setting.hasAccessKeySecret);
|
||||
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
|
||||
|
||||
useEffect(() => {
|
||||
void getAdminOSSSetting()
|
||||
.then(({ setting: value }) => { setSetting(value); form.setFieldsValue(formValues(value)); })
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取 OSS 配置失败"))
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取对象存储配置失败"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [form, message]);
|
||||
|
||||
const save = async () => {
|
||||
await form.validateFields();
|
||||
// 本地模式会卸载 OSS 字段,读取完整 store 才能保留已保存的 OSS 配置。
|
||||
// 本地模式会卸载对象存储字段,读取完整 store 才能保留已保存的配置。
|
||||
const values = form.getFieldsValue(true);
|
||||
if (values.mode === "local" && !values.publicBaseUrl?.trim()) return message.error("请填写服务器访问地址");
|
||||
if (values.mode === "oss" && !values.accessKeySecret?.trim() && !setting?.hasAccessKeySecret) return message.error("请填写 AccessKey Secret");
|
||||
if (values.mode === "oss" && !values.endpoint?.trim()) return message.error("请填写 OSS Endpoint");
|
||||
if (values.mode === "oss" && !values.bucket?.trim()) return message.error("请填写 OSS Bucket");
|
||||
if (values.mode === "oss" && !values.accessKeyId?.trim()) return message.error("请填写 AccessKey ID");
|
||||
if (values.mode !== "local" && !values.accessKeySecret?.trim() && !hasCurrentProviderSecret) return message.error(`请填写${values.mode === "tencent" ? " SecretKey" : " AccessKey Secret"}`);
|
||||
if (values.mode === "aliyun" && !values.endpoint?.trim()) return message.error("请填写阿里云 OSS Endpoint");
|
||||
if (values.mode === "tencent" && !values.endpoint?.trim() && !values.region?.trim()) return message.error("请填写腾讯云 COS Region 或 Endpoint");
|
||||
if (values.mode !== "local" && !values.bucket?.trim()) return message.error("请填写对象存储 Bucket");
|
||||
if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写${values.mode === "tencent" ? " SecretId" : " AccessKey ID"}`);
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateAdminOSSSetting({ enabled: values.mode === "oss", provider: "aliyun", region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
|
||||
const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(formValues(result.setting));
|
||||
message.success("存储配置已保存");
|
||||
@@ -59,7 +66,7 @@ export default function StorageSettingsPage() {
|
||||
icon={<Cloud className="size-4" />}
|
||||
title="平台存储"
|
||||
description="选择平台新增媒体资源的默认写入方式。"
|
||||
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "blue" : "default"}>{setting?.enabled ? "阿里云 OSS" : "服务器本地"}</Tag>{setting?.enabled ? <Tag variant="filled" color={setting.hasAccessKeySecret ? "success" : "warning"}>{setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag> : null}</Space>}
|
||||
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "blue" : "default"}>{setting?.enabled ? storageProviderLabel(setting.provider) : "服务器本地"}</Tag>{setting?.enabled ? <Tag variant="filled" color={setting.hasAccessKeySecret ? "success" : "warning"}>{setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag> : null}</Space>}
|
||||
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "尚未保存平台存储配置"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存存储配置</Button></>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
|
||||
@@ -69,18 +76,24 @@ export default function StorageSettingsPage() {
|
||||
block
|
||||
options={[
|
||||
{ label: <span className="inline-flex items-center gap-2"><HardDrive className="size-4" />服务器本地</span>, value: "local" },
|
||||
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />阿里云 OSS</span>, value: "oss" },
|
||||
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />阿里云 OSS</span>, value: "aliyun" },
|
||||
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />腾讯云 COS</span>, value: "tencent" },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
const nextMode = value as StorageMode;
|
||||
const switchingProvider = nextMode !== "local" && ((mode !== "local" && mode !== nextMode) || (mode === "local" && setting?.provider !== nextMode));
|
||||
if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{mode === "oss" ? (
|
||||
{isObjectStorage ? (
|
||||
<>
|
||||
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder="例如:oss-cn-hangzhou" /></Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint"><Input autoComplete="off" placeholder="https://oss-cn-hangzhou.aliyuncs.com" /></Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder="例如:my-canvas-assets" /></Form.Item>
|
||||
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : "例如:oss-cn-hangzhou"} /></Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined}><Input autoComplete="off" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} /></Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:my-canvas-assets-1250000000" : "例如:my-canvas-assets"} /></Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀"><Input autoComplete="off" placeholder="例如:uploads/infinite-canvas" /></Form.Item>
|
||||
<Form.Item name="accessKeyId" label="AccessKey ID"><Input autoComplete="off" placeholder="阿里云 AccessKey ID" /></Form.Item>
|
||||
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? `AccessKey Secret(${configuredSecretText})` : "AccessKey Secret"}><Input.Password autoComplete="new-password" placeholder={setting?.hasAccessKeySecret ? "留空保留原密钥" : "阿里云 AccessKey Secret"} /></Form.Item>
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel}><Input autoComplete="off" placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} /></Form.Item>
|
||||
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(${configuredSecretText})` : accessKeySecretLabel}><Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} /></Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -111,12 +124,13 @@ export default function StorageSettingsPage() {
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={mode === "oss" ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={mode === "oss" ? "新资源写入阿里云 OSS" : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text="外部链接仅在签名有效期内可用" /></div>
|
||||
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={isObjectStorage ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={isObjectStorage ? `新资源写入${selectedProviderLabel}` : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text="外部链接仅在签名有效期内可用" /></div>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? "oss" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
|
||||
function storageProviderLabel(provider?: AdminOSSSetting["provider"]) { return provider === "tencent" ? "腾讯云 COS" : "阿里云 OSS"; }
|
||||
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 px-3 py-2.5"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }
|
||||
|
||||
@@ -34,7 +34,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
|
||||
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
|
||||
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
@@ -527,7 +527,7 @@ export default function SettingsPage() {
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Cloud className="size-4" />
|
||||
我的 OSS
|
||||
我的对象存储
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
|
||||
@@ -249,7 +249,7 @@ export type UserPromptPreference = {
|
||||
|
||||
export type AdminOSSSetting = {
|
||||
enabled: boolean;
|
||||
provider: "aliyun";
|
||||
provider: "aliyun" | "tencent";
|
||||
region: string;
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type RemoteResource = {
|
||||
|
||||
export type UserOSSSetting = {
|
||||
enabled: boolean;
|
||||
provider: "aliyun";
|
||||
provider: "aliyun" | "tencent";
|
||||
region: string;
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
@@ -112,10 +112,10 @@ export async function getResourceOSSUrl(storageKey?: string) {
|
||||
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
|
||||
try {
|
||||
const data = await request<{ url: string }>(api.get(`/resources/${encodeURIComponent(id)}/oss-url`));
|
||||
if (!data.url) throw new Error("后端未返回 OSS 地址");
|
||||
if (!data.url) throw new Error("后端未返回对象存储地址");
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取 OSS 地址失败");
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取对象存储地址失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ async function pollVideoGenerationsTask(config: ResolvedAiConfig, task: VideoGen
|
||||
async function resolveVideoGenerationsUrl(value: string | undefined, storageKey?: string) {
|
||||
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
|
||||
if (isPublicMediaUrl(value || "")) return String(value);
|
||||
throw new Error("NewAPI Video Generations 的参考素材需要公网 URL;请先把素材保存到 OSS");
|
||||
throw new Error("NewAPI Video Generations 的参考素材需要公网 URL;请先把素材保存到对象存储");
|
||||
}
|
||||
|
||||
type GeminiVeoOperation = {
|
||||
@@ -488,7 +488,7 @@ async function buildVolcengineArkContent(prompt: string, references: ReferenceIm
|
||||
async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) {
|
||||
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
|
||||
if (isPublicMediaUrl(value || "") || String(value || "").startsWith("asset://")) return String(value);
|
||||
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到 OSS");
|
||||
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储");
|
||||
}
|
||||
|
||||
async function buildSeedanceVideosPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
|
||||
|
||||
Reference in New Issue
Block a user