Files
ttstd-infinite-canvas/backend/internal/service/storage_location.go
T

260 lines
9.0 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"path"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"github.com/tencentyun/cos-go-sdk-v5"
"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())
if err := verifyOSSConnection(value, testKey); err != nil {
return nil, err
}
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 verifyOSSConnection(value ossSettingValue, testKey string) error {
payload := []byte("yingce-storage-test")
// 连接测试验证服务端到对象存储的真实读写权限。CDN 是浏览器读取出口,
// 可能存在回源鉴权或边缘同步延迟,不能参与刚写入对象的最小读写测试。
testValue := value
testValue.CDNBaseURL = ""
if _, err := putOSSObject(testValue, testKey, "application/octet-stream", int64(len(payload)), bytes.NewReader(payload)); err != nil {
return storageConnectionTestError(testValue.Provider, "写入", err)
}
stream, err := getOSSObjectRange(testValue, 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(testValue, testKey); err != nil {
return storageConnectionTestError(testValue.Provider, "删除", err)
}
if rangeErr != nil {
return storageConnectionTestError(testValue.Provider, "Range 读取", rangeErr)
}
return nil
}
func storageConnectionTestError(provider string, operation string, cause error) error {
provider = strings.ToLower(strings.TrimSpace(provider))
providerName := "对象存储"
switch provider {
case aliyunOSSProvider:
providerName = "阿里云 OSS"
case tencentCOSProvider:
providerName = "腾讯云 COS"
case qiniuKodoProvider:
providerName = "七牛云 Kodo"
case s3Provider:
providerName = "S3 兼容存储"
}
if errors.Is(cause, context.DeadlineExceeded) {
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
}
var networkErr net.Error
if errors.As(cause, &networkErr) && networkErr.Timeout() {
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
}
if provider == tencentCOSProvider {
var responseErr *cos.ErrorResponse
if errors.As(cause, &responseErr) && responseErr.Response != nil {
switch responseErr.Response.StatusCode {
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求被拒绝,请检查 Bucket、Region 和 Endpoint", operation), cause)
case http.StatusUnauthorized, http.StatusForbidden:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s鉴权失败,请检查 SecretId、SecretKey 和 Bucket 权限", operation), cause)
case http.StatusNotFound:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s未找到 Bucket,请检查 Bucket、Region 和 Endpoint", operation), cause)
case http.StatusTooManyRequests:
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求过于频繁,请稍后重试", operation), cause)
}
if responseErr.Response.StatusCode >= http.StatusInternalServerError {
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s服务暂时不可用,请稍后重试", operation), cause)
}
}
}
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("%s %s测试失败,请检查 Endpoint、Bucket、访问密钥和存储桶权限", providerName, operation), cause)
}
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)
}
}