Files
easyai-ai-gateway/apps/api/internal/httpapi/system_settings_handlers.go
T
wangbo 6a64d3936a feat(storage): 完善对象存储配置与过期策略
补齐 OSS/S3 的 Endpoint、Region、Bucket、CDN、对象前缀和签名有效期配置,并为生成结果与请求素材自动维护分级生命周期规则。普通上传继续保持永久,私有资源按配置生成限时签名 URL,管理端连接测试覆盖生命周期、上传、读取和删除。\n\n新增可重复的真实 OSS 验收脚本,凭据仅从本地环境读取,接口响应继续保持脱敏。\n\n验证:Go 全量测试、迁移安全检查、pnpm lint、pnpm test、pnpm build、本地阿里云 OSS 真实上传下载删除验收。
2026-08-04 12:44:24 +08:00

475 lines
18 KiB
Go

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"
)
// listFileStorageChannels godoc
// @Summary 列出文件存储通道
// @Description 返回所有未删除的文件存储通道,用于管理上传与生成资源回传策略。
// @Tags system
// @Produce json
// @Security BearerAuth
// @Success 200 {object} FileStorageChannelListResponse
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/channels [get]
func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListFileStorageChannels(r.Context())
if err != nil {
s.logger.Error("list file storage channels failed", "error", err)
writeError(w, http.StatusInternalServerError, "list file storage channels failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// getFileStorageSettings godoc
// @Summary 获取文件存储设置
// @Description 返回文件存储系统设置;数据库对象尚未创建时返回默认设置。
// @Tags system
// @Produce json
// @Security BearerAuth
// @Success 200 {object} store.FileStorageSettings
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/settings [get]
func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetFileStorageSettings(r.Context())
if err != nil {
if store.IsUndefinedDatabaseObject(err) {
writeJSON(w, http.StatusOK, store.DefaultFileStorageSettings())
return
}
s.logger.Error("get file storage settings failed", "error", err)
writeError(w, http.StatusInternalServerError, "get file storage settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
// updateFileStorageSettings godoc
// @Summary 更新文件存储设置
// @Description 更新生成资源上传策略等文件存储系统设置。
// @Tags system
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body store.FileStorageSettingsInput true "文件存储设置"
// @Success 200 {object} store.FileStorageSettings
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/settings [patch]
func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageSettingsInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
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)
writeError(w, http.StatusInternalServerError, "update file storage settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
// getClientCustomizationSettings godoc
// @Summary 获取客户端自定义设置
// @Description 返回客户端名称、英文名称、平台名和图标路径;数据库对象尚未创建时返回默认设置。
// @Tags system
// @Produce json
// @Security BearerAuth
// @Success 200 {object} store.ClientCustomizationSettings
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/client-customization/settings [get]
func (s *Server) getClientCustomizationSettings(w http.ResponseWriter, r *http.Request) {
s.writeClientCustomizationSettings(w, r)
}
// getPublicClientCustomizationSettings godoc
// @Summary 获取公开客户端自定义设置
// @Description 无需鉴权返回客户端名称、英文名称、平台名和图标路径;数据库对象尚未创建时返回默认设置。
// @Tags system
// @Produce json
// @Success 200 {object} store.ClientCustomizationSettings
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/public/client-customization [get]
func (s *Server) getPublicClientCustomizationSettings(w http.ResponseWriter, r *http.Request) {
s.writeClientCustomizationSettings(w, r)
}
func (s *Server) writeClientCustomizationSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetClientCustomizationSettings(r.Context())
if err != nil {
if store.IsUndefinedDatabaseObject(err) {
writeJSON(w, http.StatusOK, store.DefaultClientCustomizationSettings())
return
}
s.logger.Error("get client customization settings failed", "error", err)
writeError(w, http.StatusInternalServerError, "get client customization settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
// updateClientCustomizationSettings godoc
// @Summary 更新客户端自定义设置
// @Description 更新客户端名称、英文名称、平台名和图标路径。
// @Tags system
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body store.ClientCustomizationSettingsInput true "客户端自定义设置"
// @Success 200 {object} store.ClientCustomizationSettings
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/client-customization/settings [patch]
func (s *Server) updateClientCustomizationSettings(w http.ResponseWriter, r *http.Request) {
var input store.ClientCustomizationSettingsInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
settings, err := s.store.UpdateClientCustomizationSettings(r.Context(), input)
if err != nil {
s.logger.Error("update client customization settings failed", "error", err)
writeError(w, http.StatusInternalServerError, "update client customization settings failed")
return
}
writeJSON(w, http.StatusOK, settings)
}
// createFileStorageChannel godoc
// @Summary 创建文件存储通道
// @Description 创建 server-main OpenAPI、阿里云 OSS 或 S3 兼容文件存储通道。
// @Tags system
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body store.FileStorageChannelInput true "文件存储通道"
// @Success 201 {object} store.FileStorageChannel
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/channels [post]
func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageChannelInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if message := validateFileStorageChannelInput(input, nil); message != "" {
writeError(w, http.StatusBadRequest, message)
return
}
item, err := s.store.CreateFileStorageChannel(r.Context(), input)
if err != nil {
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "file storage channel key already exists")
return
}
s.logger.Error("create file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "create file storage channel failed")
return
}
writeJSON(w, http.StatusCreated, item)
}
// updateFileStorageChannel godoc
// @Summary 更新文件存储通道
// @Description 更新指定文件存储通道的名称、凭证、场景、优先级、状态和重试策略。
// @Tags system
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param channelID path string true "文件存储通道 ID"
// @Param body body store.FileStorageChannelInput true "文件存储通道"
// @Success 200 {object} store.FileStorageChannel
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/channels/{channelID} [patch]
func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request) {
var input store.FileStorageChannelInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
existing, 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 failed", "error", err)
writeError(w, http.StatusInternalServerError, "get file storage channel failed")
return
}
if message := validateFileStorageChannelInput(input, &existing); message != "" {
writeError(w, http.StatusBadRequest, message)
return
}
item, err := s.store.UpdateFileStorageChannel(r.Context(), r.PathValue("channelID"), input)
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "file storage channel not found")
return
}
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "file storage channel key already exists")
return
}
s.logger.Error("update file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "update file storage channel failed")
return
}
writeJSON(w, http.StatusOK, item)
}
// deleteFileStorageChannel godoc
// @Summary 删除文件存储通道
// @Description 软删除指定文件存储通道。
// @Tags system
// @Produce json
// @Security BearerAuth
// @Param channelID path string true "文件存储通道 ID"
// @Success 204 "No Content"
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/system/file-storage/channels/{channelID} [delete]
func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteFileStorageChannel(r.Context(), r.PathValue("channelID")); err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "file storage channel not found")
return
}
s.logger.Error("delete file storage channel failed", "error", err)
writeError(w, http.StatusInternalServerError, "delete file storage channel failed")
return
}
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 == "" {
provider = "server_main_openapi"
}
status := strings.ToLower(strings.TrimSpace(input.Status))
if status == "" {
status = "disabled"
}
if strings.TrimSpace(input.ChannelKey) == "" || strings.TrimSpace(input.Name) == "" {
return "channelKey and name are required"
}
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 {
hasAPIKey = strings.TrimSpace(*input.APIKey) != ""
} else if existing != nil {
hasAPIKey = strings.TrimSpace(existing.APIKey) != ""
}
if status == "enabled" && !hasAPIKey {
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"),
fileStorageConfigString(input.Config, "cdnDomain"),
fileStorageConfigString(input.Config, "publicDomain"),
); publicBaseURL != "" && !validFileStorageBaseURL(publicBaseURL) {
return "object storage config.publicBaseUrl must be an http or https URL without embedded credentials"
}
if policy := firstNonEmpty(
fileStorageConfigString(input.Config, "temporaryFileExpirePolicy"),
fileStorageConfigString(input.Config, "apiReturnExpirePolicy"),
); policy != "" && !validFileStorageExpirationPolicy(policy) {
return "object storage config.temporaryFileExpirePolicy must be never, 1d, 1m, 3m or 6m"
}
if value, exists := input.Config["signedUrlExpiresSeconds"]; exists {
seconds, ok := fileStorageConfigInteger(value)
if !ok || seconds < 60 || seconds > 7*24*60*60 {
return "object storage config.signedUrlExpiresSeconds must be an integer between 60 and 604800"
}
}
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 validFileStorageExpirationPolicy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "never", "1d", "1m", "3m", "6m":
return true
default:
return false
}
}
func fileStorageConfigInteger(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float64:
converted := int64(typed)
return converted, float64(converted) == typed
case float32:
converted := int64(typed)
return converted, float32(converted) == typed
default:
return 0, false
}
}
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
}
}