7643dd457e
Replace the legacy "redacted placeholder + Clear* boolean" pattern with
dedicated per-resource credential subresources across MCP services,
Models, Web search providers, and Data sources.
Why
---
The previous design had three problems:
1. Main PUT body carried secret fields. The frontend echoed back a
redacted "***" placeholder, and a fragile MergeUpdate / IsRedactedOrEmpty
defense in the service layer tried to detect "user did not change this"
vs "user wants to clear this". A regression in that defense (or a new
frontend forgetting it) silently overwrites the stored secret with the
placeholder.
2. The "remove this credential" UX was a red checkbox under a pre-filled
password input. Three intents (preserve / replace / clear) collapsed
onto one field, and credential changes were bundled with unrelated
config edits in the same submit. Users wiped working keys by mistake.
3. Secret presence was inferred from "did the response come back with a
'***' placeholder", which couples the contract to a magic string.
Design
------
Each of the four resources now exposes:
PUT /{resource}/{id}/credentials # write one or more fields
DELETE /{resource}/{id}/credentials/{field} # clear a single field
"Is this configured?" metadata travels on the main resource response as
a typed map (dto.MCPServiceResponse.Credentials etc.) — no separate GET
endpoint. The frontend reads the configured boolean from the main GET
and never sees secret values at all.
Main PUT endpoints now ignore any secret fields in the request body and
log a deprecation warning if they appear, so legacy clients fail loudly
rather than overwriting silently.
Frontend
--------
- New reusable <CredentialResource> component renders a three-state card
(unconfigured / configured / editing) and drives the dedicated
endpoints. Used by MCP, Model, Web search; DataSource has a bespoke
card with the same behaviour because its credentials are a single
atomic per-connector map.
- Cancel from edit mode now restores state synchronously from the meta
prop. The previous async refresh() was a no-op while state was still
'editing', leaving the input frozen open.
- Remove is single-click + toast. The danger-themed button is the
deterrent; a modal confirm adds friction without adding safety (the
plaintext is irrecoverable client-side either way — recovery means
re-typing).
DTOs (internal/handler/dto/) are deliberately separate from the GORM
models so "no secret in response" is a compile-time invariant: a future
contributor cannot leak a secret without explicitly adding the field to
the DTO, which is review-able in a single diff.
Storage is unchanged — credentials still live in the existing jsonb /
parameters columns. No schema migration.
Cleanup
-------
- types.MCPService / types.Model / types.WebSearchProviderEntity /
types.DataSourceConfig: drop ClearAPIKey / ClearToken / ClearAppSecret
/ ClearCredentials boolean fields, MergeUpdate(), RedactSensitiveData().
- utils/types/secret.go: drop PreserveIfRedacted / IsRedactedOrEmpty.
RedactedSecretPlaceholder constant is retained because VectorStore
still uses the old pattern and is out of scope here.
- Frontend hasExistingApiKey / clearApiKey / convertToLegacyFormat
redaction handling removed; i18n keys renamed secret -> credential.
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/service"
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/handler/dto"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/models/provider"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ModelHandler handles HTTP requests for model-related operations
|
|
// It implements the necessary methods to create, retrieve, update, and delete models
|
|
type ModelHandler struct {
|
|
service interfaces.ModelService
|
|
}
|
|
|
|
// NewModelHandler creates a new instance of ModelHandler
|
|
// It requires a model service implementation that handles business logic
|
|
// Parameters:
|
|
// - service: An implementation of the ModelService interface
|
|
//
|
|
// Returns a pointer to the newly created ModelHandler
|
|
func NewModelHandler(service interfaces.ModelService) *ModelHandler {
|
|
return &ModelHandler{service: service}
|
|
}
|
|
|
|
// Per-response redaction/stripping for Model now lives in
|
|
// dto.NewModelResponse — handlers must use it for every body that contains a
|
|
// model. The previous hideSensitiveInfo helper has been removed.
|
|
|
|
// CreateModelRequest defines the structure for model creation requests
|
|
// Contains all fields required to create a new model in the system
|
|
type CreateModelRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Type types.ModelType `json:"type" binding:"required"`
|
|
Source types.ModelSource `json:"source" binding:"required"`
|
|
Description string `json:"description"`
|
|
Parameters types.ModelParameters `json:"parameters" binding:"required"`
|
|
}
|
|
|
|
// CreateModel godoc
|
|
// @Summary 创建模型
|
|
// @Description 创建新的模型配置
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body CreateModelRequest true "模型信息"
|
|
// @Success 201 {object} map[string]interface{} "创建的模型"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models [post]
|
|
func (h *ModelHandler) CreateModel(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start creating model")
|
|
|
|
var req CreateModelRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|
return
|
|
}
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if tenantID == 0 {
|
|
logger.Error(ctx, "Tenant ID is empty")
|
|
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Creating model, Tenant ID: %d, Model name: %s, Model type: %s",
|
|
tenantID, secutils.SanitizeForLog(req.Name), secutils.SanitizeForLog(string(req.Type)))
|
|
|
|
// SSRF validation for model BaseURL
|
|
if req.Parameters.BaseURL != "" {
|
|
if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil {
|
|
logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err)
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
model := &types.Model{
|
|
TenantID: tenantID,
|
|
Name: secutils.SanitizeForLog(req.Name),
|
|
Type: types.ModelType(secutils.SanitizeForLog(string(req.Type))),
|
|
Source: req.Source,
|
|
Description: secutils.SanitizeForLog(req.Description),
|
|
Parameters: req.Parameters,
|
|
}
|
|
|
|
if err := h.service.CreateModel(ctx, model); err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(
|
|
ctx,
|
|
"Model created successfully, ID: %s, Name: %s",
|
|
secutils.SanitizeForLog(model.ID),
|
|
secutils.SanitizeForLog(model.Name),
|
|
)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": dto.NewModelResponse(model),
|
|
})
|
|
}
|
|
|
|
// GetModel godoc
|
|
// @Summary 获取模型详情
|
|
// @Description 根据ID获取模型详情
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "模型ID"
|
|
// @Success 200 {object} map[string]interface{} "模型详情"
|
|
// @Failure 404 {object} errors.AppError "模型不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models/{id} [get]
|
|
func (h *ModelHandler) GetModel(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start retrieving model")
|
|
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|
if id == "" {
|
|
logger.Error(ctx, "Model ID is empty")
|
|
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieving model, ID: %s", id)
|
|
model, err := h.service.GetModelByID(ctx, id)
|
|
if err != nil {
|
|
if err == service.ErrModelNotFound {
|
|
logger.Warnf(ctx, "Model not found, ID: %s", id)
|
|
c.Error(errors.NewNotFoundError("Model not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieved model successfully, ID: %s, Name: %s", model.ID, model.Name)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": dto.NewModelResponse(model),
|
|
})
|
|
}
|
|
|
|
// ListModels godoc
|
|
// @Summary 获取模型列表
|
|
// @Description 获取当前租户的所有模型
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{} "模型列表"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models [get]
|
|
func (h *ModelHandler) ListModels(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start retrieving model list")
|
|
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if tenantID == 0 {
|
|
logger.Error(ctx, "Tenant ID is empty")
|
|
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
models, err := h.service.ListModels(ctx)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieved model list successfully, Tenant ID: %d, Total: %d models", tenantID, len(models))
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": dto.NewModelResponses(models),
|
|
})
|
|
}
|
|
|
|
// UpdateModelRequest defines the structure for model update requests
|
|
// Contains fields that can be updated for an existing model
|
|
type UpdateModelRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Parameters types.ModelParameters `json:"parameters"`
|
|
Source types.ModelSource `json:"source"`
|
|
Type types.ModelType `json:"type"`
|
|
}
|
|
|
|
// UpdateModel godoc
|
|
// @Summary 更新模型
|
|
// @Description 更新模型配置信息
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "模型ID"
|
|
// @Param request body UpdateModelRequest true "更新信息"
|
|
// @Success 200 {object} map[string]interface{} "更新后的模型"
|
|
// @Failure 404 {object} errors.AppError "模型不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models/{id} [put]
|
|
func (h *ModelHandler) UpdateModel(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start updating model")
|
|
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|
if id == "" {
|
|
logger.Error(ctx, "Model ID is empty")
|
|
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
var req UpdateModelRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieving model information, ID: %s", id)
|
|
model, err := h.service.GetModelByID(ctx, id)
|
|
if err != nil {
|
|
if err == service.ErrModelNotFound {
|
|
logger.Warnf(ctx, "Model not found, ID: %s", id)
|
|
c.Error(errors.NewNotFoundError("Model not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Update model fields if they are provided in the request
|
|
if req.Name != "" {
|
|
model.Name = req.Name
|
|
}
|
|
model.Description = req.Description
|
|
|
|
// SSRF validation for updated model BaseURL
|
|
if req.Parameters.BaseURL != "" {
|
|
if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil {
|
|
logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err)
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err)))
|
|
return
|
|
}
|
|
}
|
|
// Credentials (api_key, app_secret) NEVER flow through this endpoint —
|
|
// they live behind the /credentials subresource. Force-preserve them by
|
|
// snapshotting the stored values before copying request fields in, so
|
|
// that even a misbehaving caller that puts api_key in the body cannot
|
|
// clobber a stored credential. Log a warning to spot stale callers.
|
|
storedAPIKey := model.Parameters.APIKey
|
|
storedAppSecret := model.Parameters.AppSecret
|
|
if req.Parameters.APIKey != "" && req.Parameters.APIKey != storedAPIKey {
|
|
logger.Warnf(ctx,
|
|
"deprecated: api_key in PUT /models/%s body is ignored; use PUT /credentials instead", id)
|
|
}
|
|
if req.Parameters.AppSecret != "" && req.Parameters.AppSecret != storedAppSecret {
|
|
logger.Warnf(ctx,
|
|
"deprecated: app_secret in PUT /models/%s body is ignored; use PUT /credentials instead", id)
|
|
}
|
|
newParams := req.Parameters
|
|
newParams.APIKey = storedAPIKey
|
|
newParams.AppSecret = storedAppSecret
|
|
// Preserve backend-managed fields not sent by the frontend either.
|
|
newParams.ParameterSize = model.Parameters.ParameterSize
|
|
if newParams.ExtraConfig == nil {
|
|
newParams.ExtraConfig = model.Parameters.ExtraConfig
|
|
}
|
|
model.Parameters = newParams
|
|
|
|
model.Source = req.Source
|
|
model.Type = req.Type
|
|
|
|
logger.Infof(ctx, "Updating model, ID: %s, Name: %s", id, model.Name)
|
|
if err := h.service.UpdateModel(ctx, model); err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Model updated successfully, ID: %s", id)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": dto.NewModelResponse(model),
|
|
})
|
|
}
|
|
|
|
// DeleteModel godoc
|
|
// @Summary 删除模型
|
|
// @Description 删除指定的模型
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "模型ID"
|
|
// @Success 200 {object} map[string]interface{} "删除成功"
|
|
// @Failure 404 {object} errors.AppError "模型不存在"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models/{id} [delete]
|
|
func (h *ModelHandler) DeleteModel(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
logger.Info(ctx, "Start deleting model")
|
|
|
|
id := secutils.SanitizeForLog(c.Param("id"))
|
|
if id == "" {
|
|
logger.Error(ctx, "Model ID is empty")
|
|
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Deleting model, ID: %s", id)
|
|
if err := h.service.DeleteModel(ctx, id); err != nil {
|
|
if err == service.ErrModelNotFound {
|
|
logger.Warnf(ctx, "Model not found, ID: %s", id)
|
|
c.Error(errors.NewNotFoundError("Model not found"))
|
|
return
|
|
}
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Model deleted successfully, ID: %s", id)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Model deleted",
|
|
})
|
|
}
|
|
|
|
// ModelProviderDTO 模型厂商信息 DTO
|
|
type ModelProviderDTO struct {
|
|
Value string `json:"value"` // provider 标识符
|
|
Label string `json:"label"` // 显示名称
|
|
Description string `json:"description"` // 描述
|
|
DefaultURLs map[string]string `json:"defaultUrls"` // 按模型类型区分的默认 URL
|
|
ModelTypes []string `json:"modelTypes"` // 支持的模型类型
|
|
}
|
|
|
|
// modelTypeToFrontend 将后端 ModelType 转换为前端兼容的字符串
|
|
// KnowledgeQA -> chat, Embedding -> embedding, Rerank -> rerank, VLLM -> vllm
|
|
func modelTypeToFrontend(mt types.ModelType) string {
|
|
switch mt {
|
|
case types.ModelTypeKnowledgeQA:
|
|
return "chat"
|
|
case types.ModelTypeEmbedding:
|
|
return "embedding"
|
|
case types.ModelTypeRerank:
|
|
return "rerank"
|
|
case types.ModelTypeVLLM:
|
|
return "vllm"
|
|
case types.ModelTypeASR:
|
|
return "asr"
|
|
default:
|
|
return string(mt)
|
|
}
|
|
}
|
|
|
|
// ListModelProviders godoc
|
|
// @Summary 获取模型厂商列表
|
|
// @Description 根据模型类型获取支持的厂商列表及配置信息
|
|
// @Tags 模型管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param model_type query string false "模型类型 (chat, embedding, rerank, vllm)"
|
|
// @Success 200 {object} map[string]interface{} "厂商列表"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /models/providers [get]
|
|
func (h *ModelHandler) ListModelProviders(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
modelType := c.Query("model_type")
|
|
logger.Infof(ctx, "Listing model providers for type: %s", secutils.SanitizeForLog(modelType))
|
|
|
|
// 将前端类型映射到后端类型
|
|
// 前端: chat, embedding, rerank, vllm
|
|
// 后端: KnowledgeQA, Embedding, Rerank, VLLM
|
|
var backendModelType types.ModelType
|
|
switch modelType {
|
|
case "chat":
|
|
backendModelType = types.ModelTypeKnowledgeQA
|
|
case "embedding":
|
|
backendModelType = types.ModelTypeEmbedding
|
|
case "rerank":
|
|
backendModelType = types.ModelTypeRerank
|
|
case "vllm":
|
|
backendModelType = types.ModelTypeVLLM
|
|
case "asr":
|
|
backendModelType = types.ModelTypeASR
|
|
default:
|
|
backendModelType = types.ModelType(modelType)
|
|
}
|
|
|
|
var providers []provider.ProviderInfo
|
|
if modelType != "" {
|
|
// 按模型类型过滤
|
|
providers = provider.ListByModelType(backendModelType)
|
|
} else {
|
|
// 返回所有 provider
|
|
providers = provider.List()
|
|
}
|
|
|
|
// 转换为 DTO
|
|
result := make([]ModelProviderDTO, 0, len(providers))
|
|
for _, p := range providers {
|
|
// 转换 DefaultURLs map[types.ModelType]string -> map[string]string
|
|
// 使用前端兼容的 key (chat 而不是 KnowledgeQA)
|
|
defaultURLs := make(map[string]string)
|
|
for mt, url := range p.DefaultURLs {
|
|
frontendType := modelTypeToFrontend(mt)
|
|
defaultURLs[frontendType] = url
|
|
}
|
|
|
|
// 转换 ModelTypes 为前端兼容格式
|
|
modelTypes := make([]string, 0, len(p.ModelTypes))
|
|
for _, mt := range p.ModelTypes {
|
|
modelTypes = append(modelTypes, modelTypeToFrontend(mt))
|
|
}
|
|
|
|
result = append(result, ModelProviderDTO{
|
|
Value: string(p.Name),
|
|
Label: p.DisplayName,
|
|
Description: p.Description,
|
|
DefaultURLs: defaultURLs,
|
|
ModelTypes: modelTypes,
|
|
})
|
|
}
|
|
|
|
logger.Infof(ctx, "Retrieved %d providers", len(result))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": result,
|
|
})
|
|
}
|