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 }