支持 S3 兼容对象存储 (#336)

* feat(storage): 支持 S3 对象存储

* fix(storage): 补齐 S3 对象文件后缀
This commit is contained in:
Rou
2026-08-27 22:16:08 +08:00
committed by GitHub
parent 4675cf4c99
commit 482d8bdbc2
25 changed files with 1374 additions and 88 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased
- 新增平台与个人 S3 兼容存储,支持 AWS S3、Cloudflare R2、Backblaze B2、RustFS 和自定义 Endpoint,提供 Path Style、临时凭据、连接测试、历史位置绑定及私网资源 HTTPS 代理;个人配置启用时优先于平台存储。
- 新增用户诊断包导出:设置页和失败任务详情支持导出最近 15 分钟、30 分钟、1 小时或 24 小时内的脱敏 ZIP,并通过 `traceId`、`requestId`、`taskId` 和 `providerRequestId` 关联前端、任务日志与模型上游调用。
- 建立诊断数据安全边界:前端内存事件缓冲区最多保留 500 条,后端按当前用户校验任务和项目归属,导出时排除提示词、任务输入、请求/响应体、密钥、Cookie 和 URL 查询参数,并补充脱敏与越权测试。
- 修复 Eagle 素材库在素材缺少文件夹或标签时的空集合兼容问题,避免前端读取 `folderIds` 或 `tags` 时出现异常。

View File

@@ -221,6 +221,12 @@ func migrations() []tableMigration {
migrateTable[model.EmailVerificationCode]("email_verification_codes"),
migrateTable[model.ModelChannel]("model_channels"),
migrateTable[model.ChannelModel]("channel_models"),
migrateTable[model.ChannelModelPriceTier]("channel_model_price_tiers"),
migrateTable[model.IDSequence]("id_sequences"),
migrateTable[model.LogicalModel]("logical_models"),
migrateTable[model.LogicalModelRevision]("logical_model_revisions"),
migrateTable[model.LogicalModelRoute]("logical_model_routes"),
migrateTable[model.RouteAttempt]("route_attempts"),
migrateTable[model.ApiCallLog]("api_call_logs"),
migrateTable[model.ModelPricing]("model_pricings"),
migrateTable[model.CreditAccount]("credit_accounts"),
@@ -231,13 +237,19 @@ func migrations() []tableMigration {
migrateTable[model.AdminAuditEvent]("admin_audit_events"),
migrateTable[model.UserDailyActivity]("user_daily_activities"),
migrateTable[model.SystemSetting]("system_settings"),
migrateTable[model.PluginPlatformState]("plugin_platform_states"),
migrateTable[model.UserPluginState]("user_plugin_states"),
migrateTable[model.ArkPrivateAssetBinding]("ark_private_asset_bindings"),
migrateTable[model.UserOSSSetting]("user_oss_settings"),
migrateTable[model.StorageLocation]("storage_locations"),
migrateTable[model.UserDailyUploadUsage]("user_daily_upload_usages"),
migrateTable[model.Skill]("skills"),
migrateTable[model.UserSkillState]("user_skill_states"),
migrateTable[model.Resource]("resources"),
migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"),
migrateTable[model.Asset]("assets"),
migrateTable[model.ProjectAssetLink]("project_asset_links"),
migrateTable[model.ProjectAssetFolder]("project_asset_folders"),
migrateTable[model.ProjectAssetCandidate]("project_asset_candidates"),
migrateTable[model.AssetVersion]("asset_versions"),
migrateTable[model.AssetRepresentation]("asset_representations"),
@@ -266,5 +278,7 @@ func migrations() []tableMigration {
migrateTable[model.TaskLog]("task_logs"),
migrateTable[model.SessionFile]("session_files"),
migrateTable[model.Result]("results"),
migrateTable[model.ComfyBridge]("comfy_bridges"),
migrateTable[model.ComfyBridgeRequest]("comfy_bridge_requests"),
}
}

View File

@@ -0,0 +1,36 @@
package main
import (
"sync"
"testing"
"infinite-canvas/backend/internal/database"
"gorm.io/gorm/schema"
)
func TestMigrationListCoversSchemaModels(t *testing.T) {
want := make(map[string]bool, len(database.Models()))
cache := &sync.Map{}
for _, value := range database.Models() {
parsed, err := schema.Parse(value, cache, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
want[parsed.Table] = false
}
for _, migration := range migrations() {
if _, exists := want[migration.name]; !exists {
t.Fatalf("migration list contains unknown table %q", migration.name)
}
if want[migration.name] {
t.Fatalf("migration list contains duplicate table %q", migration.name)
}
want[migration.name] = true
}
for table, covered := range want {
if !covered {
t.Errorf("migration list is missing table %q", table)
}
}
}

View File

@@ -43,6 +43,7 @@ func Models() []any {
&model.UserPluginState{},
&model.ArkPrivateAssetBinding{},
&model.UserOSSSetting{},
&model.StorageLocation{},
&model.UserDailyUploadUsage{},
&model.Skill{},
&model.UserSkillState{},

View File

@@ -477,6 +477,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.OSSSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
@@ -489,6 +490,28 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"setting": setting})
})
r.POST("/admin/settings/oss/test", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "admin-storage-test:"+user.ID, 6, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.OSSSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.TestAdminOSSSetting(user, req)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/admin/settings/ark-private-assets", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -0,0 +1,32 @@
package handler
import (
"testing"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func TestStorageConnectionTestRoutesAreRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
group := router.Group("/api")
RegisterAdminRoutes(group, &service.Service{})
RegisterUserDataRoutes(group, &service.Service{})
wanted := map[string]bool{
"POST /api/admin/settings/oss/test": false,
"POST /api/settings/oss/test": false,
}
for _, route := range router.Routes() {
key := route.Method + " " + route.Path
if _, exists := wanted[key]; exists {
wanted[key] = true
}
}
for route, found := range wanted {
if !found {
t.Errorf("route %s is not registered", route)
}
}
}

View File

@@ -92,6 +92,28 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"setting": setting})
})
r.POST("/settings/oss/test", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "user-storage-test:"+user.ID, 6, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.OSSSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.TestUserOSSSetting(user, req)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/resources", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -50,6 +50,22 @@ type UserOSSSetting struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// StorageLocation is an immutable object-storage address. Credentials may be
// rotated in place, while resources keep referring to the same location ID.
type StorageLocation struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
Scope string `json:"scope" gorm:"size:16;index:idx_storage_locations_scope_owner_active,priority:1;uniqueIndex:idx_storage_locations_identity,priority:1"`
OwnerID string `json:"ownerId" gorm:"size:36;index:idx_storage_locations_scope_owner_active,priority:2;uniqueIndex:idx_storage_locations_identity,priority:2"`
Provider string `json:"provider" gorm:"size:24;index;uniqueIndex:idx_storage_locations_identity,priority:3"`
LocationDigest string `json:"-" gorm:"size:64;uniqueIndex:idx_storage_locations_identity,priority:4"`
ValueJSON string `json:"-" gorm:"type:text"`
TestedDigest string `json:"-" gorm:"size:64"`
TestedAt *time.Time `json:"testedAt,omitempty"`
Active bool `json:"active" gorm:"index:idx_storage_locations_scope_owner_active,priority:3"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserDailyUploadUsage struct {
ID string `json:"id" gorm:"primaryKey;size:64"`
UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_daily_upload_day,priority:1"`

View File

@@ -840,6 +840,61 @@ func (r *Repository) CreateUserOSSSetting(setting *model.UserOSSSetting) error {
return r.db.Create(setting).Error
}
func (r *Repository) StorageLocation(id string) (*model.StorageLocation, error) {
var location model.StorageLocation
if err := r.db.First(&location, "id = ?", id).Error; err != nil {
return nil, err
}
return &location, nil
}
func (r *Repository) StorageLocationByDigest(scope string, ownerID string, provider string, digest string) (*model.StorageLocation, error) {
var location model.StorageLocation
if err := r.db.First(&location, "scope = ? AND owner_id = ? AND provider = ? AND location_digest = ?", scope, ownerID, provider, digest).Error; err != nil {
return nil, err
}
return &location, nil
}
func (r *Repository) StorageLocationHistoryCount(scope string, ownerID string) (int64, error) {
var count int64
err := r.db.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ?", scope, ownerID).Count(&count).Error
return count, err
}
func (r *Repository) StorageLocationResourceCount(id string) (int64, error) {
var count int64
err := r.db.Model(&model.Resource{}).Where("storage_setting_id = ?", id).Count(&count).Error
return count, err
}
func (r *Repository) CreateStorageLocation(location *model.StorageLocation) error {
return r.db.Create(location).Error
}
func (r *Repository) SaveStorageLocation(location *model.StorageLocation) error {
return r.db.Save(location).Error
}
func (r *Repository) ActivateStorageLocation(scope string, ownerID string, id string, active bool) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ? AND active = ?", scope, ownerID, true).Update("active", false).Error; err != nil {
return err
}
if !active {
return nil
}
result := tx.Model(&model.StorageLocation{}).Where("id = ? AND scope = ? AND owner_id = ?", id, scope, ownerID).Update("active", true)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *Repository) ReserveDailyUpload(userID string, day string, size int64, limit int64) error {
usage := model.UserDailyUploadUsage{ID: userID + ":" + day, UserID: userID, Day: day}
return r.db.Transaction(func(tx *gorm.DB) error {

View File

@@ -103,6 +103,9 @@ func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Tim
setting.Provider = firstNonEmpty(resource.Provider, setting.Provider)
setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint)
setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket)
if setting.Provider == s3Provider && !publicHTTPSStorageEndpoint(setting.Endpoint) {
return s.signedHTTPSPublicResourceURL(resource.ID, expiresAt)
}
return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt)
}
@@ -130,6 +133,11 @@ func (s *Service) prepareResourceDelivery(userID string, resource *model.Resourc
if err != nil {
return nil, err
}
// S3 兼容 Endpoint 可能是私网服务;浏览器默认始终使用同源代理。
// 只有明确的服务端上游需求才签发可公开访问的短时地址。
if setting.Provider == s3Provider && !options.ForceDirect {
return &ResourceDelivery{Resource: resource}, nil
}
if setting.Provider == qiniuKodoProvider && setting.CDNBaseURL != "" {
// 七牛私有空间即使配置了绑定域名,也不能匿名访问;必须使用
// Kodo 私有下载签名,否则浏览器会收到 NotSupportAnonymous。
@@ -147,6 +155,13 @@ func (s *Service) prepareResourceDelivery(userID string, resource *model.Resourc
return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
}
if options.ForceDirect {
if setting.Provider == s3Provider && !publicHTTPSStorageEndpoint(setting.Endpoint) {
redirectURL, err := s.signedHTTPSPublicResourceURL(resource.ID, time.Now().Add(directResourceURLTTL))
if err != nil {
return nil, err
}
return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
}
redirectURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
if err != nil {
return nil, err
@@ -175,6 +190,17 @@ func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time
return baseURL.String(), nil
}
func (s *Service) signedHTTPSPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) {
baseURL, err := s.publicResourceBaseURL()
if err != nil {
return "", err
}
if baseURL.Scheme != "https" {
return "", BadAuthRequest("私网或 HTTP S3 用于上游资源时,服务器公开访问地址必须使用 HTTPS")
}
return s.signedPublicResourceURL(resourceID, expiresAt)
}
func (s *Service) verifyPublicResourceSignature(resourceID string, expires string, signature string) error {
if strings.TrimSpace(signature) == "" || !decimalDigits(expires) {
return Forbidden("匿名下载链接无效")
@@ -323,7 +349,7 @@ func (s *Service) OpenPublicResourceRange(id string, expires string, signature s
if err != nil {
return nil, Forbidden("匿名下载链接无效")
}
if resource.Provider != "local" {
if resource.Provider != "local" && resource.Provider != s3Provider {
return nil, Forbidden("匿名下载链接无效")
}
if err := s.verifyPublicResourceSignature(resource.ID, expires, signature); err != nil {
@@ -368,11 +394,11 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
return nil, err
}
provider := "local"
objectKey := localObjectKey(userID, kind, fileName, now)
objectKey := localObjectKey(userID, kind, fileName, mimeType, now)
resource := model.Resource{ID: newID(), UserID: userID, Kind: kind, Status: model.ResourceStatusPending, Provider: provider, ObjectKey: objectKey, MimeType: mimeType, Size: size, Width: width, Height: height, DurationMs: durationMs, CreatedAt: now, UpdatedAt: now}
if useOSS {
provider = setting.Provider
objectKey = ossObjectKey(setting, userID, kind, fileName, now)
objectKey = ossObjectKey(setting, userID, kind, fileName, mimeType, now)
resource.Provider = provider
resource.Endpoint = setting.Endpoint
resource.Bucket = setting.Bucket
@@ -431,8 +457,8 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
return &resource, nil
}
func localObjectKey(userID string, kind string, fileName string, now time.Time) string {
ext := strings.ToLower(filepath.Ext(fileName))
func localObjectKey(userID string, kind string, fileName string, mimeType string, now time.Time) string {
ext := resourceFileExtension(fileName, mimeType, kind)
return path.Join("users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), newID()+ext)
}
@@ -676,6 +702,26 @@ func extensionFromMimeType(mimeType string) string {
return "bin"
}
func resourceFileExtension(fileName string, mimeType string, kind string) string {
if ext := strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))); ext != "" && ext != "." {
return ext
}
cleanMimeType := strings.TrimSpace(strings.Split(mimeType, ";")[0])
if extensions, err := mime.ExtensionsByType(cleanMimeType); err == nil && len(extensions) > 0 {
return strings.ToLower(extensions[0])
}
switch kind {
case "image":
return ".png"
case "video":
return ".mp4"
case "audio":
return ".mp3"
default:
return ".bin"
}
}
func imageDimensions(data []byte) (int, int) {
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
@@ -697,26 +743,30 @@ func (s *Service) activeResourceOSSSetting(userID string) (ossSettingValue, stri
if err != nil {
return ossSettingValue{}, "", false, err
}
if userSetting != nil && value.Enabled {
value, err = validateActiveOSSSetting(value, "用户 OSS 尚未启用", "你的 OSS 配置不完整")
return value, userSetting.ID, true, err
}
_, systemValue, err := s.readOSSSetting()
if err != nil {
return ossSettingValue{}, "", false, err
}
userAllowed := value.Provider != s3Provider || systemValue.AllowUserS3
if userSetting != nil && value.Enabled && userAllowed {
value, err = validateActiveOSSSetting(value, "用户 OSS 尚未启用", "你的 OSS 配置不完整")
return value, firstNonEmpty(value.StorageLocationID, userSetting.ID), true, err
}
if !systemValue.Enabled {
return ossSettingValue{}, "", false, nil
}
systemValue, err = validateActiveOSSSetting(systemValue, "管理员尚未启用 OSS", "平台 OSS 配置不完整,请联系管理员")
return systemValue, "", true, err
return systemValue, systemValue.StorageLocationID, true, err
}
func (s *Service) ossSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) {
var setting ossSettingValue
var err error
if resource.StorageSettingID != "" {
_, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID)
_, setting, err = s.storageLocationValue(resource.StorageSettingID)
if errors.Is(err, gorm.ErrRecordNotFound) {
_, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID)
}
if err == nil {
_, current, currentErr := s.readUserOSSSetting(userID)
if currentErr != nil {
@@ -805,8 +855,8 @@ func validateActiveOSSSetting(setting ossSettingValue, disabledMessage string, i
if !setting.Enabled {
return ossSettingValue{}, BadAuthRequest(disabledMessage)
}
if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider && setting.Provider != qiniuKodoProvider {
return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS 和七牛云 Kodo")
if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider && setting.Provider != qiniuKodoProvider && setting.Provider != s3Provider {
return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS、七牛云 Kodo 和通用 S3")
}
if setting.Bucket == "" || setting.Endpoint == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
return ossSettingValue{}, BadAuthRequest(incompleteMessage)
@@ -832,8 +882,8 @@ func normalizeResourceKind(kind string, mimeType string) string {
return "file"
}
func ossObjectKey(setting ossSettingValue, userID string, kind string, fileName string, now time.Time) string {
ext := strings.ToLower(filepath.Ext(fileName))
func ossObjectKey(setting ossSettingValue, userID string, kind string, fileName string, mimeType string, now time.Time) string {
ext := resourceFileExtension(fileName, mimeType, kind)
name := newID()
parts := []string{setting.PathPrefix, "users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), name + ext}
return strings.Trim(strings.Join(nonEmptySegments(parts), "/"), "/")
@@ -847,6 +897,9 @@ func putOSSObject(setting ossSettingValue, objectKey string, mimeType string, si
if setting.Provider == qiniuKodoProvider {
return putQiniuObject(setting, objectKey, mimeType, size, body)
}
if setting.Provider == s3Provider {
return putS3Object(setting, objectKey, mimeType, size, body)
}
return putAliyunOSSObject(setting, objectKey, mimeType, size, body)
}
@@ -884,6 +937,9 @@ type ossObjectStream struct {
func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
setting = normalizeOSSSetting(setting)
if setting.Provider == s3Provider {
return getS3ObjectRange(setting, objectKey, rangeHeader)
}
if setting.CDNBaseURL != "" {
return getOSSObjectRangeViaCDN(setting, objectKey, rangeHeader)
}
@@ -939,6 +995,9 @@ func decimalDigits(value string) bool {
func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
setting = normalizeOSSSetting(setting)
if setting.Provider == s3Provider {
return signedS3ObjectURL(setting, objectKey, expiresAt)
}
if setting.Provider == qiniuKodoProvider {
return signedQiniuObjectURL(setting, objectKey, expiresAt)
}

View File

@@ -310,6 +310,12 @@ func (s *Service) deleteStoredResourceObject(userID string, resource *model.Reso
return fmt.Errorf("无法读取七牛云 Kodo 配置:%w", err)
}
return deleteQiniuObject(setting, resource.ObjectKey)
case s3Provider:
setting, err := s.ossSettingForResource(userID, resource)
if err != nil {
return fmt.Errorf("无法读取 S3 配置:%w", err)
}
return deleteS3Object(setting, resource.ObjectKey)
default:
return fmt.Errorf("资源 %s 使用了不支持的存储类型 %q", resource.ID, resource.Provider)
}

View File

@@ -825,7 +825,7 @@ func newResourceTestService(t *testing.T) *Service {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.SystemSetting{}, &model.UserOSSSetting{}, &model.UserDailyUploadUsage{}, &model.Resource{}, &model.SessionFile{}); err != nil {
if err := db.AutoMigrate(&model.SystemSetting{}, &model.UserOSSSetting{}, &model.StorageLocation{}, &model.UserDailyUploadUsage{}, &model.Resource{}, &model.SessionFile{}); err != nil {
t.Fatal(err)
}
return &Service{repo: repository.New(db), dataDir: t.TempDir()}

View File

@@ -25,6 +25,8 @@ type Service struct {
emailCodeMu sync.Mutex
redeemBatchMu sync.Mutex
storageMu sync.Mutex
storageTestMu sync.Mutex
activeStorageTests map[string]bool
characterTaskMu sync.Mutex
activeCancels map[string]context.CancelFunc
pendingStorage map[string]int64
@@ -102,7 +104,7 @@ func New(repo *repository.Repository, dataDir string) *Service {
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
coordinator, err := newRuntimeCoordinator(repo.Dialect())
pluginRuntime, pluginRuntimeErr := newPluginRuntime(dataDir)
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo)
service.taskTerminalCoordinator = newTaskTerminalCoordinator(service)
service.taskRouteExecutor = newTaskRouteExecutor(service)

View File

@@ -4,13 +4,16 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -21,11 +24,13 @@ import (
const ossSettingKey = "oss"
const encryptedSettingPrefix = "enc:v1:"
const defaultOSSPathPrefix = "open-ai-canvas"
const (
aliyunOSSProvider = "aliyun"
tencentCOSProvider = "tencent"
qiniuKodoProvider = "qiniu"
s3Provider = "s3"
)
type OSSSettingRequest struct {
@@ -39,35 +44,53 @@ type OSSSettingRequest struct {
AccessKeySecret string `json:"accessKeySecret"`
PublicBaseURL string `json:"publicBaseUrl"`
PathPrefix string `json:"pathPrefix"`
S3Preset string `json:"s3Preset"`
PathStyle bool `json:"pathStyle"`
SessionToken string `json:"sessionToken"`
AllowUserS3 bool `json:"allowUserS3"`
}
type PublicOSSSetting struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
HasAccessKeySecret bool `json:"hasAccessKeySecret"`
PublicBaseURL string `json:"publicBaseUrl"`
PathPrefix string `json:"pathPrefix"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
HasAccessKeySecret bool `json:"hasAccessKeySecret"`
PublicBaseURL string `json:"publicBaseUrl"`
PathPrefix string `json:"pathPrefix"`
S3Preset string `json:"s3Preset"`
PathStyle bool `json:"pathStyle"`
HasSessionToken bool `json:"hasSessionToken"`
StorageLocationID string `json:"storageLocationId"`
TestedAt *time.Time `json:"testedAt,omitempty"`
TestedDigest string `json:"testedDigest,omitempty"`
HistoryCount int64 `json:"historyCount"`
ReferencedResourceCount int64 `json:"referencedResourceCount"`
AllowUserS3 bool `json:"allowUserS3"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ossSettingValue struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
PublicBaseURL string `json:"publicBaseUrl"`
PathPrefix string `json:"pathPrefix"`
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
PublicBaseURL string `json:"publicBaseUrl"`
PathPrefix string `json:"pathPrefix"`
S3Preset string `json:"s3Preset"`
PathStyle bool `json:"pathStyle"`
SessionToken string `json:"sessionToken"`
StorageLocationID string `json:"storageLocationId"`
AllowUserS3 bool `json:"allowUserS3"`
// 平台切换云厂商后仍需读取历史资源,因此仅归档非当前厂商的访问密钥。
ArchivedCredentials map[string]ossProviderCredentials `json:"archivedCredentials,omitempty"`
}
@@ -85,7 +108,10 @@ func (s *Service) AdminOSSSetting(actor *model.User) (*PublicOSSSetting, error)
if err != nil {
return nil, err
}
public := publicOSSSetting(setting, value)
public, err := s.publicOSSSetting(setting, value, "platform", "")
if err != nil {
return nil, err
}
return &public, nil
}
@@ -110,6 +136,15 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P
}
}
next = archiveOSSProviderCredentials(next, currentValue)
if next.Provider == s3Provider && next.Enabled {
location, err := s.requireTestedS3Location("platform", "", next)
if err != nil {
return nil, err
}
next.StorageLocationID = location.ID
} else if next.Provider == s3Provider && storageLocationDigest(next) == storageLocationDigest(currentValue) {
next.StorageLocationID = currentValue.StorageLocationID
}
stored, err := s.encryptOSSSettingSecrets(next)
if err != nil {
return nil, err
@@ -129,7 +164,19 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P
if err := s.repo.SaveSystemSetting(&setting); err != nil {
return nil, err
}
public := publicOSSSetting(&setting, next)
if next.Provider == s3Provider && next.StorageLocationID != "" {
if err := s.repo.ActivateStorageLocation("platform", "", next.StorageLocationID, next.Enabled); err != nil {
return nil, err
}
} else if currentValue.Provider == s3Provider && currentValue.StorageLocationID != "" {
if err := s.repo.ActivateStorageLocation("platform", "", "", false); err != nil {
return nil, err
}
}
public, err := s.publicOSSSetting(&setting, next, "platform", "")
if err != nil {
return nil, err
}
return &public, nil
}
@@ -141,7 +188,14 @@ func (s *Service) UserOSSSetting(actor *model.User) (*PublicOSSSetting, error) {
if err != nil {
return nil, err
}
public := publicUserOSSSetting(setting, value)
_, platform, err := s.readOSSSetting()
if err != nil {
return nil, err
}
public, err := s.publicUserOSSSetting(setting, value, actor.ID, platform.AllowUserS3)
if err != nil {
return nil, err
}
return &public, nil
}
@@ -157,6 +211,22 @@ func (s *Service) UpdateUserOSSSetting(actor *model.User, req OSSSettingRequest)
if err != nil {
return nil, err
}
_, platform, err := s.readOSSSetting()
if err != nil {
return nil, err
}
if next.Enabled && next.Provider == s3Provider && !platform.AllowUserS3 {
return nil, Forbidden("平台管理员尚未允许个人 S3 兼容存储")
}
if next.Provider == s3Provider && next.Enabled {
location, err := s.requireTestedS3Location("user", actor.ID, next)
if err != nil {
return nil, err
}
next.StorageLocationID = location.ID
} else if next.Provider == s3Provider && storageLocationDigest(next) == storageLocationDigest(currentValue) {
next.StorageLocationID = currentValue.StorageLocationID
}
stored, err := s.encryptOSSSettingSecrets(next)
if err != nil {
return nil, err
@@ -170,7 +240,19 @@ func (s *Service) UpdateUserOSSSetting(actor *model.User, req OSSSettingRequest)
if err := s.repo.CreateUserOSSSetting(&setting); err != nil {
return nil, err
}
public := publicUserOSSSetting(&setting, next)
if next.Provider == s3Provider && next.StorageLocationID != "" {
if err := s.repo.ActivateStorageLocation("user", actor.ID, next.StorageLocationID, next.Enabled); err != nil {
return nil, err
}
} else if currentValue.Provider == s3Provider && currentValue.StorageLocationID != "" {
if err := s.repo.ActivateStorageLocation("user", actor.ID, "", false); err != nil {
return nil, err
}
}
public, err := s.publicUserOSSSetting(&setting, next, actor.ID, platform.AllowUserS3)
if err != nil {
return nil, err
}
return &public, nil
}
@@ -250,6 +332,10 @@ func (s *Service) encryptOSSSettingSecrets(value ossSettingValue) (ossSettingVal
if err != nil {
return ossSettingValue{}, err
}
value.SessionToken, err = s.encryptSettingSecret(value.SessionToken)
if err != nil {
return ossSettingValue{}, err
}
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
for provider, credentials := range value.ArchivedCredentials {
credentials.AccessKeySecret, err = s.encryptSettingSecret(credentials.AccessKeySecret)
@@ -268,6 +354,13 @@ func (s *Service) decryptOSSSettingSecrets(value *ossSettingValue) (bool, error)
return false, err
}
value.AccessKeySecret = secret
if value.SessionToken != "" && !strings.HasPrefix(value.SessionToken, encryptedSettingPrefix) {
needsMigration = true
}
value.SessionToken, err = s.decryptSettingSecret(value.SessionToken)
if err != nil {
return false, err
}
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
for provider, credentials := range value.ArchivedCredentials {
if credentials.AccessKeySecret != "" && !strings.HasPrefix(credentials.AccessKeySecret, encryptedSettingPrefix) {
@@ -466,15 +559,29 @@ 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), "/"),
S3Preset: strings.TrimSpace(req.S3Preset),
PathStyle: req.PathStyle,
SessionToken: strings.TrimSpace(req.SessionToken),
AllowUserS3: req.AllowUserS3,
})
if next.Provider != aliyunOSSProvider && next.Provider != tencentCOSProvider && next.Provider != qiniuKodoProvider {
return next, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS 和七牛云 Kodo")
if next.Provider != aliyunOSSProvider && next.Provider != tencentCOSProvider && next.Provider != qiniuKodoProvider && next.Provider != s3Provider {
return next, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS、七牛云 Kodo 和通用 S3")
}
if next.Provider == s3Provider {
switch next.S3Preset {
case "aws", "r2", "b2", "rustfs", "custom":
default:
return next, BadAuthRequest("S3 预设无效")
}
}
current = normalizeOSSSetting(current)
// 不同云厂商的密钥不能复用;只有继续使用同一厂商时,留空才表示保留原密钥。
// 同一云厂商修改存储位置时,留空仍表示保留现有密钥;切换厂商时不能复用。
if next.AccessKeySecret == "" && next.Provider == current.Provider {
next.AccessKeySecret = current.AccessKeySecret
}
if next.SessionToken == "" && next.Provider == current.Provider {
next.SessionToken = current.SessionToken
}
if next.Enabled {
if next.Bucket == "" {
return next, BadAuthRequest("请填写对象存储 Bucket")
@@ -486,10 +593,22 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
if next.Provider == qiniuKodoProvider {
return next, BadAuthRequest("请填写七牛云 Kodo 上传 Endpoint")
}
if next.Provider == s3Provider {
return next, BadAuthRequest("请填写 S3 Endpoint 服务根 URL")
}
return next, BadAuthRequest("请填写阿里云 OSS Endpoint")
}
if _, err := ValidateOutboundURL(next.Endpoint); err != nil {
return next, err
if next.Provider == s3Provider && next.Region == "" {
return next, BadAuthRequest("请填写 S3 Region")
}
var endpointErr error
if next.Provider == s3Provider {
_, endpointErr = validateStorageEndpoint(next.Endpoint)
} else {
_, endpointErr = ValidateOutboundURL(next.Endpoint)
}
if endpointErr != nil {
return next, endpointErr
}
if next.CDNBaseURL != "" {
if _, err := ossCDNBaseURL(next.CDNBaseURL); err != nil {
@@ -551,15 +670,24 @@ func normalizeOSSSetting(value ossSettingValue) ossSettingValue {
value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret)
value.PublicBaseURL = strings.TrimRight(strings.TrimSpace(value.PublicBaseURL), "/")
value.PathPrefix = strings.Trim(strings.TrimSpace(value.PathPrefix), "/")
if value.PathPrefix == "" {
value.PathPrefix = defaultOSSPathPrefix
}
value.S3Preset = strings.ToLower(strings.TrimSpace(value.S3Preset))
if value.S3Preset == "" {
value.S3Preset = "custom"
}
value.SessionToken = strings.TrimSpace(value.SessionToken)
value.StorageLocationID = strings.TrimSpace(value.StorageLocationID)
value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials)
return value
}
func defaultOSSSetting() ossSettingValue {
return ossSettingValue{Provider: aliyunOSSProvider}
return ossSettingValue{Provider: aliyunOSSProvider, PathPrefix: defaultOSSPathPrefix}
}
func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) PublicOSSSetting {
func (s *Service) publicOSSSetting(setting *model.SystemSetting, value ossSettingValue, scope string, ownerID string) (PublicOSSSetting, error) {
result := PublicOSSSetting{
Enabled: value.Enabled,
Provider: value.Provider,
@@ -571,16 +699,24 @@ func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) Publi
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",
PublicBaseURL: value.PublicBaseURL,
PathPrefix: value.PathPrefix,
S3Preset: value.S3Preset,
PathStyle: value.PathStyle,
HasSessionToken: value.SessionToken != "",
StorageLocationID: value.StorageLocationID,
AllowUserS3: value.AllowUserS3,
}
if setting != nil {
result.UpdatedBy = setting.UpdatedBy
result.CreatedAt = setting.CreatedAt
result.UpdatedAt = setting.UpdatedAt
}
return result
if err := s.populateStorageLocationPublic(&result, scope, ownerID, value.StorageLocationID); err != nil {
return PublicOSSSetting{}, err
}
return result, nil
}
func publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue) PublicOSSSetting {
func (s *Service) publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue, ownerID string, allowUserS3 bool) (PublicOSSSetting, error) {
result := PublicOSSSetting{
Enabled: value.Enabled,
Provider: value.Provider,
@@ -592,11 +728,35 @@ func publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue)
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",
PublicBaseURL: value.PublicBaseURL,
PathPrefix: value.PathPrefix,
S3Preset: value.S3Preset,
PathStyle: value.PathStyle,
HasSessionToken: value.SessionToken != "",
StorageLocationID: value.StorageLocationID,
AllowUserS3: allowUserS3,
}
if value.Provider == s3Provider && !allowUserS3 {
result.Enabled = false
}
if setting != nil {
result.UpdatedBy = setting.UserID
result.CreatedAt = setting.CreatedAt
result.UpdatedAt = setting.UpdatedAt
}
return result
if err := s.populateStorageLocationPublic(&result, "user", ownerID, value.StorageLocationID); err != nil {
return PublicOSSSetting{}, err
}
return result, nil
}
func storageLocationDigest(value ossSettingValue) string {
value = normalizeOSSSetting(value)
payload := strings.Join([]string{value.Provider, value.Endpoint, value.Bucket, value.Region, strconv.FormatBool(value.PathStyle), value.PathPrefix}, "\x00")
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}
func storageTestDigest(value ossSettingValue) string {
payload := strings.Join([]string{storageLocationDigest(value), value.AccessKeyID, value.AccessKeySecret, value.SessionToken}, "\x00")
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,201 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
type OSSConnectionTestResult struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
TestedAt time.Time `json:"testedAt"`
TestedDigest string `json:"testedDigest"`
}
func (s *Service) TestAdminOSSSetting(actor *model.User, req OSSSettingRequest) (*OSSConnectionTestResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
_, current, err := s.readOSSSetting()
if err != nil {
return nil, err
}
return s.testOSSSetting("platform", "", actor.ID, req, current)
}
func (s *Service) TestUserOSSSetting(actor *model.User, req OSSSettingRequest) (*OSSConnectionTestResult, error) {
if actor == nil {
return nil, Unauthorized("请先登录")
}
_, platform, err := s.readOSSSetting()
if err != nil {
return nil, err
}
if strings.EqualFold(strings.TrimSpace(req.Provider), s3Provider) && !platform.AllowUserS3 {
return nil, Forbidden("平台管理员尚未允许个人 S3 兼容存储")
}
_, current, err := s.readUserOSSSetting(actor.ID)
if err != nil {
return nil, err
}
return s.testOSSSetting("user", actor.ID, actor.ID, req, current)
}
func (s *Service) testOSSSetting(scope string, ownerID string, actorID string, req OSSSettingRequest, current ossSettingValue) (*OSSConnectionTestResult, error) {
req.Enabled = true
value, err := ossSettingFromRequest(req, current)
if err != nil {
return nil, err
}
key := scope + ":" + actorID
s.storageTestMu.Lock()
if s.activeStorageTests == nil {
s.activeStorageTests = make(map[string]bool)
}
if s.activeStorageTests[key] {
s.storageTestMu.Unlock()
return nil, NewAppError(http.StatusConflict, "对象存储连接测试正在进行")
}
s.activeStorageTests[key] = true
s.storageTestMu.Unlock()
defer func() {
s.storageTestMu.Lock()
delete(s.activeStorageTests, key)
s.storageTestMu.Unlock()
}()
testKey := path.Join(value.PathPrefix, ".yingce-tests", scope, newID())
payload := []byte("yingce-storage-test")
if _, err := putOSSObject(value, testKey, "application/octet-stream", int64(len(payload)), bytes.NewReader(payload)); err != nil {
return nil, err
}
stream, err := getOSSObjectRange(value, testKey, "bytes=0-3")
var rangeErr error
if err != nil {
rangeErr = err
} else {
data, readErr := io.ReadAll(io.LimitReader(stream.body, 5))
closeErr := stream.body.Close()
if readErr != nil || closeErr != nil || string(data) != "ying" {
rangeErr = errors.New("对象存储 Range 读取测试失败")
}
}
if err := deleteOSSObject(value, testKey); err != nil {
return nil, fmt.Errorf("对象存储删除测试失败:%w", err)
}
if rangeErr != nil {
return nil, rangeErr
}
location, err := s.upsertStorageLocation(scope, ownerID, value)
if err != nil {
return nil, err
}
now := time.Now()
location.TestedDigest = storageTestDigest(value)
location.TestedAt = &now
if err := s.repo.SaveStorageLocation(location); err != nil {
return nil, err
}
return &OSSConnectionTestResult{OK: true, Message: "连接测试通过", TestedAt: now, TestedDigest: location.TestedDigest}, nil
}
func (s *Service) upsertStorageLocation(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
digest := storageLocationDigest(value)
location, err := s.repo.StorageLocationByDigest(scope, ownerID, value.Provider, digest)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
stored, err := s.encryptOSSSettingSecrets(value)
if err != nil {
return nil, err
}
stored.Enabled = true
stored.StorageLocationID = ""
encoded, err := json.Marshal(stored)
if err != nil {
return nil, err
}
if location == nil {
location = &model.StorageLocation{ID: newID(), Scope: scope, OwnerID: ownerID, Provider: value.Provider, LocationDigest: digest, ValueJSON: string(encoded)}
if err := s.repo.CreateStorageLocation(location); err != nil {
return nil, err
}
return location, nil
}
location.ValueJSON = string(encoded)
return location, nil
}
func (s *Service) storageLocationValue(id string) (*model.StorageLocation, ossSettingValue, error) {
location, err := s.repo.StorageLocation(id)
if err != nil {
return nil, ossSettingValue{}, err
}
var value ossSettingValue
if err := json.Unmarshal([]byte(location.ValueJSON), &value); err != nil {
return nil, ossSettingValue{}, errors.New("对象存储位置配置格式无效")
}
if _, err := s.decryptOSSSettingSecrets(&value); err != nil {
return nil, ossSettingValue{}, err
}
value.StorageLocationID = location.ID
return location, normalizeOSSSetting(value), nil
}
func (s *Service) requireTestedS3Location(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
location, err := s.repo.StorageLocationByDigest(scope, ownerID, s3Provider, storageLocationDigest(value))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("S3 关键配置尚未通过连接测试")
}
return nil, err
}
if location.TestedAt == nil || location.TestedDigest != storageTestDigest(value) {
return nil, BadAuthRequest("S3 关键配置或凭据已变化,请重新连接测试")
}
return location, nil
}
func (s *Service) populateStorageLocationPublic(result *PublicOSSSetting, scope string, ownerID string, id string) error {
count, err := s.repo.StorageLocationHistoryCount(scope, ownerID)
if err != nil {
return err
}
result.HistoryCount = count
if id == "" {
return nil
}
location, err := s.repo.StorageLocation(id)
if err != nil {
return err
}
result.TestedAt = location.TestedAt
result.TestedDigest = location.TestedDigest
result.ReferencedResourceCount, err = s.repo.StorageLocationResourceCount(id)
return err
}
func deleteOSSObject(setting ossSettingValue, objectKey string) error {
switch normalizeOSSSetting(setting).Provider {
case s3Provider:
return deleteS3Object(setting, objectKey)
case tencentCOSProvider:
return deleteTencentCOSObject(setting, objectKey)
case qiniuKodoProvider:
return deleteQiniuObject(setting, objectKey)
default:
return deleteAliyunOSSObject(setting, objectKey)
}
}

View File

@@ -0,0 +1,165 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
awss3 "github.com/aws/aws-sdk-go/service/s3"
)
func validateStorageEndpoint(raw string) (*url.URL, error) {
parsed, err := ValidateOutboundURL(raw)
if err != nil {
return nil, err
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
return nil, BadAuthRequest("对象存储 Endpoint 必须是服务根 URL,不能包含认证信息、路径、查询参数或片段")
}
if parsed.Scheme == "http" && !allowedPrivateUpstreamHost(parsed.Hostname()) {
return nil, BadAuthRequest("对象存储 HTTP Endpoint 仅允许访问 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS 精确放行的主机")
}
return parsed, nil
}
func standardAWSS3Endpoint(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
}
host := strings.ToLower(parsed.Hostname())
return host == "s3.amazonaws.com" || strings.HasPrefix(host, "s3.") && (strings.HasSuffix(host, ".amazonaws.com") || strings.HasSuffix(host, ".amazonaws.com.cn")) || strings.HasPrefix(host, "s3-") && strings.HasSuffix(host, ".amazonaws.com")
}
func publicHTTPSStorageEndpoint(endpoint string) bool {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
return false
}
return validateOutboundHost(parsed.Hostname()) == nil && !allowedPrivateUpstreamHost(parsed.Hostname())
}
func newS3Client(setting ossSettingValue, timeout time.Duration) (*awss3.S3, error) {
setting = normalizeOSSSetting(setting)
endpoint, err := validateStorageEndpoint(setting.Endpoint)
if err != nil {
return nil, err
}
if setting.Region == "" || setting.Bucket == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
return nil, errors.New("S3 Region、Bucket 或访问密钥不完整")
}
httpClient := OutboundHTTPClient(timeout)
config := aws.NewConfig().
WithRegion(setting.Region).
WithEndpoint(endpoint.String()).
WithCredentials(credentials.NewStaticCredentials(setting.AccessKeyID, setting.AccessKeySecret, setting.SessionToken)).
WithHTTPClient(httpClient).
WithS3ForcePathStyle(setting.PathStyle || !standardAWSS3Endpoint(endpoint.String())).
WithDisableSSL(endpoint.Scheme == "http")
sess, err := session.NewSession(config)
if err != nil {
return nil, err
}
return awss3.New(sess), nil
}
func putS3Object(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) {
client, err := newS3Client(setting, 2*time.Minute)
if err != nil {
return "", err
}
var seekable io.ReadSeeker
if reader, ok := body.(io.ReadSeeker); ok {
seekable = reader
} else {
limit := size + 1
if limit <= 0 {
limit = 64 << 20
}
data, readErr := io.ReadAll(io.LimitReader(body, limit))
if readErr != nil {
return "", readErr
}
if size >= 0 && int64(len(data)) != size {
return "", errors.New("S3 上传内容长度与资源记录不一致")
}
seekable = bytes.NewReader(data)
}
input := &awss3.PutObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/")), Body: seekable}
if mimeType != "" {
input.ContentType = aws.String(mimeType)
}
if size >= 0 {
input.ContentLength = aws.Int64(size)
}
output, err := client.PutObjectWithContext(context.Background(), input)
if err != nil {
return "", fmt.Errorf("S3 上传失败:%w", err)
}
return strings.Trim(aws.StringValue(output.ETag), `"`), nil
}
func getS3ObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
client, err := newS3Client(setting, 2*time.Minute)
if err != nil {
return nil, err
}
input := &awss3.GetObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/"))}
if rangeHeader != "" {
input.Range = aws.String(rangeHeader)
}
output, err := client.GetObjectWithContext(context.Background(), input)
if err != nil {
if requestFailure, ok := err.(awserr.RequestFailure); ok && requestFailure.StatusCode() == http.StatusRequestedRangeNotSatisfiable {
return &ossObjectStream{body: io.NopCloser(bytes.NewReader(nil)), statusCode: http.StatusRequestedRangeNotSatisfiable, acceptRanges: "bytes"}, nil
}
return nil, fmt.Errorf("S3 读取失败:%w", err)
}
status := http.StatusOK
if rangeHeader != "" && aws.StringValue(output.ContentRange) != "" {
status = http.StatusPartialContent
}
return &ossObjectStream{body: output.Body, statusCode: status, contentLength: aws.Int64Value(output.ContentLength), contentRange: aws.StringValue(output.ContentRange), acceptRanges: firstNonEmpty(aws.StringValue(output.AcceptRanges), "bytes")}, nil
}
func signedS3ObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
client, err := newS3Client(setting, 2*time.Minute)
if err != nil {
return "", err
}
duration := time.Until(expiresAt)
if duration <= 0 {
return "", errors.New("S3 签名有效期必须晚于当前时间")
}
req, _ := client.GetObjectRequest(&awss3.GetObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/"))})
value, err := req.Presign(duration)
if err != nil {
return "", fmt.Errorf("S3 下载地址签名失败:%w", err)
}
return value, nil
}
func deleteS3Object(setting ossSettingValue, objectKey string) error {
client, err := newS3Client(setting, 2*time.Minute)
if err != nil {
return err
}
_, err = client.DeleteObjectWithContext(context.Background(), &awss3.DeleteObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/"))})
if requestFailure, ok := err.(awserr.RequestFailure); ok && requestFailure.StatusCode() == http.StatusNotFound {
return nil
}
if err != nil {
return fmt.Errorf("删除 S3 对象失败:%w", err)
}
return nil
}

View File

@@ -0,0 +1,178 @@
package service
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestS3ObjectOperationsUsePathStyleSessionTokenAndNoManagedHeaders(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
var methods []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
if r.URL.Path != "/bucket/prefix/object.txt" {
t.Errorf("path = %q", r.URL.Path)
}
if r.Header.Get("X-Amz-Security-Token") != "session-token" {
t.Errorf("X-Amz-Security-Token = %q", r.Header.Get("X-Amz-Security-Token"))
}
if r.Header.Get("X-Amz-Acl") != "" || r.Header.Get("X-Amz-Server-Side-Encryption") != "" {
t.Errorf("unexpected ACL/SSE headers: %#v", r.Header)
}
switch r.Method {
case http.MethodPut:
data, _ := io.ReadAll(r.Body)
if string(data) != "payload" {
t.Errorf("body = %q", data)
}
w.Header().Set("ETag", `"etag-value"`)
case http.MethodGet:
if r.Header.Get("Range") != "bytes=0-3" {
t.Errorf("Range = %q", r.Header.Get("Range"))
}
w.Header().Set("Content-Range", "bytes 0-3/7")
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, _ = io.WriteString(w, "payl")
case http.MethodDelete:
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>`)
}
}))
defer server.Close()
setting := ossSettingValue{Provider: s3Provider, Region: "us-east-1", Endpoint: server.URL, Bucket: "bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", SessionToken: "session-token"}
etag, err := putS3Object(setting, "prefix/object.txt", "text/plain", 7, bytes.NewReader([]byte("payload")))
if err != nil || etag != "etag-value" {
t.Fatalf("putS3Object() = %q, %v", etag, err)
}
stream, err := getS3ObjectRange(setting, "prefix/object.txt", "bytes=0-3")
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(stream.body)
_ = stream.body.Close()
if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "payl" {
t.Fatalf("stream = %#v, body = %q", stream, data)
}
if err := deleteS3Object(setting, "prefix/object.txt"); err != nil {
t.Fatalf("deleteS3Object(404) error = %v", err)
}
if strings.Join(methods, ",") != "PUT,GET,DELETE" {
t.Fatalf("methods = %v", methods)
}
}
func TestSignedS3ObjectURLUsesSDKPresignAndSessionToken(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
value, err := signedS3ObjectURL(ossSettingValue{
Provider: s3Provider, Region: "us-east-1", Endpoint: server.URL, Bucket: "bucket",
AccessKeyID: "access-id", AccessKeySecret: "secret-value", SessionToken: "session-token",
}, "folder/object.png", time.Now().Add(time.Minute))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(value)
if err != nil {
t.Fatal(err)
}
if parsed.Path != "/bucket/folder/object.png" || parsed.Query().Get("X-Amz-Security-Token") != "session-token" || parsed.Query().Get("X-Amz-Signature") == "" {
t.Fatalf("presigned URL = %q", value)
}
if strings.Contains(value, "secret-value") {
t.Fatal("presigned URL leaked secret key")
}
}
func TestS3EndpointAndDigestsEnforceStorageContract(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "storage.internal")
if _, err := validateStorageEndpoint("https://example.com/path"); err == nil {
t.Fatal("validateStorageEndpoint() accepted a path")
}
if _, err := validateStorageEndpoint("http://example.com"); err == nil {
t.Fatal("validateStorageEndpoint() accepted public HTTP")
}
base := ossSettingValue{Provider: s3Provider, Region: "us-east-1", Endpoint: "https://s3.example.com", Bucket: "bucket", PathPrefix: "assets", AccessKeyID: "id", AccessKeySecret: "secret"}
rotated := base
rotated.AccessKeySecret = "rotated"
moved := base
moved.PathPrefix = "other"
if storageLocationDigest(base) != storageLocationDigest(rotated) {
t.Fatal("credential rotation changed the location digest")
}
if storageTestDigest(base) == storageTestDigest(rotated) {
t.Fatal("credential rotation did not invalidate the tested digest")
}
if storageLocationDigest(base) == storageLocationDigest(moved) {
t.Fatal("location change did not create a different digest")
}
}
func TestOSSSettingKeepsS3SecretsWhenLocationChanges(t *testing.T) {
current := ossSettingValue{
Provider: s3Provider,
Region: "us-east-1",
Endpoint: "https://s3.example.com",
Bucket: "old-bucket",
AccessKeyID: "access-id",
AccessKeySecret: "secret-value",
SessionToken: "session-token",
PathPrefix: defaultOSSPathPrefix,
}
next, err := ossSettingFromRequest(OSSSettingRequest{
Enabled: true,
Provider: s3Provider,
S3Preset: "custom",
Region: "us-east-1",
Endpoint: "https://s3.example.com",
Bucket: "new-bucket",
AccessKeyID: "access-id",
PathPrefix: "changed-prefix",
}, current)
if err != nil {
t.Fatal(err)
}
if next.AccessKeySecret != current.AccessKeySecret || next.SessionToken != current.SessionToken {
t.Fatalf("secrets were not retained: %#v", next)
}
}
func TestDefaultOSSPathPrefix(t *testing.T) {
value := normalizeOSSSetting(ossSettingValue{})
if value.PathPrefix != defaultOSSPathPrefix || defaultOSSSetting().PathPrefix != defaultOSSPathPrefix {
t.Fatalf("default path prefix = %q, default setting = %q", value.PathPrefix, defaultOSSSetting().PathPrefix)
}
}
func TestResourceObjectKeyPreservesOrInfersExtension(t *testing.T) {
setting := ossSettingValue{PathPrefix: "assets"}
now := time.Date(2026, time.August, 27, 20, 31, 0, 0, time.UTC)
for _, test := range []struct {
name string
fileName string
mimeType string
kind string
ext string
}{
{name: "original filename", fileName: "photo.JPEG", mimeType: "image/jpeg", kind: "image", ext: ".jpeg"},
{name: "mime type", mimeType: "image/png", kind: "image", ext: ".png"},
{name: "kind fallback", kind: "video", ext: ".mp4"},
} {
t.Run(test.name, func(t *testing.T) {
objectKey := ossObjectKey(setting, "user-1", test.kind, test.fileName, test.mimeType, now)
if !strings.HasSuffix(objectKey, test.ext) {
t.Fatalf("object key = %q, want suffix %q", objectKey, test.ext)
}
})
}
}

View File

@@ -26,3 +26,13 @@ description: 影策后端核心持久化表与状态边界
`comfy_bridge_requests` 是 Bridge 交接的真相源。后端进程重启后,未领取请求继续从数据库投递;已领取请求的迟到结果先落库,任务 worker 恢复后使用原请求 ID 读取结果,不会创建第二次本地工作流执行。请求有效期当前为两小时,过期的排队或执行记录会转为失败。
请求表保存工作流执行 payload 和结果,但不保存 Bridge Token。任务取消时,未领取请求可以确定取消;已经领取的本机工作流无法可靠中止,迟到结果会被丢弃并进入费用核对流程。
## 资源存储
| 表 | 用途 | 关键边界 |
| --- | --- | --- |
| `resources` | 保存资源元数据和实际存储位置引用 | S3 资源必须通过 `storage_setting_id` 固定绑定创建时的位置;浏览器默认经同源接口读取 |
| `storage_locations` | 保存平台或个人 S3 位置版本、加密凭据和连接测试摘要 | Endpoint、Bucket、Region、PathStyle、PathPrefix、Provider 组成不可变位置;同位置只轮换凭据,位置变化创建新记录 |
| `resource_deletion_jobs` | 在业务记录删除后可靠清理物理对象 | 固化资源的存储位置与对象键;删除失败保留任务并重试,S3 404 按幂等成功处理 |
`storage_locations.value_json` 中的 Secret Key 和 Session Token 使用后端设置密钥加密。启用 S3 前,关键位置字段和凭据摘要必须与最近一次 Put、Range Get、Delete 连接测试结果一致;历史资源继续按绑定的位置读取和删除,不随当前配置切换。

View File

@@ -170,6 +170,17 @@ description: 影策已实现功能的手工验证清单与未确认边界
- [ ] 使用真实 OSS 配置验证对象删除成功、权限失败后的退避重试和多实例租约接管。
- [ ] 浏览器真实环境验证登录快照慢响应期间工作区保持不可写,以及删除后立即刷新不复活素材。
## S3 兼容存储
- [x] 后端 S3 单次上传、Range 读取、预签名地址、Session Token、幂等删除、配置版本和迁移清单自动化测试通过。
- [x] 前端 S3 预设、关键字段重测判断、默认 `open-ai-canvas` 路径前缀、TypeScript 检查和生产构建通过。
- [ ] 分别使用 AWS S3、Cloudflare R2、Backblaze B2 和 RustFS 完成写入、Range 读取、删除及历史资源恢复验证。
- [ ] 验证 Path Style 开启时强制路径寻址,关闭时 AWS 使用虚拟主机寻址、其他自定义 Endpoint 自动使用路径寻址。
- [ ] 验证平台切换到本机后只影响新上传,历史 S3 资源仍可读取和删除;个人 S3 停用后正确回退平台存储。
- [ ] 验证同一 S3 修改 Bucket、Endpoint、Region、前缀或 Path Style 时,留空 Secret 和 Session Token 可继续复用,并要求重新连接测试。
- [ ] 使用私网 RustFS 验证浏览器同源代理,以及提交给模型上游时转换为影策公网 HTTPS 短时地址;未配置公网 HTTPS 地址时应明确拒绝。
- [ ] 验证管理员关闭个人 S3 后不再接受个人新上传,但已有个人 S3 资源仍可读取和删除。
## 3D 导演台时间轴关键帧删除与快捷键
关键帧删除入口、全局快捷键和交互控件守卫已接入,以下场景仍需要在浏览器中由用户确认:
@@ -255,4 +266,4 @@ description: 影策已实现功能的手工验证清单与未确认边界
- [ ] 在引导加载期间连续点击“重新开始引导”,再模拟 IndexedDB 写入失败与恢复,确认界面不会永久隐藏或假装推进。
- [ ] 在窄窗口检查摆场、姿态、动画、摄影机四种模式:标题栏可横向到达,底部属性面板可滚动,时间轴与引导不互相遮挡。
用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。
用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。

View File

@@ -2,19 +2,23 @@ import { App, Button, Form, Input, Select, Switch, Tag } from "antd";
import { Cloud, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { getUserOSSSetting, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources";
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type OSSProvider, type S3Preset } from "@/lib/oss-settings";
import { getUserOSSSetting, testUserOSSConnection, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources";
import { useUserStore } from "@/stores/use-user-store";
type OSSFormValues = {
enabled?: boolean;
provider: "aliyun" | "tencent" | "qiniu";
provider: OSSProvider;
s3Preset?: S3Preset;
region?: string;
endpoint?: string;
cdnBaseUrl?: string;
bucket?: string;
accessKeyId?: string;
accessKeySecret?: string;
sessionToken?: string;
pathPrefix?: string;
pathStyle?: boolean;
};
export function UserOSSSettingsForm() {
@@ -24,10 +28,15 @@ export function UserOSSSettingsForm() {
const [setting, setSetting] = useState<UserOSSSetting | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<OSSConnectionTestResult | null>(null);
const [testStale, setTestStale] = useState(false);
const savedAt = formatSavedAt(setting?.updatedAt);
const provider = Form.useWatch("provider", form) || "aliyun";
const isTencentCOS = provider === "tencent";
const isQiniuKodo = provider === "qiniu";
const isS3 = provider === "s3";
const s3Preset = Form.useWatch("s3Preset", form) || "custom";
const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret);
const accessKeyIdLabel = isTencentCOS ? "SecretId" : isQiniuKodo ? "AccessKey" : "AccessKey ID";
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : isQiniuKodo ? "SecretKey" : "AccessKey Secret";
@@ -41,6 +50,7 @@ export function UserOSSSettingsForm() {
if (!active) return;
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
setTestResult(data.setting.testedAt ? { ok: true, testedAt: data.setting.testedAt, testedDigest: data.setting.testedDigest } : null);
})
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人对象存储配置失败"))
.finally(() => active && setLoading(false));
@@ -55,21 +65,27 @@ export function UserOSSSettingsForm() {
const save = async () => {
const values = await form.validateFields();
if (values.enabled && values.provider === "s3" && !setting?.allowUserS3) return message.error("平台管理员尚未允许个人 S3 兼容存储");
setSaving(true);
try {
const data = await updateUserOSSSetting({
enabled: values.enabled === true,
provider: values.provider || "aliyun",
s3Preset: values.s3Preset || "custom",
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || "",
sessionToken: values.sessionToken?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
pathStyle: values.pathStyle === true,
});
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
setTestResult(data.setting.testedAt ? { ok: true, testedAt: data.setting.testedAt, testedDigest: data.setting.testedDigest } : null);
setTestStale(false);
message.success(data.setting.enabled ? "个人对象存储已启用,后续上传将优先使用该存储" : "个人对象存储已停用,后续上传将使用平台存储");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存个人对象存储配置失败");
@@ -78,8 +94,24 @@ export function UserOSSSettingsForm() {
}
};
const testConnection = async () => {
const values = await form.validateFields();
setTesting(true);
try {
const result = await testUserOSSConnection(connectionInput(values));
setTestResult(result);
setTestStale(false);
result.ok ? message.success(result.message || "连接测试通过") : message.error(result.message || "连接测试失败");
} catch (error) {
setTestResult({ ok: false, message: error instanceof Error ? error.message : "连接测试失败" });
setTestStale(false);
} finally {
setTesting(false);
}
};
return (
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={(changed) => changesRequireOSSRetest(changed) && setTestStale(true)}>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">
@@ -102,21 +134,26 @@ export function UserOSSSettingsForm() {
</Form.Item>
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
<Select
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }]}
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }, { label: "S3 兼容存储", value: "s3", disabled: setting?.allowUserS3 === false }]}
onChange={(nextProvider: OSSFormValues["provider"]) => {
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
if (nextProvider !== provider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
}}
/>
</Form.Item>
{isS3 ? (
<Form.Item name="s3Preset" label="S3 预设" className="mb-3">
<Select options={S3_PRESET_OPTIONS} onChange={(preset: S3Preset) => form.setFieldsValue(getS3PresetHints(preset))} />
</Form.Item>
) : null}
<Form.Item name="region" label="Region" className="mb-3">
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : isQiniuKodo ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
<Input spellCheck={false} placeholder={isS3 ? getS3PresetHints(s3Preset).region : isTencentCOS ? "ap-guangzhou" : isQiniuKodo ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
</Form.Item>
<Form.Item name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
<Form.Item name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isS3 ? getS3PresetHints(s3Preset).help : isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
<Input inputMode="url" spellCheck={false} placeholder={isS3 ? getS3PresetHints(s3Preset).endpoint : isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
</Form.Item>
<Form.Item
name="cdnBaseUrl"
label={isQiniuKodo ? "绑定域名(可选)" : "CDN 加速域名"}
label={isQiniuKodo ? "绑定域名(可选)" : isS3 ? "公开 CDN(可选)" : "CDN 加速域名"}
extra={isTencentCOS
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
: isQiniuKodo
@@ -131,7 +168,7 @@ export function UserOSSSettingsForm() {
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : isQiniuKodo ? "七牛云存储空间名称" : "my-canvas-assets"} />
</Form.Item>
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
<Input spellCheck={false} placeholder="infinite-canvas" />
<Input spellCheck={false} placeholder={DEFAULT_OSS_PATH_PREFIX} />
</Form.Item>
<Form.Item name="accessKeyId" label={accessKeyIdLabel} className="mb-3 xl:col-span-1">
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : isQiniuKodo ? "七牛云 AccessKey" : "阿里云 AccessKey ID"} />
@@ -139,13 +176,21 @@ export function UserOSSSettingsForm() {
<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" : isQiniuKodo ? "七牛云 SecretKey" : "阿里云 AccessKey Secret"} />
</Form.Item>
{isS3 ? (
<>
<Form.Item name="sessionToken" label={setting?.hasSessionToken ? "Session Token(留空保留)" : "Session Token(可选)"} className="mb-3">
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={setting?.hasSessionToken ? "留空保留已加密 Token" : "临时凭证使用的 Session Token"} />
</Form.Item>
<Form.Item name="pathStyle" label="Path Style" valuePropName="checked" extra="开启后强制 path-style;关闭时自动选择。" className="mb-3">
<Switch checkedChildren="强制" unCheckedChildren="自动" />
</Form.Item>
</>
) : null}
</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}` : "尚未保存个人对象存储配置"}</span>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存个人对象存储
</Button>
<div className="flex flex-wrap items-center gap-2"><span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}</span><ConnectionTestStatus result={testResult} stale={testStale} /></div>
<div className="flex flex-wrap gap-2"><Button loading={testing} onClick={() => void testConnection()}>测试连接</Button><Button type="primary" loading={saving} onClick={() => void save()}>保存个人对象存储</Button></div>
</div>
</Form>
);
@@ -162,12 +207,37 @@ function toFormValues(setting: UserOSSSetting): OSSFormValues {
return {
enabled: setting.enabled,
provider: setting.provider || "aliyun",
s3Preset: setting.s3Preset || "custom",
region: setting.region,
endpoint: setting.endpoint,
cdnBaseUrl: setting.cdnBaseUrl,
bucket: setting.bucket,
accessKeyId: setting.accessKeyId,
accessKeySecret: "",
pathPrefix: setting.pathPrefix,
sessionToken: "",
pathPrefix: setting.pathPrefix || DEFAULT_OSS_PATH_PREFIX,
pathStyle: setting.pathStyle === true,
};
}
function connectionInput(values: OSSFormValues) {
return {
provider: values.provider,
s3Preset: values.s3Preset,
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
sessionToken: values.sessionToken?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
pathStyle: values.pathStyle === true,
};
}
function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) {
if (stale) return <Tag color="warning">需重新测试</Tag>;
if (!result) return null;
return <Tag color={result.ok ? "success" : "error"}>{result.ok ? `测试通过${result.testedAt ? ` · ${formatSavedAt(result.testedAt)}` : ""}` : result.message || "测试失败"}</Tag>;
}

View File

@@ -0,0 +1,84 @@
export type OSSProvider = "aliyun" | "tencent" | "qiniu" | "s3";
export type S3Preset = "aws" | "r2" | "b2" | "rustfs" | "custom";
export const DEFAULT_OSS_PATH_PREFIX = "open-ai-canvas";
export type OSSConnectionTestInput = {
provider: OSSProvider;
s3Preset?: S3Preset;
region: string;
endpoint: string;
cdnBaseUrl: string;
bucket: string;
accessKeyId: string;
accessKeySecret?: string;
sessionToken?: string;
pathPrefix: string;
pathStyle?: boolean;
};
export type OSSConnectionTestResult = {
ok: boolean;
message?: string;
testedAt?: string;
testedDigest?: string;
};
export const S3_PRESET_OPTIONS: Array<{ label: string; value: S3Preset }> = [
{ label: "AWS S3", value: "aws" },
{ label: "Cloudflare R2", value: "r2" },
{ label: "Backblaze B2", value: "b2" },
{ label: "RustFS", value: "rustfs" },
{ label: "自定义", value: "custom" },
];
const S3_PRESET_HINTS: Record<S3Preset, { region: string; endpoint: string; help: string }> = {
aws: {
region: "us-east-1",
endpoint: "https://s3.us-east-1.amazonaws.com",
help: "填写 AWS 区域对应的 S3 服务根 URL。",
},
r2: {
region: "auto",
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
help: "Endpoint 使用 R2 控制台提供的账户级 S3 API 根 URL。",
},
b2: {
region: "us-west-004",
endpoint: "https://s3.us-west-004.backblazeb2.com",
help: "Region 和 Endpoint 应与 Backblaze B2 Bucket 所在区域一致。",
},
rustfs: {
region: "us-east-1",
endpoint: "http://127.0.0.1:9000",
help: "填写后端可访问的 RustFS S3 服务根 URL。",
},
custom: {
region: "us-east-1",
endpoint: "https://s3.example.com",
help: "填写兼容 S3 API 的服务根 URL,不要包含 Bucket 或对象路径。",
},
};
const CONNECTION_FIELDS = new Set([
"provider",
"s3Preset",
"region",
"endpoint",
"cdnBaseUrl",
"bucket",
"accessKeyId",
"accessKeySecret",
"sessionToken",
"pathPrefix",
"pathStyle",
]);
export function getS3PresetHints(preset?: S3Preset) {
return S3_PRESET_HINTS[preset || "custom"];
}
export function changesRequireOSSRetest(changedValues: Record<string, unknown>) {
return Object.keys(changedValues).some((key) => CONNECTION_FIELDS.has(key));
}

View File

@@ -1,15 +1,17 @@
import { App, Button, Form, Input, Segmented, Space } from "antd";
import { Cloud, Globe, HardDrive, LocateFixed } from "lucide-react";
import { App, Button, Form, Input, Segmented, Select, Space, Switch, Tag } from "antd";
import { Cloud, Globe, HardDrive, LocateFixed, Wifi } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type S3Preset } from "@/lib/oss-settings";
import { getAdminOSSSetting, testAdminOSSConnection, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type StorageMode = "local" | "aliyun" | "tencent" | "qiniu";
type StorageMode = "local" | AdminOSSSetting["provider"];
type OSSFormValues = {
mode: StorageMode;
s3Preset?: S3Preset;
publicBaseUrl?: string;
region?: string;
endpoint?: string;
@@ -17,7 +19,10 @@ type OSSFormValues = {
bucket?: string;
accessKeyId?: string;
accessKeySecret?: string;
sessionToken?: string;
pathPrefix?: string;
pathStyle?: boolean;
allowUserS3?: boolean;
};
export default function StorageSettingsPage() {
@@ -26,11 +31,16 @@ export default function StorageSettingsPage() {
const [setting, setSetting] = useState<AdminOSSSetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<OSSConnectionTestResult | null>(null);
const [testStale, setTestStale] = useState(false);
const [form] = Form.useForm<OSSFormValues>();
const mode = Form.useWatch("mode", form) || "local";
const isObjectStorage = mode !== "local";
const isTencentCOS = mode === "tencent";
const isQiniuKodo = mode === "qiniu";
const isS3 = mode === "s3";
const s3Preset = Form.useWatch("s3Preset", form) || "custom";
const accessKeyIdLabel = isTencentCOS ? "SecretId" : isQiniuKodo ? "AccessKey" : "AccessKey ID";
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : isQiniuKodo ? "SecretKey" : "AccessKey Secret";
const hasCurrentProviderSecret = Boolean(setting && setting.provider === mode && setting.hasAccessKeySecret);
@@ -41,6 +51,7 @@ export default function StorageSettingsPage() {
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(formValues(value));
setTestResult(value.testedAt ? { ok: true, testedAt: value.testedAt, testedDigest: value.testedDigest } : null);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取对象存储配置失败"))
.finally(() => setLoading(false));
@@ -56,23 +67,31 @@ export default function StorageSettingsPage() {
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 === "qiniu" && !values.endpoint?.trim()) return message.error("请填写七牛云 Kodo 上传 Endpoint");
if (values.mode === "s3" && !values.region?.trim()) return message.error("请填写 S3 Region");
if (values.mode === "s3" && !values.endpoint?.trim()) return message.error("请填写 S3 Endpoint 服务根 URL");
setSaving(true);
try {
const result = await updateAdminOSSSetting({
enabled: values.mode !== "local",
provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode,
s3Preset: values.s3Preset || "custom",
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
sessionToken: values.sessionToken?.trim() || "",
pathStyle: values.pathStyle === true,
allowUserS3: values.allowUserS3 === true,
publicBaseUrl: values.publicBaseUrl?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
});
setSetting(result.setting);
form.setFieldsValue(formValues(result.setting));
setTestResult(result.setting.testedAt ? { ok: true, testedAt: result.setting.testedAt, testedDigest: result.setting.testedDigest } : null);
setTestStale(false);
message.success("存储配置已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存存储配置失败");
@@ -81,6 +100,23 @@ export default function StorageSettingsPage() {
}
};
const testConnection = async () => {
const values = await form.validateFields();
if (values.mode === "local") return;
setTesting(true);
try {
const result = await testAdminOSSConnection(connectionInput(values));
setTestResult(result);
setTestStale(false);
result.ok ? message.success(result.message || "连接测试通过") : message.error(result.message || "连接测试失败");
} catch (error) {
setTestResult({ ok: false, message: error instanceof Error ? error.message : "连接测试失败" });
setTestStale(false);
} finally {
setTesting(false);
}
};
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
<div className="space-y-4 pt-4">
@@ -104,7 +140,7 @@ export default function StorageSettingsPage() {
</>
}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} className="px-5 pb-2">
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} className="px-5 pb-2" onValuesChange={(changed) => changesRequireOSSRetest(changed) && setTestStale(true)}>
<Form.Item name="mode" label="存储类型" rules={[{ required: true, message: "请选择存储类型" }]}>
<Segmented<StorageMode>
block
@@ -113,35 +149,47 @@ export default function StorageSettingsPage() {
{ 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" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />七牛云 Kodo</span>, value: "qiniu" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />S3 兼容存储</span>, value: "s3" },
]}
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: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
if (switchingProvider) form.setFieldsValue({ s3Preset: "custom", region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathStyle: false });
}}
/>
</Form.Item>
{isObjectStorage ? (
<div className="space-y-1">
{isS3 ? (
<Form.Item name="s3Preset" label="S3 预设">
<Select
options={S3_PRESET_OPTIONS}
onChange={(preset: S3Preset) => {
const hints = getS3PresetHints(preset);
form.setFieldsValue({ region: hints.region, endpoint: hints.endpoint });
}}
/>
</Form.Item>
) : null}
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2 xl:grid-cols-3">
<Form.Item name="region" label="Region">
<Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : isQiniuKodo ? "例如:z0 / cn-east-1" : "例如:oss-cn-hangzhou"} />
<Input autoComplete="off" placeholder={isS3 ? getS3PresetHints(s3Preset).region : isTencentCOS ? "例如:ap-guangzhou" : isQiniuKodo ? "例如:z0 / cn-east-1" : "例如:oss-cn-hangzhou"} />
</Form.Item>
<Form.Item name="bucket" label="Bucket">
<Input autoComplete="off" placeholder={isQiniuKodo ? "七牛云存储空间名称" : "对象存储 Bucket"} />
</Form.Item>
<Form.Item name="pathPrefix" label="路径前缀">
<Input autoComplete="off" placeholder="可选,例如:canvas" />
<Input autoComplete="off" placeholder={DEFAULT_OSS_PATH_PREFIX} />
</Form.Item>
</div>
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2 xl:grid-cols-3">
<Form.Item className="xl:col-span-2" name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"}>
<Input autoComplete="off" inputMode="url" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
<Form.Item className="xl:col-span-2" name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isS3 ? getS3PresetHints(s3Preset).help : undefined}>
<Input autoComplete="off" inputMode="url" placeholder={isS3 ? getS3PresetHints(s3Preset).endpoint : isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
</Form.Item>
<Form.Item
name="cdnBaseUrl"
label={isQiniuKodo ? "绑定域名(可选)" : "CDN 加速域名"}
label={isQiniuKodo ? "绑定域名(可选)" : isS3 ? "公开 CDN(可选)" : "CDN 加速域名"}
extra={isQiniuKodo ? "可选。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。" : undefined}
rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}
>
@@ -156,6 +204,20 @@ export default function StorageSettingsPage() {
<Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : accessKeySecretLabel} />
</Form.Item>
</div>
{isS3 ? (
<div className="grid gap-x-4 gap-y-1 md:grid-cols-2">
<Form.Item name="sessionToken" label={setting?.hasSessionToken ? `Session Token(${configuredSecretText},留空保留)` : "Session Token(可选)"}>
<Input.Password autoComplete="new-password" placeholder={setting?.hasSessionToken ? "留空保留原 Token" : "临时凭证使用的 Session Token"} />
</Form.Item>
<Form.Item name="pathStyle" label="Path Style" valuePropName="checked" extra="开启后强制使用 path-style;关闭时由后端自动选择。">
<Switch checkedChildren="强制" unCheckedChildren="自动" />
</Form.Item>
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center gap-3">
<Button icon={<Wifi className="size-4" />} loading={testing} onClick={() => void testConnection()}>测试连接</Button>
<ConnectionTestStatus result={testResult} stale={testStale} />
</div>
</div>
) : (
<Form.Item
@@ -171,6 +233,9 @@ export default function StorageSettingsPage() {
</Space.Compact>
</Form.Item>
)}
<Form.Item name="allowUserS3" label="允许个人 S3 兼容存储" valuePropName="checked" extra="开启后,用户可配置个人 S3;个人配置启用时优先于平台存储,停用时回退平台存储。">
<Switch checkedChildren="允许" unCheckedChildren="不允许" />
</Form.Item>
</Form>
</SettingsSectionCard>
</div>
@@ -181,6 +246,7 @@ export default function StorageSettingsPage() {
function formValues(setting?: AdminOSSSetting | null): OSSFormValues {
return {
mode: setting?.enabled ? setting.provider : "local",
s3Preset: setting?.s3Preset || "custom",
publicBaseUrl: setting?.publicBaseUrl || "",
region: setting?.region || "",
endpoint: setting?.endpoint || "",
@@ -188,14 +254,39 @@ function formValues(setting?: AdminOSSSetting | null): OSSFormValues {
bucket: setting?.bucket || "",
accessKeyId: setting?.accessKeyId || "",
accessKeySecret: "",
pathPrefix: setting?.pathPrefix || "",
sessionToken: "",
pathPrefix: setting?.pathPrefix || DEFAULT_OSS_PATH_PREFIX,
pathStyle: setting?.pathStyle === true,
allowUserS3: setting?.allowUserS3 === true,
};
}
function storageProviderLabel(provider?: AdminOSSSetting["provider"] | StorageMode) {
return provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
return provider === "s3" ? "S3 兼容存储" : provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}
function connectionInput(values: OSSFormValues) {
return {
provider: values.mode === "local" ? "aliyun" as const : values.mode,
s3Preset: values.s3Preset,
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
sessionToken: values.sessionToken?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX,
pathStyle: values.pathStyle === true,
};
}
function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) {
if (stale) return <Tag color="warning">关键配置已变更,需重新测试</Tag>;
if (!result) return <span className="text-xs text-foreground/50">尚未测试连接</span>;
return <Tag color={result.ok ? "success" : "error"}>{result.ok ? `测试通过${result.testedAt ? ` · ${formatTime(result.testedAt)}` : ""}` : result.message || "测试失败"}</Tag>;
}

View File

@@ -5,6 +5,7 @@ import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-eng
import type { FeatureAvailability } from "@/stores/use-user-store";
import { apiClient, request } from "@/services/api/request";
import type { PublicLogicalModel } from "@/services/api/logical-models";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
const api = apiClient;
@@ -257,7 +258,8 @@ export type UserPromptPreference = {
export type AdminOSSSetting = {
enabled: boolean;
provider: "aliyun" | "tencent" | "qiniu";
provider: OSSProvider;
s3Preset: S3Preset;
region: string;
endpoint: string;
cdnBaseUrl: string;
@@ -265,8 +267,16 @@ export type AdminOSSSetting = {
accessKeyId: string;
accessKeySecret?: string;
hasAccessKeySecret: boolean;
sessionToken?: string;
hasSessionToken: boolean;
pathStyle: boolean;
allowUserS3: boolean;
publicBaseUrl: string;
pathPrefix: string;
testedAt?: string;
testedDigest?: string;
historyCount?: number;
referencedResourceCount?: number;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
@@ -501,6 +511,10 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
}
export function testAdminOSSConnection(input: OSSConnectionTestInput) {
return request<OSSConnectionTestResult>(api.post("/admin/settings/oss/test", input));
}
export function getAdminArkPrivateAssetSetting() {
return request<{ setting: AdminArkPrivateAssetSetting }>(api.get("/admin/settings/ark-private-assets"));
}

View File

@@ -1,6 +1,7 @@
import { getActiveUserScope } from "@/lib/user-scope";
import axios from "axios";
import { apiBaseURL, apiClient, request, type BackendEnvelope } from "@/services/api/request";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
export type RemoteResource = {
id: string;
@@ -25,20 +26,30 @@ export type RemoteResource = {
export type UserOSSSetting = {
enabled: boolean;
provider: "aliyun" | "tencent" | "qiniu";
provider: OSSProvider;
s3Preset: S3Preset;
region: string;
endpoint: string;
cdnBaseUrl: string;
bucket: string;
accessKeyId: string;
hasAccessKeySecret: boolean;
sessionToken?: string;
hasSessionToken: boolean;
pathStyle: boolean;
allowUserS3: boolean;
publicBaseUrl: string;
pathPrefix: string;
testedAt?: string;
testedDigest?: string;
historyCount?: number;
referencedResourceCount?: number;
updatedAt?: string;
};
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix"> & {
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "s3Preset" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix" | "pathStyle"> & {
accessKeySecret?: string;
sessionToken?: string;
};
export type AccountFileStorageUsage = {
@@ -68,6 +79,10 @@ export function updateUserOSSSetting(input: UserOSSSettingInput) {
return request<{ setting: UserOSSSetting }>(api.patch("/settings/oss", input));
}
export function testUserOSSConnection(input: OSSConnectionTestInput) {
return request<OSSConnectionTestResult>(api.post("/settings/oss/test", input));
}
export async function getAccountFileStorageUsage() {
const data = await request<{ usage: AccountFileStorageUsage }>(api.get("/resources/storage-usage"));
return data.usage;

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints } from "../src/lib/oss-settings";
describe("OSS settings helpers", () => {
test("provides editable S3 endpoint hints for known presets", () => {
expect(getS3PresetHints("r2")).toMatchObject({ region: "auto" });
expect(getS3PresetHints("b2").endpoint).toContain("backblazeb2.com");
});
test("only connection fields invalidate a previous test", () => {
expect(changesRequireOSSRetest({ endpoint: "https://s3.example.com" })).toBe(true);
expect(changesRequireOSSRetest({ enabled: true })).toBe(false);
expect(changesRequireOSSRetest({ allowUserS3: true })).toBe(false);
});
test("uses the product path prefix by default", () => {
expect(DEFAULT_OSS_PATH_PREFIX).toBe("open-ai-canvas");
});
});