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")); 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 } }