1930 lines
68 KiB
Go
1930 lines
68 KiB
Go
package handler
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/golang-jwt/jwt/v5"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/config"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/handler/dto"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|||
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// TenantHandler implements HTTP request handlers for tenant management
|
|||
|
|
// Provides functionality for creating, retrieving, updating, and deleting tenants
|
|||
|
|
// through the REST API endpoints
|
|||
|
|
type TenantHandler struct {
|
|||
|
|
service interfaces.TenantService
|
|||
|
|
apiKeyService interfaces.TenantAPIKeyService
|
|||
|
|
userService interfaces.UserService
|
|||
|
|
memberService interfaces.TenantMemberService
|
|||
|
|
kbService interfaces.KnowledgeBaseService
|
|||
|
|
config *config.Config
|
|||
|
|
// systemSettingSvc resolves runtime tenant policies and limits.
|
|||
|
|
// Reading goes DB > ENV >
|
|||
|
|
// in-code default, so a SystemAdmin's UI override applies on the
|
|||
|
|
// very next CreateTenant call.
|
|||
|
|
systemSettingSvc interfaces.SystemSettingService
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewTenantHandler creates a new tenant handler instance with the provided service
|
|||
|
|
// Parameters:
|
|||
|
|
// - service: An implementation of the TenantService interface for business logic
|
|||
|
|
// - userService: An implementation of the UserService interface for user operations
|
|||
|
|
// - memberService: An implementation of TenantMemberService used to bootstrap
|
|||
|
|
// the creator as Owner of the tenant they just created (self-service create).
|
|||
|
|
// - config: Application configuration
|
|||
|
|
//
|
|||
|
|
// # Returns a pointer to the newly created TenantHandler
|
|||
|
|
//
|
|||
|
|
// Note on RBAC: cross-tenant gating (CanAccessAllTenants /
|
|||
|
|
// EnableCrossTenantAccess) and per-tenant path matching (URL :id ==
|
|||
|
|
// active tenant) used to live in `authorizeTenantAccess` and the if
|
|||
|
|
// blocks at the top of ListAllTenants / SearchTenants. Both moved to
|
|||
|
|
// `middleware/access.go` (RequireCrossTenantAccess /
|
|||
|
|
// RequirePathTenantMatch) and are wired in `router.go` so the handler
|
|||
|
|
// stays focused on business logic.
|
|||
|
|
func NewTenantHandler(
|
|||
|
|
service interfaces.TenantService,
|
|||
|
|
apiKeyService interfaces.TenantAPIKeyService,
|
|||
|
|
userService interfaces.UserService,
|
|||
|
|
memberService interfaces.TenantMemberService,
|
|||
|
|
kbService interfaces.KnowledgeBaseService,
|
|||
|
|
config *config.Config,
|
|||
|
|
systemSettingSvc interfaces.SystemSettingService,
|
|||
|
|
) *TenantHandler {
|
|||
|
|
return &TenantHandler{
|
|||
|
|
service: service,
|
|||
|
|
apiKeyService: apiKeyService,
|
|||
|
|
userService: userService,
|
|||
|
|
memberService: memberService,
|
|||
|
|
kbService: kbService,
|
|||
|
|
config: config,
|
|||
|
|
systemSettingSvc: systemSettingSvc,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// createTenantRequest is the JSON body for POST /tenants. Only fields a
|
|||
|
|
// regular authenticated user is allowed to set are accepted; everything
|
|||
|
|
// else (api_key, status, storage_quota, retriever_engines, etc.) is
|
|||
|
|
// generated server-side by TenantService.CreateTenant so a normal user
|
|||
|
|
// can't bypass quotas or self-suspend a workspace at create time.
|
|||
|
|
//
|
|||
|
|
// Cross-tenant superusers historically posted the full Tenant struct to
|
|||
|
|
// this endpoint. We keep that path working by binding into the same
|
|||
|
|
// types.Tenant when CanAccessAllTenants is true (see CreateTenant
|
|||
|
|
// below), but the recommended shape going forward is name+description.
|
|||
|
|
type createTenantRequest struct {
|
|||
|
|
Name string `json:"name" binding:"required,min=1,max=128"`
|
|||
|
|
Description string `json:"description" binding:"max=512"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantRequest is the JSON body for PUT /tenants/:id. Only the
|
|||
|
|
// fields an Owner is permitted to mutate via the public API are bound;
|
|||
|
|
// everything else (storage_quota, status, business, api_key, agent /
|
|||
|
|
// retrieval / storage configs, ...) is intentionally NOT writable here
|
|||
|
|
// — those go through dedicated endpoints (PUT /tenants/kv/:key, ...)
|
|||
|
|
// that have their own validation.
|
|||
|
|
//
|
|||
|
|
// Pointers so we can distinguish "not sent" from "explicit empty
|
|||
|
|
// string"; when nil we leave the existing column untouched.
|
|||
|
|
type updateTenantRequest struct {
|
|||
|
|
Name *string `json:"name" binding:"omitempty,min=1,max=128"`
|
|||
|
|
Description *string `json:"description" binding:"omitempty,max=512"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type apiPrincipalConfigRequest struct {
|
|||
|
|
Mode types.APIPrincipalMode `json:"mode"`
|
|||
|
|
DirectHeaderName string `json:"direct_header_name"`
|
|||
|
|
SignedTokenHeaderName string `json:"signed_token_header_name"`
|
|||
|
|
RequireDirectHeader bool `json:"require_direct_header"`
|
|||
|
|
HMACSecret *string `json:"hmac_secret"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type apiPrincipalConfigResponse struct {
|
|||
|
|
Mode types.APIPrincipalMode `json:"mode"`
|
|||
|
|
DirectHeaderName string `json:"direct_header_name"`
|
|||
|
|
SignedTokenHeaderName string `json:"signed_token_header_name"`
|
|||
|
|
RequireDirectHeader bool `json:"require_direct_header"`
|
|||
|
|
// HasHMACSecret reports whether a signing secret is configured. The
|
|||
|
|
// plaintext secret is NEVER returned — clients only learn presence.
|
|||
|
|
HasHMACSecret bool `json:"has_hmac_secret"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type apiPrincipalTestTokenRequest struct {
|
|||
|
|
ExternalUserID string `json:"external_user_id"`
|
|||
|
|
ExpiresInSeconds int `json:"expires_in_seconds"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type apiPrincipalTestTokenResponse struct {
|
|||
|
|
Token string `json:"token"`
|
|||
|
|
HeaderName string `json:"header_name"`
|
|||
|
|
ExpiresInSeconds int `json:"expires_in_seconds"`
|
|||
|
|
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
|||
|
|
ExternalUserID string `json:"external_user_id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type tenantAPIKeyCreateRequest struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
FullAccess bool `json:"full_access"`
|
|||
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|||
|
|
Capabilities []string `json:"capabilities"`
|
|||
|
|
ExpiresAt *int64 `json:"expires_at_unix"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// tenantAPIKeyUpdateRequest 修改已创建 API Key 的配置,字段语义与创建接口一致。
|
|||
|
|
type tenantAPIKeyUpdateRequest struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
FullAccess bool `json:"full_access"`
|
|||
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|||
|
|
Capabilities []string `json:"capabilities"`
|
|||
|
|
ExpiresAt *int64 `json:"expires_at_unix"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type tenantAPIKeyResponse struct {
|
|||
|
|
ID uint64 `json:"id"`
|
|||
|
|
ScopeType types.APIKeyScopeType `json:"scope_type"`
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
APIKey string `json:"api_key"`
|
|||
|
|
FullAccess bool `json:"full_access"`
|
|||
|
|
KnowledgeBaseIDs types.StringArray `json:"knowledge_base_ids"`
|
|||
|
|
Capabilities types.StringArray `json:"capabilities"`
|
|||
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|||
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|||
|
|
CreatedAt time.Time `json:"created_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type tenantAPIKeyCreateResponse struct {
|
|||
|
|
tenantAPIKeyResponse
|
|||
|
|
Token string `json:"token"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
defaultAPIPrincipalDirectHeader = "X-External-User-ID"
|
|||
|
|
defaultAPIPrincipalTokenHeader = "X-External-User-Token"
|
|||
|
|
defaultAPIPrincipalTestTokenTTL = 15 * time.Minute
|
|||
|
|
maxAPIPrincipalTestTokenTTL = time.Hour
|
|||
|
|
maxAPIPrincipalExternalUserIDLen = 128
|
|||
|
|
// apiPrincipalSecretRedacted is the placeholder an update request may
|
|||
|
|
// send in place of the HMAC secret to signal "leave the stored secret
|
|||
|
|
// unchanged". The plaintext secret is never returned by GET, so the
|
|||
|
|
// client cannot echo the real value back.
|
|||
|
|
apiPrincipalSecretRedacted = "***"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// defaultMaxOwnedTenantsPerUser is the cap applied when
|
|||
|
|
// config.Tenant.MaxOwnedPerUser is left at zero. Picked to comfortably
|
|||
|
|
// cover legitimate "personal + a couple of side-projects" use while
|
|||
|
|
// blunting drive-by abuse against POST /tenants (see CreateTenant).
|
|||
|
|
const defaultMaxOwnedTenantsPerUser = 10
|
|||
|
|
|
|||
|
|
// resolveMaxOwnedTenantsPerUser returns the current cap, walking the
|
|||
|
|
// 3-tier resolver: system_settings DB row > WEKNORA_TENANT_MAX_OWNED_PER_USER
|
|||
|
|
// env > config.Tenant.MaxOwnedPerUser (yaml) > defaultMaxOwnedTenantsPerUser.
|
|||
|
|
// We pre-compute the cfg-derived fallback so the SystemSettingService
|
|||
|
|
// receives a single int64 default — its 3-tier resolver layers DB and
|
|||
|
|
// env on top of that.
|
|||
|
|
func (h *TenantHandler) resolveMaxOwnedTenantsPerUser(ctx context.Context) int {
|
|||
|
|
fallback := int64(defaultMaxOwnedTenantsPerUser)
|
|||
|
|
if h.config != nil && h.config.Tenant != nil && h.config.Tenant.MaxOwnedPerUser != 0 {
|
|||
|
|
fallback = int64(h.config.Tenant.MaxOwnedPerUser)
|
|||
|
|
}
|
|||
|
|
return int(h.systemSettingSvc.GetInt(
|
|||
|
|
ctx,
|
|||
|
|
"tenant.max_owned_per_user",
|
|||
|
|
"WEKNORA_TENANT_MAX_OWNED_PER_USER",
|
|||
|
|
fallback,
|
|||
|
|
))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateTenant godoc
|
|||
|
|
// @Summary 创建空间
|
|||
|
|
// @Description 创建新的空间。任意已登录用户均可调用以建立自己的新工作区,
|
|||
|
|
// @Description 调用方会被自动设为该空间的 Owner。跨空间超管仍可像以前一样
|
|||
|
|
// @Description 通过本接口创建任意空间。
|
|||
|
|
// @Description 当 tenant.auto_create_api_key(或 WEKNORA_TENANT_AUTO_CREATE_API_KEY)
|
|||
|
|
// @Description 开启时,会自动创建一个 full_access API Key,并在响应体的 data.api_key 字段返回其明文 token。
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body handler.createTenantRequest true "空间信息"
|
|||
|
|
// @Success 201 {object} map[string]interface{} "创建的空间(可选含 api_key)"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants [post]
|
|||
|
|
func (h *TenantHandler) CreateTenant(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start creating tenant")
|
|||
|
|
|
|||
|
|
// Resolve the caller; required so we can bootstrap the Owner
|
|||
|
|
// membership and so we can branch on cross-tenant superuser status
|
|||
|
|
// for the legacy full-payload path.
|
|||
|
|
caller, err := h.userService.GetCurrentUser(ctx)
|
|||
|
|
if err != nil || caller == nil {
|
|||
|
|
logger.Error(ctx, "Failed to resolve current user from context", err)
|
|||
|
|
c.Error(errors.NewUnauthorizedError("authentication required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
apiKeyScope, hasAPIKeyScope := types.TenantAPIKeyScopeFromContext(ctx)
|
|||
|
|
platformCaller := hasAPIKeyScope && apiKeyScope.IsPlatform()
|
|||
|
|
catalogManager := caller.CanAccessAllTenants || platformCaller
|
|||
|
|
|
|||
|
|
// Deployment-level policy: ordinary users may be restricted to joining
|
|||
|
|
// existing workspaces by invitation. This check is authoritative; the
|
|||
|
|
// frontend capability only improves UX and cannot bypass it. Cross-tenant
|
|||
|
|
// superusers retain the catalog-management create path.
|
|||
|
|
if !catalogManager &&
|
|||
|
|
!resolveTenantSelfServiceCreationEnabled(ctx, h.config, h.systemSettingSvc) {
|
|||
|
|
logger.Warnf(ctx, "Self-service tenant creation denied by policy for user %s", caller.ID)
|
|||
|
|
c.Error(errors.NewTenantCreationDisabledError())
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tenantData types.Tenant
|
|||
|
|
|
|||
|
|
if catalogManager {
|
|||
|
|
// Backward-compatible path for cross-tenant superusers: accept
|
|||
|
|
// the full Tenant payload (status, storage_quota, retriever
|
|||
|
|
// engines, configs...) so existing tooling keeps working.
|
|||
|
|
if err := c.ShouldBindJSON(&tenantData); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
appErr := errors.NewValidationError("Invalid request parameters").WithDetails(err.Error())
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// Reset client-supplied primary key so we don't accidentally
|
|||
|
|
// insert with a chosen ID that collides with a future
|
|||
|
|
// auto-increment value. Tenant IDs must always be DB-generated.
|
|||
|
|
tenantData.ID = 0
|
|||
|
|
} else {
|
|||
|
|
// Self-service path: a regular user can only set name and
|
|||
|
|
// description. Everything else is server-generated by
|
|||
|
|
// TenantService.CreateTenant (status="active", storage_quota
|
|||
|
|
// default, retriever engines from RETRIEVE_DRIVER). API keys are
|
|||
|
|
// created explicitly through the integration API-key list.
|
|||
|
|
var req createTenantRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
appErr := errors.NewValidationError("Invalid request parameters").WithDetails(err.Error())
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Per-user quota: cap how many tenants a regular user can spin
|
|||
|
|
// up via self-service. Without this any authenticated client
|
|||
|
|
// can flood `tenants` (and saturate validateStorageBucketUniqueness
|
|||
|
|
// which scans the whole table). Superusers above are exempt
|
|||
|
|
// because they're already trusted to manage the catalog.
|
|||
|
|
if h.memberService != nil {
|
|||
|
|
memberships, listErr := h.memberService.ListByUser(ctx, caller.ID)
|
|||
|
|
if listErr != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to count owned tenants for user %s: %v", caller.ID, listErr)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to validate workspace quota").WithDetails(listErr.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ownedCount := 0
|
|||
|
|
for _, m := range memberships {
|
|||
|
|
if m != nil && m.Role == types.TenantRoleOwner {
|
|||
|
|
ownedCount++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
cap := h.resolveMaxOwnedTenantsPerUser(ctx)
|
|||
|
|
if cap > 0 && ownedCount >= cap {
|
|||
|
|
logger.Warnf(ctx,
|
|||
|
|
"User %s reached self-service tenant quota (%d/%d)",
|
|||
|
|
caller.ID, ownedCount, cap,
|
|||
|
|
)
|
|||
|
|
c.Error(errors.NewTooManyRequestsError(
|
|||
|
|
"reached self-service workspace quota; contact an administrator to raise the limit",
|
|||
|
|
))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenantData = types.Tenant{
|
|||
|
|
Name: strings.TrimSpace(req.Name),
|
|||
|
|
Description: strings.TrimSpace(req.Description),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Apply the system-setting-driven default storage quota when the
|
|||
|
|
// caller didn't specify one (always true for self-serve; sometimes
|
|||
|
|
// true for the superuser branch when the JSON omits storage_quota).
|
|||
|
|
// We resolve at create time on purpose — the on-disk row should
|
|||
|
|
// carry an explicit value, so changing the setting later doesn't
|
|||
|
|
// silently shrink/grow established tenants. Negative values are
|
|||
|
|
// treated as "use default" so a misconfigured setting can't yield
|
|||
|
|
// a negative quota that the storage-used checks would interpret as
|
|||
|
|
// "unlimited" (StorageQuota <= 0 disables enforcement in
|
|||
|
|
// knowledge_create.go).
|
|||
|
|
if tenantData.StorageQuota <= 0 {
|
|||
|
|
gb := h.systemSettingSvc.GetInt(
|
|||
|
|
ctx,
|
|||
|
|
"tenant.default_storage_quota_gb",
|
|||
|
|
"WEKNORA_TENANT_DEFAULT_STORAGE_QUOTA_GB",
|
|||
|
|
10,
|
|||
|
|
)
|
|||
|
|
if gb <= 0 {
|
|||
|
|
gb = 10
|
|||
|
|
}
|
|||
|
|
tenantData.StorageQuota = gb * 1024 * 1024 * 1024
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Creating tenant, name: %s", secutils.SanitizeForLog(tenantData.Name))
|
|||
|
|
|
|||
|
|
createdTenant, err := h.service.CreateTenant(ctx, &tenantData)
|
|||
|
|
if err != nil {
|
|||
|
|
// Check if this is an application-specific error
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to create workspace: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to create workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Bootstrap an Owner membership so the caller immediately has full
|
|||
|
|
// control over the tenant they just created. We MUST roll the tenant
|
|||
|
|
// back if this fails: without a membership row the new tenant is
|
|||
|
|
// unreachable (middleware/auth.go's orphan-recovery only fires for a
|
|||
|
|
// user's home tenant, never for a freshly-created side workspace),
|
|||
|
|
// yet still occupies storage_bucket / name uniqueness slots.
|
|||
|
|
// Idempotent: EnsureOwner is a no-op when the row already exists,
|
|||
|
|
// so cross-tenant superusers create-and-own through the same path.
|
|||
|
|
if h.memberService != nil && !platformCaller {
|
|||
|
|
if _, err := h.memberService.EnsureOwner(ctx, caller.ID, createdTenant.ID); err != nil {
|
|||
|
|
logger.Errorf(ctx,
|
|||
|
|
"Failed to bootstrap owner membership for user %s tenant %d: %v — rolling back tenant",
|
|||
|
|
caller.ID, createdTenant.ID, err)
|
|||
|
|
if delErr := h.service.DeleteTenant(ctx, createdTenant.ID); delErr != nil {
|
|||
|
|
logger.Errorf(ctx,
|
|||
|
|
"Rollback DeleteTenant failed for orphan tenant %d: %v",
|
|||
|
|
createdTenant.ID, delErr,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to finalise workspace ownership").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Quota TOCTOU guard. The earlier ownedCount check is racy:
|
|||
|
|
// N concurrent CreateTenant calls all read ownedCount < cap,
|
|||
|
|
// all proceed, all insert. Re-count AFTER the Owner membership
|
|||
|
|
// is committed; if we landed over the cap, roll back this
|
|||
|
|
// tenant + its membership so the bound holds in steady state.
|
|||
|
|
// We only do this for non-superusers (the only path that has
|
|||
|
|
// a cap) — superusers are exempt above.
|
|||
|
|
if !caller.CanAccessAllTenants {
|
|||
|
|
memberships, listErr := h.memberService.ListByUser(ctx, caller.ID)
|
|||
|
|
if listErr != nil {
|
|||
|
|
logger.Errorf(ctx, "Post-create quota recount failed for user %s tenant %d: %v",
|
|||
|
|
caller.ID, createdTenant.ID, listErr)
|
|||
|
|
} else {
|
|||
|
|
ownedNow := 0
|
|||
|
|
for _, m := range memberships {
|
|||
|
|
if m != nil && m.Role == types.TenantRoleOwner {
|
|||
|
|
ownedNow++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
cap := h.resolveMaxOwnedTenantsPerUser(ctx)
|
|||
|
|
if cap > 0 && ownedNow > cap {
|
|||
|
|
logger.Warnf(ctx,
|
|||
|
|
"User %s exceeded tenant quota after concurrent create (%d/%d), rolling back tenant %d",
|
|||
|
|
caller.ID, ownedNow, cap, createdTenant.ID,
|
|||
|
|
)
|
|||
|
|
if rmErr := h.memberService.RemoveMember(ctx, caller.ID, createdTenant.ID); rmErr != nil {
|
|||
|
|
logger.Errorf(ctx,
|
|||
|
|
"Rollback RemoveMember failed for user %s tenant %d: %v",
|
|||
|
|
caller.ID, createdTenant.ID, rmErr,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if delErr := h.service.DeleteTenant(ctx, createdTenant.ID); delErr != nil {
|
|||
|
|
logger.Errorf(ctx,
|
|||
|
|
"Rollback DeleteTenant failed for over-quota tenant %d: %v",
|
|||
|
|
createdTenant.ID, delErr,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
c.Error(errors.NewTooManyRequestsError(
|
|||
|
|
"reached self-service workspace quota; contact an administrator to raise the limit",
|
|||
|
|
))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// When a tenantless user creates their first workspace, make it their
|
|||
|
|
// default login tenant. Roll the just-created resources back if this
|
|||
|
|
// finalisation fails so the user is not left with an unreachable tenant.
|
|||
|
|
if caller.TenantID == 0 && !platformCaller {
|
|||
|
|
caller.TenantID = createdTenant.ID
|
|||
|
|
if err := h.userService.UpdateUser(ctx, caller); err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to set first tenant %d as default for user %s: %v",
|
|||
|
|
createdTenant.ID, caller.ID, err)
|
|||
|
|
if h.memberService != nil {
|
|||
|
|
_ = h.memberService.RemoveMember(ctx, caller.ID, createdTenant.ID)
|
|||
|
|
}
|
|||
|
|
_ = h.service.DeleteTenant(ctx, createdTenant.ID)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to finalise default workspace").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Tenant created successfully, ID: %d, name: %s",
|
|||
|
|
createdTenant.ID,
|
|||
|
|
secutils.SanitizeForLog(createdTenant.Name),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// data carries the created tenant. When the legacy auto-create-key
|
|||
|
|
// behaviour is enabled we embed the plaintext token as data.api_key so
|
|||
|
|
// the response shape mirrors the pre-break-change behaviour integrations
|
|||
|
|
// relied on.
|
|||
|
|
var data any = createdTenant
|
|||
|
|
|
|||
|
|
// Optional legacy compatibility: mint a full-access API key on tenant
|
|||
|
|
// creation and return its plaintext token, gated by the
|
|||
|
|
// tenant.auto_create_api_key setting (env WEKNORA_TENANT_AUTO_CREATE_API_KEY).
|
|||
|
|
// Default off — modern deployments create keys explicitly via
|
|||
|
|
// tenant_api_keys. Failing to create the convenience key must NOT fail
|
|||
|
|
// the whole tenant creation (the tenant is fully usable without a key);
|
|||
|
|
// we log a warning and return the tenant as usual.
|
|||
|
|
// A platform key with system_tenants_manage may create workspaces, but it
|
|||
|
|
// must not bootstrap a full-access workspace key and escalate beyond its
|
|||
|
|
// own explicit capabilities.
|
|||
|
|
if !platformCaller && h.autoCreateTenantAPIKey(ctx) && h.apiKeyService != nil {
|
|||
|
|
result, keyErr := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{
|
|||
|
|
TenantID: createdTenant.ID,
|
|||
|
|
Name: "default",
|
|||
|
|
FullAccess: true,
|
|||
|
|
})
|
|||
|
|
if keyErr != nil {
|
|||
|
|
logger.Errorf(ctx,
|
|||
|
|
"Auto-create default API key failed for tenant %d: %v — returning tenant without key",
|
|||
|
|
createdTenant.ID, keyErr)
|
|||
|
|
} else if merged, mErr := tenantWithAPIKey(createdTenant, result.Token); mErr != nil {
|
|||
|
|
// Round-trip failure is unexpected; degrade gracefully by
|
|||
|
|
// returning the tenant without embedding the key rather than
|
|||
|
|
// failing the whole request.
|
|||
|
|
logger.Errorf(ctx, "Failed to embed api_key into tenant response for tenant %d: %v",
|
|||
|
|
createdTenant.ID, mErr)
|
|||
|
|
} else {
|
|||
|
|
data = merged
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusCreated, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// tenantWithAPIKey returns the tenant serialized as a map with an extra
|
|||
|
|
// api_key field, so the create response can embed the plaintext token inside
|
|||
|
|
// data (mirroring the pre-break-change shape) without adding a persisted
|
|||
|
|
// api_key column back onto types.Tenant.
|
|||
|
|
func tenantWithAPIKey(tenant *types.Tenant, token string) (map[string]any, error) {
|
|||
|
|
raw, err := json.Marshal(tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
m := map[string]any{}
|
|||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
m["api_key"] = token
|
|||
|
|
return m, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// autoCreateTenantAPIKey resolves whether tenant creation should also mint a
|
|||
|
|
// full-access API key (legacy compatibility). 3-tier resolver:
|
|||
|
|
// system_settings DB row > WEKNORA_TENANT_AUTO_CREATE_API_KEY env > false.
|
|||
|
|
func (h *TenantHandler) autoCreateTenantAPIKey(ctx context.Context) bool {
|
|||
|
|
if h.systemSettingSvc == nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return h.systemSettingSvc.GetBool(
|
|||
|
|
ctx,
|
|||
|
|
"tenant.auto_create_api_key",
|
|||
|
|
"WEKNORA_TENANT_AUTO_CREATE_API_KEY",
|
|||
|
|
false,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenant godoc
|
|||
|
|
// @Summary 获取空间详情
|
|||
|
|
// @Description 根据ID获取空间详情
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "空间详情"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 404 {object} errors.AppError "空间不存在"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/{id} [get]
|
|||
|
|
func (h *TenantHandler) GetTenant(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Invalid workspace ID: %s", secutils.SanitizeForLog(c.Param("id")))
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, err := h.service.GetTenantByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to retrieve workspace: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to retrieve workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": dto.NewTenantResponse(ctx, tenant),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateTenant godoc
|
|||
|
|
// @Summary 更新空间
|
|||
|
|
// @Description 更新空间信息
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Param request body types.Tenant true "空间信息"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新后的空间"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/{id} [put]
|
|||
|
|
func (h *TenantHandler) UpdateTenant(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start updating tenant")
|
|||
|
|
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Invalid workspace ID: %s", secutils.SanitizeForLog(c.Param("id")))
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Strict whitelist: only Name / Description are mutable through the
|
|||
|
|
// public PUT. Storage quota, status, business, configs, api_key and
|
|||
|
|
// every other privileged column live behind dedicated endpoints
|
|||
|
|
// (PUT /tenants/kv/:key, ...). Without this, an
|
|||
|
|
// Owner — including any user who just self-served a tenant — could
|
|||
|
|
// flip status / bump storage_quota by simply crafting an extended
|
|||
|
|
// JSON body. Pointers distinguish "field omitted" from "explicit
|
|||
|
|
// empty string" so we can leave untouched columns alone.
|
|||
|
|
var req updateTenantRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Load the persisted tenant so any column the request omits keeps
|
|||
|
|
// its current value through the GORM `Updates(struct)` zero-skip
|
|||
|
|
// behaviour (we always pass back the full struct).
|
|||
|
|
existing, err := h.service.GetTenantByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to load workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if req.Name != nil {
|
|||
|
|
trimmed := strings.TrimSpace(*req.Name)
|
|||
|
|
if trimmed == "" {
|
|||
|
|
c.Error(errors.NewValidationError("name cannot be blank"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
existing.Name = trimmed
|
|||
|
|
}
|
|||
|
|
if req.Description != nil {
|
|||
|
|
existing.Description = strings.TrimSpace(*req.Description)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Updating tenant, ID: %d, Name: %s", id, secutils.SanitizeForLog(existing.Name))
|
|||
|
|
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, existing)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to update workspace: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(
|
|||
|
|
ctx,
|
|||
|
|
"Tenant updated successfully, ID: %d, Name: %s",
|
|||
|
|
updatedTenant.ID,
|
|||
|
|
secutils.SanitizeForLog(updatedTenant.Name),
|
|||
|
|
)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": dto.NewTenantResponse(ctx, updatedTenant),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (h *TenantHandler) ListAPIKeys(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
keys, err := h.apiKeyService.ListAPIKeys(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to list API keys").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
resp := make([]tenantAPIKeyResponse, 0, len(keys))
|
|||
|
|
for _, key := range keys {
|
|||
|
|
resp = append(resp, tenantAPIKeyForResponse(key))
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (h *TenantHandler) CreateAPIKey(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req tenantAPIKeyCreateRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := validateTenantAPIKeyRequest(ctx, h.kbService, id, req); err != nil {
|
|||
|
|
c.Error(err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var expiresAt *time.Time
|
|||
|
|
if req.ExpiresAt != nil {
|
|||
|
|
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
|||
|
|
if !t.After(time.Now().UTC()) {
|
|||
|
|
c.Error(errors.NewValidationError("expires_at_unix must be in the future"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
expiresAt = &t
|
|||
|
|
}
|
|||
|
|
result, err := h.apiKeyService.CreateAPIKey(ctx, interfaces.TenantAPIKeyCreateRequest{
|
|||
|
|
TenantID: id,
|
|||
|
|
Name: req.Name,
|
|||
|
|
FullAccess: req.FullAccess,
|
|||
|
|
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
|
|||
|
|
Capabilities: req.Capabilities,
|
|||
|
|
ExpiresAt: expiresAt,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to create API key").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusCreated, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": tenantAPIKeyCreateResponse{
|
|||
|
|
tenantAPIKeyResponse: tenantAPIKeyForResponse(result.APIKey),
|
|||
|
|
Token: result.Token,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateAPIKey 修改已创建租户 API Key 的授权范围和其他可配置属性。
|
|||
|
|
// 路由层要求当前租户 Owner;字段校验与创建接口保持一致。
|
|||
|
|
func (h *TenantHandler) UpdateAPIKey(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenantID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil || tenantID == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
keyID, err := strconv.ParseUint(c.Param("key_id"), 10, 64)
|
|||
|
|
if err != nil || keyID == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid API key ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req tenantAPIKeyUpdateRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if appErr := validateTenantAPIKeyRequest(ctx, h.kbService, tenantID, tenantAPIKeyCreateRequest(req)); appErr != nil {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var expiresAt *time.Time
|
|||
|
|
if req.ExpiresAt != nil {
|
|||
|
|
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
|||
|
|
expiresAt = &t
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updated, err := h.apiKeyService.UpdateAPIKey(ctx, interfaces.TenantAPIKeyUpdateRequest{
|
|||
|
|
TenantID: tenantID, APIKeyID: keyID, Name: req.Name, FullAccess: req.FullAccess,
|
|||
|
|
KnowledgeBaseIDs: req.KnowledgeBaseIDs, Capabilities: req.Capabilities, ExpiresAt: expiresAt,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewNotFoundError("API key not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": tenantAPIKeyForResponse(updated)})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (h *TenantHandler) DeleteAPIKey(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenantID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
keyID, err := strconv.ParseUint(c.Param("key_id"), 10, 64)
|
|||
|
|
if err != nil || keyID == 0 {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid API key ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := h.apiKeyService.RevokeAPIKey(ctx, tenantID, keyID); err != nil {
|
|||
|
|
c.Error(errors.NewNotFoundError("API key not found"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func tenantAPIKeyForResponse(key *types.TenantAPIKey) tenantAPIKeyResponse {
|
|||
|
|
if key == nil {
|
|||
|
|
return tenantAPIKeyResponse{}
|
|||
|
|
}
|
|||
|
|
return tenantAPIKeyResponse{
|
|||
|
|
ID: key.ID,
|
|||
|
|
ScopeType: types.NormalizeAPIKeyScopeType(key.ScopeType),
|
|||
|
|
Name: key.Name,
|
|||
|
|
APIKey: key.APIKey,
|
|||
|
|
FullAccess: key.FullAccess,
|
|||
|
|
KnowledgeBaseIDs: key.KnowledgeBaseIDs,
|
|||
|
|
Capabilities: types.NormalizeAPIKeyCapabilities(key.Capabilities),
|
|||
|
|
LastUsedAt: key.LastUsedAt,
|
|||
|
|
ExpiresAt: key.ExpiresAt,
|
|||
|
|
CreatedAt: key.CreatedAt,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validateTenantAPIKeyRequest(
|
|||
|
|
ctx context.Context,
|
|||
|
|
kbService interfaces.KnowledgeBaseService,
|
|||
|
|
tenantID uint64,
|
|||
|
|
req tenantAPIKeyCreateRequest,
|
|||
|
|
) *errors.AppError {
|
|||
|
|
if strings.TrimSpace(req.Name) == "" {
|
|||
|
|
return errors.NewValidationError("name is required")
|
|||
|
|
}
|
|||
|
|
if req.FullAccess {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
caps := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities))
|
|||
|
|
if len(caps) == 0 {
|
|||
|
|
return errors.NewValidationError("capabilities are required for scoped API keys")
|
|||
|
|
}
|
|||
|
|
for _, cap := range req.Capabilities {
|
|||
|
|
if strings.TrimSpace(cap) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if types.NormalizeAPIKeyCapability(types.APIKeyCapability(cap)) == "" {
|
|||
|
|
return errors.NewValidationError("capabilities contains an unknown capability")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return validateTenantAPIKeyKnowledgeBaseIDs(ctx, kbService, tenantID, req.KnowledgeBaseIDs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// validateTenantAPIKeyKnowledgeBaseIDs 校验白名单中的知识库真实存在且属于目标租户。
|
|||
|
|
// 入参是请求上下文、知识库服务、租户 ID 和待授权 ID;成功无返回值,失败返回可直接响应的应用错误。
|
|||
|
|
func validateTenantAPIKeyKnowledgeBaseIDs(
|
|||
|
|
ctx context.Context,
|
|||
|
|
kbService interfaces.KnowledgeBaseService,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeBaseIDs []string,
|
|||
|
|
) *errors.AppError {
|
|||
|
|
if len(knowledgeBaseIDs) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
return validateTenantAPIKeyKnowledgeBaseIDsWithLookup(
|
|||
|
|
ctx, tenantID, knowledgeBaseIDs, kbService.GetKnowledgeBaseByID,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// validateTenantAPIKeyKnowledgeBaseIDsWithLookup 将归属校验与大型知识库服务接口解耦,便于覆盖边界测试。
|
|||
|
|
// lookup 输入知识库 ID 并返回真实知识库;函数输出 nil 或可直接响应的校验错误。
|
|||
|
|
func validateTenantAPIKeyKnowledgeBaseIDsWithLookup(
|
|||
|
|
ctx context.Context,
|
|||
|
|
tenantID uint64,
|
|||
|
|
knowledgeBaseIDs []string,
|
|||
|
|
lookup func(context.Context, string) (*types.KnowledgeBase, error),
|
|||
|
|
) *errors.AppError {
|
|||
|
|
for _, kbID := range knowledgeBaseIDs {
|
|||
|
|
kbID = strings.TrimSpace(kbID)
|
|||
|
|
if kbID == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
kb, err := lookup(ctx, kbID)
|
|||
|
|
if err != nil || kb == nil {
|
|||
|
|
return errors.NewValidationError("knowledge_base_ids contains an unknown knowledge base")
|
|||
|
|
}
|
|||
|
|
if kb.TenantID != tenantID {
|
|||
|
|
return errors.NewForbiddenError("knowledge_base_ids contains a knowledge base outside this workspace")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func apiPrincipalConfigForResponse(cfg *types.APIPrincipalConfig) apiPrincipalConfigResponse {
|
|||
|
|
if cfg == nil {
|
|||
|
|
cfg = &types.APIPrincipalConfig{}
|
|||
|
|
}
|
|||
|
|
mode := cfg.Mode
|
|||
|
|
if mode == "" {
|
|||
|
|
mode = types.APIPrincipalModeTenant
|
|||
|
|
}
|
|||
|
|
return apiPrincipalConfigResponse{
|
|||
|
|
Mode: mode,
|
|||
|
|
DirectHeaderName: defaultAPIPrincipalDirectHeader,
|
|||
|
|
SignedTokenHeaderName: defaultAPIPrincipalTokenHeader,
|
|||
|
|
RequireDirectHeader: cfg.RequireDirectHeader,
|
|||
|
|
HasHMACSecret: strings.TrimSpace(cfg.HMACSecret) != "",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetAPIPrincipalConfig godoc
|
|||
|
|
// @Summary 获取空间 API Key 用户身份配置
|
|||
|
|
// @Description 返回 X-API-Key 请求如何映射为终端 Principal 的配置(Owner)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "API principal 配置"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/{id}/api-principal-config [get]
|
|||
|
|
func (h *TenantHandler) GetAPIPrincipalConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
tenant, err := h.service.GetTenantByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to load workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": apiPrincipalConfigForResponse(tenant.APIPrincipalConfig),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateAPIPrincipalConfig godoc
|
|||
|
|
// @Summary 更新空间 API Key 用户身份配置
|
|||
|
|
// @Description 配置 X-API-Key 请求如何映射为终端 Principal(Owner)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Param request body handler.apiPrincipalConfigRequest true "API principal 配置"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新后的配置"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/{id}/api-principal-config [put]
|
|||
|
|
func (h *TenantHandler) UpdateAPIPrincipalConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var req apiPrincipalConfigRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if req.Mode == "" {
|
|||
|
|
req.Mode = types.APIPrincipalModeTenant
|
|||
|
|
}
|
|||
|
|
switch req.Mode {
|
|||
|
|
case types.APIPrincipalModeTenant, types.APIPrincipalModeDirect, types.APIPrincipalModeSignedToken:
|
|||
|
|
default:
|
|||
|
|
c.Error(errors.NewValidationError("mode must be tenant, direct_header, or signed_token"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, err := h.service.GetTenantByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to load workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
existingSecret := ""
|
|||
|
|
if tenant.APIPrincipalConfig != nil {
|
|||
|
|
existingSecret = tenant.APIPrincipalConfig.HMACSecret
|
|||
|
|
}
|
|||
|
|
hmacSecret := existingSecret
|
|||
|
|
if req.HMACSecret != nil {
|
|||
|
|
provided := strings.TrimSpace(*req.HMACSecret)
|
|||
|
|
// GET no longer discloses the plaintext secret, so a client that
|
|||
|
|
// edits the config re-submits the redaction placeholder to mean
|
|||
|
|
// "keep the existing secret". Treat it as a no-op instead of
|
|||
|
|
// overwriting the real secret with "***".
|
|||
|
|
if provided != apiPrincipalSecretRedacted {
|
|||
|
|
hmacSecret = provided
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
cfg := &types.APIPrincipalConfig{
|
|||
|
|
Mode: req.Mode,
|
|||
|
|
DirectHeaderName: defaultAPIPrincipalDirectHeader,
|
|||
|
|
SignedTokenHeaderName: defaultAPIPrincipalTokenHeader,
|
|||
|
|
RequireDirectHeader: req.RequireDirectHeader,
|
|||
|
|
HMACSecret: hmacSecret,
|
|||
|
|
}
|
|||
|
|
if cfg.Mode == types.APIPrincipalModeSignedToken && strings.TrimSpace(cfg.HMACSecret) == "" {
|
|||
|
|
c.Error(errors.NewValidationError("hmac_secret is required for signed_token mode"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
tenant.APIPrincipalConfig = cfg
|
|||
|
|
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update API principal config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": apiPrincipalConfigForResponse(updatedTenant.APIPrincipalConfig),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateAPIPrincipalTestToken godoc
|
|||
|
|
// @Summary 生成 API Playground 测试 JWT
|
|||
|
|
// @Description 使用空间已保存的 HMAC 密钥签发短期外部用户 JWT(Owner)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Param request body handler.apiPrincipalTestTokenRequest true "测试 Token 参数"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "短期 JWT"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/{id}/api-principal-test-token [post]
|
|||
|
|
func (h *TenantHandler) CreateAPIPrincipalTestToken(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req apiPrincipalTestTokenRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
externalUserID := strings.TrimSpace(req.ExternalUserID)
|
|||
|
|
if err := validateAPIPrincipalExternalUserID(externalUserID); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError("external_user_id is invalid").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, err := h.service.GetTenantByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to load workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cfg := tenant.APIPrincipalConfig
|
|||
|
|
if cfg == nil || cfg.Mode != types.APIPrincipalModeSignedToken {
|
|||
|
|
c.Error(errors.NewValidationError("signed_token mode is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
secret := strings.TrimSpace(cfg.HMACSecret)
|
|||
|
|
if secret == "" {
|
|||
|
|
c.Error(errors.NewValidationError("hmac_secret is required for signed_token mode"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ttl := defaultAPIPrincipalTestTokenTTL
|
|||
|
|
if req.ExpiresInSeconds > 0 {
|
|||
|
|
ttl = time.Duration(req.ExpiresInSeconds) * time.Second
|
|||
|
|
}
|
|||
|
|
if ttl <= 0 || ttl > maxAPIPrincipalTestTokenTTL {
|
|||
|
|
c.Error(errors.NewValidationError("expires_in_seconds must be between 1 and 3600"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
now := time.Now()
|
|||
|
|
expiresAt := now.Add(ttl)
|
|||
|
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|||
|
|
"sub": externalUserID,
|
|||
|
|
"tenant_id": strconv.FormatUint(id, 10),
|
|||
|
|
"aud": "weknora",
|
|||
|
|
"iat": now.Unix(),
|
|||
|
|
"exp": expiresAt.Unix(),
|
|||
|
|
}).SignedString([]byte(secret))
|
|||
|
|
if err != nil {
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to create API principal test token").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": apiPrincipalTestTokenResponse{
|
|||
|
|
Token: token,
|
|||
|
|
HeaderName: defaultAPIPrincipalTokenHeader,
|
|||
|
|
ExpiresInSeconds: int(ttl.Seconds()),
|
|||
|
|
ExpiresAtUnix: expiresAt.Unix(),
|
|||
|
|
ExternalUserID: externalUserID,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validateAPIPrincipalExternalUserID(id string) error {
|
|||
|
|
if id == "" {
|
|||
|
|
return errors.NewValidationError("external_user_id is required")
|
|||
|
|
}
|
|||
|
|
if len(id) > maxAPIPrincipalExternalUserIDLen {
|
|||
|
|
return errors.NewValidationError("external_user_id is too long")
|
|||
|
|
}
|
|||
|
|
for _, r := range id {
|
|||
|
|
if r < 0x20 || r == 0x7f {
|
|||
|
|
return errors.NewValidationError("external_user_id contains invalid characters")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteTenant godoc
|
|||
|
|
// @Summary 删除空间
|
|||
|
|
// @Description 删除指定的空间
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "空间ID"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "删除成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/{id} [delete]
|
|||
|
|
func (h *TenantHandler) DeleteTenant(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
logger.Info(ctx, "Start deleting tenant")
|
|||
|
|
|
|||
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Invalid workspace ID: %s", secutils.SanitizeForLog(c.Param("id")))
|
|||
|
|
c.Error(errors.NewBadRequestError("Invalid workspace ID"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Deleting tenant, ID: %d", id)
|
|||
|
|
|
|||
|
|
if err := h.service.DeleteTenant(ctx, id); err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to delete workspace: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to delete workspace").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Workspace deleted successfully, ID: %d", id)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"message": "Workspace deleted successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListTenants godoc
|
|||
|
|
// @Summary 获取空间列表
|
|||
|
|
// @Description 获取当前用户可访问的空间列表
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} map[string]interface{} "空间列表"
|
|||
|
|
// @Failure 500 {object} errors.AppError "服务器错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants [get]
|
|||
|
|
func (h *TenantHandler) ListTenants(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
tenant, ok := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
|
|||
|
|
if !ok || tenant == nil {
|
|||
|
|
c.Error(errors.NewUnauthorizedError("Authentication required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": gin.H{
|
|||
|
|
"items": []*dto.TenantResponse{dto.NewTenantResponse(ctx, tenant)},
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListAllTenants godoc
|
|||
|
|
// @Summary 获取所有空间列表
|
|||
|
|
// @Description 获取系统中所有空间(需要跨空间访问权限)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} map[string]interface{} "所有空间列表"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Router /tenants/all [get]
|
|||
|
|
func (h *TenantHandler) ListAllTenants(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// Cross-tenant gating (CanAccessAllTenants + EnableCrossTenantAccess)
|
|||
|
|
// is enforced at the route layer via middleware.RequireCrossTenantAccess
|
|||
|
|
// (router.go). The handler stays focused on listing.
|
|||
|
|
tenants, err := h.service.ListAllTenants(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
// Check if this is an application-specific error
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to retrieve all workspaces list: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to retrieve all workspaces list").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": gin.H{
|
|||
|
|
"items": dto.NewTenantResponsesCrossTenant(tenants),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SearchTenants godoc
|
|||
|
|
// @Summary 搜索空间
|
|||
|
|
// @Description 分页搜索空间(需要跨空间访问权限)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param keyword query string false "搜索关键词"
|
|||
|
|
// @Param tenant_id query int false "空间ID筛选"
|
|||
|
|
// @Param page query int false "页码" default(1)
|
|||
|
|
// @Param page_size query int false "每页数量" default(20)
|
|||
|
|
// @Success 200 {object} map[string]interface{} "搜索结果"
|
|||
|
|
// @Failure 403 {object} errors.AppError "权限不足"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/search [get]
|
|||
|
|
func (h *TenantHandler) SearchTenants(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// Cross-tenant gating is enforced at the route layer via
|
|||
|
|
// middleware.RequireCrossTenantAccess (router.go); the handler only
|
|||
|
|
// parses query params and delegates to the service.
|
|||
|
|
|
|||
|
|
// Parse query parameters
|
|||
|
|
keyword := c.Query("keyword")
|
|||
|
|
tenantIDStr := c.Query("tenant_id")
|
|||
|
|
pageStr := c.DefaultQuery("page", "1")
|
|||
|
|
pageSizeStr := c.DefaultQuery("page_size", "20")
|
|||
|
|
|
|||
|
|
var tenantID uint64
|
|||
|
|
if tenantIDStr != "" {
|
|||
|
|
parsedID, err := strconv.ParseUint(tenantIDStr, 10, 64)
|
|||
|
|
if err == nil {
|
|||
|
|
tenantID = parsedID
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
page, err := strconv.Atoi(pageStr)
|
|||
|
|
if err != nil || page < 1 {
|
|||
|
|
page = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pageSize, err := strconv.Atoi(pageSizeStr)
|
|||
|
|
if err != nil || pageSize < 1 {
|
|||
|
|
pageSize = 20
|
|||
|
|
}
|
|||
|
|
if pageSize > 100 {
|
|||
|
|
pageSize = 100 // Limit max page size
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenants, total, err := h.service.SearchTenants(ctx, keyword, tenantID, page, pageSize)
|
|||
|
|
if err != nil {
|
|||
|
|
// Check if this is an application-specific error
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to search workspaces: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to search workspaces").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": gin.H{
|
|||
|
|
"items": dto.NewTenantResponsesCrossTenant(tenants),
|
|||
|
|
"total": total,
|
|||
|
|
"page": page,
|
|||
|
|
"page_size": pageSize,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantKV godoc
|
|||
|
|
// @Summary 获取空间KV配置
|
|||
|
|
// @Description 获取空间级别的KV配置(支持web-search-config、prompt-templates、parser-engine-config、storage-engine-config、chat-history-config、retrieval-config)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param key path string true "配置键名"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "配置值"
|
|||
|
|
// @Failure 400 {object} errors.AppError "不支持的键"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/kv/{key} [get]
|
|||
|
|
func (h *TenantHandler) GetTenantKV(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
key := secutils.SanitizeForLog(c.Param("key"))
|
|||
|
|
|
|||
|
|
switch key {
|
|||
|
|
case "web-search-config", "parser-engine-config", "storage-engine-config":
|
|||
|
|
if !dto.CanViewIntegrationSecrets(ctx) {
|
|||
|
|
c.Error(errors.NewForbiddenError("integration configuration requires admin access"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch key {
|
|||
|
|
case "web-search-config":
|
|||
|
|
h.GetTenantWebSearchConfig(c)
|
|||
|
|
return
|
|||
|
|
case "prompt-templates":
|
|||
|
|
h.GetPromptTemplates(c)
|
|||
|
|
return
|
|||
|
|
case "parser-engine-config":
|
|||
|
|
h.GetTenantParserEngineConfig(c)
|
|||
|
|
return
|
|||
|
|
case "storage-engine-config":
|
|||
|
|
h.GetTenantStorageEngineConfig(c)
|
|||
|
|
return
|
|||
|
|
case "chat-history-config":
|
|||
|
|
h.GetTenantChatHistoryConfig(c)
|
|||
|
|
return
|
|||
|
|
case "retrieval-config":
|
|||
|
|
h.GetTenantRetrievalConfig(c)
|
|||
|
|
return
|
|||
|
|
case "memory-config":
|
|||
|
|
h.GetTenantMemoryConfig(c)
|
|||
|
|
return
|
|||
|
|
default:
|
|||
|
|
logger.Info(ctx, "KV key not supported", "key", key)
|
|||
|
|
c.Error(errors.NewBadRequestError("unsupported key"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateTenantKV godoc
|
|||
|
|
// @Summary 更新空间KV配置
|
|||
|
|
// @Description 更新空间级别的KV配置(支持web-search-config、parser-engine-config、storage-engine-config、chat-history-config、retrieval-config)
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param key path string true "配置键名"
|
|||
|
|
// @Param request body object true "配置值"
|
|||
|
|
// @Success 200 {object} map[string]interface{} "更新成功"
|
|||
|
|
// @Failure 400 {object} errors.AppError "不支持的键"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/kv/{key} [put]
|
|||
|
|
func (h *TenantHandler) UpdateTenantKV(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
key := secutils.SanitizeForLog(c.Param("key"))
|
|||
|
|
|
|||
|
|
switch key {
|
|||
|
|
case "web-search-config", "parser-engine-config", "storage-engine-config":
|
|||
|
|
if !dto.CanViewIntegrationSecrets(ctx) {
|
|||
|
|
c.Error(errors.NewForbiddenError("integration configuration requires admin access"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch key {
|
|||
|
|
case "web-search-config":
|
|||
|
|
h.updateTenantWebSearchConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
case "parser-engine-config":
|
|||
|
|
h.updateTenantParserEngineConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
case "storage-engine-config":
|
|||
|
|
h.updateTenantStorageEngineConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
case "chat-history-config":
|
|||
|
|
h.updateTenantChatHistoryConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
case "retrieval-config":
|
|||
|
|
h.updateTenantRetrievalConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
case "memory-config":
|
|||
|
|
h.updateTenantMemoryConfigInternal(c)
|
|||
|
|
return
|
|||
|
|
default:
|
|||
|
|
logger.Info(ctx, "KV key not supported", "key", key)
|
|||
|
|
c.Error(errors.NewBadRequestError("unsupported key"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantWebSearchConfigInternal updates tenant's web search config
|
|||
|
|
func (h *TenantHandler) updateTenantWebSearchConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// Bind directly into the strong typed struct
|
|||
|
|
var cfg types.WebSearchConfig
|
|||
|
|
if err := c.ShouldBindJSON(&cfg); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cfg = *types.MergeWebSearchConfigForUpdate(&cfg, tenant.WebSearchConfig)
|
|||
|
|
|
|||
|
|
// Validate configuration
|
|||
|
|
if cfg.MaxResults < 1 || cfg.MaxResults > 50 {
|
|||
|
|
c.Error(errors.NewBadRequestError("max_results must be between 1 and 50"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant.WebSearchConfig = &cfg
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
logger.Error(ctx, "Failed to update workspace: application error", appErr)
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update workspace web search config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": types.WebSearchConfigForResponse(updatedTenant.WebSearchConfig, true),
|
|||
|
|
"message": "Web search configuration updated successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantWebSearchConfig godoc
|
|||
|
|
// @Summary 获取空间网络搜索配置
|
|||
|
|
// @Description 获取空间的网络搜索配置
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} map[string]interface{} "网络搜索配置"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/kv/web-search-config [get]
|
|||
|
|
func (h *TenantHandler) GetTenantWebSearchConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
logger.Info(ctx, "Start getting tenant web search config")
|
|||
|
|
// Get tenant
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Tenant web search config retrieved successfully, Tenant ID: %d", tenant.ID)
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": types.WebSearchConfigForResponse(tenant.WebSearchConfig, true),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantParserEngineConfig returns the tenant's parser engine config (MinerU endpoint, API key, etc.).
|
|||
|
|
func (h *TenantHandler) GetTenantParserEngineConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := types.ParserEngineConfigForResponse(tenant.ParserEngineConfig, true)
|
|||
|
|
if data == nil {
|
|||
|
|
data = &types.ParserEngineConfig{}
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantParserEngineConfigInternal updates the tenant's parser engine config.
|
|||
|
|
func (h *TenantHandler) updateTenantParserEngineConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
var cfg types.ParserEngineConfig
|
|||
|
|
if err := c.ShouldBindJSON(&cfg); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
merged := types.MergeParserEngineConfigForUpdate(&cfg, tenant.ParserEngineConfig)
|
|||
|
|
if err := validateParserEngineOutboundURLs(merged); err != nil {
|
|||
|
|
c.Error(errors.NewValidationError(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
tenant.ParserEngineConfig = merged
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update workspace parser engine config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": types.ParserEngineConfigForResponse(updatedTenant.ParserEngineConfig, true),
|
|||
|
|
"message": "解析引擎配置已更新",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantStorageEngineConfig returns the tenant's storage engine config (Local, MinIO, COS parameters).
|
|||
|
|
func (h *TenantHandler) GetTenantStorageEngineConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := types.StorageEngineConfigForResponse(tenant.StorageEngineConfig, true)
|
|||
|
|
if data == nil {
|
|||
|
|
data = &types.StorageEngineConfig{}
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantStorageEngineConfigInternal updates the tenant's storage engine config.
|
|||
|
|
func (h *TenantHandler) updateTenantStorageEngineConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
var cfg types.StorageEngineConfig
|
|||
|
|
if err := c.ShouldBindJSON(&cfg); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
provider := strings.ToLower(strings.TrimSpace(cfg.DefaultProvider))
|
|||
|
|
if provider == "" {
|
|||
|
|
provider = firstAllowedStorageProvider()
|
|||
|
|
}
|
|||
|
|
if provider == "" {
|
|||
|
|
c.Error(errors.NewBadRequestError("No storage provider is allowed by STORAGE_ALLOW_LIST"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if !isStorageProviderAllowed(provider) {
|
|||
|
|
c.Error(errors.NewBadRequestError("Storage provider is not allowed by STORAGE_ALLOW_LIST"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
cfg.DefaultProvider = provider
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
merged := types.MergeStorageEngineConfigForUpdate(&cfg, tenant.StorageEngineConfig)
|
|||
|
|
tenant.StorageEngineConfig = merged
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update workspace storage engine config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": types.StorageEngineConfigForResponse(updatedTenant.StorageEngineConfig, true),
|
|||
|
|
"message": "存储引擎配置已更新",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetPromptTemplates godoc
|
|||
|
|
// @Summary 获取提示词模板
|
|||
|
|
// @Description 获取系统配置的提示词模板列表
|
|||
|
|
// @Tags 空间管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} map[string]interface{} "提示词模板配置"
|
|||
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|||
|
|
// @Security Bearer
|
|||
|
|
// @Security ApiKeyAuth
|
|||
|
|
// @Router /tenants/kv/prompt-templates [get]
|
|||
|
|
func (h *TenantHandler) GetPromptTemplates(c *gin.Context) {
|
|||
|
|
// Return prompt templates from config.yaml
|
|||
|
|
templates := h.config.PromptTemplates
|
|||
|
|
if templates == nil {
|
|||
|
|
templates = &config.PromptTemplatesConfig{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Determine user language from context (set by Language middleware)
|
|||
|
|
lang := types.LanguageFromContextOrDefault(c.Request.Context())
|
|||
|
|
|
|||
|
|
// Build a localized copy so the original config is never mutated
|
|||
|
|
localized := &config.PromptTemplatesConfig{
|
|||
|
|
SystemPrompt: config.LocalizeTemplates(templates.SystemPrompt, lang),
|
|||
|
|
ContextTemplate: config.LocalizeTemplates(templates.ContextTemplate, lang),
|
|||
|
|
Rewrite: config.LocalizeTemplates(templates.Rewrite, lang),
|
|||
|
|
Fallback: config.LocalizeTemplates(templates.Fallback, lang),
|
|||
|
|
GenerateSessionTitle: templates.GenerateSessionTitle,
|
|||
|
|
GenerateSummary: templates.GenerateSummary,
|
|||
|
|
KeywordsExtraction: templates.KeywordsExtraction,
|
|||
|
|
AgentSystemPrompt: config.LocalizeTemplates(templates.AgentSystemPrompt, lang),
|
|||
|
|
IntentPrompts: config.LocalizeTemplates(templates.IntentPrompts, lang),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": localized,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantChatHistoryConfig returns the tenant's chat history KB configuration.
|
|||
|
|
func (h *TenantHandler) GetTenantChatHistoryConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := tenant.ChatHistoryConfig
|
|||
|
|
if data == nil {
|
|||
|
|
data = &types.ChatHistoryConfig{}
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantChatHistoryConfigInternal updates the tenant's chat history KB configuration.
|
|||
|
|
// When enabled with an embedding model and no KB exists yet, it auto-creates a hidden KB.
|
|||
|
|
func (h *TenantHandler) updateTenantChatHistoryConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
// The frontend sends: enabled, embedding_model_id
|
|||
|
|
// knowledge_base_id is managed internally.
|
|||
|
|
var req types.ChatHistoryConfig
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
existing := tenant.ChatHistoryConfig
|
|||
|
|
|
|||
|
|
// Build the new config, preserving the internally-managed knowledge_base_id
|
|||
|
|
cfg := &types.ChatHistoryConfig{
|
|||
|
|
Enabled: req.Enabled,
|
|||
|
|
EmbeddingModelID: req.EmbeddingModelID,
|
|||
|
|
KnowledgeBaseID: "", // will be set below
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Carry over existing KB ID if the embedding model hasn't changed
|
|||
|
|
if existing != nil && existing.KnowledgeBaseID != "" {
|
|||
|
|
if existing.EmbeddingModelID == req.EmbeddingModelID {
|
|||
|
|
cfg.KnowledgeBaseID = existing.KnowledgeBaseID
|
|||
|
|
} else {
|
|||
|
|
// Embedding model changed — the old KB is incompatible.
|
|||
|
|
// We'll create a new one below. The old KB remains but is orphaned (can be cleaned up later).
|
|||
|
|
logger.Infof(ctx, "Embedding model changed from %s to %s, will create new chat history KB", existing.EmbeddingModelID, req.EmbeddingModelID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Auto-create hidden KB if enabled + model set + no KB yet
|
|||
|
|
if cfg.Enabled && cfg.EmbeddingModelID != "" && cfg.KnowledgeBaseID == "" {
|
|||
|
|
kb := &types.KnowledgeBase{
|
|||
|
|
Name: "__chat_history__",
|
|||
|
|
Type: types.KnowledgeBaseTypeDocument,
|
|||
|
|
IsTemporary: true,
|
|||
|
|
Description: "Auto-managed knowledge base for chat history message indexing",
|
|||
|
|
EmbeddingModelID: cfg.EmbeddingModelID,
|
|||
|
|
}
|
|||
|
|
createdKB, err := h.kbService.CreateKnowledgeBase(ctx, kb)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to create chat history knowledge base").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
cfg.KnowledgeBaseID = createdKB.ID
|
|||
|
|
logger.Infof(ctx, "Auto-created chat history KB: id=%s, embedding_model=%s", createdKB.ID, cfg.EmbeddingModelID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant.ChatHistoryConfig = cfg
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update chat history config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": updatedTenant.ChatHistoryConfig,
|
|||
|
|
"message": "Chat history configuration updated successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantRetrievalConfig returns the tenant's global retrieval configuration.
|
|||
|
|
func (h *TenantHandler) GetTenantRetrievalConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := tenant.RetrievalConfig
|
|||
|
|
if data == nil {
|
|||
|
|
data = &types.RetrievalConfig{}
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantRetrievalConfigInternal updates the tenant's global retrieval configuration.
|
|||
|
|
func (h *TenantHandler) updateTenantRetrievalConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var cfg types.RetrievalConfig
|
|||
|
|
if err := c.ShouldBindJSON(&cfg); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate thresholds
|
|||
|
|
if cfg.VectorThreshold < 0 || cfg.VectorThreshold > 1 {
|
|||
|
|
c.Error(errors.NewBadRequestError("vector_threshold must be between 0 and 1"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.KeywordThreshold < 0 || cfg.KeywordThreshold > 1 {
|
|||
|
|
c.Error(errors.NewBadRequestError("keyword_threshold must be between 0 and 1"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.RerankThreshold < -10 || cfg.RerankThreshold > 10 {
|
|||
|
|
c.Error(errors.NewBadRequestError("rerank_threshold must be between -10 and 10"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.EmbeddingTopK < 0 || cfg.EmbeddingTopK > 200 {
|
|||
|
|
c.Error(errors.NewBadRequestError("embedding_top_k must be between 0 and 200"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.RerankTopK < 0 || cfg.RerankTopK > 200 {
|
|||
|
|
c.Error(errors.NewBadRequestError("rerank_top_k must be between 0 and 200"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant.RetrievalConfig = &cfg
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update retrieval config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": updatedTenant.RetrievalConfig,
|
|||
|
|
"message": "Retrieval configuration updated successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetTenantMemoryConfig returns the workspace long-term memory configuration.
|
|||
|
|
func (h *TenantHandler) GetTenantMemoryConfig(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
data := tenant.MemoryConfig
|
|||
|
|
if data == nil {
|
|||
|
|
// Memory is off until an admin turns it on: the feature retains what
|
|||
|
|
// users say across sessions, so it must not arrive enabled by default.
|
|||
|
|
data = &types.MemoryConfig{}
|
|||
|
|
}
|
|||
|
|
data.Normalize()
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": data,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateTenantMemoryConfigInternal updates the workspace memory configuration.
|
|||
|
|
func (h *TenantHandler) updateTenantMemoryConfigInternal(c *gin.Context) {
|
|||
|
|
ctx := c.Request.Context()
|
|||
|
|
|
|||
|
|
var cfg types.MemoryConfig
|
|||
|
|
if err := c.ShouldBindJSON(&cfg); err != nil {
|
|||
|
|
logger.Error(ctx, "Failed to parse request parameters", err)
|
|||
|
|
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.WriteMode != "" &&
|
|||
|
|
cfg.WriteMode != types.MemoryWriteExplicitOnly &&
|
|||
|
|
cfg.WriteMode != types.MemoryWriteAuto {
|
|||
|
|
c.Error(errors.NewBadRequestError("write_mode must be explicit_only or auto"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.MaxItems < 0 || cfg.MaxItems > 2000 {
|
|||
|
|
c.Error(errors.NewBadRequestError("max_items must be between 0 and 2000"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.ExtractDelaySeconds < 0 || cfg.ExtractDelaySeconds > types.MaxMemoryExtractDelaySeconds {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf(
|
|||
|
|
"extract_delay_seconds must be between 0 and %d", types.MaxMemoryExtractDelaySeconds)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.ExtractMinIntervalSeconds < 0 ||
|
|||
|
|
cfg.ExtractMinIntervalSeconds > types.MaxMemoryExtractMinIntervalSeconds {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf(
|
|||
|
|
"extract_min_interval_seconds must be between 0 and %d",
|
|||
|
|
types.MaxMemoryExtractMinIntervalSeconds)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if len(cfg.EmbeddingModelID) > 64 {
|
|||
|
|
c.Error(errors.NewBadRequestError("embedding_model_id is too long"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if cfg.InterestThreshold < 0 || cfg.InterestThreshold > types.MaxMemoryInterestThreshold {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf(
|
|||
|
|
"interest_threshold must be between 1 and %d", types.MaxMemoryInterestThreshold)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if len([]rune(cfg.ExtractInstructions)) > types.MaxMemoryExtractInstructionsRunes {
|
|||
|
|
c.Error(errors.NewBadRequestError(fmt.Sprintf(
|
|||
|
|
"extract_instructions must be at most %d characters",
|
|||
|
|
types.MaxMemoryExtractInstructionsRunes)))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
cfg.Normalize()
|
|||
|
|
|
|||
|
|
tenant, _ := types.TenantInfoFromContext(ctx)
|
|||
|
|
if tenant == nil {
|
|||
|
|
logger.Error(ctx, "Workspace is empty")
|
|||
|
|
c.Error(errors.NewBadRequestError("Workspace is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tenant.MemoryConfig = &cfg
|
|||
|
|
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
|
|||
|
|
if err != nil {
|
|||
|
|
if appErr, ok := errors.IsAppError(err); ok {
|
|||
|
|
c.Error(appErr)
|
|||
|
|
} else {
|
|||
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|||
|
|
c.Error(errors.NewInternalServerError("Failed to update memory config").WithDetails(err.Error()))
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": updatedTenant.MemoryConfig,
|
|||
|
|
"message": "Memory configuration updated successfully",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validateParserEngineOutboundURLs(cfg *types.ParserEngineConfig) error {
|
|||
|
|
if cfg == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if endpoint := strings.TrimSpace(cfg.MinerUEndpoint); endpoint != "" {
|
|||
|
|
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
|
|||
|
|
return fmt.Errorf("mineru_endpoint failed SSRF validation: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if vlmURL := strings.TrimSpace(cfg.MinerUVLMServerURL); vlmURL != "" {
|
|||
|
|
if err := secutils.ValidateURLForSSRF(vlmURL); err != nil {
|
|||
|
|
return fmt.Errorf("mineru_vlm_server_url failed SSRF validation: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if odlURL := strings.TrimSpace(cfg.ODLHybridURL); odlURL != "" {
|
|||
|
|
if err := secutils.ValidateURLForSSRF(odlURL); err != nil {
|
|||
|
|
return fmt.Errorf("odl_hybrid_url failed SSRF validation: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if endpoint := strings.TrimSpace(cfg.PaddleOCRVLEndpoint); endpoint != "" {
|
|||
|
|
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
|
|||
|
|
return fmt.Errorf("paddleocr_vl_endpoint failed SSRF validation: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|