feat(storage): 统一二进制对象存储与公开错误
新增 Aliyun OSS 与 S3 协议、通道内重试和按优先级跨通道切换,保留 server-main 兼容与环境 OSS 内存通道。 将请求及结果中的 Base64、Data URI、Buffer、multipart 和内联二进制统一对象化,生产路径不再写入本机静态目录,历史本地资源仅保留只读兼容。 引入 PublicErrorV1 并统一 API、异步查询、兼容协议和失败回调的安全错误输出,同时补充迁移、管理端、指标、OpenAPI 与本地模拟验收。 验证:go test ./... -count=1;go vet ./...;pnpm lint;pnpm test;pnpm build;pnpm openapi;tests/ci/migrations-test.sh。
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -74,6 +77,10 @@ func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if legacyLocalStoragePolicy(input.ResultUploadPolicy) {
|
||||
writeError(w, http.StatusBadRequest, "upload_none is no longer supported; configure an object storage channel")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.UpdateFileStorageSettings(r.Context(), input)
|
||||
if err != nil {
|
||||
s.logger.Error("update file storage settings failed", "error", err)
|
||||
@@ -155,7 +162,7 @@ func (s *Server) updateClientCustomizationSettings(w http.ResponseWriter, r *htt
|
||||
|
||||
// createFileStorageChannel godoc
|
||||
// @Summary 创建文件存储通道
|
||||
// @Description 创建文件存储通道,当前主要用于配置 server-main OpenAPI 上传通道。
|
||||
// @Description 创建 server-main OpenAPI、阿里云 OSS 或 S3 兼容文件存储通道。
|
||||
// @Tags system
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -271,6 +278,46 @@ func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testFileStorageChannel godoc
|
||||
// @Summary 测试对象存储通道
|
||||
// @Description 对指定 OSS 或 S3 通道执行隔离的 Put、Head、Delete 探针,不返回凭据或对象键。
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param channelID path string true "文件存储通道 ID"
|
||||
// @Success 200 {object} FileStorageChannelTestResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/file-storage/channels/{channelID}/test [post]
|
||||
func (s *Server) testFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||
channel, err := s.store.GetFileStorageChannel(r.Context(), r.PathValue("channelID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "file storage channel not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("get file storage channel for test failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get file storage channel failed")
|
||||
return
|
||||
}
|
||||
if channel.Provider != "aliyun_oss" && channel.Provider != "s3" {
|
||||
writeError(w, http.StatusBadRequest, "connection test is supported for aliyun_oss and s3 channels", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
result, err := s.runner.TestFileStorageChannel(r.Context(), channel)
|
||||
if err != nil {
|
||||
_ = s.store.MarkFileStorageChannelFailure(context.WithoutCancel(r.Context()), channel.ID, err.Error())
|
||||
s.logger.Warn("file storage channel test failed", "channel_id", channel.ID, "provider", channel.Provider, "error", err)
|
||||
writeError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
_ = s.store.MarkFileStorageChannelSuccess(context.WithoutCancel(r.Context()), channel.ID)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func validateFileStorageChannelInput(input store.FileStorageChannelInput, existing *store.FileStorageChannel) string {
|
||||
provider := strings.ToLower(strings.TrimSpace(input.Provider))
|
||||
if provider == "" {
|
||||
@@ -286,6 +333,9 @@ func validateFileStorageChannelInput(input store.FileStorageChannelInput, existi
|
||||
if status != "enabled" && status != "disabled" {
|
||||
return "status must be enabled or disabled"
|
||||
}
|
||||
if provider != "server_main_openapi" && provider != "aliyun_oss" && provider != "s3" {
|
||||
return "provider must be server_main_openapi, aliyun_oss or s3"
|
||||
}
|
||||
if provider == "server_main_openapi" {
|
||||
hasAPIKey := false
|
||||
if input.APIKey != nil {
|
||||
@@ -297,5 +347,83 @@ func validateFileStorageChannelInput(input store.FileStorageChannelInput, existi
|
||||
return "server-main OpenAPI channel requires API key before enabling"
|
||||
}
|
||||
}
|
||||
if provider == "aliyun_oss" || provider == "s3" {
|
||||
if fileStorageConfigContainsCredential(input.Config) {
|
||||
return "object storage credentials must use the write-only credential fields, not config"
|
||||
}
|
||||
endpoint := fileStorageConfigString(input.Config, "endpoint")
|
||||
if endpoint == "" || fileStorageConfigString(input.Config, "region") == "" || fileStorageConfigString(input.Config, "bucket") == "" {
|
||||
return "object storage channel requires config.endpoint, config.region and config.bucket"
|
||||
}
|
||||
if !validFileStorageBaseURL(endpoint) {
|
||||
return "object storage config.endpoint must be an http or https URL without embedded credentials"
|
||||
}
|
||||
if publicBaseURL := firstNonEmpty(fileStorageConfigString(input.Config, "publicBaseUrl"), fileStorageConfigString(input.Config, "publicBaseURL")); publicBaseURL != "" && !validFileStorageBaseURL(publicBaseURL) {
|
||||
return "object storage config.publicBaseUrl must be an http or https URL without embedded credentials"
|
||||
}
|
||||
accessKeyID := input.AccessKeyID
|
||||
if accessKeyID == nil {
|
||||
accessKeyID = input.AccessKey
|
||||
}
|
||||
accessKeySecret := input.AccessKeySecret
|
||||
if accessKeySecret == nil {
|
||||
accessKeySecret = input.SecretKey
|
||||
}
|
||||
hasAccessKeyID := fileStorageCredentialPresent(accessKeyID, existing, func(item *store.FileStorageChannel) string { return item.AccessKeyID })
|
||||
hasAccessKeySecret := fileStorageCredentialPresent(accessKeySecret, existing, func(item *store.FileStorageChannel) string { return item.AccessKeySecret })
|
||||
if status == "enabled" && (!hasAccessKeyID || !hasAccessKeySecret) {
|
||||
return "object storage channel requires accessKeyId and accessKeySecret before enabling"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validFileStorageBaseURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
return err == nil && parsed.User == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
||||
}
|
||||
|
||||
func fileStorageConfigContainsCredential(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized := strings.NewReplacer("_", "", "-", "", ".", "").Replace(strings.ToLower(strings.TrimSpace(key)))
|
||||
switch normalized {
|
||||
case "apikey", "accesskey", "accesskeyid", "accesskeysecret", "secretkey", "sessiontoken", "ststoken", "password", "credential", "credentials", "authorization":
|
||||
return true
|
||||
}
|
||||
if fileStorageConfigContainsCredential(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if fileStorageConfigContainsCredential(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileStorageConfigString(config map[string]any, key string) string {
|
||||
value, _ := config[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func fileStorageCredentialPresent(input *string, existing *store.FileStorageChannel, current func(*store.FileStorageChannel) string) bool {
|
||||
if input != nil {
|
||||
return strings.TrimSpace(*input) != ""
|
||||
}
|
||||
return existing != nil && strings.TrimSpace(current(existing)) != ""
|
||||
}
|
||||
|
||||
func legacyLocalStoragePolicy(value string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "-", "_")
|
||||
switch normalized {
|
||||
case "upload_none", "none", "never", "disabled", "no_upload", "skip", "skip_all":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user