Files
xinmi/internal/handler/system.go
T

2394 lines
96 KiB
Go
Raw Normal View History

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/application/service/file"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/database"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
"github.com/Tencent/WeKnora/internal/logger"
modellimiter "github.com/Tencent/WeKnora/internal/models/limiter"
"github.com/Tencent/WeKnora/internal/runtime"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
"github.com/neo4j/neo4j-go-driver/v6/neo4j"
)
type runtimeKnowledgeCanceller interface {
CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*types.Knowledge, error)
}
// SystemHandler handles system-related requests
type SystemHandler struct {
cfg *config.Config
neo4jDriver neo4j.Driver
documentReader interfaces.DocumentReader
tenantSvc interfaces.TenantService
userSvc interfaces.UserService
systemSettingSvc interfaces.SystemSettingService
apiKeySvc interfaces.TenantAPIKeyService
// auditSvc is optional — when nil, emitAdminAudit no-ops so unit
// tests that wire a partial container still compile. In production
// the dig graph always provides one.
auditSvc interfaces.AuditLogService
// taskInspector backs the SystemAdmin runtime queue dashboard. Always
// provided by the container (asynq-backed in Redis mode, a no-op in
// Lite mode), so GetRuntimeQueues can distinguish "no queues in this
// deployment" from "queues are empty".
taskInspector interfaces.TaskInspector
// knowledgeSvc supplies the domain-level cancellation path used by the
// runtime task console. It updates business state and tracing before queue
// records are removed, unlike a raw Redis deletion.
knowledgeSvc runtimeKnowledgeCanceller
// storageBackendRepo lets GetStorageEngineStatus report multi-instance
// storage backends (Settings → Storage) as "available", not just the legacy
// singleton tenant.StorageEngineConfig. Optional — nil in partially-wired
// unit tests, in which case only the legacy config is consulted.
storageBackendRepo interfaces.StorageBackendRepository
sandboxConfigSvc sandboxConfigService
// startup snapshot for GET /system/capabilities; bound in router.NewRouter.
deploymentCapabilities DeploymentCapabilitiesData
}
// NewSystemHandler creates a new system handler
func NewSystemHandler(cfg *config.Config,
neo4jDriver neo4j.Driver,
documentReader interfaces.DocumentReader,
tenantSvc interfaces.TenantService,
userSvc interfaces.UserService,
systemSettingSvc interfaces.SystemSettingService,
apiKeySvc interfaces.TenantAPIKeyService,
auditSvc interfaces.AuditLogService,
taskInspector interfaces.TaskInspector,
knowledgeSvc interfaces.KnowledgeService,
storageBackendRepo interfaces.StorageBackendRepository,
sandboxConfigSvc *service.TenantSandboxConfigService,
) *SystemHandler {
return &SystemHandler{
cfg: cfg,
neo4jDriver: neo4jDriver,
documentReader: documentReader,
tenantSvc: tenantSvc,
userSvc: userSvc,
systemSettingSvc: systemSettingSvc,
apiKeySvc: apiKeySvc,
auditSvc: auditSvc,
taskInspector: taskInspector,
knowledgeSvc: knowledgeSvc,
storageBackendRepo: storageBackendRepo,
sandboxConfigSvc: sandboxConfigSvc,
}
}
type platformAPIKeyCreateRequest struct {
Name string `json:"name"`
Capabilities []string `json:"capabilities"`
ExpiresAt *int64 `json:"expires_at_unix"`
}
// ListPlatformAPIKeys godoc
// @Summary List platform API keys
// @Description Lists non-workspace API keys. Full tokens are always masked. Human SystemAdmin only.
// @Tags System Admin
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Security Bearer
// @Router /system/admin/api-keys [get]
func (h *SystemHandler) ListPlatformAPIKeys(c *gin.Context) {
keys, err := h.apiKeySvc.ListPlatformAPIKeys(c.Request.Context())
if err != nil {
c.Error(apperrors.NewInternalServerError("Failed to list platform API keys").WithDetails(err.Error()))
return
}
response := make([]tenantAPIKeyResponse, 0, len(keys))
for _, key := range keys {
item := tenantAPIKeyForResponse(key)
item.APIKey = maskManagedAPIKey(key.APIKey)
response = append(response, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": response})
}
// CreatePlatformAPIKey godoc
// @Summary Create a platform API key
// @Description Creates a capability-scoped key that may target any workspace through X-Tenant-ID. The plaintext token is returned once. Human SystemAdmin only.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body platformAPIKeyCreateRequest true "Platform API key"
// @Success 201 {object} map[string]interface{}
// @Security Bearer
// @Router /system/admin/api-keys [post]
func (h *SystemHandler) CreatePlatformAPIKey(c *gin.Context) {
var req platformAPIKeyCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
if strings.TrimSpace(req.Name) == "" {
c.Error(apperrors.NewValidationError("name is required"))
return
}
capabilities := types.NormalizeAPIKeyCapabilities(types.StringArray(req.Capabilities))
if len(capabilities) == 0 || len(capabilities) != len(req.Capabilities) {
c.Error(apperrors.NewValidationError("valid capabilities are required"))
return
}
var expiresAt *time.Time
if req.ExpiresAt != nil {
value := time.Unix(*req.ExpiresAt, 0).UTC()
if !value.After(time.Now().UTC()) {
c.Error(apperrors.NewValidationError("expires_at_unix must be in the future"))
return
}
expiresAt = &value
}
result, err := h.apiKeySvc.CreateAPIKey(c.Request.Context(), interfaces.TenantAPIKeyCreateRequest{
ScopeType: types.APIKeyScopePlatform,
Name: req.Name,
Capabilities: []string(capabilities),
ExpiresAt: expiresAt,
})
if err != nil {
c.Error(apperrors.NewInternalServerError("Failed to create platform API key").WithDetails(err.Error()))
return
}
item := tenantAPIKeyForResponse(result.APIKey)
item.APIKey = maskManagedAPIKey(result.Token)
h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyCreated, result.APIKey)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": tenantAPIKeyCreateResponse{tenantAPIKeyResponse: item, Token: result.Token},
})
}
// DeletePlatformAPIKey godoc
// @Summary Revoke a platform API key
// @Description Immediately revokes a platform API key. Human SystemAdmin only.
// @Tags System Admin
// @Produce json
// @Param key_id path int true "API key ID"
// @Success 200 {object} map[string]interface{}
// @Security Bearer
// @Router /system/admin/api-keys/{key_id} [delete]
func (h *SystemHandler) DeletePlatformAPIKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("key_id"), 10, 64)
if err != nil || id == 0 {
c.Error(apperrors.NewBadRequestError("Invalid API key ID"))
return
}
if err := h.apiKeySvc.RevokePlatformAPIKey(c.Request.Context(), id); err != nil {
c.Error(apperrors.NewNotFoundError("Platform API key not found"))
return
}
h.emitAPIKeyAudit(c.Request.Context(), types.AuditActionSystemAPIKeyRevoked, &types.TenantAPIKey{ID: id})
c.JSON(http.StatusOK, gin.H{"success": true})
}
func maskManagedAPIKey(token string) string {
token = strings.TrimSpace(token)
if len(token) <= 12 {
return "***"
}
return token[:7] + "..." + token[len(token)-4:]
}
func (h *SystemHandler) emitAPIKeyAudit(ctx context.Context, action types.AuditAction, key *types.TenantAPIKey) {
if h.auditSvc == nil || key == nil {
return
}
actorID, _ := types.UserIDFromContext(ctx)
details, _ := json.Marshal(map[string]any{
"scope_type": types.APIKeyScopePlatform,
"capabilities": key.Capabilities,
})
_ = h.auditSvc.Log(ctx, &types.AuditLog{
TenantID: 0, ActorUserID: actorID, ActorRole: systemAuditActorRole(ctx),
Action: action, TargetType: "api_key", TargetID: strconv.FormatUint(key.ID, 10),
Outcome: types.AuditOutcomeSuccess, Details: types.JSON(details),
})
}
func systemAuditActorRole(ctx context.Context) string {
if scope, ok := types.TenantAPIKeyScopeFromContext(ctx); ok && scope.IsPlatform() {
return "platform_api_key"
}
return "system_admin"
}
// emitAdminAudit writes one audit row for a system-admin lifecycle event
// (promote / revoke). Best-effort — a nil audit service or a write
// failure does not bubble up to the caller. Mirrors the failure
// semantics of tenantMemberService.emitAudit and the system settings
// audit hook.
//
// `details` may be nil; the JSON `{}` default applies.
func (h *SystemHandler) emitAdminAudit(
ctx context.Context,
action types.AuditAction,
target *types.User,
details map[string]any,
) {
if h.auditSvc == nil {
return
}
actorID, _ := types.UserIDFromContext(ctx)
var detailsJSON types.JSON
if details != nil {
if b, err := json.Marshal(details); err == nil {
detailsJSON = types.JSON(b)
}
}
entry := &types.AuditLog{
// tenant_id=0 marks the row as system-scope. The audit_logs
// table is tenant-scoped; 0 is the convention for platform-wide
// events (matches AuditActionSystemSettingChanged).
TenantID: 0,
ActorUserID: actorID,
ActorRole: systemAuditActorRole(ctx),
Action: action,
TargetType: "user",
Outcome: types.AuditOutcomeSuccess,
Details: detailsJSON,
}
if target != nil {
entry.TargetID = target.ID
entry.TargetUserID = target.ID
}
_ = h.auditSvc.Log(ctx, entry)
}
// GetSystemInfoResponse defines the response structure for system info
type GetSystemInfoResponse struct {
Version string `json:"version"`
Edition string `json:"edition"`
CommitID string `json:"commit_id,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version,omitempty"`
KeywordIndexEngine string `json:"keyword_index_engine,omitempty"`
VectorStoreEngine string `json:"vector_store_engine,omitempty"`
GraphDatabaseEngine string `json:"graph_database_engine,omitempty"`
MinioEnabled bool `json:"minio_enabled,omitempty"`
DBVersion string `json:"db_version,omitempty"`
// DBMigrationError carries the human-readable error message recorded when
// the most recent startup migration attempt failed. Empty when migrations
// succeeded; non-empty values let the frontend surface a troubleshooting
// banner instead of silently hiding the DB version row (see issue #1319).
DBMigrationError string `json:"db_migration_error,omitempty"`
// StartedAt is the server process boot time (RFC3339, UTC).
StartedAt string `json:"started_at,omitempty"`
// UptimeSeconds is seconds elapsed since process start.
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
}
// 编译时注入的版本信息
var (
Version = "unknown"
Edition = "standard"
CommitID = "unknown"
BuildTime = "unknown"
GoVersion = "unknown"
)
// GetSystemInfo godoc
// @Summary 获取系统信息
// @Description 获取系统版本、构建信息和引擎配置
// @Tags 系统
// @Accept json
// @Produce json
// @Success 200 {object} GetSystemInfoResponse "系统信息"
// @Router /system/info [get]
func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
// Get keyword index engine from RETRIEVE_DRIVER
keywordIndexEngine := h.getKeywordIndexEngine()
// Get vector store engine from config or RETRIEVE_DRIVER
vectorStoreEngine := h.getVectorStoreEngine()
// Get graph database engine from NEO4J_ENABLE
graphDatabaseEngine := h.getGraphDatabaseEngine()
// Get MinIO enabled status
minioEnabled := h.isMinioConfigured(c)
dbMigrationErr := database.CachedMigrationError()
var dbVersion string
if ver, dirty, ok := database.CachedMigrationVersion(); ok {
dbVersion = fmt.Sprintf("%d", ver)
if dirty {
dbVersion += " (dirty)"
}
if dbMigrationErr != "" {
dbVersion += " (failed)"
}
} else if dbMigrationErr != "" {
// Failure happened before m.Version() could be read (e.g. could not
// open the database). Still emit a placeholder so the frontend renders
// the row and shows the troubleshooting banner.
dbVersion = "unknown"
}
var startedAt string
var uptimeSec int64
if boot := runtime.ServerStartedAt(); !boot.IsZero() {
startedAt = boot.UTC().Format(time.RFC3339)
uptimeSec = int64(runtime.ServerUptime().Seconds())
}
response := GetSystemInfoResponse{
Version: Version,
Edition: Edition,
CommitID: CommitID,
BuildTime: BuildTime,
GoVersion: GoVersion,
KeywordIndexEngine: keywordIndexEngine,
VectorStoreEngine: vectorStoreEngine,
GraphDatabaseEngine: graphDatabaseEngine,
MinioEnabled: minioEnabled,
DBVersion: dbVersion,
DBMigrationError: dbMigrationErr,
StartedAt: startedAt,
UptimeSeconds: uptimeSec,
}
logger.Info(ctx, "System info retrieved successfully")
c.JSON(200, gin.H{
"code": 0,
"msg": "success",
"data": response,
})
}
func (h *SystemHandler) getDocReaderConnInfo() (addr, transport string) {
addr = strings.TrimSpace(os.Getenv("DOCREADER_ADDR"))
transport = strings.TrimSpace(os.Getenv("DOCREADER_TRANSPORT"))
if transport == "" {
transport = "grpc"
}
transport = strings.ToLower(transport)
return addr, transport
}
// ListParserEngines returns available document parser engines.
// Merges Go-native static engines with engines discovered from the remote
// docreader service, so newly added Python engines are auto-discovered.
// @Summary 列出可用的文档解析引擎
// @Tags 系统
// @Produce json
// @Success 200 {object} map[string]interface{} "解析引擎列表"
// @Router /system/parser-engines [get]
func (h *SystemHandler) ListParserEngines(c *gin.Context) {
var overrides map[string]string
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil {
if tenant.ParserEngineConfig != nil {
overrides = tenant.ParserEngineConfig.ToOverridesMap()
}
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
if overrides == nil {
overrides = make(map[string]string)
}
overrides["weknoracloud_app_id"] = creds.AppID
}
}
}
reader, docreaderAddr, docreaderTransport := h.resolveDocReader(c.Request.Context(), overrides)
connected := reader != nil && reader.IsConnected()
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), reader, overrides)
engines := docparser.ListAllEngines(connected, overrides, remoteEngines)
c.JSON(200, gin.H{"code": 0, "msg": "success", "data": engines, "docreader_addr": docreaderAddr, "docreader_transport": docreaderTransport, "connected": connected})
}
// ReconnectDocReader reconnects the document converter to a new (or same) DocReader address.
// @Summary 重连文档解析服务
// @Tags 系统
// @Accept json
// @Produce json
// @Param request body object{addr string} true "DocReader 地址"
// @Success 200
// @Router /system/docreader/reconnect [post]
func (h *SystemHandler) ReconnectDocReader(c *gin.Context) {
var req struct {
Addr string `json:"addr" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": 1, "msg": "请提供 addr 参数"})
return
}
addr := strings.TrimSpace(req.Addr)
if addr == "" {
c.JSON(400, gin.H{"code": 1, "msg": "addr 不能为空"})
return
}
// SSRF validation for docreader address
if err := secutils.ValidateURLForSSRF(addr); err != nil {
logger.Warnf(c.Request.Context(), "SSRF validation failed for docreader addr: %v", err)
c.JSON(400, gin.H{"code": 1, "msg": secutils.FormatSSRFError("DocReader 地址", addr, err)})
return
}
if h.documentReader == nil {
c.JSON(500, gin.H{"code": 1, "msg": "document converter not initialized"})
return
}
if err := h.documentReader.Reconnect(addr); err != nil {
logger.Errorf(c.Request.Context(), "Failed to reconnect docreader to %s: %v", addr, err)
c.JSON(200, gin.H{"code": 1, "msg": fmt.Sprintf("连接失败: %v", err)})
return
}
var overrides map[string]string
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil {
if tenant.ParserEngineConfig != nil {
overrides = tenant.ParserEngineConfig.ToOverridesMap()
}
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
if overrides == nil {
overrides = make(map[string]string)
}
overrides["weknoracloud_app_id"] = creds.AppID
}
}
}
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), h.documentReader, overrides)
engines := docparser.ListAllEngines(true, overrides, remoteEngines)
_, docreaderTransport := h.getDocReaderConnInfo()
c.JSON(200, gin.H{"code": 0, "msg": "连接成功", "data": engines, "docreader_addr": addr, "docreader_transport": docreaderTransport, "connected": true})
}
// CheckParserEngines runs availability check with the given config overrides (e.g. current form values).
// Used to test engine availability without saving; body shape matches ParserEngineConfig.
// @Summary 使用当前参数检测解析引擎可用性
// @Tags 系统
// @Accept json
// @Produce json
// @Param body body object true "解析引擎配置(与保存接口同结构)"
// @Success 200
// @Router /system/parser-engines/check [post]
func (h *SystemHandler) CheckParserEngines(c *gin.Context) {
var body types.ParserEngineConfig
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"code": 1, "msg": "请求体格式错误"})
return
}
var existing *types.ParserEngineConfig
var tenant *types.Tenant
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if t, ok := v.(*types.Tenant); ok && t != nil {
tenant = t
existing = t.ParserEngineConfig
}
}
merged := types.MergeParserEngineConfigForUpdate(&body, existing)
overrides := merged.ToOverridesMap()
if tenant != nil {
if creds := tenant.Credentials.GetWeKnoraCloud(); creds != nil {
if overrides == nil {
overrides = make(map[string]string)
}
overrides["weknoracloud_app_id"] = creds.AppID
}
}
reader, docreaderAddr, docreaderTransport := h.resolveDocReader(c.Request.Context(), overrides)
connected := reader != nil && reader.IsConnected()
remoteEngines := h.fetchRemoteEngines(c.Request.Context(), reader, overrides)
engines := docparser.ListAllEngines(connected, overrides, remoteEngines)
c.JSON(200, gin.H{"code": 0, "msg": "success", "data": engines, "docreader_addr": docreaderAddr, "docreader_transport": docreaderTransport, "connected": connected})
}
func (h *SystemHandler) resolveDocReader(ctx context.Context, overrides map[string]string) (interfaces.DocumentReader, string, string) {
if len(overrides) > 0 {
if addr := strings.TrimSpace(overrides["docreader_addr"]); addr != "" && service.IsWeKnoraCloudDocReaderAddr(addr) {
reader := h.ResolveDocumentReader(ctx, addr)
return reader, addr, transportFromDocReaderAddr(addr)
}
}
addr, transport := h.getDocReaderConnInfo()
return h.documentReader, addr, transport
}
func transportFromDocReaderAddr(addr string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(addr)), "https://") {
return "https"
}
return "http"
}
// fetchRemoteEngines queries the remote docreader for its engine list.
// Returns nil on any error (e.g. not connected), letting the caller
// fall back to Go's static registry only.
func (h *SystemHandler) fetchRemoteEngines(ctx context.Context, reader interfaces.DocumentReader, overrides map[string]string) []types.ParserEngineInfo {
if reader == nil || !reader.IsConnected() {
return nil
}
engines, err := reader.ListEngines(ctx, overrides)
if err != nil {
logger.Warnf(ctx, "Failed to fetch remote engines from docreader: %v", err)
return nil
}
return engines
}
// getKeywordIndexEngine returns the keyword index engine name
func (h *SystemHandler) getKeywordIndexEngine() string {
retrieveDriver := os.Getenv("RETRIEVE_DRIVER")
if retrieveDriver == "" {
return "未配置"
}
drivers := strings.Split(retrieveDriver, ",")
// Filter out engines that support keyword retrieval
keywordEngines := []string{}
for _, driver := range drivers {
driver = strings.TrimSpace(driver)
if h.supportsRetrieverType(driver, types.KeywordsRetrieverType) {
keywordEngines = append(keywordEngines, driver)
}
}
if len(keywordEngines) == 0 {
return "未配置"
}
return strings.Join(keywordEngines, ", ")
}
// getVectorStoreEngine returns the vector store engine name
func (h *SystemHandler) getVectorStoreEngine() string {
// First check config.yaml
if h.cfg != nil && h.cfg.VectorDatabase != nil && h.cfg.VectorDatabase.Driver != "" {
return h.cfg.VectorDatabase.Driver
}
// Fallback to RETRIEVE_DRIVER for vector support
retrieveDriver := os.Getenv("RETRIEVE_DRIVER")
if retrieveDriver == "" {
return "未配置"
}
drivers := strings.Split(retrieveDriver, ",")
// Filter out engines that support vector retrieval
vectorEngines := []string{}
for _, driver := range drivers {
driver = strings.TrimSpace(driver)
if h.supportsRetrieverType(driver, types.VectorRetrieverType) {
vectorEngines = append(vectorEngines, driver)
}
}
if len(vectorEngines) == 0 {
return "未配置"
}
return strings.Join(vectorEngines, ", ")
}
// getGraphDatabaseEngine returns the graph database engine name
func (h *SystemHandler) getGraphDatabaseEngine() string {
if h.neo4jDriver == nil {
return "Not Enabled"
}
return "Neo4j"
}
// supportsRetrieverType checks if a driver supports a specific retriever type
// by looking up the retrieverEngineMapping from types package
func (h *SystemHandler) supportsRetrieverType(driver string, retrieverType types.RetrieverType) bool {
// Get the mapping of all supported drivers and their capabilities
mapping := types.GetRetrieverEngineMapping()
// Check if the driver exists in the mapping
engines, exists := mapping[driver]
if !exists {
return false
}
// Check if any of the engine configurations support the requested retriever type
for _, engine := range engines {
if engine.RetrieverType == retrieverType {
return true
}
}
return false
}
// getMinioConfig resolves MinIO connection parameters from tenant config (if mode=remote) or env vars (mode=docker/default).
func (h *SystemHandler) getMinioConfig(c *gin.Context) (endpoint, accessKeyID, secretAccessKey string) {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.MinIO != nil {
m := tenant.StorageEngineConfig.MinIO
if m.Mode == "remote" {
return m.Endpoint, m.AccessKeyID, m.SecretAccessKey
}
}
}
endpoint = os.Getenv("MINIO_ENDPOINT")
accessKeyID = os.Getenv("MINIO_ACCESS_KEY_ID")
secretAccessKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
return
}
// activeBackendProviders returns the set of storage providers that have at
// least one active multi-instance backend registered for the caller's
// workspace. Used to keep GetStorageEngineStatus in sync with the new Storage
// settings UI, which writes to storage_backends rather than the legacy
// tenant.StorageEngineConfig singleton. Best-effort and nil-safe: a missing
// repo, missing tenant, or query error yields an empty set so callers fall
// back to the legacy config checks.
func (h *SystemHandler) activeBackendProviders(c *gin.Context) map[string]bool {
result := map[string]bool{}
if h.storageBackendRepo == nil {
return result
}
v, exists := c.Get(types.TenantInfoContextKey.String())
if !exists {
return result
}
tenant, ok := v.(*types.Tenant)
if !ok || tenant == nil {
return result
}
backends, err := h.storageBackendRepo.List(c.Request.Context(), tenant.ID)
if err != nil {
logger.Warnf(c.Request.Context(), "[storage] list backends for status failed: tenant=%d err=%v", tenant.ID, err)
return result
}
for _, backend := range backends {
if backend == nil || backend.Status != types.StorageBackendStatusActive {
continue
}
result[strings.ToLower(strings.TrimSpace(backend.Provider))] = true
}
return result
}
// isMinioConfigured checks whether MinIO connection info is available (from tenant config or env).
func (h *SystemHandler) isMinioConfigured(c *gin.Context) bool {
endpoint, accessKeyID, secretAccessKey := h.getMinioConfig(c)
return endpoint != "" && accessKeyID != "" && secretAccessKey != ""
}
// isMinioEnvAvailable checks whether MinIO env vars (MINIO_ENDPOINT etc.) are set.
func (h *SystemHandler) isMinioEnvAvailable() bool {
return os.Getenv("MINIO_ENDPOINT") != "" &&
os.Getenv("MINIO_ACCESS_KEY_ID") != "" &&
os.Getenv("MINIO_SECRET_ACCESS_KEY") != ""
}
// isCOSConfigured checks whether COS connection info is available from tenant config.
func (h *SystemHandler) isCOSConfigured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.COS != nil {
cosConf := tenant.StorageEngineConfig.COS
return cosConf.SecretID != "" && cosConf.SecretKey != "" && cosConf.Region != "" && cosConf.BucketName != ""
}
}
return false
}
// isTOSConfigured checks whether TOS connection info is available from tenant config or env.
func (h *SystemHandler) isTOSConfigured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.TOS != nil {
tosConf := tenant.StorageEngineConfig.TOS
return tosConf.Endpoint != "" && tosConf.Region != "" && tosConf.AccessKey != "" && tosConf.SecretKey != "" && tosConf.BucketName != ""
}
}
return h.isTOSEnvAvailable()
}
// isOSSConfigured checks whether OSS connection info is available from tenant config.
func (h *SystemHandler) isOSSConfigured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.OSS != nil {
ossConf := tenant.StorageEngineConfig.OSS
return ossConf.Endpoint != "" && ossConf.Region != "" && ossConf.AccessKey != "" && ossConf.SecretKey != "" && ossConf.BucketName != ""
}
}
return false
}
// isKS3Configured checks whether KS3 connection info is available from tenant config.
func (h *SystemHandler) isKS3Configured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.KS3 != nil {
ks3Conf := tenant.StorageEngineConfig.KS3
return ks3Conf.Endpoint != "" && ks3Conf.Region != "" && ks3Conf.AccessKey != "" && ks3Conf.SecretKey != "" && ks3Conf.BucketName != ""
}
}
return false
}
// isOBSConfigured checks whether OBS connection info is available from tenant config or env.
func (h *SystemHandler) isOBSConfigured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.OBS != nil {
obsConf := tenant.StorageEngineConfig.OBS
return obsConf.Endpoint != "" && obsConf.Region != "" && obsConf.AccessKey != "" && obsConf.SecretKey != "" && obsConf.BucketName != ""
}
}
return h.isOBSEnvAvailable()
}
// isTOSEnvAvailable checks whether TOS env vars are set.
func (h *SystemHandler) isTOSEnvAvailable() bool {
return os.Getenv("TOS_ENDPOINT") != "" &&
os.Getenv("TOS_REGION") != "" &&
os.Getenv("TOS_ACCESS_KEY") != "" &&
os.Getenv("TOS_SECRET_KEY") != "" &&
os.Getenv("TOS_BUCKET_NAME") != ""
}
// isOBSEnvAvailable checks whether OBS env vars are set.
func (h *SystemHandler) isOBSEnvAvailable() bool {
return os.Getenv("OBS_ENDPOINT") != "" &&
os.Getenv("OBS_REGION") != "" &&
os.Getenv("OBS_ACCESS_KEY") != "" &&
os.Getenv("OBS_SECRET_KEY") != "" &&
os.Getenv("OBS_BUCKET_NAME") != ""
}
// StorageEngineStatusItem describes one storage engine's availability and description.
type StorageEngineStatusItem struct {
Name string `json:"name"` // "local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
Allowed bool `json:"allowed"`
Available bool `json:"available"` // whether the engine can be used
Description string `json:"description"` // short description for UI
}
// GetStorageEngineStatusResponse is the response for GET /system/storage-engine-status.
type GetStorageEngineStatusResponse struct {
Engines []StorageEngineStatusItem `json:"engines"`
AllowedProviders []string `json:"allowed_providers"`
MinioEnvAvailable bool `json:"minio_env_available"`
}
// GetStorageEngineStatus godoc
// @Summary 获取存储引擎状态
// @Description 返回 Local、MinIO、COS 各存储引擎的可用状态及说明,供全局设置与知识库选择使用
// @Tags 系统
// @Produce json
// @Success 200 {object} GetStorageEngineStatusResponse
// @Router /system/storage-engine-status [get]
func (h *SystemHandler) GetStorageEngineStatus(c *gin.Context) {
// Providers that already have an active multi-instance backend registered
// (Settings → Storage). These are authoritative and independent of the
// legacy singleton tenant.StorageEngineConfig, so a workspace that only
// configured storage through the new UI is still reported as available.
activeBackend := h.activeBackendProviders(c)
minioConfigured := h.isMinioConfigured(c) || activeBackend["minio"]
minioEnvAvailable := h.isMinioEnvAvailable()
cosConfigured := h.isCOSConfigured(c) || activeBackend["cos"]
tosConfigured := h.isTOSConfigured(c) || activeBackend["tos"]
s3Configured := h.isS3Configured(c) || activeBackend["s3"]
ossConfigured := h.isOSSConfigured(c) || activeBackend["oss"]
ks3Configured := h.isKS3Configured(c) || activeBackend["ks3"]
obsConfigured := h.isOBSConfigured(c) || activeBackend["obs"]
allowed := getAllowedStorageProviders()
allowedProviders := make([]string, 0, len(getSupportedStorageProviders()))
for _, provider := range getSupportedStorageProviders() {
if allowed[provider] {
allowedProviders = append(allowedProviders, provider)
}
}
engines := []StorageEngineStatusItem{
{Name: "local", Allowed: allowed["local"], Available: true, Description: "本地文件系统存储,仅适合单机部署"},
{Name: "minio", Allowed: allowed["minio"], Available: minioConfigured || minioEnvAvailable, Description: "S3 兼容的自托管对象存储,适合内网和私有云部署"},
{Name: "cos", Allowed: allowed["cos"], Available: cosConfigured, Description: "腾讯云对象存储服务,适合公有云部署,支持 CDN 加速"},
{Name: "tos", Allowed: allowed["tos"], Available: tosConfigured, Description: "火山引擎对象存储服务,适合公有云部署"},
{Name: "s3", Allowed: allowed["s3"], Available: s3Configured, Description: "AWS S3 与兼容对象存储服务,适合公有云与混合云部署"},
{Name: "oss", Allowed: allowed["oss"], Available: ossConfigured, Description: "阿里云对象存储服务,适合公有云部署,支持 S3 兼容协议"},
{Name: "ks3", Allowed: allowed["ks3"], Available: ks3Configured, Description: "金山云对象存储服务,适合公有云部署"},
{Name: "obs", Allowed: allowed["obs"], Available: obsConfigured, Description: "华为云对象存储服务,适合公有云部署"},
}
c.JSON(200, gin.H{
"code": 0,
"msg": "success",
"data": GetStorageEngineStatusResponse{Engines: engines, AllowedProviders: allowedProviders, MinioEnvAvailable: minioEnvAvailable},
})
}
// --- Storage engine helpers ---
// cosFieldPattern validates COS region and bucket name format to prevent URL injection.
var cosFieldPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
// ossFieldPattern validates OSS region and bucket name format to prevent URL injection.
var ossFieldPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$`)
// sanitizeStorageCheckError converts a raw storage connectivity error into a safe
// user-facing message that does not leak internal network details (hostnames, IPs, ports).
// The concrete mapping lives in internal/utils so the service layer can share it.
func sanitizeStorageCheckError(err error) string {
return secutils.SanitizeStorageConnectivityError(err)
}
// storageEndpointHost extracts the hostname from a storage endpoint string.
// Endpoints may be bare host:port, hostnames, or full URLs with a scheme
// (e.g. "http://127.0.0.1:9000" for S3-compatible stores).
func storageEndpointHost(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if strings.Contains(endpoint, "://") {
if u, err := url.Parse(endpoint); err == nil {
if h := u.Hostname(); h != "" {
return h
}
}
}
if host, _, err := net.SplitHostPort(endpoint); err == nil {
return host
}
return endpoint
}
// isBlockedStorageEndpoint keeps the legacy handler contract while delegating
// to the same fail-closed SSRF policy used by the storage clients themselves.
// Private storage endpoints must be explicitly whitelisted by an operator.
func isBlockedStorageEndpoint(endpoint string) (bool, string) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return true, "无效的地址"
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return true, secutils.FormatSSRFError("存储 Endpoint", endpoint, err)
}
return false, ""
}
// --- Storage engine connectivity check ---
// StorageCheckRequest is the body for POST /system/storage-engine-check.
type StorageCheckRequest struct {
Provider string `json:"provider"` // "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
MinIO *types.MinIOEngineConfig `json:"minio,omitempty"`
COS *types.COSEngineConfig `json:"cos,omitempty"`
TOS *types.TOSEngineConfig `json:"tos,omitempty"`
S3 *types.S3EngineConfig `json:"s3,omitempty"`
OSS *types.OSSEngineConfig `json:"oss,omitempty"`
KS3 *types.KS3EngineConfig `json:"ks3,omitempty"`
OBS *types.OBSEngineConfig `json:"obs,omitempty"`
}
// StorageCheckResponse is the response for a single-engine connectivity check.
type StorageCheckResponse struct {
OK bool `json:"ok"`
Message string `json:"message"`
BucketCreated bool `json:"bucket_created,omitempty"`
}
// CheckStorageEngine tests connectivity for a single storage engine using the provided config.
// @Summary 测试存储引擎连通性
// @Description 使用当前填写的参数测试 MinIO/COS 连通性,不保存配置
// @Tags 系统
// @Accept json
// @Produce json
// @Param body body StorageCheckRequest true "存储引擎配置"
// @Success 200 {object} StorageCheckResponse
// @Router /system/storage-engine-check [post]
func (h *SystemHandler) CheckStorageEngine(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req StorageCheckRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": 1, "msg": "请求体格式错误"})
return
}
if !isStorageProviderAllowed(req.Provider) {
c.JSON(403, gin.H{"code": 1, "msg": "该存储引擎已被禁用"})
return
}
switch req.Provider {
case "minio":
h.checkMinio(c, ctx, req.MinIO)
case "cos":
h.checkCOS(c, ctx, req.COS)
case "tos":
h.checkTOS(c, ctx, req.TOS)
case "s3":
h.checkS3(c, ctx, req.S3)
case "oss":
h.checkOSS(c, ctx, req.OSS)
case "ks3":
h.checkKS3(c, ctx, req.KS3)
case "obs":
h.checkOBS(c, ctx, req.OBS)
default:
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: "本地存储无需检测"}})
}
}
func (h *SystemHandler) isS3Configured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.S3 != nil {
s3Conf := tenant.StorageEngineConfig.S3
return s3Conf.Region != "" && s3Conf.BucketName != "" && (s3Conf.AccessKey == "") == (s3Conf.SecretKey == "")
}
}
return false
}
func (h *SystemHandler) checkMinio(c *gin.Context, ctx context.Context, cfg *types.MinIOEngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 MinIO 配置"}})
return
}
if cfg.BucketName != "" && !cosFieldPattern.MatchString(cfg.BucketName) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
return
}
endpoint, accessKeyID, secretAccessKey := cfg.Endpoint, cfg.AccessKeyID, cfg.SecretAccessKey
if cfg.Mode != "remote" {
endpoint = os.Getenv("MINIO_ENDPOINT")
accessKeyID = os.Getenv("MINIO_ACCESS_KEY_ID")
secretAccessKey = os.Getenv("MINIO_SECRET_ACCESS_KEY")
}
if endpoint == "" || accessKeyID == "" || secretAccessKey == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Access Key、Secret Key 不能为空"}})
return
}
if cfg.Mode == "remote" {
if blocked, reason := isBlockedStorageEndpoint(endpoint); blocked {
logger.Warnf(ctx, "Storage check: MinIO endpoint blocked by SSRF protection, endpoint: %s", endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
}
err := file.CheckMinioConnectivity(ctx, endpoint, accessKeyID, secretAccessKey, cfg.BucketName, cfg.UseSSL)
if err != nil {
errMsg := err.Error()
// If bucket does not exist, auto-create it
if strings.Contains(errMsg, "does not exist") && cfg.BucketName != "" {
logger.Info(ctx, "Storage check: bucket does not exist, attempting auto-creation", "bucket", cfg.BucketName)
if _, mkErr := file.NewMinioFileService(endpoint, accessKeyID, secretAccessKey, cfg.BucketName, cfg.UseSSL); mkErr != nil {
logger.Error(ctx, "Storage check: failed to create bucket", "bucket", cfg.BucketName, "error", mkErr)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Failed to auto-create Bucket '%s': %s", cfg.BucketName, sanitizeStorageCheckError(mkErr))}})
return
}
logger.Info(ctx, "Storage check: bucket created", "bucket", cfg.BucketName)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, BucketCreated: true, Message: fmt.Sprintf("Bucket '%s' does not exist, and has been automatically created", cfg.BucketName)}})
return
}
logger.Error(ctx, "Storage check: MinIO connectivity failed", "error", err)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
msg := "连接成功"
if cfg.BucketName != "" {
msg = fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: msg}})
}
func (h *SystemHandler) checkCOS(c *gin.Context, ctx context.Context, cfg *types.COSEngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 COS 配置"}})
return
}
if cfg.SecretID == "" || cfg.SecretKey == "" || cfg.Region == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Secret ID、Secret Key、Region、Bucket 名称不能为空"}})
return
}
if !cosFieldPattern.MatchString(cfg.Region) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
return
}
if !cosFieldPattern.MatchString(cfg.BucketName) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
return
}
err := file.CheckCosConnectivity(ctx, cfg.BucketName, cfg.Region, cfg.SecretID, cfg.SecretKey)
if err != nil {
logger.Errorf(ctx, "Storage check: COS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Secret ID / Secret Key 是否正确"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) checkTOS(c *gin.Context, ctx context.Context, cfg *types.TOSEngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 TOS 配置"}})
return
}
if cfg.Endpoint == "" || cfg.Region == "" || cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
return
}
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
logger.Warnf(ctx, "Storage check: TOS endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
err := file.CheckTosConnectivity(ctx, cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey, cfg.BucketName)
if err != nil {
logger.Errorf(ctx, "Storage check: TOS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
return
}
if strings.Contains(errMsg, "404") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) checkS3(c *gin.Context, ctx context.Context, cfg *types.S3EngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 S3 配置"}})
return
}
if cfg.Region == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region、Bucket 名称不能为空"}})
return
}
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Access Key 与 Secret Key 必须同时填写或同时留空(使用 AWS 默认凭证链)"}})
return
}
if cfg.Endpoint != "" {
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
logger.Warnf(ctx, "Storage check: S3 endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
}
err := file.CheckS3Connectivity(ctx, cfg.Endpoint, cfg.AccessKey, cfg.SecretKey, cfg.BucketName, cfg.Region)
if err != nil {
logger.Errorf(ctx, "Storage check: S3 connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查静态密钥或 AWS IAM Role / 默认凭证链权限"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NotFound") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) checkOSS(c *gin.Context, ctx context.Context, cfg *types.OSSEngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 OSS 配置"}})
return
}
endpoint, accessKey, secretKey := cfg.Endpoint, cfg.AccessKey, cfg.SecretKey
if endpoint == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Access Key、Secret Key、Bucket Name 不能为空"}})
return
}
// Strip URL scheme before SSRF check — OSS endpoint may include http:// or https://
ssrfEndpoint := strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
if blocked, reason := isBlockedStorageEndpoint(ssrfEndpoint); blocked {
logger.Warnf(ctx, "Storage check: OSS endpoint blocked by SSRF protection, endpoint: %s", endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
if !ossFieldPattern.MatchString(cfg.Region) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
return
}
if !ossFieldPattern.MatchString(cfg.BucketName) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
return
}
err := file.CheckOssConnectivity(ctx, endpoint, cfg.Region, accessKey, secretKey, cfg.BucketName)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
logger.Errorf(ctx, "Storage check: OSS auth failed, endpoint: %s, bucket: %s", endpoint, cfg.BucketName)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
logger.Errorf(ctx, "Storage check: OSS bucket not found, bucket: %s", cfg.BucketName)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在", cfg.BucketName)}})
return
}
logger.Errorf(ctx, "Storage check: OSS connectivity failed, endpoint: %s, bucket: %s, error: %v", endpoint, cfg.BucketName, err)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("OSS 连通性检测失败: %s", sanitizeStorageCheckError(err))}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) checkKS3(c *gin.Context, ctx context.Context, cfg *types.KS3EngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 KS3 配置"}})
return
}
endpoint, region, accessKey, secretKey := cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey
if endpoint == "" || region == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
return
}
if blocked, reason := isBlockedStorageEndpoint(endpoint); blocked {
logger.Warnf(ctx, "Storage check: KS3 endpoint blocked by SSRF protection, endpoint: %s", endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
err := file.CheckKS3Connectivity(ctx, endpoint, region, accessKey, secretKey, cfg.BucketName)
if err != nil {
logger.Errorf(ctx, "Storage check: KS3 connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) checkOBS(c *gin.Context, ctx context.Context, cfg *types.OBSEngineConfig) {
if cfg == nil {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 OBS 配置"}})
return
}
endpoint, region, accessKey, secretKey := cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey
if endpoint == "" || region == "" || accessKey == "" || secretKey == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
return
}
ssrfEndpoint := strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
if blocked, reason := isBlockedStorageEndpoint(ssrfEndpoint); blocked {
logger.Warnf(ctx, "Storage check: OBS endpoint blocked by SSRF protection, endpoint: %s", endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
if !ossFieldPattern.MatchString(cfg.Region) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region 格式不正确,仅允许字母、数字、点、连字符"}})
return
}
if !ossFieldPattern.MatchString(cfg.BucketName) {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Bucket 名称格式不正确,仅允许字母、数字、点、连字符"}})
return
}
err := file.CheckObsConnectivity(ctx, endpoint, region, accessKey, secretKey, cfg.BucketName)
if err != nil {
logger.Errorf(ctx, "Storage check: OBS connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "AccessDenied") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NoSuchBucket") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: fmt.Sprintf("Bucket「%s」不存在,请检查名称和 Region", cfg.BucketName)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: sanitizeStorageCheckError(err)}})
return
}
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: true, Message: fmt.Sprintf("连接成功,Bucket「%s」已确认存在", cfg.BucketName)}})
}
func (h *SystemHandler) ResolveDocumentReader(ctx context.Context, addr string) interfaces.DocumentReader {
if addr == "" {
return h.documentReader
}
if service.IsWeKnoraCloudDocReaderAddr(addr) {
creds := h.tenantSvc.GetWeKnoraCloudCredentials(ctx)
if creds == nil {
return nil
}
reader, err := docparser.NewWeKnoraCloudSignedDocumentReader(creds.AppID, creds.AppSecret)
if err != nil {
return nil
}
return reader
}
reader, err := docparser.NewHTTPDocumentReader(addr)
if err != nil || reader == nil {
return reader
}
return reader
}
// PromoteUserToSystemAdminRequest defines the request for promoting a user to system admin.
//
// Either user_id (UUID) or email must be supplied. When both are present
// user_id wins — explicit IDs are unambiguous, an email collision (extremely
// rare in practice but possible during a tenant merge) would otherwise
// silently target the wrong row.
//
// We don't expose a `binding:"required"` tag on either field because gin's
// validator can't express the OR constraint; the handler does the check
// manually and returns 400 with a specific message for each branch.
type PromoteUserToSystemAdminRequest struct {
UserID string `json:"user_id"`
Email string `json:"email"`
}
// PromoteUserToSystemAdmin godoc
// @Summary Promote a user to system administrator
// @Description Grant system administrator privileges to a user (SystemAdmin only).
// @Description Idempotent: re-promoting an existing system admin returns 200 with no DB write.
// @Description Identify the user by email (preferred for human operators) or user_id (UUID, for API clients).
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body PromoteUserToSystemAdminRequest true "User promotion request"
// @Success 200 {object} types.UserInfo "User promoted successfully"
// @Failure 400 {object} map[string]interface{} "Bad request"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/promote [post]
func (h *SystemHandler) PromoteUserToSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req PromoteUserToSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
userID := strings.TrimSpace(req.UserID)
email := strings.TrimSpace(req.Email)
if userID == "" && email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Either user_id or email is required"})
return
}
// Resolve user. user_id takes priority when both are sent (see request
// struct doc); otherwise we look up by email. Both branches funnel
// into the same {nil-user / error} 404 so we don't leak whether a
// given email exists in the system to non-admins — though SystemAdmin
// is already a high-trust role, the parity keeps the surface clean.
var (
user *types.User
err error
)
switch {
case userID != "":
user, err = h.userSvc.GetUserByID(ctx, userID)
default:
user, err = h.userSvc.GetUserByEmail(ctx, email)
}
if err != nil {
logger.Errorf(ctx, "Error fetching user (id=%q email=%q): %v", userID, email, err)
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if user.IsSystemAdmin {
// Idempotent: re-promoting an existing system admin is a no-op
// success. We still emit an audit row so probing the endpoint
// leaves a forensic trail (idempotent=true marks it as noop).
h.emitAdminAudit(ctx, types.AuditActionSystemAdminPromoted, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"idempotent": true,
})
c.JSON(http.StatusOK, user.ToUserInfo())
return
}
user.IsSystemAdmin = true
if err := h.userSvc.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "Error promoting user %s to system admin: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"})
return
}
logger.Infof(ctx, "User %s (ID: %s) promoted to system admin", user.Username, user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemAdminPromoted, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"idempotent": false,
})
c.JSON(http.StatusOK, user.ToUserInfo())
}
// RevokeSystemAdminRequest defines the request for revoking system admin privileges
type RevokeSystemAdminRequest struct {
UserID string `json:"user_id" binding:"required"`
}
// RevokeSystemAdmin godoc
// @Summary Revoke system administrator privileges from a user
// @Description Remove system administrator privileges from a user (SystemAdmin only).
// @Description Two safety guards: the caller cannot revoke their own privileges,
// @Description and revoking the last remaining system admin is rejected — both
// @Description prevent a SystemAdmin from accidentally locking the platform out
// @Description of system-level administration. Idempotent on already-non-admin users.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body RevokeSystemAdminRequest true "User revocation request"
// @Success 200 {object} types.UserInfo "Privileges revoked successfully"
// @Failure 400 {object} map[string]interface{} "Bad request / would remove last admin / self-revoke"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/revoke [post]
func (h *SystemHandler) RevokeSystemAdmin(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req RevokeSystemAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
callerID, _ := types.UserIDFromContext(ctx)
user, err := h.userSvc.RevokeSystemAdmin(ctx, req.UserID, callerID)
switch {
case err == nil:
// Real revoke — privileges were actually removed.
logger.Infof(ctx, "System admin privileges revoked from user %s (ID: %s)", user.Username, user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemAdminRevoked, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"changed": true,
})
c.JSON(http.StatusOK, user.ToUserInfo())
return
case errors.Is(err, repository.ErrUserNotSystemAdmin):
// Idempotent: target was already not an admin. Return 200 so
// callers that re-issue the request after a partial failure
// don't get a confusing 4xx, but mark `changed=false` in the
// audit row so a forensic reader can tell a real revoke from a
// noop probe.
if user == nil {
logger.Errorf(ctx, "RevokeSystemAdmin returned nil user with ErrUserNotSystemAdmin for %s", req.UserID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
return
}
logger.Infof(ctx, "Revoke noop (user %s was not a system admin)", user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemAdminRevoked, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"changed": false,
})
c.JSON(http.StatusOK, user.ToUserInfo())
return
case errors.Is(err, repository.ErrCannotRevokeSelf):
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke your own system admin privileges",
})
return
case errors.Is(err, repository.ErrLastSystemAdmin):
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot revoke the last remaining system administrator",
})
return
case errors.Is(err, repository.ErrUserNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
default:
logger.Errorf(ctx, "Error revoking system admin from user %s: %v", req.UserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke system admin privileges"})
return
}
}
// ListSystemAdminsResponse defines the response structure for listing system admins.
// Total reflects the underlying COUNT(*), not just the page size, so the front
// end can render pagination metadata without a follow-up call.
type ListSystemAdminsResponse struct {
Total int64 `json:"total"`
Admins []*types.UserInfo `json:"admins"`
}
// ListSystemAdmins godoc
// @Summary List all system administrators
// @Description Retrieve a paginated list of users with system administrator
// @Description privileges (SystemAdmin only). Supports `offset` (default 0)
// @Description and `limit` (default 50, max 200) query parameters. Walks the
// @Description partial-friendly idx_users_is_system_admin index.
// @Tags System Admin
// @Produce json
// @Param offset query int false "Page offset" default(0)
// @Param limit query int false "Page size (max 200)" default(50)
// @Success 200 {object} ListSystemAdminsResponse "System admins retrieved successfully"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/list [get]
func (h *SystemHandler) ListSystemAdmins(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
// Best-effort pagination parsing — a malformed `limit=foo` falls back
// to defaults rather than 400-ing, since the call is still safe and a
// failed-page is more user-hostile than a soft default.
offset := 0
limit := 50
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// Cap so a client can't ask for the entire table.
if limit > 200 {
limit = 200
}
users, total, err := h.userSvc.ListSystemAdmins(ctx, offset, limit)
if err != nil {
logger.Errorf(ctx, "Error listing system admins: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system admins"})
return
}
// Always emit a non-nil slice so the JSON serialises to `[]` rather
// than `null` for an empty page — front-end iteration is safer.
infos := make([]*types.UserInfo, 0, len(users))
for _, u := range users {
infos = append(infos, u.ToUserInfo())
}
c.JSON(http.StatusOK, ListSystemAdminsResponse{
Total: total,
Admins: infos,
})
}
// ResetUserPasswordRequest defines the system-administrator password-reset
// payload. Email is intentionally the only user-facing identifier: the UI is
// an operator tool and emails are easier to verify than UUIDs. The password is
// never written to logs or audit details.
type ResetUserPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
NewPassword string `json:"new_password" binding:"required"`
}
// ResetUserPassword godoc
// @Summary Reset another user's password
// @Description Replace another user's local password and revoke all of their existing sessions (SystemAdmin only).
// @Description A system administrator cannot reset their own password through this endpoint; self-service password change still requires the old password.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body ResetUserPasswordRequest true "Password reset request"
// @Success 200 {object} map[string]interface{} "Password reset successfully"
// @Failure 400 {object} map[string]interface{} "Invalid request, weak password, or self reset"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 404 {object} map[string]interface{} "User not found"
// @Router /system/admin/users/reset-password [post]
func (h *SystemHandler) ResetUserPassword(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req ResetUserPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid password reset request"})
return
}
req.Email = strings.TrimSpace(req.Email)
if err := service.ValidatePasswordPolicy(req.NewPassword, h.complexPasswordEnabled(ctx)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userSvc.GetUserByEmail(ctx, req.Email)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
callerID, _ := types.UserIDFromContext(ctx)
if callerID == user.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot reset your own password here"})
return
}
if err := h.userSvc.AdminResetPassword(ctx, user.ID, req.NewPassword); err != nil {
if service.IsPasswordPolicyError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logger.Errorf(ctx, "Failed to reset password for user %s: %v", user.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset user password"})
return
}
logger.Infof(ctx, "Password reset by system administrator for user ID: %s", user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemUserPasswordReset, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"sessions_revoked": true,
})
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
}
// CreateSystemUserResponse is the payload returned by
// POST /api/v1/system/admin/users/create. GeneratedPassword is populated
// exactly once, only when the server minted a random password (`password`
// omitted or null), and is never logged, audited, or retrievable again.
type CreateSystemUserResponse struct {
User *types.UserInfo `json:"user"`
// GeneratedPassword is the plaintext password when the server
// auto-generated one. Absent when the caller supplied the password.
GeneratedPassword string `json:"generated_password,omitempty"`
}
// CreateSystemUser godoc
// @Summary Create a new user (SystemAdmin)
// @Description Provision a new local user account (SystemAdmin only).
// @Description When `password` is omitted or null, a cryptographically random
// @Description password is generated (OIDC-style crypto/rand + base64url)
// @Description and returned once in the response body. Any provided value,
// @Description including empty string, is policy-checked. Tenant provisioning
// @Description follows the shared auth.default_tenant_mode policy.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param request body types.AdminCreateUserRequest true "User creation request"
// @Success 201 {object} CreateSystemUserResponse "User created successfully"
// @Success 200 {object} CreateSystemUserResponse "Identity already exists, returns the existing user"
// @Failure 400 {object} map[string]interface{} "Invalid request or weak password"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Failure 409 {object} map[string]interface{} "Email and username refer to conflicting identities"
// @Failure 500 {object} map[string]interface{} "Internal error"
// @Router /system/admin/users/create [post]
func (h *SystemHandler) CreateSystemUser(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req types.AdminCreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user creation request"})
return
}
req.Username = secutils.SanitizeForLog(strings.TrimSpace(req.Username))
req.Email = secutils.SanitizeForLog(strings.TrimSpace(req.Email))
// Password is intentionally NOT trimmed or sanitized.
if req.Username == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username and email are required"})
return
}
// Binding's min=2/max=50 ran on the raw JSON, re-check the trimmed value.
if n := utf8.RuneCountInString(req.Username); n < 2 || n > 50 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username must be 2-50 characters"})
return
}
user, generatedPassword, err := h.userSvc.AdminCreateUser(ctx, &req, h.resolveDefaultTenantMode(ctx))
if err != nil {
switch {
case errors.Is(err, service.ErrUserEmailExists) || errors.Is(err, service.ErrUserUsernameExists):
if user == nil {
logger.Errorf(ctx, "Duplicate identity error without a resolved user: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
logger.Infof(ctx, "Create user noop (identity already exists, ID: %s)", user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemUserCreated, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"password_generated": false,
"idempotent": true,
})
c.JSON(http.StatusOK, CreateSystemUserResponse{User: user.ToUserInfo()})
case errors.Is(err, service.ErrPasswordPolicy):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrUserIdentityConflict):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
logger.Errorf(ctx, "Failed to create user: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
}
return
}
logger.Infof(ctx, "System admin created user %s (ID: %s)", user.Username, user.ID)
h.emitAdminAudit(ctx, types.AuditActionSystemUserCreated, user, map[string]any{
"target_email": user.Email,
"target_username": user.Username,
"password_generated": generatedPassword != "",
"idempotent": false,
})
c.JSON(http.StatusCreated, CreateSystemUserResponse{
User: user.ToUserInfo(),
GeneratedPassword: generatedPassword,
})
}
// ============================================================================
// System Settings (P1)
// ----------------------------------------------------------------------------
// Endpoints below are mounted under /api/v1/system/admin/settings*, all
// gated to SystemAdmin via the route group's middleware. Every response
// is the raw model — no `gin.H{"data": ...}` wrapping — to match the
// project's axios interceptor contract (response.data is unwrapped at the
// HTTP layer; see frontend/src/utils/request.ts:97). The P0 ListSystemAdmins
// already follows this; do not break the convention.
// ============================================================================
// ListSystemSettings godoc
// @Summary List all system settings
// @Description Return every row in the system_settings table (system-scope,
// @Description not workspace-scoped). SystemAdmin only.
// @Tags System Admin
// @Produce json
// @Success 200 {array} types.SystemSetting "list of settings"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings [get]
// RuntimeQueuesResponse is the payload for the SystemAdmin runtime queue
// dashboard. `available` is false in Lite mode (no Redis/asynq) so the
// UI can render an "unavailable in this deployment" state instead of an
// empty table. Each pool reports configured per-process concurrency plus live
// cluster capacity/active workers aggregated from asynq server heartbeats.
type RuntimeWorkerPool struct {
Name string `json:"name"`
Concurrency int `json:"concurrency"` // configured per instance
QueueCount int `json:"queue_count"`
Instances int `json:"instances"` // live asynq server heartbeats
ClusterCapacity int `json:"cluster_capacity"` // sum of live server concurrency
Active int `json:"active"` // live workers assigned to this pool
Utilization float64 `json:"utilization"` // Active / ClusterCapacity
}
type RuntimeQueuesResponse struct {
Available bool `json:"available"`
UpstreamConcurrency int `json:"upstream_concurrency"`
ParseConcurrency int `json:"parse_concurrency"` // compatibility alias for upstream_concurrency
WikiConcurrency int `json:"wiki_concurrency"` // compatibility field
Pools []RuntimeWorkerPool `json:"pools"`
Queues []types.QueueStat `json:"queues"`
ModelLimiterAvailable bool `json:"model_limiter_available"`
Models []modellimiter.RuntimeStat `json:"models"`
Timestamp int64 `json:"timestamp"`
}
func aggregateRuntimeWorkerPools(pools []RuntimeWorkerPool, servers []types.WorkerServerStat) {
queueConfigs := map[string]map[string]int{
types.WorkerPoolCore: types.QueueWeightsForPool(types.WorkerPoolCore),
types.WorkerPoolPostProcess: types.QueueWeightsForPool(types.WorkerPoolPostProcess),
types.WorkerPoolEnrichment: types.QueueWeightsForPool(types.WorkerPoolEnrichment),
types.WorkerPoolMaintenance: types.QueueWeightsForPool(types.WorkerPoolMaintenance),
types.WorkerPoolShared: types.QueueWeightsForSharedPool(),
types.WorkerPoolWiki: types.QueueWeightsForPool(types.WorkerPoolWiki),
}
indexes := make(map[string]int, len(pools))
for i := range pools {
indexes[pools[i].Name] = i
}
for _, server := range servers {
if server.Status != "active" {
continue
}
for poolName, expectedQueues := range queueConfigs {
if !sameQueueWeights(server.Queues, expectedQueues) {
continue
}
idx, ok := indexes[poolName]
if !ok {
break
}
pools[idx].Instances++
pools[idx].ClusterCapacity += server.Concurrency
pools[idx].Active += server.Active
break
}
}
for i := range pools {
if pools[i].ClusterCapacity > 0 {
pools[i].Utilization = float64(pools[i].Active) / float64(pools[i].ClusterCapacity)
}
}
}
func sameQueueWeights(left, right map[string]int) bool {
if len(left) != len(right) {
return false
}
for name, weight := range left {
if right[name] != weight {
return false
}
}
return true
}
// GetRuntimeQueues godoc
// @Summary 获取解析任务队列运行时状态
// @Description 返回各 asynq 队列的实时深度(pending/active/scheduled/retry 等)与 worker 并发配置,仅系统管理员可见
// @Tags 系统管理
// @Produce json
// @Success 200 {object} RuntimeQueuesResponse
// @Router /system/admin/runtime/queues [get]
func (h *SystemHandler) GetRuntimeQueues(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var allocation types.WorkerPoolConcurrency
if h.systemSettingSvc != nil {
allocation = types.ResolveWorkerPoolConcurrency(func(key, env string, fallback int) int {
configured := h.systemSettingSvc.GetInt(ctx, key, env, int64(fallback))
return int(configured)
})
} else {
allocation = types.DefaultWorkerPoolConcurrency()
}
queueCounts := make(map[string]int)
for _, definition := range types.QueueDefinitions() {
queueCounts[definition.Pool]++
}
resp := RuntimeQueuesResponse{
UpstreamConcurrency: allocation.UpstreamTotal(),
ParseConcurrency: allocation.UpstreamTotal(),
WikiConcurrency: allocation.Wiki,
Pools: []RuntimeWorkerPool{
{Name: types.WorkerPoolCore, Concurrency: allocation.Core, QueueCount: queueCounts[types.WorkerPoolCore]},
{Name: types.WorkerPoolPostProcess, Concurrency: allocation.PostProcess, QueueCount: queueCounts[types.WorkerPoolPostProcess]},
{Name: types.WorkerPoolEnrichment, Concurrency: allocation.Enrichment, QueueCount: queueCounts[types.WorkerPoolEnrichment]},
{Name: types.WorkerPoolMaintenance, Concurrency: allocation.Maintenance, QueueCount: queueCounts[types.WorkerPoolMaintenance]},
{Name: types.WorkerPoolShared, Concurrency: allocation.Shared, QueueCount: len(types.QueueWeightsForSharedPool())},
{Name: types.WorkerPoolWiki, Concurrency: allocation.Wiki, QueueCount: queueCounts[types.WorkerPoolWiki]},
},
Timestamp: time.Now().Unix(),
}
if h.taskInspector != nil {
stats, supported, err := h.taskInspector.QueueStats(ctx)
if err != nil {
logger.Errorf(ctx, "get queue stats failed: %v", err)
}
resp.Available = supported
if stats != nil {
resp.Queues = stats
}
serverStats, _, serverErr := h.taskInspector.WorkerServerStats(ctx)
if serverErr != nil {
logger.Errorf(ctx, "get worker server stats failed: %v", serverErr)
} else {
aggregateRuntimeWorkerPools(resp.Pools, serverStats)
}
}
// Always emit a non-nil array so the JSON serialises to `[]` rather
// than `null`, keeping the frontend iteration simple.
if resp.Queues == nil {
resp.Queues = []types.QueueStat{}
}
modelStats, modelSupported, modelErr := modellimiter.RuntimeStats(ctx)
if modelErr != nil {
logger.Errorf(ctx, "get model concurrency stats failed: %v", modelErr)
}
resp.ModelLimiterAvailable = modelSupported
resp.Models = modelStats
c.JSON(http.StatusOK, resp)
}
type RuntimeTasksResponse struct {
Available bool `json:"available"`
Tasks []types.RuntimeTaskInfo `json:"tasks"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
func isKnownRuntimeQueue(name string) bool {
for _, definition := range types.QueueDefinitions() {
if definition.Name == name {
return true
}
}
return false
}
func runtimeTaskPageSize(c *gin.Context) int {
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return pageSize
}
func (h *SystemHandler) listRuntimeTasks(c *gin.Context, state types.RuntimeTaskState) {
queue := c.Param("queue")
if !isKnownRuntimeQueue(queue) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task queue"})
return
}
if !state.Valid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task state"})
return
}
pageSize := runtimeTaskPageSize(c)
cursor := c.Query("cursor")
inspector, ok := h.taskInspector.(interfaces.RuntimeTaskInspector)
if !ok {
c.JSON(http.StatusOK, RuntimeTasksResponse{
Available: false,
Tasks: []types.RuntimeTaskInfo{},
PageSize: pageSize,
})
return
}
page, supported, err := inspector.ListRuntimeTasks(c.Request.Context(), queue, state, cursor, pageSize)
if err != nil {
if errors.Is(err, types.ErrInvalidRuntimeTaskCursor) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid task list cursor",
"code": "runtime_task_cursor_invalid",
})
return
}
if errors.Is(err, types.ErrExpiredRuntimeTaskCursor) {
c.JSON(http.StatusConflict, gin.H{
"error": "Task list changed; refresh from the beginning",
"code": "runtime_task_cursor_expired",
})
return
}
logger.Errorf(c.Request.Context(), "list runtime queue tasks queue=%s state=%s: %v", queue, state, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list queue tasks"})
return
}
if page.Tasks == nil {
page.Tasks = []types.RuntimeTaskInfo{}
}
c.JSON(http.StatusOK, RuntimeTasksResponse{
Available: supported,
Tasks: page.Tasks,
PageSize: pageSize,
HasMore: page.HasMore,
NextCursor: page.NextCursor,
})
}
// ListRuntimeTasks returns one live task-state page for the unified operator
// drawer. Raw task payloads are never included.
// @Summary List runtime queue tasks by state
// @Tags System Admin
// @Produce json
// @Param queue path string true "Queue name"
// @Param state query string true "Task state" Enums(pending,active,scheduled,retry,archived,completed)
// @Param cursor query string false "Opaque continuation cursor"
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} RuntimeTasksResponse
// @Router /system/admin/runtime/queues/{queue}/tasks [get]
func (h *SystemHandler) ListRuntimeTasks(c *gin.Context) {
h.listRuntimeTasks(c, types.RuntimeTaskState(c.Query("state")))
}
func (h *SystemHandler) emitQueueTaskAudit(
ctx context.Context,
action types.AuditAction,
queue, taskID string,
extra map[string]string,
) {
if h.auditSvc == nil {
return
}
actorID, _ := types.UserIDFromContext(ctx)
detailMap := map[string]string{"queue": queue, "task_id": taskID}
for key, value := range extra {
detailMap[key] = value
}
details, _ := json.Marshal(detailMap)
targetType := "queue_task"
targetID := taskID
if targetID == "" {
targetType = "task_queue"
targetID = queue
}
_ = h.auditSvc.Log(ctx, &types.AuditLog{
TenantID: 0,
ActorUserID: actorID,
ActorRole: systemAuditActorRole(ctx),
Action: action,
TargetType: targetType,
TargetID: targetID,
Outcome: types.AuditOutcomeSuccess,
Details: types.JSON(details),
})
}
func runtimeTaskParams(c *gin.Context) (string, string, bool) {
queue := c.Param("queue")
taskID := c.Param("task_id")
if !isKnownRuntimeQueue(queue) || taskID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid queue or task ID"})
return "", "", false
}
return queue, taskID, true
}
func (h *SystemHandler) mutateRuntimeTask(c *gin.Context, action types.RuntimeTaskAction) {
queue, taskID, ok := runtimeTaskParams(c)
if !ok {
return
}
inspector, supported := h.taskInspector.(interfaces.RuntimeTaskInspector)
if !supported {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
task, available, err := inspector.GetRuntimeTask(c.Request.Context(), queue, taskID)
if !available {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
if err != nil || task == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available"})
return
}
if !task.Allows(action) {
c.JSON(http.StatusConflict, gin.H{"error": "Action is not allowed for the current task state"})
return
}
var auditAction types.AuditAction
auditDetails := map[string]string{
"task_type": task.Type,
"from_state": string(task.State),
"action": string(action),
}
switch action {
case types.RuntimeTaskActionCancel:
if h.knowledgeSvc == nil || task.TenantID == 0 || task.KnowledgeID == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Business cancellation is unavailable"})
return
}
cancelCtx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, task.TenantID)
if _, err = h.knowledgeSvc.CancelKnowledgeParse(cancelCtx, task.KnowledgeID); err != nil {
if isKnowledgeGone(err) {
if h.purgeOrphanRuntimeTask(c.Request.Context(), inspector, queue, taskID, task.KnowledgeID) {
logger.Warnf(c.Request.Context(),
"cancel runtime queue task queue=%s task=%s: knowledge gone, purged queue record",
queue, taskID)
auditDetails["orphan_purge"] = "true"
auditAction = types.AuditActionSystemQueueTaskCancelled
break
}
}
logger.Errorf(c.Request.Context(), "cancel runtime queue task queue=%s task=%s: %v", queue, taskID, err)
c.JSON(http.StatusConflict, gin.H{"error": "Task can no longer be cancelled"})
return
}
auditAction = types.AuditActionSystemQueueTaskCancelled
case types.RuntimeTaskActionRunNow:
available, err = inspector.RunRuntimeTask(c.Request.Context(), queue, taskID)
if !available {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
if err != nil {
logger.Errorf(c.Request.Context(), "run queue task now queue=%s task=%s: %v", queue, taskID, err)
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available to run"})
return
}
auditAction = types.AuditActionSystemQueueTaskRunNow
case types.RuntimeTaskActionDelete:
available, err = inspector.DeleteRuntimeTask(c.Request.Context(), queue, taskID)
if !available {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
if err != nil {
logger.Errorf(c.Request.Context(), "delete queue task queue=%s task=%s: %v", queue, taskID, err)
c.JSON(http.StatusConflict, gin.H{"error": "Task is no longer available for deletion"})
return
}
auditAction = types.AuditActionSystemQueueTaskDeleted
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown task action"})
return
}
h.emitQueueTaskAudit(c.Request.Context(), auditAction, queue, taskID, auditDetails)
c.JSON(http.StatusOK, gin.H{"success": true})
}
func isKnowledgeGone(err error) bool {
if errors.Is(err, repository.ErrKnowledgeNotFound) {
return true
}
var appErr *apperrors.AppError
return errors.As(err, &appErr) && appErr.Code == apperrors.ErrNotFound
}
// purgeOrphanRuntimeTask removes queue records for a task whose knowledge row
// is already gone. CancelTasksForKnowledge sweeps sibling tasks best-effort;
// success is reported only once the requested task ID is gone.
func (h *SystemHandler) purgeOrphanRuntimeTask(
ctx context.Context,
inspector interfaces.RuntimeTaskInspector,
queue, taskID, knowledgeID string,
) bool {
if h.taskInspector != nil && knowledgeID != "" {
if _, _, err := h.taskInspector.CancelTasksForKnowledge(ctx, knowledgeID); err != nil {
logger.Warnf(ctx, "purge orphan tasks for knowledge %s: %v", knowledgeID, err)
}
}
supported, err := inspector.ForceDeleteRuntimeTask(ctx, queue, taskID)
if !supported {
return false
}
if err == nil {
return true
}
// The knowledge-wide sweep may have already removed this task ID.
if _, available, getErr := inspector.GetRuntimeTask(ctx, queue, taskID); available && getErr != nil {
return true
}
logger.Warnf(ctx, "force delete orphan task queue=%s task=%s: %v", queue, taskID, err)
return false
}
// MutateRuntimeTask executes a backend-advertised action after re-reading the
// current task state to close click/race windows.
// @Summary Run a safe runtime task action
// @Tags System Admin
// @Produce json
// @Param queue path string true "Queue name"
// @Param task_id path string true "Task ID"
// @Param action path string true "Action" Enums(cancel,run_now,delete)
// @Success 200 {object} map[string]bool
// @Router /system/admin/runtime/queues/{queue}/tasks/{task_id}/actions/{action} [post]
func (h *SystemHandler) MutateRuntimeTask(c *gin.Context) {
h.mutateRuntimeTask(c, types.RuntimeTaskAction(c.Param("action")))
}
// PurgeArchivedRuntimeTasks clears every archived (finally-failed) task in one
// queue. It only touches the archived dead-letter set — live pending/active/
// scheduled/retry tasks are untouched — and mirrors single-record delete
// semantics by leaving business state as-is (archived tasks already had their
// document status flipped to "failed" on their last retry).
// @Summary Purge all archived tasks in a queue
// @Tags System Admin
// @Produce json
// @Param queue path string true "Queue name"
// @Success 200 {object} map[string]interface{}
// @Router /system/admin/runtime/queues/{queue}/archived [delete]
func (h *SystemHandler) PurgeArchivedRuntimeTasks(c *gin.Context) {
queue := c.Param("queue")
if !isKnownRuntimeQueue(queue) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid queue"})
return
}
inspector, supported := h.taskInspector.(interfaces.RuntimeTaskInspector)
if !supported {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
deleted, available, err := inspector.PurgeArchivedRuntimeTasks(c.Request.Context(), queue)
if !available {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Task queue is unavailable"})
return
}
if err != nil {
logger.Errorf(c.Request.Context(), "purge archived queue tasks queue=%s: %v", queue, err)
c.JSON(http.StatusConflict, gin.H{"error": "Archived tasks could not be purged"})
return
}
h.emitQueueTaskAudit(c.Request.Context(), types.AuditActionSystemQueueArchivedPurged, queue, "",
map[string]string{"deleted": strconv.Itoa(deleted)})
c.JSON(http.StatusOK, gin.H{"success": true, "deleted": deleted})
}
func (h *SystemHandler) ListSystemSettings(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
rows, err := h.systemSettingSvc.List(ctx)
if err != nil {
logger.Errorf(ctx, "list system settings failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list system settings"})
return
}
if rows == nil {
// Always emit a non-nil array so the JSON serialises to `[]`
// rather than `null` on an empty table — front-end iteration
// is safer.
rows = []*types.SystemSetting{}
}
h.enrichSettingsModifiedBy(ctx, rows)
c.JSON(http.StatusOK, rows)
}
// enrichSettingsModifiedBy resolves LastModifiedBy (UUID) → display name
// in a single batch lookup. Failures degrade silently: the UI already
// falls back to the UUID prefix when the name is empty, so a transient
// userSvc error must not break the settings page entirely.
func (h *SystemHandler) enrichSettingsModifiedBy(ctx context.Context, rows []*types.SystemSetting) {
if len(rows) == 0 || h.userSvc == nil {
return
}
idSet := make(map[string]struct{}, len(rows))
for _, r := range rows {
if r != nil && strings.TrimSpace(r.LastModifiedBy) != "" {
idSet[r.LastModifiedBy] = struct{}{}
}
}
if len(idSet) == 0 {
return
}
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
users, err := h.userSvc.GetUsersByIDs(ctx, ids)
if err != nil {
logger.Warnf(ctx, "enrichSettingsModifiedBy: GetUsersByIDs failed: %v", err)
return
}
for _, r := range rows {
if r == nil {
continue
}
u, ok := users[r.LastModifiedBy]
if !ok || u == nil {
continue
}
// Username is the canonical display label; fall back to email
// for older rows where username may be empty (legacy seeded
// admins). Both empty → leave LastModifiedByName empty so the
// UI's UUID-prefix fallback kicks in.
switch {
case strings.TrimSpace(u.Username) != "":
r.LastModifiedByName = u.Username
case strings.TrimSpace(u.Email) != "":
r.LastModifiedByName = u.Email
}
}
}
// GetSystemSetting godoc
// @Summary Get a single system setting by key
// @Description Returns the row matching :key. 404 when the key is unknown
// @Description to the registry; 200 with the row when known.
// @Tags System Admin
// @Produce json
// @Param key path string true "Setting key (e.g. file.max_size_mb)"
// @Success 200 {object} types.SystemSetting "the setting row"
// @Failure 400 {object} map[string]interface{} "Unknown key"
// @Failure 404 {object} map[string]interface{} "Key registered but DB row absent"
// @Router /system/admin/settings/{key} [get]
func (h *SystemHandler) GetSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
row, err := h.systemSettingSvc.Get(ctx, key)
if err != nil {
// Service-layer "unknown key" surfaces as a generic error here;
// distinguish via the error string rather than typed errors so
// we don't grow a sentinel package for a single error class.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "setting not yet persisted"})
return
}
c.JSON(http.StatusOK, row)
}
// UpdateSystemSettingRequest is the body for PUT /system/admin/settings/:key.
// `value` carries the new value as raw JSON — int / string / bool depending
// on the registry-declared value_type. The service validates the type
// strictly and rejects mismatches with 400.
type UpdateSystemSettingRequest struct {
// Value is intentionally `any` (decoded as float64 / string / bool /
// etc. by the JSON unmarshaller). Service.encodeForType normalises
// these against the registry's declared type and rejects mismatches.
Value any `json:"value"`
}
// UpdateSystemSetting godoc
// @Summary Update a system setting value
// @Description Persist a new value for :key. Service validates the
// @Description rawValue against the registry's declared value_type and
// @Description rejects mismatches with 400. SystemAdmin only. Emits an
// @Description audit row (action=system.setting_changed) on success.
// @Tags System Admin
// @Accept json
// @Produce json
// @Param key path string true "Setting key"
// @Param request body UpdateSystemSettingRequest true "New value"
// @Success 200 {object} types.SystemSetting "the updated row"
// @Failure 400 {object} map[string]interface{} "Bad request / unknown key / type mismatch"
// @Failure 403 {object} map[string]interface{} "Forbidden: not a system admin"
// @Router /system/admin/settings/{key} [put]
func (h *SystemHandler) UpdateSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
var req UpdateSystemSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if req.Value == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "value is required"})
return
}
row, err := h.systemSettingSvc.Update(ctx, key, req.Value)
if err != nil {
// Whether this is "unknown key" / "type mismatch" / "DB error"
// is encoded in the error message at the service layer; surface
// it verbatim. UI captures it as the toast text.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.enrichSettingsModifiedBy(ctx, []*types.SystemSetting{row})
c.JSON(http.StatusOK, row)
}
// ApplyDefaultStorageQuotaToAllTenants godoc
// @Summary Apply the default storage quota to every existing workspace
// @Description Reads the current value of `tenant.default_storage_quota_gb`
// @Description (3-tier resolver: DB > ENV > default) and writes that many
// @Description GiB into storage_quota for every row in tenants. Bypasses
// @Description the per-workspace PUT whitelist, which forbids storage_quota
// @Description edits by Owners. SystemAdmin only.
// @Description Idempotent — running twice with the same setting is a no-op.
// @Tags System Admin
// @Produce json
// @Success 200 {object} map[string]interface{} "{ affected: int64, quota_bytes: int64 }"
// @Failure 500 {object} map[string]interface{} "DB write failed"
// @Router /system/admin/tenants/apply-default-storage-quota [post]
func (h *SystemHandler) ApplyDefaultStorageQuotaToAllTenants(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
// Resolve via the same 3-tier path the CreateTenant handler uses
// so the action's effect mirrors what new tenants would receive.
gb := h.systemSettingSvc.GetInt(
ctx,
"tenant.default_storage_quota_gb",
"WEKNORA_TENANT_DEFAULT_STORAGE_QUOTA_GB",
10,
)
if gb <= 0 {
gb = 10
}
quotaBytes := gb * 1024 * 1024 * 1024
affected, err := h.tenantSvc.BulkSetStorageQuota(ctx, quotaBytes)
if err != nil {
logger.Errorf(ctx, "BulkSetStorageQuota failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply storage quota"})
return
}
// Audit. We capture quota_bytes (the actual value written) rather
// than the GB literal so the audit row is unambiguous if someone
// later changes the setting's units.
if h.auditSvc != nil {
actorID, _ := types.UserIDFromContext(ctx)
details, _ := json.Marshal(map[string]any{
"quota_bytes": quotaBytes,
"quota_gb": gb,
"affected": affected,
})
_ = h.auditSvc.Log(ctx, &types.AuditLog{
TenantID: 0,
ActorUserID: actorID,
ActorRole: systemAuditActorRole(ctx),
Action: types.AuditActionSystemSettingChanged,
TargetType: "tenant_storage_quota",
TargetID: "all",
Outcome: types.AuditOutcomeSuccess,
Details: types.JSON(details),
})
}
c.JSON(http.StatusOK, gin.H{
"affected": affected,
"quota_bytes": quotaBytes,
"quota_gb": gb,
})
}
// ResetSystemSetting godoc
// @Summary Reset a system setting to ENV / built-in default
// @Description Removes the DB override for :key so the 3-tier resolver
// @Description falls back to the environment variable (when configured)
// @Description or the in-code default. Idempotent — resetting a key
// @Description that was never persisted returns 200.
// @Tags System Admin
// @Param key path string true "Setting key"
// @Success 200 {object} map[string]interface{} "Reset acknowledged"
// @Failure 400 {object} map[string]interface{} "Unknown key"
// @Failure 500 {object} map[string]interface{} "DB write failed"
// @Router /system/admin/settings/{key} [delete]
func (h *SystemHandler) ResetSystemSetting(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
key := c.Param("key")
if err := h.systemSettingSvc.Reset(ctx, key); err != nil {
// Service surfaces "unknown key" as a plain error string; treat
// every error as 400 so the UI captures it as a toast. A real
// DB failure here is rare and indistinguishable from "bad key"
// at this layer — operators see the message either way.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}