WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package handler
import (
"net/http"
"strconv"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// AuditLogHandler exposes the per-tenant audit-log feed (PR 6, #1303).
// The route group lives under /tenants/:id/audit-log, gated by
// PathTenantMatch (URL :id == active tenant) plus an Admin role
// requirement — leaks of denied-action histories should not surface
// to ordinary members.
type AuditLogHandler struct {
auditService interfaces.AuditLogService
}
// NewAuditLogHandler constructs the handler.
func NewAuditLogHandler(auditService interfaces.AuditLogService) *AuditLogHandler {
return &AuditLogHandler{auditService: auditService}
}
// auditLogListResponse is the response envelope for ListTenantAuditLog. The
// cursor is the integer id of the last entry, or 0 if no more rows remain.
type auditLogListResponse struct {
Success bool `json:"success"`
Data []*types.AuditLog `json:"data"`
NextCursor uint64 `json:"next_cursor"`
}
// ListTenantAuditLog godoc
// @Summary 获取空间审计日志
// @Description 返回该空间最近的审计事件,按 id 倒序。游标分页:将上次响应的 next_cursor 作为下一次请求的 after_id。
// @Tags 审计日志
// @Produce json
// @Param id path string true "空间ID"
// @Param after_id query int false "游标:返回 id 小于此值的记录(默认从最新开始)"
// @Param limit query int false "页大小,1-100,默认 50"
// @Param action query string false "按 action 精确过滤(如 rbac.member_added / rbac.access_denied)"
// @Param outcome query string false "按 outcome 精确过滤(success / denied)"
// @Param actor query string false "按 actor_user_id 精确过滤"
// @Success 200 {object} auditLogListResponse
// @Failure 400 {object} errors.AppError
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/{id}/audit-log [get]
func (h *AuditLogHandler) ListTenantAuditLog(c *gin.Context) {
ctx := c.Request.Context()
tenantID, ok := parseTenantIDFromPath(c)
if !ok {
// parseTenantIDFromPath has already attached an error to gin.
return
}
// after_id cursor — invalid values are tolerated (treated as "from
// the top") so a misconfigured client doesn't see a hard 400 on
// the empty / first request. Tighter validation belongs at the
// frontend.
var afterID uint64
if raw := c.Query("after_id"); raw != "" {
if v, err := strconv.ParseUint(raw, 10, 64); err == nil {
afterID = v
}
}
limit := 0 // 0 lets the repository pick its default (50)
if raw := c.Query("limit"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
limit = v
}
}
q := &interfaces.AuditLogQuery{
AfterID: afterID,
Limit: limit,
Action: types.AuditAction(c.Query("action")),
Outcome: types.AuditOutcome(c.Query("outcome")),
ActorUserID: c.Query("actor"),
UnscopedOnly: true,
}
entries, err := h.auditService.List(ctx, tenantID, q)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"tenant_id": tenantID})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// next_cursor is the smallest id in the page (since rows are sorted
// id DESC). Empty page ⇒ 0, telling the client there's nothing
// older to fetch.
var nextCursor uint64
if n := len(entries); n > 0 {
nextCursor = entries[n-1].ID
}
c.JSON(http.StatusOK, auditLogListResponse{
Success: true,
Data: entries,
NextCursor: nextCursor,
})
}
// ListKnowledgeBaseActivity returns the durable activity projection for one
// knowledge base. The route has already resolved KB access; this handler adds
// an owner-tenant check so organization-shared consumers cannot inspect source
// workspace actors or configuration history.
// @Summary 获取知识库活动记录
// @Description 返回知识库的重要变更与后台任务入口。仅知识库创建者或所属空间管理员可读,共享空间不可读。
// @Tags 知识库
// @Produce json
// @Param id path string true "知识库ID"
// @Param after_id query int false "游标:返回 id 小于此值的记录"
// @Param limit query int false "页大小,1-100,默认 50"
// @Param action query string false "按 action 精确过滤"
// @Param outcome query string false "按 outcome 精确过滤"
// @Param actor query string false "按 actor_user_id 精确过滤"
// @Success 200 {object} auditLogListResponse
// @Failure 403 {object} errors.AppError
// @Security Bearer
// @Router /knowledge-bases/{id}/activity [get]
func (h *AuditLogHandler) ListKnowledgeBaseActivity(c *gin.Context) {
ctx := c.Request.Context()
kbID := c.Param("id")
access, ok := middleware.KBAccessFromContext(c)
if !ok || access == nil || access.KnowledgeBase == nil || access.KnowledgeBase.ID != kbID {
c.Error(errors.NewNotFoundError("knowledge base not found"))
return
}
callerTenantID := c.GetUint64(types.TenantIDContextKey.String())
if callerTenantID == 0 || access.KnowledgeBase.TenantID != callerTenantID {
c.Error(errors.NewForbiddenError("knowledge base activity is only available in the owner workspace"))
return
}
actorID, _ := types.UserIDFromContext(ctx)
role := types.TenantRoleFromContext(ctx)
if access.KnowledgeBase.CreatorID != actorID && !role.HasPermission(types.TenantRoleAdmin) {
c.Error(errors.NewForbiddenError("knowledge base activity requires creator or admin access"))
return
}
afterID, limit := parseAuditCursor(c)
q := &interfaces.AuditLogQuery{
AfterID: afterID,
Limit: limit,
Action: types.AuditAction(c.Query("action")),
Outcome: types.AuditOutcome(c.Query("outcome")),
ActorUserID: c.Query("actor"),
ScopeType: "knowledge_base",
ScopeID: kbID,
}
entries, err := h.auditService.List(ctx, access.KnowledgeBase.TenantID, q)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"knowledge_base_id": kbID})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
var nextCursor uint64
if n := len(entries); n > 0 {
nextCursor = entries[n-1].ID
}
c.JSON(http.StatusOK, auditLogListResponse{
Success: true, Data: entries, NextCursor: nextCursor,
})
}
func parseAuditCursor(c *gin.Context) (uint64, int) {
var afterID uint64
if raw := c.Query("after_id"); raw != "" {
if v, err := strconv.ParseUint(raw, 10, 64); err == nil {
afterID = v
}
}
limit := 0
if raw := c.Query("limit"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
limit = v
}
}
return afterID, limit
}
// ListSystemAuditLog godoc
// @Summary 获取平台审计日志
// @Description 返回 system-scope(tenant_id=0)的审计事件,覆盖 system.setting_changed / system.admin_promoted / system.admin_revoked 等 SystemAdmin 操作。按 id 倒序的游标分页。
// @Tags 审计日志
// @Produce json
// @Param after_id query int false "游标:返回 id 小于此值的记录(默认从最新开始)"
// @Param limit query int false "页大小,1-100,默认 50"
// @Param action query string false "按 action 精确过滤(如 system.setting_changed)"
// @Param outcome query string false "按 outcome 精确过滤(success / denied)"
// @Param actor query string false "按 actor_user_id 精确过滤"
// @Success 200 {object} auditLogListResponse
// @Failure 500 {object} errors.AppError
// @Security Bearer
// @Security ApiKeyAuth
// @Router /system/admin/audit-log [get]
//
// Mounted on /api/v1/system/admin/audit-log under the SystemAdmin()
// guard. Unlike ListTenantAuditLog this route is *not* tenant-scoped:
// the system_settings table, admin promote/revoke, and the
// apply-default-storage-quota bulk write all emit audit rows with
// tenant_id=0 (see audit_log.go for the action constants). Those rows
// would never surface in any tenant's audit feed, so this endpoint
// fixes the "wrote audit, can't see it" gap.
func (h *AuditLogHandler) ListSystemAuditLog(c *gin.Context) {
ctx := c.Request.Context()
// Cursor / page-size parsing mirrors ListTenantAuditLog so the
// frontend can share the same call shape; tolerant of garbage
// because the empty / first request shouldn't bounce.
var afterID uint64
if raw := c.Query("after_id"); raw != "" {
if v, err := strconv.ParseUint(raw, 10, 64); err == nil {
afterID = v
}
}
limit := 0
if raw := c.Query("limit"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
limit = v
}
}
q := &interfaces.AuditLogQuery{
AfterID: afterID,
Limit: limit,
Action: types.AuditAction(c.Query("action")),
Outcome: types.AuditOutcome(c.Query("outcome")),
ActorUserID: c.Query("actor"),
}
// tenant_id=0 is the system-scope convention; see
// AuditActionSystemSettingChanged comment in types/audit_log.go.
entries, err := h.auditService.List(ctx, 0, q)
if err != nil {
logger.Error(ctx, err)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
var nextCursor uint64
if n := len(entries); n > 0 {
nextCursor = entries[n-1].ID
}
c.JSON(http.StatusOK, auditLogListResponse{
Success: true,
Data: entries,
NextCursor: nextCursor,
})
}
+410
View File
@@ -0,0 +1,410 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// stubAuditService implements just AuditLogService.List for the
// handler tests. Embeds the interface so any other call panics —
// surfaces a contract drift loudly instead of silently working.
type stubAuditService struct {
interfaces.AuditLogService
list func(ctx context.Context, tenantID uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error)
}
func (s *stubAuditService) List(
ctx context.Context, tenantID uint64, q *interfaces.AuditLogQuery,
) ([]*types.AuditLog, error) {
return s.list(ctx, tenantID, q)
}
// newAuditHandlerTestRouter mounts the handler with the production
// ErrorHandler so c.Error renders the canonical envelope. Path :id is
// resolved by parseTenantIDFromPath; tenant context is not required by
// the handler (PathTenantMatch is stripped from this layer because the
// tests focus on the handler's own surface).
func newAuditHandlerTestRouter(svc interfaces.AuditLogService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := NewAuditLogHandler(svc)
r.GET("/tenants/:id/audit-log", h.ListTenantAuditLog)
return r
}
func TestAuditLogHandler_ReturnsEnvelopeAndCursor(t *testing.T) {
// Two rows: smallest id appears in the next_cursor field so the
// frontend can re-request older pages without re-parsing the body.
svc := &stubAuditService{
list: func(_ context.Context, tenantID uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
if tenantID != 7 {
t.Fatalf("expected tenant 7, got %d", tenantID)
}
return []*types.AuditLog{
{ID: 102, TenantID: 7, Action: types.AuditActionMemberAdded},
{ID: 95, TenantID: 7, Action: types.AuditActionAccessDenied, Outcome: types.AuditOutcomeDenied},
}, nil
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tenants/7/audit-log", nil)
newAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var got auditLogListResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !got.Success {
t.Fatalf("expected success=true")
}
if len(got.Data) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got.Data))
}
if got.NextCursor != 95 {
t.Fatalf("expected next_cursor to be the smallest id (95), got %d", got.NextCursor)
}
}
func TestAuditLogHandler_PassesQueryFiltersThrough(t *testing.T) {
// The handler must propagate after_id / limit / action / outcome /
// actor exactly as the service expects them. A regression here
// would silently drop a filter and over-return rows on the wire.
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
if q.AfterID != 100 {
t.Fatalf("expected after_id=100, got %d", q.AfterID)
}
if q.Limit != 25 {
t.Fatalf("expected limit=25, got %d", q.Limit)
}
if q.Action != types.AuditActionAccessDenied {
t.Fatalf("expected action=access_denied, got %q", q.Action)
}
if q.Outcome != types.AuditOutcomeDenied {
t.Fatalf("expected outcome=denied, got %q", q.Outcome)
}
if q.ActorUserID != "u-probing" {
t.Fatalf("expected actor=u-probing, got %q", q.ActorUserID)
}
if !q.UnscopedOnly {
t.Fatalf("tenant audit feed must exclude resource-scoped activity rows")
}
return nil, nil
},
}
w := httptest.NewRecorder()
q := "after_id=100&limit=25&action=rbac.access_denied&outcome=denied&actor=u-probing"
req := httptest.NewRequest(http.MethodGet, "/tenants/7/audit-log?"+q, nil)
newAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestAuditLogHandler_EmptyResultProducesZeroCursor(t *testing.T) {
// next_cursor=0 is the documented "no more rows" signal; the frontend
// stops paginating when it sees this, so a regression that returns
// the previous cursor on an empty page would loop forever.
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
return nil, nil
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tenants/7/audit-log", nil)
newAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var got auditLogListResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.NextCursor != 0 {
t.Fatalf("expected next_cursor=0 on empty page, got %d", got.NextCursor)
}
}
func TestAuditLogHandler_InvalidTenantIDReturns400(t *testing.T) {
// parseTenantIDFromPath rejects non-numeric tenant ids with 400 so
// the handler never even calls the service. The harness still has
// to not crash on an empty service if it's reached — guard with a
// service that fails the test loudly.
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
return nil, fmt.Errorf("must not be called")
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/tenants/not-a-number/audit-log", nil)
newAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for non-numeric tenant id, got %d body=%s", w.Code, w.Body.String())
}
}
func newKBActivityHandlerTestRouter(
t *testing.T,
svc interfaces.AuditLogService,
callerTenantID uint64,
kb *types.KnowledgeBase,
userID string,
role types.TenantRole,
) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), callerTenantID)
c.Set(middleware.KBAccessContextKey, &middleware.KBAccess{KnowledgeBase: kb})
ctx := context.WithValue(c.Request.Context(), types.UserIDContextKey, userID)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, role)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
h := NewAuditLogHandler(svc)
r.GET("/knowledge-bases/:id/activity", h.ListKnowledgeBaseActivity)
return r
}
func TestKnowledgeBaseActivityHandler_UsesKBScope(t *testing.T) {
svc := &stubAuditService{list: func(_ context.Context, tenantID uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
if tenantID != 7 || q.ScopeType != "knowledge_base" || q.ScopeID != "kb-1" {
t.Fatalf("unexpected scope: tenant=%d type=%q id=%q", tenantID, q.ScopeType, q.ScopeID)
}
if q.AfterID != 30 || q.Limit != 10 || q.Outcome != types.AuditOutcomePartial {
t.Fatalf("unexpected filters: %+v", q)
}
return []*types.AuditLog{{ID: 21, TenantID: 7, ScopeType: q.ScopeType, ScopeID: q.ScopeID}}, nil
}}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/activity?after_id=30&limit=10&outcome=partial", nil)
newKBActivityHandlerTestRouter(t, svc, 7,
&types.KnowledgeBase{ID: "kb-1", TenantID: 7, CreatorID: "creator"},
"creator", types.TenantRoleViewer).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestKnowledgeBaseActivityHandler_BlocksSharedWorkspace(t *testing.T) {
svc := &stubAuditService{list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
t.Fatal("audit list must not be called for a shared workspace")
return nil, nil
}}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/activity", nil)
newKBActivityHandlerTestRouter(t, svc, 8,
&types.KnowledgeBase{ID: "kb-1", TenantID: 7, CreatorID: "creator"},
"creator", types.TenantRoleOwner).ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String())
}
}
func TestKnowledgeBaseActivityHandler_RequiresCreatorOrAdmin(t *testing.T) {
svc := &stubAuditService{list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
t.Fatal("audit list must not be called for ordinary members")
return nil, nil
}}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/activity", nil)
newKBActivityHandlerTestRouter(t, svc, 7,
&types.KnowledgeBase{ID: "kb-1", TenantID: 7, CreatorID: "creator"},
"other", types.TenantRoleContributor).ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String())
}
}
// ---- ListSystemAuditLog (platform / system-scope feed) ----------------
//
// Mirrors the per-tenant suite above but covers the new
// GET /system/admin/audit-log endpoint. Key behavioural difference:
// the handler must always pass tenant_id=0 to the service (system-scope
// convention) regardless of any URL parameter. The route group is
// guarded by SystemAdmin() at the production wiring layer; the test
// mounts the handler directly so it focuses on the handler's own
// contract.
func newSystemAuditHandlerTestRouter(svc interfaces.AuditLogService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := NewAuditLogHandler(svc)
r.GET("/system/admin/audit-log", h.ListSystemAuditLog)
return r
}
func TestSystemAuditLogHandler_AlwaysQueriesTenantZero(t *testing.T) {
// The defining contract of ListSystemAuditLog: the underlying
// service call is hard-pinned to tenant_id=0. A regression that
// reads a tenant_id from the URL / context / header would leak
// per-tenant rbac.* rows into the platform feed (and conversely,
// hide the platform's system.* rows from the SystemAdmin).
called := false
svc := &stubAuditService{
list: func(_ context.Context, tenantID uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
called = true
if tenantID != 0 {
t.Fatalf("system audit handler must call List with tenant_id=0, got %d", tenantID)
}
return []*types.AuditLog{
{ID: 50, TenantID: 0, Action: types.AuditActionSystemSettingChanged},
{ID: 42, TenantID: 0, Action: types.AuditActionSystemAdminPromoted},
}, nil
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/system/admin/audit-log", nil)
newSystemAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if !called {
t.Fatalf("expected AuditLogService.List to be invoked")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var got auditLogListResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !got.Success {
t.Fatalf("expected success=true")
}
if len(got.Data) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got.Data))
}
if got.NextCursor != 42 {
t.Fatalf("expected next_cursor to be the smallest id (42), got %d", got.NextCursor)
}
}
func TestSystemAuditLogHandler_PassesQueryFiltersThrough(t *testing.T) {
// Same propagation guarantee as the per-tenant variant. We exercise
// every filter the OpenAPI surface advertises so a typo in the
// query-key list (e.g. dropping "outcome") is caught.
svc := &stubAuditService{
list: func(_ context.Context, tenantID uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
if tenantID != 0 {
t.Fatalf("expected tenant_id=0, got %d", tenantID)
}
if q.AfterID != 200 {
t.Fatalf("expected after_id=200, got %d", q.AfterID)
}
if q.Limit != 10 {
t.Fatalf("expected limit=10, got %d", q.Limit)
}
if q.Action != types.AuditActionSystemSettingChanged {
t.Fatalf("expected action=system.setting_changed, got %q", q.Action)
}
if q.Outcome != types.AuditOutcomeSuccess {
t.Fatalf("expected outcome=success, got %q", q.Outcome)
}
if q.ActorUserID != "u-admin-1" {
t.Fatalf("expected actor=u-admin-1, got %q", q.ActorUserID)
}
return nil, nil
},
}
w := httptest.NewRecorder()
q := "after_id=200&limit=10&action=system.setting_changed&outcome=success&actor=u-admin-1"
req := httptest.NewRequest(http.MethodGet, "/system/admin/audit-log?"+q, nil)
newSystemAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestSystemAuditLogHandler_EmptyResultProducesZeroCursor(t *testing.T) {
// next_cursor=0 is the documented "no more rows" signal — the
// platform drawer's infinite-scroll watcher stops paginating on
// it. A regression returning the previous cursor on an empty page
// would loop forever and pin the CPU.
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
return nil, nil
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/system/admin/audit-log?after_id=10", nil)
newSystemAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var got auditLogListResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.NextCursor != 0 {
t.Fatalf("expected next_cursor=0 on empty page, got %d", got.NextCursor)
}
if len(got.Data) != 0 {
t.Fatalf("expected zero entries, got %d", len(got.Data))
}
}
func TestSystemAuditLogHandler_GarbageCursorAndLimitTolerated(t *testing.T) {
// Tolerant parsing matches ListTenantAuditLog so a misconfigured
// client doesn't hard-fail the first request. Garbage `after_id`
// or non-positive `limit` are treated as "use defaults" (0 / 0)
// rather than a 400 — the frontend would otherwise show a blank
// drawer the moment a stale param ends up in the URL.
var seenAfter uint64
var seenLimit int
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, q *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
seenAfter = q.AfterID
seenLimit = q.Limit
return nil, nil
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/system/admin/audit-log?after_id=abc&limit=-1", nil)
newSystemAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (tolerant parse), got %d body=%s", w.Code, w.Body.String())
}
if seenAfter != 0 {
t.Fatalf("garbage after_id must collapse to 0, got %d", seenAfter)
}
if seenLimit != 0 {
t.Fatalf("non-positive limit must collapse to 0 (repo default), got %d", seenLimit)
}
}
func TestSystemAuditLogHandler_ServiceErrorReturns500(t *testing.T) {
// AuditLogService.List failures bubble through as 500 via
// errors.NewInternalServerError + middleware.ErrorHandler. The
// frontend surfaces the message verbatim in the drawer's error
// alert, so the body should still carry a non-empty string.
svc := &stubAuditService{
list: func(_ context.Context, _ uint64, _ *interfaces.AuditLogQuery) ([]*types.AuditLog, error) {
return nil, fmt.Errorf("db: connection refused")
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/system/admin/audit-log", nil)
newSystemAuditHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d body=%s", w.Code, w.Body.String())
}
if w.Body.Len() == 0 {
t.Fatalf("expected non-empty error body for drawer alert")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type stubChangePasswordUserService struct {
interfaces.UserService
getCurrentUser func(ctx context.Context) (*types.User, error)
changePassword func(ctx context.Context, userID, oldPassword, newPassword string) error
}
func (s *stubChangePasswordUserService) GetCurrentUser(ctx context.Context) (*types.User, error) {
return s.getCurrentUser(ctx)
}
func (s *stubChangePasswordUserService) ChangePassword(
ctx context.Context,
userID, oldPassword, newPassword string,
) error {
return s.changePassword(ctx, userID, oldPassword, newPassword)
}
func changePasswordErrorCapture() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
if appErr, ok := err.(*apperrors.AppError); ok {
c.JSON(appErr.HTTPCode, gin.H{
"success": false,
"error": gin.H{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
},
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func newChangePasswordTestRouter(h *AuthHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(changePasswordErrorCapture())
r.POST("/auth/change-password", h.ChangePassword)
return r
}
func doChangePassword(t *testing.T, r *gin.Engine, body any) *httptest.ResponseRecorder {
t.Helper()
buf, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/auth/change-password", bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestChangePassword_MapsInvalidOldPasswordDetail(t *testing.T) {
h := &AuthHandler{
userService: &stubChangePasswordUserService{
getCurrentUser: func(ctx context.Context) (*types.User, error) {
return &types.User{ID: "user-1", Email: "alice@example.com"}, nil
},
changePassword: func(ctx context.Context, userID, oldPassword, newPassword string) error {
return service.ErrInvalidOldPassword
},
},
}
w := doChangePassword(t, newChangePasswordTestRouter(h), map[string]string{
"old_password": "wrong",
"new_password": "NewSecure9",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
var resp struct {
Error struct {
Details string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Error.Details != service.DetailInvalidOldPassword {
t.Fatalf("details = %q, want %q", resp.Error.Details, service.DetailInvalidOldPassword)
}
}
func TestChangePassword_MapsSamePasswordDetail(t *testing.T) {
h := &AuthHandler{
userService: &stubChangePasswordUserService{
getCurrentUser: func(ctx context.Context) (*types.User, error) {
return &types.User{ID: "user-1", Email: "alice@example.com"}, nil
},
changePassword: func(ctx context.Context, userID, oldPassword, newPassword string) error {
return service.ErrSamePassword
},
},
}
w := doChangePassword(t, newChangePasswordTestRouter(h), map[string]string{
"old_password": "OldSecure9",
"new_password": "OldSecure9",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
var resp struct {
Error struct {
Details string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Error.Details != service.DetailSamePassword {
t.Fatalf("details = %q, want %q", resp.Error.Details, service.DetailSamePassword)
}
}
func TestChangePassword_MapsPasswordPolicyDetail(t *testing.T) {
h := &AuthHandler{
userService: &stubChangePasswordUserService{
getCurrentUser: func(_ context.Context) (*types.User, error) {
return &types.User{ID: "user-1", Email: "alice@example.com"}, nil
},
changePassword: func(_ context.Context, _, _, _ string) error {
return service.ErrComplexPasswordPolicy
},
},
}
w := doChangePassword(t, newChangePasswordTestRouter(h), map[string]string{
"old_password": "OldSecure9",
"new_password": "NewSecure9",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
var resp struct {
Error struct {
Details string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Error.Details != service.DetailPasswordPolicy {
t.Fatalf("details = %q, want %q", resp.Error.Details, service.DetailPasswordPolicy)
}
}
+123
View File
@@ -0,0 +1,123 @@
package handler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// stubOIDCStartUserService only implements GetOIDCAuthorizationURL; every
// other UserService call panics via the nil interface embedding. Keeps the
// test focused on the OIDCStart handler's branching logic.
type stubOIDCStartUserService struct {
interfaces.UserService
getOIDCAuthorizationURL func(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error)
}
func (s *stubOIDCStartUserService) GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) {
return s.getOIDCAuthorizationURL(ctx, redirectURI)
}
func newOIDCStartTestRouter(h *AuthHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(errorCapture())
r.GET("/auth/oidc/start", h.OIDCStart)
return r
}
// TestOIDCStart_RedirectsToAuthProvider: on success the handler must 302
// to the IdP authorization URL and bind the nonce via cookie (CSRF/replay
// defence), exactly like /auth/oidc/url.
func TestOIDCStart_RedirectsToAuthProvider(t *testing.T) {
const authURL = "http://idp.example.com/authorize?client_id=weknora"
us := &stubOIDCStartUserService{
getOIDCAuthorizationURL: func(context.Context, string) (*types.OIDCAuthURLResponse, error) {
return &types.OIDCAuthURLResponse{
Success: true,
AuthorizationURL: authURL,
State: "state123",
Nonce: "nonce456",
}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil, nil, nil)
r := newOIDCStartTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/start", nil)
req.Host = "weknora.example.com"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != authURL {
t.Errorf("Location = %q, want %q", loc, authURL)
}
var hasNonce bool
for _, ck := range w.Result().Cookies() {
if ck.Name == oidcNonceCookieName && ck.Value == "nonce456" {
hasNonce = true
}
}
if !hasNonce {
t.Errorf("expected %s cookie to be set with the nonce", oidcNonceCookieName)
}
}
// TestOIDCStart_BuildsCallbackURLFromRequestOrigin: redirect_uri handed to
// the IdP is derived from the request's own origin (no caller-supplied
// value needed); X-Forwarded-Proto upgrades the scheme to https.
func TestOIDCStart_BuildsCallbackURLFromRequestOrigin(t *testing.T) {
var captured string
us := &stubOIDCStartUserService{
getOIDCAuthorizationURL: func(_ context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) {
captured = redirectURI
return &types.OIDCAuthURLResponse{Success: true, AuthorizationURL: "http://idp", Nonce: "n"}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil, nil, nil)
r := newOIDCStartTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/start", nil)
req.Host = "weknora.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
const want = "https://weknora.example.com/api/v1/auth/oidc/callback"
if captured != want {
t.Errorf("callback URL passed to IdP = %q, want %q", captured, want)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
}
// TestOIDCStart_ServiceErrorReturnsNonRedirect: if the IdP URL cannot be
// built, never 302 — surface the error the same way /auth/oidc/url does.
func TestOIDCStart_ServiceErrorReturnsNonRedirect(t *testing.T) {
us := &stubOIDCStartUserService{
getOIDCAuthorizationURL: func(context.Context, string) (*types.OIDCAuthURLResponse, error) {
return nil, fmt.Errorf("idp unavailable")
},
}
h := NewAuthHandler(&config.Config{}, us, nil, nil, nil)
r := newOIDCStartTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/start", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == http.StatusFound {
t.Fatalf("status = 302, want non-redirect on service error")
}
}
+241
View File
@@ -0,0 +1,241 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "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"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// registerByInviteRequest is the body for /auth/register-by-invite.
//
// Email is collected from the invitee themselves: share-link rows have
// no specific invitee, so we cannot pre-bind any address. The token
// IS the authorisation; the email is whatever account the invitee
// wants to register.
type registerByInviteRequest struct {
Token string `json:"token" binding:"required"`
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=3"`
}
// invitationLookupResponse is the public projection of a share-link
// row returned by /auth/invitations/lookup. Kept narrow on purpose:
// just enough for the registration page to render context ("X invited
// you to Y") without exposing inviter audit fields.
type invitationLookupResponse struct {
TenantID uint64 `json:"tenant_id"`
TenantName string `json:"tenant_name,omitempty"`
Role types.TenantRole `json:"role"`
ExpiresAt string `json:"expires_at"`
}
// invitationLookupRequest carries the token in the request body
// instead of the URL path: GET /auth/invitations/:token would have
// landed the plaintext token in access logs, browser history, and
// tracing spans. POST + body keeps the token out of those surfaces
// at the cost of "this endpoint reads, why is it POST" — worth it
// because the token is the sole authorisation for registration.
type invitationLookupRequest struct {
Token string `json:"token" binding:"required"`
}
// LookupInvitationByToken godoc
// @Summary 解析共享邀请链接 token
// @Description 根据邀请链接中的 token 返回邀请上下文(空间名 / 角色 / 过期时间),
// @Description 供注册页展示。无认证;token 无效或被撤销返回 410。
// @Description 使用 POST + body 而非 GET + path,避免 token 落入访问日志 / 浏览器历史 / tracing。
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body invitationLookupRequest true "邀请 token"
// @Success 200 {object} invitationLookupResponse
// @Failure 410 {object} apperrors.AppError "链接无效或已撤销"
// @Router /auth/invitations/lookup [post]
func (h *AuthHandler) LookupInvitationByToken(c *gin.Context) {
ctx := c.Request.Context()
if h.invitationSvc == nil {
c.Error(apperrors.NewInternalServerError("invitation service unavailable"))
return
}
var req invitationLookupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("token is required").WithDetails(err.Error()))
return
}
token := strings.TrimSpace(req.Token)
if token == "" {
c.Error(apperrors.NewValidationError("token is required"))
return
}
inv, err := h.invitationSvc.LookupByToken(ctx, token)
if err != nil {
// Collapse "unknown / expired / revoked" into 410 to avoid
// leaking which slot a stolen token used to occupy.
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is invalid or has been revoked",
HTTPCode: http.StatusGone,
})
return
}
resp := invitationLookupResponse{
TenantID: inv.TenantID,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
}
if tenant, terr := h.tenantService.GetTenantByID(ctx, inv.TenantID); terr == nil && tenant != nil {
resp.TenantName = tenant.Name
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": resp,
})
}
// RegisterByInvite godoc
// @Summary 使用共享链接注册
// @Description 通过 Owner 生成的共享邀请链接 token 完成注册,绕过 invite_only 模式拦截。
// @Description 注册者自填邮箱(与 token 不绑定);注册成功后自动加入对应空间。
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body registerByInviteRequest true "邀请注册请求"
// @Success 201 {object} types.LoginResponse
// @Failure 400 {object} apperrors.AppError "请求参数错误"
// @Failure 409 {object} apperrors.AppError "邮箱已注册"
// @Failure 410 {object} apperrors.AppError "链接无效或已撤销"
// @Router /auth/register-by-invite [post]
//
// RegisterByInvite is intentionally NOT subject to the invite_only gate:
// the gate suppresses public registration, while this endpoint requires
// a valid token issued by an Owner. The token IS the authorisation.
func (h *AuthHandler) RegisterByInvite(c *gin.Context) {
ctx := c.Request.Context()
if h.invitationSvc == nil {
c.Error(apperrors.NewInternalServerError("invitation service unavailable"))
return
}
var req registerByInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid registration parameters").WithDetails(err.Error()))
return
}
req.Token = strings.TrimSpace(req.Token)
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Password is intentionally NOT sanitized: SanitizeForLog strips
// \n, \r, \t and control chars to make a string safe to write into
// a log line — applying it to a real password would silently
// rewrite the stored credential and lock the user out. Passwords
// must never be logged, so they don't need that defence here.
if req.Token == "" || req.Email == "" || req.Username == "" || req.Password == "" {
c.Error(apperrors.NewValidationError("token, email, username and password are required"))
return
}
inv, err := h.invitationSvc.LookupByToken(ctx, req.Token)
if err != nil {
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is invalid or has been revoked",
HTTPCode: http.StatusGone,
})
return
}
// If the email already has an account we shouldn't quietly succeed
// here: register would fail with a duplicate-email error, but the
// share-link flow is "create new account + join tenant", not "join
// my existing account". Surface 409 so the SPA can prompt them to
// log in instead, and the invitation can be applied to their
// existing account via /me/invitations once we wire that path.
if existing, _ := h.userService.GetUserByEmail(ctx, req.Email); existing != nil {
c.Error(apperrors.NewConflictError(
"this email already has an account; please log in to join the workspace"))
return
}
if err := service.ValidatePasswordPolicy(req.Password, h.complexPasswordEnabled(ctx)); err != nil {
logger.Error(ctx, "Invalid password policy")
_ = c.Error(apperrors.NewValidationError(err.Error()))
return
}
user, err := h.userService.Register(ctx, &types.RegisterRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
TenantProvisioning: types.TenantProvisioningTenantless,
})
if err != nil {
logger.Errorf(ctx, "register-by-invite: user create failed for %s: %v",
secutils.SanitizeForLog(req.Email), err)
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
// The invited tenant becomes the user's initial/default tenant. No
user.TenantID = inv.TenantID
if err := h.userService.UpdateUser(ctx, user); err != nil {
logger.Errorf(ctx, "register-by-invite: failed to set home tenant for user %s: %v", user.ID, err)
_ = h.userService.DeleteUser(ctx, user.ID)
c.Error(apperrors.NewInternalServerError("failed to finalise invited account").WithDetails(err.Error()))
return
}
if _, err := h.invitationSvc.AcceptByToken(ctx, req.Token, user.ID); err != nil {
// Race: link was revoked between Lookup and Accept. Keep the new
// account, but restore it to tenantless so it does not point at a
// tenant for which no membership was created. If even that repair
// fails, remove the half-provisioned identity.
logger.Errorf(ctx, "register-by-invite: accept failed for user %s: %v", user.ID, err)
user.TenantID = 0
if rollbackErr := h.userService.UpdateUser(ctx, user); rollbackErr != nil {
logger.Errorf(ctx, "register-by-invite: failed to restore tenantless user %s: %v", user.ID, rollbackErr)
_ = h.userService.DeleteUser(ctx, user.ID)
}
c.Error(&apperrors.AppError{
Code: apperrors.ErrNotFound,
Message: "invitation link is no longer valid; please log in to your new account",
HTTPCode: http.StatusGone,
})
return
}
accessToken, refreshToken, err := h.userService.GenerateTokens(ctx, user)
if err != nil {
logger.Errorf(ctx, "register-by-invite: token generation failed for user %s: %v", user.ID, err)
c.Error(apperrors.NewInternalServerError("token generation failed").WithDetails(err.Error()))
return
}
tenant, _ := h.tenantService.GetTenantByID(ctx, inv.TenantID)
c.JSON(http.StatusCreated, dto.NewAuthLoginResponse(&types.LoginResponse{
Success: true,
Message: "Registration successful",
User: user,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: inv.TenantID,
TenantName: tenantNameOrEmpty(tenant),
Role: inv.Role,
}},
Token: accessToken,
RefreshToken: refreshToken,
}))
}
@@ -0,0 +1,149 @@
package handler
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
type invitedRegistrationUserService struct {
interfaces.UserService
registeredMode types.TenantProvisioningMode
updatedTenant uint64
updateCalls []uint64
}
func (s *invitedRegistrationUserService) GetUserByEmail(context.Context, string) (*types.User, error) {
return nil, nil
}
func (s *invitedRegistrationUserService) Register(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
s.registeredMode = req.TenantProvisioning
return &types.User{ID: "new-user", Username: req.Username, Email: req.Email, IsActive: true}, nil
}
func (s *invitedRegistrationUserService) UpdateUser(_ context.Context, user *types.User) error {
s.updatedTenant = user.TenantID
s.updateCalls = append(s.updateCalls, user.TenantID)
return nil
}
func (s *invitedRegistrationUserService) GenerateTokens(context.Context, *types.User) (string, string, error) {
return "access", "refresh", nil
}
type invitedRegistrationInvitationService struct {
interfaces.TenantInvitationService
acceptErr error
}
func (s *invitedRegistrationInvitationService) LookupByToken(context.Context, string) (*types.TenantInvitation, error) {
return &types.TenantInvitation{TenantID: 42, Role: types.TenantRoleViewer}, nil
}
func (s *invitedRegistrationInvitationService) AcceptByToken(context.Context, string, string) (*types.TenantMember, error) {
if s.acceptErr != nil {
return nil, s.acceptErr
}
return &types.TenantMember{TenantID: 42, Role: types.TenantRoleViewer}, nil
}
type invitedRegistrationTenantService struct {
interfaces.TenantService
}
func TestRegisterByInviteRestoresTenantlessAccountWhenInviteExpiresDuringRegistration(t *testing.T) {
gin.SetMode(gin.TestMode)
users := &invitedRegistrationUserService{}
h := &AuthHandler{
userService: users,
tenantService: &invitedRegistrationTenantService{},
invitationSvc: &invitedRegistrationInvitationService{acceptErr: errors.New("expired")},
}
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/register-by-invite", h.RegisterByInvite)
body := []byte(`{"token":"invite-token","email":"alice@example.com","username":"alice","password":"supersecret1"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/register-by-invite", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusGone {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if users.updatedTenant != 0 {
t.Fatalf("updated tenant=%d, want tenantless rollback", users.updatedTenant)
}
if len(users.updateCalls) != 2 || users.updateCalls[0] != 42 || users.updateCalls[1] != 0 {
t.Fatalf("update calls=%v, want [42 0]", users.updateCalls)
}
}
func (s *invitedRegistrationTenantService) GetTenantByID(context.Context, uint64) (*types.Tenant, error) {
return &types.Tenant{ID: 42, Name: "Invited Workspace"}, nil
}
func TestRegisterByInviteUsesInvitedTenantWithoutPersonalTenant(t *testing.T) {
gin.SetMode(gin.TestMode)
users := &invitedRegistrationUserService{}
h := &AuthHandler{
userService: users,
tenantService: &invitedRegistrationTenantService{},
invitationSvc: &invitedRegistrationInvitationService{},
}
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/register-by-invite", h.RegisterByInvite)
body := []byte(`{"token":"invite-token","email":"alice@example.com","username":"alice","password":"supersecret1"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/register-by-invite", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if users.registeredMode != types.TenantProvisioningTenantless {
t.Fatalf("register mode=%q, want tenantless", users.registeredMode)
}
if users.updatedTenant != 42 {
t.Fatalf("updated tenant=%d, want 42", users.updatedTenant)
}
}
func TestRegisterByInviteRejectsSimplePasswordWhenComplexEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
users := &invitedRegistrationUserService{}
h := &AuthHandler{
userService: users,
tenantService: &invitedRegistrationTenantService{},
invitationSvc: &invitedRegistrationInvitationService{},
systemSettingSvc: &tenantPolicySettingService{enabled: true},
}
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/register-by-invite", h.RegisterByInvite)
body := []byte(`{"token":"invite-token","email":"alice@example.com","username":"alice","password":"supersecret1"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/register-by-invite", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s, want 400", w.Code, w.Body.String())
}
if users.registeredMode != "" {
t.Fatalf("Register was called with mode=%q", users.registeredMode)
}
}
@@ -0,0 +1,225 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/config"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// stubRegisterUserService is a UserService whose ONLY useful method is
// Register; every other call panics. Using an interface embedding plus a
// targeted override keeps the test focused on the Register handler's
// branching logic without dragging in the entire user service surface.
type stubRegisterUserService struct {
interfaces.UserService
register func(ctx context.Context, req *types.RegisterRequest) (*types.User, error)
}
func (s *stubRegisterUserService) Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error) {
return s.register(ctx, req)
}
// errorCapture mirrors gin's default ErrorHandler behaviour for tests:
// when a handler calls c.Error(), we surface it as an HTTP response so the
// recorder reflects the real client-visible status. The production
// middleware does the same thing in middleware/error_handler.go.
func errorCapture() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
if appErr, ok := err.(*apperrors.AppError); ok {
c.JSON(appErr.HTTPCode, gin.H{"error": appErr.Message})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func newRegisterTestRouter(h *AuthHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/register", h.Register)
return r
}
func doRegister(t *testing.T, r *gin.Engine, body any) *httptest.ResponseRecorder {
t.Helper()
buf, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// validRegisterBody returns a payload that passes parameter validation, so
// each test is exercising the gate logic and not the body parser.
func validRegisterBody() map[string]string {
return map[string]string{
"username": "alice",
"email": "alice@example.com",
"password": "supersecret1",
}
}
func TestRegister_InviteOnlyRejects(t *testing.T) {
// PR 3 (#1303): when auth.registration_mode=invite_only, Register
// must respond 403 BEFORE touching the user service. The frontend
// already hides the sign-up link via /auth/config; this is the
// server-side enforcement for direct API hits.
called := false
us := &stubRegisterUserService{
register: func(context.Context, *types.RegisterRequest) (*types.User, error) {
called = true
return &types.User{ID: "u1"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeInviteOnly},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusForbidden {
t.Fatalf("invite_only must return 403, got %d body=%s", w.Code, w.Body.String())
}
if called {
t.Fatalf("UserService.Register must not be called when invite_only blocks the request")
}
}
func TestRegister_SelfServeAllowsRegistration(t *testing.T) {
// Default registration_mode keeps PR 1 behaviour intact: the gate
// is dormant and the request reaches the user service. We don't
// exercise the real service here — just confirm the gate let it
// through by observing the stub being invoked.
called := false
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
called = true
if req.TenantProvisioning != types.TenantProvisioningCreatePersonal {
t.Fatalf("default provisioning = %q, want create_personal", req.TenantProvisioning)
}
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("self_serve must allow registration, got %d body=%s", w.Code, w.Body.String())
}
if !called {
t.Fatalf("UserService.Register should have been invoked")
}
}
func TestRegister_TenantlessProvisioningFromConfig(t *testing.T) {
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
if req.TenantProvisioning != types.TenantProvisioningTenantless {
t.Fatalf("provisioning = %q, want tenantless", req.TenantProvisioning)
}
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{
RegistrationMode: config.AuthRegistrationModeSelfServe,
DefaultTenantMode: config.AuthDefaultTenantModeTenantless,
},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("tenantless self-serve registration got %d body=%s", w.Code, w.Body.String())
}
}
func TestGetAuthConfigExposesComplexPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{
configInfo: &config.Config{Auth: &config.AuthConfig{
RegistrationMode: config.AuthRegistrationModeSelfServe,
}},
systemSettingSvc: &tenantPolicySettingService{enabled: true},
}
r := gin.New()
r.GET("/auth/config", h.GetAuthConfig)
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"complex_password_enabled":true`) {
t.Fatalf("body=%s, want complex_password_enabled true", w.Body.String())
}
}
func TestRegister_NilAuthConfigDoesNotPanic(t *testing.T) {
// Defensive: a nil Auth section means the operator hasn't set the
// registration mode at all, which must not crash and must keep the
// legacy "registration enabled" behaviour. Mirrors the nil guard in
// the handler so a config-loading bug doesn't take the server down.
us := &stubRegisterUserService{
register: func(_ context.Context, _ *types.RegisterRequest) (*types.User, error) {
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("nil Auth config must fall back to allow, got %d body=%s", w.Code, w.Body.String())
}
}
// TestRegister_PreservesPasswordBytes is the regression for #2521.
// SanitizeForLog is for log output only; applying it to credentials
// rewrites tabs/newlines/control chars before hashing, so a password
// that registers successfully cannot be used at login.
func TestRegister_PreservesPasswordBytes(t *testing.T) {
// Password contains a tab and a newline — both are rewritten by
// SanitizeForLog (tab → space, newline → space). The handler must
// pass the exact original string to UserService.Register.
const originalPassword = "abc\t123!\nX"
var gotPassword string
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
gotPassword = req.Password
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil, nil, nil)
body := validRegisterBody()
body["password"] = originalPassword
w := doRegister(t, newRegisterTestRouter(h), body)
if w.Code != http.StatusCreated {
t.Fatalf("registration with control chars in password must succeed, got %d body=%s",
w.Code, w.Body.String())
}
if gotPassword != originalPassword {
t.Fatalf("password mutated before UserService.Register:\n got %q\n want %q",
gotPassword, originalPassword)
}
}
@@ -0,0 +1,92 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
type usernameLoginService struct {
interfaces.UserService
received *types.LoginRequest
}
func (s *usernameLoginService) Login(_ context.Context, req *types.LoginRequest) (*types.LoginResponse, error) {
s.received = req
return &types.LoginResponse{Success: true, User: &types.User{ID: "test-user"}, Token: "test-token"}, nil
}
func TestLoginAcceptsUsernameAndLegacyEmailRequests(t *testing.T) {
cases := []struct {
name string
body map[string]string
status int
}{
{"username without email", map[string]string{"username": "alice", "password": "123"}, http.StatusOK},
{"trim username", map[string]string{"username": " alice ", "password": "123"}, http.StatusOK},
{"Unicode password", map[string]string{"username": "alice", "password": "三个字"}, http.StatusOK},
{"legacy email", map[string]string{"email": "alice@example.com", "password": "123"}, http.StatusOK},
{"missing identifier", map[string]string{"password": "123"}, http.StatusBadRequest},
{"blank identifier", map[string]string{"username": " ", "password": "123"}, http.StatusBadRequest},
{"missing password", map[string]string{"username": "alice"}, http.StatusBadRequest},
}
gin.SetMode(gin.TestMode)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := &usernameLoginService{}
h := &AuthHandler{userService: svc}
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/login", h.Login)
payload, err := json.Marshal(tc.body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
r.ServeHTTP(response, req)
if response.Code != tc.status {
t.Fatalf("status=%d body=%s, want %d", response.Code, response.Body, tc.status)
}
if tc.status != http.StatusOK {
if svc.received != nil {
t.Fatal("invalid input reached the login service")
}
return
}
if svc.received == nil || svc.received.Password != tc.body["password"] {
t.Fatalf("password changed before authentication: %+v", svc.received)
}
if tc.body["username"] != "" && svc.received.Username != "alice" {
t.Fatalf("username=%q, want alice", svc.received.Username)
}
})
}
}
func TestRegisterAcceptsThreeCharacterPassword(t *testing.T) {
called := false
svc := &stubRegisterUserService{register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
called = true
if req.Password != "123" {
t.Fatalf("password=%q", req.Password)
}
return &types.User{ID: "created-user"}, nil
}}
h := &AuthHandler{userService: svc, configInfo: &config.Config{Auth: &config.AuthConfig{}}}
body := validRegisterBody()
body["password"] = "123"
response := doRegister(t, newRegisterTestRouter(h), body)
if response.Code != http.StatusCreated || !called {
t.Fatalf("status=%d body=%s service_called=%v", response.Code, response.Body, called)
}
}
+462
View File
@@ -0,0 +1,462 @@
package handler
import (
stderrors "errors"
"net/http"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"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"
"github.com/gin-gonic/gin"
)
// ChunkHandler defines HTTP handlers for chunk operations.
//
// All KB-access checks (own / org-shared / via shared agent) are now
// performed by the route-level g.KBAccessRead*FromKnowledgeIDParam /
// g.KBAccessWrite*FromKnowledgeIDParam / g.KBAccess*FromChunkIDParam
// guards in router.go — the guard rewrites c.Request.Context() to
// carry the effective tenant ID, so the handler reads tenant from
// context the way it always did.
//
// kgService is retained because the route-level *creator-ownership*
// lookup KBCreatorLookupFromKnowledgeIDParam still walks
// knowledge_id -> kb_id to resolve creator_id (separate axis from
// access — that lookup answers "is the caller the creator of THIS
// resource", not "does the caller's tenant have access").
type ChunkHandler struct {
service interfaces.ChunkService
kgService interfaces.KnowledgeService
}
// NewChunkHandler creates a new chunk handler.
func NewChunkHandler(service interfaces.ChunkService, kgService interfaces.KnowledgeService) *ChunkHandler {
return &ChunkHandler{service: service, kgService: kgService}
}
// GetChunkByIDOnly godoc
// @Summary 通过ID获取分块
// @Description 仅通过分块ID获取分块详情(不需要knowledge_id);支持共享知识库下的分块访问
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param id path string true "分块ID"
// @Success 200 {object} map[string]interface{} "分块详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "分块不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/by-id/{id} [get]
func (h *ChunkHandler) GetChunkByIDOnly(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving chunk by ID only")
chunkID := secutils.SanitizeForLog(c.Param("id"))
if chunkID == "" {
logger.Error(ctx, "Chunk ID is empty")
c.Error(errors.NewBadRequestError("Chunk ID cannot be empty"))
return
}
// Get chunk by ID without tenant filter (chunk may belong to shared
// KB; the route-level KB-access guard already verified read
// permission against the parent KB before we got here).
chunk, err := h.service.GetChunkByIDOnly(ctx, chunkID)
if err != nil {
if err == service.ErrChunkNotFound {
logger.Warnf(ctx, "Chunk not found, chunk ID: %s", chunkID)
c.Error(errors.NewNotFoundError("Chunk not found"))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": chunk,
})
}
// ListKnowledgeChunks godoc
// @Summary 获取知识分块列表
// @Description 获取指定知识下的所有分块列表,支持分页
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param knowledge_id path string true "知识ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Success 200 {object} map[string]interface{} "分块列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/{knowledge_id} [get]
func (h *ChunkHandler) ListKnowledgeChunks(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving knowledge chunks list")
knowledgeID := secutils.SanitizeForLog(c.Param("knowledge_id"))
if knowledgeID == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
// Parse pagination parameters
var pagination types.Pagination
if err := c.ShouldBindQuery(&pagination); err != nil {
logger.Errorf(ctx, "Failed to parse pagination parameters: %s", secutils.SanitizeForLog(err.Error()))
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if pagination.Page < 1 {
pagination.Page = 1
}
if pagination.PageSize < 1 {
pagination.PageSize = 10
}
if pagination.PageSize > 100 {
pagination.PageSize = 100
}
// Default to text chunks; callers may override via ?chunk_type=image_caption etc.
chunkType := []types.ChunkType{types.ChunkTypeText}
if queryTypes := c.QueryArray("chunk_type"); len(queryTypes) > 0 {
chunkType = make([]types.ChunkType, 0, len(queryTypes))
for _, qt := range queryTypes {
chunkType = append(chunkType, types.ChunkType(qt))
}
}
// The route-level guard has rewritten the request's tenant context
// to the effective tenant for shared KBs.
result, err := h.service.ListPagedChunksByKnowledgeID(ctx, knowledgeID, &pagination, chunkType)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
"total": result.Total,
"page": result.Page,
"page_size": result.PageSize,
})
}
// UpdateChunkRequest defines the request structure for updating a chunk
type UpdateChunkRequest struct {
Content *string `json:"content"`
IsEnabled *bool `json:"is_enabled"`
ExpectedRevision *int `json:"expected_revision"`
}
// fetchChunkAndVerifyOwnership fetches a chunk by ID and verifies it
// belongs to the URL :knowledge_id (defence in depth: the route-level
// KB-access guard already ensured the caller has write access to the
// KB; this check stops a same-tenant attacker from passing one
// knowledge_id while addressing a chunk owned by a different
// knowledge in the same KB).
func (h *ChunkHandler) fetchChunkAndVerifyOwnership(c *gin.Context) (*types.Chunk, string, error) {
ctx := c.Request.Context()
knowledgeID := secutils.SanitizeForLog(c.Param("knowledge_id"))
if knowledgeID == "" {
logger.Error(ctx, "Knowledge ID is empty")
return nil, "", errors.NewBadRequestError("Knowledge ID cannot be empty")
}
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Chunk ID is empty")
return nil, knowledgeID, errors.NewBadRequestError("Chunk ID cannot be empty")
}
chunk, err := h.service.GetChunkByID(ctx, id)
if err != nil {
if err == service.ErrChunkNotFound {
logger.Warnf(ctx, "Chunk not found, knowledge ID: %s, chunk ID: %s", knowledgeID, id)
return nil, knowledgeID, errors.NewNotFoundError("Chunk not found")
}
logger.ErrorWithFields(ctx, err, nil)
return nil, knowledgeID, errors.NewInternalServerError(err.Error())
}
if chunk.KnowledgeID != knowledgeID {
logger.Warnf(ctx, "Chunk does not belong to knowledge, knowledge ID: %s, chunk ID: %s", knowledgeID, id)
return nil, knowledgeID, errors.NewForbiddenError("No permission to access this chunk")
}
return chunk, knowledgeID, nil
}
// UpdateChunk godoc
// @Summary 更新分块
// @Description 更新指定分块的内容和属性
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param knowledge_id path string true "知识ID"
// @Param id path string true "分块ID"
// @Param request body UpdateChunkRequest true "更新请求"
// @Success 200 {object} map[string]interface{} "更新后的分块"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "分块不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/{knowledge_id}/{id} [put]
func (h *ChunkHandler) UpdateChunk(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating knowledge chunk")
chunk, knowledgeID, err := h.fetchChunkAndVerifyOwnership(c)
if err != nil {
c.Error(err)
return
}
var req UpdateChunkRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Errorf(ctx, "Failed to parse request parameters: %s", secutils.SanitizeForLog(err.Error()))
c.Error(errors.NewBadRequestError(err.Error()))
return
}
chunk, err = h.service.UpdateDocumentChunk(ctx, chunk.ID, req.Content, req.IsEnabled, req.ExpectedRevision)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
if stderrors.Is(err, service.ErrChunkRevisionConflict) {
c.Error(errors.NewConflictError("Chunk was modified by another user; refresh and retry"))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Knowledge chunk updated successfully, knowledge ID: %s, chunk ID: %s",
secutils.SanitizeForLog(knowledgeID), secutils.SanitizeForLog(chunk.ID))
knowledge, getErr := h.kgService.GetKnowledgeByID(ctx, knowledgeID)
if getErr != nil {
logger.Warnf(ctx, "Chunk updated but failed to reload summary status for %s: %v", knowledgeID, getErr)
}
response := gin.H{"success": true, "data": chunk}
if knowledge != nil {
response["summary_status"] = knowledge.SummaryStatus
response["description"] = knowledge.Description
}
c.JSON(http.StatusOK, response)
}
func (h *ChunkHandler) ListChunkRevisions(c *gin.Context) {
chunk, _, err := h.fetchChunkAndVerifyOwnership(c)
if err != nil {
c.Error(err)
return
}
items, err := h.service.ListChunkRevisions(c.Request.Context(), chunk.ID)
if err != nil {
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
type RevertChunkRequest struct {
Revision *int `json:"revision" binding:"required"`
ExpectedRevision *int `json:"expected_revision"`
}
func (h *ChunkHandler) RevertChunk(c *gin.Context) {
chunk, knowledgeID, err := h.fetchChunkAndVerifyOwnership(c)
if err != nil {
c.Error(err)
return
}
var req RevertChunkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if req.Revision == nil || *req.Revision < 0 {
c.Error(errors.NewBadRequestError("revision must be a non-negative integer"))
return
}
updated, err := h.service.RevertDocumentChunk(c.Request.Context(), chunk.ID, *req.Revision, req.ExpectedRevision)
if stderrors.Is(err, service.ErrChunkRevisionConflict) {
c.Error(errors.NewConflictError("Chunk was modified by another user; refresh and retry"))
return
}
if err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
knowledge, getErr := h.kgService.GetKnowledgeByID(c.Request.Context(), knowledgeID)
if getErr != nil {
logger.Warnf(c.Request.Context(), "Chunk reverted but failed to reload summary status for %s: %v", knowledgeID, getErr)
}
response := gin.H{"success": true, "data": updated}
if knowledge != nil {
response["summary_status"] = knowledge.SummaryStatus
response["description"] = knowledge.Description
}
c.JSON(http.StatusOK, response)
}
type UpsertGeneratedQuestionRequest struct {
QuestionID string `json:"question_id"`
Question string `json:"question" binding:"required"`
}
func (h *ChunkHandler) UpsertGeneratedQuestion(c *gin.Context) {
chunkID := secutils.SanitizeForLog(c.Param("id"))
if chunkID == "" {
c.Error(errors.NewBadRequestError("Chunk ID is required"))
return
}
var req UpsertGeneratedQuestionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
item, err := h.service.UpsertGeneratedQuestion(c.Request.Context(), chunkID, req.QuestionID, req.Question)
if err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
func (h *ChunkHandler) RegenerateGeneratedQuestions(c *gin.Context) {
chunkID := secutils.SanitizeForLog(c.Param("id"))
if chunkID == "" {
c.Error(errors.NewBadRequestError("Chunk ID is required"))
return
}
items, err := h.kgService.RegenerateChunkQuestions(c.Request.Context(), chunkID)
if err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
// DeleteChunk godoc
// @Summary 删除分块
// @Description 删除指定的分块
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param knowledge_id path string true "知识ID"
// @Param id path string true "分块ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "分块不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/{knowledge_id}/{id} [delete]
func (h *ChunkHandler) DeleteChunk(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting knowledge chunk")
chunk, _, err := h.fetchChunkAndVerifyOwnership(c)
if err != nil {
c.Error(err)
return
}
if err := h.service.DeleteChunk(ctx, chunk.ID); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Chunk deleted",
})
}
// DeleteChunksByKnowledgeID godoc
// @Summary 删除知识下所有分块
// @Description 删除指定知识下的所有分块
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param knowledge_id path string true "知识ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/{knowledge_id} [delete]
func (h *ChunkHandler) DeleteChunksByKnowledgeID(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting all chunks under knowledge")
knowledgeID := secutils.SanitizeForLog(c.Param("knowledge_id"))
if knowledgeID == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
if err := h.service.DeleteChunksByKnowledgeID(ctx, knowledgeID); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "All chunks under knowledge deleted",
})
}
// DeleteGeneratedQuestion godoc
// @Summary 删除生成的问题
// @Description 删除分块中生成的问题
// @Tags 分块管理
// @Accept json
// @Produce json
// @Param id path string true "分块ID"
// @Param request body object{question_id=string} true "问题ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "分块不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunks/by-id/{id}/questions [delete]
func (h *ChunkHandler) DeleteGeneratedQuestion(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting generated question from chunk")
chunkID := secutils.SanitizeForLog(c.Param("id"))
if chunkID == "" {
logger.Error(ctx, "Chunk ID is empty")
c.Error(errors.NewBadRequestError("Chunk ID cannot be empty"))
return
}
var req struct {
QuestionID string `json:"question_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
logger.Errorf(ctx, "Failed to parse request parameters: %s", secutils.SanitizeForLog(err.Error()))
c.Error(errors.NewBadRequestError("Question ID is required"))
return
}
if err := h.service.DeleteGeneratedQuestion(ctx, chunkID, req.QuestionID); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Generated question deleted successfully, chunk ID: %s, question ID: %s",
secutils.SanitizeForLog(chunkID), secutils.SanitizeForLog(req.QuestionID))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Generated question deleted",
})
}
+292
View File
@@ -0,0 +1,292 @@
// Package handler — chunker_debug.go exposes a read-only preview endpoint
// that runs the adaptive chunker on supplied text without touching the DB
// or generating embeddings. Used by the KB editor's debug panel so users
// can experiment with chunking parameters before committing to a re-index.
package handler
import (
"context"
"math"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/infrastructure/chunker"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/gin-gonic/gin"
)
// previewMaxChars caps the input text size so a single preview request
// cannot tie up the splitter for long. Chosen to bound worst-case CPU
// well under the previewTimeout: at 64k runes even the heaviest tier
// chain finishes in well under a second on commodity hardware.
//
// SECURITY NOTE: the splitter is CPU-bound and does NOT accept a
// context.Context. When previewTimeout fires the handler returns to the
// caller, but the worker goroutine keeps running until the splitter
// finishes naturally. The 64k ceiling is the primary mitigation against
// goroutine pile-up under repeated authenticated requests. If the
// splitter ever gains context-awareness, the goroutine-wrapper in
// PreviewChunking should switch to it for true cancellation.
const previewMaxChars = 64 * 1024
// previewMaxChunks caps the number of chunks returned in a single preview
// response so the UI doesn't choke on pathological splits. Stats are
// computed over the full chunk set before truncation so the displayed
// avg/min/max/stddev stay representative.
const previewMaxChunks = 500
// previewTimeout caps how long the handler waits for the splitter
// goroutine before returning a 504. See note above on previewMaxChars.
const previewTimeout = 5 * time.Second
// PreviewChunkingRequest is the body shape accepted by /chunker/preview.
// Text is checked manually below so we can return a friendlier error than
// gin's default "Field validation for 'Text' failed on the 'required' tag".
type PreviewChunkingRequest struct {
Text string `json:"text"`
ChunkingConfig PreviewChunkingPayload `json:"chunking_config"`
}
// PreviewChunkingPayload mirrors the snake_case JSON the rest of the API
// uses for ChunkingConfig fields. We don't reuse types.ChunkingConfig
// directly because it carries unrelated parser rules and table-processing
// metadata that the preview path does not use.
type PreviewChunkingPayload struct {
ChunkSize int `json:"chunk_size"`
ChunkOverlap int `json:"chunk_overlap"`
Separators []string `json:"separators"`
EnableParentChild bool `json:"enable_parent_child"`
ParentChunkSize int `json:"parent_chunk_size"`
ChildChunkSize int `json:"child_chunk_size"`
Strategy string `json:"strategy"`
TokenLimit int `json:"token_limit"`
Languages []string `json:"languages"`
}
// PreviewChunkResult describes one chunk emitted during preview.
type PreviewChunkResult struct {
Seq int `json:"seq"`
Start int `json:"start"`
End int `json:"end"`
SizeChars int `json:"size_chars"`
SizeTokensApprox int `json:"size_tokens_approx"`
ContextHeader string `json:"context_header,omitempty"`
Content string `json:"content"`
}
// PreviewChunkingStats summarizes chunk-size distribution. Computed over
// the FULL chunk set, even when the response truncates to previewMaxChunks
// items, so avg/min/max/stddev reflect the real distribution, not just
// the first N.
type PreviewChunkingStats struct {
Count int `json:"count"`
AvgChars int `json:"avg_chars"`
MinChars int `json:"min_chars"`
MaxChars int `json:"max_chars"`
StddevChars int `json:"stddev_chars"`
// TruncatedTo, when set, is the original chunk count before the
// response was truncated to previewMaxChunks for transport.
TruncatedTo int `json:"truncated_to,omitempty"`
}
// PreviewChunkingResponse is the body returned by /chunker/preview.
type PreviewChunkingResponse struct {
SelectedTier chunker.StrategyTier `json:"selected_tier"`
TierChain []chunker.StrategyTier `json:"tier_chain"`
Rejected []chunker.TierRejection `json:"rejected"`
Profile *chunker.DocProfile `json:"profile"`
Chunks []PreviewChunkResult `json:"chunks"`
Stats PreviewChunkingStats `json:"stats"`
}
// PreviewChunking handles POST /chunker/preview. It runs the supplied text
// through the adaptive chunker and returns the chunks plus diagnostic
// information about which tier won. Read-only: no DB writes, no embedding
// calls, no logging of the supplied text.
//
// PreviewChunking godoc
// @Summary 预览分块结果
// @Description 对提交的文本运行自适应分块器并返回分块预览,不写入数据库不生成 embedding。文本最大 64k 字符
// @Tags 分块
// @Accept json
// @Produce json
// @Param request body handler.PreviewChunkingRequest true "{text, chunking_config}"
// @Success 200 {object} handler.PreviewChunkingResponse "分块结果"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 413 {object} map[string]interface{} "文本超过预览限制"
// @Failure 504 {object} map[string]interface{} "分块超时"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /chunker/preview [post]
func PreviewChunking(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), previewTimeout)
defer cancel()
var req PreviewChunkingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid request body: " + err.Error()})
return
}
if strings.TrimSpace(req.Text) == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "text is empty — paste a sample to preview chunking",
})
return
}
if utf8.RuneCountInString(req.Text) > previewMaxChars {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"success": false,
"error": "text exceeds preview limit",
"limit": previewMaxChars,
})
return
}
text := chunker.NormalizeLineEndings(req.Text)
cfg := chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
ChunkSize: req.ChunkingConfig.ChunkSize,
ChunkOverlap: req.ChunkingConfig.ChunkOverlap,
Separators: req.ChunkingConfig.Separators,
Strategy: req.ChunkingConfig.Strategy,
TokenLimit: req.ChunkingConfig.TokenLimit,
Languages: req.ChunkingConfig.Languages,
})
// Run the splitter on a goroutine so we can honor the request timeout.
// The splitter is CPU-bound and doesn't accept a context — wrapping
// here is the cheapest cancellation we can offer.
type splitResult struct {
chunks []chunker.Chunk
diag *chunker.Diagnostics
}
resCh := make(chan splitResult, 1)
go func() {
var chunks []chunker.Chunk
var diag *chunker.Diagnostics
if req.ChunkingConfig.EnableParentChild {
parentCfg, childCfg := chunker.DeriveParentChildConfigs(
cfg,
req.ChunkingConfig.ParentChunkSize,
req.ChunkingConfig.ChildChunkSize,
)
result, parentDiag := chunker.SplitParentChildWithDiagnostics(text, parentCfg, childCfg)
chunks = make([]chunker.Chunk, len(result.Children))
for i, child := range result.Children {
chunks[i] = child.Chunk
}
diag = parentDiag
} else {
chunks, diag = chunker.SplitWithDiagnostics(text, cfg)
}
resCh <- splitResult{chunks: chunks, diag: diag}
}()
var sr splitResult
select {
case sr = <-resCh:
case <-ctx.Done():
c.JSON(http.StatusGatewayTimeout, gin.H{
"success": false,
"error": "chunker preview timed out",
})
return
}
chunks, diag := sr.chunks, sr.diag
// Diagnostics carries the profile when auto-strategy ran; for explicit
// strategies the profile is nil and we materialize it here so the UI
// can still show document stats. Avoids the previous double-pass.
profile := diag.Profile
if profile == nil {
profile = chunker.ProfileDocument(text)
}
logger.Debugf(ctx, "chunker preview: tier=%s chunks=%d", diag.SelectedTier, len(chunks))
lang := chunker.LangMixed
if len(profile.DetectedLangs) > 0 {
lang = profile.DetectedLangs[0]
}
// Compute rune lengths once per chunk; reused for stats and result
// payload below. Avoids the previous triple-pass over each chunk's
// content (stats + result + ApproxTokenCount each rune-counted).
runeLens := make([]int, len(chunks))
for i, ch := range chunks {
runeLens[i] = utf8.RuneCountInString(ch.Content)
}
// Compute stats over the FULL chunk set first so the metrics stay
// representative even when we trim the response to previewMaxChunks.
totalCount := len(chunks)
stats := computeChunkSizeStats(runeLens)
if totalCount > previewMaxChunks {
stats.TruncatedTo = totalCount
chunks = chunks[:previewMaxChunks]
runeLens = runeLens[:previewMaxChunks]
}
results := make([]PreviewChunkResult, 0, len(chunks))
for i, ch := range chunks {
results = append(results, PreviewChunkResult{
Seq: ch.Seq,
Start: ch.Start,
End: ch.End,
SizeChars: runeLens[i],
SizeTokensApprox: chunker.ApproxTokenCountFromRuneLen(runeLens[i], lang),
ContextHeader: ch.ContextHeader,
Content: ch.Content,
})
}
resp := PreviewChunkingResponse{
SelectedTier: diag.SelectedTier,
TierChain: diag.TierChain,
Rejected: diag.Rejected,
Profile: profile,
Chunks: results,
Stats: stats,
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
// computeChunkSizeStats summarizes count / avg / min / max / stddev from
// a pre-computed rune-length slice. Decoupling from chunker.Chunk lets
// the caller compute rune lengths once and reuse them for the response
// payload (avoids a second []rune allocation per chunk).
func computeChunkSizeStats(runeLens []int) PreviewChunkingStats {
stats := PreviewChunkingStats{Count: len(runeLens)}
if len(runeLens) == 0 {
return stats
}
var sum, sumSq float64
minLen, maxLen := math.MaxInt32, 0
for _, l := range runeLens {
sum += float64(l)
sumSq += float64(l) * float64(l)
if l < minLen {
minLen = l
}
if l > maxLen {
maxLen = l
}
}
avg := sum / float64(len(runeLens))
variance := sumSq/float64(len(runeLens)) - avg*avg
if variance < 0 {
// Float precision can push the variance slightly below zero on
// near-uniform inputs; clamp so sqrt doesn't return NaN.
variance = 0
}
stats.AvgChars = int(avg + 0.5)
stats.MinChars = minLen
stats.MaxChars = maxLen
stats.StddevChars = int(math.Sqrt(variance) + 0.5)
return stats
}
+342
View File
@@ -0,0 +1,342 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/infrastructure/chunker"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestComputeChunkSizeStats_Empty(t *testing.T) {
stats := computeChunkSizeStats(nil)
if stats.Count != 0 || stats.AvgChars != 0 || stats.MaxChars != 0 {
t.Errorf("empty input should yield zero stats, got %+v", stats)
}
}
func TestComputeChunkSizeStats_SingleChunk(t *testing.T) {
stats := computeChunkSizeStats([]int{500})
if stats.Count != 1 {
t.Errorf("count: got %d want 1", stats.Count)
}
if stats.AvgChars != 500 || stats.MinChars != 500 || stats.MaxChars != 500 {
t.Errorf("single-chunk stats should all equal 500, got %+v", stats)
}
if stats.StddevChars != 0 {
t.Errorf("stddev for one element should be 0, got %d", stats.StddevChars)
}
}
func TestComputeChunkSizeStats_VaryingSizes(t *testing.T) {
// 100, 200, 300, 400, 500 → avg 300, stddev ≈ 141
stats := computeChunkSizeStats([]int{100, 200, 300, 400, 500})
if stats.Count != 5 {
t.Errorf("count: got %d want 5", stats.Count)
}
if stats.AvgChars != 300 {
t.Errorf("avg: got %d want 300", stats.AvgChars)
}
if stats.MinChars != 100 || stats.MaxChars != 500 {
t.Errorf("min/max: got %d/%d want 100/500", stats.MinChars, stats.MaxChars)
}
if stats.StddevChars < 130 || stats.StddevChars > 150 {
t.Errorf("stddev: got %d, want ~141", stats.StddevChars)
}
}
func TestComputeChunkSizeStats_NoVarianceUnderflow(t *testing.T) {
// All identical — variance must clamp to 0 not flip negative on
// float-precision rounding.
stats := computeChunkSizeStats([]int{1234, 1234, 1234, 1234})
if stats.StddevChars != 0 {
t.Errorf("identical values must yield stddev=0, got %d", stats.StddevChars)
}
}
// --- PreviewChunking httptest -------------------------------------------------
func newPreviewRouter() *gin.Engine {
r := gin.New()
r.POST("/chunker/preview", PreviewChunking)
return r
}
func postPreview(t *testing.T, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
r := newPreviewRouter()
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
t.Fatalf("encode body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/chunker/preview", buf)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var parsed map[string]any
if w.Body.Len() > 0 {
_ = json.Unmarshal(w.Body.Bytes(), &parsed)
}
return w, parsed
}
func TestPreviewChunking_HappyPath_AutoStrategy(t *testing.T) {
body := PreviewChunkingRequest{
Text: "# Top\nintro paragraph here.\n\n## Section A\nbody A.\n\n## Section B\nbody B.",
ChunkingConfig: PreviewChunkingPayload{
ChunkSize: 200,
ChunkOverlap: 20,
Separators: []string{"\n\n", "\n"},
Strategy: "auto",
},
}
w, parsed := postPreview(t, body)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", w.Code, w.Body.String())
}
if parsed["success"] != true {
t.Fatalf("success flag missing or false: %v", parsed)
}
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("data missing: %v", parsed)
}
if data["selected_tier"] == "" {
t.Errorf("selected_tier must be set, got %v", data["selected_tier"])
}
if _, ok := data["chunks"].([]any); !ok {
t.Errorf("chunks must be an array, got %T", data["chunks"])
}
stats, ok := data["stats"].(map[string]any)
if !ok {
t.Fatalf("stats must be an object, got %T", data["stats"])
}
if c, _ := stats["count"].(float64); c <= 0 {
t.Errorf("stats.count should be > 0, got %v", stats["count"])
}
}
func TestPreviewChunking_RejectsEmptyText(t *testing.T) {
w, parsed := postPreview(t, PreviewChunkingRequest{Text: " \n\t "})
if w.Code != http.StatusBadRequest {
t.Errorf("status: got %d want 400", w.Code)
}
if errStr, _ := parsed["error"].(string); !strings.Contains(errStr, "empty") {
t.Errorf("error should mention 'empty', got %q", errStr)
}
}
func TestPreviewChunking_RejectsOversizedText(t *testing.T) {
body := PreviewChunkingRequest{Text: strings.Repeat("a", previewMaxChars+1)}
w, parsed := postPreview(t, body)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status: got %d want 413", w.Code)
}
if parsed["limit"] == nil {
t.Errorf("response should include limit hint, got %v", parsed)
}
}
func TestPreviewChunking_LegacyStrategy_NoProfile(t *testing.T) {
// Auto-strategy is the only path that produces a profile inside
// SplitWithDiagnostics. For explicit strategies the handler
// materializes one itself so the UI always sees stats.
body := PreviewChunkingRequest{
Text: "para one.\n\npara two.\n\npara three.\n\npara four.",
ChunkingConfig: PreviewChunkingPayload{
ChunkSize: 100,
ChunkOverlap: 10,
Separators: []string{"\n\n"},
Strategy: "legacy",
},
}
w, parsed := postPreview(t, body)
if w.Code != http.StatusOK {
t.Fatalf("status %d body=%s", w.Code, w.Body.String())
}
data := parsed["data"].(map[string]any)
if data["profile"] == nil {
t.Error("profile should be materialized for explicit strategy too")
}
if string(chunker.StrategyTier(data["selected_tier"].(string))) != string(chunker.TierLegacy) {
t.Errorf("selected_tier: got %v want %s", data["selected_tier"], chunker.TierLegacy)
}
}
func TestPreviewChunking_ChunkTruncation(t *testing.T) {
// Build text that produces > previewMaxChunks chunks.
body := PreviewChunkingRequest{
Text: strings.Repeat("x.\n\n", previewMaxChunks+50),
ChunkingConfig: PreviewChunkingPayload{
ChunkSize: 3,
ChunkOverlap: 0,
Separators: []string{"\n\n"},
Strategy: "legacy",
},
}
w, parsed := postPreview(t, body)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
data := parsed["data"].(map[string]any)
chunks := data["chunks"].([]any)
if len(chunks) > previewMaxChunks {
t.Errorf("chunks should be truncated to ≤%d, got %d", previewMaxChunks, len(chunks))
}
stats := data["stats"].(map[string]any)
if truncated, _ := stats["truncated_to"].(float64); int(truncated) <= previewMaxChunks {
t.Errorf("stats.truncated_to should reflect ORIGINAL count > %d, got %v", previewMaxChunks, truncated)
}
}
func TestPreviewChunking_ParentChildMatchesIngestion(t *testing.T) {
text := strings.Repeat("## Record\n"+strings.Repeat("A sufficiently long entry body. ", 10)+"\n\n", 12)
payload := PreviewChunkingPayload{
ChunkSize: 300,
ChunkOverlap: 30,
Separators: []string{"\n\n", "\n"},
EnableParentChild: true,
ParentChunkSize: 300,
ChildChunkSize: 100,
Strategy: chunker.StrategyHeading,
}
w, parsed := postPreview(t, PreviewChunkingRequest{Text: text, ChunkingConfig: payload})
if w.Code != http.StatusOK {
t.Fatalf("status %d body=%s", w.Code, w.Body.String())
}
base := chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
ChunkSize: payload.ChunkSize,
ChunkOverlap: payload.ChunkOverlap,
Separators: payload.Separators,
Strategy: payload.Strategy,
})
parentCfg, childCfg := chunker.DeriveParentChildConfigs(base, payload.ParentChunkSize, payload.ChildChunkSize)
want := chunker.SplitParentChild(text, parentCfg, childCfg)
data := parsed["data"].(map[string]any)
got := data["chunks"].([]any)
if len(got) != len(want.Children) {
t.Fatalf("chunk count: got %d want %d", len(got), len(want.Children))
}
for i, child := range want.Children {
previewChunk := got[i].(map[string]any)
if previewChunk["content"] != child.Content {
t.Errorf("chunk %d content differs", i)
}
if int(previewChunk["start"].(float64)) != child.Start || int(previewChunk["end"].(float64)) != child.End {
t.Errorf("chunk %d span: got %v-%v want %d-%d", i, previewChunk["start"], previewChunk["end"], child.Start, child.End)
}
gotHeader, _ := previewChunk["context_header"].(string)
if gotHeader != child.ContextHeader {
t.Errorf("chunk %d context_header: got %q want %q", i, gotHeader, child.ContextHeader)
}
}
}
func TestPreviewChunking_SingleLevelUnchanged(t *testing.T) {
text := strings.Repeat("Paragraph one.\n\nParagraph two.\n\n", 20)
payload := PreviewChunkingPayload{
ChunkSize: 200,
ChunkOverlap: 20,
Separators: []string{"\n\n", "\n"},
Strategy: chunker.StrategyLegacy,
}
w, parsed := postPreview(t, PreviewChunkingRequest{Text: text, ChunkingConfig: payload})
if w.Code != http.StatusOK {
t.Fatalf("status %d body=%s", w.Code, w.Body.String())
}
want, _ := chunker.SplitWithDiagnostics(text, chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
ChunkSize: payload.ChunkSize,
ChunkOverlap: payload.ChunkOverlap,
Separators: payload.Separators,
Strategy: payload.Strategy,
}))
data := parsed["data"].(map[string]any)
got := data["chunks"].([]any)
if len(got) != len(want) {
t.Fatalf("chunk count: got %d want %d", len(got), len(want))
}
}
func TestPreviewChunking_ParentChildDefaultSizes(t *testing.T) {
text := strings.Repeat("## Record\n"+strings.Repeat("A sufficiently long entry body. ", 10)+"\n\n", 12)
payload := PreviewChunkingPayload{
ChunkSize: 300,
ChunkOverlap: 30,
Separators: []string{"\n\n", "\n"},
EnableParentChild: true,
Strategy: chunker.StrategyHeading,
}
w, parsed := postPreview(t, PreviewChunkingRequest{Text: text, ChunkingConfig: payload})
if w.Code != http.StatusOK {
t.Fatalf("status %d body=%s", w.Code, w.Body.String())
}
base := chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
ChunkSize: payload.ChunkSize,
ChunkOverlap: payload.ChunkOverlap,
Separators: payload.Separators,
Strategy: payload.Strategy,
})
parentCfg, childCfg := chunker.DeriveParentChildConfigs(base, 0, 0)
want := chunker.SplitParentChild(text, parentCfg, childCfg)
data := parsed["data"].(map[string]any)
got := data["chunks"].([]any)
if len(got) != len(want.Children) {
t.Fatalf("chunk count: got %d want %d", len(got), len(want.Children))
}
}
func TestPreviewChunking_LineEndingsMatchUpload(t *testing.T) {
uploaded := strings.Repeat("## Record\r\n"+strings.Repeat("A sufficiently long entry body. ", 10)+"\r\n\r\n", 12)
pasted := strings.ReplaceAll(uploaded, "\r\n", "\n") // HTML textarea normalization
payload := PreviewChunkingPayload{
ChunkSize: 500,
ChunkOverlap: 20,
Separators: []string{"\n\n", "\n", "。", "!", "?", ";", ";"},
Strategy: chunker.StrategyHeading,
}
actual := chunker.Split(chunker.NormalizeLineEndings(uploaded), chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
ChunkSize: payload.ChunkSize,
ChunkOverlap: payload.ChunkOverlap,
Separators: payload.Separators,
Strategy: payload.Strategy,
}))
for name, text := range map[string]string{
"uploaded CRLF": uploaded,
"pasted LF": pasted,
} {
t.Run(name, func(t *testing.T) {
w, parsed := postPreview(t, PreviewChunkingRequest{Text: text, ChunkingConfig: payload})
if w.Code != http.StatusOK {
t.Fatalf("status %d body=%s", w.Code, w.Body.String())
}
data := parsed["data"].(map[string]any)
preview := data["chunks"].([]any)
if len(preview) != len(actual) {
t.Fatalf("chunk count: got %d want %d", len(preview), len(actual))
}
for i, chunk := range actual {
previewChunk := preview[i].(map[string]any)
if previewChunk["content"] != chunk.Content {
t.Errorf("chunk %d content differs", i)
}
if int(previewChunk["start"].(float64)) != chunk.Start || int(previewChunk["end"].(float64)) != chunk.End {
t.Errorf("chunk %d span: got %v-%v want %d-%d", i, previewChunk["start"], previewChunk["end"], chunk.Start, chunk.End)
}
}
})
}
}
+777
View File
@@ -0,0 +1,777 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/im"
"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"
"github.com/gin-gonic/gin"
)
// sandboxConfigLookup is the existence check an agent's sandbox selection needs.
// Narrower than the full config service so this handler cannot grow a dependency
// on config mutation.
type sandboxConfigLookup interface {
Get(ctx context.Context, tenantID uint64, id string) (*types.TenantSandboxConfigEntity, error)
}
// CustomAgentHandler defines the HTTP handler for custom agent operations
type CustomAgentHandler struct {
service interfaces.CustomAgentService
imService *im.Service
disabledRepo interfaces.TenantDisabledSharedAgentRepository
// userService 仅用于 list 接口批量回填 creator_name,作用见
// KnowledgeBaseHandler.userService。
userService interfaces.UserService
// sandboxConfigs validates an agent's sandbox backend selection. Optional —
// nil in partially-wired unit tests, where the selection is left unchecked.
sandboxConfigs sandboxConfigLookup
}
// NewCustomAgentHandler creates a new custom agent handler instance
func NewCustomAgentHandler(
service interfaces.CustomAgentService,
imService *im.Service,
disabledRepo interfaces.TenantDisabledSharedAgentRepository,
userService interfaces.UserService,
sandboxConfigs *service.TenantSandboxConfigService,
) *CustomAgentHandler {
return &CustomAgentHandler{
service: service,
imService: imService,
disabledRepo: disabledRepo,
userService: userService,
sandboxConfigs: sandboxConfigs,
}
}
// CreateAgentRequest defines the request body for creating an agent
type CreateAgentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Config types.CustomAgentConfig `json:"config"`
}
// UpdateAgentRequest defines the request body for updating an agent
type UpdateAgentRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Config types.CustomAgentConfig `json:"config"`
}
// CreateAgent godoc
// @Summary 创建智能体
// @Description 创建新的自定义智能体
// @Tags 智能体
// @Accept json
// @Produce json
// @Param request body CreateAgentRequest true "智能体信息"
// @Success 201 {object} map[string]interface{} "创建的智能体"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents [post]
func (h *CustomAgentHandler) CreateAgent(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating custom agent")
// Parse request body
var req CreateAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
return
}
if err := authorizeAgentKnowledgeScope(ctx, req.Config); err != nil {
c.Error(err)
return
}
if err := h.validateAgentSandboxConfig(ctx, req.Config); err != nil {
c.Error(err)
return
}
// Build agent object
agent := &types.CustomAgent{
Name: req.Name,
Description: req.Description,
Avatar: req.Avatar,
Config: req.Config,
Visibility: "private",
Enabled: true,
}
agent.EnsureDefaults()
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Creating custom agent, name: %s, agent_mode: %s",
secutils.SanitizeForLog(req.Name), req.Config.AgentMode)
// Create agent using the service
createdAgent, err := h.service.CreateAgent(ctx, agent)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
if appErr, ok := err.(*errors.AppError); ok {
c.Error(appErr)
return
}
if err == service.ErrAgentNameRequired {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Custom agent created successfully, ID: %s, name: %s",
secutils.SanitizeForLog(createdAgent.ID), secutils.SanitizeForLog(createdAgent.Name))
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": createdAgent,
})
}
func (h *CustomAgentHandler) DraftAgent(c *gin.Context) {
var request struct {
ModelID string `json:"model_id" binding:"required"`
Requirement string `json:"requirement" binding:"required"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32768)
if err := c.ShouldBindJSON(&request); err != nil {
c.Error(errors.NewBadRequestError("请填写 Agent 用途并选择对话模型"))
return
}
drafter, ok := h.service.(interface {
DraftAgent(context.Context, string, string) (*types.CustomAgent, error)
})
if !ok {
c.Error(errors.NewServiceUnavailableError("Agent 草稿生成服务暂不可用"))
return
}
draft, err := drafter.DraftAgent(c.Request.Context(), request.ModelID, request.Requirement)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{"operation": "agent_draft_generation"})
c.Error(errors.NewServiceUnavailableError("AI 生成 Agent 草稿失败,请重试或改为手动创建"))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": draft})
}
// GetAgent godoc
// @Summary 获取智能体详情
// @Description 根据ID获取智能体详情
// @Tags 智能体
// @Accept json
// @Produce json
// @Param id path string true "智能体ID"
// @Success 200 {object} map[string]interface{} "智能体详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "智能体不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/{id} [get]
func (h *CustomAgentHandler) GetAgent(c *gin.Context) {
ctx := c.Request.Context()
// Get agent ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Agent ID is empty")
c.Error(errors.NewBadRequestError("Agent ID cannot be empty"))
return
}
agent, err := h.service.GetAgentByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
if err == service.ErrAgentNotFound {
c.Error(errors.NewNotFoundError("Agent not found"))
return
}
if appErr, ok := err.(*errors.AppError); ok {
c.Error(appErr)
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": agent,
})
}
// ListAgents godoc
// @Summary 获取智能体列表
// @Description 获取当前空间的所有智能体(包括内置智能体)
// @Tags 智能体
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "智能体列表"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents [get]
func (h *CustomAgentHandler) ListAgents(c *gin.Context) {
ctx := c.Request.Context()
// Get all agents for this tenant
agents, err := h.service.ListAgents(ctx)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Optional creator filter — see the matching block in
// KnowledgeBaseHandler.ListKnowledgeBases for rationale. Built-in
// agents (IsBuiltin=true, CreatedBy="") are tenant-level fixtures
// rather than user creations; we always keep them regardless of the
// filter so the conversation dropdown never silently loses
// quick-answer / smart-reasoning when a user picks "Created by me".
creatorFilter := strings.ToLower(strings.TrimSpace(c.Query("creator")))
if creatorFilter == "mine" || creatorFilter == "others" {
callerUserID, _ := c.Get(types.UserIDContextKey.String())
callerUserIDStr, _ := callerUserID.(string)
filtered := make([]*types.CustomAgent, 0, len(agents))
for _, ag := range agents {
if ag.IsBuiltin {
filtered = append(filtered, ag)
continue
}
if ag.CreatedBy == "" {
continue
}
if creatorFilter == "mine" && ag.CreatedBy == callerUserIDStr {
filtered = append(filtered, ag)
} else if creatorFilter == "others" && ag.CreatedBy != callerUserIDStr {
filtered = append(filtered, ag)
}
}
agents = filtered
}
// Per-tenant "disabled by me" for own agents (only affects this tenant's conversation dropdown)
tenantIDVal, exists := c.Get(types.TenantIDContextKey.String())
if !exists {
logger.Error(ctx, "Workspace ID not found in context")
c.Error(errors.NewUnauthorizedError("Missing workspace context"))
return
}
tenantID, ok := tenantIDVal.(uint64)
if !ok {
logger.Errorf(ctx, "Tenant ID has unexpected type %T in context", tenantIDVal)
c.Error(errors.NewInternalServerError("Invalid workspace context type"))
return
}
disabledOwnIDs, err := h.disabledRepo.ListDisabledOwnAgentIDs(ctx, tenantID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
})
c.Error(errors.NewInternalServerError("Failed to list disabled agent IDs: " + err.Error()))
return
}
// 批量回填 creator_name,作用同 KB 列表:让前端能区分「我创建」与「同空间其他成员」。
// 内建 agent(IsBuiltin=true, CreatedBy="")不会有 creator_name,前端按 builtin
// 分支单独渲染。
enrichAgentCreatorNames(ctx, h.userService, agents)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": agents,
"disabled_own_agent_ids": disabledOwnIDs,
})
}
// enrichAgentCreatorNames 批量把 agent.CreatedBy 解析成展示名。失败吞掉,
// 不影响列表本身可用。与 enrichKBCreatorNames 行为对齐。
func enrichAgentCreatorNames(ctx context.Context, userSvc interfaces.UserService, agents []*types.CustomAgent) {
if userSvc == nil || len(agents) == 0 {
return
}
idSet := make(map[string]struct{}, len(agents))
for _, ag := range agents {
if ag.IsBuiltin || ag.CreatedBy == "" {
continue
}
idSet[ag.CreatedBy] = struct{}{}
}
if len(idSet) == 0 {
return
}
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
users, err := userSvc.GetUsersByIDs(ctx, ids)
if err != nil {
logger.Warnf(ctx, "Failed to resolve agent creator names: %v", err)
return
}
for _, ag := range agents {
if ag.IsBuiltin || ag.CreatedBy == "" {
continue
}
u, ok := users[ag.CreatedBy]
if !ok || u == nil {
continue
}
ag.CreatorName = pickUserDisplayName(u)
}
}
// UpdateAgent godoc
// @Summary 更新智能体
// @Description 更新智能体的名称、描述和配置
// @Tags 智能体
// @Accept json
// @Produce json
// @Param id path string true "智能体ID"
// @Param request body UpdateAgentRequest true "更新请求"
// @Success 200 {object} map[string]interface{} "更新后的智能体"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 403 {object} errors.AppError "无法修改内置智能体"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/{id} [put]
func (h *CustomAgentHandler) UpdateAgent(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating custom agent")
// Get agent ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Agent ID is empty")
c.Error(errors.NewBadRequestError("Agent ID cannot be empty"))
return
}
// Parse request body
var req UpdateAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
return
}
if err := authorizeAgentKnowledgeScope(ctx, req.Config); err != nil {
c.Error(err)
return
}
if err := h.validateAgentSandboxConfig(ctx, req.Config); err != nil {
c.Error(err)
return
}
// Build agent object
agent := &types.CustomAgent{
ID: id,
Name: req.Name,
Description: req.Description,
Avatar: req.Avatar,
Config: req.Config,
}
agent.EnsureDefaults()
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Updating custom agent, ID: %s, name: %s",
secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.Name))
// Update the agent
updatedAgent, err := h.service.UpdateAgent(ctx, agent)
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
switch err {
case service.ErrAgentNotFound:
c.Error(errors.NewNotFoundError("Agent not found"))
case service.ErrCannotModifyBuiltin:
c.Error(errors.NewForbiddenError("Cannot modify built-in agent"))
case service.ErrAgentNameRequired:
c.Error(errors.NewBadRequestError(err.Error()))
default:
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
logger.Infof(ctx, "Custom agent updated successfully, ID: %s", secutils.SanitizeForLog(id))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedAgent,
})
}
// DeleteAgent godoc
// @Summary 删除智能体
// @Description 删除指定的智能体
// @Tags 智能体
// @Accept json
// @Produce json
// @Param id path string true "智能体ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 403 {object} errors.AppError "无法删除内置智能体"
// @Failure 404 {object} errors.AppError "智能体不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/{id} [delete]
func (h *CustomAgentHandler) DeleteAgent(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting custom agent")
// Get agent ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Agent ID is empty")
c.Error(errors.NewBadRequestError("Agent ID cannot be empty"))
return
}
logger.Infof(ctx, "Deleting custom agent, ID: %s", secutils.SanitizeForLog(id))
_, ok := types.TenantIDFromContext(ctx)
if !ok {
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
if guard, ok := h.service.(interface {
CheckDigitalHumanAgentDeletion(context.Context, string) error
}); ok {
if err := guard.CheckDigitalHumanAgentDeletion(ctx, id); err != nil {
if err == types.ErrDigitalHumanPrimaryAgent {
c.Error(errors.NewConflictError(err.Error()))
} else {
c.Error(errors.NewInternalServerError("无法检查数字人关联,请稍后重试"))
}
return
}
}
// Durable channel and agent deletion commit together. Only then stop runtime
// adapters, so a failed deletion never disconnects a still-existing agent.
var channelIDs []string
var err error
if deleter, ok := h.service.(interface {
DeleteAgentWithIMChannels(context.Context, string) ([]string, error)
}); ok {
channelIDs, err = deleter.DeleteAgentWithIMChannels(ctx, id)
} else {
err = h.service.DeleteAgent(ctx, id)
}
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
switch err {
case service.ErrAgentNotFound:
c.Error(errors.NewNotFoundError("Agent not found"))
case service.ErrCannotDeleteBuiltin:
c.Error(errors.NewForbiddenError("Cannot delete built-in agent"))
case types.ErrDigitalHumanPrimaryAgent:
c.Error(errors.NewConflictError(err.Error()))
default:
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
if h.imService != nil {
h.imService.InvalidateDeletedChannels(channelIDs)
}
logger.Infof(ctx, "Custom agent deleted successfully, ID: %s", secutils.SanitizeForLog(id))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Agent deleted successfully",
})
}
// CopyAgent godoc
// @Summary 复制智能体
// @Description 复制指定的智能体
// @Tags 智能体
// @Accept json
// @Produce json
// @Param id path string true "智能体ID"
// @Success 201 {object} map[string]interface{} "复制成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "智能体不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/{id}/copy [post]
func (h *CustomAgentHandler) CopyAgent(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start copying custom agent")
// Get agent ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Agent ID is empty")
c.Error(errors.NewBadRequestError("Agent ID cannot be empty"))
return
}
logger.Infof(ctx, "Copying custom agent, ID: %s", secutils.SanitizeForLog(id))
sourceAgent, err := h.service.GetAgentByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
switch err {
case service.ErrAgentNotFound:
c.Error(errors.NewNotFoundError("Agent not found"))
default:
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
if err := authorizeAgentKnowledgeScope(ctx, sourceAgent.Config); err != nil {
c.Error(err)
return
}
// Copy the agent
copiedAgent, err := h.service.CopyAgent(ctx, id)
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
switch err {
case service.ErrAgentNotFound:
c.Error(errors.NewNotFoundError("Agent not found"))
default:
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
logger.Infof(ctx, "Custom agent copied successfully, source ID: %s, new ID: %s",
secutils.SanitizeForLog(id), secutils.SanitizeForLog(copiedAgent.ID))
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": copiedAgent,
})
}
// GetPlaceholders godoc
// @Summary 获取占位符定义
// @Description 获取所有可用的提示词占位符定义,按字段类型分组
// @Tags 智能体
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "占位符定义"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/placeholders [get]
func (h *CustomAgentHandler) GetPlaceholders(c *gin.Context) {
// Return all placeholder definitions grouped by field type
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"all": types.AllPlaceholders(),
"system_prompt": types.PlaceholdersByField(types.PromptFieldSystemPrompt),
"agent_system_prompt": types.PlaceholdersByField(types.PromptFieldAgentSystemPrompt),
"context_template": types.PlaceholdersByField(types.PromptFieldContextTemplate),
"rewrite_system_prompt": types.PlaceholdersByField(types.PromptFieldRewriteSystemPrompt),
"rewrite_prompt": types.PlaceholdersByField(types.PromptFieldRewritePrompt),
"fallback_prompt": types.PlaceholdersByField(types.PromptFieldFallbackPrompt),
},
})
}
// GetAgentTypePresets godoc
// @Summary 获取智能体类型预设列表
// @Description 返回所有 smart-reasoning 下可用的智能体类型预设(RAG/Wiki/Hybrid/Custom),用于编辑器自动填充系统提示词、工具和 KB 兼容性
// @Tags 智能体
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "预设列表"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/type-presets [get]
func (h *CustomAgentHandler) GetAgentTypePresets(c *gin.Context) {
ctx := c.Request.Context()
presets := types.ListAgentTypePresetsWithContext(ctx)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": presets,
})
}
// GetSuggestedQuestions godoc
// @Summary 获取推荐问题
// @Description 基于智能体关联的知识库,返回推荐问题供用户快捷提问
// @Tags 智能体
// @Accept json
// @Produce json
// @Param id path string true "智能体ID"
// @Param knowledge_base_ids query string false "知识库ID列表(逗号分隔),覆盖智能体默认配置"
// @Param knowledge_ids query string false "知识ID列表(逗号分隔),限定到具体文档"
// @Param tag_scopes query string false "带知识库归属的标签范围(JSON)"
// @Param limit query int false "返回数量上限(未传时使用智能体配置的开场问题数量,最大30)"
// @Success 200 {object} map[string]interface{} "推荐问题列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "智能体不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agents/{id}/suggested-questions [get]
func (h *CustomAgentHandler) GetSuggestedQuestions(c *gin.Context) {
ctx := c.Request.Context()
// Get agent ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Agent ID is empty")
c.Error(errors.NewBadRequestError("Agent ID cannot be empty"))
return
}
// Parse optional query parameters
var kbIDs []string
if kbIDsStr := strings.TrimSpace(c.Query("knowledge_base_ids")); kbIDsStr != "" {
for _, id := range strings.Split(kbIDsStr, ",") {
if trimmed := strings.TrimSpace(id); trimmed != "" {
kbIDs = append(kbIDs, trimmed)
}
}
}
var knowledgeIDs []string
if kIDsStr := strings.TrimSpace(c.Query("knowledge_ids")); kIDsStr != "" {
for _, id := range strings.Split(kIDsStr, ",") {
if trimmed := strings.TrimSpace(id); trimmed != "" {
knowledgeIDs = append(knowledgeIDs, trimmed)
}
}
}
var tagScopes []types.TagScope
if raw := strings.TrimSpace(c.Query("tag_scopes")); raw != "" {
if err := json.Unmarshal([]byte(raw), &tagScopes); err != nil {
c.Error(errors.NewBadRequestError("tag_scopes must be valid JSON"))
return
}
}
// limit == 0 signals "unspecified" so the service falls back to the agent's
// configured starter count. A provided value is passed through unchanged and
// bounded by the service's safety cap.
limit := 0
if limitStr := c.Query("limit"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
logger.Infof(ctx, "Getting suggested questions for agent %s, kbIDs: %v, tagScopes: %d, limit: %d",
secutils.SanitizeForLog(id), kbIDs, len(tagScopes), limit)
questions, err := h.service.GetSuggestedQuestions(ctx, id, kbIDs, knowledgeIDs, tagScopes, limit)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"agent_id": id,
})
if err == service.ErrAgentNotFound {
c.Error(errors.NewNotFoundError("Agent not found"))
return
}
if appErr, ok := err.(*errors.AppError); ok {
c.Error(appErr)
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"questions": questions,
},
})
}
// validateAgentSandboxConfig rejects a selection the workspace does not have.
//
// Checking at save time is what makes the mistake fixable: a dangling reference
// only fails when the agent next runs a skill, mid-conversation, as an opaque
// resolution error with no hint about which agent to edit.
func (h *CustomAgentHandler) validateAgentSandboxConfig(
ctx context.Context, cfg types.CustomAgentConfig,
) error {
configID := strings.TrimSpace(cfg.SandboxConfigID)
if configID == "" || h.sandboxConfigs == nil {
// Empty means the deployment-wide default, which always exists.
return nil
}
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return errors.NewUnauthorizedError("Missing workspace context")
}
stored, err := h.sandboxConfigs.Get(ctx, tenantID, configID)
if err != nil {
return errors.NewInternalServerError("Failed to verify sandbox config").
WithDetails(err.Error())
}
if stored == nil {
return errors.NewBadRequestError("所选沙箱后端配置不存在,请重新选择")
}
return nil
}
func authorizeAgentKnowledgeScope(ctx context.Context, cfg types.CustomAgentConfig) error {
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
if !ok || !scope.IsKnowledgeBaseRestricted() {
return nil
}
switch strings.ToLower(strings.TrimSpace(cfg.KBSelectionMode)) {
case "none":
return nil
case "all":
return errors.NewForbiddenError("API key scope does not allow agents that use all knowledge bases")
case "selected":
return types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, cfg.KnowledgeBases...)
default:
if len(cfg.KnowledgeBases) == 0 {
return nil
}
return types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, cfg.KnowledgeBases...)
}
}
@@ -0,0 +1,94 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type suggestedQuestionsAgentService struct {
interfaces.CustomAgentService
err error
tagScopes []types.TagScope
}
func (s *suggestedQuestionsAgentService) GetSuggestedQuestions(
_ context.Context,
_ string,
_ []string,
_ []string,
tagScopes []types.TagScope,
_ int,
) ([]types.SuggestedQuestion, error) {
s.tagScopes = tagScopes
return nil, s.err
}
func TestGetSuggestedQuestionsPreservesAppErrorStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &CustomAgentHandler{service: &suggestedQuestionsAgentService{
err: apperrors.NewForbiddenError("API key scope does not allow one or more knowledge bases"),
}}
r.GET("/agents/:id/suggested-questions", h.GetSuggestedQuestions)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/agents/agent-1/suggested-questions?knowledge_base_ids=kb-blocked", nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
func TestGetSuggestedQuestionsParsesScopedTags(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
service := &suggestedQuestionsAgentService{}
h := &CustomAgentHandler{service: service}
r.GET("/agents/:id/suggested-questions", h.GetSuggestedQuestions)
rawScopes := `[{"knowledge_base_id":"kb-1","tag_ids":["tag-1","tag-2"]}]`
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/agents/agent-1/suggested-questions?tag_scopes="+url.QueryEscape(rawScopes),
nil,
)
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, []types.TagScope{{KnowledgeBaseID: "kb-1", TagIDs: []string{"tag-1", "tag-2"}}}, service.tagScopes)
}
func TestGetSuggestedQuestionsRejectsInvalidScopedTags(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &CustomAgentHandler{service: &suggestedQuestionsAgentService{}}
r.GET("/agents/:id/suggested-questions", h.GetSuggestedQuestions)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/agents/agent-1/suggested-questions?tag_scopes="+url.QueryEscape("not-json"),
nil,
)
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
}
@@ -0,0 +1,50 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type draftHandlerService struct {
interfaces.CustomAgentService
calls int
}
func (s *draftHandlerService) DraftAgent(ctx context.Context, model, requirement string) (*types.CustomAgent, error) {
s.calls++
return &types.CustomAgent{Name: requirement, Config: types.CustomAgentConfig{ModelID: model}}, nil
}
func TestAgentDraftHandlerReturnsUnsavedEditableDraft(t *testing.T) {
svc := &draftHandlerService{}
h := &CustomAgentHandler{service: svc}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/agents/draft", strings.NewReader(`{"model_id":"selected","requirement":"Plan a course"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.DraftAgent(c)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 1, svc.calls)
require.Contains(t, w.Body.String(), `"model_id":"selected"`)
require.Contains(t, w.Body.String(), `"id":""`)
}
func TestAgentDraftHandlerRejectsMissingAndOversizedInput(t *testing.T) {
for _, body := range []string{`{"requirement":"Plan"}`, `{"model_id":"selected","requirement":"` + strings.Repeat("x", 33000) + `"}`} {
svc := &draftHandlerService{}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/agents/draft", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
(&CustomAgentHandler{service: svc}).DraftAgent(c)
require.NotEmpty(t, c.Errors)
require.Zero(t, svc.calls)
}
}
@@ -0,0 +1,117 @@
package handler
import (
"context"
"encoding/json"
stderrors "errors"
"net/http"
"testing"
"github.com/stretchr/testify/require"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
)
// stubSandboxConfigLookup answers the existence check without a database.
type stubSandboxConfigLookup struct {
entity *types.TenantSandboxConfigEntity
err error
lookups []string
}
func (s *stubSandboxConfigLookup) Get(
_ context.Context, _ uint64, id string,
) (*types.TenantSandboxConfigEntity, error) {
s.lookups = append(s.lookups, id)
return s.entity, s.err
}
func agentTenantContext() context.Context {
return context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
}
// The field must round-trip through the agent's JSON config column, since that
// is where it is persisted (no dedicated column).
func TestAgentConfigCarriesSandboxConfigID(t *testing.T) {
cfg := types.CustomAgentConfig{
AgentMode: "smart-reasoning",
SandboxConfigID: "cfg-a",
}
raw, err := json.Marshal(cfg)
require.NoError(t, err)
require.Contains(t, string(raw), `"sandbox_config_id":"cfg-a"`)
var back types.CustomAgentConfig
require.NoError(t, json.Unmarshal(raw, &back))
require.Equal(t, "cfg-a", back.SandboxConfigID)
}
// Empty must be omitted so existing agents keep an unchanged config payload.
func TestAgentConfigOmitsEmptySandboxConfigID(t *testing.T) {
raw, err := json.Marshal(types.CustomAgentConfig{AgentMode: "quick-answer"})
require.NoError(t, err)
require.NotContains(t, string(raw), "sandbox_config_id")
}
// Saving a dangling reference must fail here with a readable message. Deferring
// it means the agent looks fine until its first skill execution, which then dies
// on an opaque resolver error mid-conversation.
func TestAgentSandboxConfigValidationRejectsUnknownID(t *testing.T) {
lookup := &stubSandboxConfigLookup{}
h := &CustomAgentHandler{sandboxConfigs: lookup}
err := h.validateAgentSandboxConfig(agentTenantContext(),
types.CustomAgentConfig{SandboxConfigID: "cfg-gone"})
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
require.Equal(t, http.StatusBadRequest, appErr.HTTPCode)
require.Equal(t, []string{"cfg-gone"}, lookup.lookups)
}
func TestAgentSandboxConfigValidationAcceptsExistingID(t *testing.T) {
lookup := &stubSandboxConfigLookup{
entity: &types.TenantSandboxConfigEntity{ID: "cfg-a", TenantID: 7},
}
h := &CustomAgentHandler{sandboxConfigs: lookup}
require.NoError(t, h.validateAgentSandboxConfig(agentTenantContext(),
types.CustomAgentConfig{SandboxConfigID: "cfg-a"}))
}
// Most agents run on the deployment default, so the common path must not pay for
// a lookup - and must never be rejected for leaving the field blank.
func TestAgentSandboxConfigValidationSkipsEmptyID(t *testing.T) {
lookup := &stubSandboxConfigLookup{}
h := &CustomAgentHandler{sandboxConfigs: lookup}
require.NoError(t, h.validateAgentSandboxConfig(agentTenantContext(),
types.CustomAgentConfig{SandboxConfigID: " "}))
require.Empty(t, lookup.lookups)
}
// A lookup failure is not the admin's fault; it must not read as "you picked a
// config that does not exist".
func TestAgentSandboxConfigValidationSurfacesLookupFailure(t *testing.T) {
lookup := &stubSandboxConfigLookup{err: stderrors.New("database is on fire")}
h := &CustomAgentHandler{sandboxConfigs: lookup}
err := h.validateAgentSandboxConfig(agentTenantContext(),
types.CustomAgentConfig{SandboxConfigID: "cfg-a"})
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
require.Equal(t, http.StatusInternalServerError, appErr.HTTPCode)
}
// Partially-wired handlers appear in tests and in deployments without the
// config service; they must not reject every agent that selects a backend.
func TestAgentSandboxConfigValidationSkipsWhenServiceMissing(t *testing.T) {
h := &CustomAgentHandler{}
require.NoError(t, h.validateAgentSandboxConfig(agentTenantContext(),
types.CustomAgentConfig{SandboxConfigID: "cfg-a"}))
}
+612
View File
@@ -0,0 +1,612 @@
package handler
import (
"context"
"net/http"
"strconv"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// DataSourceHandler handles HTTP requests for data source management
type DataSourceHandler struct {
service interfaces.DataSourceService
kbService interfaces.KnowledgeBaseService
}
// NewDataSourceHandler creates a new data source handler
func NewDataSourceHandler(
service interfaces.DataSourceService,
kbService interfaces.KnowledgeBaseService,
) *DataSourceHandler {
return &DataSourceHandler{
service: service,
kbService: kbService,
}
}
// getTenantID safely extracts and validates tenant ID from context
// Returns 0 if tenant ID is not found (caller should return 401)
func (h *DataSourceHandler) getTenantID(c *gin.Context) uint64 {
tenantID := c.GetUint64(types.TenantIDContextKey.String())
return tenantID
}
// Data source settings contain connector credentials, so only the owning tenant can access them.
func (h *DataSourceHandler) getOwnedKnowledgeBase(
ctx context.Context,
tenantID uint64,
kbID string,
) (*types.KnowledgeBase, int, string) {
if kbID == "" {
return nil, http.StatusBadRequest, "kb_id is required"
}
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil || kb == nil {
return nil, http.StatusNotFound, "knowledge base not found"
}
if kb.TenantID != tenantID {
return nil, http.StatusForbidden, "access denied"
}
if err := types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, kbID); err != nil {
return nil, http.StatusForbidden, err.Error()
}
return kb, http.StatusOK, ""
}
func (h *DataSourceHandler) getOwnedDataSource(
ctx context.Context,
tenantID uint64,
id string,
) (*types.DataSource, int, string) {
ds, err := h.service.GetDataSource(ctx, id)
if err != nil {
return nil, http.StatusNotFound, "data source not found"
}
if _, status, msg := h.getOwnedKnowledgeBase(ctx, tenantID, ds.KnowledgeBaseID); status != http.StatusOK {
return nil, status, msg
}
return ds, http.StatusOK, ""
}
// CreateDataSource godoc
// @Summary Create a new data source
// @Description Create a new data source configuration for a knowledge base
// @Tags DataSource
// @Accept json
// @Produce json
// @Param request body types.DataSource true "Data source configuration"
// @Success 201 {object} types.DataSource
// @Failure 400 {object} map[string]string
// @Router /datasource [post]
func (h *DataSourceHandler) CreateDataSource(c *gin.Context) {
ctx := c.Request.Context()
// Extract tenant ID from context (set by auth middleware)
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
var req types.DataSource
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if _, status, msg := h.getOwnedKnowledgeBase(ctx, tenantID, req.KnowledgeBaseID); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
// Enforce tenant isolation
req.TenantID = tenantID
ds, err := h.service.CreateDataSource(ctx, &req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, dto.NewDataSourceResponse(ds))
}
// GetDataSource godoc
// @Summary Get a data source by ID
// @Description Retrieve a data source configuration by ID
// @Tags DataSource
// @Produce json
// @Param id path string true "Data source ID"
// @Success 200 {object} types.DataSource
// @Failure 404 {object} map[string]string
// @Router /datasource/{id} [get]
func (h *DataSourceHandler) GetDataSource(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
ds, status, msg := h.getOwnedDataSource(ctx, tenantID, id)
if status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, dto.NewDataSourceResponse(ds))
}
// ListDataSources godoc
// @Summary List data sources for a knowledge base
// @Description List all data sources for a specific knowledge base
// @Tags DataSource
// @Produce json
// @Param kb_id query string true "Knowledge base ID"
// @Success 200 {object} []types.DataSource
// @Failure 400 {object} map[string]string
// @Router /datasource [get]
func (h *DataSourceHandler) ListDataSources(c *gin.Context) {
ctx := c.Request.Context()
// Extract tenant ID from context
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: workspace context missing"})
return
}
kbID := c.Query("kb_id")
if _, status, msg := h.getOwnedKnowledgeBase(ctx, tenantID, kbID); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
dataSources, err := h.service.ListDataSources(ctx, kbID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list data sources"})
return
}
if dataSources == nil {
dataSources = make([]*types.DataSource, 0)
}
c.JSON(http.StatusOK, dto.NewDataSourceResponses(dataSources))
}
// UpdateDataSource godoc
// @Summary Update a data source
// @Description Update an existing data source configuration
// @Tags DataSource
// @Accept json
// @Produce json
// @Param id path string true "Data source ID"
// @Param request body types.DataSource true "Updated configuration"
// @Success 200 {object} types.DataSource
// @Failure 400 {object} map[string]string
// @Router /datasource/{id} [put]
func (h *DataSourceHandler) UpdateDataSource(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
var req types.DataSource
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
existing, status, msg := h.getOwnedDataSource(ctx, tenantID, id)
if status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
req.ID = id
req.TenantID = existing.TenantID
req.KnowledgeBaseID = existing.KnowledgeBaseID
ds, err := h.service.UpdateDataSource(ctx, &req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, dto.NewDataSourceResponse(ds))
}
// DeleteDataSource godoc
// @Summary Delete a data source
// @Description Delete a data source (soft delete)
// @Tags DataSource
// @Param id path string true "Data source ID"
// @Success 204
// @Failure 404 {object} map[string]string
// @Router /datasource/{id} [delete]
func (h *DataSourceHandler) DeleteDataSource(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
if err := h.service.DeleteDataSource(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data source"})
return
}
c.Status(http.StatusNoContent)
}
// ValidateConnection godoc
// @Summary Test data source connection
// @Description Validate the connection to an external data source
// @Tags DataSource
// @Param id path string true "Data source ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/validate [post]
func (h *DataSourceHandler) ValidateConnection(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
if err := h.service.ValidateConnection(ctx, id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "connected"})
}
// ValidateCredentials godoc
// @Summary Test connection with raw credentials (no persistence)
// @Description Validate connectivity to an external data source using type + credentials
//
// without creating or updating any database records.
// Used by the frontend "Test Connection" button during data source creation.
//
// @Tags DataSource
// @Accept json
// @Produce json
// @Param request body object true "type and credentials"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /datasource/validate-credentials [post]
func (h *DataSourceHandler) ValidateCredentials(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req struct {
Type string `json:"type" binding:"required"`
Credentials map[string]interface{} `json:"credentials" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: type and credentials are required"})
return
}
if err := h.service.ValidateCredentials(ctx, req.Type, req.Credentials); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "connected"})
}
// @Summary List available resources in data source
// @Description List resources available for sync in the external system. Pass parent_id to lazily load the direct children of a resource (used for large hierarchical sources such as Feishu wiki).
// @Tags DataSource
// @Produce json
// @Param id path string true "Data source ID"
// @Param parent_id query string false "Parent resource ExternalID; empty lists the top level"
// @Success 200 {object} []types.Resource
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/resources [get]
func (h *DataSourceHandler) ListAvailableResources(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
parentID := c.Query("parent_id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
resources, err := h.service.ListAvailableResources(ctx, id, parentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if resources == nil {
resources = make([]types.Resource, 0)
}
c.JSON(http.StatusOK, resources)
}
// @Summary Resolve resource ancestors
// @Description Resolve the ancestor ExternalIDs that must be expanded to reveal the given (possibly deeply nested) resources in a lazily-loaded picker. Used to restore an existing selection when editing a data source.
// @Tags DataSource
// @Accept json
// @Produce json
// @Param id path string true "Data source ID"
// @Param request body resolveAncestorsRequest true "Resource IDs to resolve"
// @Success 200 {object} map[string][]string
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/resource-ancestors [post]
func (h *DataSourceHandler) ResolveResourceAncestors(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
var req resolveAncestorsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ancestors, err := h.service.ResolveResourceAncestors(ctx, id, req.ResourceIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ancestors == nil {
ancestors = make([]string, 0)
}
c.JSON(http.StatusOK, gin.H{"ancestors": ancestors})
}
// resolveAncestorsRequest is the body for ResolveResourceAncestors.
type resolveAncestorsRequest struct {
ResourceIDs []string `json:"resource_ids"`
}
// ManualSync godoc
// @Summary Trigger immediate sync
// @Description Trigger an immediate sync for a data source
// @Tags DataSource
// @Param id path string true "Data source ID"
// @Success 200 {object} types.SyncLog
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/sync [post]
func (h *DataSourceHandler) ManualSync(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
syncLog, err := h.service.ManualSync(ctx, id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, syncLog)
}
// PauseDataSource godoc
// @Summary Pause data source
// @Description Pause a data source's scheduled syncs
// @Tags DataSource
// @Param id path string true "Data source ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/pause [post]
func (h *DataSourceHandler) PauseDataSource(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
if err := h.service.PauseDataSource(ctx, id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "paused"})
}
// ResumeDataSource godoc
// @Summary Resume data source
// @Description Resume a paused data source
// @Tags DataSource
// @Param id path string true "Data source ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/resume [post]
func (h *DataSourceHandler) ResumeDataSource(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
if err := h.service.ResumeDataSource(ctx, id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "active"})
}
// GetSyncLogs godoc
// @Summary Get sync logs
// @Description Retrieve sync history for a data source
// @Tags DataSource
// @Produce json
// @Param id path string true "Data source ID"
// @Param limit query int false "Limit (default: 10)"
// @Param offset query int false "Offset (default: 0)"
// @Success 200 {object} []types.SyncLog
// @Failure 400 {object} map[string]string
// @Router /datasource/{id}/logs [get]
func (h *DataSourceHandler) GetSyncLogs(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, id); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
limit := 10
offset := 0
if l := c.Query("limit"); l != "" {
v, err := strconv.Atoi(l)
if err != nil || v <= 0 || v > maxListPageSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be between 1 and " + strconv.Itoa(maxListPageSize)})
return
}
limit = v
}
if o := c.Query("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
logs, err := h.service.GetSyncLogs(ctx, id, limit, offset)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if logs == nil {
logs = make([]*types.SyncLog, 0)
}
c.JSON(http.StatusOK, logs)
}
// GetSyncLog godoc
// @Summary Get specific sync log
// @Description Retrieve a specific sync log entry
// @Tags DataSource
// @Produce json
// @Param log_id path string true "Sync log ID"
// @Success 200 {object} types.SyncLog
// @Failure 404 {object} map[string]string
// @Router /datasource/logs/{log_id} [get]
func (h *DataSourceHandler) GetSyncLog(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.getTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
logID := c.Param("log_id")
log, err := h.service.GetSyncLog(ctx, logID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sync log not found"})
return
}
if _, status, msg := h.getOwnedDataSource(ctx, tenantID, log.DataSourceID); status != http.StatusOK {
c.JSON(status, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, log)
}
// GetAvailableConnectors godoc
// @Summary Get available connectors
// @Description Get list of available data source connectors
// @Tags DataSource
// @Produce json
// @Success 200 {object} []datasource.ConnectorMetadata
// @Router /datasource/types [get]
func (h *DataSourceHandler) GetAvailableConnectors(c *gin.Context) {
connectors := datasource.ListAvailableConnectors()
c.JSON(http.StatusOK, connectors)
}
+115
View File
@@ -0,0 +1,115 @@
package handler
import (
"net/http"
"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"
"github.com/gin-gonic/gin"
)
// DataSourceCredentialsHandler handles credentials for data source
// connectors via the dedicated /credentials subresource.
//
// Unlike the other three resources (MCP / Model / WebSearch), DataSource
// credentials are a per-connector atomic map — there's no individual-field
// PUT or DELETE because half-configured connector auth doesn't work. So we
// expose a single logical field "credentials": GET returns whether anything
// is stored, PUT replaces the whole map, DELETE wipes it.
type DataSourceCredentialsHandler struct {
service interfaces.DataSourceService
kbService interfaces.KnowledgeBaseService
}
func NewDataSourceCredentialsHandler(
service interfaces.DataSourceService,
kbService interfaces.KnowledgeBaseService,
) *DataSourceCredentialsHandler {
return &DataSourceCredentialsHandler{service: service, kbService: kbService}
}
// ownDataSource is the same tenant-isolation check used in datasource.go,
// duplicated here to avoid coupling the two handlers via internal helpers.
func (h *DataSourceCredentialsHandler) ownDataSource(c *gin.Context) (*types.DataSource, bool) {
ctx := c.Request.Context()
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return nil, false
}
id := c.Param("id")
ds, err := h.service.GetDataSource(ctx, id)
if err != nil || ds == nil {
c.Error(errors.NewNotFoundError("data source not found"))
return nil, false
}
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, ds.KnowledgeBaseID)
if err != nil || kb == nil || kb.TenantID != tenantID {
c.Error(errors.NewNotFoundError("data source not found"))
return nil, false
}
return ds, true
}
type dataSourceCredentialsPutRequest struct {
Credentials map[string]interface{} `json:"credentials" binding:"required"`
}
func (h *DataSourceCredentialsHandler) Put(c *gin.Context) {
ds, ok := h.ownDataSource(c)
if !ok {
return
}
var req dataSourceCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if len(req.Credentials) == 0 {
c.Error(errors.NewBadRequestError(
"credentials map must be non-empty; to remove credentials use DELETE /credentials/credentials"))
return
}
updated, err := h.service.UpdateDataSourceCredentials(c.Request.Context(), ds.ID, req.Credentials)
if err != nil {
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{
"data_source_id": secutils.SanitizeForLog(ds.ID),
})
c.Error(errors.NewBadRequestError("failed to update credentials: " + err.Error()))
return
}
configured := false
if parsed, err := updated.ParseConfig(); err == nil && parsed != nil {
configured = parsed.HasConfiguredCredentials(updated.Type)
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"credentials": {Configured: configured},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func (h *DataSourceCredentialsHandler) DeleteField(c *gin.Context) {
ds, ok := h.ownDataSource(c)
if !ok {
return
}
field := c.Param("field")
if field != "credentials" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.service.ClearDataSourceCredentials(c.Request.Context(), ds.ID); err != nil {
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{
"data_source_id": secutils.SanitizeForLog(ds.ID),
})
c.Error(errors.NewInternalServerError("failed to clear credentials: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}
+256
View File
@@ -0,0 +1,256 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
type stubDataSourceService struct {
interfaces.DataSourceService
getSyncLogs func(ctx context.Context, dsID string, limit int, offset int) ([]*types.SyncLog, error)
getDataSource func(ctx context.Context, id string) (*types.DataSource, error)
}
func (s *stubDataSourceService) GetSyncLogs(ctx context.Context, dsID string, limit int, offset int) ([]*types.SyncLog, error) {
if s.getSyncLogs != nil {
return s.getSyncLogs(ctx, dsID, limit, offset)
}
return nil, nil
}
func (s *stubDataSourceService) GetDataSource(ctx context.Context, id string) (*types.DataSource, error) {
if s.getDataSource != nil {
return s.getDataSource(ctx, id)
}
return nil, nil
}
type stubKBServiceForDS struct {
interfaces.KnowledgeBaseService
getByID func(ctx context.Context, id string) (*types.KnowledgeBase, error)
}
func (s *stubKBServiceForDS) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
if s.getByID != nil {
return s.getByID(ctx, id)
}
return nil, nil
}
func newDataSourceTestRouter(h *DataSourceHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(errorCapture())
r.Use(func(c *gin.Context) {
if tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64); ok {
c.Set(types.TenantIDContextKey.String(), tenantID)
}
c.Next()
})
r.GET("/datasource/:id/logs", h.GetSyncLogs)
return r
}
func withDSCtx(req *http.Request, tenantID uint64) *http.Request {
ctx := req.Context()
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
return req.WithContext(ctx)
}
func TestDataSource_GetSyncLogs_ValidLimitWithinBounds(t *testing.T) {
var capturedLimit, capturedOffset int
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, limit int, offset int) ([]*types.SyncLog, error) {
capturedLimit = limit
capturedOffset = offset
return []*types.SyncLog{
{ID: "log1", DataSourceID: "ds1"},
}, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs?limit=50&offset=25", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
if capturedLimit != 50 {
t.Fatalf("expected limit=50, got %d", capturedLimit)
}
if capturedOffset != 25 {
t.Fatalf("expected offset=25, got %d", capturedOffset)
}
}
func TestDataSource_GetSyncLogs_LimitExceedingMaximum(t *testing.T) {
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, _ int, _ int) ([]*types.SyncLog, error) {
t.Fatalf("service must not be called when limit exceeds maximum")
return nil, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs?limit=999", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for limit > 100, got %d body=%s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
errMsg, ok := resp["error"].(string)
if !ok || errMsg == "" {
t.Fatalf("expected error message in response")
}
if errMsg != "limit must be between 1 and 100" {
t.Fatalf("expected specific error message, got %q", errMsg)
}
}
func TestDataSource_GetSyncLogs_MissingLimitDefaultsCorrectly(t *testing.T) {
var capturedLimit, capturedOffset int
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, limit int, offset int) ([]*types.SyncLog, error) {
capturedLimit = limit
capturedOffset = offset
return []*types.SyncLog{}, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
if capturedLimit != 10 {
t.Fatalf("expected default limit=10, got %d", capturedLimit)
}
if capturedOffset != 0 {
t.Fatalf("expected default offset=0 (page 1), got %d", capturedOffset)
}
}
func TestDataSource_GetSyncLogs_NonNumericLimitRejected(t *testing.T) {
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, _ int, _ int) ([]*types.SyncLog, error) {
t.Fatalf("service must not be called with non-numeric limit")
return nil, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs?limit=abc", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for non-numeric limit, got %d body=%s", w.Code, w.Body.String())
}
}
func TestDataSource_GetSyncLogs_ZeroLimitRejected(t *testing.T) {
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, _ int, _ int) ([]*types.SyncLog, error) {
t.Fatalf("service must not be called with limit=0")
return nil, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs?limit=0", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for limit=0, got %d body=%s", w.Code, w.Body.String())
}
}
func TestDataSource_GetSyncLogs_NegativeLimitRejected(t *testing.T) {
dsSvc := &stubDataSourceService{
getDataSource: func(_ context.Context, id string) (*types.DataSource, error) {
return &types.DataSource{ID: id, KnowledgeBaseID: "kb1"}, nil
},
getSyncLogs: func(_ context.Context, _ string, _ int, _ int) ([]*types.SyncLog, error) {
t.Fatalf("service must not be called with negative limit")
return nil, nil
},
}
kbSvc := &stubKBServiceForDS{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb1", TenantID: 1}, nil
},
}
h := NewDataSourceHandler(dsSvc, kbSvc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/datasource/ds1/logs?limit=-5", nil)
req = withDSCtx(req, 1)
newDataSourceTestRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for negative limit, got %d body=%s", w.Code, w.Body.String())
}
}
+139
View File
@@ -0,0 +1,139 @@
package handler
import (
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/gin-gonic/gin"
)
// DeploymentCapabilityKeys is the canonical capability key list shared with
// frontend/src/config/deploymentCapabilities.ts — keep both in sync.
var DeploymentCapabilityKeys = []string{
"organizations",
"agents",
"integrations.im",
"integrations.embed",
"integrations.api",
"settings.mcp",
"settings.websearch",
"settings.vectorstore",
"settings.storage",
"settings.sandbox",
"settings.sandbox.docker",
}
// DeploymentCapability describes whether a deployment exposes a feature route.
type DeploymentCapability struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
}
// DeploymentCapabilitiesData is returned by GET /system/capabilities.
type DeploymentCapabilitiesData struct {
Edition string `json:"edition"`
Capabilities map[string]DeploymentCapability `json:"capabilities"`
}
// DeploymentFeatureAvailability mirrors injected backend handlers/services.
type DeploymentFeatureAvailability struct {
Organizations bool
Agents bool
IM bool
Embed bool
API bool
MCP bool
WebSearch bool
VectorStore bool
Storage bool
Sandbox bool
SandboxDocker bool
}
func supportedDeploymentCapability(supported bool) DeploymentCapability {
if supported {
return DeploymentCapability{Supported: true}
}
return DeploymentCapability{Supported: false, Reason: "route_not_registered"}
}
// BuildDeploymentCapabilities derives the deployment capability snapshot.
func BuildDeploymentCapabilities(
edition string,
available DeploymentFeatureAvailability,
) DeploymentCapabilitiesData {
isLite := strings.EqualFold(strings.TrimSpace(edition), "lite")
organizations := supportedDeploymentCapability(available.Organizations && !isLite)
if isLite {
organizations.Reason = "not_supported_in_lite"
}
sandboxDocker := DeploymentCapability{
Supported: available.Sandbox && available.SandboxDocker,
}
if available.Sandbox && !available.SandboxDocker {
sandboxDocker.Reason = "docker_backend_disabled"
} else if !available.Sandbox {
sandboxDocker.Reason = "route_not_registered"
}
return DeploymentCapabilitiesData{
Edition: edition,
Capabilities: map[string]DeploymentCapability{
"organizations": organizations,
"agents": supportedDeploymentCapability(available.Agents),
"integrations.im": supportedDeploymentCapability(available.IM),
"integrations.embed": supportedDeploymentCapability(available.Embed),
"integrations.api": supportedDeploymentCapability(available.API),
"settings.mcp": supportedDeploymentCapability(available.MCP),
"settings.websearch": supportedDeploymentCapability(available.WebSearch),
"settings.vectorstore": supportedDeploymentCapability(available.VectorStore),
"settings.storage": supportedDeploymentCapability(available.Storage),
"settings.sandbox": supportedDeploymentCapability(available.Sandbox),
"settings.sandbox.docker": sandboxDocker,
},
}
}
// BindDeploymentCapabilities stores the startup snapshot used by GetDeploymentCapabilities.
func (h *SystemHandler) BindDeploymentCapabilities(data DeploymentCapabilitiesData) {
h.deploymentCapabilities = data
}
// GetDeploymentCapabilities godoc
// @Summary 获取部署能力清单
// @Description 返回当前部署版本及实际注册的后端路由所对应的功能能力;仅 supported=false 表示入口应隐藏
// @Tags 系统
// @Produce json
// @Success 200 {object} map[string]interface{} "标准 code/msg/data 包装,data 为 DeploymentCapabilitiesData"
// @Router /system/capabilities [get]
func (h *SystemHandler) GetDeploymentCapabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": overlayLiveDockerSandboxCapability(h.deploymentCapabilities),
})
}
// overlayLiveDockerSandboxCapability replaces the startup snapshot's Docker
// flag with the live 3-tier value so a System Settings toggle is visible
// without restarting the process.
func overlayLiveDockerSandboxCapability(data DeploymentCapabilitiesData) DeploymentCapabilitiesData {
caps := make(map[string]DeploymentCapability, len(data.Capabilities))
for key, capability := range data.Capabilities {
caps[key] = capability
}
sandboxCap := caps["settings.sandbox"]
docker := DeploymentCapability{
Supported: sandboxCap.Supported && sandbox.DockerBackendEnabled(),
}
if sandboxCap.Supported && !docker.Supported {
docker.Reason = "docker_backend_disabled"
} else if !sandboxCap.Supported {
docker.Reason = "route_not_registered"
}
caps["settings.sandbox.docker"] = docker
data.Capabilities = caps
return data
}
@@ -0,0 +1,100 @@
package handler
import (
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/sandbox"
)
func TestDeploymentCapabilityKeysMatchFrontend(t *testing.T) {
frontendKeys, err := readFrontendDeploymentCapabilityKeys()
if err != nil {
t.Fatalf("read frontend capability keys: %v", err)
}
if !slices.Equal(DeploymentCapabilityKeys, frontendKeys) {
t.Fatalf("backend keys = %#v, frontend keys = %#v", DeploymentCapabilityKeys, frontendKeys)
}
}
func TestBuildDeploymentCapabilitiesIncludesAllKeys(t *testing.T) {
result := BuildDeploymentCapabilities("standard", DeploymentFeatureAvailability{
Organizations: true,
Agents: true,
IM: true,
Embed: true,
API: true,
MCP: true,
WebSearch: true,
VectorStore: true,
Storage: true,
Sandbox: true,
})
for _, key := range DeploymentCapabilityKeys {
if _, ok := result.Capabilities[key]; !ok {
t.Fatalf("missing capability key %q", key)
}
}
}
func TestOverlayLiveDockerSandboxCapabilityIgnoresStartupSnapshot(t *testing.T) {
sandbox.ClearDockerBackendEnabledOverride()
t.Cleanup(sandbox.ClearDockerBackendEnabledOverride)
t.Setenv(sandbox.DockerBackendEnabledEnv, "")
snapshot := BuildDeploymentCapabilities("standard", DeploymentFeatureAvailability{
Sandbox: true,
SandboxDocker: true,
})
live := overlayLiveDockerSandboxCapability(snapshot)
docker := live.Capabilities["settings.sandbox.docker"]
if docker.Supported {
t.Fatal("live env off must hide docker even if the startup snapshot was on")
}
if docker.Reason != "docker_backend_disabled" {
t.Fatalf("reason = %q, want docker_backend_disabled", docker.Reason)
}
t.Setenv(sandbox.DockerBackendEnabledEnv, "true")
enabled := overlayLiveDockerSandboxCapability(snapshot)
if !enabled.Capabilities["settings.sandbox.docker"].Supported {
t.Fatal("live env true must expose docker")
}
}
func readFrontendDeploymentCapabilityKeys() ([]string, error) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return nil, os.ErrInvalid
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
frontendPath := filepath.Join(repoRoot, "frontend", "src", "config", "deploymentCapabilities.ts")
content, err := os.ReadFile(frontendPath)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`(?s)export const DEPLOYMENT_CAPABILITY_KEYS = \[(.*?)\]`)
match := re.FindSubmatch(content)
if len(match) < 2 {
return nil, os.ErrInvalid
}
var keys []string
for _, line := range strings.Split(string(match[1]), "\n") {
line = strings.TrimSpace(strings.TrimRight(line, ","))
if line == "" {
continue
}
line = strings.Trim(line, `'`)
keys = append(keys, line)
}
return keys, nil
}
+205
View File
@@ -0,0 +1,205 @@
package handler
import (
"errors"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
type DigitalHumanHandler struct{ service *service.DigitalHumanService }
func NewDigitalHumanHandler(svc *service.DigitalHumanService) *DigitalHumanHandler {
return &DigitalHumanHandler{service: svc}
}
func digitalHumanHTTPError(c *gin.Context, err error) {
var inputError *types.DigitalHumanInputError
switch {
case errors.Is(err, types.ErrDigitalHumanNotFound):
c.Error(apperrors.NewNotFoundError(err.Error()))
case errors.Is(err, types.ErrDigitalHumanIdentity):
c.Error(apperrors.NewUnauthorizedError(err.Error()))
case errors.Is(err, types.ErrDigitalHumanConflict):
c.Error(apperrors.NewConflictError(err.Error()))
case errors.As(err, &inputError):
c.Error(apperrors.NewBadRequestError(inputError.Error()))
default:
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{"feature": "digital_human"})
c.Error(apperrors.NewInternalServerError("数字人操作失败,请稍后重试"))
}
}
func (h *DigitalHumanHandler) List(c *gin.Context) {
offset, _ := strconv.Atoi(c.Query("offset"))
limit, _ := strconv.Atoi(c.Query("limit"))
data, err := h.service.List(c.Request.Context(), offset, limit)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) Get(c *gin.Context) {
data, err := h.service.Get(c.Request.Context(), c.Param("id"))
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) Create(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20)
var input service.DigitalHumanInput
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(apperrors.NewBadRequestError("请填写有效的数字人资料"))
return
}
data, err := h.service.Create(c.Request.Context(), input)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) Update(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20)
var input service.DigitalHumanInput
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(apperrors.NewBadRequestError("请填写有效的数字人资料"))
return
}
data, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) Delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *DigitalHumanHandler) Review(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
var input struct {
Version int `json:"version"`
Approve *bool `json:"approve"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&input); err != nil || input.Approve == nil || input.Version < 1 {
c.Error(apperrors.NewBadRequestError("请选择审核版本和通过或退回"))
return
}
data, err := h.service.Review(c.Request.Context(), c.Param("id"), input.Version, *input.Approve, input.Note)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) Bind(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
var input struct {
AgentID string `json:"agent_id"`
}
if err := c.ShouldBindJSON(&input); err != nil || input.AgentID == "" {
c.Error(apperrors.NewBadRequestError("请选择 Agent"))
return
}
if err := h.service.Bind(c.Request.Context(), c.Param("id"), input.AgentID); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *DigitalHumanHandler) Unbind(c *gin.Context) {
if err := h.service.Unbind(c.Request.Context(), c.Param("id"), c.Param("agent_id")); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *DigitalHumanHandler) Distill(c *gin.Context) {
var input service.DigitalHumanDistillInput
if strings.HasPrefix(c.ContentType(), "multipart/form-data") {
// Authorize before accepting or parsing potentially large private documents.
if err := h.service.CheckOwner(c.Request.Context(), c.Param("id")); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.DigitalHumanUploadLimit)
if err := c.Request.ParseMultipartForm(4 << 20); err != nil {
c.Error(apperrors.NewBadRequestError("上传材料总大小不能超过50MB"))
return
}
defer c.Request.MultipartForm.RemoveAll()
if len(c.Request.MultipartForm.File["files"]) > service.DigitalHumanFileCountLimit {
c.Error(apperrors.NewBadRequestError("每次最多上传99份材料"))
return
}
input.Advice, input.ModelID = c.PostForm("advice"), c.PostForm("model_id")
input.Revision, _ = strconv.ParseInt(c.PostForm("revision"), 10, 64)
if text := strings.TrimSpace(c.PostForm("text")); text != "" {
input.Sources = append(input.Sources, types.DigitalHumanSource{Name: "补充原稿", Text: text})
}
for _, header := range c.Request.MultipartForm.File["files"] {
if !service.DigitalHumanUploadSupported(header.Filename) {
c.Error(apperrors.NewBadRequestError("支持 Word、PDF、PPT、MD、TXT、SRT、VTT、RTF、ODT、CSV、JSON 和 HTML 材料"))
return
}
file, err := header.Open()
if err != nil {
c.Error(apperrors.NewBadRequestError("无法读取上传材料"))
return
}
data, readErr := io.ReadAll(io.LimitReader(file, service.DigitalHumanFileLimit+1))
file.Close()
if readErr != nil || len(data) > service.DigitalHumanFileLimit || len(data) == 0 {
c.Error(apperrors.NewBadRequestError("单份材料须有内容且不能超过20MB"))
return
}
input.Files = append(input.Files, service.DigitalHumanUpload{Name: filepath.Base(header.Filename), Data: data})
}
} else {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20)
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(apperrors.NewBadRequestError("请提供有效的蒸馏原稿和建议"))
return
}
}
job, err := h.service.Distill(c.Request.Context(), c.Param("id"), input)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": job})
}
func (h *DigitalHumanHandler) Job(c *gin.Context) {
job, err := h.service.Job(c.Request.Context(), c.Param("id"), c.Param("job_id"))
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": job})
}
+45
View File
@@ -0,0 +1,45 @@
package handler
import (
"net/http"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
)
func (h *DigitalHumanHandler) Trial(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
var input service.DigitalHumanTrialInput
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(apperrors.NewBadRequestError("请填写有效的试写版本、主题和要求"))
return
}
stream, err := h.service.Trial(c.Request.Context(), c.Param("id"), input)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache, no-transform")
c.Header("X-Accel-Buffering", "no")
c.Writer.Flush()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-stream:
if !ok {
return
}
c.SSEvent("trial", event)
c.Writer.Flush()
case <-ticker.C:
c.SSEvent("heartbeat", gin.H{"status": "running"})
c.Writer.Flush()
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"net/http"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
)
func (h *DigitalHumanHandler) WeChatChannels(c *gin.Context) {
data, err := h.service.WeChatChannels(c.Request.Context(), c.Param("id"))
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) WeChatQRCode(c *gin.Context) {
data, err := h.service.WeChatQRCode(c.Request.Context(), c.Param("id"))
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) WeChatPoll(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
var input struct {
QRCode string `json:"qrcode" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(apperrors.NewBadRequestError("请先获取微信二维码"))
return
}
data, err := h.service.WeChatPoll(c.Request.Context(), c.Param("id"), input.QRCode)
if err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func (h *DigitalHumanHandler) DeleteWeChatChannel(c *gin.Context) {
if err := h.service.DeleteWeChatChannel(c.Request.Context(), c.Param("id"), c.Param("channel_id")); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *DigitalHumanHandler) SetWeChatChannelEnabled(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
var input struct {
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil || input.Enabled == nil {
c.Error(apperrors.NewBadRequestError("请提供连接启用状态"))
return
}
if err := h.service.SetWeChatChannelEnabled(c.Request.Context(), c.Param("id"), c.Param("channel_id"), *input.Enabled); err != nil {
digitalHumanHTTPError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+76
View File
@@ -0,0 +1,76 @@
package dto
import "github.com/Tencent/WeKnora/internal/types"
// AuthLoginResponse is the HTTP-safe login / switch-tenant response shape.
type AuthLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *types.User `json:"user,omitempty"`
ActiveTenant *TenantResponse `json:"active_tenant,omitempty"`
Memberships []types.Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// AuthOIDCCallbackResponse is the HTTP-safe OIDC callback payload shape.
type AuthOIDCCallbackResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *types.User `json:"user,omitempty"`
Tenant *TenantResponse `json:"tenant,omitempty"`
Memberships []types.Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
IsNewUser bool `json:"is_new_user,omitempty"`
}
// NewAuthLoginResponse converts a service-layer login response for HTTP output.
func NewAuthLoginResponse(resp *types.LoginResponse) *AuthLoginResponse {
if resp == nil {
return nil
}
var role types.TenantRole
if resp.ActiveTenant != nil {
role = membershipRoleForTenant(resp.Memberships, resp.ActiveTenant.ID)
}
return &AuthLoginResponse{
Success: resp.Success,
Message: resp.Message,
User: resp.User,
ActiveTenant: NewTenantResponseWithRole(resp.ActiveTenant, role),
Memberships: resp.Memberships,
Token: resp.Token,
RefreshToken: resp.RefreshToken,
}
}
// NewAuthOIDCCallbackResponse converts an OIDC callback response for HTTP output.
func NewAuthOIDCCallbackResponse(resp *types.OIDCCallbackResponse) *AuthOIDCCallbackResponse {
if resp == nil {
return nil
}
var role types.TenantRole
if resp.Tenant != nil {
role = membershipRoleForTenant(resp.Memberships, resp.Tenant.ID)
}
return &AuthOIDCCallbackResponse{
Success: resp.Success,
Message: resp.Message,
User: resp.User,
Tenant: NewTenantResponseWithRole(resp.Tenant, role),
Memberships: resp.Memberships,
Token: resp.Token,
RefreshToken: resp.RefreshToken,
IsNewUser: resp.IsNewUser,
}
}
func membershipRoleForTenant(memberships []types.Membership, tenantID uint64) types.TenantRole {
for _, m := range memberships {
if m.TenantID == tenantID && m.Role.IsValid() {
return m.Role
}
}
return ""
}
+60
View File
@@ -0,0 +1,60 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthLoginResponse_ViewerOmitsActiveTenantSecrets(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthLoginResponse(&types.LoginResponse{
Success: true,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleViewer,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "parser-secret-123")
}
func TestAuthLoginResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthLoginResponse(&types.LoginResponse{
Success: true,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleOwner,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, `"api_key"`)
assert.NotContains(t, s, "legacy-search-secret-999")
assert.Contains(t, s, "web_search_config")
}
func TestAuthOIDCCallbackResponse_ViewerOmitsTenantSecrets(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthOIDCCallbackResponse(&types.OIDCCallbackResponse{
Success: true,
Tenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleViewer,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
assert.NotContains(t, string(body), "tenant-api-key-123")
}
+131
View File
@@ -0,0 +1,131 @@
package dto
import (
"encoding/json"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// DataSourceResponse mirrors types.DataSource for response bodies, with the
// connector Credentials map stripped from the Config jsonb. Credential
// presence is exposed via the dedicated /credentials subresource.
//
// Unlike MCP / Model / WebSearch (which have a flat set of named credential
// fields), DataSource credentials are a per-connector atomic map — an OAuth
// token pair, a Confluence email+token bundle, etc. Splitting them at the
// field level would leave half-configured states that can't actually
// authenticate. The subresource therefore exposes only one logical field,
// "credentials", with PUT replacing the whole map and DELETE wiping it.
type DataSourceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Name string `json:"name"`
Type string `json:"type"`
Config *DataSourceConfigDTO `json:"config,omitempty"`
SyncSchedule string `json:"sync_schedule"`
SyncMode string `json:"sync_mode"`
Status string `json:"status"`
ConflictStrategy string `json:"conflict_strategy"`
SyncDeletions bool `json:"sync_deletions"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncCursor json.RawMessage `json:"last_sync_cursor,omitempty"`
LastSyncResult json.RawMessage `json:"last_sync_result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
SyncLogRetentionDays int `json:"sync_log_retention_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalItemsSynced int64 `json:"total_items_synced"`
LatestSyncLog *types.SyncLog `json:"latest_sync_log,omitempty"`
// Single logical credential field — DataSource credentials are a
// per-connector atomic map, so "configured?" applies to the whole set.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// DataSourceConfigDTO is types.DataSourceConfig with the Credentials map
// removed by construction. Type, ResourceIDs and Settings remain visible.
type DataSourceConfigDTO struct {
Type string `json:"type"`
ResourceIDs []string `json:"resource_ids,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
// NewDataSourceResponse converts a stored entity into its response shape.
// Returns nil for nil input.
func NewDataSourceResponse(ds *types.DataSource) *DataSourceResponse {
if ds == nil {
return nil
}
var cfgDTO *DataSourceConfigDTO
configured := false
if parsed, err := ds.ParseConfig(); err == nil && parsed != nil {
cfgDTO = &DataSourceConfigDTO{
Type: parsed.Type,
ResourceIDs: parsed.ResourceIDs,
Settings: parsed.Settings,
}
enrichRSSFeedURLsInSettings(ds.Type, parsed, cfgDTO)
configured = parsed.HasConfiguredCredentials(ds.Type)
}
return &DataSourceResponse{
ID: ds.ID,
TenantID: ds.TenantID,
KnowledgeBaseID: ds.KnowledgeBaseID,
Name: ds.Name,
Type: ds.Type,
Config: cfgDTO,
SyncSchedule: ds.SyncSchedule,
SyncMode: ds.SyncMode,
Status: ds.Status,
ConflictStrategy: ds.ConflictStrategy,
SyncDeletions: ds.SyncDeletions,
LastSyncAt: ds.LastSyncAt,
LastSyncCursor: json.RawMessage(ds.LastSyncCursor),
LastSyncResult: json.RawMessage(ds.LastSyncResult),
ErrorMessage: ds.ErrorMessage,
SyncLogRetentionDays: ds.SyncLogRetentionDays,
CreatedAt: ds.CreatedAt,
UpdatedAt: ds.UpdatedAt,
TotalItemsSynced: ds.TotalItemsSynced,
LatestSyncLog: ds.LatestSyncLog,
Credentials: map[string]CredentialFieldMetadata{
"credentials": {Configured: configured},
},
}
}
// enrichRSSFeedURLsInSettings copies feed_urls from credentials into settings
// for API responses. Feed URLs are not secrets but may still live in the
// encrypted credentials blob on rows created before they moved to settings.
func enrichRSSFeedURLsInSettings(dsType string, parsed *types.DataSourceConfig, cfgDTO *DataSourceConfigDTO) {
if dsType != types.ConnectorTypeRSS || parsed == nil || cfgDTO == nil {
return
}
if cfgDTO.Settings != nil {
if v, ok := cfgDTO.Settings["feed_urls"].(string); ok && strings.TrimSpace(v) != "" {
return
}
}
raw, ok := parsed.Credentials["feed_urls"]
if !ok {
return
}
feedURLs, ok := raw.(string)
if !ok || strings.TrimSpace(feedURLs) == "" {
return
}
if cfgDTO.Settings == nil {
cfgDTO.Settings = make(map[string]interface{})
}
cfgDTO.Settings["feed_urls"] = feedURLs
}
func NewDataSourceResponses(dss []*types.DataSource) []*DataSourceResponse {
out := make([]*DataSourceResponse, 0, len(dss))
for _, d := range dss {
out = append(out, NewDataSourceResponse(d))
}
return out
}
+112
View File
@@ -0,0 +1,112 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestDataSourceResponse_OmitsCredentials(t *testing.T) {
cfg := types.DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"token": "ghp-secret-do-not-leak",
},
ResourceIDs: []string{"repo-1"},
Settings: map[string]interface{}{"branch": "main"},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-1",
Name: "github-prod",
Type: "github",
Config: blob,
}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "ghp-secret-do-not-leak")
// The inner config object must not carry the credentials map (the
// DataSourceConfigDTO type omits it structurally).
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if cfgRaw, ok := raw["config"]; ok {
var inner map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(cfgRaw, &inner))
_, hasCredsInConfig := inner["credentials"]
assert.False(t, hasCredsInConfig,
"credentials map must not appear inside the config DTO")
}
// Top-level credentials map is just the "configured?" indicator,
// replaces the removed GET /credentials endpoint.
assert.Contains(t, s, `"credentials":{"credentials":{"configured":true}}`)
// Non-secret config fields pass through.
assert.Contains(t, s, "repo-1")
assert.Contains(t, s, "branch")
assert.Contains(t, s, "main")
}
func TestDataSourceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewDataSourceResponse(nil))
assert.Equal(t, []*DataSourceResponse{}, NewDataSourceResponses(nil))
}
func TestDataSourceResponse_RSSFeedURLsFromCredentials(t *testing.T) {
cfg := types.DataSourceConfig{
Type: types.ConnectorTypeRSS,
Credentials: map[string]interface{}{
"feed_urls": "https://example.com/a.xml\nhttps://example.com/b.xml",
},
ResourceIDs: []string{"https://example.com/a.xml"},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-rss",
Name: "my-rss",
Type: types.ConnectorTypeRSS,
Config: blob,
}
resp := NewDataSourceResponse(ds)
assert.NotNil(t, resp.Config)
assert.Equal(t, "https://example.com/a.xml\nhttps://example.com/b.xml", resp.Config.Settings["feed_urls"])
assert.False(t, resp.Credentials["credentials"].Configured)
body, err := json.Marshal(resp)
assert.NoError(t, err)
s := string(body)
assert.Contains(t, s, "https://example.com/a.xml")
assert.NotContains(t, s, "Bearer secret")
}
func TestDataSourceResponse_RSSAuthHeadersConfigured(t *testing.T) {
cfg := types.DataSourceConfig{
Type: types.ConnectorTypeRSS,
Credentials: map[string]interface{}{
"auth_headers": "Authorization: Bearer secret",
},
Settings: map[string]interface{}{
"feed_urls": "https://example.com/feed.xml",
},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-rss-auth",
Name: "my-rss",
Type: types.ConnectorTypeRSS,
Config: blob,
}
resp := NewDataSourceResponse(ds)
assert.True(t, resp.Credentials["credentials"].Configured)
body, err := json.Marshal(resp)
assert.NoError(t, err)
assert.NotContains(t, string(body), "Bearer secret")
}
func TestDataSourceResponse_NoConfig(t *testing.T) {
ds := &types.DataSource{ID: "x", Name: "x"}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
// No config jsonb stored → no config object in the response.
assert.NotContains(t, string(body), `"config":`)
}
+145
View File
@@ -0,0 +1,145 @@
// Package dto holds response shapes for handler responses that need to differ
// from the persisted GORM model — most notably, response types that must NOT
// carry secret fields.
//
// Why a separate package: response DTOs are deliberately distinct from the
// internal model so the "no secret in responses" guarantee is a compile-time
// invariant rather than a runtime redaction step. If a future contributor
// wants to expose a credential in a response, they must add it to the DTO
// explicitly, which makes the leak surface review-able in a single diff.
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPServiceResponse mirrors types.MCPService for response bodies, omitting
// every secret field (api_key, token). Credential presence/absence is exposed
// separately via the /credentials subresource endpoint; this shape carries
// only a boolean per credential field so the frontend can render a
// "configured / not configured" badge without an additional round-trip.
type MCPServiceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
TransportType types.MCPTransportType `json:"transport_type"`
URL *string `json:"url,omitempty"`
Headers types.MCPHeaders `json:"headers,omitempty"`
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
IsBuiltin bool `json:"is_builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Credentials is the per-field "configured?" map. Embedded on the main
// response so the credential UI doesn't need a follow-up GET. The
// frontend never sees the actual secret value — only whether one is
// stored. Omitted entirely for builtin services (they can't have
// per-tenant credentials).
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// MCPAuthConfigResponse intentionally has no APIKey or Token fields. Their
// presence is signalled via MCPServiceResponse.Credentials. AuthType, Scopes
// and AuthServerMetadataURL are non-secret OAuth configuration and are safe to
// echo back so the UI can render the current strategy.
type MCPAuthConfigResponse struct {
AuthType types.MCPAuthType `json:"auth_type,omitempty"`
APIKeyHeader string `json:"api_key_header,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
Scopes []string `json:"scopes,omitempty"`
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
}
// CredentialFieldMetadata reports whether a credential field has a value
// stored server-side, without exposing the value itself.
type CredentialFieldMetadata struct {
Configured bool `json:"configured"`
}
// NewMCPServiceResponse converts a stored MCPService into its response shape.
//
// Builtin MCP services have their tenant-specific transport details (URL,
// Headers, EnvVars, StdioConfig) stripped — these reveal how the tenant
// configured an upstream provider and must not be visible to other tenants
// that see the same builtin row via the cross-tenant list.
func NewMCPServiceResponse(ctx context.Context, svc *types.MCPService) *MCPServiceResponse {
if svc == nil {
return nil
}
includeDetail := CanViewIntegrationSecrets(ctx)
resp := &MCPServiceResponse{
ID: svc.ID,
TenantID: svc.TenantID,
Name: svc.Name,
Description: svc.Description,
Enabled: svc.Enabled,
TransportType: svc.TransportType,
URL: svc.URL,
Headers: svc.Headers,
AdvancedConfig: svc.AdvancedConfig,
StdioConfig: svc.StdioConfig,
EnvVars: svc.EnvVars,
IsBuiltin: svc.IsBuiltin,
CreatedAt: svc.CreatedAt,
UpdatedAt: svc.UpdatedAt,
}
if !includeDetail {
resp.Headers = nil
resp.EnvVars = nil
resp.URL = nil
resp.StdioConfig = nil
resp.AdvancedConfig = nil
}
if svc.AuthConfig != nil {
auth := &MCPAuthConfigResponse{
AuthType: svc.AuthConfig.AuthType,
APIKeyHeader: svc.AuthConfig.APIKeyHeader,
CustomHeaders: svc.AuthConfig.CustomHeaders,
Scopes: svc.AuthConfig.Scopes,
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
}
if !includeDetail {
auth.CustomHeaders = nil
}
resp.AuthConfig = auth
}
if svc.IsBuiltin {
// Builtin services are shared across tenants — strip everything that
// could leak how this tenant configured the underlying provider.
resp.URL = nil
resp.Headers = nil
resp.EnvVars = nil
resp.StdioConfig = nil
resp.AuthConfig = nil
} else {
resp.Credentials = map[string]CredentialFieldMetadata{
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
}
}
return resp
}
// NewMCPServiceResponses is the slice convenience wrapper used by ListMCPServices.
func NewMCPServiceResponses(ctx context.Context, svcs []*types.MCPService) []*MCPServiceResponse {
out := make([]*MCPServiceResponse, 0, len(svcs))
for _, s := range svcs {
out = append(out, NewMCPServiceResponse(ctx, s))
}
return out
}
// CredentialsResponse is the shared shape returned by PUT
// /{resource}/{id}/credentials. Keyed by field name (e.g. "api_key",
// "token"). The frontend uses this to update its in-memory metadata after a
// successful save without needing to re-fetch the whole resource.
type CredentialsResponse struct {
Fields map[string]CredentialFieldMetadata `json:"fields"`
}
+105
View File
@@ -0,0 +1,105 @@
package dto
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
// The most important guarantee of the DTO layer is structural: the serialized
// response body must NEVER contain api_key or token under any condition,
// regardless of the underlying entity's state. We assert this via the
// serialized JSON (not just struct shape) because reflection-based
// custom-marshalers anywhere downstream could otherwise reintroduce a leak.
func TestMCPServiceResponse_OmitsSecrets(t *testing.T) {
svc := &types.MCPService{
ID: "svc-1",
Name: "svc",
AuthConfig: &types.MCPAuthConfig{
APIKey: "sk-real-api-key-do-not-leak",
Token: "tok-real-bearer-token-do-not-leak",
CustomHeaders: map[string]string{"X-Trace": "abc"},
},
}
body, err := json.Marshal(NewMCPServiceResponse(adminContext(), svc))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak",
"raw api_key must never appear in MCPServiceResponse")
assert.NotContains(t, s, "tok-real-bearer-token-do-not-leak",
"raw token must never appear in MCPServiceResponse")
// auth_config no longer has api_key/token fields (only custom_headers
// survived). Verify the auth_config sub-object contains no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if ac, ok := raw["auth_config"]; ok {
acStr := string(ac)
assert.NotContains(t, acStr, `"api_key"`)
assert.NotContains(t, acStr, `"token"`)
}
// The new credentials map exposes "configured?" booleans by design
// (replaces the standalone GET /credentials endpoint). Verify the
// values are booleans, not strings.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"token":{"configured":true}`)
// CustomHeaders is structural metadata and SHOULD pass through.
assert.Contains(t, s, `"custom_headers"`)
assert.Contains(t, s, `"X-Trace"`)
}
func TestMCPServiceResponse_BuiltinStripsTenantConfig(t *testing.T) {
url := "https://tenant-private.example.com"
svc := &types.MCPService{
ID: "builtin-1",
IsBuiltin: true,
URL: &url,
Headers: types.MCPHeaders{"X-Tenant-Secret": "shhh"},
AuthConfig: &types.MCPAuthConfig{
APIKey: "should-not-leak-via-builtin",
},
}
resp := NewMCPServiceResponse(adminContext(), svc)
assert.Nil(t, resp.URL, "builtin must not leak per-tenant URL")
assert.Nil(t, resp.Headers, "builtin must not leak per-tenant headers")
assert.Nil(t, resp.AuthConfig, "builtin must not leak auth config")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak-via-builtin"))
assert.False(t, strings.Contains(string(body), "X-Tenant-Secret"))
}
func TestMCPServiceResponse_ViewerStripsIntegrationDetail(t *testing.T) {
url := "https://tenant-private.example.com"
svc := &types.MCPService{
ID: "svc-2",
URL: &url,
Headers: types.MCPHeaders{"Authorization": "Bearer secret"},
EnvVars: types.MCPEnvVars{"TOKEN": "secret"},
StdioConfig: &types.MCPStdioConfig{
Command: "npx",
Args: []string{"-y", "mcp-server"},
},
AdvancedConfig: &types.MCPAdvancedConfig{},
AuthConfig: &types.MCPAuthConfig{
CustomHeaders: map[string]string{"X-Auth": "secret"},
},
}
resp := NewMCPServiceResponse(viewerContext(), svc)
assert.Nil(t, resp.URL)
assert.Nil(t, resp.Headers)
assert.Nil(t, resp.EnvVars)
assert.Nil(t, resp.StdioConfig)
assert.Nil(t, resp.AdvancedConfig)
assert.NotNil(t, resp.AuthConfig)
assert.Nil(t, resp.AuthConfig.CustomHeaders)
}
func TestMCPServiceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewMCPServiceResponse(adminContext(), nil))
assert.Equal(t, []*MCPServiceResponse{}, NewMCPServiceResponses(adminContext(), nil))
}
+127
View File
@@ -0,0 +1,127 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// ModelResponse mirrors types.Model for response bodies, with all secret
// fields (APIKey, AppSecret) removed by construction. Credential presence
// metadata lives behind the /credentials subresource, not inlined here.
//
// BaseURL is preserved for tenant-owned models (the frontend needs it to
// render which endpoint a custom model points at). For builtin models it is
// stripped along with every other field that could leak how a particular
// tenant configured the upstream provider.
type ModelResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Type types.ModelType `json:"type"`
Source types.ModelSource `json:"source"`
Description string `json:"description"`
Parameters ModelParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
IsBuiltin bool `json:"is_builtin"`
Status types.ModelStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. Omitted for builtin models unless the
// caller is a system administrator. See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// ModelParametersDTO carries every parameter field EXCEPT the two secret
// ones (APIKey, AppSecret). AppID is non-secret and stays — it's an account
// identifier the WeKnora Cloud frontend renders. CustomHeaders is also kept
// (structural metadata, not a credential).
type ModelParametersDTO struct {
BaseURL string `json:"base_url"`
InterfaceType string `json:"interface_type"`
EmbeddingParameters types.EmbeddingParameters `json:"embedding_parameters"`
ParameterSize string `json:"parameter_size"`
Provider string `json:"provider"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
SupportsVision bool `json:"supports_vision"`
ContextWindow int `json:"context_window,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
MaxConcurrency int `json:"max_concurrency,omitempty"`
AppID string `json:"app_id,omitempty"`
}
// NewModelResponse converts a stored Model into its response shape.
//
// Builtin models are shared across tenants — strip BaseURL (which can leak
// the tenant's private endpoint) and any non-shared parameters.
func NewModelResponse(ctx context.Context, m *types.Model) *ModelResponse {
if m == nil {
return nil
}
params := ModelParametersDTO{
BaseURL: m.Parameters.BaseURL,
InterfaceType: m.Parameters.InterfaceType,
EmbeddingParameters: m.Parameters.EmbeddingParameters,
ParameterSize: m.Parameters.ParameterSize,
Provider: m.Parameters.Provider,
ExtraConfig: m.Parameters.ExtraConfig,
CustomHeaders: m.Parameters.CustomHeaders,
SupportsVision: m.Parameters.SupportsVision,
ContextWindow: m.Parameters.ContextWindow,
MaxOutputTokens: m.Parameters.MaxOutputTokens,
MaxConcurrency: m.Parameters.MaxConcurrency,
AppID: m.Parameters.AppID,
}
canManageBuiltin := m.IsBuiltin && types.IsSystemAdminFromContext(ctx)
if !CanViewIntegrationSecrets(ctx) && !canManageBuiltin {
params.ExtraConfig = nil
params.CustomHeaders = nil
params.BaseURL = ""
}
if m.IsBuiltin && !canManageBuiltin {
// Builtin: strip everything that could reveal per-tenant config.
// EmbeddingParameters and ParameterSize / Provider / InterfaceType /
// SupportsVision / ContextWindow / MaxOutputTokens are intentionally
// preserved (they describe the capability surface, not the configured
// endpoint).
params.BaseURL = ""
params.ExtraConfig = nil
params.CustomHeaders = nil
params.AppID = ""
}
var creds map[string]CredentialFieldMetadata
if !m.IsBuiltin || canManageBuiltin {
creds = map[string]CredentialFieldMetadata{
"api_key": {Configured: m.Parameters.APIKey != ""},
"app_secret": {Configured: m.Parameters.AppSecret != ""},
}
}
return &ModelResponse{
ID: m.ID,
TenantID: m.TenantID,
Name: m.Name,
DisplayName: m.DisplayName,
Type: m.Type,
Source: m.Source,
Description: m.Description,
Parameters: params,
IsDefault: m.IsDefault,
IsBuiltin: m.IsBuiltin,
Status: m.Status,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Credentials: creds,
}
}
// NewModelResponses is the slice convenience wrapper.
func NewModelResponses(ctx context.Context, models []*types.Model) []*ModelResponse {
out := make([]*ModelResponse, 0, len(models))
for _, m := range models {
out = append(out, NewModelResponse(ctx, m))
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package dto
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestModelResponse_OmitsSecrets(t *testing.T) {
m := &types.Model{
ID: "m-1",
Name: "gpt-x",
DisplayName: "Support QA",
Parameters: types.ModelParameters{
APIKey: "sk-real-api-key-do-not-leak",
AppSecret: "app-real-secret-do-not-leak",
AppID: "appid-public-ok-to-show",
BaseURL: "https://api.example.com",
Provider: "openai",
},
}
body, err := json.Marshal(NewModelResponse(adminContext(), m))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak")
assert.NotContains(t, s, "app-real-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
params := string(raw["parameters"])
assert.NotContains(t, params, `"api_key"`)
assert.NotContains(t, params, `"app_secret"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"app_secret":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "appid-public-ok-to-show")
assert.Contains(t, s, "api.example.com")
assert.Contains(t, s, `"display_name":"Support QA"`)
}
func TestModelResponse_BuiltinStripsTenantConfig(t *testing.T) {
m := &types.Model{
ID: "builtin-1",
IsBuiltin: true,
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
APIKey: "should-not-leak",
AppID: "tenant-app-id",
SupportsVision: true,
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
},
}
resp := NewModelResponse(adminContext(), m)
assert.Empty(t, resp.Parameters.BaseURL,
"builtin must not leak per-tenant base URL")
assert.Empty(t, resp.Parameters.AppID,
"builtin must not leak per-tenant app_id")
assert.Nil(t, resp.Parameters.ExtraConfig,
"builtin must not leak per-tenant extra_config")
assert.True(t, resp.Parameters.SupportsVision,
"capability metadata must survive (not per-tenant)")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak"))
assert.False(t, strings.Contains(string(body), "tenant-private.example.com"))
}
func TestModelResponse_SystemAdminCanManageBuiltinConfig(t *testing.T) {
ctx := context.WithValue(viewerContext(), types.SystemAdminContextKey, true)
m := &types.Model{
ID: "builtin-1",
IsBuiltin: true,
Parameters: types.ModelParameters{
BaseURL: "https://global-provider.example.com",
APIKey: "should-never-be-returned",
AppSecret: "also-never-returned",
AppID: "global-app-id",
ExtraConfig: map[string]string{"region": "ap-guangzhou"},
CustomHeaders: map[string]string{"X-Route": "global"},
},
}
resp := NewModelResponse(ctx, m)
assert.Equal(t, "https://global-provider.example.com", resp.Parameters.BaseURL)
assert.Equal(t, "global-app-id", resp.Parameters.AppID)
assert.Equal(t, map[string]string{"region": "ap-guangzhou"}, resp.Parameters.ExtraConfig)
assert.Equal(t, map[string]string{"X-Route": "global"}, resp.Parameters.CustomHeaders)
assert.True(t, resp.Credentials["api_key"].Configured)
assert.True(t, resp.Credentials["app_secret"].Configured)
body, err := json.Marshal(resp)
require.NoError(t, err)
assert.NotContains(t, string(body), "should-never-be-returned")
assert.NotContains(t, string(body), "also-never-returned")
}
func TestModelResponse_ViewerStripsIntegrationDetail(t *testing.T) {
m := &types.Model{
ID: "m-2",
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
CustomHeaders: map[string]string{"Authorization": "Bearer secret"},
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
},
}
resp := NewModelResponse(viewerContext(), m)
assert.Empty(t, resp.Parameters.BaseURL)
assert.Nil(t, resp.Parameters.CustomHeaders)
assert.Nil(t, resp.Parameters.ExtraConfig)
}
func TestModelResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewModelResponse(adminContext(), nil))
assert.Equal(t, []*ModelResponse{}, NewModelResponses(adminContext(), nil))
}
+42
View File
@@ -0,0 +1,42 @@
package dto
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// RoleFromContext returns the caller's tenant role from ctx.
func RoleFromContext(ctx context.Context) types.TenantRole {
return types.TenantRoleFromContext(ctx)
}
// CanViewIntegrationSecrets is true for Admin+ tenant members and for API keys
// with full tenant access or the manage_tenant_settings capability.
func CanViewIntegrationSecrets(ctx context.Context) bool {
if RoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
return true
}
return apiKeyCanManageIntegrationSecrets(ctx)
}
func apiKeyCanManageIntegrationSecrets(ctx context.Context) bool {
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
if !ok {
return false
}
if scope.FullAccess {
return true
}
return scope.HasCapability(types.APIKeyCapabilityManageTenantSettings)
}
// RoleCanViewTenantAPIKey is true for Owner+ only.
func RoleCanViewTenantAPIKey(role types.TenantRole) bool {
return role.HasPermission(types.TenantRoleOwner)
}
// CanViewTenantAPIKey is true for Owner+ only.
func CanViewTenantAPIKey(ctx context.Context) bool {
return RoleCanViewTenantAPIKey(RoleFromContext(ctx))
}
+42
View File
@@ -0,0 +1,42 @@
package dto
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestCanViewIntegrationSecretsAdminRole(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleAdmin)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("admin should view integration secrets")
}
}
func TestCanViewIntegrationSecretsViewerDenied(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("viewer should not view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithManageTenantSettings(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageTenantSettings)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("manage_tenant_settings API key should view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithoutCapabilityDenied(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("chat-only API key should not view integration secrets")
}
}
+91
View File
@@ -0,0 +1,91 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
// TenantResponse is the viewer-safe tenant profile shape. Secret-bearing
// columns are omitted or redacted unless the caller has Admin+.
type TenantResponse struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
RetrieverEngines types.RetrieverEngines `json:"retriever_engines"`
Business string `json:"business"`
StorageQuota int64 `json:"storage_quota"`
StorageUsed int64 `json:"storage_used"`
ContextConfig *types.ContextConfig `json:"context_config,omitempty"`
WebSearchConfig *types.WebSearchConfig `json:"web_search_config,omitempty"`
ParserEngineConfig *types.ParserEngineConfig `json:"parser_engine_config,omitempty"`
Credentials *types.CredentialsConfig `json:"credentials,omitempty"`
StorageEngineConfig *types.StorageEngineConfig `json:"storage_engine_config,omitempty"`
ChatHistoryConfig *types.ChatHistoryConfig `json:"chat_history_config,omitempty"`
RetrievalConfig *types.RetrievalConfig `json:"retrieval_config,omitempty"`
MemoryConfig *types.MemoryConfig `json:"memory_config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at"`
}
// NewTenantResponse converts a stored tenant into its HTTP response shape.
func NewTenantResponse(ctx context.Context, tenant *types.Tenant) *TenantResponse {
return NewTenantResponseWithRole(tenant, RoleFromContext(ctx))
}
// NewTenantResponseWithRole converts a stored tenant using an explicit role
// (for auth flows where tenant role is not yet in request context).
func NewTenantResponseWithRole(tenant *types.Tenant, role types.TenantRole) *TenantResponse {
if tenant == nil {
return nil
}
includeSecrets := role.HasPermission(types.TenantRoleAdmin)
resp := &TenantResponse{
ID: tenant.ID,
Name: tenant.Name,
Description: tenant.Description,
Status: tenant.Status,
RetrieverEngines: tenant.RetrieverEngines,
Business: tenant.Business,
StorageQuota: tenant.StorageQuota,
StorageUsed: tenant.StorageUsed,
ContextConfig: tenant.ContextConfig,
ChatHistoryConfig: tenant.ChatHistoryConfig,
RetrievalConfig: tenant.RetrievalConfig,
MemoryConfig: tenant.MemoryConfig,
CreatedAt: tenant.CreatedAt,
UpdatedAt: tenant.UpdatedAt,
DeletedAt: tenant.DeletedAt,
}
if includeSecrets {
resp.WebSearchConfig = types.WebSearchConfigForResponse(tenant.WebSearchConfig, true)
resp.ParserEngineConfig = types.ParserEngineConfigForResponse(tenant.ParserEngineConfig, true)
resp.Credentials = types.CredentialsConfigForResponse(tenant.Credentials, true)
resp.StorageEngineConfig = types.StorageEngineConfigForResponse(tenant.StorageEngineConfig, true)
}
return resp
}
// NewTenantResponses is the slice convenience wrapper.
func NewTenantResponses(ctx context.Context, tenants []*types.Tenant) []*TenantResponse {
out := make([]*TenantResponse, 0, len(tenants))
for _, t := range tenants {
out = append(out, NewTenantResponse(ctx, t))
}
return out
}
// NewTenantResponsesCrossTenant redacts every tenant as Viewer regardless of
// the caller's active-tenant role. Used by cross-tenant list/search endpoints
// where the caller's home-tenant role must not unlock other tenants' secrets.
func NewTenantResponsesCrossTenant(tenants []*types.Tenant) []*TenantResponse {
out := make([]*TenantResponse, 0, len(tenants))
for _, t := range tenants {
out = append(out, NewTenantResponseWithRole(t, types.TenantRoleViewer))
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTenantResponse_ViewerOmitsSecrets(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponse(viewerContext(), tenant))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "legacy-search-secret-999")
assert.NotContains(t, s, "wk-app-secret-def")
assert.NotContains(t, s, "parser-secret-123")
assert.NotContains(t, s, "minio-secret-789")
assert.NotContains(t, s, "web_search_config")
assert.NotContains(t, s, "parser_engine_config")
assert.NotContains(t, s, "storage_engine_config")
assert.NotContains(t, s, "credentials")
}
func TestTenantResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponse(ownerContext(), tenant))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, `"api_key"`)
assert.NotContains(t, s, "legacy-search-secret-999")
assert.NotContains(t, s, "parser-secret-123")
assert.Contains(t, s, "web_search_config")
}
func TestTenantResponse_AdminGetsRedactedIntegrationConfigs(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewTenantResponse(adminContext(), tenant)
require.NotNil(t, resp.WebSearchConfig)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.WebSearchConfig.ProxyURL)
assert.Empty(t, resp.WebSearchConfig.APIKey)
require.NotNil(t, resp.ParserEngineConfig)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.ParserEngineConfig.MinerUAPIKey)
require.NotNil(t, resp.StorageEngineConfig.MinIO)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.StorageEngineConfig.MinIO.SecretAccessKey)
}
func TestTenantResponsesCrossTenant_RedactsEvenForOwnerContext(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponsesCrossTenant([]*types.Tenant{tenant}))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "parser-secret-123")
}
func sampleSecretTenant() *types.Tenant {
return &types.Tenant{
ID: 42,
Name: "tenant",
WebSearchConfig: &types.WebSearchConfig{
APIKey: "legacy-search-secret-999",
ProxyURL: "http://proxy.internal:8080",
},
Credentials: &types.CredentialsConfig{
WeKnoraCloud: &types.WeKnoraCloudCredentials{
AppID: "wk-app-id-abc",
AppSecret: "wk-app-secret-def",
},
},
ParserEngineConfig: &types.ParserEngineConfig{
MinerUAPIKey: "parser-secret-123",
PaddleOCRVLCloudToken: "paddle-secret-456",
},
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "minio",
MinIO: &types.MinIOEngineConfig{
AccessKeyID: "minio-access-id",
SecretAccessKey: "minio-secret-789",
},
},
}
}
+22
View File
@@ -0,0 +1,22 @@
package dto
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
func adminContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
}
func viewerContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
}
func ownerContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleOwner)
}
@@ -0,0 +1,74 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProviderResponse mirrors types.WebSearchProviderEntity for
// response bodies, with the APIKey field removed by construction. Credential
// presence is exposed via the /credentials subresource.
type WebSearchProviderResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Provider types.WebSearchProviderType `json:"provider"`
Description string `json:"description"`
Parameters WebSearchProviderParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// WebSearchProviderParametersDTO holds every parameter field except APIKey.
// EngineID, BaseURL, ProxyURL and ExtraConfig are not secrets (they describe
// where to send the request, not how to authenticate) and remain visible.
type WebSearchProviderParametersDTO struct {
EngineID string `json:"engine_id,omitempty"`
BaseURL string `json:"base_url,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
}
// NewWebSearchProviderResponse converts a stored entity into its response shape.
func NewWebSearchProviderResponse(ctx context.Context, e *types.WebSearchProviderEntity) *WebSearchProviderResponse {
if e == nil {
return nil
}
params := WebSearchProviderParametersDTO{
EngineID: e.Parameters.EngineID,
BaseURL: e.Parameters.BaseURL,
ProxyURL: e.Parameters.ProxyURL,
ExtraConfig: e.Parameters.ExtraConfig,
}
if !CanViewIntegrationSecrets(ctx) {
params.ProxyURL = ""
params.ExtraConfig = nil
}
return &WebSearchProviderResponse{
ID: e.ID,
TenantID: e.TenantID,
Name: e.Name,
Provider: e.Provider,
Description: e.Description,
Parameters: params,
IsDefault: e.IsDefault,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Credentials: map[string]CredentialFieldMetadata{
"api_key": {Configured: e.Parameters.APIKey != ""},
},
}
}
func NewWebSearchProviderResponses(ctx context.Context, es []*types.WebSearchProviderEntity) []*WebSearchProviderResponse {
out := make([]*WebSearchProviderResponse, 0, len(es))
for _, e := range es {
out = append(out, NewWebSearchProviderResponse(ctx, e))
}
return out
}
@@ -0,0 +1,54 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestWebSearchProviderResponse_OmitsSecrets(t *testing.T) {
e := &types.WebSearchProviderEntity{
ID: "wsp-1",
Name: "bing prod",
Provider: types.WebSearchProviderTypeBing,
Parameters: types.WebSearchProviderParameters{
APIKey: "bing-secret-do-not-leak",
EngineID: "engine-public-id",
BaseURL: "https://example.com",
},
}
body, err := json.Marshal(NewWebSearchProviderResponse(adminContext(), e))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "bing-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
assert.NotContains(t, string(raw["parameters"]), `"api_key"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "engine-public-id")
assert.Contains(t, s, "example.com")
}
func TestWebSearchProviderResponse_ViewerStripsIntegrationDetail(t *testing.T) {
e := &types.WebSearchProviderEntity{
ID: "wsp-2",
Parameters: types.WebSearchProviderParameters{
ProxyURL: "http://proxy:8080",
ExtraConfig: map[string]string{"token": "secret"},
},
}
resp := NewWebSearchProviderResponse(viewerContext(), e)
assert.Empty(t, resp.Parameters.ProxyURL)
assert.Nil(t, resp.Parameters.ExtraConfig)
}
func TestWebSearchProviderResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewWebSearchProviderResponse(adminContext(), nil))
assert.Equal(t, []*WebSearchProviderResponse{}, NewWebSearchProviderResponses(adminContext(), nil))
}
+826
View File
@@ -0,0 +1,826 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/session"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/storageurl"
"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/redis/go-redis/v9"
)
// EmbedChannelHandler manages web embed channel CRUD and public embed endpoints.
type EmbedChannelHandler struct {
embedSvc interfaces.EmbedChannelService
sessionService interfaces.SessionService
sessionHandler *session.Handler
messageHandler *MessageHandler
suggestionHandler *MessageSuggestionHandler
mcpOAuthHandler *MCPOAuthHandler
mcpServiceHandler *MCPServiceHandler
redis *redis.Client
}
func NewEmbedChannelHandler(
embedSvc interfaces.EmbedChannelService,
sessionService interfaces.SessionService,
sessionHandler *session.Handler,
messageHandler *MessageHandler,
suggestionHandler *MessageSuggestionHandler,
mcpOAuthHandler *MCPOAuthHandler,
mcpServiceHandler *MCPServiceHandler,
redisClient *redis.Client,
) *EmbedChannelHandler {
return &EmbedChannelHandler{
embedSvc: embedSvc,
sessionService: sessionService,
sessionHandler: sessionHandler,
messageHandler: messageHandler,
suggestionHandler: suggestionHandler,
mcpOAuthHandler: mcpOAuthHandler,
mcpServiceHandler: mcpServiceHandler,
redis: redisClient,
}
}
type embedChannelRequest struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
AllowedOrigins []string `json:"allowed_origins"`
WelcomeMessage string `json:"welcome_message"`
RateLimitPerMinute int `json:"rate_limit_per_minute"`
RateLimitPerDay int `json:"rate_limit_per_day"`
PrimaryColor string `json:"primary_color"`
PageTitle string `json:"page_title"`
HeaderTitleMode string `json:"header_title_mode"`
ShowSuggestedQuestions *bool `json:"show_suggested_questions"`
WidgetPosition string `json:"widget_position"`
AllowWebSearch *bool `json:"allow_web_search"`
AllowFileUpload *bool `json:"allow_file_upload"`
DefaultLocale *string `json:"default_locale"`
WebhookURL *string `json:"webhook_url"`
WebhookSecret *string `json:"webhook_secret"`
AgentID *string `json:"agent_id"`
}
// isProductionMode reports whether the server runs in a hardened (release) mode.
func isProductionMode() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("GIN_MODE")), "release")
}
func stringOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}
// validateAllowedOrigins enforces that a public embed channel declares an
// explicit origin allowlist. An empty list means "allow any origin" in the
// auth middleware, which is unsafe for a publicly reachable widget, so it is
// rejected. In production a wildcard ("*") is also rejected; each entry must be
// a well-formed http(s) origin (optionally a "*." subdomain wildcard).
func validateAllowedOrigins(origins []string) error {
cleaned := make([]string, 0, len(origins))
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
cleaned = append(cleaned, o)
}
if len(cleaned) == 0 {
return fmt.Errorf("at least one allowed origin is required")
}
for _, o := range cleaned {
if o == "*" {
if isProductionMode() {
return fmt.Errorf("wildcard origin '*' is not allowed in production")
}
continue
}
host := o
if strings.HasPrefix(o, "*.") {
host = "https://" + strings.TrimPrefix(o, "*.")
}
u, err := url.Parse(host)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("invalid allowed origin: %q", o)
}
}
return nil
}
func (h *EmbedChannelHandler) CreateEmbedChannel(c *gin.Context) {
agentID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
var req embedChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateAllowedOrigins(req.AllowedOrigins); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
originsJSON, _ := json.Marshal(req.AllowedOrigins)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
showSuggested := true
if req.ShowSuggestedQuestions != nil {
showSuggested = *req.ShowSuggestedQuestions
}
allowWebSearch := false
if req.AllowWebSearch != nil {
allowWebSearch = *req.AllowWebSearch
}
allowFileUpload := false
if req.AllowFileUpload != nil {
allowFileUpload = *req.AllowFileUpload
}
ch, token, err := h.embedSvc.Create(c.Request.Context(), tenantID, agentID, &types.EmbedChannel{
Name: req.Name,
Enabled: enabled,
AllowedOrigins: originsJSON,
WelcomeMessage: req.WelcomeMessage,
RateLimitPerMinute: req.RateLimitPerMinute,
RateLimitPerDay: req.RateLimitPerDay,
PrimaryColor: req.PrimaryColor,
PageTitle: req.PageTitle,
HeaderTitleMode: req.HeaderTitleMode,
ShowSuggestedQuestions: showSuggested,
WidgetPosition: req.WidgetPosition,
AllowWebSearch: allowWebSearch,
AllowFileUpload: allowFileUpload,
DefaultLocale: types.NormalizeEmbedDefaultLocale(stringOrEmpty(req.DefaultLocale)),
})
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": embedChannelResponse(ch, token),
})
}
func (h *EmbedChannelHandler) ListEmbedChannels(c *gin.Context) {
agentID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
rows, err := h.embedSvc.ListByAgent(c.Request.Context(), tenantID, agentID)
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": embedChannelsResponse(rows)})
}
// ListAllEmbedChannels lists every embed channel in the current tenant, across
// agents, for sidebar session grouping. Publish tokens are never included.
func (h *EmbedChannelHandler) ListAllEmbedChannels(c *gin.Context) {
tenantID := c.GetUint64(types.TenantIDContextKey.String())
rows, err := h.embedSvc.ListByTenant(c.Request.Context(), tenantID)
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": embedChannelsResponse(rows)})
}
func embedChannelsResponse(rows []*types.EmbedChannel) []gin.H {
data := make([]gin.H, 0, len(rows))
for _, ch := range rows {
data = append(data, embedChannelResponse(ch, ""))
}
return data
}
func (h *EmbedChannelHandler) UpdateEmbedChannel(c *gin.Context) {
channelID := secutils.SanitizeForLog(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
var req embedChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Only validate when the caller intends to change the allowlist. A nil slice
// means "leave unchanged"; a present slice must still be a valid allowlist.
if req.AllowedOrigins != nil {
if err := validateAllowedOrigins(req.AllowedOrigins); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
if req.WebhookURL != nil {
if err := service.ValidateEmbedWebhookURL(*req.WebhookURL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
originsJSON, _ := json.Marshal(req.AllowedOrigins)
update := &types.EmbedChannel{
Name: req.Name,
AllowedOrigins: originsJSON,
WelcomeMessage: req.WelcomeMessage,
RateLimitPerMinute: req.RateLimitPerMinute,
RateLimitPerDay: req.RateLimitPerDay,
PrimaryColor: req.PrimaryColor,
PageTitle: req.PageTitle,
HeaderTitleMode: req.HeaderTitleMode,
WidgetPosition: req.WidgetPosition,
}
if req.AgentID != nil {
update.AgentID = strings.TrimSpace(*req.AgentID)
}
ch, err := h.embedSvc.Update(c.Request.Context(), tenantID, channelID, update, req.Enabled, req.ShowSuggestedQuestions, req.AllowWebSearch, req.AllowFileUpload, req.DefaultLocale, req.WebhookURL, req.WebhookSecret)
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": embedChannelResponse(ch, "")})
}
func (h *EmbedChannelHandler) DeleteEmbedChannel(c *gin.Context) {
channelID := secutils.SanitizeForLog(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if err := h.embedSvc.Delete(c.Request.Context(), tenantID, channelID); err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *EmbedChannelHandler) RotateEmbedToken(c *gin.Context) {
channelID := secutils.SanitizeForLog(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
ch, token, err := h.embedSvc.RotateToken(c.Request.Context(), tenantID, channelID)
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": embedChannelResponse(ch, token)})
}
func (h *EmbedChannelHandler) IssuePreviewSession(c *gin.Context) {
channelID := secutils.SanitizeForLog(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
sessionToken, expiresIn, err := h.embedSvc.IssuePreviewSession(c.Request.Context(), tenantID, channelID)
if err != nil {
if errors.Is(err, service.ErrEmbedChannelDisabled) {
c.JSON(http.StatusForbidden, gin.H{"error": "embed channel is disabled"})
return
}
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"session_token": sessionToken,
"expires_in": expiresIn,
},
})
}
func (h *EmbedChannelHandler) ExchangeEmbedSession(c *gin.Context) {
ctx := c.Request.Context()
ch, ok := middleware.EmbedChannelFromContext(ctx)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
// Only the long-lived publish token may mint session tokens. Accepting a
// session token here would let a holder renew it indefinitely without ever
// re-presenting the publish token.
if auth := strings.TrimSpace(c.GetHeader("Authorization")); !strings.HasPrefix(auth, "Embed ") ||
service.IsEmbedSessionToken(strings.TrimPrefix(auth, "Embed ")) {
c.JSON(http.StatusForbidden, gin.H{"error": "publish token required"})
return
}
sessionToken, expiresIn, err := h.embedSvc.IssueSessionToken(ctx, ch.ID)
if err != nil {
if errors.Is(err, service.ErrEmbedSessionUnavailable) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "session tokens unavailable"})
return
}
logger.ErrorWithFields(ctx, err, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue session token"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"session_token": sessionToken,
"expires_in": expiresIn,
},
})
}
func (h *EmbedChannelHandler) GetEmbedConfig(c *gin.Context) {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": h.embedSvc.PublicConfig(c.Request.Context(), ch)})
}
func (h *EmbedChannelHandler) GetEmbedChunk(c *gin.Context) {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
chunkID := secutils.SanitizeForLog(c.Param("chunk_id"))
if chunkID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "chunk_id is required"})
return
}
chunk, err := h.embedSvc.EmbedChunk(c.Request.Context(), ch, chunkID)
if err != nil {
switch {
case errors.Is(err, service.ErrEmbedChunkForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "chunk not accessible"})
case errors.Is(err, service.ErrEmbedChunkNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "chunk not found"})
default:
logger.Error(c.Request.Context(), "embed chunk lookup failed", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load chunk"})
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": chunk})
}
func (h *EmbedChannelHandler) GetEmbedSuggestedQuestions(c *gin.Context) {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if !ch.ShowSuggestedQuestions {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"questions": []types.SuggestedQuestion{}}})
return
}
// limit == 0 signals "unspecified" so the channel agent's starter count
// applies. A provided value is honored up to the embed cap.
limit := 0
if raw := c.Query("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
if limit > 12 {
limit = 12
}
}
}
questions, err := h.embedSvc.SuggestedQuestions(c.Request.Context(), ch, limit)
if err != nil {
logger.Error(c.Request.Context(), "embed suggested questions failed", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load suggested questions"})
return
}
if questions == nil {
questions = []types.SuggestedQuestion{}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"questions": questions}})
}
func (h *EmbedChannelHandler) CreateEmbedSession(c *gin.Context) {
ctx := c.Request.Context()
ch, ok := middleware.EmbedChannelFromContext(ctx)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
tenantID := c.GetUint64(types.TenantIDContextKey.String())
// Leave Title empty so the first visitor message triggers the same async
// title generation as normal chat (see setupSSEStream in session/qa.go).
// Channel display name belongs on the embed page chrome, not every session row.
createdSession := &types.Session{
TenantID: tenantID,
Title: "",
Description: service.EmbedSessionDescription(ch.ID),
}
created, err := h.sessionService.CreateSession(ctx, createdSession)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
return
}
ownerID := types.EmbedSessionPrincipal(tenantID, ch.ID, created.ID).StorageID()
if err := h.sessionService.SetSessionOwnerID(ctx, tenantID, created.ID, ownerID); err != nil {
logger.Warnf(ctx, "failed to assign embed session owner for %s: %v", created.ID, err)
} else {
created.UserID = ownerID
}
// Hand back a signed handle bound to this session; the widget must echo it
// (X-Embed-Session header) on every subsequent load/chat call.
sig := service.SignEmbedSessionHandle(ch, created.ID)
c.JSON(http.StatusCreated, gin.H{"success": true, "data": gin.H{"id": created.ID, "sig": sig}})
}
func (h *EmbedChannelHandler) EmbedKnowledgeChat(c *gin.Context) {
h.delegateEmbedChat(c, false)
}
func (h *EmbedChannelHandler) EmbedAgentChat(c *gin.Context) {
h.delegateEmbedChat(c, true)
}
func (h *EmbedChannelHandler) EmbedLoadMessages(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
h.messageHandler.LoadMessages(c)
}
func (h *EmbedChannelHandler) EmbedStopSession(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
h.sessionHandler.StopSession(c)
}
func (h *EmbedChannelHandler) EmbedEnsureMessageSuggestions(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
ch, _ := middleware.EmbedChannelFromContext(c.Request.Context())
if ch == nil || !ch.ShowSuggestedQuestions || h.suggestionHandler == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"status": "suppressed", "suppression_reason": "channel_disabled", "questions": []any{},
}})
return
}
h.suggestionHandler.Ensure(c)
}
func (h *EmbedChannelHandler) EmbedGetMessageSuggestions(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
ch, _ := middleware.EmbedChannelFromContext(c.Request.Context())
if ch == nil || !ch.ShowSuggestedQuestions || h.suggestionHandler == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"status": "suppressed", "suppression_reason": "channel_disabled", "questions": []any{},
}})
return
}
h.suggestionHandler.Get(c)
}
func (h *EmbedChannelHandler) EmbedRecordSuggestionEvent(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.suggestionHandler == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "suggestion service unavailable"})
return
}
h.suggestionHandler.RecordEvent(c)
}
func (h *EmbedChannelHandler) EmbedResolveMCPOAuth(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.mcpOAuthHandler == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth handler unavailable"})
return
}
h.mcpOAuthHandler.ResolveMCPOAuth(c)
}
func (h *EmbedChannelHandler) EmbedCancelMCPOAuth(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.mcpOAuthHandler == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth handler unavailable"})
return
}
h.mcpOAuthHandler.CancelMCPOAuth(c)
}
func (h *EmbedChannelHandler) EmbedMCPOAuthAuthorizeURL(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.mcpOAuthHandler == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth handler unavailable"})
return
}
h.mcpOAuthHandler.AuthorizeURL(c)
}
func (h *EmbedChannelHandler) EmbedMCPOAuthStatus(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.mcpOAuthHandler == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "oauth handler unavailable"})
return
}
h.mcpOAuthHandler.Status(c)
}
func (h *EmbedChannelHandler) EmbedResolveToolApproval(c *gin.Context) {
if err := h.ensureEmbedSession(c); err != nil {
return
}
if h.mcpServiceHandler == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "tool approval handler unavailable"})
return
}
h.mcpServiceHandler.ResolveToolApproval(c)
}
type embedWebhookEventRequest struct {
Type string `json:"type"`
SessionID string `json:"session_id"`
Query string `json:"query"`
Content string `json:"content"`
}
// EmbedRelayWebhookEvent forwards a visitor chat event to the channel webhook URL.
func (h *EmbedChannelHandler) EmbedRelayWebhookEvent(c *gin.Context) {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if err := h.ensureEmbedSession(c); err != nil {
return
}
var req embedWebhookEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
eventType := strings.TrimSpace(req.Type)
switch eventType {
case "message_sent", "message_received":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported event type"})
return
}
payload := map[string]any{}
if q := strings.TrimSpace(req.Query); q != "" {
payload["query"] = q
}
if content := strings.TrimSpace(req.Content); content != "" {
payload["content"] = content
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
sessionID = secutils.SanitizeForLog(c.Param("session_id"))
}
service.DispatchEmbedWebhook(ch, eventType, sessionID, payload)
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *EmbedChannelHandler) delegateEmbedChat(c *gin.Context, agentMode bool) {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if err := h.ensureEmbedSession(c); err != nil {
return
}
patched, err := patchEmbedChatPayload(c.Request.Body, ch, agentMode)
if err != nil {
switch {
case errors.Is(err, errInvalidEmbedChatBody):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
case errors.Is(err, errInvalidEmbedChatJSON):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare request"})
}
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(patched))
c.Request.ContentLength = int64(len(patched))
if agentMode && ch.AgentID != types.BuiltinQuickAnswerID {
h.sessionHandler.AgentQA(c)
return
}
h.sessionHandler.KnowledgeQA(c)
}
func (h *EmbedChannelHandler) ensureEmbedSession(c *gin.Context) error {
ch, ok := middleware.EmbedChannelFromContext(c.Request.Context())
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return apperrors.NewUnauthorizedError("unauthorized")
}
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"})
return apperrors.NewBadRequestError("session_id is required")
}
sess, err := h.sessionService.GetSessionByID(c.Request.Context(), ch.TenantID, sessionID)
if err != nil || sess == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return apperrors.NewNotFoundError("session not found")
}
marker := service.EmbedSessionDescription(ch.ID)
if sess.TenantID != ch.TenantID || sess.Description != marker {
c.JSON(http.StatusForbidden, gin.H{"error": "session not allowed for this embed channel"})
return apperrors.NewForbiddenError("session not allowed")
}
ownerID := types.EmbedSessionPrincipal(ch.TenantID, ch.ID, sessionID).StorageID()
if strings.TrimSpace(sess.UserID) == "" {
if err := h.sessionService.SetSessionOwnerID(c.Request.Context(), ch.TenantID, sessionID, ownerID); err != nil {
logger.Warnf(c.Request.Context(), "failed to backfill embed session owner for %s: %v", sessionID, err)
}
}
// Require the signed handle minted at creation. This is the per-visitor
// authorization secret: knowing the session id alone (e.g. from a leaked
// access log) is insufficient without the matching signature.
sig := c.GetHeader("X-Embed-Session")
if !service.VerifyEmbedSessionHandle(ch, sessionID, sig) {
c.JSON(http.StatusForbidden, gin.H{"error": "session signature invalid"})
return apperrors.NewForbiddenError("session signature invalid")
}
principal := types.EmbedSessionPrincipal(ch.TenantID, ch.ID, sessionID)
ctx := c.Request.Context()
if visitorID := strings.TrimSpace(c.GetHeader(types.EmbedVisitorHeader)); visitorID != "" {
if err := types.ValidateEmbedVisitorID(visitorID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid embed visitor id"})
return apperrors.NewBadRequestError("invalid embed visitor id")
}
ctx = types.WithEmbedVisitorID(ctx, visitorID)
}
c.Set(types.PrincipalContextKey.String(), principal)
// Embed visitors are anonymous, so every delegated handler must keep
// returning `resource://` handles: their images stay behind the
// channel-scoped /embed/:channel_id/files proxy instead of being handed out
// as shareable, credential-free URLs. Pinning it here covers both the
// `?resource_urls=public` query parameter and a deployment-wide
// RESOURCE_URL_MODE=public default.
ctx = storageurl.WithForcedHandleMode(types.WithPrincipal(ctx, principal))
c.Request = c.Request.WithContext(ctx)
return nil
}
var (
errInvalidEmbedChatBody = errors.New("invalid embed chat request body")
errInvalidEmbedChatJSON = errors.New("invalid embed chat json")
)
// patchEmbedChatPayload merges embed-channel constraints into the client QA body.
func patchEmbedChatPayload(body io.Reader, ch *types.EmbedChannel, agentMode bool) ([]byte, error) {
raw, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("%w: %v", errInvalidEmbedChatBody, err)
}
var payload map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("%w: %v", errInvalidEmbedChatJSON, err)
}
}
if payload == nil {
payload = make(map[string]any)
}
payload["agent_id"] = ch.AgentID
payload["knowledge_base_ids"] = []string{}
clientWebSearch := false
if v, ok := payload["web_search_enabled"].(bool); ok {
clientWebSearch = v
}
// Channel allow_web_search only exposes the visitor toggle; the client must opt in.
payload["web_search_enabled"] = ch.AllowWebSearch && clientWebSearch
if !ch.AllowFileUpload {
delete(payload, "images")
delete(payload, "attachment_uploads")
delete(payload, "attachment_ids")
}
payload["mcp_service_ids"] = []string{}
if agentMode {
payload["agent_enabled"] = true
} else {
payload["agent_enabled"] = false
}
patched, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return patched, nil
}
// GetEmbedChannel returns a single embed channel for management, including the
// publish token so admins can copy deploy snippets at any time.
func (h *EmbedChannelHandler) GetEmbedChannel(c *gin.Context) {
channelID := strings.TrimSpace(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
ch, err := h.embedSvc.GetOwnedChannel(c.Request.Context(), tenantID, channelID)
if err != nil {
writeEmbedMgmtError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": embedChannelResponse(ch, ch.PublishToken),
})
}
// GetEmbedChannelStats returns lightweight usage stats for an embed channel.
func (h *EmbedChannelHandler) GetEmbedChannelStats(c *gin.Context) {
channelID := strings.TrimSpace(c.Param("channel_id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
ctx := c.Request.Context()
if _, err := h.embedSvc.GetOwnedChannel(ctx, tenantID, channelID); err != nil {
writeEmbedMgmtError(c, err)
return
}
result, err := h.sessionService.CountSessionsBySource(ctx, &types.SessionListQuery{
TenantID: tenantID,
Source: "embed:" + channelID,
Page: 1,
PageSize: 1,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total := result
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"session_count": total,
},
})
}
func embedChannelResponse(ch *types.EmbedChannel, publishToken string) gin.H {
row := gin.H{
"id": ch.ID,
"tenant_id": ch.TenantID,
"agent_id": ch.AgentID,
"name": ch.Name,
"enabled": ch.Enabled,
"allowed_origins": ch.AllowedOriginsList(),
"welcome_message": ch.WelcomeMessage,
"rate_limit_per_minute": ch.RateLimitPerMinute,
"rate_limit_per_day": ch.RateLimitPerDay,
"primary_color": ch.PrimaryColor,
"page_title": ch.PageTitle,
"header_title_mode": types.NormalizeEmbedHeaderTitleMode(ch.HeaderTitleMode),
"show_suggested_questions": ch.ShowSuggestedQuestions,
"widget_position": ch.WidgetPosition,
"allow_web_search": ch.AllowWebSearch,
"allow_file_upload": ch.AllowFileUpload,
"default_locale": ch.DefaultLocale,
"webhook_url": ch.WebhookURL,
"has_webhook_secret": ch.WebhookSecret != "",
"created_at": ch.CreatedAt,
"updated_at": ch.UpdatedAt,
}
if publishToken != "" {
row["publish_token"] = publishToken
}
return row
}
func writeEmbedMgmtError(c *gin.Context, err error) {
var appErr *apperrors.AppError
switch {
case errors.Is(err, service.ErrEmbedChannelNotFound):
appErr = apperrors.NewNotFoundError("embed channel not found")
case errors.Is(err, service.ErrAgentNotFound):
appErr = apperrors.NewNotFoundError("agent not found")
case errors.Is(err, service.ErrEmbedWebhookURLInvalid):
appErr = apperrors.NewBadRequestError(err.Error())
case errors.As(err, &appErr) && appErr.HTTPCode >= 400 && appErr.HTTPCode < 500:
// Preserve typed permission/validation failures, including wrapped ones.
default:
logger.Error(c.Request.Context(), "embed channel management failed", err)
appErr = apperrors.NewInternalServerError("operation failed")
}
// Set status for audit middleware; the outer error handler renders once.
c.Status(appErr.HTTPCode)
_ = c.Error(appErr)
}
@@ -0,0 +1,151 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
type exchangeEmbedSvc struct {
sessionToken string
expiresIn int
err error
}
func (f *exchangeEmbedSvc) Create(context.Context, uint64, string, *types.EmbedChannel) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *exchangeEmbedSvc) ListByAgent(context.Context, uint64, string) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) ListByTenant(context.Context, uint64) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) Update(context.Context, uint64, string, *types.EmbedChannel, *bool, *bool, *bool, *bool, *string, *string, *string) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) GetOwnedChannel(context.Context, uint64, string) (*types.EmbedChannel, error) {
return nil, service.ErrEmbedChannelNotFound
}
func (f *exchangeEmbedSvc) Delete(context.Context, uint64, string) error { return nil }
func (f *exchangeEmbedSvc) RotateToken(context.Context, uint64, string) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *exchangeEmbedSvc) LookupForEmbed(context.Context, string, string) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) LookupEnabledChannel(context.Context, string) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) IssueSessionToken(context.Context, string) (string, int, error) {
if f.err != nil {
return "", 0, f.err
}
return f.sessionToken, f.expiresIn, nil
}
func (f *exchangeEmbedSvc) IssuePreviewSession(context.Context, uint64, string) (string, int, error) {
return f.IssueSessionToken(context.Background(), "")
}
func (f *exchangeEmbedSvc) ResolveSessionToken(context.Context, string) (string, error) {
return "", nil
}
func (f *exchangeEmbedSvc) PublicConfig(context.Context, *types.EmbedChannel) types.EmbedChannelPublicConfig {
return types.EmbedChannelPublicConfig{}
}
func (f *exchangeEmbedSvc) SuggestedQuestions(context.Context, *types.EmbedChannel, int) ([]types.SuggestedQuestion, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) EmbedChunk(context.Context, *types.EmbedChannel, string) (*types.Chunk, error) {
return nil, nil
}
func (f *exchangeEmbedSvc) EmbedDisplayTitle(context.Context, *types.EmbedChannel) string {
return "AI Assistant"
}
func TestExchangeEmbedSessionSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &EmbedChannelHandler{embedSvc: &exchangeEmbedSvc{
sessionToken: "ems_test_token",
expiresIn: 1800,
}}
r := gin.New()
r.POST("/exchange", func(c *gin.Context) {
ch := &types.EmbedChannel{ID: "channel-1", Enabled: true}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.ExchangeEmbedSession(c)
})
req := httptest.NewRequest(http.MethodPost, "/exchange", nil)
req.Header.Set("Authorization", "Embed em_publish_token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
SessionToken string `json:"session_token"`
ExpiresIn int `json:"expires_in"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.Success || resp.Data.SessionToken != "ems_test_token" || resp.Data.ExpiresIn != 1800 {
t.Fatalf("unexpected response: %#v", resp)
}
}
func TestExchangeEmbedSessionUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &EmbedChannelHandler{embedSvc: &exchangeEmbedSvc{err: service.ErrEmbedSessionUnavailable}}
r := gin.New()
r.POST("/exchange", func(c *gin.Context) {
ch := &types.EmbedChannel{ID: "channel-1", Enabled: true}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.ExchangeEmbedSession(c)
})
req := httptest.NewRequest(http.MethodPost, "/exchange", nil)
req.Header.Set("Authorization", "Embed em_publish_token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", w.Code)
}
}
func TestExchangeEmbedSessionRejectsSessionToken(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &EmbedChannelHandler{embedSvc: &exchangeEmbedSvc{sessionToken: "ems_new", expiresIn: 1800}}
r := gin.New()
r.POST("/exchange", func(c *gin.Context) {
ch := &types.EmbedChannel{ID: "channel-1", Enabled: true}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.ExchangeEmbedSession(c)
})
req := httptest.NewRequest(http.MethodPost, "/exchange", nil)
req.Header.Set("Authorization", "Embed ems_existing_session")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
}
@@ -0,0 +1,76 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/gin-gonic/gin"
)
func TestEmbedManagementErrorMapping(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
err error
status int
message string
}{
{"forbidden", apperrors.NewForbiddenError("owner or admin required"), http.StatusForbidden, "owner or admin required"},
{"wrapped forbidden", fmt.Errorf("list: %w", apperrors.NewForbiddenError("owner or admin required")), http.StatusForbidden, "owner or admin required"},
{"agent missing", service.ErrAgentNotFound, http.StatusNotFound, "agent not found"},
{"wrapped agent missing", fmt.Errorf("list: %w", service.ErrAgentNotFound), http.StatusNotFound, "agent not found"},
{"channel missing", service.ErrEmbedChannelNotFound, http.StatusNotFound, "embed channel not found"},
{"typed missing", apperrors.NewNotFoundError("agent not found"), http.StatusNotFound, "agent not found"},
{"validation", apperrors.NewBadRequestError("agent_id is required"), http.StatusBadRequest, "agent_id is required"},
{"unexpected failure", errors.New("database credentials must stay private"), http.StatusInternalServerError, "operation failed"},
{"internal app failure", apperrors.NewInternalServerError("private failure details"), http.StatusInternalServerError, "operation failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(middleware.ErrorHandler())
var observedStatus int
var recorded error
router.Use(func(c *gin.Context) {
c.Next()
observedStatus = c.Writer.Status()
if c.Errors.Last() != nil {
recorded = c.Errors.Last().Err
}
})
router.GET("/agents/agent-1/embed-channels", func(c *gin.Context) {
writeEmbedMgmtError(c, tt.err)
})
writer := httptest.NewRecorder()
router.ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "/agents/agent-1/embed-channels", nil))
if writer.Code != tt.status || observedStatus != tt.status {
t.Fatalf("response status %d, audit status %d, want %d", writer.Code, observedStatus, tt.status)
}
var appErr *apperrors.AppError
if !errors.As(recorded, &appErr) || appErr.Message != tt.message {
t.Fatalf("unexpected audit error: %v", recorded)
}
var body struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
decoder := json.NewDecoder(writer.Body)
if err := decoder.Decode(&body); err != nil || body.Error.Message != tt.message {
t.Fatalf("invalid response: %v, %#v", err, body)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
t.Fatalf("error middleware wrote more than one response: %v", err)
}
})
}
}
+335
View File
@@ -0,0 +1,335 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
type flowEmbedSvc struct {
sessionToken string
expiresIn int
issueErr error
channels map[string]*types.EmbedChannel
}
func (f *flowEmbedSvc) Create(context.Context, uint64, string, *types.EmbedChannel) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *flowEmbedSvc) ListByAgent(context.Context, uint64, string) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *flowEmbedSvc) ListByTenant(context.Context, uint64) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (f *flowEmbedSvc) Update(context.Context, uint64, string, *types.EmbedChannel, *bool, *bool, *bool, *bool, *string, *string, *string) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *flowEmbedSvc) GetOwnedChannel(_ context.Context, tenantID uint64, id string) (*types.EmbedChannel, error) {
ch := f.channels[id]
if ch == nil || ch.TenantID != tenantID {
return nil, service.ErrEmbedChannelNotFound
}
return ch, nil
}
func (f *flowEmbedSvc) Delete(context.Context, uint64, string) error { return nil }
func (f *flowEmbedSvc) RotateToken(context.Context, uint64, string) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (f *flowEmbedSvc) LookupForEmbed(_ context.Context, channelID, token string) (*types.EmbedChannel, error) {
ch := f.channels[channelID]
if ch == nil || ch.PublishToken != token {
return nil, service.ErrEmbedTokenInvalid
}
if !ch.Enabled {
return nil, service.ErrEmbedChannelDisabled
}
return ch, nil
}
func (f *flowEmbedSvc) LookupEnabledChannel(context.Context, string) (*types.EmbedChannel, error) {
return nil, nil
}
func (f *flowEmbedSvc) IssueSessionToken(context.Context, string) (string, int, error) {
if f.issueErr != nil {
return "", 0, f.issueErr
}
return f.sessionToken, f.expiresIn, nil
}
func (f *flowEmbedSvc) IssuePreviewSession(context.Context, uint64, string) (string, int, error) {
return f.IssueSessionToken(context.Background(), "")
}
func (f *flowEmbedSvc) ResolveSessionToken(context.Context, string) (string, error) {
return "", nil
}
func (f *flowEmbedSvc) PublicConfig(context.Context, *types.EmbedChannel) types.EmbedChannelPublicConfig {
return types.EmbedChannelPublicConfig{}
}
func (f *flowEmbedSvc) SuggestedQuestions(context.Context, *types.EmbedChannel, int) ([]types.SuggestedQuestion, error) {
return nil, nil
}
func (f *flowEmbedSvc) EmbedChunk(context.Context, *types.EmbedChannel, string) (*types.Chunk, error) {
return nil, nil
}
func (f *flowEmbedSvc) EmbedDisplayTitle(context.Context, *types.EmbedChannel) string {
return "AI Assistant"
}
type flowTenantSvc struct {
tenant *types.Tenant
}
func (f *flowTenantSvc) GetTenantByID(context.Context, uint64) (*types.Tenant, error) {
return f.tenant, nil
}
func (f *flowTenantSvc) CreateTenant(context.Context, *types.Tenant) (*types.Tenant, error) {
return nil, nil
}
func (f *flowTenantSvc) GetTenantsByIDs(context.Context, []uint64) (map[uint64]*types.Tenant, error) {
return nil, nil
}
func (f *flowTenantSvc) UpdateTenant(context.Context, *types.Tenant) (*types.Tenant, error) {
return nil, nil
}
func (f *flowTenantSvc) DeleteTenant(context.Context, uint64) error { return nil }
func (f *flowTenantSvc) ListTenants(context.Context) ([]*types.Tenant, error) {
return nil, nil
}
func (f *flowTenantSvc) ListAllTenants(context.Context) ([]*types.Tenant, error) {
return nil, nil
}
func (f *flowTenantSvc) BulkSetStorageQuota(context.Context, int64) (int64, error) {
return 0, nil
}
func (f *flowTenantSvc) SearchTenants(context.Context, string, uint64, int, int) ([]*types.Tenant, int64, error) {
return nil, 0, nil
}
func (f *flowTenantSvc) GetTenantByIDForUser(context.Context, uint64, string) (*types.Tenant, error) {
return f.tenant, nil
}
func (f *flowTenantSvc) GetWeKnoraCloudCredentials(context.Context) *types.WeKnoraCloudCredentials {
return nil
}
func TestEmbedExchangeFlowIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
const (
channelID = "ch-flow-1"
publishToken = "em_publish_valid"
)
svc := &flowEmbedSvc{
sessionToken: "ems_integration_token",
expiresIn: 1800,
channels: map[string]*types.EmbedChannel{
channelID: {
ID: channelID,
TenantID: 7,
AgentID: "agent-flow-1",
Enabled: true,
PublishToken: publishToken,
AllowedOrigins: []byte(`["https://partner.example.com"]`),
RateLimitPerMinute: 0,
},
},
}
h := &EmbedChannelHandler{embedSvc: svc}
tenantSvc := &flowTenantSvc{tenant: &types.Tenant{ID: 7}}
r := gin.New()
r.POST(
"/api/v1/embed/:channel_id/exchange",
middleware.EmbedAuth(svc, tenantSvc, nil),
h.ExchangeEmbedSession,
)
req := httptest.NewRequest(http.MethodPost, "/api/v1/embed/"+channelID+"/exchange", nil)
req.Header.Set("Authorization", "Embed "+publishToken)
req.Header.Set("Origin", "https://partner.example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
SessionToken string `json:"session_token"`
ExpiresIn int `json:"expires_in"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.Success {
t.Fatalf("expected success, got %#v", resp)
}
if !strings.HasPrefix(resp.Data.SessionToken, "ems_") {
t.Fatalf("session_token = %q, want ems_ prefix", resp.Data.SessionToken)
}
if resp.Data.SessionToken != "ems_integration_token" || resp.Data.ExpiresIn != 1800 {
t.Fatalf("unexpected exchange payload: %#v", resp.Data)
}
}
func TestPatchEmbedChatPayloadInjectsAgentID(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-embed-42"}
body := `{"query":"hello","agent_id":"client-override","web_search_enabled":true}`
patched, err := patchEmbedChatPayload(strings.NewReader(body), ch, false)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
if payload["agent_id"] != "agent-embed-42" {
t.Fatalf("agent_id = %v, want channel agent", payload["agent_id"])
}
if payload["query"] != "hello" {
t.Fatalf("query = %v, want preserved client field", payload["query"])
}
if payload["web_search_enabled"] != false {
t.Fatalf("web_search_enabled = %v, want false", payload["web_search_enabled"])
}
if payload["agent_enabled"] != false {
t.Fatalf("agent_enabled = %v, want false for knowledge mode", payload["agent_enabled"])
}
kbIDs, ok := payload["knowledge_base_ids"].([]any)
if !ok || len(kbIDs) != 0 {
t.Fatalf("knowledge_base_ids = %v, want empty slice", payload["knowledge_base_ids"])
}
}
func TestPatchEmbedChatPayloadWebSearchRequiresClientOptIn(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1", AllowWebSearch: true}
body := `{"query":"hello","web_search_enabled":false}`
patched, err := patchEmbedChatPayload(strings.NewReader(body), ch, false)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
if payload["web_search_enabled"] != false {
t.Fatalf("web_search_enabled = %v, want false when visitor did not opt in", payload["web_search_enabled"])
}
bodyOn := `{"query":"hello","web_search_enabled":true}`
patchedOn, err := patchEmbedChatPayload(strings.NewReader(bodyOn), ch, false)
if err != nil {
t.Fatal(err)
}
var payloadOn map[string]any
if err := json.Unmarshal(patchedOn, &payloadOn); err != nil {
t.Fatal(err)
}
if payloadOn["web_search_enabled"] != true {
t.Fatalf("web_search_enabled = %v, want true when channel allows and visitor opted in", payloadOn["web_search_enabled"])
}
}
func TestPatchEmbedChatPayloadWebSearchBlockedWhenChannelDisabled(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1", AllowWebSearch: false}
body := `{"query":"hello","web_search_enabled":true}`
patched, err := patchEmbedChatPayload(strings.NewReader(body), ch, false)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
if payload["web_search_enabled"] != false {
t.Fatalf("web_search_enabled = %v, want false when channel disallows web search", payload["web_search_enabled"])
}
}
func TestPatchEmbedChatPayloadStripsAttachmentsWhenUploadDisabled(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1", AllowFileUpload: false}
body := `{"query":"hello","images":[{"data":"x"}],"attachment_uploads":[{"file_name":"a.pdf"}],"attachment_ids":["doc-1"]}`
patched, err := patchEmbedChatPayload(strings.NewReader(body), ch, false)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
for _, key := range []string{"images", "attachment_uploads", "attachment_ids"} {
if _, ok := payload[key]; ok {
t.Fatalf("%s should be stripped when allow_file_upload is false, got %v", key, payload[key])
}
}
}
func TestPatchEmbedChatPayloadKeepsAttachmentIDsWhenUploadAllowed(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1", AllowFileUpload: true}
body := `{"query":"hello","attachment_ids":["doc-1","doc-2"]}`
patched, err := patchEmbedChatPayload(strings.NewReader(body), ch, false)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
ids, ok := payload["attachment_ids"].([]any)
if !ok || len(ids) != 2 {
t.Fatalf("attachment_ids = %v, want preserved when upload allowed", payload["attachment_ids"])
}
}
func TestPatchEmbedChatPayloadAgentMode(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-embed-99"}
patched, err := patchEmbedChatPayload(bytes.NewReader(nil), ch, true)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(patched, &payload); err != nil {
t.Fatal(err)
}
if payload["agent_id"] != "agent-embed-99" {
t.Fatalf("agent_id = %v", payload["agent_id"])
}
if payload["agent_enabled"] != true {
t.Fatalf("agent_enabled = %v, want true", payload["agent_enabled"])
}
}
func TestPatchEmbedChatPayloadInvalidJSON(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1"}
_, err := patchEmbedChatPayload(strings.NewReader("{not-json"), ch, false)
if err == nil || !strings.Contains(err.Error(), "invalid embed chat json") {
t.Fatalf("expected invalid json error, got %v", err)
}
}
func TestPatchEmbedChatPayloadInvalidBody(t *testing.T) {
ch := &types.EmbedChannel{AgentID: "agent-1"}
_, err := patchEmbedChatPayload(badReader{}, ch, false)
if err == nil || !strings.Contains(err.Error(), "invalid embed chat request body") {
t.Fatalf("expected invalid body error, got %v", err)
}
}
type badReader struct{}
func (badReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
@@ -0,0 +1,83 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/types"
)
// newEmbedLoadMessagesCtx builds a signed embed request for the message history
// endpoint, with query carrying whatever the visitor asked for.
func newEmbedLoadMessagesCtx(
ch *types.EmbedChannel, query string,
) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(
http.MethodGet, "/messages/"+testEmbedSessionID+"/load"+query, nil)
c.Request.Header.Set("X-Embed-Session", service.SignEmbedSessionHandle(ch, testEmbedSessionID))
c.Params = gin.Params{{Key: "session_id", Value: testEmbedSessionID}}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
return c, w
}
func newEmbedHandlerWithMessage(ch *types.EmbedChannel, content string) *EmbedChannelHandler {
return &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{testEmbedSessionID: validEmbedSession(ch)},
},
messageHandler: &MessageHandler{
MessageService: &stubMessageService{
getRecent: func(context.Context, string, int) ([]*types.Message, error) {
return []*types.Message{{
Content: content,
Images: types.MessageImages{{URL: testResourceHandle}},
}}, nil
},
},
FileService: &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
},
}
}
// Embed visitors are anonymous: their images must stay behind the
// channel-scoped /embed/:channel_id/files proxy. A visitor asking for public
// URLs is downgraded rather than rejected, so an embed client that forwards the
// parameter keeps working.
func TestEmbedLoadMessages_IgnoresPublicResourceURLRequest(t *testing.T) {
ch := testEmbedChannel()
h := newEmbedHandlerWithMessage(ch, "see ![fig]("+testResourceHandle+")")
c, w := newEmbedLoadMessagesCtx(ch, "?resource_urls=public")
h.EmbedLoadMessages(c)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), testResourceHandle)
assert.NotContains(t, w.Body.String(), "cdn.example.com")
}
// The deployment-wide default must not leak public URLs into embed traffic
// either: switching an integration over is a decision about authenticated API
// callers, not about anonymous website visitors.
func TestEmbedLoadMessages_IgnoresDeploymentPublicDefault(t *testing.T) {
t.Setenv("RESOURCE_URL_MODE", "public")
ch := testEmbedChannel()
h := newEmbedHandlerWithMessage(ch, "see ![fig]("+testResourceHandle+")")
c, w := newEmbedLoadMessagesCtx(ch, "")
h.EmbedLoadMessages(c)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), testResourceHandle)
assert.NotContains(t, w.Body.String(), "cdn.example.com")
}
+385
View File
@@ -0,0 +1,385 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/handler/session"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
const (
testEmbedChannelID = "ch-session-test"
testEmbedTenantID = uint64(42)
testEmbedSessionID = "11111111-2222-3333-4444-555555555555"
testEmbedPublishTok = "em_publish_session_test"
)
type stubSessionServiceForEmbed struct {
interfaces.SessionService
sessions map[string]*types.Session
created *types.Session
}
func (s *stubSessionServiceForEmbed) GetSession(_ context.Context, id string) (*types.Session, error) {
sess, ok := s.sessions[id]
if !ok {
return nil, errors.New("session not found")
}
return sess, nil
}
func (s *stubSessionServiceForEmbed) GetSessionByID(_ context.Context, tenantID uint64, id string) (*types.Session, error) {
sess, ok := s.sessions[id]
if !ok || sess.TenantID != tenantID {
return nil, errors.New("session not found")
}
return sess, nil
}
func (s *stubSessionServiceForEmbed) SetSessionOwnerID(_ context.Context, tenantID uint64, sessionID, ownerID string) error {
sess, ok := s.sessions[sessionID]
if !ok || sess.TenantID != tenantID {
return errors.New("session not found")
}
sess.UserID = ownerID
return nil
}
func (s *stubSessionServiceForEmbed) CreateSession(_ context.Context, session *types.Session) (*types.Session, error) {
created := *session
if created.ID == "" {
created.ID = testEmbedSessionID
}
s.created = &created
return &created, nil
}
type sessionEmbedSvc struct {
interfaces.EmbedChannelService
embedChunk func(context.Context, *types.EmbedChannel, string) (*types.Chunk, error)
}
func (s *sessionEmbedSvc) Create(context.Context, uint64, string, *types.EmbedChannel) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (s *sessionEmbedSvc) ListByAgent(context.Context, uint64, string) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (s *sessionEmbedSvc) ListByTenant(context.Context, uint64) ([]*types.EmbedChannel, error) {
return nil, nil
}
func (s *sessionEmbedSvc) Update(context.Context, uint64, string, *types.EmbedChannel, *bool, *bool, *bool, *bool, *string, *string, *string) (*types.EmbedChannel, error) {
return nil, nil
}
func (s *sessionEmbedSvc) GetOwnedChannel(context.Context, uint64, string) (*types.EmbedChannel, error) {
return nil, service.ErrEmbedChannelNotFound
}
func (s *sessionEmbedSvc) Delete(context.Context, uint64, string) error { return nil }
func (s *sessionEmbedSvc) RotateToken(context.Context, uint64, string) (*types.EmbedChannel, string, error) {
return nil, "", nil
}
func (s *sessionEmbedSvc) LookupForEmbed(context.Context, string, string) (*types.EmbedChannel, error) {
return nil, nil
}
func (s *sessionEmbedSvc) LookupEnabledChannel(context.Context, string) (*types.EmbedChannel, error) {
return nil, nil
}
func (s *sessionEmbedSvc) IssueSessionToken(context.Context, string) (string, int, error) {
return "", 0, nil
}
func (s *sessionEmbedSvc) IssuePreviewSession(context.Context, uint64, string) (string, int, error) {
return "", 0, nil
}
func (s *sessionEmbedSvc) ResolveSessionToken(context.Context, string) (string, error) {
return "", nil
}
func (s *sessionEmbedSvc) PublicConfig(context.Context, *types.EmbedChannel) types.EmbedChannelPublicConfig {
return types.EmbedChannelPublicConfig{}
}
func (s *sessionEmbedSvc) SuggestedQuestions(context.Context, *types.EmbedChannel, int) ([]types.SuggestedQuestion, error) {
return nil, nil
}
func (s *sessionEmbedSvc) EmbedChunk(ctx context.Context, ch *types.EmbedChannel, chunkID string) (*types.Chunk, error) {
if s.embedChunk != nil {
return s.embedChunk(ctx, ch, chunkID)
}
return nil, nil
}
func (s *sessionEmbedSvc) EmbedDisplayTitle(context.Context, *types.EmbedChannel) string {
return ""
}
func testEmbedChannel() *types.EmbedChannel {
return &types.EmbedChannel{
ID: testEmbedChannelID,
TenantID: testEmbedTenantID,
PublishToken: testEmbedPublishTok,
Enabled: true,
}
}
func validEmbedSession(ch *types.EmbedChannel) *types.Session {
return &types.Session{
ID: testEmbedSessionID,
TenantID: ch.TenantID,
Description: service.EmbedSessionDescription(ch.ID),
}
}
func newEnsureEmbedSessionCtx(ch *types.EmbedChannel, sessionID, sig string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/sessions/"+sessionID+"/messages", nil)
if sig != "" {
c.Request.Header.Set("X-Embed-Session", sig)
}
c.Params = gin.Params{{Key: "session_id", Value: sessionID}}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
return c, w
}
func TestEnsureEmbedSessionValid(t *testing.T) {
ch := testEmbedChannel()
sig := service.SignEmbedSessionHandle(ch, testEmbedSessionID)
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: validEmbedSession(ch),
},
},
}
c, w := newEnsureEmbedSessionCtx(ch, testEmbedSessionID, sig)
if err := h.ensureEmbedSession(c); err != nil {
t.Fatalf("ensureEmbedSession() = %v, want nil", err)
}
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want no response written on success", w.Code)
}
}
func TestEnsureEmbedSessionWrongTenant(t *testing.T) {
ch := testEmbedChannel()
sig := service.SignEmbedSessionHandle(ch, testEmbedSessionID)
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: {
ID: testEmbedSessionID,
TenantID: 999,
Description: service.EmbedSessionDescription(ch.ID),
},
},
},
}
c, w := newEnsureEmbedSessionCtx(ch, testEmbedSessionID, sig)
if err := h.ensureEmbedSession(c); err == nil {
t.Fatal("expected error for cross-tenant session")
}
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d, body = %s", w.Code, http.StatusNotFound, w.Body.String())
}
}
func TestEnsureEmbedSessionWrongDescription(t *testing.T) {
ch := testEmbedChannel()
sig := service.SignEmbedSessionHandle(ch, testEmbedSessionID)
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: {
ID: testEmbedSessionID,
TenantID: ch.TenantID,
Description: "not-an-embed-session",
},
},
},
}
c, w := newEnsureEmbedSessionCtx(ch, testEmbedSessionID, sig)
if err := h.ensureEmbedSession(c); err == nil {
t.Fatal("expected error for wrong description marker")
}
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestEnsureEmbedSessionInvalidSig(t *testing.T) {
ch := testEmbedChannel()
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: validEmbedSession(ch),
},
},
}
c, w := newEnsureEmbedSessionCtx(ch, testEmbedSessionID, "invalid-signature")
if err := h.ensureEmbedSession(c); err == nil {
t.Fatal("expected error for invalid signature")
}
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestEnsureEmbedSessionNotFound(t *testing.T) {
ch := testEmbedChannel()
h := &EmbedChannelHandler{sessionService: &stubSessionServiceForEmbed{sessions: map[string]*types.Session{}}}
c, w := newEnsureEmbedSessionCtx(ch, testEmbedSessionID, "anything")
if err := h.ensureEmbedSession(c); err == nil {
t.Fatal("expected error for missing session")
}
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestCreateEmbedSessionSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
ch := testEmbedChannel()
stub := &stubSessionServiceForEmbed{sessions: map[string]*types.Session{}}
h := &EmbedChannelHandler{sessionService: stub}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/sessions", nil)
c.Set(types.TenantIDContextKey.String(), ch.TenantID)
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.CreateEmbedSession(c)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
ID string `json:"id"`
Sig string `json:"sig"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.Success || resp.Data.ID != testEmbedSessionID {
t.Fatalf("unexpected response: %#v", resp)
}
if stub.created == nil || stub.created.Description != service.EmbedSessionDescription(ch.ID) {
t.Fatalf("created session marker = %q", stub.created.Description)
}
if !service.VerifyEmbedSessionHandle(ch, resp.Data.ID, resp.Data.Sig) {
t.Fatal("returned sig must verify against created session")
}
}
func TestGetEmbedChunkForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
ch := testEmbedChannel()
h := &EmbedChannelHandler{
embedSvc: &sessionEmbedSvc{
embedChunk: func(_ context.Context, _ *types.EmbedChannel, _ string) (*types.Chunk, error) {
return nil, service.ErrEmbedChunkForbidden
},
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/chunks/chunk-1", nil)
c.Params = gin.Params{{Key: "chunk_id", Value: "chunk-1"}}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.GetEmbedChunk(c)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestGetEmbedChunkSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
ch := testEmbedChannel()
h := &EmbedChannelHandler{
embedSvc: &sessionEmbedSvc{
embedChunk: func(_ context.Context, _ *types.EmbedChannel, chunkID string) (*types.Chunk, error) {
return &types.Chunk{ID: chunkID, Content: "hello"}, nil
},
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/chunks/chunk-ok", nil)
c.Params = gin.Params{{Key: "chunk_id", Value: "chunk-ok"}}
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
h.GetEmbedChunk(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func newEmbedStopSessionCtx(ch *types.EmbedChannel, sessionID, sig, body string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/sessions/"+sessionID+"/stop", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
if sig != "" {
c.Request.Header.Set("X-Embed-Session", sig)
}
c.Params = gin.Params{{Key: "session_id", Value: sessionID}}
c.Set(types.TenantIDContextKey.String(), ch.TenantID)
ctx := context.WithValue(c.Request.Context(), types.EmbedChannelContextKey, ch)
c.Request = c.Request.WithContext(ctx)
return c, w
}
func TestEmbedStopSessionInvalidSig(t *testing.T) {
ch := testEmbedChannel()
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: validEmbedSession(ch),
},
},
sessionHandler: &session.Handler{},
}
c, w := newEmbedStopSessionCtx(ch, testEmbedSessionID, "bad-sig", `{"message_id":"msg-1"}`)
h.EmbedStopSession(c)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func TestEmbedStopSessionMissingMessageID(t *testing.T) {
ch := testEmbedChannel()
sig := service.SignEmbedSessionHandle(ch, testEmbedSessionID)
h := &EmbedChannelHandler{
sessionService: &stubSessionServiceForEmbed{
sessions: map[string]*types.Session{
testEmbedSessionID: validEmbedSession(ch),
},
},
sessionHandler: &session.Handler{},
}
c, w := newEmbedStopSessionCtx(ch, testEmbedSessionID, sig, `{}`)
h.EmbedStopSession(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
+131
View File
@@ -0,0 +1,131 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"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"
"github.com/gin-gonic/gin"
)
// EvaluationHandler handles evaluation related HTTP requests
type EvaluationHandler struct {
evaluationService interfaces.EvaluationService // Service for evaluation operations
}
// NewEvaluationHandler creates a new EvaluationHandler instance
func NewEvaluationHandler(evaluationService interfaces.EvaluationService) *EvaluationHandler {
return &EvaluationHandler{evaluationService: evaluationService}
}
// EvaluationRequest contains parameters for evaluation request
type EvaluationRequest struct {
DatasetID string `json:"dataset_id"` // ID of dataset to evaluate
KnowledgeBaseID string `json:"knowledge_base_id"` // ID of knowledge base to use
ChatModelID string `json:"chat_id"` // ID of chat model to use
RerankModelID string `json:"rerank_id"` // ID of rerank model to use
}
// Evaluation godoc
// @Summary 执行评估
// @Description 对知识库进行评估测试
// @Tags 评估
// @Accept json
// @Produce json
// @Param request body EvaluationRequest true "评估请求参数"
// @Success 200 {object} map[string]interface{} "评估任务"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /evaluation/ [post]
func (e *EvaluationHandler) Evaluation(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start processing evaluation request")
var request EvaluationRequest
if err := c.ShouldBind(&request); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
return
}
tenantID, exists := c.Get(string(types.TenantIDContextKey))
if !exists {
logger.Error(ctx, "Failed to get tenant ID")
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
logger.Infof(ctx, "Executing evaluation, tenant: %v, dataset: %s, knowledge_base: %s, chat: %s, rerank: %s",
tenantID,
secutils.SanitizeForLog(request.DatasetID),
secutils.SanitizeForLog(request.KnowledgeBaseID),
secutils.SanitizeForLog(request.ChatModelID),
secutils.SanitizeForLog(request.RerankModelID),
)
task, err := e.evaluationService.Evaluation(ctx,
secutils.SanitizeForLog(request.DatasetID),
secutils.SanitizeForLog(request.KnowledgeBaseID),
secutils.SanitizeForLog(request.ChatModelID),
secutils.SanitizeForLog(request.RerankModelID),
)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Evaluation task created successfully")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": task,
})
}
// GetEvaluationRequest contains parameters for getting evaluation result
type GetEvaluationRequest struct {
TaskID string `form:"task_id" binding:"required"` // ID of evaluation task
}
// GetEvaluationResult godoc
// @Summary 获取评估结果
// @Description 根据任务ID获取评估结果
// @Tags 评估
// @Accept json
// @Produce json
// @Param task_id query string true "评估任务ID"
// @Success 200 {object} map[string]interface{} "评估结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /evaluation/ [get]
func (e *EvaluationHandler) GetEvaluationResult(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving evaluation result")
var request GetEvaluationRequest
if err := c.ShouldBind(&request); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
return
}
result, err := e.evaluationService.EvaluationResult(ctx, secutils.SanitizeForLog(request.TaskID))
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Info(ctx, "Retrieved evaluation result successfully")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
+581
View File
@@ -0,0 +1,581 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/errors"
"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"
)
// FAQHandler handles FAQ knowledge base operations.
//
// All KB-access checks (own / org-shared / via shared agent) are now
// performed by the route-level g.KBAccessRead / g.KBAccessWrite
// guards in router.go — the guard rewrites c.Request.Context() to
// carry the effective tenant ID for the duration of the handler, so
// the handler reads tenant from context the way it always did.
type FAQHandler struct {
knowledgeService interfaces.KnowledgeService
kbService interfaces.KnowledgeBaseService
}
// NewFAQHandler creates a new FAQ handler.
func NewFAQHandler(
knowledgeService interfaces.KnowledgeService,
kbService interfaces.KnowledgeBaseService,
) *FAQHandler {
return &FAQHandler{
knowledgeService: knowledgeService,
kbService: kbService,
}
}
// faqDeleteRequest is a request for deleting FAQ entries in batch
type faqDeleteRequest struct {
IDs []int64 `json:"ids" binding:"required,min=1"`
}
// faqEntryTagBatchRequest is a request for updating tags for FAQ entries in batch
// key: entry seq_id, value: tag seq_id (nil to remove tag)
type faqEntryTagBatchRequest struct {
Updates map[int64]*int64 `json:"updates" binding:"required,min=1"`
}
// addSimilarQuestionsRequest is a request for adding similar questions to a FAQ entry
type addSimilarQuestionsRequest struct {
SimilarQuestions []string `json:"similar_questions" binding:"required,min=1"`
}
// updateLastFAQImportResultDisplayStatusRequest is the request payload for UpdateLastImportResultDisplayStatus
type updateLastFAQImportResultDisplayStatusRequest struct {
DisplayStatus string `json:"display_status" binding:"required,oneof=open close"`
}
// ListEntries godoc
// @Summary 获取FAQ条目列表
// @Description 获取知识库下的FAQ条目列表,支持分页和筛选
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param tag_id query int false "标签ID筛选(seq_id),兼容旧版单标签"
// @Param tag_ids query string false "标签UUID筛选,逗号分隔(OR语义)"
// @Param keyword query string false "关键词搜索"
// @Param search_field query string false "搜索字段: standard_question(标准问题), similar_questions(相似问法), answers(答案), 默认搜索全部"
// @Param sort_order query string false "排序方式: asc(按更新时间正序), 默认按更新时间倒序"
// @Success 200 {object} map[string]interface{} "FAQ列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries [get]
func (h *FAQHandler) ListEntries(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var page types.Pagination
if err := c.ShouldBindQuery(&page); err != nil {
logger.Error(ctx, "Failed to bind pagination query", err)
c.Error(errors.NewBadRequestError("分页参数不合法").WithDetails(err.Error()))
return
}
tagUUIDs := parseCommaSeparatedTagIDs(c.Query("tag_ids"))
var legacyTagSeqID int64
tagIDStr := c.Query("tag_id")
if tagIDStr != "" {
var err error
legacyTagSeqID, err = strconv.ParseInt(tagIDStr, 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("tag_id 必须是整数"))
return
}
}
keyword := secutils.SanitizeForLog(c.Query("keyword"))
searchField := secutils.SanitizeForLog(c.Query("search_field"))
sortOrder := secutils.SanitizeForLog(c.Query("sort_order"))
result, err := h.knowledgeService.ListFAQEntries(ctx, kbID, &page, tagUUIDs, legacyTagSeqID, keyword, searchField, sortOrder)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
// UpsertEntries godoc
// @Summary 批量更新/插入FAQ条目
// @Description 异步批量更新或插入FAQ条目。支持 dry_run 模式(设置 dry_run=true),异步验证不实际导入。
// @Description dry_run 模式是异步操作,返回 task_id,通过 /faq/import/progress/{task_id} 查询进度和结果。
// @Description 验证内容包括:1) 条目基本格式 2) 重复问题(批次内和知识库已有) 3) 内容安全检查。
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body types.FAQBatchUpsertPayload true "批量操作请求"
// @Success 200 {object} map[string]interface{} "任务ID"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries [post]
func (h *FAQHandler) UpsertEntries(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req types.FAQBatchUpsertPayload
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ upsert payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
taskID, err := h.knowledgeService.UpsertFAQEntries(ctx, kbID, &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"task_id": taskID,
},
})
}
// CreateEntry godoc
// @Summary 创建单个FAQ条目
// @Description 同步创建单个FAQ条目
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body types.FAQEntryPayload true "FAQ条目"
// @Success 200 {object} map[string]interface{} "创建的FAQ条目"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entry [post]
func (h *FAQHandler) CreateEntry(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req types.FAQEntryPayload
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ entry payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
entry, err := h.knowledgeService.CreateFAQEntry(ctx, kbID, &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": entry,
})
}
// UpdateEntry godoc
// @Summary 更新FAQ条目
// @Description 更新指定的FAQ条目
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param entry_id path int true "FAQ条目ID(seq_id)"
// @Param request body types.FAQEntryPayload true "FAQ条目"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/{entry_id} [put]
func (h *FAQHandler) UpdateEntry(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req types.FAQEntryPayload
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ entry payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
return
}
entry, err := h.knowledgeService.UpdateFAQEntry(ctx, kbID, entrySeqID, &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": entry,
})
}
// UpdateEntryTagBatch godoc
// @Summary 批量更新FAQ标签
// @Description 批量更新FAQ条目的标签
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body object true "标签更新请求"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/tags [put]
func (h *FAQHandler) UpdateEntryTagBatch(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req faqEntryTagBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ entry tag batch payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
if err := h.knowledgeService.UpdateFAQEntryTagBatch(ctx, kbID, req.Updates); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
// UpdateEntryFieldsBatch godoc
// @Summary 批量更新FAQ字段
// @Description 批量更新FAQ条目的多个字段(is_enabled, is_recommended, tag_id)
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body types.FAQEntryFieldsBatchUpdate true "字段更新请求"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/fields [put]
func (h *FAQHandler) UpdateEntryFieldsBatch(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req types.FAQEntryFieldsBatchUpdate
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ entry fields batch payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
if err := h.knowledgeService.UpdateFAQEntryFieldsBatch(ctx, kbID, &req); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
// DeleteEntries godoc
// @Summary 批量删除FAQ条目
// @Description 批量删除指定的FAQ条目
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body object{ids=[]int} true "要删除的FAQ ID列表(seq_id)"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries [delete]
func (h *FAQHandler) DeleteEntries(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req faqDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Errorf(ctx, "Failed to bind FAQ delete payload: %s", secutils.SanitizeForLog(err.Error()))
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
if err := h.knowledgeService.DeleteFAQEntries(ctx, kbID, req.IDs); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
// SearchFAQ godoc
// @Summary 搜索FAQ
// @Description 使用混合搜索在FAQ中搜索,支持两级优先级标签召回:first_priority_tag_ids优先级最高,second_priority_tag_ids次之
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body types.FAQSearchRequest true "搜索请求"
// @Success 200 {object} map[string]interface{} "搜索结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/search [post]
func (h *FAQHandler) SearchFAQ(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req types.FAQSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind FAQ search payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
req.QueryText = secutils.SanitizeForLog(req.QueryText)
if req.MatchCount <= 0 {
req.MatchCount = 10
}
if req.MatchCount > 200 {
req.MatchCount = 200
}
entries, err := h.knowledgeService.SearchFAQEntries(ctx, kbID, &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": entries,
})
}
// ExportEntries godoc
// @Summary 导出FAQ条目
// @Description 将所有FAQ条目导出为 CSV(默认)或 JSON。?format=json 返回与 FAQEntryPayload 结构兼容的数组。
// @Tags FAQ管理
// @Accept json
// @Produce text/csv
// @Produce application/json
// @Param id path string true "知识库ID"
// @Param format query string false "导出格式:csv(默认)或 json"
// @Success 200 {file} file "导出文件"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/export [get]
func (h *FAQHandler) ExportEntries(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
format := strings.ToLower(strings.TrimSpace(c.Query("format")))
if format == "json" {
jsonData, err := h.knowledgeService.ExportFAQEntriesJSON(ctx, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename=faq_export.json")
c.Data(http.StatusOK, "application/json; charset=utf-8", jsonData)
return
}
csvData, err := h.knowledgeService.ExportFAQEntries(ctx, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
// Set response headers for CSV download
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename=faq_export.csv")
// Add BOM for Excel compatibility with UTF-8
bom := []byte{0xEF, 0xBB, 0xBF}
c.Data(http.StatusOK, "text/csv; charset=utf-8", append(bom, csvData...))
}
// GetEntry godoc
// @Summary 获取FAQ条目详情
// @Description 根据ID获取单个FAQ条目的详情
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param entry_id path int true "FAQ条目ID(seq_id)"
// @Success 200 {object} map[string]interface{} "FAQ条目详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "条目不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/{entry_id} [get]
func (h *FAQHandler) GetEntry(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
return
}
entry, err := h.knowledgeService.GetFAQEntry(ctx, kbID, entrySeqID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": entry,
})
}
// GetImportProgress godoc
// @Summary 获取FAQ导入进度
// @Description 获取FAQ导入任务的进度
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param task_id path string true "任务ID"
// @Success 200 {object} map[string]interface{} "导入进度"
// @Failure 404 {object} errors.AppError "任务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /faq/import/progress/{task_id} [get]
func (h *FAQHandler) GetImportProgress(c *gin.Context) {
ctx := c.Request.Context()
taskID := secutils.SanitizeForLog(c.Param("task_id"))
if err := requireTaskProgressTenant(ctx, taskID); err != nil {
c.Error(err)
return
}
progress, err := h.knowledgeService.GetFAQImportProgress(ctx, taskID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": progress,
})
}
// UpdateLastImportResultDisplayStatus godoc
// @Summary 更新FAQ最后一次导入结果显示状态
// @Description 更新FAQ知识库导入结果统计卡片的显示或隐藏状态
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body updateLastFAQImportResultDisplayStatusRequest true "状态更新请求"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "知识库不存在或无导入记录"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/import/last-result/display [put]
func (h *FAQHandler) UpdateLastImportResultDisplayStatus(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
var req updateLastFAQImportResultDisplayStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind display status update payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
if err := h.knowledgeService.UpdateLastFAQImportResultDisplayStatus(ctx, kbID, req.DisplayStatus); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
// AddSimilarQuestions godoc
// @Summary 添加相似问
// @Description 向指定的FAQ条目添加相似问题
// @Tags FAQ管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param entry_id path int true "FAQ条目ID(seq_id)"
// @Param request body addSimilarQuestionsRequest true "相似问列表"
// @Success 200 {object} map[string]interface{} "更新后的FAQ条目"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "条目不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/faq/entries/{entry_id}/similar-questions [post]
func (h *FAQHandler) AddSimilarQuestions(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
return
}
var req addSimilarQuestionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind add similar questions payload", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
entry, err := h.knowledgeService.AddSimilarQuestions(ctx, kbID, entrySeqID, req.SimilarQuestions)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": entry,
})
}
+466
View File
@@ -0,0 +1,466 @@
package handler
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"github.com/Tencent/WeKnora/internal/im"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// validIMPlatforms is the set of supported IM platforms.
var validIMPlatforms = map[string]bool{
"wecom": true, "feishu": true, "lark": true, "slack": true, "telegram": true, "dingtalk": true,
"mattermost": true, "wechat": true, "qqbot": true, "yunzhijia": true,
}
// invalidIMPlatformError is the 400 message listing the accepted platforms. It
// is derived from validIMPlatforms so the two cannot drift apart as platforms
// are added.
var invalidIMPlatformError = func() string {
names := make([]string, 0, len(validIMPlatforms))
for p := range validIMPlatforms {
names = append(names, "'"+p+"'")
}
sort.Strings(names)
return "platform must be one of: " + strings.Join(names, ", ")
}()
// IMHandler handles IM platform callback requests and channel CRUD.
type IMHandler struct {
imService *im.Service
}
// NewIMHandler creates a new IM handler.
func NewIMHandler(imService *im.Service) *IMHandler {
return &IMHandler{
imService: imService,
}
}
// ── Channel CRUD handlers ──
// CreateIMChannel creates a new IM channel for an agent.
func (h *IMHandler) CreateIMChannel(c *gin.Context) {
agentID := c.Param("id")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id is required"})
return
}
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req struct {
Platform string `json:"platform" binding:"required"`
Name string `json:"name"`
Mode string `json:"mode"`
OutputMode string `json:"output_mode"`
SessionMode string `json:"session_mode"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Credentials types.JSON `json:"credentials"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validIMPlatforms[req.Platform] {
c.JSON(http.StatusBadRequest, gin.H{"error": invalidIMPlatformError})
return
}
channel := &im.IMChannel{
TenantID: tenantID,
AgentID: agentID,
Platform: req.Platform,
Name: req.Name,
Mode: req.Mode,
OutputMode: req.OutputMode,
SessionMode: req.SessionMode,
KnowledgeBaseID: req.KnowledgeBaseID,
Credentials: req.Credentials,
Enabled: true,
}
if req.Enabled != nil {
channel.Enabled = *req.Enabled
}
// WeChat uses long-polling mode and full output only
if req.Platform == "wechat" {
channel.Mode = "longpoll"
channel.OutputMode = "full"
} else {
if channel.Mode == "" {
if channel.Platform == "mattermost" || channel.Platform == "yunzhijia" {
channel.Mode = "webhook"
} else {
channel.Mode = "websocket"
}
}
if channel.OutputMode == "" {
channel.OutputMode = "stream"
}
}
if channel.Credentials == nil {
channel.Credentials = types.JSON("{}")
}
if err := h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), agentID, tenantID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
if err := h.imService.CreateChannel(channel); err != nil {
logger.Errorf(c.Request.Context(), "[IM] Create channel failed: %v", err)
if strings.HasPrefix(err.Error(), "duplicate_bot:") {
c.JSON(http.StatusConflict, gin.H{"error": strings.TrimPrefix(err.Error(), "duplicate_bot: ")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
c.JSON(http.StatusOK, gin.H{"data": channel})
}
// ListIMChannels lists all IM channels for an agent.
func (h *IMHandler) ListIMChannels(c *gin.Context) {
agentID := c.Param("id")
if agentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id is required"})
return
}
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if err := h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), agentID, tenantID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
channels, err := h.imService.ListChannelsByAgent(agentID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
c.JSON(http.StatusOK, gin.H{"data": im.SummarizeIMChannels(channels)})
}
// ListAllIMChannels lists every IM channel in the current tenant, across
// agents, for the cross-agent overview page. Credentials are intentionally
// NOT included in the response.
func (h *IMHandler) ListAllIMChannels(c *gin.Context) {
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
channels, err := h.imService.ListChannelsByTenant(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
c.JSON(http.StatusOK, gin.H{"data": channels})
}
// UpdateIMChannel updates an IM channel.
//
// UpdateIMChannel godoc
// @Summary 更新 IM 渠道
// @Description 更新指定 IM 渠道的名称、模式、知识库、凭证或启用状态
// @Tags IM 渠道
// @Accept json
// @Produce json
// @Param id path string true "渠道 ID"
// @Param request body map[string]interface{} true "更新字段(name/mode/output_mode/knowledge_base_id/credentials/enabled)"
// @Success 200 {object} map[string]interface{} "更新后的渠道"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "渠道不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /im-channels/{id} [put]
func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
channelID := c.Param("id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
return
}
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
channel, err := h.imService.GetChannelByIDAndTenant(channelID, tenantID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err := h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), channel.AgentID, tenantID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
var req struct {
Name *string `json:"name"`
Mode *string `json:"mode"`
OutputMode *string `json:"output_mode"`
SessionMode *string `json:"session_mode"`
KnowledgeBaseID *string `json:"knowledge_base_id"`
Credentials types.JSON `json:"credentials"`
Enabled *bool `json:"enabled"`
AgentID *string `json:"agent_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
channel.Name = *req.Name
}
if req.Mode != nil {
channel.Mode = *req.Mode
}
if req.OutputMode != nil {
channel.OutputMode = *req.OutputMode
}
if req.SessionMode != nil {
channel.SessionMode = *req.SessionMode
}
if req.KnowledgeBaseID != nil {
channel.KnowledgeBaseID = *req.KnowledgeBaseID
}
if req.Credentials != nil {
channel.Credentials = req.Credentials
}
if req.Enabled != nil {
channel.Enabled = *req.Enabled
}
if req.AgentID != nil {
newAgentID := strings.TrimSpace(*req.AgentID)
if newAgentID != "" && newAgentID != channel.AgentID {
if err := h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), newAgentID, tenantID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
if err := h.imService.SetChannelAgentID(c.Request.Context(), channel, newAgentID); err != nil {
logger.Errorf(c.Request.Context(), "[IM] Update channel agent failed: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "agent not found"})
return
}
}
}
if err := h.imService.UpdateChannel(channel); err != nil {
logger.Errorf(c.Request.Context(), "[IM] Update channel failed: %v", err)
if strings.HasPrefix(err.Error(), "duplicate_bot:") {
c.JSON(http.StatusConflict, gin.H{"error": strings.TrimPrefix(err.Error(), "duplicate_bot: ")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
c.JSON(http.StatusOK, gin.H{"data": channel})
}
// DeleteIMChannel deletes an IM channel.
//
// DeleteIMChannel godoc
// @Summary 删除 IM 渠道
// @Description 删除指定 IM 渠道
// @Tags IM 渠道
// @Produce json
// @Param id path string true "渠道 ID"
// @Success 200 {object} map[string]interface{} "success: true"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "渠道不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /im-channels/{id} [delete]
func (h *IMHandler) DeleteIMChannel(c *gin.Context) {
channelID := c.Param("id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
return
}
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
channel, err := h.imService.GetChannelByIDAndTenant(channelID, tenantID)
if err != nil || h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), channel.AgentID, tenantID) != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err := h.imService.DeleteChannel(channelID, tenantID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ToggleIMChannel toggles the enabled state of an IM channel.
//
// ToggleIMChannel godoc
// @Summary 启用/停用 IM 渠道
// @Description 切换指定 IM 渠道的启用状态
// @Tags IM 渠道
// @Produce json
// @Param id path string true "渠道 ID"
// @Success 200 {object} map[string]interface{} "更新后的渠道"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "渠道不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /im-channels/{id}/toggle [post]
func (h *IMHandler) ToggleIMChannel(c *gin.Context) {
channelID := c.Param("id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel id is required"})
return
}
tenantID, ok := c.Request.Context().Value(types.TenantIDContextKey).(uint64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
channel, err := h.imService.GetChannelByIDAndTenant(channelID, tenantID)
if err != nil || h.imService.CheckPersonalChannelAgentAccess(c.Request.Context(), channel.AgentID, tenantID) != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
channel, err = h.imService.ToggleChannel(channelID, tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to toggle channel"})
return
}
c.JSON(http.StatusOK, gin.H{"data": channel})
}
func writeIMCallbackACK(c *gin.Context, platform string) {
if platform == "yunzhijia" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"type": 2,
"content": "",
},
})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ── Callback handlers ──
// IMCallback handles IM platform callback requests for a specific channel.
// Route: POST /api/v1/im/callback/:channel_id
//
// IMCallback godoc
// @Summary IM 平台回调
// @Description 接收各 IM 平台的事件回调;走平台自身签名校验,不使用 API Key
// @Tags IM 回调
// @Accept json
// @Produce json
// @Param channel_id path string true "渠道 ID"
// @Success 200 {object} map[string]interface{} "处理结果"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "签名校验失败"
// @Router /im/callback/{channel_id} [get]
// @Router /im/callback/{channel_id} [post]
func (h *IMHandler) IMCallback(c *gin.Context) {
ctx := c.Request.Context()
channelID := c.Param("channel_id")
// Always validate the durable row before using a cached webhook adapter.
// This is the correctness fallback when a replica missed Redis invalidation
// while disconnected, and prevents stale credentials/config from being used.
adapter, channel, err := h.imService.EnsureChannelAdapter(channelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Errorf(ctx, "[IM] Channel not found for callback: %s", channelID)
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if errors.Is(err, im.ErrChannelDisabled) {
logger.Errorf(ctx, "[IM] Channel disabled for callback: %s", channelID)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "channel is disabled"})
return
}
logger.Errorf(ctx, "[IM] Channel unavailable for callback %s: %v", channelID, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "channel not available"})
return
}
logger.Infof(ctx, "[IM] Callback received platform=%s path_channel_id=%s", channel.Platform, channelID)
// Handle URL verification
if adapter.HandleURLVerification(c) {
return
}
// Verify callback signature
if err := adapter.VerifyCallback(c); err != nil {
logger.Errorf(ctx, "[IM] Callback verification failed for channel %s: %v", channelID, err)
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
return
}
// Parse the callback message
msg, err := adapter.ParseCallback(c)
if err != nil {
logger.Errorf(ctx, "[IM] Parse callback failed for channel %s: %v", channelID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "parse failed"})
return
}
// If nil, it's a non-message event - just acknowledge
if msg == nil {
if channel.Platform == "mattermost" {
logger.Infof(ctx, "[IM] Mattermost callback ignored (no message): path_channel_id=%s — check: (1) trigger word must be the *first word* of the post; (2) if channel+trigger are both set, post must be in that channel; (3) bot_user_id must not match the sender", channelID)
} else {
logger.Infof(ctx, "[IM] Callback parsed no message to process platform=%s path_channel_id=%s", channel.Platform, channelID)
}
writeIMCallbackACK(c, channel.Platform)
return
}
// Respond immediately to avoid platform timeout
writeIMCallbackACK(c, channel.Platform)
// Detach from gin request context
asyncCtx := context.WithoutCancel(ctx)
// Process message asynchronously
go func() {
if err := h.imService.HandleMessage(asyncCtx, msg, channelID); err != nil {
logger.Errorf(asyncCtx, "[IM] Handle message error for channel %s: %v", channelID, err)
}
}()
}
+33
View File
@@ -0,0 +1,33 @@
package handler
import (
"strings"
"testing"
)
// The channel-creation endpoint rejects any platform without a registered
// adapter factory, so this set must track the factories wired in the container.
func TestValidIMPlatforms_CoversLark(t *testing.T) {
want := []string{
"wecom", "feishu", "lark", "slack", "telegram",
"dingtalk", "mattermost", "wechat", "qqbot",
}
for _, platform := range want {
if !validIMPlatforms[platform] {
t.Errorf("platform %q is not accepted", platform)
}
}
if validIMPlatforms["nonsense"] {
t.Error("unknown platform is accepted")
}
}
// The 400 message is derived from validIMPlatforms; it must not drift as
// platforms are added.
func TestInvalidIMPlatformError_ListsEveryPlatform(t *testing.T) {
for platform := range validIMPlatforms {
if !strings.Contains(invalidIMPlatformError, "'"+platform+"'") {
t.Errorf("error message omits %q: %s", platform, invalidIMPlatformError)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
package handler
import (
"context"
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildConfigResponse_ViewerOmitsModelBaseURL(t *testing.T) {
h := &InitializationHandler{}
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
models := []*types.Model{{
Type: types.ModelTypeKnowledgeQA,
Name: "custom-llm",
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
APIKey: "sk-secret-do-not-leak",
},
}}
kb := &types.KnowledgeBase{}
config := h.buildConfigResponse(ctx, models, kb, false)
llm, ok := config["llm"].(map[string]interface{})
require.True(t, ok)
assert.Empty(t, llm["baseUrl"])
body, err := json.Marshal(config)
require.NoError(t, err)
assert.NotContains(t, string(body), "tenant-private.example.com")
assert.NotContains(t, string(body), "sk-secret-do-not-leak")
}
func TestBuildConfigResponse_AdminKeepsModelBaseURL(t *testing.T) {
h := &InitializationHandler{}
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleAdmin)
models := []*types.Model{{
Type: types.ModelTypeKnowledgeQA,
Name: "custom-llm",
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
APIKey: "sk-secret-do-not-leak",
},
}}
kb := &types.KnowledgeBase{}
config := h.buildConfigResponse(ctx, models, kb, false)
llm, ok := config["llm"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "https://tenant-private.example.com", llm["baseUrl"])
body, err := json.Marshal(config)
require.NoError(t, err)
assert.NotContains(t, string(body), "sk-secret-do-not-leak")
}
@@ -0,0 +1,42 @@
package handler
import (
"context"
"encoding/json"
"testing"
)
func TestValidateNodeExtractConfigGraphBackend(t *testing.T) {
for _, tc := range []struct {
name string
neo4j string
legacy string
disabled bool
incomplete bool
wantErr bool
}{
{name: "no backend", wantErr: true},
{name: "neo4j", neo4j: "true"},
{name: "native backend", neo4j: "false", legacy: "true"},
{name: "case insensitive native backend", legacy: "TRUE"},
{name: "disabled extraction needs no backend", disabled: true},
{name: "native backend still validates examples", legacy: "true", incomplete: true, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("NEO4J_ENABLE", tc.neo4j)
t.Setenv("LEGACY_GRAPH_ENABLE", tc.legacy)
req := &InitializationRequest{}
if err := json.Unmarshal([]byte(`{"nodeExtract":{"text":"Alice knows Bob","tags":["knows"],"nodes":[{"name":"Alice"},{"name":"Bob"}],"relations":[{"node1":"Alice","node2":"Bob","type":"knows"}]}}`), req); err != nil {
t.Fatal(err)
}
req.NodeExtract.Enabled = !tc.disabled
if tc.incomplete {
req.NodeExtract.Relations = nil
}
err := validateNodeExtractConfig(context.Background(), req)
if (err != nil) != tc.wantErr {
t.Fatalf("validateNodeExtractConfig() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package handler
import (
"io"
"net/http"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
)
func (h *SkillHandler) instructionServiceReady(c *gin.Context) bool {
if h.instructions != nil {
return true
}
_ = c.Error(apperrors.NewInternalServerError("文字技能服务尚未初始化"))
return false
}
func (h *SkillHandler) ListInstructionSkills(c *gin.Context) {
if !h.instructionServiceReady(c) {
return
}
rows, err := h.instructions.List(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": rows})
}
func (h *SkillHandler) GetInstructionSkill(c *gin.Context) {
if !h.instructionServiceReady(c) {
return
}
row, err := h.instructions.Get(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": row})
}
func (h *SkillHandler) ImportInstructionSkill(c *gin.Context) {
if !h.instructionServiceReady(c) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxInstructionSkillBytes+64*1024)
file, header, err := c.Request.FormFile("file")
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
if err != nil {
_ = c.Error(apperrors.NewBadRequestError("请通过 file 字段上传不超过 256 KB 的 Markdown 文件"))
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, service.MaxInstructionSkillBytes+1))
if err != nil {
_ = c.Error(apperrors.NewBadRequestError("读取 Markdown 文件失败"))
return
}
row, err := h.instructions.Import(c.Request.Context(), header.Filename, c.PostForm("name"), c.PostForm("description"), content)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "data": row})
}
func (h *SkillHandler) MountInstructionSkills(c *gin.Context) {
if !h.instructionServiceReady(c) {
return
}
limitJSONBody(c, 64*1024)
var req service.InstructionSkillMountRequest
if err := c.ShouldBindJSON(&req); err != nil {
_ = c.Error(apperrors.NewBadRequestError("挂载参数无效"))
return
}
result, err := h.instructions.Mount(c.Request.Context(), req)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
func (h *SkillHandler) DeleteInstructionSkill(c *gin.Context) {
if !h.instructionServiceReady(c) { return }
if err := h.instructions.Delete(c.Request.Context(),c.Param("id"),c.Query("detach")=="true"); err != nil { _ = c.Error(err); return }
c.JSON(http.StatusOK,gin.H{"success":true})
}
@@ -0,0 +1,70 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestInstructionImportHTTPKeepsDescriptionAndReturnsReadableBody(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
require.NoError(t, db.AutoMigrate(&types.TenantSkillCatalogEntity{}))
h := NewSkillHandler(nil, nil)
h.SetInstructionService(service.NewInstructionSkillService(db, nil))
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.UserIDContextKey, "alice")
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleContributor)
c.Request = c.Request.WithContext(ctx)
c.Next()
})
r.POST("/skills/instructions/import", h.ImportInstructionSkill)
r.GET("/skills/instructions/:id", h.GetInstructionSkill)
r.GET("/skills/instructions", h.ListInstructionSkills)
var body bytes.Buffer
form := multipart.NewWriter(&body)
file, err := form.CreateFormFile("file", "写作规则.md")
require.NoError(t, err)
_, err = file.Write([]byte("# 写作规则\n保持先后依赖,不重复正文。"))
require.NoError(t, err)
require.NoError(t, form.WriteField("description", "用于连续课程写作"))
require.NoError(t, form.Close())
req := httptest.NewRequest(http.MethodPost, "/skills/instructions/import", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
var imported struct {
Data service.InstructionSkillView `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &imported))
require.NotEmpty(t, imported.Data.ID)
require.Equal(t, "用于连续课程写作", imported.Data.Description)
require.Contains(t, imported.Data.Instructions, "先后依赖")
for _, endpoint := range []string{"/skills/instructions", "/skills/instructions/" + imported.Data.ID} {
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, endpoint, nil))
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "用于连续课程写作")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
package handler
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestRequireTenantAPIKeyKnowledgeBaseAllowsUnrestrictedCaller(t *testing.T) {
ctx := context.Background()
if err := requireTenantAPIKeyKnowledgeBase(ctx, "kb-1"); err != nil {
t.Fatalf("unrestricted caller error = %v, want nil", err)
}
}
func TestRequireTenantAPIKeyKnowledgeBaseRejectsOutOfScopeKB(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
})
if err := requireTenantAPIKeyKnowledgeBase(ctx, "kb-2"); err == nil {
t.Fatal("expected forbidden for out-of-scope kb_id")
}
}
func TestIntersectKBAllowSetCombinesAgentAndAPIKeyScopes(t *testing.T) {
got := intersectKBAllowSet(
map[string]bool{"kb-1": true, "kb-2": true},
map[string]bool{"kb-2": true, "kb-3": true},
)
if len(got) != 1 || !got["kb-2"] {
t.Fatalf("intersection = %#v, want only kb-2", got)
}
}
func TestFilterKnowledgesByKBAllowSet(t *testing.T) {
in := []*types.Knowledge{
{ID: "k1", KnowledgeBaseID: "kb-1"},
{ID: "k2", KnowledgeBaseID: "kb-2"},
}
got := filterKnowledgesByKBAllowSet(in, map[string]bool{"kb-1": true})
if len(got) != 1 || got[0].ID != "k1" {
t.Fatalf("filtered = %#v, want only k1", got)
}
}
func TestFilterKnowledgeSearchScopesForAPIKey(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
})
scopes := []types.KnowledgeSearchScope{
{TenantID: 1, KBID: "kb-1"},
{TenantID: 1, KBID: "kb-2"},
}
got := filterKnowledgeSearchScopesForAPIKey(ctx, scopes)
if len(got) != 1 || got[0].KBID != "kb-1" {
t.Fatalf("filtered scopes = %#v, want only kb-1", got)
}
}
func TestTenantAPIKeySearchScopesUsesCallerTenant(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1", "kb-2"},
})
ctx = context.WithValue(ctx, types.TenantIDContextKey, uint64(42))
scopes, restricted := tenantAPIKeySearchScopes(ctx)
if !restricted {
t.Fatal("expected restricted search scopes")
}
if len(scopes) != 2 {
t.Fatalf("scopes len = %d, want 2", len(scopes))
}
for _, scope := range scopes {
if scope.TenantID != 42 {
t.Fatalf("scope tenant_id = %d, want 42", scope.TenantID)
}
}
}
func TestRequireTenantAPIKeyKnowledgeBasesRejectsPartialOverlap(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
})
if err := requireTenantAPIKeyKnowledgeBases(ctx, "kb-1", "kb-2"); err == nil {
t.Fatal("expected forbidden when one kb_id is out of scope")
}
}
@@ -0,0 +1,112 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/require"
)
type batchOwnershipKBService struct {
interfaces.KnowledgeBaseService
}
func (*batchOwnershipKBService) GetKnowledgeBaseByID(context.Context, string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb-owner", TenantID: 1, CreatorID: "owner"}, nil
}
type batchOwnershipKnowledgeService struct {
interfaces.KnowledgeService
updated bool
fetched bool
}
func (*batchOwnershipKnowledgeService) GetKnowledgeByIDOnly(context.Context, string) (*types.Knowledge, error) {
return &types.Knowledge{ID: "doc-owner", TenantID: 1, KnowledgeBaseID: "kb-owner"}, nil
}
func (s *batchOwnershipKnowledgeService) UpdateKnowledgeTagBatch(_ context.Context, kbID string, _ map[string][]string) error {
s.updated = kbID == "kb-owner"
return nil
}
func (s *batchOwnershipKnowledgeService) GetKnowledgeBatch(context.Context, uint64, []string) ([]*types.Knowledge, error) {
s.fetched = true
return []*types.Knowledge{{ID: "doc-owner", TenantID: 1, KnowledgeBaseID: "kb-owner"}}, nil
}
type batchOwnershipQueue struct{ enqueued bool }
func (q *batchOwnershipQueue) Enqueue(*asynq.Task, ...asynq.Option) (*asynq.TaskInfo, error) {
q.enqueued = true
return &asynq.TaskInfo{ID: "reparse-test"}, nil
}
func TestKnowledgeBatchMutationsRequireKBOwnerOrAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, principal := range []struct {
name string
user string
role types.TenantRole
want int
}{
{"other contributor", "other", types.TenantRoleContributor, http.StatusForbidden},
{"other viewer", "other", types.TenantRoleViewer, http.StatusForbidden},
{"KB creator", "owner", types.TenantRoleContributor, http.StatusOK},
{"tenant admin", "other", types.TenantRoleAdmin, http.StatusOK},
} {
for _, operation := range []struct {
name string
body string
}{
{"tags with KB", `{"kb_id":"kb-owner","updates":{"doc-owner":["tag-1"]}}`},
{"tags inferred KB", `{"updates":{"doc-owner":["tag-1"]}}`},
{"batch reparse", `{"kb_id":"kb-owner","ids":["doc-owner"]}`},
} {
t.Run(principal.name+"/"+operation.name, func(t *testing.T) {
enabled := true
cfg := &config.Config{Tenant: &config.TenantConfig{EnableRBAC: &enabled}}
kg := &batchOwnershipKnowledgeService{}
queue := &batchOwnershipQueue{}
h := &KnowledgeHandler{cfg: cfg, kbService: &batchOwnershipKBService{}, kgService: kg, asynqClient: queue}
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, uint64(1))
ctx = context.WithValue(ctx, types.UserIDContextKey, principal.user)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, principal.role)
c.Request = c.Request.WithContext(ctx)
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), principal.user)
c.Next()
})
if operation.name == "batch reparse" {
r.POST("/batch", h.BatchReparseKnowledge)
} else {
r.POST("/batch", h.UpdateKnowledgeTagBatch)
}
req := httptest.NewRequest(http.MethodPost, "/batch", strings.NewReader(operation.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, principal.want, w.Code, "body=%s", w.Body.String())
allowed := principal.want == http.StatusOK
if operation.name == "batch reparse" {
require.Equal(t, allowed, kg.fetched, "reject before reading batch")
require.Equal(t, allowed, queue.enqueued, "reject before scheduling reparse")
} else {
require.Equal(t, allowed, kg.updated, "reject before changing tags")
}
})
}
}
}
@@ -0,0 +1,60 @@
package handler
import (
"github.com/Tencent/WeKnora/internal/application/service"
werrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
)
type KnowledgeClassificationHandler struct {
service *service.KnowledgeClassificationService
}
func NewKnowledgeClassificationHandler(s *service.KnowledgeClassificationService) *KnowledgeClassificationHandler {
return &KnowledgeClassificationHandler{service: s}
}
func (h *KnowledgeClassificationHandler) List(c *gin.Context) {
data, e := h.service.List(c.Request.Context(), c.Param("id"))
if e != nil {
c.Error(service.KnowledgeClassificationPublicError(e))
return
}
c.JSON(200, gin.H{"success": true, "data": data})
}
func (h *KnowledgeClassificationHandler) Create(c *gin.Context) {
var input struct {
ModelID string `json:"model_id"`
KnowledgeIDs []string `json:"knowledge_ids"`
}
if e := c.ShouldBindJSON(&input); e != nil {
c.Error(werrors.NewBadRequestError("分类请求格式有误"))
return
}
data, e := h.service.Create(c.Request.Context(), c.Param("id"), input.ModelID, input.KnowledgeIDs)
if e != nil {
c.Error(service.KnowledgeClassificationPublicError(e))
return
}
c.JSON(202, gin.H{"success": true, "data": data})
}
func (h *KnowledgeClassificationHandler) Apply(c *gin.Context) {
var input struct {
ItemIDs []string `json:"item_ids"`
}
if e := c.ShouldBindJSON(&input); e != nil {
c.Error(werrors.NewBadRequestError("请选择需要采用的分类建议"))
return
}
if e := h.service.Apply(c.Request.Context(), c.Param("id"), c.Param("job_id"), input.ItemIDs); e != nil {
c.Error(service.KnowledgeClassificationPublicError(e))
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *KnowledgeClassificationHandler) Retry(c *gin.Context) {
if e := h.service.Retry(c.Request.Context(), c.Param("id"), c.Param("job_id")); e != nil {
c.Error(service.KnowledgeClassificationPublicError(e))
return
}
c.JSON(202, gin.H{"success": true})
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type stubFolderKBService struct {
interfaces.KnowledgeBaseService
}
func (s *stubFolderKBService) GetKnowledgeBaseByID(_ context.Context, id string) (*types.KnowledgeBase, error) {
if id != "kb-1" {
return nil, errors.New("kb not found")
}
return &types.KnowledgeBase{ID: "kb-1", TenantID: 1, Type: types.KnowledgeBaseTypeDocument}, nil
}
type stubFolderKGService struct {
interfaces.KnowledgeService
gotFilter types.KnowledgeListFilter
tree *types.KnowledgeFolderTree
treeErr error
}
func (s *stubFolderKGService) ListPagedKnowledgeByKnowledgeBaseID(
_ context.Context,
_ string,
page *types.Pagination,
filter types.KnowledgeListFilter,
) (*types.PageResult, error) {
s.gotFilter = filter
return types.NewPageResult(0, page, []*types.Knowledge{}), nil
}
func (s *stubFolderKGService) ListKnowledgeFolderTree(
_ context.Context,
_ string,
) (*types.KnowledgeFolderTree, error) {
return s.tree, s.treeErr
}
func newFolderRouter(kg interfaces.KnowledgeService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeHandler{kbService: &stubFolderKBService{}, kgService: kg}
r.GET("/knowledge-bases/:id/knowledge", h.ListKnowledge)
r.GET("/knowledge-bases/:id/knowledge/folders", h.ListKnowledgeFolders)
return r
}
// The folder dimension is keyed off parameter presence, because an empty
// folder_path is a meaningful value (the knowledge base root) and must be
// distinguishable from "browse every folder".
func TestListKnowledge_FolderScopeFromQuery(t *testing.T) {
tests := []struct {
name string
query string
wantPath string
wantScope types.KnowledgeFolderScope
}{
{"absent parameter keeps the flat listing", "", "", types.FolderScopeAny},
{"empty parameter selects the root", "?folder_path=", "", types.FolderScopeExact},
{"folder selects that folder only", "?folder_path=docs/spec", "docs/spec", types.FolderScopeExact},
{"recursive selects the subtree", "?folder_path=docs&folder_recursive=true", "docs", types.FolderScopeSubtree},
{"recursive false stays exact", "?folder_path=docs&folder_recursive=false", "docs", types.FolderScopeExact},
{"path is normalized", "?folder_path=/docs//spec/", "docs/spec", types.FolderScopeExact},
{"traversal cannot escape the root", "?folder_path=../../spec", "spec", types.FolderScopeExact},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kg := &stubFolderKGService{}
router := newFolderRouter(kg)
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/knowledge"+tt.query, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if kg.gotFilter.FolderPath != tt.wantPath {
t.Fatalf("folder path = %q, want %q", kg.gotFilter.FolderPath, tt.wantPath)
}
if kg.gotFilter.FolderScope != tt.wantScope {
t.Fatalf("folder scope = %q, want %q", kg.gotFilter.FolderScope, tt.wantScope)
}
})
}
}
func TestListKnowledgeFolders_ReturnsTree(t *testing.T) {
kg := &stubFolderKGService{
tree: types.BuildKnowledgeFolderTree([]*types.KnowledgeFolderCount{
{FolderPath: "", Count: 1},
{FolderPath: "docs/spec", Count: 2},
}),
}
router := newFolderRouter(kg)
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/knowledge/folders", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var body struct {
Success bool `json:"success"`
Data types.KnowledgeFolderTree `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v; body=%s", err, w.Body.String())
}
if !body.Success {
t.Fatalf("success = false; body=%s", w.Body.String())
}
if body.Data.RootDocumentCount != 1 || body.Data.TotalDocumentCount != 3 {
t.Fatalf("counts = root %d total %d, want 1 / 3",
body.Data.RootDocumentCount, body.Data.TotalDocumentCount)
}
if len(body.Data.Folders) != 1 || body.Data.Folders[0].Path != "docs" {
t.Fatalf("folders = %+v, want a single \"docs\" root folder", body.Data.Folders)
}
if len(body.Data.Folders[0].Children) != 1 || body.Data.Folders[0].Children[0].Path != "docs/spec" {
t.Fatalf("children = %+v, want \"docs/spec\"", body.Data.Folders[0].Children)
}
}
@@ -0,0 +1,136 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// MoveKnowledge cross-store reuse_vectors gate — handler-level pre-flight tests.
//
// reuse_vectors copies vector indices between KBs through the source store only;
// a cross-store reuse_vectors move would corrupt vector data. The handler must
// reject it synchronously (before enqueueing the async move) and point the
// caller at reparse mode. reparse moves and same-store reuse_vectors must pass
// the gate (they fail later here only because the stub knowledge does not exist,
// which proves the gate did not short-circuit them).
type stubMoveKBService struct {
interfaces.KnowledgeBaseService
byID func(ctx context.Context, id string) (*types.KnowledgeBase, error)
}
func (s *stubMoveKBService) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
return s.byID(ctx, id)
}
// stubMoveKGService.GetKnowledgeByID always reports not-found, so any request
// that PASSES the store gate fails at the later knowledge-ID validation with a
// distinct message — letting the test tell "rejected by gate" apart from
// "passed the gate."
type stubMoveKGService struct {
interfaces.KnowledgeService
}
func (s *stubMoveKGService) GetKnowledgeByID(_ context.Context, _ string) (*types.Knowledge, error) {
return nil, errors.New("knowledge not found")
}
func newMoveGateRouter(kb interfaces.KnowledgeBaseService, kg interfaces.KnowledgeService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
// asynqClient stays nil: every case here either rejects at the gate or
// fails at knowledge-ID validation, so enqueue is never reached. A nil
// deref would mean the gate let a request through to enqueue unexpectedly.
h := &KnowledgeHandler{kbService: kb, kgService: kg}
r.POST("/move", h.MoveKnowledge)
return r
}
func TestMoveKnowledge_CrossStoreGate(t *testing.T) {
storeA, storeB := "store-a", "store-b"
kbWith := func(id string, store *string) *types.KnowledgeBase {
return &types.KnowledgeBase{ID: id, TenantID: 1, Type: "document",
EmbeddingModelID: "m1", VectorStoreID: store}
}
tests := []struct {
name string
mode string
srcStore *string
dstStore *string
wantStatus int
wantBodyHas string // substring that must appear
wantBodyNotHas string // substring that must NOT appear (gate message)
}{
{
name: "reuse_vectors cross-store rejected by gate",
mode: "reuse_vectors", srcStore: &storeA, dstStore: &storeB,
wantStatus: http.StatusBadRequest, wantBodyHas: "different vector stores",
},
{
name: "reparse cross-store passes gate (fails later at knowledge lookup)",
mode: "reparse", srcStore: &storeA, dstStore: &storeB,
wantStatus: http.StatusBadRequest, wantBodyNotHas: "different vector stores",
},
{
name: "reuse_vectors same-store passes gate",
mode: "reuse_vectors", srcStore: &storeA, dstStore: &storeA,
wantStatus: http.StatusBadRequest, wantBodyNotHas: "different vector stores",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kb := &stubMoveKBService{byID: func(_ context.Context, id string) (*types.KnowledgeBase, error) {
switch id {
case "kb-src":
return kbWith("kb-src", tt.srcStore), nil
case "kb-dst":
return kbWith("kb-dst", tt.dstStore), nil
}
return nil, errors.New("kb not found")
}}
router := newMoveGateRouter(kb, &stubMoveKGService{})
body, _ := json.Marshal(map[string]any{
"knowledge_ids": []string{"k1"},
"source_kb_id": "kb-src",
"target_kb_id": "kb-dst",
"mode": tt.mode,
})
req := httptest.NewRequest(http.MethodPost, "/move", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantBodyHas != "" && !strings.Contains(w.Body.String(), tt.wantBodyHas) {
t.Fatalf("body %q does not contain %q", w.Body.String(), tt.wantBodyHas)
}
if tt.wantBodyNotHas != "" && strings.Contains(w.Body.String(), tt.wantBodyNotHas) {
t.Fatalf("body %q unexpectedly contains gate message %q (gate short-circuited a valid move)",
w.Body.String(), tt.wantBodyNotHas)
}
})
}
}
@@ -0,0 +1,67 @@
package handler
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
type previewKnowledgeServiceStub struct {
interfaces.KnowledgeService
filename string
}
type closeNotifyRecorder struct {
*httptest.ResponseRecorder
}
func (r *closeNotifyRecorder) CloseNotify() <-chan bool {
ch := make(chan bool, 1)
return ch
}
func (s *previewKnowledgeServiceStub) GetKnowledgeByIDOnly(context.Context, string) (*types.Knowledge, error) {
return &types.Knowledge{ID: "k1", TenantID: 42, KnowledgeBaseID: "kb1"}, nil
}
func (s *previewKnowledgeServiceStub) GetKnowledgeFile(context.Context, string) (io.ReadCloser, string, error) {
return io.NopCloser(strings.NewReader(`<html><script>alert(1)</script></html>`)), s.filename, nil
}
func TestPreviewKnowledgeFileForcesActiveContentDownload(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(middleware.ErrorHandler())
router.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(42))
c.Next()
})
h := &KnowledgeHandler{kgService: &previewKnowledgeServiceStub{filename: "payload.html"}}
router.GET("/knowledge/:id/preview", h.PreviewKnowledgeFile)
req := httptest.NewRequest(http.MethodGet, "/knowledge/k1/preview", nil)
w := &closeNotifyRecorder{ResponseRecorder: httptest.NewRecorder()}
router.ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d; body=%s", got, want, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("Content-Type = %q, want application/octet-stream", got)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := w.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
t.Fatalf("Content-Disposition = %q, want attachment", got)
}
}
+173
View File
@@ -0,0 +1,173 @@
package handler
import (
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
// TestBuildSpanTree_AssemblesParentChild covers the basic shape: a root
// with stage children and an image generation grandchild. The handler's
// JSON contract depends on this tree topology; if the linking logic
// regresses, the UI sees a flat list and renders nothing.
func TestBuildSpanTree_AssemblesParentChild(t *testing.T) {
now := time.Now()
rows := []types.KnowledgeProcessingSpan{
{KnowledgeID: "kid", Attempt: 1, SpanID: "root", Name: "knowledge_processing", Kind: types.SpanKindRoot, Status: types.SpanStatusRunning, StartedAt: &now},
{KnowledgeID: "kid", Attempt: 1, SpanID: "doc", ParentSpanID: "root", Name: types.StageDocReader, Kind: types.SpanKindStage, Status: types.SpanStatusDone, StartedAt: &now},
{KnowledgeID: "kid", Attempt: 1, SpanID: "mm", ParentSpanID: "root", Name: types.StageMultimodal, Kind: types.SpanKindStage, Status: types.SpanStatusRunning, StartedAt: &now},
{KnowledgeID: "kid", Attempt: 1, SpanID: "img0", ParentSpanID: "mm", Name: "multimodal.image[0]", Kind: types.SpanKindGeneration, Status: types.SpanStatusRunning, StartedAt: &now},
}
tree, currentStage, lastFail := buildSpanTree("kid", 1, rows, types.ParseStatusProcessing)
require := assert.New(t)
require.NotNil(tree)
require.Equal("root", tree.SpanID)
require.Equal(types.StageMultimodal, currentStage,
"running stage span surfaces as current_stage")
require.Nil(lastFail)
// The 5 canonical stages must all appear under root (real or
// synthesized placeholder).
stageNames := map[string]string{}
for _, child := range tree.Children {
if child.Kind == types.SpanKindStage {
stageNames[child.Name] = child.Status
}
}
for _, name := range types.AllStages {
_, ok := stageNames[name]
require.True(ok, "stage %s must appear in tree", name)
}
require.Equal(types.SpanStatusDone, stageNames[types.StageDocReader])
require.Equal(types.SpanStatusRunning, stageNames[types.StageMultimodal])
require.Equal(types.SpanStatusPending, stageNames[types.StageEmbedding],
"missing stage rows must synthesize as pending placeholders")
// The image subspan must hang under multimodal, not at root level.
var mmNode *types.SpanTreeNode
for _, c := range tree.Children {
if c.Name == types.StageMultimodal {
mmNode = c
break
}
}
require.NotNil(mmNode)
require.Len(mmNode.Children, 1, "image generation subspan must be a child of multimodal stage")
require.Equal("multimodal.image[0]", mmNode.Children[0].Name)
}
// TestBuildSpanTree_NoRows_SynthesizesPlaceholderRoot ensures the API
// keeps a stable shape for fresh / never-parsed knowledge: the frontend
// always gets a `trace` with five pending stage children, never a
// nil/empty response.
func TestBuildSpanTree_NoRows_SynthesizesPlaceholderRoot(t *testing.T) {
tree, currentStage, lastFail := buildSpanTree("kid-empty", 0, nil, "")
a := assert.New(t)
a.NotNil(tree)
a.Equal(types.SpanKindRoot, tree.Kind)
a.Equal(types.SpanStatusPending, tree.Status)
a.Equal("", currentStage, "no rows means no running stage")
a.Nil(lastFail)
// All 5 stages must be present as pending placeholders so the UI
// renders a complete timeline even pre-parse.
a.Len(tree.Children, len(types.AllStages))
for _, child := range tree.Children {
a.Equal(types.SpanStatusPending, child.Status)
a.Equal(types.SpanKindStage, child.Kind)
}
}
// TestBuildSpanTree_LegacyCompletedRendersAsDone records the regression
// for historical knowledge parsed before span tracking was wired: rows
// is empty but parse_status is "completed", so the synthesized timeline
// must reflect the actual terminal state instead of looking forever
// "pending". Same contract for failed parses — synthesizes "failed".
func TestBuildSpanTree_LegacyCompletedRendersAsDone(t *testing.T) {
a := assert.New(t)
completedTree, _, _ := buildSpanTree("kid-legacy", 0, nil, types.ParseStatusCompleted)
a.Equal(types.SpanStatusDone, completedTree.Status,
"legacy completed knowledge with no rows must render the synthesized root as done")
a.Len(completedTree.Children, len(types.AllStages))
for _, child := range completedTree.Children {
a.Equal(types.SpanStatusDone, child.Status,
"legacy completed knowledge: every synthesized stage placeholder must be done, not pending")
}
failedTree, _, _ := buildSpanTree("kid-legacy-fail", 0, nil, types.ParseStatusFailed)
a.Equal(types.SpanStatusFailed, failedTree.Status)
for _, child := range failedTree.Children {
a.Equal(types.SpanStatusFailed, child.Status)
}
}
// TestBuildSpanTree_LastFailureSurfaces records that a failed stage is
// reported as last_error so the UI can highlight the responsible step
// even if a later stage was cancelled by cascade.
func TestBuildSpanTree_LastFailureSurfaces(t *testing.T) {
now := time.Now()
finished := now.Add(5 * time.Second)
rows := []types.KnowledgeProcessingSpan{
{KnowledgeID: "kid", Attempt: 1, SpanID: "root", Name: "knowledge_processing", Kind: types.SpanKindRoot, Status: types.SpanStatusFailed, StartedAt: &now, FinishedAt: &finished},
{KnowledgeID: "kid", Attempt: 1, SpanID: "doc", ParentSpanID: "root", Name: types.StageDocReader, Kind: types.SpanKindStage, Status: types.SpanStatusFailed, ErrorCode: "DOCREADER_TIMEOUT", ErrorMessage: "slow", StartedAt: &now, FinishedAt: &finished},
{KnowledgeID: "kid", Attempt: 1, SpanID: "chunk", ParentSpanID: "root", Name: types.StageChunking, Kind: types.SpanKindStage, Status: types.SpanStatusCancelled, ErrorCode: "UPSTREAM_FAILED"},
}
_, _, lastFail := buildSpanTree("kid", 1, rows, types.ParseStatusFailed)
a := assert.New(t)
a.NotNil(lastFail)
a.Equal(types.StageDocReader, lastFail.Name,
"last_error must point at the actually-failed span, not the cascade-cancelled downstream")
a.Equal("DOCREADER_TIMEOUT", lastFail.ErrorCode)
}
func TestKnowledgeSpansLastError_PrefersSpanFailure(t *testing.T) {
now := time.Now()
spanFail := &types.KnowledgeProcessingSpan{
Name: types.StageDocReader,
ErrorCode: "DOCREADER_TIMEOUT",
ErrorMessage: "slow",
FinishedAt: &now,
}
got := knowledgeSpansLastError(2, 2, types.ParseStatusFailed, "recovery blew up", now, spanFail)
a := assert.New(t)
a.Equal(types.StageDocReader, got["name"])
a.Equal("DOCREADER_TIMEOUT", got["error_code"])
a.Equal("slow", got["error_message"])
}
func TestKnowledgeSpansLastError_RecoveryFallbackUsesKnowledgeMessage(t *testing.T) {
now := time.Now()
got := knowledgeSpansLastError(2, 2, types.ParseStatusFailed, "wiki ingest timed out", now, nil)
a := assert.New(t)
a.NotNil(got)
a.Equal("knowledge_processing", got["name"])
a.Equal("UNKNOWN", got["error_code"])
a.Equal("wiki ingest timed out", got["error_message"])
a.Equal(now, got["finished_at"])
}
func TestKnowledgeSpansLastError_ClassifiesServerRestart(t *testing.T) {
now := time.Now()
got := knowledgeSpansLastError(2, 2, types.ParseStatusFailed,
"Task interrupted due to application restart", now, nil)
a := assert.New(t)
a.NotNil(got)
a.Equal("SERVER_RESTART", got["error_code"])
a.Equal("SERVER_RESTART", got["code"])
}
func TestKnowledgeSpansLastError_SkipsRecoveryFallbackForHistoricalAttempt(t *testing.T) {
now := time.Now()
got := knowledgeSpansLastError(1, 2, types.ParseStatusFailed, "wiki ingest timed out", now, nil)
assert.Nil(t, got)
}
func TestKnowledgeSpansLastError_SkipsRecoveryFallbackWithoutMessage(t *testing.T) {
now := time.Now()
got := knowledgeSpansLastError(2, 2, types.ParseStatusFailed, "", now, nil)
assert.Nil(t, got)
}
@@ -0,0 +1,14 @@
package handler
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseCommaSeparatedTagIDs(t *testing.T) {
assert.Nil(t, parseCommaSeparatedTagIDs(""))
assert.Equal(t, []string{"a", "b"}, parseCommaSeparatedTagIDs("a,b"))
assert.Equal(t, []string{"a", "b"}, parseCommaSeparatedTagIDs(" a , b "))
assert.Equal(t, []string{"a"}, parseCommaSeparatedTagIDs("a,__untagged__,,"))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/repository"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// stubKBCopyService provides only the methods the duplicate handler reaches.
// Other interface methods stay embedded so accidental new calls panic in tests.
type stubKBCopyService struct {
interfaces.KnowledgeBaseService
byID func(ctx context.Context, id string) (*types.KnowledgeBase, error)
duplicate func(ctx context.Context, sourceID string) (*types.KnowledgeBase, error)
}
func (s *stubKBCopyService) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
return s.byID(ctx, id)
}
func (s *stubKBCopyService) DuplicateKnowledgeBase(
ctx context.Context,
sourceID string,
) (*types.KnowledgeBase, error) {
return s.duplicate(ctx, sourceID)
}
func newDuplicateRouter(svc interfaces.KnowledgeBaseService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeBaseHandler{service: svc}
r.POST("/knowledge-bases/:id/duplicate", h.DuplicateKnowledgeBase)
return r
}
func TestDuplicateHandler_ReturnsCreatedKnowledgeBase(t *testing.T) {
var gotSourceID string
svc := &stubKBCopyService{
byID: func(_ context.Context, id string) (*types.KnowledgeBase, error) {
if id != "src" {
t.Fatalf("handler should only load the source KB, got id=%s", id)
}
return &types.KnowledgeBase{ID: "src", TenantID: 1, Name: "Source"}, nil
},
duplicate: func(_ context.Context, sourceID string) (*types.KnowledgeBase, error) {
gotSourceID = sourceID
return &types.KnowledgeBase{
ID: "copy-id",
TenantID: 1,
Name: "Source Copy",
CreatorID: "u-test",
}, nil
},
}
r := newDuplicateRouter(svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases/src/duplicate", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for duplicate, got %d body=%s", w.Code, w.Body.String())
}
if gotSourceID != "src" {
t.Fatalf("duplicate service called with source=%q", gotSourceID)
}
body := w.Body.String()
for _, want := range []string{`"source_id":"src"`, `"target_id":"copy-id"`, `"knowledge_base"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
}
func TestDuplicateHandler_RejectsCrossTenantSource(t *testing.T) {
calledDuplicate := false
svc := &stubKBCopyService{
byID: func(_ context.Context, id string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: id, TenantID: 2, Name: "Shared"}, nil
},
duplicate: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
calledDuplicate = true
return nil, nil
},
}
r := newDuplicateRouter(svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases/src/duplicate", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for cross-tenant source, got %d body=%s", w.Code, w.Body.String())
}
if calledDuplicate {
t.Fatal("duplicate service must not be called when source KB is outside the caller tenant")
}
}
func TestDuplicateHandler_SourceNotFound(t *testing.T) {
calledDuplicate := false
svc := &stubKBCopyService{
byID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, repository.ErrKnowledgeBaseNotFound
},
duplicate: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
calledDuplicate = true
return nil, nil
},
}
r := newDuplicateRouter(svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases/missing/duplicate", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for missing source, got %d body=%s", w.Code, w.Body.String())
}
if calledDuplicate {
t.Fatal("duplicate service must not be called when source KB is missing")
}
}
func TestDuplicateHandler_PropagatesServiceAppError(t *testing.T) {
svc := &stubKBCopyService{
byID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "src", TenantID: 1, Name: "Source"}, nil
},
duplicate: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, apperrors.NewBadRequestError("invalid vector store binding")
},
}
r := newDuplicateRouter(svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases/src/duplicate", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for service app error, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "invalid vector store binding") {
t.Fatalf("expected service error message in body: %s", w.Body.String())
}
}
func TestDuplicateHandler_ServiceUnexpectedError(t *testing.T) {
svc := &stubKBCopyService{
byID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "src", TenantID: 1, Name: "Source"}, nil
},
duplicate: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, errors.New("database unavailable")
},
}
r := newDuplicateRouter(svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases/src/duplicate", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for unexpected service error, got %d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
func newHybridSearchResourceURLRouter(svc interfaces.KnowledgeBaseService, fileSvc interfaces.FileService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(middleware.ErrorHandler())
router.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeBaseHandler{service: svc, fileService: fileSvc}
router.POST("/knowledge-bases/:id/hybrid-search", h.HybridSearch)
router.GET("/knowledge-bases/:id/hybrid-search", h.HybridSearch)
return router
}
func performHybridSearchResourceURLRequest(
svc interfaces.KnowledgeBaseService,
fileSvc interfaces.FileService,
method, query, body string,
) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
request := httptest.NewRequest(
method,
"/knowledge-bases/kb-1/hybrid-search"+query,
bytes.NewBufferString(body),
)
request.Header.Set("Content-Type", "application/json")
newHybridSearchResourceURLRouter(svc, fileSvc).ServeHTTP(response, request)
return response
}
func TestHybridSearch_PublicResourceURLs(t *testing.T) {
svc := &hybridSearchTestService{
results: []*types.SearchResult{{
Content: "chunk ![c](" + testResourceHandle + ")",
ImageInfo: `[{"url":"` + testResourceHandle + `"}]`,
}},
}
fileSvc := &stubResourceFileService{url: "https://cdn.example.com/signed.png"}
for _, method := range []string{http.MethodPost, http.MethodGet} {
t.Run(method, func(t *testing.T) {
response := performHybridSearchResourceURLRequest(
svc, fileSvc, method, "?resource_urls=public",
`{"query_text":"diagram"}`,
)
require.Equal(t, http.StatusOK, response.Code, "body=%s", response.Body.String())
assert.NotContains(t, response.Body.String(), testResourceHandle)
assert.Contains(t, response.Body.String(), "cdn.example.com")
})
}
}
func TestHybridSearch_InvalidResourceURLMode(t *testing.T) {
svc := &hybridSearchTestService{}
response := performHybridSearchResourceURLRequest(
svc, &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
http.MethodPost, "?resource_urls=signed",
`{"query_text":"diagram"}`,
)
require.Equal(t, http.StatusBadRequest, response.Code, "body=%s", response.Body.String())
assert.Contains(t, response.Body.String(), "resource_urls")
assert.Equal(t, 0, svc.searchCalls, "invalid mode must not reach HybridSearch")
}
func TestHybridSearch_DefaultKeepsHandles(t *testing.T) {
svc := &hybridSearchTestService{
results: []*types.SearchResult{{
Content: "chunk ![c](" + testResourceHandle + ")",
}},
}
response := performHybridSearchResourceURLRequest(
svc, &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
http.MethodPost, "",
`{"query_text":"diagram"}`,
)
require.Equal(t, http.StatusOK, response.Code, "body=%s", response.Body.String())
var resp struct {
Data []struct {
Content string `json:"content"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &resp))
require.Len(t, resp.Data, 1)
assert.Contains(t, resp.Data[0].Content, testResourceHandle)
}
@@ -0,0 +1,138 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type hybridSearchTestService struct {
interfaces.KnowledgeBaseService
searchCalls int
searchParams types.SearchParams
results []*types.SearchResult
}
func (s *hybridSearchTestService) GetKnowledgeBaseByID(_ context.Context, id string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: id, TenantID: 1}, nil
}
func (s *hybridSearchTestService) HybridSearch(
_ context.Context,
_ string,
params types.SearchParams,
) ([]*types.SearchResult, error) {
s.searchCalls++
s.searchParams = params
if s.results != nil {
return s.results, nil
}
return []*types.SearchResult{}, nil
}
func newHybridSearchTestRouter(svc interfaces.KnowledgeBaseService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(middleware.ErrorHandler())
router.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
handler := &KnowledgeBaseHandler{service: svc}
router.POST("/knowledge-bases/:id/hybrid-search", handler.HybridSearch)
return router
}
func TestHybridSearchRejectsMissingQueryText(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "missing field", body: `{}`},
{name: "empty field", body: `{"query_text":""}`},
{name: "whitespace field", body: `{"query_text":" "}`},
{name: "wrong field name", body: `{"query":"MiniMax"}`},
{name: "embedding with keyword matching", body: `{"query_embedding":[0.1]}`},
{
name: "embedding with all matching disabled",
body: `{"query_embedding":[0.1],"disable_keywords_match":true,"disable_vector_match":true}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &hybridSearchTestService{}
response := performHybridSearchRequest(svc, tt.body)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", response.Code, response.Body.String())
}
if svc.searchCalls != 0 {
t.Fatalf("invalid request reached HybridSearch %d time(s)", svc.searchCalls)
}
if !strings.Contains(response.Body.String(), `"code":1000`) {
t.Fatalf("expected bad-request envelope, got %s", response.Body.String())
}
})
}
}
func TestHybridSearchAcceptsQueryText(t *testing.T) {
svc := &hybridSearchTestService{}
response := performHybridSearchRequest(svc, `{"query_text":"MiniMax","match_count":3}`)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String())
}
if svc.searchCalls != 1 {
t.Fatalf("expected one HybridSearch call, got %d", svc.searchCalls)
}
if svc.searchParams.QueryText != "MiniMax" {
t.Fatalf("query text = %q, want MiniMax", svc.searchParams.QueryText)
}
if svc.searchParams.MatchCount != 3 {
t.Fatalf("match count = %d, want 3", svc.searchParams.MatchCount)
}
}
func TestHybridSearchAcceptsPrecomputedVectorWithoutQueryText(t *testing.T) {
svc := &hybridSearchTestService{}
response := performHybridSearchRequest(
svc,
`{"query_embedding":[0.1,0.2],"disable_keywords_match":true}`,
)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String())
}
if svc.searchCalls != 1 {
t.Fatalf("expected one HybridSearch call, got %d", svc.searchCalls)
}
if len(svc.searchParams.QueryEmbedding) != 2 {
t.Fatalf("query embedding length = %d, want 2", len(svc.searchParams.QueryEmbedding))
}
if !svc.searchParams.DisableKeywordsMatch || svc.searchParams.DisableVectorMatch {
t.Fatalf("expected vector-only params, got %+v", svc.searchParams)
}
}
func performHybridSearchRequest(svc interfaces.KnowledgeBaseService, body string) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/knowledge-bases/kb-1/hybrid-search",
strings.NewReader(body),
)
request.Header.Set("Content-Type", "application/json")
newHybridSearchTestRouter(svc).ServeHTTP(response, request)
return response
}
@@ -0,0 +1,135 @@
package handler
import (
"context"
stderrors "errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/repository"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// validateAndGetKnowledgeBase (knowledgebase.go) and the four sibling
// helpers in faq.go / knowledge.go / tag.go / initialization.go used to
// wrap every Get*ByID error — including the well-known
// repository.ErrKnowledgeBaseNotFound sentinel — as a 500. That turned
// every probe of a stale or cross-tenant KB id into a fake "internal
// server error" envelope. These tests pin the corrected mapping (404)
// at the HTTP boundary so a future refactor can't quietly regress it.
//
// The wrapped-sentinel cases are the actual security boundary: if a
// caller drops the stderrors.Is comparison and reverts to `==`, the
// fmt.Errorf("%w") path silently fails over to 500 and the tests below
// fail before the change ships.
// stubKBOnlyService implements just enough of KnowledgeBaseService to
// drive validateAndGetKnowledgeBase. Embedding the interface keeps every
// other method nil-panicky on purpose so a future test that reaches
// outside the contract fails loudly.
type stubKBOnlyService struct {
interfaces.KnowledgeBaseService
getByID func(ctx context.Context, id string) (*types.KnowledgeBase, error)
fillKnowledgeBaseCounts func(ctx context.Context, kb *types.KnowledgeBase) error
}
func (s *stubKBOnlyService) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
return s.getByID(ctx, id)
}
func (s *stubKBOnlyService) FillKnowledgeBaseCounts(ctx context.Context, kb *types.KnowledgeBase) error {
if s.fillKnowledgeBaseCounts != nil {
return s.fillKnowledgeBaseCounts(ctx, kb)
}
return nil
}
// newKBHandlerTestRouter mounts the production ErrorHandler so
// c.Error(NewNotFoundError(...)) renders as the real 404 envelope.
// Tenant id and user id are injected by a tiny middleware so
// validateAndGetKnowledgeBase doesn't bail at the unauthorized branch.
func newKBHandlerTestRouter(svc interfaces.KnowledgeBaseService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeBaseHandler{service: svc}
r.GET("/knowledge-bases/:id", h.GetKnowledgeBase)
return r
}
func TestKBHandlerMapsErrKnowledgeBaseNotFoundToNotFound(t *testing.T) {
svc := &stubKBOnlyService{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, repository.ErrKnowledgeBaseNotFound
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/missing-kb", nil)
newKBHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("ErrKnowledgeBaseNotFound must map to 404, got %d body=%s", w.Code, w.Body.String())
}
}
func TestKBHandlerHonoursWrappedErrKnowledgeBaseNotFound(t *testing.T) {
// Regression test against a stale `==` sentinel comparison: errors.Is
// unwraps fmt.Errorf("%w", ...); a literal `==` does not. If anyone
// reverts the comparison this test fails before the change ships.
svc := &stubKBOnlyService{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, fmt.Errorf("loading kb: %w", repository.ErrKnowledgeBaseNotFound)
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/missing-kb", nil)
newKBHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("wrapped ErrKnowledgeBaseNotFound must still map to 404, got %d body=%s",
w.Code, w.Body.String())
}
// Defence-in-depth: the response body should expose the AppError
// envelope (ErrorHandler renders {success,error:{code,message}}),
// not the bare gin error string. Code 1003 is ErrNotFound.
if !strings.Contains(w.Body.String(), `"code":1003`) {
t.Fatalf("expected NotFound envelope with code=1003, got body=%s", w.Body.String())
}
}
func TestKBHandlerKeeps500ForGenuineInfraErrors(t *testing.T) {
// The mapping is *only* for the not-found sentinel — every other
// error must still surface as a real 5xx so monitoring catches
// genuine DB / repo failures.
svc := &stubKBOnlyService{
getByID: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, stderrors.New("connection refused")
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/some-kb", nil)
newKBHandlerTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("non-sentinel errors must remain 500, got %d body=%s", w.Code, w.Body.String())
}
}
// guardAgainstStaleSentinelEquality is a compile-time assertion: if a
// future refactor accidentally drops the apperrors / stderrors imports
// from THIS file, the test stops compiling and the human gets a clear
// signal that something tied to the not-found mapping went away.
var (
_ = stderrors.Is
_ = apperrors.NewNotFoundError
)
@@ -0,0 +1,188 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// CreateKnowledgeBase typed-error preservation — the handler must surface
// the typed AppError (ErrVectorStoreBindingInvalid / ErrVectorStoreUnavailable)
// returned by validateVectorStoreBinding instead of stripping it into a
// generic 500 via NewInternalServerError. Without the IsAppError unwrap in
// the handler, the typed error codes would be silently nullified at the
// HTTP boundary and clients would lose the ability to branch on the cause.
//
// Shared-KB UUID suppression — responses for cross-tenant shared KBs must
// not leak the owner tenant's vector_store_id UUID. SharedStoreDisplay
// suppresses store name + engine_type for cross-tenant callers, but the
// underlying KnowledgeBase.MarshalJSON still emits the UUID; the
// buildKBResponse strip closes the gap so the UUID cannot be correlated
// across multiple shared KBs.
// stubKBCreateService drives CreateKnowledgeBase end-to-end with a
// service that returns a chosen error. Embedding the interface keeps
// any other method nil-panic'ing on purpose.
type stubKBCreateService struct {
interfaces.KnowledgeBaseService
createErr error
}
func (s *stubKBCreateService) CreateKnowledgeBase(_ context.Context, kb *types.KnowledgeBase) (*types.KnowledgeBase, error) {
if s.createErr != nil {
return nil, s.createErr
}
kb.ID = "kb-new"
kb.TenantID = 1
return kb, nil
}
func newCreateKBRouter(svc interfaces.KnowledgeBaseService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeBaseHandler{service: svc}
r.POST("/knowledge-bases", h.CreateKnowledgeBase)
return r
}
func TestCreateKB_PreservesTypedErrorCode_2200(t *testing.T) {
svc := &stubKBCreateService{
createErr: apperrors.NewVectorStoreBindingInvalidError("vector store not found"),
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases",
strings.NewReader(`{"name":"kb"}`))
req.Header.Set("Content-Type", "application/json")
newCreateKBRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"code":2200`) {
t.Fatalf("expected envelope to contain code 2200, got %s", body)
}
if strings.Contains(body, `"code":1007`) || strings.Contains(body, `"code":1000`) {
t.Fatalf("typed error must not be wrapped into a generic code, got %s", body)
}
}
func TestCreateKB_PreservesTypedErrorCode_2201(t *testing.T) {
svc := &stubKBCreateService{
createErr: apperrors.NewVectorStoreUnavailableError(""),
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases",
strings.NewReader(`{"name":"kb"}`))
req.Header.Set("Content-Type", "application/json")
newCreateKBRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"code":2201`) {
t.Fatalf("expected envelope to contain code 2201, got %s", w.Body.String())
}
}
func TestCreateKB_GenericErrorStillFallsThroughTo500(t *testing.T) {
// A non-AppError must NOT be auto-rewritten to 200/400 — operational
// monitoring still needs to see infrastructure failures as 5xx.
svc := &stubKBCreateService{createErr: errSentinel("connection refused")}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/knowledge-bases",
strings.NewReader(`{"name":"kb"}`))
req.Header.Set("Content-Type", "application/json")
newCreateKBRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for raw infra error, got %d body=%s", w.Code, w.Body.String())
}
}
type errSentinel string
func (e errSentinel) Error() string { return string(e) }
// ---------------------------------------------------------------------------
// buildKBResponse must strip vector_store_id for shared KB responses
// ---------------------------------------------------------------------------
func TestBuildKBResponse_StripsVectorStoreIDForSharedKB(t *testing.T) {
storeID := "aaaa-bbbb-cccc-dddd"
kb := &types.KnowledgeBase{
ID: "kb-1",
Name: "shared-kb",
TenantID: 42, // different from caller
EmbeddingModelID: "e",
SummaryModelID: "s",
VectorStoreID: &storeID,
}
got := buildKBResponse(kb, types.SharedStoreDisplay(), nil)
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", got)
}
if _, exists := m["vector_store_id"]; exists {
t.Fatalf("shared KB response must not expose vector_store_id, got %v", m["vector_store_id"])
}
if _, exists := m["vector_store_name"]; exists {
t.Fatalf("shared KB response must not expose vector_store_name, got %v", m["vector_store_name"])
}
if m["vector_store_source"] != types.StoreSourceShared {
t.Fatalf("expected vector_store_source=shared, got %v", m["vector_store_source"])
}
// Defensive: ensure the source UUID does not appear *anywhere* in
// the serialized output (paranoid check against future map keys).
serialized, _ := json.Marshal(m)
if strings.Contains(string(serialized), storeID) {
t.Fatalf("shared KB response leaked vector store UUID via some path: %s", serialized)
}
}
func TestBuildKBResponse_KeepsVectorStoreIDForOwnerKB(t *testing.T) {
// Same setup but with the user-source display — owner caller should
// still see the UUID alongside the resolved metadata.
storeID := "aaaa-bbbb-cccc-dddd"
kb := &types.KnowledgeBase{
ID: "kb-1",
Name: "owner-kb",
TenantID: 1,
EmbeddingModelID: "e",
SummaryModelID: "s",
VectorStoreID: &storeID,
}
view := types.StoreDisplay{
Name: "prod-es",
Source: types.StoreSourceUser,
EngineType: "elasticsearch",
Status: "available",
}
got := buildKBResponse(kb, view, nil)
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", got)
}
if m["vector_store_id"] != storeID {
t.Fatalf("owner KB must keep vector_store_id, got %v", m["vector_store_id"])
}
if m["vector_store_name"] != "prod-es" {
t.Fatalf("owner KB must surface store name, got %v", m["vector_store_name"])
}
}
@@ -0,0 +1,244 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// ListKnowledgeBases store-enrichment — every KB in the list response
// carries the same resolved vector_store_* metadata as the single-KB
// endpoint. The list path funnels the resolution through
// BatchResolveStoreView so an N-KB list costs one service call rather
// than N. Cross-tenant shared KBs still render via SharedStoreDisplay
// so the owner-tenant's store inventory cannot be correlated across
// rows in the same response.
// stubListKBService returns a fixed slice from ListKnowledgeBases. Only
// the methods exercised by ListKnowledgeBases are implemented; embedding
// the interface keeps the rest nil-panic'ing intentionally.
type stubListKBService struct {
interfaces.KnowledgeBaseService
kbs []*types.KnowledgeBase
}
func (s *stubListKBService) ListKnowledgeBases(context.Context) ([]*types.KnowledgeBase, error) {
return s.kbs, nil
}
// stubVectorStoreService satisfies the two service methods the list
// path depends on: BatchResolveStoreView for bound KBs and
// EnvDefaultStoreView for env-fallback KBs. ResolveStoreView is
// intentionally left nil because ListKnowledgeBases must never reach
// into the single-KB resolver — doing so per row would be the N+1
// pattern this path is designed to avoid.
type stubVectorStoreService struct {
interfaces.VectorStoreService
batch map[string]types.StoreDisplay
batchCalls int
batchErr error
envView types.StoreDisplay
}
func (s *stubVectorStoreService) BatchResolveStoreView(
_ context.Context, _ uint64, storeIDs []string,
) (map[string]types.StoreDisplay, error) {
s.batchCalls++
if s.batchErr != nil {
return nil, s.batchErr
}
out := make(map[string]types.StoreDisplay, len(storeIDs))
for _, id := range storeIDs {
if v, ok := s.batch[id]; ok {
out[id] = v
} else {
out[id] = types.UnavailableStoreDisplay()
}
}
return out, nil
}
func (s *stubVectorStoreService) EnvDefaultStoreView(_ context.Context) types.StoreDisplay {
if s.envView.Source == "" {
return types.DefaultStoreDisplay()
}
return s.envView
}
func newListKBRouter(
t *testing.T,
svc interfaces.KnowledgeBaseService,
vss interfaces.VectorStoreService,
) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(1))
c.Set(types.UserIDContextKey.String(), "u-test")
c.Next()
})
h := &KnowledgeBaseHandler{service: svc, vectorStoreService: vss}
r.GET("/knowledge-bases", h.ListKnowledgeBases)
return r
}
func TestListKB_EnrichesEnvBoundAndSharedDistinctly(t *testing.T) {
storeUserA := "aaaa-bbbb-cccc-dddd"
storeForeign := "ffff-eeee-dddd-cccc"
kbs := []*types.KnowledgeBase{
{ID: "kb-env", Name: "env", TenantID: 1},
{ID: "kb-bound", Name: "bound", TenantID: 1, VectorStoreID: &storeUserA},
{ID: "kb-shared", Name: "shared", TenantID: 99, VectorStoreID: &storeForeign},
}
vss := &stubVectorStoreService{
batch: map[string]types.StoreDisplay{
storeUserA: {
Name: "prod-qdrant",
Source: types.StoreSourceUser,
EngineType: "qdrant",
Status: "available",
},
// storeForeign is intentionally absent — shared KBs do not
// flow through BatchResolveStoreView so the stub must never
// see it. The assertion below confirms.
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases", nil)
newListKBRouter(t, &stubListKBService{kbs: kbs}, vss).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var envelope struct {
Success bool `json:"success"`
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode: %v body=%s", err, w.Body.String())
}
if !envelope.Success || len(envelope.Data) != 3 {
t.Fatalf("expected 3 rows, got %d body=%s", len(envelope.Data), w.Body.String())
}
byID := map[string]map[string]interface{}{}
for _, row := range envelope.Data {
byID[row["id"].(string)] = row
}
// 1) env-store KB — System default labelling, no engine type.
envRow := byID["kb-env"]
if envRow["vector_store_source"] != string(types.StoreSourceEnv) {
t.Errorf("env KB: expected source=env, got %v", envRow["vector_store_source"])
}
if name, _ := envRow["vector_store_name"].(string); name == "" {
t.Errorf("env KB: expected non-empty system-default name")
}
// 2) own-tenant bound KB — name + engine surfaced.
boundRow := byID["kb-bound"]
if boundRow["vector_store_source"] != string(types.StoreSourceUser) {
t.Errorf("bound KB: expected source=user, got %v", boundRow["vector_store_source"])
}
if boundRow["vector_store_name"] != "prod-qdrant" {
t.Errorf("bound KB: expected name=prod-qdrant, got %v", boundRow["vector_store_name"])
}
if boundRow["vector_store_engine_type"] != "qdrant" {
t.Errorf("bound KB: expected engine=qdrant, got %v", boundRow["vector_store_engine_type"])
}
// 3) cross-tenant shared KB — UUID stripped, source=shared, no name.
sharedRow := byID["kb-shared"]
if _, exists := sharedRow["vector_store_id"]; exists {
t.Errorf("shared KB must NOT expose vector_store_id, got %v", sharedRow["vector_store_id"])
}
if sharedRow["vector_store_source"] != string(types.StoreSourceShared) {
t.Errorf("shared KB: expected source=shared, got %v", sharedRow["vector_store_source"])
}
if name, ok := sharedRow["vector_store_name"]; ok && name != "" {
t.Errorf("shared KB must not surface a name, got %v", name)
}
// Defensive: the foreign store UUID must not appear anywhere in the
// shared row's serialized payload.
serialized, _ := json.Marshal(sharedRow)
if strings.Contains(string(serialized), storeForeign) {
t.Fatalf("shared row leaked foreign store UUID: %s", serialized)
}
}
func TestListKB_BatchesStoreLookupsToAvoidNPlus1(t *testing.T) {
// Five KBs bound to three distinct stores. The list endpoint must
// resolve them in a single BatchResolveStoreView call regardless of
// row count — calling the per-KB ResolveStoreView path inside the
// loop would issue one service call per KB (the N+1 pattern this
// test pins against).
s1, s2, s3 := "store-1", "store-2", "store-3"
kbs := []*types.KnowledgeBase{
{ID: "a", TenantID: 1, VectorStoreID: &s1},
{ID: "b", TenantID: 1, VectorStoreID: &s2},
{ID: "c", TenantID: 1, VectorStoreID: &s1}, // dup
{ID: "d", TenantID: 1, VectorStoreID: &s3},
{ID: "e", TenantID: 1}, // env, no store call
}
vss := &stubVectorStoreService{
batch: map[string]types.StoreDisplay{
s1: {Name: "s1", Source: types.StoreSourceUser, EngineType: "qdrant", Status: "available"},
s2: {Name: "s2", Source: types.StoreSourceUser, EngineType: "postgres", Status: "available"},
s3: {Name: "s3", Source: types.StoreSourceUser, EngineType: "weaviate", Status: "available"},
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases", nil)
newListKBRouter(t, &stubListKBService{kbs: kbs}, vss).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if vss.batchCalls != 1 {
t.Fatalf("expected exactly 1 batch store-view call (N+1 protection), got %d", vss.batchCalls)
}
}
func TestListKB_GracefullyDegradesWhenBatchResolveFails(t *testing.T) {
// If the store-view resolver fails, the list response must still
// succeed — bound KBs render as unavailable. The list endpoint is
// not allowed to 500 just because the vector-store service is
// momentarily unhealthy.
storeID := "aaaa-bbbb"
kbs := []*types.KnowledgeBase{
{ID: "kb", TenantID: 1, VectorStoreID: &storeID},
}
vss := &stubVectorStoreService{batchErr: errSentinel("infra glitch")}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases", nil)
newListKBRouter(t, &stubListKBService{kbs: kbs}, vss).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 even when batch resolve fails, got %d body=%s", w.Code, w.Body.String())
}
var envelope struct {
Success bool `json:"success"`
Data []map[string]interface{} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
if len(envelope.Data) != 1 {
t.Fatalf("expected 1 row, got %d", len(envelope.Data))
}
if envelope.Data[0]["vector_store_source"] != string(types.StoreSourceUnavailable) {
t.Errorf("expected fallback source=unavailable, got %v", envelope.Data[0]["vector_store_source"])
}
}
@@ -0,0 +1,64 @@
package handler
import (
"reflect"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// TestUpdateKBRequest_DoesNotAcceptVectorStoreID is the structural enforcement
// behind the vector_store_id immutability contract. The GORM `<-:create`
// tag on KnowledgeBase.VectorStoreID already blocks every ORM UPDATE path
// (verified by the repository-level sqlite immutability tests), but the
// service DTO must independently refuse to even *accept* the field —
// otherwise a future maintainer who adds it to UpdateKnowledgeBaseRequest
// or KnowledgeBaseConfig opens a path where the field is silently ignored
// by the ORM, which is worse than an explicit rejection.
//
// This test walks the request and config struct shapes and fails if either
// gains a VectorStoreID member, by name or by JSON tag.
func TestUpdateKBRequest_DoesNotAcceptVectorStoreID(t *testing.T) {
t.Run("UpdateKnowledgeBaseRequest", func(t *testing.T) {
assertNoVectorStoreIDField(t, reflect.TypeOf(UpdateKnowledgeBaseRequest{}))
})
t.Run("KnowledgeBaseConfig", func(t *testing.T) {
// Config carries chunking / extract / faq / wiki sub-configs and must
// not be extended with a VectorStoreID either (Config is passed
// straight into the service Update path).
assertNoVectorStoreIDField(t, reflect.TypeOf(types.KnowledgeBaseConfig{}))
})
}
// assertNoVectorStoreIDField walks the visible fields of t (including embedded
// anonymous structs) and reports any field named VectorStoreID or carrying
// a json tag of "vector_store_id".
func assertNoVectorStoreIDField(t *testing.T, typ reflect.Type) {
t.Helper()
var visit func(rt reflect.Type, path string)
visit = func(rt reflect.Type, path string) {
for rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
if rt.Kind() != reflect.Struct {
return
}
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
full := path + "." + f.Name
if f.Name == "VectorStoreID" {
t.Fatalf("%s declares VectorStoreID — vector_store_id is immutable post-create "+
"and must not be accepted by update DTOs", full)
}
if tag := strings.Split(f.Tag.Get("json"), ",")[0]; tag == "vector_store_id" {
t.Fatalf("%s carries json tag \"vector_store_id\" — the field is immutable "+
"post-create and must not be accepted by update DTOs", full)
}
if f.Anonymous {
visit(f.Type, full)
}
}
}
visit(typ, typ.Name())
}
+68
View File
@@ -0,0 +1,68 @@
package handler
import (
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
apperrors "github.com/Tencent/WeKnora/internal/errors"
)
const (
defaultListPageSize = 20
maxListPageSize = 100
defaultSearchLimit = 20
maxSearchLimit = 100
)
// parseListPagination parses ?page and ?page_size for tenant-scoped list
// endpoints. Omitted keys default to page=1 and page_size=20. A present but
// malformed or out-of-range value yields a validation error on the context.
func parseListPagination(c *gin.Context) (page, pageSize int, ok bool) {
page = 1
pageSize = defaultListPageSize
if s := strings.TrimSpace(c.Query("page")); s != "" {
p, err := strconv.Atoi(s)
if err != nil || p < 1 {
c.Error(apperrors.NewValidationError("page must be a positive integer"))
return 0, 0, false
}
page = p
}
if s := strings.TrimSpace(c.Query("page_size")); s != "" {
ps, err := strconv.Atoi(s)
if err != nil || ps < 1 || ps > maxListPageSize {
c.Error(apperrors.NewValidationError(fmt.Sprintf("page_size must be between 1 and %d", maxListPageSize)))
return 0, 0, false
}
pageSize = ps
}
return page, pageSize, true
}
// parseOffsetPagination parses ?offset and ?limit for offset-based search
// endpoints. Omitted keys default to offset=0 and limit=20.
func parseOffsetPagination(c *gin.Context) (offset, limit int, ok bool) {
limit = defaultSearchLimit
if s := strings.TrimSpace(c.Query("offset")); s != "" {
parsedOffset, err := strconv.Atoi(s)
if err != nil || parsedOffset < 0 {
c.Error(apperrors.NewValidationError("offset must be a non-negative integer"))
return 0, 0, false
}
offset = parsedOffset
}
if s := strings.TrimSpace(c.Query("limit")); s != "" {
parsedLimit, err := strconv.Atoi(s)
if err != nil || parsedLimit < 1 || parsedLimit > maxSearchLimit {
c.Error(apperrors.NewValidationError(fmt.Sprintf("limit must be between 1 and %d", maxSearchLimit)))
return 0, 0, false
}
limit = parsedLimit
}
return offset, limit, true
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestParseOffsetPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
query string
wantOffset int
wantLimit int
wantOK bool
wantMessage string
}{
{name: "defaults", wantOffset: 0, wantLimit: 20, wantOK: true},
{name: "valid", query: "?offset=40&limit=50", wantOffset: 40, wantLimit: 50, wantOK: true},
{name: "invalid offset", query: "?offset=not-a-number", wantMessage: "offset must be a non-negative integer"},
{name: "negative offset", query: "?offset=-1", wantMessage: "offset must be a non-negative integer"},
{name: "invalid limit", query: "?limit=not-a-number", wantMessage: "limit must be between 1 and 100"},
{name: "zero limit", query: "?limit=0", wantMessage: "limit must be between 1 and 100"},
{name: "limit too large", query: "?limit=101", wantMessage: "limit must be between 1 and 100"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/knowledge/search"+tt.query, nil)
offset, limit, ok := parseOffsetPagination(c)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if offset != tt.wantOffset || limit != tt.wantLimit {
t.Fatalf("got offset=%d limit=%d, want offset=%d limit=%d", offset, limit, tt.wantOffset, tt.wantLimit)
}
if tt.wantOK {
if len(c.Errors) != 0 {
t.Fatalf("unexpected errors: %v", c.Errors)
}
return
}
if len(c.Errors) != 1 {
t.Fatalf("got %d errors, want 1", len(c.Errors))
}
if !strings.Contains(c.Errors[0].Error(), tt.wantMessage) {
t.Fatalf("error = %q, want message containing %q", c.Errors[0].Error(), tt.wantMessage)
}
})
}
}
func TestSearchKnowledgeRejectsInvalidPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{
"?keyword=test&offset=-1",
"?keyword=test&limit=101",
} {
t.Run(query, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/knowledge/search"+query, nil)
(&KnowledgeHandler{}).SearchKnowledge(c)
if len(c.Errors) != 1 {
t.Fatalf("got %d errors, want 1", len(c.Errors))
}
})
}
}
+155
View File
@@ -0,0 +1,155 @@
package handler
import (
"net/http"
"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"
"github.com/gin-gonic/gin"
)
// MCPCredentialsHandler handles secret credentials for MCP services via a
// dedicated subresource (/mcp-services/{id}/credentials). Splitting this out
// of UpdateMCPService delivers three concrete benefits:
//
// 1. The main MCP PUT body never carries secrets — eliminating the
// "masked-value round-trip overwrites stored secret" class of bug at the
// contract level rather than via runtime preserve-on-redacted defenses.
//
// 2. Saving the MCP edit dialog (changing timeout / enabled / etc.) cannot
// accidentally invalidate or clobber a working credential. Credential
// operations are explicit and atomic.
//
// 3. The "is this configured?" metadata travels on the main resource
// response (MCPServiceResponse.Credentials) — no separate GET endpoint
// needed. Only PUT and DELETE live here.
type MCPCredentialsHandler struct {
svc interfaces.MCPServiceService
}
// NewMCPCredentialsHandler constructs the handler.
func NewMCPCredentialsHandler(svc interfaces.MCPServiceService) *MCPCredentialsHandler {
return &MCPCredentialsHandler{svc: svc}
}
// mcpCredentialsPutRequest is the body shape for PUT /credentials. Both
// fields are pointers so the handler can distinguish "absent" (preserve)
// from "present and empty" (treat as no-op; clients should call DELETE to
// remove a credential). Non-empty values replace the stored secret.
type mcpCredentialsPutRequest struct {
APIKey *string `json:"api_key,omitempty"`
Token *string `json:"token,omitempty"`
}
// Put writes (creates or replaces) one or more credential fields on the MCP
// service. Triggers a connection recycle so the next upstream call uses the
// new credential.
//
// Put godoc
// @Summary 设置 MCP 服务凭据
// @Description 为指定字段写入新凭据;省略的字段保留原值;空字符串视为 no-op(如需删除请用 DELETE)
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param request body map[string]interface{} true "{api_key?: string, token?: string}"
// @Success 200 {object} map[string]interface{} "写入后的凭据状态"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "服务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/credentials [put]
func (h *MCPCredentialsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
var req mcpCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Nothing to do — but rather than 400 (benign no-op), look up current
// state and return it. Client treats this identically to a real save.
if req.APIKey == nil && req.Token == nil {
svc, err := h.svc.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil || svc == nil {
c.Error(errors.NewNotFoundError("MCP service not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
},
}})
return
}
updated, err := h.svc.UpdateMCPCredentials(ctx, tenantID, serviceID, req.APIKey, req.Token)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
})
c.Error(errors.NewInternalServerError("failed to update credentials: " + err.Error()))
return
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: updated.AuthConfig != nil && updated.AuthConfig.APIKey != ""},
"token": {Configured: updated.AuthConfig != nil && updated.AuthConfig.Token != ""},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
// DeleteField removes one credential field. Recognized fields: "api_key",
// "token". Returns 204 on success (even if the field was already empty).
//
// DeleteField godoc
// @Summary 移除 MCP 服务的单个凭据字段
// @Description 删除指定字段的存储凭据;删除已为空的字段是幂等的
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param field path string true "字段名(api_key | token)"
// @Success 204
// @Failure 400 {object} errors.AppError "字段名非法"
// @Failure 404 {object} errors.AppError "服务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/credentials/{field} [delete]
func (h *MCPCredentialsHandler) DeleteField(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
field := c.Param("field")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if field != "api_key" && field != "token" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.svc.ClearMCPCredential(ctx, tenantID, serviceID, field); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
"field": field,
})
c.Error(errors.NewInternalServerError("failed to clear credential: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}
+423
View File
@@ -0,0 +1,423 @@
package handler
import (
stderrors "errors"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/agent/approval"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/mcp"
"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"
)
// MCPOAuthHandler exposes the per-user MCP OAuth2 authorization-code flow:
// kicking off authorization (discovery + dynamic client registration + PKCE),
// receiving the provider redirect, reporting authorization status, and
// revoking a user's token.
type MCPOAuthHandler struct {
oauth *mcp.OAuthManager
mcpManager *mcp.MCPManager
svc interfaces.MCPServiceService
gate *approval.Gate
}
// NewMCPOAuthHandler constructs the handler.
func NewMCPOAuthHandler(
oauth *mcp.OAuthManager,
mcpManager *mcp.MCPManager,
svc interfaces.MCPServiceService,
gate *approval.Gate,
) *MCPOAuthHandler {
return &MCPOAuthHandler{oauth: oauth, mcpManager: mcpManager, svc: svc, gate: gate}
}
func mcpOAuthPrincipalsFromContext(ctx *gin.Context) (tokenPrincipal types.Principal, gateUserID string) {
raw, _ := types.PrincipalFromContext(ctx.Request.Context())
raw = raw.Normalize()
tokenPrincipal = types.MCPOAuthPrincipalFromContext(ctx.Request.Context())
if raw.Valid() {
gateUserID = raw.StorageID()
}
return tokenPrincipal, gateUserID
}
type mcpOAuthAuthorizeRequest struct {
// RedirectURI is the absolute backend callback URL registered with the
// authorization server (e.g. https://host/api/v1/mcp-services/oauth/callback).
RedirectURI string `json:"redirect_uri"`
// FrontendRedirect is where the callback bounces the browser when done
// (e.g. the MCP settings page). Optional; defaults to "/".
FrontendRedirect string `json:"frontend_redirect"`
}
// AuthorizeURL begins authorization and returns the URL the browser must open.
//
// AuthorizeURL godoc
// @Summary 发起 MCP OAuth 授权
// @Description 对使用 OAuth 的 MCP 服务执行发现与动态客户端注册,返回浏览器应跳转的授权地址(当前用户维度)
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param request body map[string]interface{} true "{redirect_uri: string, frontend_redirect?: string}"
// @Success 200 {object} map[string]interface{} "{authorization_url: string, authorization_attempt: string}"
// @Failure 400 {object} errors.AppError
// @Security Bearer
// @Router /mcp-services/{id}/oauth/authorize-url [post]
func (h *MCPOAuthHandler) AuthorizeURL(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
principal, _ := mcpOAuthPrincipalsFromContext(c)
if tenantID == 0 || !principal.Valid() {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
var req mcpOAuthAuthorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
req.RedirectURI = strings.TrimSpace(req.RedirectURI)
if req.RedirectURI == "" {
c.Error(errors.NewValidationError("redirect_uri is required"))
return
}
if req.FrontendRedirect == "" {
req.FrontendRedirect = "/"
}
service, err := h.svc.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil || service == nil {
c.Error(errors.NewNotFoundError("MCP service not found"))
return
}
if !service.AuthConfig.IsOAuth() {
c.Error(errors.NewValidationError("MCP service is not configured to use OAuth"))
return
}
authURL, attemptID, err := h.oauth.StartAuthorization(
ctx, service, tenantID, principal, req.RedirectURI, req.FrontendRedirect,
)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
})
c.Error(errors.NewInternalServerError("failed to start authorization: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"authorization_url": authURL,
"authorization_attempt": attemptID,
}})
}
// Callback receives the authorization-server redirect. It is registered as a
// public (no-bearer) route; the opaque single-use `state` parameter
// authenticates the request. On completion it redirects the browser back to
// the frontend with the result encoded in the URL fragment.
//
// Callback godoc
// @Summary MCP OAuth 回调
// @Description 接收授权服务器回调并完成 code 交换,随后重定向回前端
// @Tags MCP服务
// @Param code query string false "授权码"
// @Param state query string false "状态参数"
// @Param error query string false "授权错误码"
// @Success 302
// @Router /mcp-services/oauth/callback [get]
func (h *MCPOAuthHandler) Callback(c *gin.Context) {
ctx := c.Request.Context()
state := strings.TrimSpace(c.Query("state"))
code := strings.TrimSpace(c.Query("code"))
providerErr := strings.TrimSpace(c.Query("error"))
const fallbackRedirect = "/"
if providerErr != "" {
c.Redirect(http.StatusFound, fallbackRedirect+"#mcp_oauth_error="+urlQueryEscape(providerErr))
return
}
if state == "" || code == "" {
c.Redirect(http.StatusFound, fallbackRedirect+"#mcp_oauth_error="+urlQueryEscape("missing_code_or_state"))
return
}
frontendRedirect, serviceID, err := h.oauth.CompleteAuthorization(ctx, state, code)
if frontendRedirect == "" {
frontendRedirect = fallbackRedirect
}
if err != nil {
logger.Errorf(ctx, "MCP OAuth callback failed: %v", err)
c.Redirect(http.StatusFound, frontendRedirect+"#mcp_oauth_error="+urlQueryEscape("authorization_failed"))
return
}
// The old transport may have been created with an OAuth client registration
// that was invalidated together with the refresh token. Recreate it against
// the freshly persisted token/client on next use.
if h.mcpManager != nil && serviceID != "" {
_ = h.mcpManager.CloseClient(serviceID)
}
c.Redirect(http.StatusFound, frontendRedirect+"#mcp_oauth_result=success")
}
// Status reports whether the current user has authorized this service.
//
// Status godoc
// @Summary 查询 MCP OAuth 授权状态
// @Description 返回当前用户的 OAuth Token 生命周期状态;传 authorization_attempt 时只检查本次授权流程
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param authorization_attempt query string false "本次授权尝试 ID;传入后不会接受历史 Token"
// @Success 200 {object} map[string]interface{} "{authorized: bool, state: string, refresh_available: bool, expires_at?: string}"
// @Security Bearer
// @Router /mcp-services/{id}/oauth/status [get]
func (h *MCPOAuthHandler) Status(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
principal, _ := mcpOAuthPrincipalsFromContext(c)
if tenantID == 0 || !principal.Valid() {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
attemptID := strings.TrimSpace(c.Query("authorization_attempt"))
if attemptID != "" {
authorized, err := h.oauth.IsAuthorizationAttemptComplete(
ctx, tenantID, principal, serviceID, attemptID,
)
if err != nil {
c.Error(errors.NewInternalServerError("failed to query authorization status: " + err.Error()))
return
}
state := "pending"
if authorized {
state = "authorized"
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"authorized": authorized,
"state": state,
}})
return
}
status, err := h.oauth.AuthorizationStatus(ctx, tenantID, principal, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("failed to query authorization status: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": status})
}
// Revoke removes the current user's stored token and recycles connections.
//
// Revoke godoc
// @Summary 撤销 MCP OAuth 授权
// @Description 删除当前用户对指定 MCP 服务的 OAuth 令牌
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Success 204
// @Security Bearer
// @Router /mcp-services/{id}/oauth/token [delete]
func (h *MCPOAuthHandler) Revoke(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
principal, _ := mcpOAuthPrincipalsFromContext(c)
if tenantID == 0 || !principal.Valid() {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if err := h.oauth.Revoke(ctx, tenantID, principal, serviceID); err != nil {
c.Error(errors.NewInternalServerError("failed to revoke authorization: " + err.Error()))
return
}
// Recycle any cached connections so a subsequent call re-authorizes.
_ = h.mcpManager.CloseClient(serviceID)
c.Status(http.StatusNoContent)
}
type resolveMCPOAuthBody struct {
// ServiceID is the MCP service the pending prompt belongs to; used to
// verify the user actually holds a token before resuming the agent.
ServiceID string `json:"service_id" binding:"required"`
// Decision is "authorize" (default) or "cancel" when the user skips OAuth.
Decision string `json:"decision"`
}
// ResolveMCPOAuth resumes an agent run that paused on an in-conversation OAuth
// prompt. The frontend calls this once the per-user authorization popup has
// completed; the backend verifies a token now exists for (tenant, user,
// service) before unblocking, so a premature/failed authorization does not
// resume the tool into another failure.
//
// ResolveMCPOAuth godoc
// @Summary 完成对话内 MCP OAuth 授权
// @Description 用户在对话中完成 OAuth 授权后调用,校验令牌存在后恢复被暂停的 Agent 工具调用
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param pending_id path string true "待授权 ID"
// @Param request body map[string]interface{} true "{service_id: string}"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} errors.AppError
// @Failure 409 {object} errors.AppError "用户尚未完成授权"
// @Security Bearer
// @Router /agent/mcp-oauth-resolutions/{pending_id} [post]
func (h *MCPOAuthHandler) ResolveMCPOAuth(c *gin.Context) {
ctx := c.Request.Context()
pendingID := c.Param("pending_id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
principal, gateUserID := mcpOAuthPrincipalsFromContext(c)
if tenantID == 0 || !principal.Valid() || gateUserID == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if h.gate == nil {
c.Error(errors.NewInternalServerError("OAuth gate is not configured"))
return
}
var body resolveMCPOAuthBody
if err := c.ShouldBindJSON(&body); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
serviceID := strings.TrimSpace(body.ServiceID)
if serviceID == "" {
c.Error(errors.NewValidationError("service_id is required"))
return
}
decision := strings.TrimSpace(strings.ToLower(body.Decision))
if decision == "" {
decision = "authorize"
}
switch decision {
case "cancel", "reject", "skip":
if err := h.gate.Resolve(tenantID, gateUserID, pendingID, approval.Decision{
Approved: false,
Reason: "user canceled",
}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("workspace mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
case "authorize":
// continue below
default:
c.Error(errors.NewBadRequestError("decision must be authorize or cancel"))
return
}
// Only resume once the user genuinely holds a token; otherwise the retry
// would just fail again with another authorization-required error.
authorized, err := h.oauth.IsAuthorized(ctx, tenantID, principal, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("failed to verify authorization: " + err.Error()))
return
}
if !authorized {
c.Error(errors.NewConflictError("authorization not completed yet for this MCP service"))
return
}
if err := h.gate.Resolve(tenantID, gateUserID, pendingID, approval.Decision{Approved: true}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("workspace mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CancelMCPOAuth lets the user skip an in-conversation OAuth prompt without
// completing authorization. This unblocks the paused agent with a denial.
//
// CancelMCPOAuth godoc
// @Summary 跳过对话内 MCP OAuth 授权
// @Description 用户主动跳过 OAuth 授权,解除 Agent 阻塞
// @Tags MCP服务
// @Produce json
// @Param pending_id path string true "待授权 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} errors.AppError
// @Security Bearer
// @Router /agent/mcp-oauth-resolutions/{pending_id}/cancel [post]
func (h *MCPOAuthHandler) CancelMCPOAuth(c *gin.Context) {
ctx := c.Request.Context()
pendingID := c.Param("pending_id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
_, gateUserID := mcpOAuthPrincipalsFromContext(c)
if tenantID == 0 || strings.TrimSpace(gateUserID) == "" {
c.Error(errors.NewUnauthorizedError("authentication required"))
return
}
if h.gate == nil {
c.Error(errors.NewInternalServerError("OAuth gate is not configured"))
return
}
if err := h.gate.Resolve(tenantID, gateUserID, pendingID, approval.Decision{
Approved: false,
Reason: "user canceled",
}); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending authorization not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending authorization already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("workspace mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this prompt"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"pending_id": secutils.SanitizeForLog(pendingID),
})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+685
View File
@@ -0,0 +1,685 @@
package handler
import (
"encoding/json"
stderrors "errors"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/agent/approval"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
mcpsecurity "github.com/Tencent/WeKnora/internal/mcp"
"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"
)
// MCPServiceHandler handles MCP service related HTTP requests
type MCPServiceHandler struct {
mcpServiceService interfaces.MCPServiceService
mcpToolApprovalService interfaces.MCPToolApprovalService
toolApprovalGate *approval.Gate
}
// NewMCPServiceHandler creates a new MCP service handler
func NewMCPServiceHandler(
mcpServiceService interfaces.MCPServiceService,
mcpToolApprovalService interfaces.MCPToolApprovalService,
toolApprovalGate *approval.Gate,
) *MCPServiceHandler {
return &MCPServiceHandler{
mcpServiceService: mcpServiceService,
mcpToolApprovalService: mcpToolApprovalService,
toolApprovalGate: toolApprovalGate,
}
}
// CreateMCPService godoc
// @Summary 创建MCP服务
// @Description 创建新的MCP服务配置
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param request body types.MCPService true "MCP服务配置"
// @Success 200 {object} map[string]interface{} "创建的MCP服务"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services [post]
func (h *MCPServiceHandler) CreateMCPService(c *gin.Context) {
ctx := c.Request.Context()
var service types.MCPService
if err := c.ShouldBindJSON(&service); err != nil {
logger.Error(ctx, "Failed to parse MCP service request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
service.TenantID = tenantID
// SSRF validation for MCP service URL
if service.URL != nil && *service.URL != "" {
if err := secutils.ValidateURLForSSRF(*service.URL); err != nil {
logger.Warnf(ctx, "SSRF validation failed for MCP service URL: %v", err)
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("MCP service URL", *service.URL, err)))
return
}
}
if err := mcpsecurity.ValidateServiceOutboundURLs(&service); err != nil {
logger.Warnf(ctx, "SSRF validation failed for MCP service configuration: %v", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if err := h.mcpServiceService.CreateMCPService(ctx, &service); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_name": secutils.SanitizeForLog(service.Name)})
c.Error(errors.NewInternalServerError("Failed to create MCP service: " + err.Error()))
return
}
// Response uses dto.MCPServiceResponse which omits secret fields by
// construction — no runtime redaction needed.
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewMCPServiceResponse(ctx, &service),
})
}
// ListMCPServices godoc
// @Summary 获取MCP服务列表
// @Description 获取当前空间的所有MCP服务
// @Tags MCP服务
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "MCP服务列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services [get]
func (h *MCPServiceHandler) ListMCPServices(c *gin.Context) {
ctx := c.Request.Context()
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
services, err := h.mcpServiceService.ListMCPServices(ctx, tenantID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"tenant_id": tenantID})
c.Error(errors.NewInternalServerError("Failed to list MCP services: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewMCPServiceResponses(ctx, services),
})
}
// GetMCPService godoc
// @Summary 获取MCP服务详情
// @Description 根据ID获取MCP服务详情
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Success 200 {object} map[string]interface{} "MCP服务详情"
// @Failure 404 {object} errors.AppError "服务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id} [get]
func (h *MCPServiceHandler) GetMCPService(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
service, err := h.mcpServiceService.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.Error(errors.NewNotFoundError("MCP service not found"))
return
}
// dto.NewMCPServiceResponse omits secret fields and additionally strips
// transport details (URL/Headers/EnvVars/StdioConfig) for builtin services
// so the cross-tenant builtin list does not leak per-tenant config.
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewMCPServiceResponse(ctx, service),
})
}
// UpdateMCPService godoc
// @Summary 更新MCP服务
// @Description 更新MCP服务配置
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Param request body object true "更新字段"
// @Success 200 {object} map[string]interface{} "更新后的MCP服务"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id} [put]
func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
// Use map to handle partial updates, including false values
var updateData map[string]interface{}
if err := c.ShouldBindJSON(&updateData); err != nil {
logger.Error(ctx, "Failed to parse MCP service update request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Convert map to MCPService struct for validation and processing
var service types.MCPService
service.ID = serviceID
service.TenantID = tenantID
// Track which fields are being updated
updateFields := make(map[string]bool)
// Map the update data to service struct
if name, ok := updateData["name"].(string); ok {
service.Name = name
updateFields["name"] = true
}
if desc, ok := updateData["description"].(string); ok {
service.Description = desc
updateFields["description"] = true
}
if enabled, ok := updateData["enabled"].(bool); ok {
if enabled {
service.Enabled = true
} else {
service.Enabled = false
}
updateFields["enabled"] = true
}
if transportType, ok := updateData["transport_type"].(string); ok {
service.TransportType = types.MCPTransportType(transportType)
}
if url, ok := updateData["url"].(string); ok && url != "" {
service.URL = &url
} else if _, exists := updateData["url"]; exists {
// Explicitly set to nil if provided as null/empty
service.URL = nil
}
// SSRF validation for updated MCP service URL
if service.URL != nil && *service.URL != "" {
if err := secutils.ValidateURLForSSRF(*service.URL); err != nil {
logger.Warnf(ctx, "SSRF validation failed for MCP service URL: %v", err)
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("MCP service URL", *service.URL, err)))
return
}
}
if stdioConfig, ok := updateData["stdio_config"].(map[string]interface{}); ok {
config := &types.MCPStdioConfig{}
if command, ok := stdioConfig["command"].(string); ok {
config.Command = command
}
if args, ok := stdioConfig["args"].([]interface{}); ok {
config.Args = make([]string, len(args))
for i, arg := range args {
if str, ok := arg.(string); ok {
config.Args[i] = str
}
}
}
service.StdioConfig = config
}
if envVars, ok := updateData["env_vars"].(map[string]interface{}); ok {
service.EnvVars = make(types.MCPEnvVars)
for k, v := range envVars {
if str, ok := v.(string); ok {
service.EnvVars[k] = str
}
}
}
if headers, ok := updateData["headers"].(map[string]interface{}); ok {
service.Headers = make(types.MCPHeaders)
for k, v := range headers {
if str, ok := v.(string); ok {
service.Headers[k] = str
}
}
}
if authConfig, ok := updateData["auth_config"].(map[string]interface{}); ok {
service.AuthConfig = &types.MCPAuthConfig{}
// Secret fields (api_key, token) are intentionally NOT read from the
// main PUT body — they live behind the /credentials subresource so
// editing unrelated config (timeout, enabled, etc.) cannot
// accidentally clobber a stored credential. Log a warning when a
// client still tries to send them so we can spot stale callers.
if _, present := authConfig["api_key"]; present {
logger.Warnf(ctx,
"deprecated: api_key in PUT /mcp-services/%s body is ignored; use PUT /credentials instead",
secutils.SanitizeForLog(serviceID))
}
if _, present := authConfig["token"]; present {
logger.Warnf(ctx,
"deprecated: token in PUT /mcp-services/%s body is ignored; use PUT /credentials instead",
secutils.SanitizeForLog(serviceID))
}
// CustomHeaders is structural (not a secret) — keep accepting it here.
// nil preserves existing, non-nil replaces; the service layer treats a
// nil CustomHeaders as "no change".
if customHeaders, ok := authConfig["custom_headers"].(map[string]interface{}); ok {
headers := make(map[string]string, len(customHeaders))
for k, v := range customHeaders {
if s, ok := v.(string); ok {
headers[k] = s
}
}
service.AuthConfig.CustomHeaders = headers
}
// auth_type and scopes are non-secret OAuth configuration; allow them
// through the main PUT so a service can be switched to/from OAuth.
if authType, ok := authConfig["auth_type"].(string); ok {
service.AuthConfig.AuthType = types.MCPAuthType(authType)
}
// api_key_header is non-secret structural config (header name for the
// api_key strategy); flows through the main PUT like custom_headers.
if apiKeyHeader, ok := authConfig["api_key_header"].(string); ok {
service.AuthConfig.APIKeyHeader = apiKeyHeader
}
if scopes, ok := authConfig["scopes"].([]interface{}); ok {
list := make([]string, 0, len(scopes))
for _, s := range scopes {
if str, ok := s.(string); ok {
list = append(list, str)
}
}
service.AuthConfig.Scopes = list
}
if metaURL, ok := authConfig["auth_server_metadata_url"].(string); ok {
service.AuthConfig.AuthServerMetadataURL = metaURL
}
}
if advancedConfig, ok := updateData["advanced_config"].(map[string]interface{}); ok {
service.AdvancedConfig = &types.MCPAdvancedConfig{}
if timeout, ok := advancedConfig["timeout"].(float64); ok {
service.AdvancedConfig.Timeout = int(timeout)
}
if retryCount, ok := advancedConfig["retry_count"].(float64); ok {
service.AdvancedConfig.RetryCount = int(retryCount)
}
if retryDelay, ok := advancedConfig["retry_delay"].(float64); ok {
service.AdvancedConfig.RetryDelay = int(retryDelay)
}
}
if err := mcpsecurity.ValidateServiceOutboundURLs(&service); err != nil {
logger.Warnf(ctx, "SSRF validation failed for MCP service update: %v", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if err := h.mcpServiceService.UpdateMCPService(ctx, &service, updateFields); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.Error(errors.NewInternalServerError("Failed to update MCP service: " + err.Error()))
return
}
logger.Infof(ctx, "MCP service updated successfully: %s", secutils.SanitizeForLog(serviceID))
// Re-fetch to pick up server-side merges (CustomHeaders preserve, etc.)
// and respond with the full current state via the secret-free DTO.
stored, err := h.mcpServiceService.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("Failed to fetch updated MCP service: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewMCPServiceResponse(ctx, stored),
})
}
// DeleteMCPService godoc
// @Summary 删除MCP服务
// @Description 删除指定的MCP服务
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id} [delete]
func (h *MCPServiceHandler) DeleteMCPService(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if err := h.mcpServiceService.DeleteMCPService(ctx, tenantID, serviceID); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.Error(errors.NewInternalServerError("Failed to delete MCP service: " + err.Error()))
return
}
logger.Infof(ctx, "MCP service deleted successfully: %s", secutils.SanitizeForLog(serviceID))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "MCP service deleted successfully",
})
}
// TestMCPService godoc
// @Summary 测试MCP服务连接
// @Description 测试MCP服务是否可以正常连接
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Success 200 {object} map[string]interface{} "测试结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/test [post]
func (h *MCPServiceHandler) TestMCPService(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
logger.Infof(ctx, "Testing MCP service: %s", secutils.SanitizeForLog(serviceID))
result, err := h.mcpServiceService.TestMCPService(ctx, tenantID, serviceID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": types.MCPTestResult{
Success: false,
Message: "Test failed: " + err.Error(),
},
})
return
}
logger.Infof(ctx, "MCP service test completed: %s, success: %v", secutils.SanitizeForLog(serviceID), result.Success)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
// GetMCPServiceTools godoc
// @Summary 获取MCP服务工具列表
// @Description 获取MCP服务提供的工具列表
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Success 200 {object} map[string]interface{} "工具列表"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/tools [get]
func (h *MCPServiceHandler) GetMCPServiceTools(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
tools, err := h.mcpServiceService.GetMCPServiceTools(ctx, tenantID, serviceID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.Error(errors.NewInternalServerError("Failed to get MCP service tools: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tools,
})
}
// GetMCPServiceResources godoc
// @Summary 获取MCP服务资源列表
// @Description 获取MCP服务提供的资源列表
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP服务ID"
// @Success 200 {object} map[string]interface{} "资源列表"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/resources [get]
func (h *MCPServiceHandler) GetMCPServiceResources(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
resources, err := h.mcpServiceService.GetMCPServiceResources(ctx, tenantID, serviceID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": secutils.SanitizeForLog(serviceID)})
c.Error(errors.NewInternalServerError("Failed to get MCP service resources: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": resources,
})
}
// ListMCPToolApprovals returns persisted require_approval flags for tools on an MCP service.
func (h *MCPServiceHandler) ListMCPToolApprovals(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if h.mcpToolApprovalService == nil {
c.Error(errors.NewInternalServerError("MCP tool approval is not configured"))
return
}
rows, err := h.mcpToolApprovalService.ListByService(ctx, tenantID, serviceID)
if err != nil {
// Distinguish "service not found" from internal errors so the client
// gets an accurate status code instead of an opaque 404.
if strings.Contains(err.Error(), "not found") {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{"service_id": serviceID})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": rows})
}
type setMCPToolApprovalBody struct {
RequireApproval bool `json:"require_approval"`
}
// SetMCPToolApproval sets whether a tool requires human approval before the agent may call it.
//
// SetMCPToolApproval godoc
// @Summary 设置 MCP 工具人工审批策略
// @Description 为指定 MCP 服务下的某个工具设置/更新审批要求
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param tool_name path string true "工具名"
// @Param request body map[string]interface{} true "{require_approval: bool}"
// @Success 200 {object} map[string]interface{} "更新结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "MCP 服务或工具不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/tool-approvals/{tool_name} [put]
func (h *MCPServiceHandler) SetMCPToolApproval(c *gin.Context) {
ctx := c.Request.Context()
serviceID := secutils.SanitizeForLog(c.Param("id"))
// Gin already URL-decodes path params; do not call url.PathUnescape again
// or names containing literal "%" become corrupted.
toolName := c.Param("tool_name")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if h.mcpToolApprovalService == nil {
c.Error(errors.NewInternalServerError("MCP tool approval is not configured"))
return
}
var body setMCPToolApprovalBody
if err := c.ShouldBindJSON(&body); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if err := h.mcpToolApprovalService.SetRequireApproval(ctx, tenantID, serviceID, toolName, body.RequireApproval); err != nil {
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type resolveToolApprovalBody struct {
Decision string `json:"decision" binding:"required"` // approve | reject
ModifiedArgs json.RawMessage `json:"modified_args"`
Reason string `json:"reason"`
}
// ResolveToolApproval completes a pending MCP tool approval (agent execution resumes).
//
// ResolveToolApproval godoc
// @Summary 处理 MCP 工具调用待审批请求
// @Description 用户审批通过或驳回一次工具调用(用于 Agent 阻塞等待审批的场景)
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param pending_id path string true "待审批记录 ID"
// @Param request body map[string]interface{} true "{decision: \"approve\"|\"reject\", reason?: string, modified_args?: object}"
// @Success 200 {object} map[string]interface{} "审批结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "待审批记录不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /agent/tool-approvals/{pending_id} [post]
func (h *MCPServiceHandler) ResolveToolApproval(c *gin.Context) {
ctx := c.Request.Context()
pendingID := c.Param("pending_id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if h.toolApprovalGate == nil {
c.Error(errors.NewInternalServerError("Tool approval gate is not configured"))
return
}
var body resolveToolApprovalBody
if err := c.ShouldBindJSON(&body); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
dec := approval.Decision{Reason: body.Reason}
switch body.Decision {
case "approve":
dec.Approved = true
// Reject "null" / non-object payloads up front. Without this, "null"
// (4 bytes) passes the len>0 check and the downstream tool sees a nil
// argument map, silently losing the original args.
trimmed := strings.TrimSpace(string(body.ModifiedArgs))
if len(trimmed) > 0 && trimmed != "null" {
var probe map[string]interface{}
if err := json.Unmarshal(body.ModifiedArgs, &probe); err != nil || probe == nil {
c.Error(errors.NewBadRequestError("modified_args must be a non-null JSON object"))
return
}
dec.ModifiedArgs = body.ModifiedArgs
}
case "reject":
dec.Approved = false
default:
c.Error(errors.NewBadRequestError("decision must be approve or reject"))
return
}
principal, _ := types.PrincipalFromContext(ctx)
gateUserID := principal.StorageID()
// Reject calls without an authenticated principal up front. The gate's
// per-principal authorization is fail-close, but surfacing 401 here gives
// a clearer signal that auth middleware did not populate the context.
if strings.TrimSpace(gateUserID) == "" {
c.Error(errors.NewUnauthorizedError("authenticated user required to resolve tool approval"))
return
}
if err := h.toolApprovalGate.Resolve(tenantID, gateUserID, pendingID, dec); err != nil {
switch {
case stderrors.Is(err, approval.ErrPendingNotFound):
c.Error(errors.NewNotFoundError("pending approval not found or already completed"))
case stderrors.Is(err, approval.ErrAlreadyResolved):
c.Error(errors.NewBadRequestError("pending approval already resolved (timeout / cancel raced your action)"))
case stderrors.Is(err, approval.ErrTenantMismatch):
c.Error(errors.NewBadRequestError("workspace mismatch"))
case stderrors.Is(err, approval.ErrUserMismatch):
c.Error(errors.NewBadRequestError("user mismatch: only the session owner may resolve this approval"))
default:
logger.ErrorWithFields(ctx, err, map[string]interface{}{"pending_id": pendingID})
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+197
View File
@@ -0,0 +1,197 @@
package handler
import (
"context"
stderrors "errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
)
// meEnvVarService is the member-facing slice of the env var surface. Not one
// method takes an identity: the service derives it from the context, which is
// what makes it impossible for a request to select whose values are touched.
type meEnvVarService interface {
ListMine(ctx context.Context) ([]service.ConfigEnvGroup, error)
SetMineSkill(ctx context.Context, skillID, name, value string) error
DeleteMineSkill(ctx context.Context, skillID, name string) error
SetMineSandbox(ctx context.Context, configID, name, value string) error
DeleteMineSandbox(ctx context.Context, configID, name string) error
}
// MeEnvVarHandler serves the /me/env-vars endpoints.
//
// These exist instead of relaxing /sandbox-configs/:id/skills*, which is Admin+
// even for reads on purpose: an upload there drives a root shell whose output is
// baked into the image every session of that config boots, and the listing names
// what that image carries. This handler returns declarations and set/unset
// status only — never a value.
type MeEnvVarHandler struct {
service meEnvVarService
}
func NewMeEnvVarHandler(s *service.UserEnvService) *MeEnvVarHandler {
return &MeEnvVarHandler{service: s}
}
// meEnvVarRequest is the body of every write endpoint. It has no principal
// field, deliberately: not an ignored one, an absent one. A field that could
// name an identity is one refactor away from being honoured, and honouring it
// would let any logged-in member write another member's values.
type meEnvVarRequest struct {
SkillID string `json:"skill_id"`
SandboxConfigID string `json:"sandbox_config_id"`
Name string `json:"name"`
// Value is unused by the delete endpoints. Clearing a value is a delete
// rather than a write of "", so a member always has one unambiguous way to
// revoke.
Value string `json:"value"`
}
// respondEnvVarError renders a refusal. Nothing to delete is a 404; a rejected
// input is a 400; anything else is the service failing and stays a 500 so an
// internal message never reaches a member.
func respondEnvVarError(c *gin.Context, err error) {
switch {
case stderrors.Is(err, types.ErrEnvVarNotFound):
c.Error(apperrors.NewNotFoundError("this environment variable is not set"))
default:
c.Error(err)
}
}
// List godoc
// @Summary List my environment variables
// @Description List every sandbox config of this workspace with the caller's own config-wide variables and the credentials its skills declared, each reporting whether it is unset, filled in workspace-wide, or filled in by the caller. Values are never returned.
// @Tags Me
// @Produce json
// @Success 200 {object} map[string]interface{} "One group per sandbox config"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Security Bearer
// @Router /me/env-vars [get]
func (h *MeEnvVarHandler) List(c *gin.Context) {
groups, err := h.service.ListMine(c.Request.Context())
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": groups})
}
// SetSkill godoc
// @Summary Set one of my skill credentials
// @Description Store the caller's own value for one variable the skill declared. It overrides the workspace-wide value for this caller only, and is injected only into executions that name this skill.
// @Tags Me
// @Accept json
// @Produce json
// @Param request body meEnvVarRequest true "Skill, variable name and value"
// @Success 200 {object} map[string]interface{} "Stored"
// @Failure 400 {object} apperrors.AppError "Invalid request"
// @Security Bearer
// @Router /me/env-vars/skill [put]
func (h *MeEnvVarHandler) SetSkill(c *gin.Context) {
req, ok := bindEnvVarRequest(c, "skill_id", func(r meEnvVarRequest) string { return r.SkillID })
if !ok {
return
}
if err := h.service.SetMineSkill(c.Request.Context(), req.SkillID, req.Name, req.Value); err != nil {
respondEnvVarError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DeleteSkill godoc
// @Summary Delete one of my skill credentials
// @Description Remove the caller's own value. The workspace-wide value, if there is one, applies again afterwards.
// @Tags Me
// @Accept json
// @Produce json
// @Param request body meEnvVarRequest true "Skill and variable name"
// @Success 200 {object} map[string]interface{} "Deleted"
// @Failure 404 {object} apperrors.AppError "Not set"
// @Security Bearer
// @Router /me/env-vars/skill [delete]
func (h *MeEnvVarHandler) DeleteSkill(c *gin.Context) {
req, ok := bindEnvVarRequest(c, "skill_id", func(r meEnvVarRequest) string { return r.SkillID })
if !ok {
return
}
if err := h.service.DeleteMineSkill(c.Request.Context(), req.SkillID, req.Name); err != nil {
respondEnvVarError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SetSandbox godoc
// @Summary Set one of my sandbox environment variables
// @Description Store the caller's own value for one variable on a sandbox config. It is injected into every skill script and shell command this caller's turns run on that config.
// @Tags Me
// @Accept json
// @Produce json
// @Param request body meEnvVarRequest true "Sandbox config, variable name and value"
// @Success 200 {object} map[string]interface{} "Stored"
// @Failure 400 {object} apperrors.AppError "Invalid request"
// @Security Bearer
// @Router /me/env-vars/sandbox [put]
func (h *MeEnvVarHandler) SetSandbox(c *gin.Context) {
req, ok := bindEnvVarRequest(c, "sandbox_config_id",
func(r meEnvVarRequest) string { return r.SandboxConfigID })
if !ok {
return
}
err := h.service.SetMineSandbox(c.Request.Context(), req.SandboxConfigID, req.Name, req.Value)
if err != nil {
respondEnvVarError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DeleteSandbox godoc
// @Summary Delete one of my sandbox environment variables
// @Description Remove the caller's own value for one variable on a sandbox config.
// @Tags Me
// @Accept json
// @Produce json
// @Param request body meEnvVarRequest true "Sandbox config and variable name"
// @Success 200 {object} map[string]interface{} "Deleted"
// @Failure 404 {object} apperrors.AppError "Not set"
// @Security Bearer
// @Router /me/env-vars/sandbox [delete]
func (h *MeEnvVarHandler) DeleteSandbox(c *gin.Context) {
req, ok := bindEnvVarRequest(c, "sandbox_config_id",
func(r meEnvVarRequest) string { return r.SandboxConfigID })
if !ok {
return
}
err := h.service.DeleteMineSandbox(c.Request.Context(), req.SandboxConfigID, req.Name)
if err != nil {
respondEnvVarError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// bindEnvVarRequest decodes a write body and refuses one missing its scope key
// or its name, so every endpoint reports the same two required fields the same
// way.
func bindEnvVarRequest(
c *gin.Context, scopeField string, scope func(meEnvVarRequest) string,
) (meEnvVarRequest, bool) {
var req meEnvVarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return req, false
}
if scope(req) == "" || req.Name == "" {
c.Error(apperrors.NewBadRequestError(scopeField + " and name are required"))
return req, false
}
return req, true
}
+227
View File
@@ -0,0 +1,227 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
)
type fakeMeEnvVarService struct {
groups []service.ConfigEnvGroup
listErr error
setErr error
delErr error
setScope, setName, setValue string
delScope, delName string
}
func (f *fakeMeEnvVarService) ListMine(context.Context) ([]service.ConfigEnvGroup, error) {
return f.groups, f.listErr
}
func (f *fakeMeEnvVarService) SetMineSkill(_ context.Context, skillID, name, value string) error {
f.setScope, f.setName, f.setValue = skillID, name, value
return f.setErr
}
func (f *fakeMeEnvVarService) DeleteMineSkill(_ context.Context, skillID, name string) error {
f.delScope, f.delName = skillID, name
return f.delErr
}
func (f *fakeMeEnvVarService) SetMineSandbox(_ context.Context, configID, name, value string) error {
f.setScope, f.setName, f.setValue = configID, name, value
return f.setErr
}
func (f *fakeMeEnvVarService) DeleteMineSandbox(_ context.Context, configID, name string) error {
f.delScope, f.delName = configID, name
return f.delErr
}
func newMeEnvVarRouter(svc meEnvVarService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &MeEnvVarHandler{service: svc}
r.GET("/me/env-vars", h.List)
r.PUT("/me/env-vars/skill", h.SetSkill)
r.DELETE("/me/env-vars/skill", h.DeleteSkill)
r.PUT("/me/env-vars/sandbox", h.SetSandbox)
r.DELETE("/me/env-vars/sandbox", h.DeleteSandbox)
return r
}
func envVarRequest(method, path, body string) *http.Request {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
// The principal is derived from the context, so the request type must have no
// field that could name one — absent rather than ignored, because an ignored
// field is one refactor away from being honoured.
func TestMeEnvVarRequestCannotCarryAPrincipal(t *testing.T) {
rt := reflect.TypeOf(meEnvVarRequest{})
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
lower := strings.ToLower(field.Name + " " + field.Tag.Get("json"))
for _, forbidden := range []string{"principal", "user"} {
require.NotContains(t, lower, forbidden,
"field %s would let a request choose whose values are touched", field.Name)
}
}
raw, err := json.Marshal(meEnvVarRequest{})
require.NoError(t, err)
require.NotContains(t, strings.ToLower(string(raw)), "principal")
}
// A body that tries to name somebody else must simply not bind: the extra keys
// are dropped and the handler still passes only what the type carries.
func TestMeEnvVarSetIgnoresPrincipalFieldsInTheBody(t *testing.T) {
svc := &fakeMeEnvVarService{}
router := newMeEnvVarRouter(svc)
w := httptest.NewRecorder()
router.ServeHTTP(w, envVarRequest(http.MethodPut, "/me/env-vars/skill",
`{"skill_id":"sk-1","name":"API_TOKEN","value":"mine",`+
`"principal_id":"bob","principal_type":"web_user","user_id":"bob"}`))
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "sk-1", svc.setScope)
require.Equal(t, "API_TOKEN", svc.setName)
require.Equal(t, "mine", svc.setValue)
}
func TestEnvVarGroupJSONCarriesNoSecrets(t *testing.T) {
updated := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)
svc := &fakeMeEnvVarService{groups: []service.ConfigEnvGroup{{
SandboxConfigID: "cfg-a",
SandboxConfigName: "Production",
Vars: []service.EnvVarView{
{Name: "HTTP_PROXY", Source: service.EnvSourceUser, UpdatedAt: &updated},
},
Skills: []service.SkillEnvGroup{{
SkillID: "sk-1",
SkillName: "pdf-tools",
Vars: []service.EnvVarView{
{Name: "API_TOKEN", Description: "the workspace token", Required: true,
Source: service.EnvSourceWorkspace},
{Name: "USER_TOKEN", Source: service.EnvSourceUser, UpdatedAt: &updated},
{Name: "REGION", Source: service.EnvSourceUnset},
},
}},
}}}
router := newMeEnvVarRouter(svc)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/me/env-vars", nil))
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
for _, forbidden := range []string{
`"value"`, "instructions", "bundle_ref", "installed_snapshot_id",
`"status"`, `"error"`,
} {
require.NotContains(t, body, forbidden)
}
require.Contains(t, body, `"sandbox_config_name":"Production"`)
require.Contains(t, body, `"skill_name":"pdf-tools"`)
require.Contains(t, body, `"source":"workspace"`)
require.Contains(t, body, `"source":"user"`)
require.Contains(t, body, `"source":"unset"`)
}
func TestMeEnvVarWritesRejectAnIncompleteBody(t *testing.T) {
cases := []struct{ path, body string }{
{"/me/env-vars/skill", `{}`},
{"/me/env-vars/skill", `{"skill_id":"sk-1"}`},
{"/me/env-vars/skill", `{"name":"API_TOKEN"}`},
// A skill id on the sandbox route names no config.
{"/me/env-vars/sandbox", `{"skill_id":"sk-1","name":"API_TOKEN"}`},
{"/me/env-vars/sandbox", `{"sandbox_config_id":"cfg-a"}`},
}
for _, tc := range cases {
t.Run(tc.path+tc.body, func(t *testing.T) {
svc := &fakeMeEnvVarService{}
w := httptest.NewRecorder()
newMeEnvVarRouter(svc).ServeHTTP(w, envVarRequest(http.MethodPut, tc.path, tc.body))
require.Equal(t, http.StatusBadRequest, w.Code)
require.Empty(t, svc.setScope)
})
}
}
// A refused name is the caller's mistake, and the service says so with an
// AppError; the handler must not turn that into a 500.
func TestMeEnvVarSetMapsAServiceRefusalTo400(t *testing.T) {
svc := &fakeMeEnvVarService{
setErr: apperrors.NewBadRequestError(`environment variable name "PATH" is reserved`),
}
w := httptest.NewRecorder()
newMeEnvVarRouter(svc).ServeHTTP(w, envVarRequest(http.MethodPut, "/me/env-vars/sandbox",
`{"sandbox_config_id":"cfg-a","name":"PATH","value":"x"}`))
require.Equal(t, http.StatusBadRequest, w.Code)
}
// A repository failure is not the caller's mistake. Reporting it as 400 would
// both mislead and hand an internal message to any logged-in member.
func TestMeEnvVarSetDoesNotReportAServerFailureAs400(t *testing.T) {
svc := &fakeMeEnvVarService{setErr: errors.New("dial tcp 10.0.0.5:5432: connect: refused")}
w := httptest.NewRecorder()
newMeEnvVarRouter(svc).ServeHTTP(w, envVarRequest(http.MethodPut, "/me/env-vars/sandbox",
`{"sandbox_config_id":"cfg-a","name":"HTTP_PROXY","value":"x"}`))
require.Equal(t, http.StatusInternalServerError, w.Code)
require.NotContains(t, w.Body.String(), "10.0.0.5")
}
func TestMeEnvVarDeleteMapsNothingToDeleteTo404(t *testing.T) {
for _, path := range []string{"/me/env-vars/skill", "/me/env-vars/sandbox"} {
t.Run(path, func(t *testing.T) {
svc := &fakeMeEnvVarService{delErr: types.ErrEnvVarNotFound}
body := `{"skill_id":"sk-1","sandbox_config_id":"cfg-a","name":"API_TOKEN"}`
w := httptest.NewRecorder()
newMeEnvVarRouter(svc).ServeHTTP(w, envVarRequest(http.MethodDelete, path, body))
require.Equal(t, http.StatusNotFound, w.Code)
require.Equal(t, "API_TOKEN", svc.delName)
})
}
}
func TestMeEnvVarDeleteRoutesToTheRequestedScope(t *testing.T) {
body := `{"skill_id":"sk-1","sandbox_config_id":"cfg-a","name":"API_TOKEN"}`
skillSvc := &fakeMeEnvVarService{}
w := httptest.NewRecorder()
newMeEnvVarRouter(skillSvc).ServeHTTP(w,
envVarRequest(http.MethodDelete, "/me/env-vars/skill", body))
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "sk-1", skillSvc.delScope)
sandboxSvc := &fakeMeEnvVarService{}
w = httptest.NewRecorder()
newMeEnvVarRouter(sandboxSvc).ServeHTTP(w,
envVarRequest(http.MethodDelete, "/me/env-vars/sandbox", body))
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "cfg-a", sandboxSvc.delScope)
}
+472
View File
@@ -0,0 +1,472 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service/memory"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// MemoryHandler exposes the caller's own long-term memory.
//
// Every route operates on the memory space derived from the request context,
// so no endpoint takes a subject id. That is deliberate: it removes the entire
// class of "can I read another user's memories by changing an id" bugs instead
// of relying on a per-route ownership check.
type MemoryHandler struct {
memoryService interfaces.MemoryService
}
func NewMemoryHandler(memoryService interfaces.MemoryService) *MemoryHandler {
return &MemoryHandler{memoryService: memoryService}
}
// GetSettings godoc
// @Summary 获取我的记忆设置
// @Description 返回合并后的记忆开关状态(空间级 + 个人级)与记忆条数
// @Tags 长期记忆
// @Produce json
// @Success 200 {object} map[string]interface{} "记忆设置"
// @Security Bearer
// @Router /memory/settings [get]
func (h *MemoryHandler) GetSettings(c *gin.Context) {
ctx := c.Request.Context()
settings, err := h.memoryService.GetSettings(ctx)
if err != nil {
h.fail(c, err, "Failed to load memory settings")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": settings})
}
type updateMemorySettingsRequest struct {
Enabled *bool `json:"enabled"`
}
// RetryExtraction renews the authenticated subject's exhausted budget only.
func (h *MemoryHandler) RetryExtraction(c *gin.Context) {
if err := h.memoryService.RetryExtraction(c.Request.Context()); err != nil {
h.fail(c, err, "Failed to retry memory extraction")
return
}
h.GetSettings(c)
}
// UpdateSettings godoc
// @Summary 更新我的记忆设置
// @Description 开启或关闭当前用户自己的长期记忆
// @Tags 长期记忆
// @Accept json
// @Produce json
// @Param request body object true "设置"
// @Success 200 {object} map[string]interface{} "更新后的设置"
// @Security Bearer
// @Router /memory/settings [put]
func (h *MemoryHandler) UpdateSettings(c *gin.Context) {
ctx := c.Request.Context()
var req updateMemorySettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
if req.Enabled == nil {
c.Error(apperrors.NewBadRequestError("enabled is required"))
return
}
if err := h.memoryService.SetEnabled(ctx, *req.Enabled); err != nil {
h.fail(c, err, "Failed to update memory settings")
return
}
settings, err := h.memoryService.GetSettings(ctx)
if err != nil {
h.fail(c, err, "Failed to load memory settings")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": settings})
}
// ListItems godoc
// @Summary 列出我的记忆
// @Description 分页返回当前用户的记忆条目,可按状态过滤
// @Tags 长期记忆
// @Produce json
// @Param status query string false "状态过滤" Enums(active, superseded, archived, pending)
// @Param limit query int false "每页条数" default(50)
// @Param offset query int false "偏移量"
// @Success 200 {object} map[string]interface{} "记忆列表"
// @Security Bearer
// @Router /memory/items [get]
func (h *MemoryHandler) ListItems(c *gin.Context) {
ctx := c.Request.Context()
status := c.Query("status")
switch status {
case "", types.MemoryStatusActive, types.MemoryStatusSuperseded,
types.MemoryStatusArchived, types.MemoryStatusPending:
default:
c.Error(apperrors.NewBadRequestError("unsupported status"))
return
}
limit, offset := memoryListPaging(c)
items, total, err := h.memoryService.ListItems(ctx, status, limit, offset)
if err != nil {
h.fail(c, err, "Failed to list memories")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": items,
"total": total,
})
}
const (
// memoryExportPageSize is how many rows one export page reads.
memoryExportPageSize = 500
// memoryExportMaxItems bounds a single export so one enormous store cannot
// turn a download into an unbounded read.
memoryExportMaxItems = 20000
)
func memoryListPaging(c *gin.Context) (limit, offset int) {
limit, _ = strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ = strconv.Atoi(c.DefaultQuery("offset", "0"))
if offset < 0 {
offset = 0
}
return limit, offset
}
// ListTopics godoc
// @Summary 列出正在观察的主题
// @Description 返回已计数、尚未提升为长期关注的主题,以及距离阈值还差几次
// @Tags 长期记忆
// @Produce json
// @Param limit query int false "每页条数" default(50)
// @Param offset query int false "偏移量"
// @Success 200 {object} map[string]interface{} "主题列表"
// @Security Bearer
// @Router /memory/topics [get]
func (h *MemoryHandler) ListTopics(c *gin.Context) {
ctx := c.Request.Context()
limit, offset := memoryListPaging(c)
topics, total, err := h.memoryService.ListTopics(ctx, limit, offset)
if err != nil {
h.fail(c, err, "Failed to list topics")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": topics,
"total": total,
})
}
// PromoteTopic godoc
// @Summary 立即记为长期关注
// @Description 不等待剩余次数,把正在观察的主题提升为一条长期关注记忆
// @Tags 长期记忆
// @Produce json
// @Param id path string true "主题 ID"
// @Success 200 {object} map[string]interface{} "新增的记忆"
// @Security Bearer
// @Router /memory/topics/{id}/promote [post]
func (h *MemoryHandler) PromoteTopic(c *gin.Context) {
ctx := c.Request.Context()
item, err := h.memoryService.PromoteTopic(ctx, c.Param("id"))
if err != nil {
h.fail(c, err, "Failed to promote topic")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
// DeleteTopic godoc
// @Summary 停止跟踪一个主题
// @Description 删除尚未提升的主题计数,并记住这次拒绝,之后不会再自动记为长期关注
// @Tags 长期记忆
// @Produce json
// @Param id path string true "主题 ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Security Bearer
// @Router /memory/topics/{id} [delete]
func (h *MemoryHandler) DeleteTopic(c *gin.Context) {
ctx := c.Request.Context()
if err := h.memoryService.DeleteTopic(ctx, c.Param("id")); err != nil {
h.fail(c, err, "Failed to delete topic")
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ListDocuments godoc
// @Summary 列出常用资料
// @Description 返回当前用户回答里反复引用的文档,次数未达习惯门槛的不展示
// @Tags 长期记忆
// @Produce json
// @Param limit query int false "每页条数" default(50)
// @Param offset query int false "偏移量"
// @Success 200 {object} map[string]interface{} "文档列表"
// @Security Bearer
// @Router /memory/documents [get]
func (h *MemoryHandler) ListDocuments(c *gin.Context) {
ctx := c.Request.Context()
limit, offset := memoryListPaging(c)
docs, total, err := h.memoryService.ListDocuments(ctx, limit, offset)
if err != nil {
h.fail(c, err, "Failed to list documents")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": docs,
"total": total,
})
}
// DeleteDocument godoc
// @Summary 停止用某份文档做个性化检索
// @Description 删除一条文档亲和度计数,之后检索不再因为这份文档而加权
// @Tags 长期记忆
// @Produce json
// @Param id path string true "亲和度 ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Security Bearer
// @Router /memory/documents/{id} [delete]
func (h *MemoryHandler) DeleteDocument(c *gin.Context) {
ctx := c.Request.Context()
if err := h.memoryService.DeleteDocument(ctx, c.Param("id")); err != nil {
h.fail(c, err, "Failed to delete document affinity")
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type createMemoryItemRequest struct {
Kind string `json:"kind"`
Content string `json:"content"`
Importance int `json:"importance"`
}
// CreateItem godoc
// @Summary 新增一条记忆
// @Description 手动添加一条长期记忆
// @Tags 长期记忆
// @Accept json
// @Produce json
// @Param request body object true "记忆内容"
// @Success 200 {object} map[string]interface{} "新增的记忆"
// @Security Bearer
// @Router /memory/items [post]
func (h *MemoryHandler) CreateItem(c *gin.Context) {
ctx := c.Request.Context()
var req createMemoryItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
item, err := h.memoryService.CreateItem(ctx, req.Kind, req.Content, req.Importance)
if err != nil {
h.fail(c, err, "Failed to create memory")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
type updateMemoryItemRequest struct {
Content string `json:"content"`
Importance int `json:"importance"`
}
// UpdateItem godoc
// @Summary 修改一条记忆
// @Description 修改记忆内容与重要度,修改后该条记忆不会被后台抽取覆盖
// @Tags 长期记忆
// @Accept json
// @Produce json
// @Param id path string true "记忆ID"
// @Param request body object true "记忆内容"
// @Success 200 {object} map[string]interface{} "更新后的记忆"
// @Security Bearer
// @Router /memory/items/{id} [put]
func (h *MemoryHandler) UpdateItem(c *gin.Context) {
ctx := c.Request.Context()
var req updateMemoryItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
item, err := h.memoryService.UpdateItem(ctx, c.Param("id"), req.Content, req.Importance)
if err != nil {
h.fail(c, err, "Failed to update memory")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
// DeleteItem godoc
// @Summary 删除一条记忆
// @Description 永久删除一条记忆
// @Tags 长期记忆
// @Produce json
// @Param id path string true "记忆ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Security Bearer
// @Router /memory/items/{id} [delete]
func (h *MemoryHandler) DeleteItem(c *gin.Context) {
ctx := c.Request.Context()
if err := h.memoryService.DeleteItem(ctx, c.Param("id")); err != nil {
h.fail(c, err, "Failed to delete memory")
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ConfirmItem godoc
// @Summary 确认一条推断出的记忆
// @Description 接受系统推断的记忆,使其开始生效
// @Tags 长期记忆
// @Produce json
// @Param id path string true "记忆 ID"
// @Success 200 {object} map[string]interface{} "确认成功"
// @Security Bearer
// @Router /memory/items/{id}/confirm [post]
//
// Inferred memories are the ones worth having and the ones most likely to be
// wrong, so they wait here rather than taking effect silently.
func (h *MemoryHandler) ConfirmItem(c *gin.Context) {
ctx := c.Request.Context()
item, err := h.memoryService.ConfirmItem(ctx, c.Param("id"))
if err != nil {
h.fail(c, err, "Failed to confirm memory")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
// RejectItem godoc
// @Summary 否决一条推断出的记忆
// @Description 拒绝系统推断的记忆,并记住这次拒绝
// @Tags 长期记忆
// @Produce json
// @Param id path string true "记忆 ID"
// @Success 200 {object} map[string]interface{} "否决成功"
// @Security Bearer
// @Router /memory/items/{id}/reject [post]
func (h *MemoryHandler) RejectItem(c *gin.Context) {
ctx := c.Request.Context()
if err := h.memoryService.RejectItem(ctx, c.Param("id")); err != nil {
h.fail(c, err, "Failed to reject memory")
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// Clear godoc
// @Summary 清空我的记忆
// @Description 永久删除当前用户的全部记忆
// @Tags 长期记忆
// @Produce json
// @Success 200 {object} map[string]interface{} "清空成功"
// @Security Bearer
// @Router /memory/items [delete]
func (h *MemoryHandler) Clear(c *gin.Context) {
ctx := c.Request.Context()
removed, err := h.memoryService.Clear(ctx)
if err != nil {
h.fail(c, err, "Failed to clear memories")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "removed": removed})
}
// Export godoc
// @Summary 导出我的记忆
// @Description 以 JSON 导出当前用户的全部记忆
// @Tags 长期记忆
// @Produce json
// @Success 200 {object} map[string]interface{} "记忆导出"
// @Security Bearer
// @Router /memory/export [get]
func (h *MemoryHandler) Export(c *gin.Context) {
ctx := c.Request.Context()
// Export is a snapshot, not a page, so it walks every status to the end.
//
// A single fixed page used to serve this on the grounds that it matched the
// largest capacity a workspace can configure. It does not: max_items caps
// active memories only, while superseded and archived rows accumulate
// without limit, so a long-lived store holds far more than its capacity and
// the export quietly returned a prefix of it.
var items []*types.MemoryItem
var total int64
for {
page, pageTotal, err := h.memoryService.ListItems(ctx, "", memoryExportPageSize, len(items))
if err != nil {
h.fail(c, err, "Failed to export memories")
return
}
total = pageTotal
items = append(items, page...)
if len(page) < memoryExportPageSize || int64(len(items)) >= total {
break
}
if len(items) >= memoryExportMaxItems {
break
}
}
c.Header("Content-Disposition", `attachment; filename="weknora-memories.json"`)
c.JSON(http.StatusOK, gin.H{
"success": true,
"total": total,
// Say so rather than letting a partial file look complete. Only the
// safety ceiling can trigger this, so it stays false in practice.
"truncated": int64(len(items)) < total,
"data": items,
})
}
// Consolidate godoc
// @Summary 立刻整理我的记忆
// @Description 合并意思接近的条目、归档到期事项,不等待每日后台整理
// @Tags 长期记忆
// @Produce json
// @Success 200 {object} map[string]interface{} "整理结果"
// @Security Bearer
// @Router /memory/consolidate [post]
func (h *MemoryHandler) Consolidate(c *gin.Context) {
ctx := c.Request.Context()
result, err := h.memoryService.ConsolidateNow(ctx)
if err != nil {
h.fail(c, err, "Failed to consolidate memories")
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// fail maps service errors onto HTTP responses. A missing item and an item
// belonging to someone else produce the same 404 on purpose.
func (h *MemoryHandler) fail(c *gin.Context, err error, message string) {
switch {
case errors.Is(err, memory.ErrNoMemoryScope):
c.Error(apperrors.NewUnauthorizedError("no principal in request"))
case errors.Is(err, memory.ErrItemNotFound):
c.Error(apperrors.NewNotFoundError("memory not found"))
case errors.Is(err, memory.ErrItemNotPending):
c.Error(apperrors.NewBadRequestError("memory is not awaiting confirmation"))
case errors.Is(err, memory.ErrMemoryDisabled):
c.Error(apperrors.NewBadRequestError("memory is disabled"))
default:
logger.ErrorWithFields(c.Request.Context(), err, nil)
c.Error(apperrors.NewInternalServerError(message).WithDetails(err.Error()))
}
}
+346
View File
@@ -0,0 +1,346 @@
package handler
import (
stderrors "errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/storageurl"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// MessageHandler handles HTTP requests related to messages within chat sessions
// It provides endpoints for loading and managing message history
type MessageHandler struct {
MessageService interfaces.MessageService // Service that implements message business logic
// FileService and StorageResolver back the optional `resource_urls=public`
// mode, which returns loadable HTTP URLs instead of internal
// `resource://` handles. Both may be nil, in which case only the default
// handle mode is available.
FileService interfaces.FileService
StorageResolver interfaces.StorageBackendResolver
}
// NewMessageHandler creates a new message handler instance with the required service
// Parameters:
// - messageService: Service that implements message business logic
// - fileService: Storage access used to sign public resource URLs
// - storageResolver: Resolves per-tenant storage backends for those URLs
//
// Returns a pointer to a new MessageHandler
func NewMessageHandler(
messageService interfaces.MessageService,
fileService interfaces.FileService,
storageResolver interfaces.StorageBackendResolver,
) *MessageHandler {
return &MessageHandler{
MessageService: messageService,
FileService: fileService,
StorageResolver: storageResolver,
}
}
// resolveResourceRewriter builds the storage-reference rewriter for one response
// from the request's `resource_urls` parameter, falling back to the deployment
// default. The returned error is already an AppError the caller can hand to
// c.Error: a rejected scope is a 403, a typo in the parameter is a 400.
func (h *MessageHandler) resolveResourceRewriter(c *gin.Context) (*storageurl.Rewriter, error) {
ctx := c.Request.Context()
mode, err := storageurl.ResolveMode(ctx, c.Query(storageurl.QueryParam))
if err != nil {
if stderrors.Is(err, storageurl.ErrPublicModeForbidden) {
return nil, errors.NewForbiddenError(err.Error())
}
return nil, errors.NewBadRequestError(err.Error())
}
return storageurl.NewRequestRewriter(ctx, mode, h.FileService, h.StorageResolver), nil
}
// LoadMessages godoc
// @Summary 加载消息历史
// @Description 加载会话的消息历史,支持分页和时间筛选
// @Tags 消息
// @Accept json
// @Produce json
// @Param session_id path string true "会话ID"
// @Param limit query int false "返回数量" default(20)
// @Param before_time query string false "在此时间之前的消息(RFC3339Nano格式)"
// @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle)
// @Success 200 {object} map[string]interface{} "消息列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /messages/{session_id}/load [get]
func (h *MessageHandler) LoadMessages(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start loading messages")
// Get path parameters and query parameters
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
limit := secutils.SanitizeForLog(c.DefaultQuery("limit", "20"))
beforeTimeStr := secutils.SanitizeForLog(c.DefaultQuery("before_time", ""))
logger.Infof(ctx, "Loading messages params, session ID: %s, limit: %s, before time: %s",
sessionID, limit, beforeTimeStr)
rewriter, err := h.resolveResourceRewriter(c)
if err != nil {
logger.Warnf(ctx, "Rejected resource URL mode: %v", err)
_ = c.Error(err)
return
}
// Parse limit parameter with fallback to default
limitInt, convErr := strconv.Atoi(limit)
if convErr != nil {
logger.Warnf(ctx, "Invalid limit value, using default value 20, input: %s", limit)
limitInt = 20
}
// If no beforeTime is provided, retrieve the most recent messages
if beforeTimeStr == "" {
logger.Infof(ctx, "Getting recent messages for session, session ID: %s, limit: %d", sessionID, limitInt)
messages, err := h.MessageService.GetRecentMessagesBySession(ctx, sessionID, limitInt)
if err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
// PR #1309 plumbed user-scope into the message service's
// session existence check; non-owner / wrong-tenant lookups
// surface as ErrSessionNotFound. Map to 404 so clients can
// tell "wrong URL" from a real 5xx.
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Successfully retrieved recent messages, session ID: %s, message count: %d",
sessionID, len(messages),
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": rewriter.RewriteMessagesResponse(ctx, messages),
})
return
}
// If beforeTime is provided, parse the timestamp (RFC3339Nano or RFC3339).
beforeTime, err := parseMessageBeforeTime(beforeTimeStr)
if err != nil {
logger.Errorf(
ctx,
"Invalid time format, please use RFC3339/RFC3339Nano format, err: %v, beforeTimeStr: %s",
err, beforeTimeStr,
)
c.Error(errors.NewBadRequestError("Invalid time format, please use RFC3339 or RFC3339Nano format"))
return
}
// Retrieve messages before the specified timestamp
logger.Infof(ctx, "Getting messages before specific time, session ID: %s, before time: %s, limit: %d",
sessionID, beforeTime.Format(time.RFC3339Nano), limitInt)
messages, err := h.MessageService.GetMessagesBySessionBeforeTime(ctx, sessionID, beforeTime, limitInt)
if err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
// See note on the GetRecentMessagesBySession path above.
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Successfully retrieved messages before time, session ID: %s, message count: %d",
sessionID, len(messages),
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": rewriter.RewriteMessagesResponse(ctx, messages),
})
}
// DeleteMessage godoc
// @Summary 删除消息
// @Description 从会话中删除指定消息
// @Tags 消息
// @Accept json
// @Produce json
// @Param session_id path string true "会话ID"
// @Param id path string true "消息ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /messages/{session_id}/{id} [delete]
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting message")
// Get path parameters for session and message identification
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
messageID := secutils.SanitizeForLog(c.Param("id"))
logger.Infof(ctx, "Deleting message, session ID: %s, message ID: %s", sessionID, messageID)
// Delete the message using the message service
if err := h.MessageService.DeleteMessage(ctx, sessionID, messageID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
// See note on LoadMessages above — message-service operations
// surface ErrSessionNotFound when the caller can't see the
// owning session (post-#1309 user scope).
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
if stderrors.Is(err, gorm.ErrRecordNotFound) {
// The message_id doesn't exist under this session — a client error,
// not a server fault. 404 so callers read resource.not_found (a
// permanent condition, not retryable) instead of a 5xx. Mirrors the
// ContinueStream / kb / doc / chunk not-found handling.
logger.Warnf(ctx, "Message not found, session ID: %s, message ID: %s", sessionID, messageID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Message deleted successfully, session ID: %s, message ID: %s", sessionID, messageID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Message deleted successfully",
})
}
// SearchMessages godoc
// @Summary 搜索历史对话
// @Description 通过关键词和/或向量相似度搜索历史对话记录,支持关键词、向量、混合三种模式
// @Tags 消息
// @Accept json
// @Produce json
// @Param request body SearchMessagesRequest true "搜索请求"
// @Success 200 {object} map[string]interface{} "搜索结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /messages/search [post]
func (h *MessageHandler) SearchMessages(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start searching messages")
var request SearchMessagesRequest
if err := c.ShouldBindJSON(&request); err != nil {
logger.Error(ctx, "Failed to parse search request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if request.Query == "" {
logger.Error(ctx, "Query content is empty")
c.Error(errors.NewBadRequestError("Query content cannot be empty"))
return
}
params := &types.MessageSearchParams{
Query: secutils.SanitizeForLog(request.Query),
Mode: types.MessageSearchMode(request.Mode),
Limit: request.Limit,
SessionIDs: request.SessionIDs,
}
logger.Infof(ctx, "Searching messages with params: query=%s, mode=%s, limit=%d, session_ids=%v",
params.Query, params.Mode, params.Limit, params.SessionIDs)
result, err := h.MessageService.SearchMessages(ctx, params)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Message search completed, found %d results", result.Total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
// SearchMessagesRequest defines the request structure for searching messages
type SearchMessagesRequest struct {
// Query text for search
Query string `json:"query" binding:"required"`
// Search mode: "keyword", "vector", "hybrid" (default: "hybrid")
Mode string `json:"mode"`
// Maximum number of results to return (default: 20)
Limit int `json:"limit"`
// Filter by specific session IDs (optional)
SessionIDs []string `json:"session_ids"`
}
// GetChatHistoryKBStats godoc
// @Summary 获取聊天历史知识库统计
// @Description 获取聊天历史知识库的统计信息(已索引消息数、知识库大小等)
// @Tags 消息
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "统计信息"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /messages/chat-history-stats [get]
func (h *MessageHandler) GetChatHistoryKBStats(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Getting chat history KB stats")
stats, err := h.MessageService.GetChatHistoryKBStats(ctx)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// parseMessageBeforeTime parses the `before_time` query used by LoadMessages.
// Frontend cursors may be RFC3339 (no fractional seconds) or RFC3339Nano.
func parseMessageBeforeTime(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, stderrors.New("empty before_time")
}
layouts := []string{time.RFC3339Nano, time.RFC3339}
var lastErr error
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t, nil
} else {
lastErr = err
}
}
return time.Time{}, lastErr
}
@@ -0,0 +1,168 @@
package handler
import (
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// stubResourceFileService resolves any storage reference to one fixed public URL.
type stubResourceFileService struct {
interfaces.FileService
url string
}
func (s *stubResourceFileService) GetFileURL(context.Context, string) (string, error) {
return s.url, nil
}
func (s *stubResourceFileService) SaveFile(
context.Context, *multipart.FileHeader, uint64, string,
) (string, error) {
return "", nil
}
func (s *stubResourceFileService) GetFile(context.Context, string) (io.ReadCloser, error) {
return nil, nil
}
const testResourceHandle = "resource://xifDo7NTSL300Lp1goVutw"
func newResourceURLTestRouter(t *testing.T, messages []*types.Message) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &MessageHandler{
MessageService: &stubMessageService{
getRecent: func(context.Context, string, int) ([]*types.Message, error) {
return messages, nil
},
},
FileService: &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
}
r.GET("/messages/:session_id/load", h.LoadMessages)
return r
}
func loadMessageContent(t *testing.T, router *gin.Engine, query string) string {
t.Helper()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/messages/sess1/load"+query, nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var body struct {
Data []struct {
Content string `json:"content"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Len(t, body.Data, 1)
return body.Data[0].Content
}
// Without the parameter, history must keep returning handles so existing clients
// (including the WeKnora frontend, which proxies through /files) are unaffected.
func TestLoadMessages_DefaultsToHandles(t *testing.T) {
router := newResourceURLTestRouter(t, []*types.Message{
{Content: "see ![fig](" + testResourceHandle + ")"},
})
assert.Equal(t, "see ![fig]("+testResourceHandle+")", loadMessageContent(t, router, ""))
}
func TestLoadMessages_PublicModeReturnsLoadableURLs(t *testing.T) {
router := newResourceURLTestRouter(t, []*types.Message{
{
Content: "see ![fig](" + testResourceHandle + ")",
Images: types.MessageImages{{URL: testResourceHandle}},
},
})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/messages/sess1/load?resource_urls=public", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
body := w.Body.String()
assert.Contains(t, body, "https://cdn.example.com/signed.png")
assert.NotContains(t, body, "resource://")
}
// A typo must be reported rather than silently falling back to handles, so an
// integrator finds the mistake instead of debugging "broken images".
func TestLoadMessages_RejectsInvalidResourceURLMode(t *testing.T) {
router := newResourceURLTestRouter(t, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(
http.MethodGet, "/messages/sess1/load?resource_urls=signed", nil))
assert.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), "resource_urls")
}
// The deployment-wide default lets an operator switch every integration over
// without changing client code.
func TestLoadMessages_DeploymentDefaultAppliesWithoutParameter(t *testing.T) {
t.Setenv("RESOURCE_URL_MODE", "public")
router := newResourceURLTestRouter(t, []*types.Message{
{Content: "see ![fig](" + testResourceHandle + ")"},
})
assert.Equal(t, "see ![fig](https://cdn.example.com/signed.png)",
loadMessageContent(t, router, ""))
}
// An explicit parameter must still win over the deployment default.
func TestLoadMessages_ParameterOverridesDeploymentDefault(t *testing.T) {
t.Setenv("RESOURCE_URL_MODE", "public")
router := newResourceURLTestRouter(t, []*types.Message{
{Content: "see ![fig](" + testResourceHandle + ")"},
})
assert.Equal(t, "see ![fig]("+testResourceHandle+")",
loadMessageContent(t, router, "?resource_urls=handle"))
}
// A knowledge-base-restricted API key is denied the /files proxy because a raw
// storage path cannot be bound to its allow-list. Handing it anonymous file URLs
// instead would reopen exactly that hole, so public mode is refused outright.
func TestLoadMessages_RejectsPublicModeForKBRestrictedAPIKey(t *testing.T) {
router := newResourceURLTestRouter(t, nil)
req := httptest.NewRequest(http.MethodGet, "/messages/sess1/load?resource_urls=public", nil)
req = req.WithContext(types.WithTenantAPIKeyScope(req.Context(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
KnowledgeBaseIDs: types.StringArray{"kb-1"},
}))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code, "body=%s", w.Body.String())
}
// A KB-restricted key keeps working in the default mode: only the public URLs
// are off limits, not the endpoint.
func TestLoadMessages_KBRestrictedAPIKeyStillReadsHandles(t *testing.T) {
router := newResourceURLTestRouter(t, []*types.Message{
{Content: "see ![fig](" + testResourceHandle + ")"},
})
req := httptest.NewRequest(http.MethodGet, "/messages/sess1/load", nil)
req = req.WithContext(types.WithTenantAPIKeyScope(req.Context(),
types.TenantAPIKeyScope{KnowledgeBaseIDs: types.StringArray{"kb-1"}}))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), "resource://")
}
@@ -0,0 +1,172 @@
package handler
import (
"context"
stderrors "errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// stubMessageService satisfies just the four MessageService methods this
// file exercises. Embedding the interface keeps the rest nil-panicky on
// purpose: any test that reaches an un-stubbed method should fail loudly
// rather than silently returning zero-values.
type stubMessageService struct {
interfaces.MessageService
getRecent func(ctx context.Context, sessionID string, limit int) ([]*types.Message, error)
getBeforeTime func(ctx context.Context, sessionID string, beforeTime time.Time, limit int) ([]*types.Message, error)
deleteMessage func(ctx context.Context, sessionID, id string) error
}
func (s *stubMessageService) GetRecentMessagesBySession(
ctx context.Context, sessionID string, limit int,
) ([]*types.Message, error) {
return s.getRecent(ctx, sessionID, limit)
}
func (s *stubMessageService) GetMessagesBySessionBeforeTime(
ctx context.Context, sessionID string, beforeTime time.Time, limit int,
) ([]*types.Message, error) {
return s.getBeforeTime(ctx, sessionID, beforeTime, limit)
}
func (s *stubMessageService) DeleteMessage(ctx context.Context, sessionID, id string) error {
return s.deleteMessage(ctx, sessionID, id)
}
// newMessageTestRouter mounts the standard ErrorHandler middleware so
// c.Error(NewNotFoundError(...)) renders as a real 404 envelope —
// matching production routing exactly.
func newMessageTestRouter(svc interfaces.MessageService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &MessageHandler{MessageService: svc}
r.GET("/messages/:session_id/load", h.LoadMessages)
r.DELETE("/messages/:session_id/:id", h.DeleteMessage)
return r
}
// TestLoadMessagesMapsErrSessionNotFoundToNotFound pins the fix for
// PR #1309's surfacing gap: when message-service ops can't see the
// owning session (non-owner / wrong tenant), the handler used to
// surface a 500 because ErrSessionNotFound flowed through the generic
// error branch. We now map it to 404 so clients can tell "wrong URL"
// from a real 5xx.
func TestLoadMessagesMapsErrSessionNotFoundToNotFound(t *testing.T) {
svc := &stubMessageService{
getRecent: func(_ context.Context, _ string, _ int) ([]*types.Message, error) {
return nil, apperrors.ErrSessionNotFound
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/messages/sess1/load?limit=20", nil)
newMessageTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("ErrSessionNotFound must map to 404, got %d body=%s", w.Code, w.Body.String())
}
}
// TestLoadMessagesBeforeTimeMapsErrSessionNotFoundToNotFound covers the
// second branch of LoadMessages (the `before_time` path), which has its
// own error-handling block.
func TestLoadMessagesBeforeTimeMapsErrSessionNotFoundToNotFound(t *testing.T) {
svc := &stubMessageService{
getBeforeTime: func(_ context.Context, _ string, _ time.Time, _ int) ([]*types.Message, error) {
return nil, apperrors.ErrSessionNotFound
},
}
w := httptest.NewRecorder()
url := "/messages/sess1/load?limit=20&before_time=" + time.Now().UTC().Format(time.RFC3339Nano)
req := httptest.NewRequest(http.MethodGet, url, nil)
newMessageTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("ErrSessionNotFound on before-time path must map to 404, got %d body=%s",
w.Code, w.Body.String())
}
}
// TestLoadMessagesAcceptsRFC3339BeforeTime ensures pagination cursors sent
// without fractional seconds (e.g. 2026-05-20T09:43:37+08:00) are accepted.
func TestLoadMessagesAcceptsRFC3339BeforeTime(t *testing.T) {
var capturedBefore time.Time
svc := &stubMessageService{
getBeforeTime: func(_ context.Context, _ string, beforeTime time.Time, _ int) ([]*types.Message, error) {
capturedBefore = beforeTime
return []*types.Message{}, nil
},
}
w := httptest.NewRecorder()
url := "/messages/sess1/load?limit=20&before_time=2026-05-20T09:43:37%2B08:00"
req := httptest.NewRequest(http.MethodGet, url, nil)
newMessageTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("RFC3339 before_time must be accepted, got %d body=%s", w.Code, w.Body.String())
}
expected, err := time.Parse(time.RFC3339, "2026-05-20T09:43:37+08:00")
if err != nil {
t.Fatal(err)
}
if !capturedBefore.Equal(expected) {
t.Fatalf("parsed before_time = %v, want %v", capturedBefore, expected)
}
}
// TestDeleteMessageMapsErrSessionNotFoundToNotFound mirrors the
// LoadMessages test for the delete endpoint.
func TestDeleteMessageMapsErrSessionNotFoundToNotFound(t *testing.T) {
svc := &stubMessageService{
deleteMessage: func(_ context.Context, _, _ string) error {
return apperrors.ErrSessionNotFound
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/messages/sess1/msg1", nil)
newMessageTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("ErrSessionNotFound on DeleteMessage must map to 404, got %d body=%s",
w.Code, w.Body.String())
}
}
// TestDeleteMessageHonoursWrappedErrSessionNotFound is the regression
// test against the stale `==` sentinel comparison the original PR used.
// errors.Is unwraps fmt.Errorf("%w", ...); a literal `==` does not. If
// anyone reverts the comparison, this test fails before the change ships.
func TestDeleteMessageHonoursWrappedErrSessionNotFound(t *testing.T) {
svc := &stubMessageService{
deleteMessage: func(_ context.Context, _, _ string) error {
return fmt.Errorf("delete: %w", apperrors.ErrSessionNotFound)
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/messages/sess1/msg1", nil)
newMessageTestRouter(svc).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("wrapped ErrSessionNotFound must still map to 404, got %d body=%s",
w.Code, w.Body.String())
}
// Defence-in-depth: the response body should expose the AppError
// envelope (ErrorHandler renders {success,error:{code,message}}),
// not the bare gin error string.
if !strings.Contains(w.Body.String(), `"code":1003`) {
t.Fatalf("expected NotFound envelope with code=1003, got body=%s", w.Body.String())
}
}
// guardAgainstStaleSentinelEquality is a compile-time assertion: if a
// future refactor accidentally drops the stderrors import, the test file
// stops compiling and the human gets a clear signal that something tied
// to errors.Is wrapping went away.
var _ = stderrors.Is
+155
View File
@@ -0,0 +1,155 @@
package handler
import (
"errors"
"net/http"
"strings"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type MessageSuggestionHandler struct {
service interfaces.MessageSuggestionService
}
func NewMessageSuggestionHandler(service interfaces.MessageSuggestionService) *MessageSuggestionHandler {
return &MessageSuggestionHandler{service: service}
}
type EnsureMessageSuggestionsRequest struct {
Regenerate bool `json:"regenerate"`
}
type SuggestionEventRequest struct {
SuggestionSetID string `json:"suggestion_set_id" binding:"required"`
QuestionID string `json:"question_id"`
EventType string `json:"event_type" binding:"required"`
}
// Ensure godoc
// @Summary 确保生成回答后推荐问题
// @Description 对已完成的助手消息异步生成或重新生成推荐问题;相同配置快照会复用持久化结果
// @Tags 会话
// @Accept json
// @Produce json
// @Param session_id path string true "会话 ID"
// @Param message_id path string true "助手消息 ID"
// @Param request body EnsureMessageSuggestionsRequest false "生成选项"
// @Success 200 {object} map[string]interface{}
// @Success 202 {object} map[string]interface{}
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/messages/{message_id}/suggestions [post]
func (h *MessageSuggestionHandler) Ensure(c *gin.Context) {
var request EnsureMessageSuggestionsRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&request); err != nil {
c.Error(apperrors.NewBadRequestError("invalid request body"))
return
}
}
set, err := h.service.EnsureFollowUps(
c.Request.Context(),
secutils.SanitizeForLog(c.Param("session_id")),
secutils.SanitizeForLog(c.Param("message_id")),
request.Regenerate,
)
if err != nil {
h.writeError(c, err)
return
}
status := http.StatusOK
if set != nil && set.Status == "generating" {
status = http.StatusAccepted
}
c.JSON(status, gin.H{"success": true, "data": set})
}
// Get godoc
// @Summary 获取回答后推荐问题
// @Tags 会话
// @Produce json
// @Param session_id path string true "会话 ID"
// @Param message_id path string true "助手消息 ID"
// @Success 200 {object} map[string]interface{}
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/messages/{message_id}/suggestions [get]
func (h *MessageSuggestionHandler) Get(c *gin.Context) {
set, err := h.service.GetFollowUps(
c.Request.Context(),
messageSuggestionSessionID(c),
secutils.SanitizeForLog(c.Param("message_id")),
)
if err != nil {
h.writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": set})
}
func messageSuggestionSessionID(c *gin.Context) string {
sessionID := c.Param("session_id")
if sessionID == "" {
sessionID = c.Param("id")
}
return secutils.SanitizeForLog(sessionID)
}
// RecordEvent godoc
// @Summary 上报推荐问题事件
// @Description 记录曝光、点击或关闭事件
// @Tags 会话
// @Accept json
// @Param session_id path string true "会话 ID"
// @Param request body SuggestionEventRequest true "事件"
// @Success 204
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/suggestion-events [post]
func (h *MessageSuggestionHandler) RecordEvent(c *gin.Context) {
var request SuggestionEventRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.Error(apperrors.NewBadRequestError("invalid request body"))
return
}
err := h.service.RecordEvent(
c.Request.Context(),
secutils.SanitizeForLog(c.Param("session_id")),
strings.TrimSpace(request.SuggestionSetID),
strings.TrimSpace(request.QuestionID),
strings.TrimSpace(request.EventType),
)
if err != nil {
h.writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *MessageSuggestionHandler) writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c.Error(apperrors.NewNotFoundError("suggestions not found"))
// apperrors.ErrSessionNotFound is a bare errors.New (not wrapping
// gorm.ErrRecordNotFound), so it needs its own branch to surface as a 404
// instead of falling through to a misleading 500.
case errors.Is(err, apperrors.ErrSessionNotFound):
c.Error(apperrors.NewNotFoundError("session not found"))
case strings.Contains(err.Error(), "completed assistant"):
c.Error(apperrors.NewBadRequestError(err.Error()))
case strings.Contains(err.Error(), "invalid suggestion event"),
strings.Contains(err.Error(), "requires question_id"),
strings.Contains(err.Error(), "does not belong"),
strings.Contains(err.Error(), "not allowed"):
c.Error(apperrors.NewBadRequestError(err.Error()))
default:
logger.Error(c.Request.Context(), "message suggestion operation failed", err)
c.Error(apperrors.NewInternalServerError("message suggestion operation failed"))
}
}
@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// When the suggestion pipeline bubbles up a session-not-found (e.g. an embed
// Viewer whose session read was rejected), the response must be a 404, not a
// misleading 500. apperrors.ErrSessionNotFound is a bare errors.New and so is
// not caught by the gorm.ErrRecordNotFound branch on its own.
func TestMessageSuggestionWriteErrorMapsSessionNotFoundTo404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewMessageSuggestionHandler(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
h.writeError(c, apperrors.ErrSessionNotFound)
require.Len(t, c.Errors, 1)
var appErr *apperrors.AppError
require.ErrorAs(t, c.Errors[0].Err, &appErr)
require.Equal(t, apperrors.ErrNotFound, appErr.Code)
require.Equal(t, http.StatusNotFound, appErr.HTTPCode)
}
+814
View File
@@ -0,0 +1,814 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// ModelHandler handles HTTP requests for model-related operations
// It implements the necessary methods to create, retrieve, update, and delete models
type ModelHandler struct {
service interfaces.ModelService
}
// NewModelHandler creates a new instance of ModelHandler
// It requires a model service implementation that handles business logic
// Parameters:
// - service: An implementation of the ModelService interface
//
// Returns a pointer to the newly created ModelHandler
func NewModelHandler(service interfaces.ModelService) *ModelHandler {
return &ModelHandler{service: service}
}
// Per-response redaction/stripping for Model now lives in
// dto.NewModelResponse — handlers must use it for every body that contains a
// model. The previous hideSensitiveInfo helper has been removed.
// CreateModelRequest defines the structure for model creation requests
// Contains all fields required to create a new model in the system
type CreateModelRequest struct {
Name string `json:"name" binding:"required"`
DisplayName string `json:"display_name"`
Type types.ModelType `json:"type" binding:"required"`
Source types.ModelSource `json:"source" binding:"required"`
Description string `json:"description"`
Parameters types.ModelParameters `json:"parameters" binding:"required"`
}
// CreateModel godoc
// @Summary 创建模型
// @Description 创建新的模型配置
// @Tags 模型管理
// @Accept json
// @Produce json
// @Param request body CreateModelRequest true "模型信息"
// @Success 201 {object} map[string]interface{} "创建的模型"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models [post]
func (h *ModelHandler) CreateModel(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating model")
var req CreateModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
logger.Infof(ctx, "Creating model, Tenant ID: %d, Model name: %s, Model type: %s",
tenantID, secutils.SanitizeForLog(req.Name), secutils.SanitizeForLog(string(req.Type)))
// SSRF validation for model BaseURL
if req.Parameters.BaseURL != "" {
if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil {
logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err)
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("Base URL", req.Parameters.BaseURL, err)))
return
}
}
model := &types.Model{
TenantID: tenantID,
Name: secutils.SanitizeForLog(req.Name),
DisplayName: secutils.SanitizeForLog(req.DisplayName),
Type: types.ModelType(secutils.SanitizeForLog(string(req.Type))),
Source: req.Source,
Description: secutils.SanitizeForLog(req.Description),
Parameters: req.Parameters,
}
if err := h.service.CreateModel(ctx, model); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Model created successfully, ID: %s, Name: %s",
secutils.SanitizeForLog(model.ID),
secutils.SanitizeForLog(model.Name),
)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": dto.NewModelResponse(ctx, model),
})
}
// GetModel godoc
// @Summary 获取模型详情
// @Description 根据ID获取模型详情
// @Tags 模型管理
// @Accept json
// @Produce json
// @Param id path string true "模型ID"
// @Success 200 {object} map[string]interface{} "模型详情"
// @Failure 404 {object} errors.AppError "模型不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models/{id} [get]
func (h *ModelHandler) GetModel(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving model")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Model ID is empty")
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
return
}
logger.Infof(ctx, "Retrieving model, ID: %s", id)
model, err := h.service.GetModelByID(ctx, id)
if err != nil {
if err == service.ErrModelNotFound {
logger.Warnf(ctx, "Model not found, ID: %s", id)
c.Error(errors.NewNotFoundError("Model not found"))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Retrieved model successfully, ID: %s, Name: %s", model.ID, model.Name)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewModelResponse(ctx, model),
})
}
// ListModels godoc
// @Summary 获取模型列表
// @Description 获取当前空间的所有模型
// @Tags 模型管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "模型列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models [get]
func (h *ModelHandler) ListModels(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving model list")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Tenant ID is empty")
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
models, err := h.service.ListModels(ctx)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Retrieved model list successfully, Tenant ID: %d, Total: %d models", tenantID, len(models))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewModelResponses(ctx, models),
})
}
const modelDebugMaxInputBytes = 64 * 1024
// ModelDebugOptions contains the cross-provider parameters exposed by the
// model debugger. Pointer fields preserve explicit zero/false values.
type ModelDebugOptions struct {
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Thinking *bool `json:"thinking,omitempty"`
}
func parseModelDebugOptions(raw string) (ModelDebugOptions, error) {
var opts ModelDebugOptions
if strings.TrimSpace(raw) == "" {
return opts, nil
}
if err := json.Unmarshal([]byte(raw), &opts); err != nil {
return opts, fmt.Errorf("invalid options: %w", err)
}
if opts.MaxTokens != nil && (*opts.MaxTokens < 1 || *opts.MaxTokens > 8192) {
return opts, fmt.Errorf("max_tokens must be between 1 and 8192")
}
if opts.Temperature != nil && (*opts.Temperature < 0 || *opts.Temperature > 2) {
return opts, fmt.Errorf("temperature must be between 0 and 2")
}
if opts.TopP != nil && (*opts.TopP <= 0 || *opts.TopP > 1) {
return opts, fmt.Errorf("top_p must be greater than 0 and at most 1")
}
return opts, nil
}
func redactedDebugConfig(config map[string]string) map[string]string {
if len(config) == 0 {
return nil
}
out := make(map[string]string, len(config))
for key, value := range config {
lower := strings.ToLower(key)
if strings.Contains(lower, "secret") ||
strings.Contains(lower, "token") ||
strings.Contains(lower, "password") ||
strings.Contains(lower, "api_key") ||
strings.Contains(lower, "apikey") ||
strings.Contains(lower, "authorization") {
out[key] = "[REDACTED]"
continue
}
out[key] = value
}
return out
}
func modelDebugRequestPreview(model *types.Model, input string, documents []string, opts ModelDebugOptions, fileName string, fileSize int64) gin.H {
preview := gin.H{
"model_id": model.ID,
"model_name": model.Name,
"model_type": model.Type,
"source": model.Source,
"provider": model.Parameters.Provider,
"input": input,
"options": opts,
}
if len(documents) > 0 {
preview["documents"] = documents
}
if fileName != "" {
preview["file"] = gin.H{"name": fileName, "size": fileSize}
}
if model.Parameters.ExtraConfig != nil {
preview["model_extra_config"] = redactedDebugConfig(model.Parameters.ExtraConfig)
}
if len(model.Parameters.CustomHeaders) > 0 {
headerNames := make([]string, 0, len(model.Parameters.CustomHeaders))
for name := range model.Parameters.CustomHeaders {
headerNames = append(headerNames, name)
}
preview["custom_header_names"] = headerNames
}
return preview
}
func writeModelDebugResult(c *gin.Context, started time.Time, request gin.H, response any, callErr error, observations gin.H) {
data := gin.H{
"ok": callErr == nil,
"elapsed_ms": time.Since(started).Milliseconds(),
"request": request,
"raw_response": response,
"observations": observations,
}
if callErr != nil {
data["error"] = callErr.Error()
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
type modelDebugChatStreamResponse struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []types.LLMToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Usage *types.TokenUsage `json:"usage,omitempty"`
StreamEvents []types.StreamResponse `json:"stream_events"`
}
func consumeModelDebugChatStream(stream <-chan types.StreamResponse) (*modelDebugChatStreamResponse, error) {
result := &modelDebugChatStreamResponse{
StreamEvents: make([]types.StreamResponse, 0),
}
for event := range stream {
result.StreamEvents = append(result.StreamEvents, event)
switch event.ResponseType {
case types.ResponseTypeThinking:
result.ReasoningContent += event.Content
case types.ResponseTypeAnswer:
result.Content += event.Content
if len(event.ToolCalls) > 0 {
result.ToolCalls = event.ToolCalls
}
if event.FinishReason != "" {
result.FinishReason = event.FinishReason
}
if event.Usage != nil {
result.Usage = event.Usage
}
case types.ResponseTypeToolCall:
if len(event.ToolCalls) > 0 {
result.ToolCalls = event.ToolCalls
}
case types.ResponseTypeError:
return result, fmt.Errorf("%s", event.Content)
}
}
return result, nil
}
// DebugModel executes a saved model through the same service constructors used
// by production calls and returns the complete normalized response. Credentials
// stay server-side; the request preview contains only non-secret fields.
func (h *ModelHandler) DebugModel(c *gin.Context) {
ctx := c.Request.Context()
started := time.Now()
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
return
}
model, err := h.service.GetModelByID(ctx, id)
if err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// The optional file makes this a multipart body, so the cap has to be in
// place before the first PostForm below — that call is what parses it, and
// parsing is what buffers the upload to disk. Answer an oversized body here
// too: the file is read with a `fileErr == nil` guard further down, which
// would otherwise report a rejected upload as one that was never sent.
limitUploadBody(c, secutils.GetMaxFileSize())
if _, formErr := c.MultipartForm(); formErr != nil && isRequestBodyTooLarge(formErr) {
c.Error(errors.NewBadRequestError(
fmt.Sprintf("file cannot exceed %d MB", secutils.GetMaxFileSizeMB())))
return
}
input := c.PostForm("input")
if len(input) > modelDebugMaxInputBytes {
c.Error(errors.NewBadRequestError("input is too long"))
return
}
opts, err := parseModelDebugOptions(c.PostForm("options"))
if err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
var documents []string
if rawDocuments := c.PostForm("documents"); strings.TrimSpace(rawDocuments) != "" {
if err := json.Unmarshal([]byte(rawDocuments), &documents); err != nil {
c.Error(errors.NewBadRequestError("documents must be a JSON string array"))
return
}
if len(documents) > 100 {
c.Error(errors.NewBadRequestError("documents cannot exceed 100 items"))
return
}
}
var (
fileBytes []byte
fileName string
fileSize int64
)
if file, header, fileErr := c.Request.FormFile("file"); fileErr == nil {
defer file.Close()
fileName = header.Filename
fileSize = header.Size
maxFileBytes := secutils.GetMaxFileSize()
maxFileSizeMB := secutils.GetMaxFileSizeMB()
if fileSize > maxFileBytes {
c.Error(errors.NewBadRequestError(fmt.Sprintf("file cannot exceed %d MB", maxFileSizeMB)))
return
}
fileBytes, err = io.ReadAll(io.LimitReader(file, maxFileBytes+1))
if err != nil {
c.Error(errors.NewBadRequestError("failed to read uploaded file"))
return
}
if int64(len(fileBytes)) > maxFileBytes {
c.Error(errors.NewBadRequestError(fmt.Sprintf("file cannot exceed %d MB", maxFileSizeMB)))
return
}
fileSize = int64(len(fileBytes))
}
requestPreview := modelDebugRequestPreview(model, input, documents, opts, fileName, fileSize)
observations := gin.H{}
switch model.Type {
case types.ModelTypeKnowledgeQA:
if strings.TrimSpace(input) == "" {
c.Error(errors.NewBadRequestError("query cannot be empty"))
return
}
instance, callErr := h.service.GetChatModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
messages := make([]chat.Message, 0, 2)
if strings.TrimSpace(opts.SystemPrompt) != "" {
messages = append(messages, chat.Message{Role: "system", Content: opts.SystemPrompt})
}
messages = append(messages, chat.Message{Role: "user", Content: input})
chatOpts := &chat.ChatOptions{}
if opts.Temperature != nil {
chatOpts.Temperature = *opts.Temperature
}
if opts.TopP != nil {
chatOpts.TopP = *opts.TopP
}
if opts.MaxTokens != nil {
chatOpts.MaxTokens = *opts.MaxTokens
}
chatOpts.Thinking = opts.Thinking
chatConfig := chat.ConfigFromModel(model, "", "")
thinkingControl := chat.EffectiveThinkingControl(chatConfig)
observations["stream"] = true
observations["requested_thinking"] = opts.Thinking != nil && *opts.Thinking
observations["thinking_control"] = thinkingControl
observations["thinking_parameter_sent"] = opts.Thinking != nil && thinkingControl != "none"
stream, callErr := instance.ChatStream(ctx, messages, chatOpts)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
resp, callErr := consumeModelDebugChatStream(stream)
if resp != nil {
observations["reasoning_returned"] = strings.TrimSpace(resp.ReasoningContent) != ""
observations["reasoning_characters"] = len([]rune(resp.ReasoningContent))
observations["answer_characters"] = len([]rune(resp.Content))
}
writeModelDebugResult(c, started, requestPreview, resp, callErr, observations)
case types.ModelTypeEmbedding:
if strings.TrimSpace(input) == "" {
c.Error(errors.NewBadRequestError("input cannot be empty"))
return
}
instance, callErr := h.service.GetEmbeddingModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
vector, callErr := instance.Embed(ctx, input)
observations["dimension"] = len(vector)
writeModelDebugResult(c, started, requestPreview, vector, callErr, observations)
case types.ModelTypeRerank:
if strings.TrimSpace(input) == "" || len(documents) == 0 {
c.Error(errors.NewBadRequestError("query and documents cannot be empty"))
return
}
instance, callErr := h.service.GetRerankModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
results, callErr := instance.Rerank(ctx, input, documents)
observations["result_count"] = len(results)
writeModelDebugResult(c, started, requestPreview, results, callErr, observations)
case types.ModelTypeVLLM:
if len(fileBytes) == 0 {
c.Error(errors.NewBadRequestError("image file is required"))
return
}
instance, callErr := h.service.GetVLMModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
result, callErr := instance.Predict(ctx, [][]byte{fileBytes}, input)
observations["answer_characters"] = len([]rune(result))
writeModelDebugResult(c, started, requestPreview, result, callErr, observations)
case types.ModelTypeASR:
if len(fileBytes) == 0 {
c.Error(errors.NewBadRequestError("audio file is required"))
return
}
instance, callErr := h.service.GetASRModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
result, callErr := instance.Transcribe(ctx, fileBytes, fileName)
if result != nil {
observations["text_characters"] = len([]rune(result.Text))
observations["segment_count"] = len(result.Segments)
}
writeModelDebugResult(c, started, requestPreview, result, callErr, observations)
default:
c.Error(errors.NewBadRequestError("unsupported model type"))
}
}
// UpdateModelRequest defines the structure for model update requests
// Contains fields that can be updated for an existing model
type UpdateModelRequest struct {
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Description string `json:"description"`
Parameters types.ModelParameters `json:"parameters"`
Source types.ModelSource `json:"source"`
Type types.ModelType `json:"type"`
}
// UpdateModel godoc
// @Summary 更新模型
// @Description 更新模型配置信息
// @Tags 模型管理
// @Accept json
// @Produce json
// @Param id path string true "模型ID"
// @Param request body UpdateModelRequest true "更新信息"
// @Success 200 {object} map[string]interface{} "更新后的模型"
// @Failure 404 {object} errors.AppError "模型不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models/{id} [put]
func (h *ModelHandler) UpdateModel(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating model")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Model ID is empty")
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
return
}
var req UpdateModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Retrieving model information, ID: %s", id)
model, err := h.service.GetModelByID(ctx, id)
if err != nil {
if err == service.ErrModelNotFound {
logger.Warnf(ctx, "Model not found, ID: %s", id)
c.Error(errors.NewNotFoundError("Model not found"))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Update model fields if they are provided in the request
if req.Name != "" {
model.Name = req.Name
}
if req.DisplayName != nil {
model.DisplayName = secutils.SanitizeForLog(*req.DisplayName)
}
model.Description = req.Description
// SSRF validation for updated model BaseURL
if req.Parameters.BaseURL != "" {
if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil {
logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err)
c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("Base URL", req.Parameters.BaseURL, err)))
return
}
}
// Credentials (api_key, app_secret) NEVER flow through this endpoint —
// they live behind the /credentials subresource. Force-preserve them by
// snapshotting the stored values before copying request fields in, so
// that even a misbehaving caller that puts api_key in the body cannot
// clobber a stored credential. Log a warning to spot stale callers.
storedAPIKey := model.Parameters.APIKey
storedAppSecret := model.Parameters.AppSecret
if req.Parameters.APIKey != "" && req.Parameters.APIKey != storedAPIKey {
logger.Warnf(ctx,
"deprecated: api_key in PUT /models/%s body is ignored; use PUT /credentials instead", id)
}
if req.Parameters.AppSecret != "" && req.Parameters.AppSecret != storedAppSecret {
logger.Warnf(ctx,
"deprecated: app_secret in PUT /models/%s body is ignored; use PUT /credentials instead", id)
}
newParams := req.Parameters
newParams.APIKey = storedAPIKey
newParams.AppSecret = storedAppSecret
// Preserve backend-managed fields not sent by the frontend either.
newParams.ParameterSize = model.Parameters.ParameterSize
if newParams.InterfaceType == "" {
newParams.InterfaceType = model.Parameters.InterfaceType
}
if newParams.AppID == "" {
newParams.AppID = model.Parameters.AppID
}
if newParams.ExtraConfig == nil {
newParams.ExtraConfig = model.Parameters.ExtraConfig
}
model.Parameters = newParams
model.Source = req.Source
model.Type = req.Type
logger.Infof(ctx, "Updating model, ID: %s, Name: %s", id, model.Name)
if err := h.service.UpdateModel(ctx, model); err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Model updated successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto.NewModelResponse(ctx, model),
})
}
// DeleteModel godoc
// @Summary 删除模型
// @Description 删除指定的模型
// @Tags 模型管理
// @Accept json
// @Produce json
// @Param id path string true "模型ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "模型仍被知识库、智能体或长期记忆引用"
// @Failure 404 {object} errors.AppError "模型不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models/{id} [delete]
func (h *ModelHandler) DeleteModel(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting model")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Model ID is empty")
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
return
}
logger.Infof(ctx, "Deleting model, ID: %s", id)
if err := h.service.DeleteModel(ctx, id); err != nil {
if err == service.ErrModelNotFound {
logger.Warnf(ctx, "Model not found, ID: %s", id)
c.Error(errors.NewNotFoundError("Model not found"))
return
}
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Model deleted successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Model deleted",
})
}
// ModelProviderDTO 模型厂商信息 DTO
type ModelProviderDTO struct {
Value string `json:"value"` // provider 标识符
Label string `json:"label"` // 显示名称
Description string `json:"description"` // 描述
DefaultURLs map[string]string `json:"defaultUrls"` // 按模型类型区分的默认 URL
ModelTypes []string `json:"modelTypes"` // 支持的模型类型
}
// modelTypeToFrontend 将后端 ModelType 转换为前端兼容的字符串
// KnowledgeQA -> chat, Embedding -> embedding, Rerank -> rerank, VLLM -> vllm
func modelTypeToFrontend(mt types.ModelType) string {
switch mt {
case types.ModelTypeKnowledgeQA:
return "chat"
case types.ModelTypeEmbedding:
return "embedding"
case types.ModelTypeRerank:
return "rerank"
case types.ModelTypeVLLM:
return "vllm"
case types.ModelTypeASR:
return "asr"
default:
return string(mt)
}
}
// ListModelProviders godoc
// @Summary 获取模型厂商列表
// @Description 根据模型类型获取支持的厂商列表及配置信息
// @Tags 模型管理
// @Accept json
// @Produce json
// @Param model_type query string false "模型类型 (chat, embedding, rerank, vllm)"
// @Success 200 {object} map[string]interface{} "厂商列表"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /models/providers [get]
func (h *ModelHandler) ListModelProviders(c *gin.Context) {
ctx := c.Request.Context()
modelType := c.Query("model_type")
logger.Infof(ctx, "Listing model providers for type: %s", secutils.SanitizeForLog(modelType))
// 将前端类型映射到后端类型
// 前端: chat, embedding, rerank, vllm
// 后端: KnowledgeQA, Embedding, Rerank, VLLM
var backendModelType types.ModelType
switch modelType {
case "chat":
backendModelType = types.ModelTypeKnowledgeQA
case "embedding":
backendModelType = types.ModelTypeEmbedding
case "rerank":
backendModelType = types.ModelTypeRerank
case "vllm":
backendModelType = types.ModelTypeVLLM
case "asr":
backendModelType = types.ModelTypeASR
default:
backendModelType = types.ModelType(modelType)
}
var providers []provider.ProviderInfo
if modelType != "" {
// 按模型类型过滤
providers = provider.ListByModelType(backendModelType)
} else {
// 返回所有 provider
providers = provider.List()
}
// 转换为 DTO
result := make([]ModelProviderDTO, 0, len(providers))
for _, p := range providers {
// 转换 DefaultURLs map[types.ModelType]string -> map[string]string
// 使用前端兼容的 key (chat 而不是 KnowledgeQA)
defaultURLs := make(map[string]string)
for mt, url := range p.DefaultURLs {
frontendType := modelTypeToFrontend(mt)
defaultURLs[frontendType] = url
}
// 转换 ModelTypes 为前端兼容格式
modelTypes := make([]string, 0, len(p.ModelTypes))
for _, mt := range p.ModelTypes {
modelTypes = append(modelTypes, modelTypeToFrontend(mt))
}
result = append(result, ModelProviderDTO{
Value: string(p.Name),
Label: p.DisplayName,
Description: p.Description,
DefaultURLs: defaultURLs,
ModelTypes: modelTypes,
})
}
logger.Infof(ctx, "Retrieved %d providers", len(result))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
+117
View File
@@ -0,0 +1,117 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// ModelCredentialsHandler handles secret credentials for models via the
// dedicated /models/:id/credentials subresource. See mcp_credentials.go for
// the rationale; this handler mirrors that contract for Model resources.
//
// Recognized fields: "api_key" (every provider), "app_secret" (WeKnora Cloud).
type ModelCredentialsHandler struct {
svc interfaces.ModelService
}
func NewModelCredentialsHandler(svc interfaces.ModelService) *ModelCredentialsHandler {
return &ModelCredentialsHandler{svc: svc}
}
type modelCredentialsPutRequest struct {
APIKey *string `json:"api_key,omitempty"`
AppSecret *string `json:"app_secret,omitempty"`
}
func (h *ModelCredentialsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
var req modelCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if req.APIKey == nil && req.AppSecret == nil {
m, err := h.svc.GetModelByID(ctx, id)
if err != nil || m == nil {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: m.Parameters.APIKey != ""},
"app_secret": {Configured: m.Parameters.AppSecret != ""},
},
}})
return
}
updated, err := h.svc.UpdateModelCredentials(ctx, id, req.APIKey, req.AppSecret)
if err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{"model_id": secutils.SanitizeForLog(id)})
c.Error(errors.NewInternalServerError("failed to update credentials: " + err.Error()))
return
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: updated.Parameters.APIKey != ""},
"app_secret": {Configured: updated.Parameters.AppSecret != ""},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func (h *ModelCredentialsHandler) DeleteField(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
field := c.Param("field")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Workspace ID cannot be empty"))
return
}
if field != "api_key" && field != "app_secret" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.svc.ClearModelCredential(ctx, id, field); err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": secutils.SanitizeForLog(id),
"field": field,
})
c.Error(errors.NewInternalServerError("failed to clear credential: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}
+72
View File
@@ -0,0 +1,72 @@
package handler
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestModelUpdateRequestDisplayNamePresence(t *testing.T) {
var omitted UpdateModelRequest
require.NoError(t, json.Unmarshal([]byte(`{"name":"gpt-4o"}`), &omitted))
assert.Nil(t, omitted.DisplayName)
var cleared UpdateModelRequest
require.NoError(t, json.Unmarshal([]byte(`{"display_name":""}`), &cleared))
require.NotNil(t, cleared.DisplayName)
assert.Equal(t, "", *cleared.DisplayName)
}
func TestParseModelDebugOptionsPreservesExplicitThinkingFalse(t *testing.T) {
opts, err := parseModelDebugOptions(`{"thinking":false,"temperature":0,"max_tokens":256}`)
require.NoError(t, err)
require.NotNil(t, opts.Thinking)
assert.False(t, *opts.Thinking)
require.NotNil(t, opts.Temperature)
assert.Zero(t, *opts.Temperature)
require.NotNil(t, opts.MaxTokens)
assert.Equal(t, 256, *opts.MaxTokens)
}
func TestParseModelDebugOptionsRejectsOutOfRangeValues(t *testing.T) {
_, err := parseModelDebugOptions(`{"top_p":0}`)
require.ErrorContains(t, err, "top_p")
}
func TestRedactedDebugConfig(t *testing.T) {
got := redactedDebugConfig(map[string]string{
"thinking_control": "enable_thinking",
"secret_key": "do-not-leak",
"access_token": "do-not-leak-either",
})
assert.Equal(t, "enable_thinking", got["thinking_control"])
assert.Equal(t, "[REDACTED]", got["secret_key"])
assert.Equal(t, "[REDACTED]", got["access_token"])
}
func TestConsumeModelDebugChatStream(t *testing.T) {
stream := make(chan types.StreamResponse, 5)
stream <- types.StreamResponse{ResponseType: types.ResponseTypeThinking, Content: "reason "}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeThinking, Content: "more", Done: true}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeAnswer, Content: "answer "}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeAnswer, Content: "done"}
stream <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Done: true,
FinishReason: "stop",
Usage: &types.TokenUsage{PromptTokens: 3, CompletionTokens: 4, TotalTokens: 7},
}
close(stream)
got, err := consumeModelDebugChatStream(stream)
require.NoError(t, err)
assert.Equal(t, "reason more", got.ReasoningContent)
assert.Equal(t, "answer done", got.Content)
assert.Equal(t, "stop", got.FinishReason)
require.NotNil(t, got.Usage)
assert.Equal(t, 7, got.Usage.TotalTokens)
assert.Len(t, got.StreamEvents, 5)
}
+29
View File
@@ -0,0 +1,29 @@
package handler
import (
"github.com/Tencent/WeKnora/internal/application/repository/retriever/legacygraph"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
)
type NativeGraphHandler struct{ repo *legacygraph.Repository }
func NewNativeGraphHandler(repo *legacygraph.Repository) *NativeGraphHandler {
return &NativeGraphHandler{repo: repo}
}
func (h *NativeGraphHandler) Browse(c *gin.Context) {
cursor, _ := strconv.ParseInt(c.Query("cursor"), 10, 64)
entity, _ := strconv.ParseInt(c.Query("entity"), 10, 64)
query := c.Query("q")
if len([]rune(query)) > 200 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "查询过长"})
return
}
data, err := h.repo.Browse(c.Request.Context(), c.Param("id"), query, cursor, entity)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "图谱读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
package handler
import (
"errors"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// Per-resource creator-id resolvers used by middleware.RequireOwnershipOrRole.
// The route registration wires these as closures so they capture the handler's
// service dependencies; the middleware just calls them with the gin.Context
// and gets back one of:
//
// - (creatorID, nil) : ownership match check decides
// - ("", nil) : tenant-owned (legacy or built-in)
// - ("", ErrResourceNotFound) : :id doesn't resolve in this tenant;
// middleware passes through so the
// handler can issue a real 404
// - ("", <other err>) : transient/unexpected failure;
// middleware will 503 the request
//
// Tenant scoping is enforced INSIDE the lookup: even if the underlying
// `Get*ByID` repo call is unscoped, the lookup re-checks `tenant_id`
// against the caller's context. This stops a creator-match shortcut
// from leaking access to a same-ID resource in a different tenant.
// KBCreatorLookup resolves :id -> KnowledgeBase.CreatorID, scoped to
// the caller's tenant. Used by all per-KB mutating routes.
func (h *KnowledgeBaseHandler) KBCreatorLookup(c *gin.Context) (string, error) {
id := c.Param("id")
if id == "" {
return "", errors.New("missing :id param for KB creator lookup")
}
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
// 没有空间上下文意味着 auth 中间件未完成;当作 lookup 失败让上层 503,
// 而不是 silently 走 fail-open 给一个不该有的访问。
return "", errors.New("workspace context missing")
}
kb, err := h.service.GetKnowledgeBaseByID(ctx, id)
if err != nil {
if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if kb == nil {
return "", middleware.ErrResourceNotFound
}
// 显式重校验空间:repo.GetKnowledgeBaseByID 不带 tenant 过滤,
// 万一未来 :id 被攻击者从他人空间 UUID 试探到,也不会借由
// "ownership match" 通过中间件。
if kb.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
return kb.CreatorID, nil
}
// KBCreatorLookupFromKbIDParam is the same lookup as KBCreatorLookup
// but reads `:kbId` instead of `:id`. Used by the /initialization
// routes (POST /initialization/initialize/:kbId, PUT
// /initialization/config/:kbId), which are KB-scoped mutating ops:
// changing a KB's embedding/parser/storage configuration is at least
// as sensitive as updating the KB itself, so it must follow the same
// "creator OR Admin+" matrix.
func (h *KnowledgeBaseHandler) KBCreatorLookupFromKbIDParam(c *gin.Context) (string, error) {
id := c.Param("kbId")
if id == "" {
return "", errors.New("missing :kbId param for KB creator lookup")
}
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return "", errors.New("workspace context missing")
}
kb, err := h.service.GetKnowledgeBaseByID(ctx, id)
if err != nil {
if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if kb == nil {
return "", middleware.ErrResourceNotFound
}
if kb.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
return kb.CreatorID, nil
}
// AgentCreatorLookup resolves :id -> CustomAgent.CreatedBy. Built-in
// agents (IsBuiltin == true) are tenant-owned across the board: they
// belong to the tenant rather than to any one user, so we return
// ("", nil) and let the role check decide. The same holds for legacy
// rows whose CreatedBy was never populated.
//
// The underlying GetAgentByID already scopes to the caller's tenant,
// but we keep an explicit defence-in-depth check here in case future
// refactors loosen the service-layer scope.
func (h *CustomAgentHandler) AgentCreatorLookup(c *gin.Context) (string, error) {
id := c.Param("id")
if id == "" {
return "", errors.New("missing :id param for agent creator lookup")
}
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return "", errors.New("workspace context missing")
}
agent, err := h.service.GetAgentByID(ctx, id)
if err != nil {
if errors.Is(err, service.ErrAgentNotFound) ||
errors.Is(err, apprepo.ErrCustomAgentNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if agent == nil {
return "", middleware.ErrResourceNotFound
}
if agent.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
if agent.IsBuiltin {
return "", nil
}
return agent.CreatedBy, nil
}
// Compile-time guards: the methods must satisfy middleware.CreatorLookup
// so route wiring stays type-safe even if a signature drifts.
var (
_ middleware.CreatorLookup = (*KnowledgeBaseHandler)(nil).KBCreatorLookup
_ middleware.CreatorLookup = (*KnowledgeBaseHandler)(nil).KBCreatorLookupFromKbIDParam
_ middleware.CreatorLookup = (*CustomAgentHandler)(nil).AgentCreatorLookup
_ middleware.CreatorLookup = (*KnowledgeHandler)(nil).KBCreatorLookupFromKnowledgeID
_ middleware.CreatorLookup = (*ChunkHandler)(nil).KBCreatorLookupFromKnowledgeIDParam
_ middleware.CreatorLookup = (*ChunkHandler)(nil).KBCreatorLookupFromChunkIDParam
_ middleware.CreatorLookup = (*WikiPageHandler)(nil).KBCreatorLookupFromKBPath
)
// KBCreatorLookupFromKnowledgeID resolves :id (a Knowledge ID) to the
// CreatorID of the *owning KB*, scoped to the caller's tenant. Used by
// per-knowledge mutating routes (PR 5, #1303) so a Contributor who owns
// the KB can edit/delete any of its documents while a Contributor who
// merely belongs to the tenant cannot. Built-in / legacy KBs without a
// CreatorID surface as ("", nil) and stay Admin-gated.
//
// The chain (knowledge_id -> kb_id -> KB.CreatorID) lives in the
// service layer (KnowledgeService.GetOwningKBCreatorID) so this lookup
// stays a pure adapter — same shape as KBCreatorLookup.
func (h *KnowledgeHandler) KBCreatorLookupFromKnowledgeID(c *gin.Context) (string, error) {
knowledgeID := c.Param("id")
if knowledgeID == "" {
return "", errors.New("missing :id param for knowledge owner lookup")
}
return resolveKBCreatorByKnowledgeID(c, h.kgService, knowledgeID)
}
// KBCreatorLookupFromKnowledgeIDParam mirrors KBCreatorLookupFromKnowledgeID
// for chunk routes that use :knowledge_id rather than :id. Chunk routes
// addressed by :id (no knowledge id) instead use
// KBCreatorLookupFromChunkIDParam below.
func (h *ChunkHandler) KBCreatorLookupFromKnowledgeIDParam(c *gin.Context) (string, error) {
knowledgeID := c.Param("knowledge_id")
if knowledgeID == "" {
return "", errors.New("missing :knowledge_id param for chunk owner lookup")
}
return resolveKBCreatorByKnowledgeID(c, h.kgService, knowledgeID)
}
// KBCreatorLookupFromChunkIDParam resolves :id (a chunk ID) to the
// CreatorID of the *owning KB*. The chain is:
//
// chunk_id -> chunk.KnowledgeID -> kb_id -> KB.CreatorID
//
// Used by chunks.DELETE("/by-id/:id/questions") so generated-question
// deletion follows the same OwnedKBOrAdmin matrix as every other
// chunk-level mutation, instead of the looser "any Contributor in the
// tenant" gate. The tenant scope is re-checked explicitly here even
// though the underlying GetChunkByIDOnly is unscoped — same defence-
// in-depth pattern as KBCreatorLookup.
func (h *ChunkHandler) KBCreatorLookupFromChunkIDParam(c *gin.Context) (string, error) {
chunkID := c.Param("id")
if chunkID == "" {
return "", errors.New("missing :id param for chunk owner lookup")
}
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return "", errors.New("workspace context missing")
}
chunk, err := h.service.GetChunkByIDOnly(ctx, chunkID)
if err != nil {
if errors.Is(err, service.ErrChunkNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if chunk == nil {
return "", middleware.ErrResourceNotFound
}
// 显式重校验空间:GetChunkByIDOnly 无空间过滤,必须在此挡住跨空间 chunk
// id 撞库通过 ownership 匹配获取本不该有的访问。
if chunk.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
return resolveKBCreatorByKnowledgeID(c, h.kgService, chunk.KnowledgeID)
}
// KBCreatorLookupFromKBPath resolves :kb_id (used by wiki routes) to
// KnowledgeBase.CreatorID. No chain hop — the wiki page URL already
// carries the owning KB id, so we go straight to the KB service. The
// tenant defence-in-depth re-check stays the same as KBCreatorLookup:
// repo.GetKnowledgeBaseByID is unscoped, so we explicitly compare
// against the context tenant.
func (h *WikiPageHandler) KBCreatorLookupFromKBPath(c *gin.Context) (string, error) {
kbID := c.Param("kb_id")
if kbID == "" {
return "", errors.New("missing :kb_id param for wiki owner lookup")
}
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return "", errors.New("workspace context missing")
}
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if kb == nil {
return "", middleware.ErrResourceNotFound
}
if kb.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
return kb.CreatorID, nil
}
// resolveKBCreatorByKBID resolves a KB id to CreatorID, scoped to the
// caller's tenant. Used by cross-KB handlers whose body carries kb_id
// instead of a URL param (batch-delete, move, etc.).
func resolveKBCreatorByKBID(
c *gin.Context,
kbService interfaces.KnowledgeBaseService,
kbID string,
) (string, error) {
ctx := c.Request.Context()
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
return "", errors.New("workspace context missing")
}
kb, err := kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
if kb == nil {
return "", middleware.ErrResourceNotFound
}
if kb.TenantID != tenantID {
return "", middleware.ErrResourceNotFound
}
return kb.CreatorID, nil
}
// resolveKBCreatorByKnowledgeID is the shared body for the knowledge /
// chunk lookups. The service-layer chain helper does the tenant-scoped
// fetch (knowledge -> KB) and returns repository sentinel errors;
// translating those to middleware.ErrResourceNotFound is the lookup's
// job, mirroring what KBCreatorLookup does for plain :id routes.
func resolveKBCreatorByKnowledgeID(
c *gin.Context,
kgService interfaces.KnowledgeService,
knowledgeID string,
) (string, error) {
ctx := c.Request.Context()
if _, ok := types.TenantIDFromContext(ctx); !ok {
// Same fail-closed reasoning as KBCreatorLookup: no tenant
// context means auth didn't complete, and we'd rather have the
// caller see a 503 than a silent fail-open.
return "", errors.New("workspace context missing")
}
creatorID, err := kgService.GetOwningKBCreatorID(ctx, knowledgeID)
if err != nil {
if errors.Is(err, apprepo.ErrKnowledgeNotFound) ||
errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) {
return "", middleware.ErrResourceNotFound
}
return "", err
}
return creatorID, nil
}
+482
View File
@@ -0,0 +1,482 @@
package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// The lookup helpers translate handler/service errors into the
// middleware sentinel set; that translation is where bugs hide (404
// becoming 403, cross-tenant leaks, etc.), so we test those edges
// directly rather than via end-to-end HTTP.
// stubKBService implements just enough of interfaces.KnowledgeBaseService
// to drive KBCreatorLookup. Any other method panics so the test fails
// loudly if a future lookup refactor reaches outside the contract.
type stubKBService struct {
interfaces.KnowledgeBaseService
get func(ctx context.Context, id string) (*types.KnowledgeBase, error)
}
func (s *stubKBService) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
return s.get(ctx, id)
}
func newKBLookupCtx(t *testing.T, tenantID uint64, paramID string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
c.Params = gin.Params{{Key: "id", Value: paramID}}
return c
}
func TestKBCreatorLookup_NotFoundMapsToSentinel(t *testing.T) {
h := &KnowledgeBaseHandler{service: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, apprepo.ErrKnowledgeBaseNotFound
},
}}
_, err := h.KBCreatorLookup(newKBLookupCtx(t, 1, "kb-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
func TestKBCreatorLookup_CrossTenantIsHiddenAsNotFound(t *testing.T) {
// A foreign-tenant KB must NEVER leak via the ownership shortcut.
// Returning the row's CreatorID would let a user-id collision pass
// the middleware's "creator == uid" branch; hiding it as not-found
// keeps the lookup strictly tenant-scoped.
h := &KnowledgeBaseHandler{service: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb-1", TenantID: 999, CreatorID: "u1"}, nil
},
}}
_, err := h.KBCreatorLookup(newKBLookupCtx(t, 1, "kb-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("cross-tenant KB must surface as not-found, got %v", err)
}
}
func TestKBCreatorLookup_OwnerMatchReturnsCreatorID(t *testing.T) {
h := &KnowledgeBaseHandler{service: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb-1", TenantID: 1, CreatorID: "u-creator"}, nil
},
}}
creator, err := h.KBCreatorLookup(newKBLookupCtx(t, 1, "kb-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "u-creator" {
t.Fatalf("expected creator=u-creator, got %q", creator)
}
}
func TestKBCreatorLookup_MissingTenantContext(t *testing.T) {
// Without tenant context, the lookup can't decide scope. Surfacing
// a real error (which middleware turns into 503) is safer than
// silently returning ErrResourceNotFound: the request shouldn't be
// happening at all.
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
c.Params = gin.Params{{Key: "id", Value: "kb-1"}}
h := &KnowledgeBaseHandler{service: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
t.Fatalf("service must not be called without tenant context")
return nil, nil
},
}}
_, err := h.KBCreatorLookup(c)
if err == nil {
t.Fatalf("expected error when workspace context missing")
}
if errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("missing tenant must not be reported as not-found: %v", err)
}
}
// stubAgentService mirrors stubKBService for the agent lookup tests.
type stubAgentService struct {
interfaces.CustomAgentService
get func(ctx context.Context, id string) (*types.CustomAgent, error)
}
func (s *stubAgentService) GetAgentByID(ctx context.Context, id string) (*types.CustomAgent, error) {
return s.get(ctx, id)
}
func TestAgentCreatorLookup_BuiltinIsTenantOwned(t *testing.T) {
h := &CustomAgentHandler{service: &stubAgentService{
get: func(_ context.Context, _ string) (*types.CustomAgent, error) {
return &types.CustomAgent{
ID: "smart-reasoning", TenantID: 1, IsBuiltin: true, CreatedBy: "ignored",
}, nil
},
}}
creator, err := h.AgentCreatorLookup(newKBLookupCtx(t, 1, "smart-reasoning"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "" {
t.Fatalf("built-in agent must surface as tenant-owned (empty creator), got %q", creator)
}
}
func TestAgentCreatorLookup_AgentNotFoundMapsToSentinel(t *testing.T) {
h := &CustomAgentHandler{service: &stubAgentService{
get: func(_ context.Context, _ string) (*types.CustomAgent, error) {
return nil, service.ErrAgentNotFound
},
}}
_, err := h.AgentCreatorLookup(newKBLookupCtx(t, 1, "missing-agent"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
func TestAgentCreatorLookup_CrossTenantIsHiddenAsNotFound(t *testing.T) {
// Defensive: service.GetAgentByID already scopes by tenant, but
// AgentCreatorLookup re-checks the row's TenantID anyway. If a
// future refactor loosens the service-layer scope (or adds a
// cross-tenant variant that gets wired here by mistake), a
// foreign-tenant row must NEVER leak through the ownership
// shortcut. Pinning the contract here so the defensive branch
// survives refactors.
h := &CustomAgentHandler{service: &stubAgentService{
get: func(_ context.Context, _ string) (*types.CustomAgent, error) {
return &types.CustomAgent{
ID: "agent-1", TenantID: 999, CreatedBy: "u1",
}, nil
},
}}
_, err := h.AgentCreatorLookup(newKBLookupCtx(t, 1, "agent-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("cross-tenant agent must surface as not-found, got %v", err)
}
}
// PR 5 (#1303) per-KB ownership lookups. The chain helper lives in the
// service layer; the lookups here are pure adapters that translate the
// repository sentinels into middleware.ErrResourceNotFound. Tests focus
// on those translations — the service-side chain has its own tests in
// internal/application/service.
// stubKgService stands in for interfaces.KnowledgeService for the
// knowledge / chunk lookups. Embedding the interface keeps every
// non-stubbed method nil-panicky on purpose: a future refactor that
// reaches outside GetOwningKBCreatorID should fail loudly, not silently.
type stubKgService struct {
interfaces.KnowledgeService
getOwningKBCreatorID func(ctx context.Context, knowledgeID string) (string, error)
}
func (s *stubKgService) GetOwningKBCreatorID(ctx context.Context, knowledgeID string) (string, error) {
return s.getOwningKBCreatorID(ctx, knowledgeID)
}
// newKnowledgeLookupCtx builds a gin.Context shaped like the
// /knowledge/:id route — :id holds a knowledge id.
func newKnowledgeLookupCtx(t *testing.T, tenantID uint64, knowledgeID string) *gin.Context {
return newKBLookupCtx(t, tenantID, knowledgeID)
}
// newChunkLookupCtx builds a gin.Context shaped like the
// /chunks/:knowledge_id/:id route — only :knowledge_id is set since
// that's all the lookup reads.
func newChunkLookupCtx(t *testing.T, tenantID uint64, knowledgeID string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
c.Params = gin.Params{{Key: "knowledge_id", Value: knowledgeID}}
return c
}
// newWikiLookupCtx builds a gin.Context shaped like the
// /knowledgebase/:kb_id/wiki/... routes.
func newWikiLookupCtx(t *testing.T, tenantID uint64, kbID string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
c.Params = gin.Params{{Key: "kb_id", Value: kbID}}
return c
}
func TestKBCreatorLookupFromKnowledgeID_NotFoundMapsToSentinel(t *testing.T) {
// Either sentinel from the repo (knowledge missing OR its KB missing)
// must surface as ErrResourceNotFound — same behaviour as the
// KB-level lookup so middleware translates uniformly.
for _, tc := range []struct {
name string
err error
}{
{"knowledge-not-found", apprepo.ErrKnowledgeNotFound},
{"kb-not-found", apprepo.ErrKnowledgeBaseNotFound},
} {
t.Run(tc.name, func(t *testing.T) {
h := &KnowledgeHandler{kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
return "", tc.err
},
}}
_, err := h.KBCreatorLookupFromKnowledgeID(newKnowledgeLookupCtx(t, 1, "kn-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
})
}
}
func TestKBCreatorLookupFromKnowledgeID_OwnerMatchReturnsCreatorID(t *testing.T) {
h := &KnowledgeHandler{kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
return "u-kb-creator", nil
},
}}
creator, err := h.KBCreatorLookupFromKnowledgeID(newKnowledgeLookupCtx(t, 1, "kn-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "u-kb-creator" {
t.Fatalf("expected creator=u-kb-creator, got %q", creator)
}
}
func TestKBCreatorLookupFromKnowledgeID_MissingTenantContext(t *testing.T) {
// Mirror KBCreatorLookup_MissingTenantContext: no tenant context
// means auth didn't complete; the lookup must NOT call the service
// and must NOT return ErrResourceNotFound (which would silently turn
// into a "fail open" pass).
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
c.Params = gin.Params{{Key: "id", Value: "kn-1"}}
h := &KnowledgeHandler{kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
t.Fatalf("service must not be called without tenant context")
return "", nil
},
}}
_, err := h.KBCreatorLookupFromKnowledgeID(c)
if err == nil {
t.Fatalf("expected error when workspace context missing")
}
if errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("missing tenant must not be reported as not-found: %v", err)
}
}
func TestKBCreatorLookupFromKnowledgeIDParam_ChunkRouteShape(t *testing.T) {
// Chunk routes use :knowledge_id rather than :id; the lookup must
// read from the right param name. A bug here would silently break
// chunk delete/update on rollout.
h := &ChunkHandler{kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, knowledgeID string) (string, error) {
if knowledgeID != "kn-from-chunk-route" {
t.Fatalf("lookup must read :knowledge_id, got %q", knowledgeID)
}
return "u-kb-creator", nil
},
}}
creator, err := h.KBCreatorLookupFromKnowledgeIDParam(
newChunkLookupCtx(t, 1, "kn-from-chunk-route"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "u-kb-creator" {
t.Fatalf("expected creator=u-kb-creator, got %q", creator)
}
}
func TestKBCreatorLookupFromKnowledgeIDParam_NotFoundMapsToSentinel(t *testing.T) {
h := &ChunkHandler{kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
return "", apprepo.ErrKnowledgeNotFound
},
}}
_, err := h.KBCreatorLookupFromKnowledgeIDParam(newChunkLookupCtx(t, 1, "kn-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
func TestKBCreatorLookupFromKBPath_NotFoundMapsToSentinel(t *testing.T) {
// Wiki lookup path goes straight to the KB service (no chain hop),
// so its translation behaviour mirrors KBCreatorLookup.
h := &WikiPageHandler{kbService: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return nil, apprepo.ErrKnowledgeBaseNotFound
},
}}
_, err := h.KBCreatorLookupFromKBPath(newWikiLookupCtx(t, 1, "kb-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
func TestKBCreatorLookupFromKBPath_CrossTenantIsHiddenAsNotFound(t *testing.T) {
// Same security boundary as KBCreatorLookup_CrossTenantIsHiddenAsNotFound.
// repo.GetKnowledgeBaseByID is unscoped, so the lookup MUST re-check
// TenantID; otherwise a probed cross-tenant kb_id whose CreatorID
// happens to match the caller's user id would slip past the
// ownership shortcut.
h := &WikiPageHandler{kbService: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb-1", TenantID: 999, CreatorID: "u1"}, nil
},
}}
_, err := h.KBCreatorLookupFromKBPath(newWikiLookupCtx(t, 1, "kb-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("cross-tenant KB must surface as not-found, got %v", err)
}
}
func TestKBCreatorLookupFromKBPath_OwnerMatchReturnsCreatorID(t *testing.T) {
h := &WikiPageHandler{kbService: &stubKBService{
get: func(_ context.Context, _ string) (*types.KnowledgeBase, error) {
return &types.KnowledgeBase{ID: "kb-1", TenantID: 1, CreatorID: "u-creator"}, nil
},
}}
creator, err := h.KBCreatorLookupFromKBPath(newWikiLookupCtx(t, 1, "kb-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "u-creator" {
t.Fatalf("expected creator=u-creator, got %q", creator)
}
}
// stubChunkService stands in for interfaces.ChunkService for the
// chunk-id-driven lookup. Only GetChunkByIDOnly is stubbed; everything
// else panics on contact so a future refactor that broadens the
// surface gets caught loudly.
type stubChunkService struct {
interfaces.ChunkService
getByIDOnly func(ctx context.Context, id string) (*types.Chunk, error)
}
func (s *stubChunkService) GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error) {
return s.getByIDOnly(ctx, id)
}
// newChunkIDLookupCtx is the gin.Context shape for routes that address
// chunks by their own id, e.g. DELETE /chunks/by-id/:id/questions.
func newChunkIDLookupCtx(t *testing.T, tenantID uint64, chunkID string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/x", nil)
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
c.Params = gin.Params{{Key: "id", Value: chunkID}}
return c
}
func TestKBCreatorLookupFromChunkIDParam_HappyPath(t *testing.T) {
h := &ChunkHandler{
service: &stubChunkService{
getByIDOnly: func(_ context.Context, id string) (*types.Chunk, error) {
if id != "ch-1" {
t.Fatalf("expected chunk id=ch-1, got %q", id)
}
return &types.Chunk{ID: id, TenantID: 1, KnowledgeID: "kn-1"}, nil
},
},
kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, knowledgeID string) (string, error) {
if knowledgeID != "kn-1" {
t.Fatalf("expected knowledge id=kn-1, got %q", knowledgeID)
}
return "u-creator", nil
},
},
}
creator, err := h.KBCreatorLookupFromChunkIDParam(newChunkIDLookupCtx(t, 1, "ch-1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creator != "u-creator" {
t.Fatalf("expected creator=u-creator, got %q", creator)
}
}
func TestKBCreatorLookupFromChunkIDParam_ChunkNotFoundMapsToSentinel(t *testing.T) {
h := &ChunkHandler{
service: &stubChunkService{
getByIDOnly: func(_ context.Context, _ string) (*types.Chunk, error) {
return nil, service.ErrChunkNotFound
},
},
}
_, err := h.KBCreatorLookupFromChunkIDParam(newChunkIDLookupCtx(t, 1, "ch-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
func TestKBCreatorLookupFromChunkIDParam_CrossTenantIsHiddenAsNotFound(t *testing.T) {
// GetChunkByIDOnly is unscoped — a chunk from another tenant whose
// owning-KB-creator happens to match the caller's user id must NOT
// slip past the ownership shortcut. The lookup re-checks TenantID
// before walking up to the KB.
h := &ChunkHandler{
service: &stubChunkService{
getByIDOnly: func(_ context.Context, _ string) (*types.Chunk, error) {
return &types.Chunk{ID: "ch-1", TenantID: 999, KnowledgeID: "kn-1"}, nil
},
},
kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
t.Fatalf("kb chain must not be consulted on cross-tenant chunk")
return "", nil
},
},
}
_, err := h.KBCreatorLookupFromChunkIDParam(newChunkIDLookupCtx(t, 1, "ch-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("cross-tenant chunk must surface as not-found, got %v", err)
}
}
func TestKBCreatorLookupFromChunkIDParam_KBNotFoundFromChainMapsToSentinel(t *testing.T) {
// The chunk exists and is in-tenant, but the chain (knowledge_id ->
// kb) errors with KnowledgeBaseNotFound — that must become the
// middleware sentinel, same as every other lookup.
h := &ChunkHandler{
service: &stubChunkService{
getByIDOnly: func(_ context.Context, _ string) (*types.Chunk, error) {
return &types.Chunk{ID: "ch-1", TenantID: 1, KnowledgeID: "kn-1"}, nil
},
},
kgService: &stubKgService{
getOwningKBCreatorID: func(_ context.Context, _ string) (string, error) {
return "", apprepo.ErrKnowledgeBaseNotFound
},
},
}
_, err := h.KBCreatorLookupFromChunkIDParam(newChunkIDLookupCtx(t, 1, "ch-1"))
if !errors.Is(err, middleware.ErrResourceNotFound) {
t.Fatalf("expected ErrResourceNotFound, got %v", err)
}
}
+567
View File
@@ -0,0 +1,567 @@
package handler
import (
"context"
stderrors "errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
// --- Sandbox connectivity check ---
// SandboxCheckRequest is the body for POST /system/sandbox-check. Secrets may
// arrive redacted; they are resolved against the workspace's stored config so
// an admin can test without retyping an API key.
type SandboxCheckRequest struct {
Config *types.TenantSandboxConfig `json:"config"`
// ConfigID lets an edit form test stored credentials while overriding only
// the fields the admin changed in the drawer.
ConfigID string `json:"config_id"`
// Deep additionally runs a throwaway script. For remote backends this also
// creates and destroys one sandbox, which is the only way to validate the
// template ID, data plane, in-sandbox execution, and outbound egress. It may
// consume real sandbox time, so it is opt-in.
Deep bool `json:"deep"`
}
// SandboxCheckItem is one probe outcome. OK is nil when the probe was not
// executed, which distinguishes "skipped" from "failed" in the UI.
type SandboxCheckItem struct {
Name string `json:"name"`
OK *bool `json:"ok"`
// Message carries free-form provider detail for an executed probe.
Message string `json:"message,omitempty"`
// Reason is a stable code explaining why a probe was skipped. It exists so
// the UI can phrase the skip in the operator's language instead of echoing
// a server-side sentence.
Reason string `json:"reason,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
}
// Why a probe was not executed. Kept as codes because the operator reads them
// in the frontend, which is localized.
const (
// The probe needs a real sandbox, so it only runs on an opt-in deep check.
skipReasonNeedsDeepCheck = "needs_deep_check"
// An earlier probe failed and this one cannot be reached without it.
skipReasonControlPlaneUnreachable = "control_plane_unreachable"
skipReasonSandboxNotCreated = "sandbox_not_created"
skipReasonSandboxExecFailed = "sandbox_exec_failed"
// The config denies egress by default, so a probe that cannot reach the
// internet is the policy working as configured, not a fault.
skipReasonEgressRestrictedByPolicy = "egress_restricted_by_policy"
)
// SandboxCheckResponse aggregates the probes for one sandbox configuration.
type SandboxCheckResponse struct {
OK bool `json:"ok"`
Provider string `json:"provider"`
Checks []SandboxCheckItem `json:"checks"`
Capabilities map[string]bool `json:"capabilities,omitempty"`
}
// add records an executed probe. A single failure fails the whole result.
func (r *SandboxCheckResponse) add(name string, ok bool, message string, latencyMS int64) {
value := ok
r.Checks = append(r.Checks, SandboxCheckItem{
Name: name, OK: &value, Message: message, LatencyMS: latencyMS,
})
if !ok {
r.OK = false
}
}
// skip records a probe that was not run; it never affects OK.
func (r *SandboxCheckResponse) skip(name, reason string) {
r.Checks = append(r.Checks, SandboxCheckItem{Name: name, OK: nil, Reason: reason})
}
// CheckSandboxConfig tests a sandbox configuration without persisting it.
// @Summary 测试沙箱连通性
// @Description 使用当前填写的参数测试沙箱后端,不保存配置;deep=true 会执行临时脚本,远端后端还会创建并销毁一个沙箱
// @Tags 系统
// @Accept json
// @Produce json
// @Param body body SandboxCheckRequest true "沙箱配置"
// @Success 200 {object} SandboxCheckResponse
// @Router /system/sandbox-check [post]
func (h *SystemHandler) CheckSandboxConfig(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
var req SandboxCheckRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求体格式错误"})
return
}
tenant, _ := types.TenantInfoFromContext(c.Request.Context())
if tenant == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "空间为空"})
return
}
var stored *types.TenantSandboxConfig
incoming := req.Config
if req.ConfigID != "" {
if h.sandboxConfigSvc == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "沙箱配置服务不可用"})
return
}
entity, err := h.sandboxConfigSvc.Get(ctx, tenant.ID, req.ConfigID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": err.Error()})
return
}
if entity == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "沙箱配置不存在"})
return
}
stored = entity.Config
if incoming == nil {
incoming = stored
}
}
if !req.Deep {
incoming = sandboxConnectionCheckConfig(incoming)
}
merged, err := service.SanitizeSandboxConfig(incoming, stored)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": err.Error()})
return
}
effective, err := sandbox.ResolveEffectiveConfig(merged, sandbox.DefaultConfig())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": err.Error()})
return
}
result := &SandboxCheckResponse{OK: true, Provider: string(effective.Type)}
client, err := sandbox.NewRemoteClientForCheck(effective)
if err != nil {
result.add("client_build", false, err.Error(), 0)
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
return
}
// Level 1: an authenticated control-plane call, which validates endpoint
// reachability AND credentials in a single round-trip.
start := time.Now()
healthErr := client.Health(ctx)
latency := time.Since(start).Milliseconds()
if healthErr != nil {
result.add("api_url_reachable", false, sandboxCheckReason(healthErr), latency)
result.skip("credential_valid", skipReasonControlPlaneUnreachable)
} else {
result.add("api_url_reachable", true, "", latency)
result.add("credential_valid", true, "", 0)
}
caps := client.Capabilities()
result.Capabilities = map[string]bool{
"supports_volumes": caps.SupportsVolumes,
"supports_pause_resume": caps.SupportsPauseResume,
"supports_reconnect": caps.SupportsReconnect,
}
if !req.Deep || healthErr != nil {
reason := skipReasonNeedsDeepCheck
if healthErr != nil {
reason = skipReasonControlPlaneUnreachable
}
result.skip("template_exists", reason)
result.skip("sandbox_exec", reason)
result.skip("egress_available", reason)
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
return
}
h.runDeepSandboxCheck(ctx, client, effective, result)
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// sandboxConnectionCheckConfig supplies a private template placeholder for a
// shallow remote probe. Connectivity is deliberately checked before template
// discovery in the settings wizard, and Health never uses this value.
func sandboxConnectionCheckConfig(cfg *types.TenantSandboxConfig) *types.TenantSandboxConfig {
if cfg == nil {
return nil
}
copy := *cfg
switch sandbox.SandboxType(copy.SandboxType) {
case sandbox.SandboxTypeCube:
cube := types.CubeSandboxConfig{}
if copy.Cube != nil {
cube = *copy.Cube
}
if strings.TrimSpace(cube.TemplateID) == "" {
cube.TemplateID = "__connection_check__"
}
copy.Cube = &cube
case sandbox.SandboxTypeE2B:
e2b := types.E2BSandboxConfig{}
if copy.E2B != nil {
e2b = *copy.E2B
}
if strings.TrimSpace(e2b.TemplateID) == "" {
e2b.TemplateID = "__connection_check__"
}
copy.E2B = &e2b
}
return &copy
}
// describeProbeMismatch reports why the probe script did not print its marker.
// "命令输出与预期不符" on its own hides the exit code and the stderr line that
// normally name the actual problem — a missing interpreter, an image entrypoint
// that swallowed the command, or a bind mount that never reached the Docker VM.
func describeProbeMismatch(exitCode int, killed bool, stdout, stderr, execErr string) string {
parts := []string{fmt.Sprintf("退出码 %d", exitCode)}
if killed {
parts = append(parts, "执行超时被终止")
}
switch {
case firstProbeLine(stderr) != "":
parts = append(parts, "stderr: "+firstProbeLine(stderr))
case firstProbeLine(stdout) != "":
parts = append(parts, "stdout: "+firstProbeLine(stdout))
default:
parts = append(parts, "没有任何输出")
}
if trimmed := strings.TrimSpace(execErr); trimmed != "" {
parts = append(parts, trimmed)
}
return strings.Join(parts, ";")
}
// firstProbeLine picks the first non-empty line and caps it, so one runaway log
// line cannot flood the check panel.
func firstProbeLine(output string) string {
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if len(trimmed) > 300 {
return trimmed[:300] + "…"
}
return trimmed
}
return ""
}
// runDeepSandboxCheck creates one throwaway sandbox and verifies a command can
// run inside it. The sandbox is always deleted, including on failure.
func (h *SystemHandler) runDeepSandboxCheck(
ctx context.Context,
client sandbox.RemoteSandboxClient,
cfg *sandbox.Config,
result *SandboxCheckResponse,
) {
probeCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
handle, err := client.Create(probeCtx, sandbox.RemoteCreateRequest{
TemplateID: sandbox.EffectiveTemplateID(cfg),
// Probe under the admin's policy: the point of this check is whether
// THIS config works, not whether the provider's default does.
Network: cfg.Network,
Timeout: sandbox.RemoteTimeoutPolicy{
Mode: sandbox.RemoteTimeoutExplicit,
Value: 2 * time.Minute,
Action: sandbox.RemoteOnTimeoutKill,
},
})
if err != nil {
result.add("template_exists", false, explainSandboxCreateFailure(ctx, client, cfg, err), 0)
result.skip("sandbox_exec", skipReasonSandboxNotCreated)
result.skip("egress_available", skipReasonSandboxNotCreated)
return
}
defer func() {
// Detach from ctx so cleanup still runs if the request was cancelled;
// a leaked probe sandbox would otherwise sit there billing.
cleanupCtx, cleanupCancel := context.WithTimeout(
context.WithoutCancel(ctx), 30*time.Second)
defer cleanupCancel()
if err := client.Delete(cleanupCtx, handle.ID()); err != nil {
logger.Warnf(ctx, "[SandboxCheck] failed to delete probe sandbox %s: %v",
handle.ID(), err)
}
}()
result.add("template_exists", true, "", 0)
logger.Infof(ctx,
"[SandboxCheck] probe sandbox created id=%s provider=%s template=%s",
handle.ID(), result.Provider, sandbox.EffectiveTemplateID(cfg),
)
const marker = "weknora-ok"
start := time.Now()
execResult, err := client.Exec(probeCtx, handle, sandbox.RemoteExecRequest{
Command: "echo",
Args: []string{marker},
User: sandbox.DefaultSandboxExecUser,
Timeout: 30 * time.Second,
})
latency := time.Since(start).Milliseconds()
switch {
case err != nil:
logger.Warnf(ctx,
"[SandboxCheck] sandbox_exec failed sandbox=%s latency_ms=%d ui_reason=%s detail=%s",
handle.ID(), latency, sandboxCheckReason(err), sandbox.RemoteErrorDiagnostics(err),
)
result.add("sandbox_exec", false, sandboxCheckReason(err), latency)
result.skip("egress_available", skipReasonSandboxExecFailed)
return
case execResult == nil:
result.add("sandbox_exec", false, "沙箱没有返回执行结果", latency)
result.skip("egress_available", skipReasonSandboxExecFailed)
return
case !strings.Contains(execResult.Stdout, marker):
logger.Warnf(ctx,
"[SandboxCheck] sandbox_exec probe mismatch sandbox=%s exit=%d killed=%v stdout=%q stderr=%q",
handle.ID(), execResult.ExitCode, execResult.Killed,
firstProbeLine(execResult.Stdout), firstProbeLine(execResult.Stderr),
)
result.add("sandbox_exec", false, describeProbeMismatch(
execResult.ExitCode, execResult.Killed,
execResult.Stdout, execResult.Stderr, "",
), latency)
result.skip("egress_available", skipReasonSandboxExecFailed)
return
default:
result.add("sandbox_exec", true, "", latency)
}
h.probeSandboxEgress(probeCtx, client, handle, cfg.Network, result)
}
// egressProbeTargets are tried in order; the first reachable one passes
// egress_available. Domestic and international endpoints cover regional
// egress policies without requiring both to succeed.
var egressProbeTargets = []struct {
label string
url string
}{
{label: "cn:baidu", url: "https://www.baidu.com"},
{label: "intl:1.1.1.1", url: "https://1.1.1.1"},
}
// probeSandboxEgress verifies the sandbox can reach the public internet.
// Any single target succeeding is enough — skill installs only need some
// outbound path, not both CN and international reachability.
func (h *SystemHandler) probeSandboxEgress(
ctx context.Context,
client sandbox.RemoteSandboxClient,
handle sandbox.RemoteSandboxHandle,
policy sandbox.RemoteNetworkPolicy,
result *SandboxCheckResponse,
) {
// Echo which target succeeded so the UI message is actionable when
// only one region is reachable. First success exits 0 immediately.
var b strings.Builder
for _, target := range egressProbeTargets {
fmt.Fprintf(&b,
`if curl -fsS -o /dev/null -m 8 -I %s; then echo %s; exit 0; fi; `,
shellSingleQuote(target.url), shellSingleQuote(target.label))
}
b.WriteString(`echo "all probes failed" >&2; exit 1`)
start := time.Now()
execResult, err := client.Exec(ctx, handle, sandbox.RemoteExecRequest{
Command: b.String(),
Shell: true,
User: sandbox.DefaultSandboxExecUser,
Timeout: 30 * time.Second,
})
latency := time.Since(start).Milliseconds()
switch {
case err != nil:
reportEgressProbe(result, policy, false, sandboxCheckReason(err), latency)
case execResult == nil:
reportEgressProbe(result, policy, false, "出网探测无返回", latency)
case execResult.Killed:
reportEgressProbe(result, policy, false, "出网探测超时", latency)
case execResult.ExitCode != 0:
msg := strings.TrimSpace(execResult.Stderr)
if msg == "" {
msg = strings.TrimSpace(execResult.Stdout)
}
if msg == "" {
msg = "国内与国际探测目标均不可达"
}
reportEgressProbe(result, policy, false, msg, latency)
default:
hit := strings.TrimSpace(execResult.Stdout)
if hit == "" {
hit = "ok"
}
reportEgressProbe(result, policy, true, "reachable via "+hit, latency)
}
}
// reportEgressProbe records the outbound-connectivity probe. Under a
// deny-by-default policy a blocked probe is the expected outcome — the probe
// target is not on the admin's allow list — so it is reported as "restricted
// by policy" instead of failing the whole check. Only a config that allows
// public egress can fail this probe.
func reportEgressProbe(
result *SandboxCheckResponse,
policy sandbox.RemoteNetworkPolicy,
reachable bool,
detail string,
latencyMS int64,
) {
if reachable {
result.add("egress_available", true, detail, latencyMS)
return
}
if !policy.DeniesEgressByDefault() {
result.add("egress_available", false, detail, latencyMS)
return
}
result.Checks = append(result.Checks, SandboxCheckItem{
Name: "egress_available",
OK: nil,
Reason: skipReasonEgressRestrictedByPolicy,
LatencyMS: latencyMS,
})
}
// explainSandboxCreateFailure turns a failed sandbox creation into a cause the
// operator can act on.
//
// A 404 from the provider is not proof that the template is gone: the E2B SDK
// discards the response body of any 404 on the create endpoint and substitutes
// a fixed "template not found" sentence, so the message alone cannot tell a
// deleted template apart from one whose build cannot boot yet. The catalog is
// the only place that distinction exists, so it is consulted before blaming the
// template ID the admin just picked from a list.
func explainSandboxCreateFailure(
ctx context.Context,
client sandbox.RemoteSandboxClient,
cfg *sandbox.Config,
err error,
) string {
reason := sandboxCheckReason(err)
var remoteErr *sandbox.RemoteError
if !stderrors.As(err, &remoteErr) {
return reason
}
// A create-time 404 is classified as InvalidRequest, not NotFound, because
// for the lifecycle it means "bad argument" rather than "sandbox is gone".
// Diagnosis needs the status itself.
if remoteErr.Kind != sandbox.RemoteErrorKindNotFound &&
remoteErr.StatusCode != http.StatusNotFound {
return reason
}
templateID := strings.TrimSpace(sandbox.EffectiveTemplateID(cfg))
catalog, ok := client.(sandbox.RemoteTemplateCatalog)
if !ok || templateID == "" {
return reason
}
templates, listErr := catalog.ListTemplates(ctx)
if listErr != nil {
logger.Warnf(ctx, "[SandboxCheck] template lookup after create 404 failed: %v", listErr)
return reason
}
for _, tpl := range templates {
if tpl.ID != templateID && !strings.EqualFold(tpl.Name, templateID) {
continue
}
if tpl.Status == sandbox.TemplateStatusUntagged {
return fmt.Sprintf(
"模板 %s 的构建已完成,但没有一个构建带上 %q 标签,创建沙箱时无法解析;"+
"请重新构建该模板(删除后由 WeKnora 重新创建即可)",
templateID, sandbox.DefaultE2BTemplateTag,
)
}
if tpl.Status == "ready" || tpl.Status == "" {
return fmt.Sprintf(
"模板 %s 在列表中已就绪,但集群拒绝创建沙箱(HTTP 404);"+
"通常是构建快照尚未生效,请稍后重试或重新构建模板",
templateID,
)
}
return fmt.Sprintf(
"模板 %s 存在,但构建状态为 %s,还不能启动沙箱;请等待构建完成或查看构建日志",
templateID, tpl.Status,
)
}
return fmt.Sprintf(
"当前 API Key 看不到模板 %s:模板可能已删除,或该 Key 属于其他团队/集群",
templateID,
)
}
// shellSingleQuote wraps s for safe inclusion in a single-quoted shell string.
func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func dockerUnavailableCheckReason(err *sandbox.RemoteError) string {
host := dockerHostFromUnavailableMessage(err.Message)
if host == "" {
detail := firstProbeLine(err.Message)
if detail == "" {
return "无法连接 Docker 守护进程"
}
return "无法连接 Docker 守护进程:" + detail
}
return "无法连接 Docker 守护进程 " + host +
"。留空地址时跟随本机 docker CLI(DOCKER_HOST 或当前 docker context)。" +
"Colima 一般是 unix://$HOME/.colima/default/docker.sock;" +
"WeKnora 跑在容器里时需要把该 socket 挂进 app。"
}
func dockerHostFromUnavailableMessage(message string) string {
const prefix = "Cannot connect to the Docker daemon at "
rest, ok := strings.CutPrefix(message, prefix)
if !ok {
return ""
}
if end := strings.IndexAny(rest, " \t"); end > 0 {
rest = rest[:end]
}
return strings.TrimSuffix(strings.TrimSpace(rest), ".")
}
// sandboxCheckReason turns a provider error into a readable cause using the
// adapter's normalized RemoteError.Kind, so the UI never shows raw SDK text.
func sandboxCheckReason(err error) string {
if err == nil {
return ""
}
var remoteErr *sandbox.RemoteError
if !stderrors.As(err, &remoteErr) {
return err.Error()
}
switch remoteErr.Kind {
case sandbox.RemoteErrorKindAuthentication:
return "认证失败:API Key 无效或无权限"
case sandbox.RemoteErrorKindNotFound:
return "资源不存在:请检查模板 ID"
case sandbox.RemoteErrorKindTimeout:
return "请求超时:端点不可达或响应过慢"
case sandbox.RemoteErrorKindUnavailable:
if remoteErr.Provider == sandbox.SandboxTypeDocker {
return dockerUnavailableCheckReason(remoteErr)
}
return "服务不可用:端点拒绝连接"
case sandbox.RemoteErrorKindCapacity:
return "配额不足或触发限流"
case sandbox.RemoteErrorKindUnsupported:
return "该后端不支持此操作"
case sandbox.RemoteErrorKindInvalidRequest:
return "参数无效:" + remoteErr.Message
default:
return remoteErr.Message
}
}
+137
View File
@@ -0,0 +1,137 @@
package handler
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
func TestSandboxConnectionCheckConfigAllowsTemplateDiscoveryAfterConnection(t *testing.T) {
incoming := &types.TenantSandboxConfig{
SandboxType: "e2b",
E2B: &types.E2BSandboxConfig{APIKey: "key"},
}
got := sandboxConnectionCheckConfig(incoming)
require.Equal(t, "__connection_check__", got.E2B.TemplateID)
require.Empty(t, incoming.E2B.TemplateID, "the submitted form must not be mutated")
}
func TestSandboxCheckReasonDockerUnavailableIncludesHost(t *testing.T) {
msg := sandboxCheckReason(&sandbox.RemoteError{
Kind: sandbox.RemoteErrorKindUnavailable,
Provider: sandbox.SandboxTypeDocker,
Message: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?",
})
require.Contains(t, msg, "unix:///var/run/docker.sock")
require.Contains(t, msg, "docker context")
}
func TestRunStatelessSandboxCheckRemovedWithLocalBackend(t *testing.T) {
incoming := &types.TenantSandboxConfig{SandboxType: "local"}
_, err := sandbox.ResolveEffectiveConfig(incoming, sandbox.DefaultConfig())
require.ErrorIs(t, err, sandbox.ErrUnsupportedSandboxType)
}
// The probe has to run under the policy the admin configured, or
// egress_available answers a question nobody asked: whether the provider's
// default allows egress.
func TestDeepSandboxCheckUsesConfiguredNetworkPolicy(t *testing.T) {
incoming := &types.TenantSandboxConfig{
SandboxType: "cube",
Cube: &types.CubeSandboxConfig{
APIURL: "https://203.0.113.20", ProxyURL: "https://203.0.113.20",
SandboxDomain: "cube.app", TemplateID: "tpl-1",
},
Network: &types.SandboxNetworkPolicy{
DenyEgressByDefault: true,
AllowOut: []string{"api.example.com"},
},
}
effective, err := sandbox.ResolveEffectiveConfig(incoming, sandbox.DefaultConfig())
require.NoError(t, err)
require.NotNil(t, effective.Network.AllowInternetAccess)
require.False(t, *effective.Network.AllowInternetAccess)
require.Equal(t, []string{"api.example.com"}, effective.Network.AllowOut)
}
// Under a deny-by-default policy a blocked probe is the policy working, not a
// misconfiguration, so it must not be reported as a failure.
func TestDeepSandboxCheckReportsEgressRestrictedRatherThanFailed(t *testing.T) {
result := &SandboxCheckResponse{OK: true, Provider: "cube"}
denied := false
reportEgressProbe(result, sandbox.RemoteNetworkPolicy{
AllowInternetAccess: &denied,
AllowOut: []string{"api.example.com"},
}, false, "curl: (28) timeout", 0)
require.True(t, result.OK, "a policy-blocked probe must not fail the check")
item := result.Checks[len(result.Checks)-1]
require.Equal(t, "egress_available", item.Name)
require.Nil(t, item.OK)
require.Equal(t, skipReasonEgressRestrictedByPolicy, item.Reason)
require.Empty(t, item.Message, "skip reasons are localized by the UI")
}
// ValidateSandboxNetworkPolicy accepts two equivalent spellings of the
// deny-all fallback — "默认拒绝" and a 0.0.0.0/0 entry in deny_out — and the
// drawer offers both. A config that used the second one is behaving exactly as
// designed when the probe is blocked, so reporting a hard failure would cry
// wolf over a correct configuration.
func TestDeepSandboxCheckReportsEgressRestrictedForDenyOutSpelling(t *testing.T) {
result := &SandboxCheckResponse{OK: true, Provider: "cube"}
allowed := true
reportEgressProbe(result, sandbox.RemoteNetworkPolicy{
AllowInternetAccess: &allowed,
AllowOut: []string{"api.example.com"},
DenyOut: []string{"0.0.0.0/0"},
}, false, "curl: (28) timeout", 0)
require.True(t, result.OK, "a policy-blocked probe must not fail the check")
item := result.Checks[len(result.Checks)-1]
require.Equal(t, "egress_available", item.Name)
require.Nil(t, item.OK)
require.Equal(t, skipReasonEgressRestrictedByPolicy, item.Reason)
require.Empty(t, item.Message, "skip reasons are localized by the UI")
}
// A deny list that does not cover everything leaves the probe target reachable
// by default, so a blocked probe there is a genuine failure.
func TestDeepSandboxCheckStillFailsEgressWhenDenyListIsPartial(t *testing.T) {
result := &SandboxCheckResponse{OK: true, Provider: "cube"}
allowed := true
reportEgressProbe(result, sandbox.RemoteNetworkPolicy{
AllowInternetAccess: &allowed,
DenyOut: []string{"10.0.0.0/8"},
}, false, "curl: (28) timeout", 0)
require.False(t, result.OK)
item := result.Checks[len(result.Checks)-1]
require.NotNil(t, item.OK)
require.False(t, *item.OK)
}
func TestDeepSandboxCheckStillFailsEgressWhenPolicyAllowsAll(t *testing.T) {
result := &SandboxCheckResponse{OK: true, Provider: "cube"}
allowed := true
reportEgressProbe(result, sandbox.RemoteNetworkPolicy{
AllowInternetAccess: &allowed,
}, false, "curl: (28) timeout", 120)
require.False(t, result.OK)
item := result.Checks[len(result.Checks)-1]
require.NotNil(t, item.OK)
require.False(t, *item.OK)
}
+408
View File
@@ -0,0 +1,408 @@
package handler
import (
"context"
stderrors "errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
type sandboxConfigService interface {
Create(context.Context, uint64, service.CreateSandboxConfigInput) (*types.TenantSandboxConfigEntity, error)
List(context.Context, uint64) ([]*types.TenantSandboxConfigEntity, error)
Get(context.Context, uint64, string) (*types.TenantSandboxConfigEntity, error)
Update(context.Context, uint64, string, service.UpdateSandboxConfigInput) (*types.TenantSandboxConfigEntity, error)
Delete(context.Context, uint64, string, bool) error
Inventory(context.Context, uint64, string) (service.SandboxInventory, error)
WorkspaceScriptsDisabled(context.Context, uint64) (bool, error)
SetWorkspaceScriptsDisabled(context.Context, uint64, bool) error
QueryTemplates(context.Context, uint64, service.SandboxTemplateQueryInput) (*service.SandboxTemplateCatalog, error)
}
type sandboxTemplateQueryRequest struct {
Config *types.TenantSandboxConfig `json:"config"`
ConfigID string `json:"config_id,omitempty"`
EnsureStandard bool `json:"ensure_standard"`
ReplaceStandard bool `json:"replace_standard"`
}
// QueryTemplates returns the templates visible through an unsaved workspace
// connection. ensure_standard starts a build only when the cluster has no
// usable WeKnora template; replace_standard rebuilds that template so a
// new spec (DNS, image) can take effect. replace_standard requires config_id.
func (h *SandboxConfigHandler) QueryTemplates(c *gin.Context) {
var req sandboxTemplateQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
result, err := h.service.QueryTemplates(c.Request.Context(), sandboxConfigTenantID(c),
service.SandboxTemplateQueryInput{
Config: req.Config,
ConfigID: req.ConfigID,
EnsureStandard: req.EnsureStandard,
ReplaceStandard: req.ReplaceStandard,
})
if err != nil {
if respondSandboxConfigRefusal(c, err) {
return
}
respondSandboxConfigServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
type SandboxConfigHandler struct {
service sandboxConfigService
}
func NewSandboxConfigHandler(
service *service.TenantSandboxConfigService,
) *SandboxConfigHandler {
return &SandboxConfigHandler{service: service}
}
type sandboxConfigRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description,omitempty"`
Config *types.TenantSandboxConfig `json:"config"`
}
// sandboxConfigResponse is the only outward projection of stored configs, so a
// new read path cannot accidentally return decrypted credentials.
type sandboxConfigResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
SandboxType string `json:"sandbox_type"`
Config *types.TenantSandboxConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sandboxConfigTenantID(c *gin.Context) uint64 {
return c.GetUint64(types.TenantIDContextKey.String())
}
func toSandboxConfigResponse(e *types.TenantSandboxConfigEntity) sandboxConfigResponse {
if e == nil {
return sandboxConfigResponse{}
}
return sandboxConfigResponse{
ID: e.ID,
Name: e.Name,
Description: e.Description,
SandboxType: e.SandboxType,
Config: types.SandboxConfigForResponse(e.Config, true),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
// respondSandboxesStillLive carries the exact inventory that refused the write;
// recomputing it could race and show the admin a different reason.
func respondSandboxesStillLive(c *gin.Context, inv service.SandboxInventory) {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": gin.H{
"code": "sandboxes_still_live",
"message": "该配置仍有运行中或已暂停的沙箱,请先结束或删除相关会话,或新建一份配置",
"data": inv,
},
})
}
func respondSandboxInventoryUnverifiable(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": gin.H{
"code": "sandbox_inventory_unverifiable",
"message": "无法连接该后端核实是否仍有沙箱",
},
})
}
func respondSkillSnapshotReleaseFailed(c *gin.Context, remaining []string) {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": gin.H{
"code": "skill_snapshot_release_failed",
"message": "无法销毁该配置下的技能快照,已中止删除以免快照继续计费",
"data": gin.H{"snapshot_ids": remaining},
},
})
}
func respondSkillSnapshotBlocksTemplate(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": gin.H{
"code": "skill_snapshot_blocks_template",
"message": "该配置已安装 Skill,不能更换连接、DNS 或重建运行模板。请新建一份沙箱后再装 Skill。",
},
})
}
func respondSandboxConfigCordoned(c *gin.Context) {
c.JSON(http.StatusLocked, gin.H{
"success": false,
"error": gin.H{
"code": "sandbox_config_cordoned",
"message": "该配置正在被其他人修改,请稍后重试",
},
})
}
func respondSandboxConfigRefusal(c *gin.Context, err error) bool {
var liveErr *service.SandboxesStillLiveError
if stderrors.As(err, &liveErr) {
respondSandboxesStillLive(c, liveErr.Inventory)
return true
}
if stderrors.Is(err, service.ErrSandboxInventoryUnverifiable) {
respondSandboxInventoryUnverifiable(c)
return true
}
var releaseErr *service.SkillSnapshotReleaseFailedError
if stderrors.As(err, &releaseErr) {
respondSkillSnapshotReleaseFailed(c, releaseErr.Remaining)
return true
}
if stderrors.Is(err, service.ErrSkillSnapshotReleaseFailed) {
respondSkillSnapshotReleaseFailed(c, nil)
return true
}
if stderrors.Is(err, service.ErrSkillSnapshotBlocksTemplateChange) {
respondSkillSnapshotBlocksTemplate(c)
return true
}
if stderrors.Is(err, repository.ErrSandboxConfigCordoned) {
respondSandboxConfigCordoned(c)
return true
}
return false
}
// respondSandboxConfigServiceError promotes the service's input-validation
// sentinels to 400. They are matched as sentinels rather than by message so a
// reworded error cannot silently start returning 500 for bad input.
func respondSandboxConfigServiceError(c *gin.Context, err error) {
switch {
case stderrors.Is(err, service.ErrSandboxConfigNameRequired),
stderrors.Is(err, service.ErrNamedSandboxBackendUnsupported),
stderrors.Is(err, sandbox.ErrUnsupportedSandboxType),
stderrors.Is(err, sandbox.ErrUnsafeOutboundURL),
stderrors.Is(err, sandbox.ErrSandboxConfigIncomplete),
stderrors.Is(err, sandbox.ErrDockerBackendDisabled):
c.Error(apperrors.NewBadRequestError(err.Error()))
default:
c.Error(err)
}
}
// List godoc
// @Summary List sandbox configs
// @Description List workspace sandbox backend configs with credentials masked.
// @Tags SandboxConfig
// @Produce json
// @Success 200 {object} map[string]interface{} "Sandbox configs and defaults"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs [get]
func (h *SandboxConfigHandler) List(c *gin.Context) {
ctx := c.Request.Context()
tenantID := sandboxConfigTenantID(c)
configs, err := h.service.List(ctx, tenantID)
if err != nil {
c.Error(err)
return
}
disabled, err := h.service.WorkspaceScriptsDisabled(ctx, tenantID)
if err != nil {
c.Error(err)
return
}
data := make([]sandboxConfigResponse, 0, len(configs))
for _, cfg := range configs {
data = append(data, toSandboxConfigResponse(cfg))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": data,
"workspace_scripts_disabled": disabled,
})
}
type workspacePolicyRequest struct {
ScriptsDisabled bool `json:"scripts_disabled"`
}
// SetWorkspacePolicy toggles script execution for the whole workspace.
func (h *SandboxConfigHandler) SetWorkspacePolicy(c *gin.Context) {
var req workspacePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
tenantID := sandboxConfigTenantID(c)
if err := h.service.SetWorkspaceScriptsDisabled(c.Request.Context(), tenantID, req.ScriptsDisabled); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "workspace_scripts_disabled": req.ScriptsDisabled})
}
// Create godoc
// @Summary Create sandbox config
// @Description Create a named workspace sandbox backend config. Credentials are masked in the response.
// @Tags SandboxConfig
// @Accept json
// @Produce json
// @Param request body sandboxConfigRequest true "Sandbox backend config"
// @Success 201 {object} map[string]interface{} "Created sandbox config"
// @Failure 400 {object} apperrors.AppError "Invalid request or validation failure"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs [post]
func (h *SandboxConfigHandler) Create(c *gin.Context) {
var req sandboxConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
created, err := h.service.Create(c.Request.Context(), sandboxConfigTenantID(c),
service.CreateSandboxConfigInput{
Name: req.Name,
Description: req.Description,
Config: req.Config,
})
if err != nil {
respondSandboxConfigServiceError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "data": toSandboxConfigResponse(created)})
}
// Get godoc
// @Summary Get sandbox config
// @Description Retrieve a workspace sandbox backend config with credentials masked.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Success 200 {object} map[string]interface{} "Sandbox config"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Sandbox config not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id} [get]
func (h *SandboxConfigHandler) Get(c *gin.Context) {
cfg, err := h.service.Get(c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"))
if err != nil {
c.Error(err)
return
}
if cfg == nil {
c.Error(apperrors.NewNotFoundError("sandbox config not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": toSandboxConfigResponse(cfg)})
}
// Update godoc
// @Summary Update sandbox config
// @Description Update a sandbox backend config. Identity-field changes are refused while the config owns live or paused sandboxes.
// @Tags SandboxConfig
// @Accept json
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param request body sandboxConfigRequest true "Updated sandbox config"
// @Success 200 {object} map[string]interface{} "Updated sandbox config"
// @Failure 400 {object} apperrors.AppError "Invalid request or validation failure"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Sandbox config not found"
// @Failure 409 {object} map[string]interface{} "Live sandboxes or unverifiable inventory"
// @Failure 423 {object} map[string]interface{} "Sandbox config is being modified by another request"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id} [put]
func (h *SandboxConfigHandler) Update(c *gin.Context) {
var req sandboxConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
updated, err := h.service.Update(c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"),
service.UpdateSandboxConfigInput{
Name: req.Name,
Description: req.Description,
Config: req.Config,
})
if err != nil {
if respondSandboxConfigRefusal(c, err) {
return
}
respondSandboxConfigServiceError(c, err)
return
}
if updated == nil {
c.Error(apperrors.NewNotFoundError("sandbox config not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": toSandboxConfigResponse(updated)})
}
// Delete godoc
// @Summary Delete sandbox config
// @Description Soft-delete a sandbox backend config. force=true only overrides unverifiable provider inventory, never confirmed live sandboxes.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param force query bool false "Force delete when inventory is unverifiable"
// @Success 200 {object} map[string]interface{} "Deletion success"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 409 {object} map[string]interface{} "Live sandboxes or unverifiable inventory"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id} [delete]
func (h *SandboxConfigHandler) Delete(c *gin.Context) {
force := c.Query("force") == "true"
if err := h.service.Delete(c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), force); err != nil {
if respondSandboxConfigRefusal(c, err) {
return
}
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// Inventory godoc
// @Summary Inspect sandbox config inventory
// @Description Return live/paused sandbox inventory and affected agent names for one config.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Success 200 {object} map[string]interface{} "Sandbox inventory"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/sandboxes [get]
func (h *SandboxConfigHandler) Inventory(c *gin.Context) {
inv, err := h.service.Inventory(c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": inv})
}
+313
View File
@@ -0,0 +1,313 @@
package handler
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
)
// Responses must never carry plaintext credentials, no matter which path
// produced them.
func TestSandboxConfigResponseMasksSecrets(t *testing.T) {
cfg := &types.TenantSandboxConfig{
SandboxType: "e2b",
E2B: &types.E2BSandboxConfig{APIKey: "super-secret"},
EnvVars: map[string]string{"TOKEN": "also-secret"},
}
entity := &types.TenantSandboxConfigEntity{
ID: "cfg-a",
Name: "primary",
SandboxType: "e2b",
Config: cfg,
CreatedAt: time.Unix(1, 0),
UpdatedAt: time.Unix(2, 0),
}
body, err := json.Marshal(toSandboxConfigResponse(entity))
require.NoError(t, err)
require.NotContains(t, string(body), "super-secret")
require.NotContains(t, string(body), "also-secret")
require.Contains(t, string(body), types.RedactedSecretPlaceholder)
}
// The 409 must be machine-readable: the UI renders "release N sandboxes first"
// from it rather than parsing a message string.
func TestSandboxesStillLiveMapsTo409WithCounts(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.PUT("/sandbox-configs/:id", func(c *gin.Context) {
respondSandboxesStillLive(c, service.SandboxInventory{
SandboxCount: 3,
SessionIDs: []string{"s-1", "s-2"},
AgentNames: []string{"数据分析"},
})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/sandbox-configs/cfg-a",
strings.NewReader(`{}`))
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
var payload struct {
Error struct {
Code string `json:"code"`
Data struct {
SandboxCount int `json:"sandbox_count"`
SessionIDs []string `json:"session_ids"`
AgentNames []string `json:"agent_names"`
} `json:"data"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "sandboxes_still_live", payload.Error.Code)
require.Equal(t, 3, payload.Error.Data.SandboxCount)
require.Len(t, payload.Error.Data.SessionIDs, 2)
require.Equal(t, []string{"数据分析"}, payload.Error.Data.AgentNames)
}
func TestSandboxInventoryUnverifiableMapsToDistinct409(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.DELETE("/sandbox-configs/:id", func(c *gin.Context) {
respondSandboxInventoryUnverifiable(c)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/sandbox-configs/cfg-a", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "sandbox_inventory_unverifiable", payload.Error.Code)
require.Contains(t, payload.Error.Message, "无法连接")
}
func TestSkillSnapshotReleaseFailedMapsTo409WithRemaining(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.DELETE("/sandbox-configs/:id", func(c *gin.Context) {
if !respondSandboxConfigRefusal(c, &service.SkillSnapshotReleaseFailedError{
Remaining: []string{"snap-2"},
}) {
t.Fatal("expected snapshot release failure to map as a refusal")
}
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/sandbox-configs/cfg-a", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
var payload struct {
Error struct {
Code string `json:"code"`
Data struct {
SnapshotIDs []string `json:"snapshot_ids"`
} `json:"data"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "skill_snapshot_release_failed", payload.Error.Code)
require.Equal(t, []string{"snap-2"}, payload.Error.Data.SnapshotIDs)
}
func TestSkillSnapshotBlocksTemplateMapsTo409(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/sandbox-configs/templates/query", func(c *gin.Context) {
if !respondSandboxConfigRefusal(c, service.ErrSkillSnapshotBlocksTemplateChange) {
t.Fatal("expected skill snapshot template lock to map as a refusal")
}
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/sandbox-configs/templates/query", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "skill_snapshot_blocks_template", payload.Error.Code)
require.Contains(t, payload.Error.Message, "不能更换连接")
}
func TestSandboxConfigDeletePassesForceQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeSandboxConfigService{}
h := &SandboxConfigHandler{service: svc}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(42))
c.Next()
})
router.DELETE("/sandbox-configs/:id", h.Delete)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/sandbox-configs/cfg-a?force=true", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, uint64(42), svc.deleteTenantID)
require.Equal(t, "cfg-a", svc.deleteID)
require.True(t, svc.deleteForce)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodDelete, "/sandbox-configs/cfg-b", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "cfg-b", svc.deleteID)
require.False(t, svc.deleteForce)
}
// Bad input must not surface as a 500. The sentinels are matched with errors.Is
// so rewording a message cannot silently reclassify these.
func TestSandboxConfigValidationSentinelsMapTo400(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
err error
}{
{"missing name", service.ErrSandboxConfigNameRequired},
{"named backend unsupported", service.ErrNamedSandboxBackendUnsupported},
{"unknown backend", fmt.Errorf("%w %q", sandbox.ErrUnsupportedSandboxType, "quantum")},
{"unsafe endpoint", fmt.Errorf("%w: host is private", sandbox.ErrUnsafeOutboundURL)},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
respondSandboxConfigServiceError(c, tc.err)
require.Len(t, c.Errors, 1)
var appErr *apperrors.AppError
require.ErrorAs(t, c.Errors[0].Err, &appErr)
require.Equal(t, http.StatusBadRequest, appErr.HTTPCode)
})
}
}
// An unexpected failure must keep its original error so the middleware can map
// it, rather than being flattened into a 400.
func TestSandboxConfigUnexpectedErrorIsNotDowngraded(t *testing.T) {
gin.SetMode(gin.TestMode)
boom := stderrors.New("database is on fire")
c, _ := gin.CreateTestContext(httptest.NewRecorder())
respondSandboxConfigServiceError(c, boom)
require.Len(t, c.Errors, 1)
require.ErrorIs(t, c.Errors[0].Err, boom)
}
type fakeSandboxConfigService struct {
deleteTenantID uint64
deleteID string
deleteForce bool
}
func (s *fakeSandboxConfigService) Create(
context.Context,
uint64,
service.CreateSandboxConfigInput,
) (*types.TenantSandboxConfigEntity, error) {
return nil, nil
}
func (s *fakeSandboxConfigService) List(
context.Context,
uint64,
) ([]*types.TenantSandboxConfigEntity, error) {
return nil, nil
}
func (s *fakeSandboxConfigService) Get(
context.Context,
uint64,
string,
) (*types.TenantSandboxConfigEntity, error) {
return nil, nil
}
func (s *fakeSandboxConfigService) Update(
context.Context,
uint64,
string,
service.UpdateSandboxConfigInput,
) (*types.TenantSandboxConfigEntity, error) {
return nil, nil
}
func (s *fakeSandboxConfigService) Delete(
_ context.Context,
tenantID uint64,
id string,
force bool,
) error {
s.deleteTenantID = tenantID
s.deleteID = id
s.deleteForce = force
return nil
}
func (s *fakeSandboxConfigService) Inventory(
context.Context,
uint64,
string,
) (service.SandboxInventory, error) {
return service.SandboxInventory{}, nil
}
func (s *fakeSandboxConfigService) WorkspaceScriptsDisabled(
context.Context,
uint64,
) (bool, error) {
return false, nil
}
func (s *fakeSandboxConfigService) SetWorkspaceScriptsDisabled(
context.Context,
uint64,
bool,
) error {
return nil
}
func (s *fakeSandboxConfigService) QueryTemplates(
context.Context,
uint64,
service.SandboxTemplateQueryInput,
) (*service.SandboxTemplateCatalog, error) {
return &service.SandboxTemplateCatalog{}, nil
}
+886
View File
@@ -0,0 +1,886 @@
package handler
import (
"context"
stderrors "errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/application/service"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"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"
)
const (
// skillEventPollInterval is how often a live stream re-reads the durable
// status. It is not a nicety: a run can end without publishing anything
// (a duplicate removal returns early), and this poll is what ends the
// stream in that case.
skillEventPollInterval = 5 * time.Second
// skillEventMaxDuration bounds one stream. An install whose process died
// leaves the row at "installing" until the stuck-run reaper rewrites it,
// and a connection that waits for that forever is a leak, so the stream
// stops following and says so.
skillEventMaxDuration = 60 * time.Minute
)
// sandboxSkillService is the skill surface the admin endpoints need. Reads and
// writes are workspace-scoped by the service; the progress calls are not, which
// is why every handler here resolves the skill for the caller's workspace first.
type sandboxSkillService interface {
ListSkills(ctx context.Context, tenantID uint64, configID string) ([]*types.TenantSkillEntity, error)
GetSkill(ctx context.Context, tenantID uint64, configID, skillID string) (*types.TenantSkillEntity, error)
ListSkillFiles(
ctx context.Context, tenantID uint64, configID, skillID string,
) ([]service.SkillFileEntry, error)
ReadSkillFile(
ctx context.Context, tenantID uint64, configID, skillID, relativePath string,
) (*service.SkillFileContent, error)
UpdateSkillAdmin(
ctx context.Context, tenantID uint64, configID, skillID string,
update service.SkillAdminUpdate,
) (*types.TenantSkillEntity, error)
InstallSkill(ctx context.Context, tenantID uint64, configID string, archive []byte) (string, error)
InstallSkillFromSource(
ctx context.Context, tenantID uint64, configID, source string,
) (string, error)
ReinstallSkill(ctx context.Context, tenantID uint64, configID, skillID string) (string, error)
StopSkill(ctx context.Context, tenantID uint64, configID, skillID string) (*types.TenantSkillEntity, error)
RemoveSkill(ctx context.Context, tenantID uint64, configID, skillID string) error
LastProgress(
ctx context.Context, tenantID uint64, configID, skillID string,
) (service.SkillProgress, bool)
SubscribeProgress(
ctx context.Context, tenantID uint64, configID, skillID string,
) (<-chan service.SkillProgress, func(), error)
}
// SandboxSkillHandler serves the agent-skill endpoints of one sandbox config.
type SandboxSkillHandler struct {
service sandboxSkillService
// streams replays the installer agent's own event log. It is the same
// manager the install writes through, and it may be nil in a deployment
// without Redis, which only costs the transcript endpoint.
streams interfaces.StreamManager
// pollInterval and maxDuration are fields rather than constants so a test
// can exercise a whole stream lifecycle without waiting minutes for it.
pollInterval time.Duration
maxDuration time.Duration
}
// NewSandboxSkillHandler constructs the admin HTTP surface for tenant skills.
func NewSandboxSkillHandler(
svc sandboxSkillService, streams interfaces.StreamManager,
) *SandboxSkillHandler {
return &SandboxSkillHandler{
service: svc,
streams: streams,
pollInterval: skillEventPollInterval,
maxDuration: skillEventMaxDuration,
}
}
// skillResponse is the outward projection of an installed skill. The SKILL.md
// body is deliberately omitted: it is level-2 disclosure for the agent, and a
// list of them would dwarf the response.
type skillResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
BundleSHA256 string `json:"bundle_sha256,omitempty"`
InstalledSnapshotID string `json:"installed_snapshot_id,omitempty"`
InstallSessionID string `json:"install_session_id,omitempty"`
InstallMessageID string `json:"install_message_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Envs []skillEnvResponse `json:"envs,omitempty"`
}
// skillEnvResponse is one declared environment variable. It reports whether a
// workspace value exists and never what it is: a stored credential is written
// once and read only by the sandbox that needs it.
type skillEnvResponse struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
IsSet bool `json:"is_set"`
}
func toSkillEnvResponses(envs types.SkillEnvVars) []skillEnvResponse {
if len(envs) == 0 {
return nil
}
out := make([]skillEnvResponse, 0, len(envs))
for _, entry := range envs {
out = append(out, skillEnvResponse{
Name: entry.Name,
Description: entry.Description,
Required: entry.Required,
IsSet: entry.Value != "",
})
}
return out
}
func toSkillResponse(e *types.TenantSkillEntity) skillResponse {
if e == nil {
return skillResponse{}
}
return skillResponse{
ID: e.ID,
Name: e.Name,
Version: e.Version,
Description: e.Description,
Enabled: e.Enabled,
Status: e.Status,
Error: e.Error,
BundleSHA256: e.BundleSHA256,
InstalledSnapshotID: e.InstalledSnapshotID,
InstallSessionID: e.InstallSessionID,
InstallMessageID: e.InstallMessageID,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Envs: toSkillEnvResponses(e.Envs),
}
}
// respondSkillServiceError promotes every rejection of an uploaded archive to
// 400. It is matched as a sentinel rather than by message so a reworded
// validation error cannot silently start returning 500 for bad input.
func respondSkillServiceError(c *gin.Context, err error) {
if stderrors.Is(err, service.ErrSkillBundleInvalid) || stderrors.Is(err, service.ErrSkillSourceInvalid) {
_ = c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
_ = c.Error(err)
}
// List godoc
// @Summary List installed skills
// @Description List the agent skills installed onto one sandbox config's image.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Success 200 {object} map[string]interface{} "Installed skills"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Sandbox config not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills [get]
func (h *SandboxSkillHandler) List(c *gin.Context) {
skills, err := h.service.ListSkills(c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
data := make([]skillResponse, 0, len(skills))
for _, skill := range skills {
data = append(data, toSkillResponse(skill))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
// Get godoc
// @Summary Get an installed skill
// @Description Retrieve one installed skill of a sandbox config.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 200 {object} map[string]interface{} "Installed skill"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId} [get]
func (h *SandboxSkillHandler) Get(c *gin.Context) {
skill, err := h.resolveSkill(c)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": toSkillResponse(skill)})
}
// ListFiles godoc
// @Summary List files of an installed skill
// @Description List files in the stored skill bundle without starting a sandbox.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 200 {object} map[string]interface{} "Skill files"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill or files not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/files [get]
func (h *SandboxSkillHandler) ListFiles(c *gin.Context) {
files, err := h.service.ListSkillFiles(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), c.Param("skillId"),
)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": files})
}
// GetFile godoc
// @Summary Read one file of an installed skill
// @Description Read one skill file as UTF-8, a small base64 image, or binary.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Param path query string true "Skill-root-relative file path"
// @Success 200 {object} map[string]interface{} "Skill file"
// @Failure 400 {object} apperrors.AppError "Invalid path"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill or file not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/files/content [get]
func (h *SandboxSkillHandler) GetFile(c *gin.Context) {
file, err := h.service.ReadSkillFile(
c.Request.Context(), sandboxConfigTenantID(c),
c.Param("id"), c.Param("skillId"), c.Query("path"),
)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": file})
}
// Upload godoc
// @Summary Install a skill
// @Description Install a skill onto this sandbox config's image. Send a zip
// @Description as multipart form field "file", or JSON {"source":"..."} to
// @Description pull a public skill. source is one of: "@owner/slug" or a
// @Description slash-free slug (ClawHub), a github.com / gitlab.com /
// @Description skills.sh / clawhub / skillhub URL, a ClawHub skills-sh
// @Description catalog page (https://clawhub.ai/skills-sh/owner/repo/slug),
// @Description a skills-sh:owner/repo/slug locator, or a direct zip/SKILL.md
// @Description URL. Bare "owner/slug" is rejected as ambiguous. The source
// @Description must be readable anonymously. The install boots a sandbox and
// @Description runs for minutes, so the request is only accepted; follow it
// @Description via the install-events stream.
// @Tags SandboxConfig
// @Accept json
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param file formData file false "Skill bundle (zip)"
// @Param request body skillSourceRequest false "Install from a registry, git host, or archive URL"
// @Success 202 {object} map[string]interface{} "Install accepted"
// @Failure 400 {object} apperrors.AppError "Missing, oversized or invalid bundle or source"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Sandbox config not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills [post]
func (h *SandboxSkillHandler) Upload(c *gin.Context) {
maxBytes := secutils.GetMaxSkillBundleSize()
limitSkillUploadBody(c, maxBytes)
if strings.HasPrefix(c.ContentType(), "application/json") {
h.installFromSource(c)
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
if isRequestBodyTooLarge(err) {
_ = c.Error(skillTooLargeError())
return
}
_ = c.Error(apperrors.NewBadRequestError("file is required"))
return
}
defer func() { _ = file.Close() }()
if header.Size > maxBytes {
_ = c.Error(skillTooLargeError())
return
}
archive, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil {
_ = c.Error(apperrors.NewBadRequestError("failed to read the uploaded skill bundle"))
return
}
// A multipart part may under-declare its size, so the bytes actually read
// are checked too.
if int64(len(archive)) > maxBytes {
_ = c.Error(skillTooLargeError())
return
}
skillID, err := h.service.InstallSkill(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), archive,
)
if err != nil {
respondSkillServiceError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": gin.H{"skill_id": skillID}})
}
type skillSourceRequest struct {
// Source is exactly one of: "@owner/slug" or a slash-free slug (ClawHub),
// a github.com / gitlab.com / skills.sh / clawhub / skillhub page URL, a
// ClawHub skills-sh catalog page or "skills-sh:owner/repo/slug" locator, or
// a direct zip/SKILL.md URL. Bare "owner/slug" is rejected: it is both a
// ClawHub id and a GitHub repo. The fetch carries no credential.
Source string `json:"source"`
}
func (h *SandboxSkillHandler) installFromSource(c *gin.Context) {
var req skillSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
var tooLarge *http.MaxBytesError
if stderrors.As(err, &tooLarge) {
_ = c.Error(skillSourceRequestTooLargeError())
return
}
_ = c.Error(apperrors.NewBadRequestError("invalid skill source request"))
return
}
source := strings.TrimSpace(req.Source)
if source == "" {
_ = c.Error(apperrors.NewBadRequestError("source is required"))
return
}
skillID, err := h.service.InstallSkillFromSource(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), source,
)
if err != nil {
respondSkillServiceError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": gin.H{"skill_id": skillID}})
}
// Reinstall godoc
// @Summary Retry a skill install
// @Description Retry a failed install from the stored archive; does not re-upload.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 202 {object} map[string]interface{} "Reinstall accepted"
// @Failure 400 {object} apperrors.AppError "The stored archive is gone"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/reinstall [post]
func (h *SandboxSkillHandler) Reinstall(c *gin.Context) {
skillID, err := h.service.ReinstallSkill(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), c.Param("skillId"),
)
if err != nil {
respondSkillServiceError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": gin.H{"skill_id": skillID}})
}
// Stop godoc
// @Summary Stop a skill install
// @Description Abort an in-flight install so the operator can retry or uninstall. After a process restart the row may still say installing with no live process; this rewrites it immediately instead of waiting for the stuck-run reaper. Removal is not stopped.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 200 {object} map[string]interface{} "Stopped skill"
// @Failure 400 {object} apperrors.AppError "Skill is not installing"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/stop [post]
func (h *SandboxSkillHandler) Stop(c *gin.Context) {
skill, err := h.service.StopSkill(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), c.Param("skillId"),
)
if err != nil {
respondSkillServiceError(c, err)
return
}
if skill == nil {
_ = c.Error(apperrors.NewNotFoundError("skill not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": toSkillResponse(skill)})
}
func skillTooLargeError() error {
return apperrors.NewBadRequestError(
fmt.Sprintf("skill bundle cannot exceed %d MB", secutils.GetMaxSkillBundleSizeMB()))
}
func skillSourceRequestTooLargeError() error {
return apperrors.NewBadRequestError("skill source request is too large")
}
func skillJSONRequestTooLargeError() error {
return apperrors.NewBadRequestError("skill request is too large")
}
type skillPatchRequest struct {
// Enabled is a pointer because its absence is not a request to disable the
// skill; a body may carry envs instead.
Enabled *bool `json:"enabled"`
// Envs is a pointer to a map because "sent an empty object" and "did not
// mention envs" are different requests: the first clears what it names,
// the second must leave every stored value alone.
Envs *map[string]string `json:"envs"`
}
// Patch godoc
// @Summary Update an installed skill
// @Description Show or hide an installed skill and set the workspace-wide values of the environment variables it declared. Either field may be sent, or both. The files stay in the image either way; removal is a separate flow.
// @Tags SandboxConfig
// @Accept json
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Param request body skillPatchRequest true "Fields to update"
// @Success 200 {object} map[string]interface{} "Updated skill"
// @Failure 400 {object} apperrors.AppError "Invalid request"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId} [patch]
func (h *SandboxSkillHandler) Patch(c *gin.Context) {
limitJSONBody(c, skillSourceJSONMaxBytes)
var req skillPatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
if isRequestBodyTooLarge(err) {
_ = c.Error(skillJSONRequestTooLargeError())
return
}
_ = c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
if req.Enabled == nil && req.Envs == nil {
_ = c.Error(apperrors.NewBadRequestError("enabled or envs is required"))
return
}
ctx := c.Request.Context()
tenantID := sandboxConfigTenantID(c)
configID, skillID := c.Param("id"), c.Param("skillId")
// Both fields go down in one call so the request is all-or-nothing: two
// service calls could persist the toggle and then fail the values, which
// is exactly what a half-applied credential rotation looks like.
update := service.SkillAdminUpdate{Enabled: req.Enabled}
if req.Envs != nil {
update.EnvValues = *req.Envs
}
updated, err := h.service.UpdateSkillAdmin(ctx, tenantID, configID, skillID, update)
if err != nil {
_ = c.Error(err)
return
}
if updated == nil {
_ = c.Error(apperrors.NewNotFoundError("skill not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": toSkillResponse(updated)})
}
// Delete godoc
// @Summary Remove an installed skill
// @Description Remove a skill from the config's image. The removal rebuilds
// @Description the image and runs for minutes, so it is only accepted; follow
// @Description it via the install-events stream.
// @Tags SandboxConfig
// @Produce json
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 202 {object} map[string]interface{} "Removal accepted"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId} [delete]
func (h *SandboxSkillHandler) Delete(c *gin.Context) {
skillID := c.Param("skillId")
err := h.service.RemoveSkill(
c.Request.Context(), sandboxConfigTenantID(c), c.Param("id"), skillID,
)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": gin.H{"skill_id": skillID}})
}
// skillInstallEvent is one frame of the install/removal stream. Done is carried
// explicitly so a client terminates on the flag rather than on a stage name it
// has to keep in sync with the server.
type skillInstallEvent struct {
Percent int `json:"percent"`
Stage string `json:"stage"`
Log string `json:"log,omitempty"`
Status string `json:"status,omitempty"`
Done bool `json:"done"`
}
// Terminal stages, as published by the service when a run reaches 100%.
const (
skillStageDone = "done"
skillStageFailed = "failed"
// skillStageDetached is the handler's own: the stream stopped following a
// run that is still in progress. It is not a verdict on the install.
skillStageDetached = "detached"
)
// skillStatusRemoved is the status a finished removal publishes. The row is
// gone by then, so it is also what a synthesized terminal event reports.
const skillStatusRemoved = "removed"
func skillEventFromProgress(p service.SkillProgress) skillInstallEvent {
return skillInstallEvent{
Percent: p.Percent,
Stage: p.Stage,
Log: p.Log,
Status: p.Status,
Done: p.Stage == skillStageDone || p.Stage == skillStageFailed,
}
}
// terminalSkillEvent derives an end-of-stream frame from the durable state, for
// every run that ends without publishing one: a duplicate removal returns
// early, and a run whose process died publishes nothing ever again. A nil skill
// is a finished removal — the row is deleted by the last step of one.
func terminalSkillEvent(skill *types.TenantSkillEntity) (skillInstallEvent, bool) {
if skill == nil {
return skillInstallEvent{
Percent: 100, Stage: skillStageDone, Status: skillStatusRemoved, Done: true,
}, true
}
switch skill.Status {
case types.SkillStatusInstalling, types.SkillStatusRemoving:
return skillInstallEvent{}, false
case types.SkillStatusFailed:
return skillInstallEvent{
Percent: 100, Stage: skillStageFailed, Status: skill.Status,
Log: skill.Error, Done: true,
}, true
default:
return skillInstallEvent{
Percent: 100, Stage: skillStageDone, Status: skill.Status,
Log: skill.Error, Done: true,
}, true
}
}
// InstallEvents godoc
// @Summary Follow an install or removal
// @Description Server-sent progress for one install or removal. The stream
// @Description always terminates: with the run's own terminal event, with one
// @Description derived from the durable status, or with a "detached" frame
// @Description when it stops following a run that is still going.
// @Tags SandboxConfig
// @Produce text/event-stream
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 200 {string} string "SSE stream of progress events"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/install-events [get]
func (h *SandboxSkillHandler) InstallEvents(c *gin.Context) {
ctx := c.Request.Context()
tenantID := sandboxConfigTenantID(c)
configID, skillID := c.Param("id"), c.Param("skillId")
// The progress key is workspace-scoped, so this lookup is not what isolates
// the stream; it is what turns "this skill is not yours" into a 404 while a
// refusal can still be rendered as JSON, before any SSE header is written.
skill, err := h.resolveSkill(c)
if err != nil {
_ = c.Error(err)
return
}
// Subscribing before the first read means an event published between the
// two is delivered rather than missed.
events, release, err := h.service.SubscribeProgress(ctx, tenantID, configID, skillID)
if err != nil {
_ = c.Error(err)
return
}
defer release()
setSandboxSkillSSEHeaders(c)
// lastPercent is what a detached frame reports, so giving up on following
// a run does not appear to reset its progress.
lastPercent := 0
if last, ok := h.service.LastProgress(ctx, tenantID, configID, skillID); ok {
event := skillEventFromProgress(last)
lastPercent = event.Percent
if !h.emit(c, event) || event.Done {
return
}
}
if terminal, ok := terminalSkillEvent(skill); ok {
h.emit(c, terminal)
return
}
if events == nil {
// Nothing publishes progress without Redis. One frame stating the
// durable status is all this connection can ever say.
h.emit(c, skillInstallEvent{
Stage: skill.Status,
Status: skill.Status,
Log: "live progress is unavailable; poll the skill for its status",
Done: true,
})
return
}
poll := time.NewTicker(h.pollInterval)
defer poll.Stop()
deadline := time.NewTimer(h.maxDuration)
defer deadline.Stop()
for {
select {
case <-ctx.Done():
// The client is gone. The install keeps running; only this view
// of it ends.
return
case p, ok := <-events:
if !ok {
// The subscription ended under us. The poll below is now the
// only source of truth, so keep the connection until it
// reaches a terminal state or the cap expires.
events = nil
continue
}
event := skillEventFromProgress(p)
lastPercent = event.Percent
if !h.emit(c, event) || event.Done {
return
}
case <-poll.C:
current, err := h.service.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
logger.Warnf(ctx, "[skill] re-read %s while streaming failed: %v", skillID, err)
if !h.emitComment(c) {
return
}
continue
}
if terminal, ok := terminalSkillEvent(current); ok {
h.emit(c, terminal)
return
}
// Nothing to report: a comment keeps the connection warm and
// surfaces a client that has already gone away.
if !h.emitComment(c) {
return
}
case <-deadline.C:
h.emit(c, skillInstallEvent{
Percent: lastPercent,
Stage: skillStageDetached,
Status: skill.Status,
Log: "stopped following this run; reconnect or poll the skill for its status",
Done: true,
})
return
}
}
}
// InstallTranscript godoc
// @Summary Follow an install's agent transcript
// @Description Server-sent replay of everything the installer agent did — the
// @Description prompt it was given, its thinking, the commands it ran and
// @Description their output — followed live while the install is still
// @Description running. Frames are the same shape the chat stream uses, so a
// @Description console renders an install with the components it renders a
// @Description chat turn with. 404 once the event log has expired; the
// @Description durable message history is the fallback.
// @Tags SandboxConfig
// @Produce text/event-stream
// @Param id path string true "Sandbox config ID"
// @Param skillId path string true "Skill ID"
// @Success 200 {string} string "SSE stream of transcript events"
// @Success 204 {string} string "Install is still preparing; retry once locators exist"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 404 {object} apperrors.AppError "Skill or transcript not found"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sandbox-configs/{id}/skills/{skillId}/transcript [get]
//
// The transcript deliberately does not reuse /sessions/continue-stream. That
// endpoint authorizes by "does this chat session belong to you", while an
// install is a workspace-level maintenance run whose session is hidden from the
// session list on purpose — the two rules pull in opposite directions. Here the
// skill lookup above is the authorization, exactly as it is for every other
// route in this file.
func (h *SandboxSkillHandler) InstallTranscript(c *gin.Context) {
ctx := c.Request.Context()
skill, err := h.resolveSkill(c)
if err != nil {
_ = c.Error(err)
return
}
sessionID, messageID := skill.InstallSessionID, skill.InstallMessageID
if sessionID == "" || messageID == "" {
// The skill row exists the moment the upload is accepted; locators
// are written only after the installer sandbox is up. A 404 here is
// "not yet", not "gone", and the access log would WARN on every poll.
if skill.Status == types.SkillStatusInstalling {
c.Status(http.StatusNoContent)
return
}
_ = c.Error(apperrors.NewNotFoundError("this skill has no install transcript"))
return
}
if h.streams == nil {
_ = c.Error(apperrors.NewNotFoundError("install transcripts are unavailable"))
return
}
events, offset, err := h.streams.GetEvents(ctx, sessionID, messageID, 0)
if err != nil {
logger.Errorf(ctx, "[skill] read install transcript of %s failed: %v", skill.ID, err)
_ = c.Error(apperrors.NewInternalServerError(err.Error()))
return
}
// An empty log means the run predates the transcript or its TTL has passed.
// Refuse before any SSE header is written so the caller can still fall back
// to the durable message history.
if len(events) == 0 {
_ = c.Error(apperrors.NewNotFoundError("this install's event log is no longer available"))
return
}
setSandboxSkillSSEHeaders(c)
done := h.emitTranscript(c, sessionID, messageID, events)
if done {
return
}
// Tailing at the chat stream's cadence: the installer emits thinking token
// by token, and a slower tick would arrive as visible bursts.
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
deadline := time.NewTimer(h.maxDuration)
defer deadline.Stop()
for {
select {
case <-ctx.Done():
// The viewer navigated away. The install keeps running.
return
case <-deadline.C:
return
case <-tick.C:
newEvents, newOffset, err := h.streams.GetEvents(ctx, sessionID, messageID, offset)
if err != nil {
logger.Warnf(ctx, "[skill] tail install transcript of %s failed: %v", skill.ID, err)
return
}
offset = newOffset
if h.emitTranscript(c, sessionID, messageID, newEvents) {
return
}
}
}
}
// emitTranscript writes frames and reports whether the stream is over, either
// because the run completed or because the viewer is gone.
func (h *SandboxSkillHandler) emitTranscript(
c *gin.Context, sessionID, messageID string, events []interfaces.StreamEvent,
) bool {
for _, evt := range events {
c.SSEvent("message", types.StreamResponse{
// Every frame carries the assistant message ID so the console
// groups the whole run into one turn.
ID: messageID,
ResponseType: evt.Type,
Content: evt.Content,
Done: evt.Done,
SessionID: sessionID,
AssistantMessageID: messageID,
Data: evt.Data,
})
c.Writer.Flush()
if c.Request.Context().Err() != nil {
return true
}
if evt.Type == types.ResponseTypeComplete {
return true
}
}
return c.Request.Context().Err() != nil
}
// resolveSkill loads the skill for the caller's workspace and config, and
// returns the 404 every route renders when it is not reachable.
func (h *SandboxSkillHandler) resolveSkill(c *gin.Context) (*types.TenantSkillEntity, error) {
skill, err := h.service.GetSkill(c.Request.Context(), sandboxConfigTenantID(c),
c.Param("id"), c.Param("skillId"))
if err != nil {
return nil, err
}
if skill == nil {
return nil, apperrors.NewNotFoundError("skill not found")
}
return skill, nil
}
// emit writes one frame and reports whether the stream can continue.
func (h *SandboxSkillHandler) emit(c *gin.Context, event skillInstallEvent) bool {
c.SSEvent("message", event)
c.Writer.Flush()
return c.Request.Context().Err() == nil
}
func (h *SandboxSkillHandler) emitComment(c *gin.Context) bool {
if _, err := c.Writer.WriteString(": keep-alive\n\n"); err != nil {
return false
}
c.Writer.Flush()
return c.Request.Context().Err() == nil
}
// setSandboxSkillSSEHeaders mirrors the session package's SSE preamble;
// X-Accel-Buffering is what stops nginx from holding progress frames back.
func setSandboxSkillSSEHeaders(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
}
+124
View File
@@ -0,0 +1,124 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/types"
)
// skillWithEnvDeclaration is a row carrying everything the projection has to
// keep out of a response: the stored credential, the SKILL.md body and the
// pointer to the uploaded archive.
func skillWithEnvDeclaration() *types.TenantSkillEntity {
return &types.TenantSkillEntity{
ID: "skill-1", TenantID: testSkillTenantID, SandboxConfigID: "cfg-a",
Name: "pdf", Status: types.SkillStatusReady, Enabled: true,
Instructions: "run scripts/extract.py to read a pdf",
BundleRef: "tenant/42/skills/pdf.zip",
Envs: types.SkillEnvVars{
{Name: "API_TOKEN", Description: "the workspace token", Required: true, Value: "admin-secret"},
{Name: "REGION", Description: "which region"},
},
}
}
// The response says whether a value exists and never what it is. Instructions
// and the bundle pointer are level-2 disclosure for the agent, not for a
// listing.
func TestSkillResponseReportsIsSetWithoutTheValue(t *testing.T) {
raw, err := json.Marshal(toSkillResponse(skillWithEnvDeclaration()))
require.NoError(t, err)
body := string(raw)
require.NotContains(t, body, "admin-secret")
require.NotContains(t, body, "scripts/extract.py")
require.NotContains(t, body, "pdf.zip")
require.NotContains(t, body, `"value"`)
require.Contains(t, body, `"API_TOKEN"`)
require.Contains(t, body, "the workspace token")
require.Contains(t, body, `"is_set":true`)
require.Contains(t, body, `"required":true`)
require.Contains(t, body, `"is_set":false`)
}
func TestSkillResponsePatchWritesEnvValues(t *testing.T) {
skill := skillWithEnvDeclaration()
svc := &fakeSandboxSkillService{skills: map[string]*types.TenantSkillEntity{"skill-1": skill}}
router := newSkillTestRouter(NewSandboxSkillHandler(svc, nil))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/sandbox-configs/cfg-a/skills/skill-1",
strings.NewReader(`{"envs":{"API_TOKEN":"rotated"}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, map[string]string{"API_TOKEN": "rotated"}, svc.patchEnvs)
require.NotContains(t, w.Body.String(), "rotated")
require.True(t, svc.skills["skill-1"].Enabled, "envs-only patch must not touch visibility")
}
// An empty object is a real request — "clear everything you sent me" — and is
// distinguishable from a body that never mentioned envs at all.
func TestSkillResponsePatchAcceptsAnEmptyEnvsObject(t *testing.T) {
svc := &fakeSandboxSkillService{skills: map[string]*types.TenantSkillEntity{
"skill-1": skillWithEnvDeclaration(),
}}
router := newSkillTestRouter(NewSandboxSkillHandler(svc, nil))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/sandbox-configs/cfg-a/skills/skill-1",
strings.NewReader(`{"envs":{}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, svc.patchEnvs)
require.Empty(t, svc.patchEnvs)
}
// The existing contract must survive: an enabled-only body is still a valid
// request and must not start requiring envs.
func TestSkillResponsePatchStillAcceptsEnabledAlone(t *testing.T) {
svc := &fakeSandboxSkillService{skills: map[string]*types.TenantSkillEntity{
"skill-1": skillWithEnvDeclaration(),
}}
router := newSkillTestRouter(NewSandboxSkillHandler(svc, nil))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/sandbox-configs/cfg-a/skills/skill-1",
strings.NewReader(`{"enabled":false}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.False(t, svc.patchEnabled)
require.Nil(t, svc.patchEnvs, "an enabled-only body must not be read as clearing values")
}
func TestSkillResponsePatchAcceptsEnabledAndEnvsTogether(t *testing.T) {
svc := &fakeSandboxSkillService{skills: map[string]*types.TenantSkillEntity{
"skill-1": skillWithEnvDeclaration(),
}}
router := newSkillTestRouter(NewSandboxSkillHandler(svc, nil))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/sandbox-configs/cfg-a/skills/skill-1",
strings.NewReader(`{"enabled":false,"envs":{"REGION":"eu"}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.False(t, svc.patchEnabled)
require.Equal(t, map[string]string{"REGION": "eu"}, svc.patchEnvs)
require.Equal(t, 1, svc.patchCalls,
"both fields must reach the service in one call, or a failed second write "+
"leaves the first already persisted")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,845 @@
package session
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/agent/skills"
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// AgentStreamHandler handles agent events for SSE streaming
// It uses a dedicated EventBus per request to avoid SessionID filtering
// Events are appended to StreamManager without accumulation
type AgentStreamHandler struct {
ctx context.Context
sessionID string
tenantID uint64 // Tenant that owns this session; used when persisting skill artifacts.
assistantMessageID string
requestID string
receivedAt time.Time // Handler entry timestamp, used for TTFB logging
ttfbLogged bool // Guards one-shot TTFB log on first answer chunk
assistantMessage *types.Message
streamManager interfaces.StreamManager
eventBus *event.EventBus
// artifactCollector drains skill-generated files from the session
// sandbox after the agent completes. Nil when the sandbox backend
// doesn't support artifact collection or WeKnora was built without it.
artifactCollector *service.ArtifactCollector
// State tracking
knowledgeRefs []*types.SearchResult
finalAnswer string
answerSegments []*answerSegment // Per-answer-event-ID accumulation, so superseded preambles can be dropped
eventStartTimes map[string]time.Time // Track start time for duration calculation
mu sync.Mutex
}
// answerSegment accumulates the streamed content of a single final-answer event
// ID. A non-terminal round may stream a preamble ("let me search…") under its
// own answer ID and then be marked superseded once the round turns out to call
// tools; tracking segments separately lets us exclude that preamble from the
// persisted assistant message instead of leaking it into the final answer.
type answerSegment struct {
id string
content string
superseded bool
}
// findAnswerSegment returns the segment for an answer event ID, or nil.
// Callers must hold h.mu.
func (h *AgentStreamHandler) findAnswerSegment(id string) *answerSegment {
for _, seg := range h.answerSegments {
if seg.id == id {
return seg
}
}
return nil
}
// composeFinalAnswer rebuilds the persisted answer from all non-superseded
// segments in arrival order. Callers must hold h.mu.
func (h *AgentStreamHandler) composeFinalAnswer() string {
var b strings.Builder
for _, seg := range h.answerSegments {
if !seg.superseded {
b.WriteString(seg.content)
}
}
return b.String()
}
// NewAgentStreamHandler creates a new handler for agent SSE streaming
func NewAgentStreamHandler(
ctx context.Context,
sessionID, assistantMessageID, requestID string,
tenantID uint64,
receivedAt time.Time,
assistantMessage *types.Message,
streamManager interfaces.StreamManager,
eventBus *event.EventBus,
artifactCollector *service.ArtifactCollector,
) *AgentStreamHandler {
return &AgentStreamHandler{
ctx: ctx,
sessionID: sessionID,
tenantID: tenantID,
assistantMessageID: assistantMessageID,
requestID: requestID,
receivedAt: receivedAt,
assistantMessage: assistantMessage,
streamManager: streamManager,
eventBus: eventBus,
artifactCollector: artifactCollector,
knowledgeRefs: make([]*types.SearchResult, 0),
eventStartTimes: make(map[string]time.Time),
}
}
// Subscribe subscribes to all agent streaming events on the dedicated EventBus
// No SessionID filtering needed since we have a dedicated EventBus per request
func (h *AgentStreamHandler) Subscribe() {
// Subscribe to all agent streaming events on the dedicated EventBus
h.eventBus.On(event.EventAgentThought, h.handleThought)
h.eventBus.On(event.EventAgentToolCall, h.handleToolCall)
h.eventBus.On(event.EventAgentToolResult, h.handleToolResult)
h.eventBus.On(event.EventAgentReferences, h.handleReferences)
h.eventBus.On(event.EventMemoryRecalled, h.handleMemoryRecalled)
h.eventBus.On(event.EventContextCompacted, h.handleContextCompacted)
h.eventBus.On(event.EventAgentFinalAnswer, h.handleFinalAnswer)
h.eventBus.On(event.EventAgentReflection, h.handleReflection)
h.eventBus.On(event.EventError, h.handleError)
h.eventBus.On(event.EventSessionTitle, h.handleSessionTitle)
h.eventBus.On(event.EventAgentComplete, h.handleComplete)
h.eventBus.On(event.EventToolApprovalRequired, h.handleToolApprovalRequired)
h.eventBus.On(event.EventToolApprovalResolved, h.handleToolApprovalResolved)
h.eventBus.On(event.EventMCPOAuthRequired, h.handleMCPOAuthRequired)
h.eventBus.On(event.EventMCPOAuthResolved, h.handleMCPOAuthResolved)
}
// handleThought handles agent thought events
func (h *AgentStreamHandler) handleThought(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentThoughtData)
if !ok {
return nil
}
h.mu.Lock()
// Track start time on first chunk
if _, exists := h.eventStartTimes[evt.ID]; !exists {
h.eventStartTimes[evt.ID] = time.Now()
}
// Calculate duration if done
var metadata map[string]interface{}
if data.Done {
startTime := h.eventStartTimes[evt.ID]
duration := time.Since(startTime)
metadata = map[string]interface{}{
"event_id": evt.ID,
"duration_ms": duration.Milliseconds(),
"completed_at": time.Now().Unix(),
}
delete(h.eventStartTimes, evt.ID)
} else {
metadata = map[string]interface{}{
"event_id": evt.ID,
}
}
h.mu.Unlock()
// Append this chunk to stream (no accumulation - frontend will accumulate)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeThinking,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append thought event to stream failed", "error", err)
}
return nil
}
// handleToolCall handles tool call events
func (h *AgentStreamHandler) handleToolCall(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolCallData)
if !ok {
return nil
}
h.mu.Lock()
_, first := h.eventStartTimes[data.ToolCallID]
if !first {
h.eventStartTimes[data.ToolCallID] = time.Now()
// Any answer text streamed before this tool call was a non-terminal round's
// preamble, not the final answer (the agent only ends by stopping naturally
// with plain text and no tool calls). Drop those segments from the persisted
// answer so the preamble never leaks into Message.Content.
supersededAny := false
for _, seg := range h.answerSegments {
if !seg.superseded && seg.content != "" {
seg.superseded = true
supersededAny = true
}
}
if supersededAny {
h.finalAnswer = h.composeFinalAnswer()
}
}
h.mu.Unlock()
metadata := map[string]interface{}{
"tool_name": data.ToolName,
"arguments": agenttools.SanitizeSandboxFileCallArgs(data.ToolName, data.Arguments),
"tool_call_id": data.ToolCallID,
}
// Append event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolCall,
Content: fmt.Sprintf("Calling tool: %s", data.ToolName),
Done: false,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool call event to stream failed", "error", err)
}
return nil
}
// handleToolResult handles tool result events
func (h *AgentStreamHandler) handleToolResult(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolResultData)
if !ok {
return nil
}
h.mu.Lock()
// Calculate duration from start time if available, otherwise use provided duration
var durationMs int64
if startTime, exists := h.eventStartTimes[data.ToolCallID]; exists {
durationMs = time.Since(startTime).Milliseconds()
delete(h.eventStartTimes, data.ToolCallID)
} else if data.Duration > 0 {
// Fallback to provided duration if start time not tracked
durationMs = data.Duration
}
h.mu.Unlock()
// Send SSE response (both success and failure)
responseType := types.ResponseTypeToolResult
content := agenttools.StreamContentForToolResult(data.ToolName, data.Success, data.Error, data.Data)
if !data.Success {
responseType = types.ResponseTypeError
if content == "" && data.Error != "" {
content = data.Error
}
}
// Build metadata including tool result data for rich frontend rendering
metadata := map[string]interface{}{
"tool_name": data.ToolName,
"success": data.Success,
"error": data.Error,
"duration_ms": durationMs,
"tool_call_id": data.ToolCallID,
}
clientData := agenttools.SanitizeToolResultForClient(data.ToolName, &types.ToolResult{
Success: data.Success,
Output: data.Output,
Error: data.Error,
Data: data.Data,
})
for k, v := range clientData {
metadata[k] = v
}
// Append event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: responseType,
Content: content,
Done: false,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool result event to stream failed", "error", err)
}
return nil
}
func toolApprovalDataToMap(v interface{}) map[string]interface{} {
b, err := json.Marshal(v)
if err != nil {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return map[string]interface{}{}
}
return m
}
// handleToolApprovalRequired persists MCP tool human-approval prompts for SSE / replay (issue #1173).
func (h *AgentStreamHandler) handleToolApprovalRequired(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ToolApprovalRequiredData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolApprovalRequired,
Content: "MCP tool requires human approval",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool approval required event failed", "error", err)
}
return nil
}
// handleToolApprovalResolved persists the outcome of a tool approval (issue #1173).
func (h *AgentStreamHandler) handleToolApprovalResolved(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ToolApprovalResolvedData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolApprovalResolved,
Content: "MCP tool approval resolved",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool approval resolved event failed", "error", err)
}
return nil
}
// handleMCPOAuthRequired forwards an in-conversation "authorize this MCP
// service" prompt to the SSE stream so the UI can render an Authorize card.
func (h *AgentStreamHandler) handleMCPOAuthRequired(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthRequiredData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthRequired,
Content: "MCP service requires OAuth authorization",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth required event failed", "error", err)
}
return nil
}
// handleMCPOAuthResolved forwards the outcome of an in-conversation OAuth prompt.
func (h *AgentStreamHandler) handleMCPOAuthResolved(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthResolvedData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthResolved,
Content: "MCP OAuth authorization resolved",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth resolved event failed", "error", err)
}
return nil
}
// handleReferences handles knowledge references events
func (h *AgentStreamHandler) handleReferences(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentReferencesData)
if !ok {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
// Extract knowledge references
// Try to cast directly to []*types.SearchResult first
if searchResults, ok := data.References.([]*types.SearchResult); ok {
h.knowledgeRefs = append(h.knowledgeRefs, searchResults...)
} else if refs, ok := data.References.([]interface{}); ok {
// Fallback: convert from []interface{}
for _, ref := range refs {
if sr, ok := ref.(*types.SearchResult); ok {
h.knowledgeRefs = append(h.knowledgeRefs, sr)
} else if refMap, ok := ref.(map[string]interface{}); ok {
// Parse from map if needed
h.knowledgeRefs = append(h.knowledgeRefs, searchResultFromMap(refMap))
}
}
}
// Update assistant message references
h.assistantMessage.KnowledgeReferences = h.knowledgeRefs
// Append references event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeReferences,
Content: "",
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{
"references": types.References(h.knowledgeRefs),
},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append references event to stream failed", "error", err)
}
return nil
}
// handleMemoryRecalled records the long-term memories injected into this turn.
// The list is both persisted on the assistant message and streamed, so the
// panel is present live and after a reload.
func (h *AgentStreamHandler) handleMemoryRecalled(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MemoryRecalledData)
if !ok {
return nil
}
used, ok := data.Memories.(types.UsedMemories)
if !ok || len(used) == 0 {
return nil
}
h.mu.Lock()
h.assistantMessage.UsedMemories = used
h.mu.Unlock()
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMemoryRecalled,
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{"memories": used},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append memory recalled event to stream failed", "error", err)
}
return nil
}
// handleContextCompacted forwards a compaction to the UI.
//
// The summary itself is carried so the user can expand it and see exactly what
// the agent kept, which is the only way to tell an agent that forgot something
// from an agent that never had it.
func (h *AgentStreamHandler) handleContextCompacted(_ context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ContextCompactedData)
if !ok {
return nil
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeContextCompacted,
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"reason": data.Reason,
"round": data.Round,
"tokens_before": data.TokensBefore,
"tokens_after": data.TokensAfter,
"messages_before": data.MessagesBefore,
"messages_after": data.MessagesAfter,
"summary": data.Summary,
"degraded": data.Degraded,
"split_turn": data.SplitTurn,
},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append context compacted event to stream failed", "error", err)
}
return nil
}
// handleFinalAnswer handles final answer events
func (h *AgentStreamHandler) handleFinalAnswer(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentFinalAnswerData)
if !ok {
return nil
}
h.mu.Lock()
// Track start time on first chunk
if _, exists := h.eventStartTimes[evt.ID]; !exists {
h.eventStartTimes[evt.ID] = time.Now()
}
// Emit a one-shot TTFB log the first time *any* answer chunk reaches
// the stream handler. This lets us compare the backend's "request in →
// first token out" timing against the frontend-observed TTFB and pin
// down where latency lives (network vs server vs LLM).
if !h.ttfbLogged && !h.receivedAt.IsZero() {
h.ttfbLogged = true
ttfb := time.Since(h.receivedAt)
logger.GetLogger(h.ctx).Infof("TTFB:first_answer_chunk request_id=%s, session_id=%s, ttfb_ms=%d",
h.requestID, h.sessionID, ttfb.Milliseconds())
}
// Accumulate final answer locally for assistant message (database). Track
// per event ID so a later supersede can subtract this segment's content.
if data.Content != "" {
seg := h.findAnswerSegment(evt.ID)
if seg == nil {
seg = &answerSegment{id: evt.ID}
h.answerSegments = append(h.answerSegments, seg)
}
seg.content += data.Content
h.finalAnswer = h.composeFinalAnswer()
}
if data.IsFallback {
h.assistantMessage.IsFallback = true
}
// Calculate duration if done
var metadata map[string]interface{}
if data.Done {
startTime := h.eventStartTimes[evt.ID]
duration := time.Since(startTime)
metadata = map[string]interface{}{
"event_id": evt.ID,
"duration_ms": duration.Milliseconds(),
"completed_at": time.Now().Unix(),
}
delete(h.eventStartTimes, evt.ID)
} else {
metadata = map[string]interface{}{
"event_id": evt.ID,
}
}
if data.IsFallback {
metadata["is_fallback"] = true
}
h.mu.Unlock()
// Append this chunk to stream (frontend will accumulate by event ID)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeAnswer,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append answer event to stream failed", "error", err)
}
return nil
}
// handleReflection handles agent reflection events
func (h *AgentStreamHandler) handleReflection(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentReflectionData)
if !ok {
return nil
}
// Append this chunk to stream (frontend will accumulate by event ID)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeReflection,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
}); err != nil {
logger.GetLogger(h.ctx).Error("Append reflection event to stream failed", "error", err)
}
return nil
}
// handleError handles error events
func (h *AgentStreamHandler) handleError(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ErrorData)
if !ok {
return nil
}
// Build error metadata
metadata := map[string]interface{}{
"stage": data.Stage,
"error": data.Error,
}
// Append error event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeError,
Content: data.Error,
Done: true,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append error event to stream failed", "error", err)
}
return nil
}
// handleSessionTitle handles session title update events
func (h *AgentStreamHandler) handleSessionTitle(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.SessionTitleData)
if !ok {
return nil
}
// Use background context for title event since it may arrive after stream completion
bgCtx := context.Background()
// Append title event to stream
if err := h.streamManager.AppendEvent(bgCtx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeSessionTitle,
Content: data.Title,
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"session_id": data.SessionID,
"title": data.Title,
},
}); err != nil {
logger.GetLogger(h.ctx).Warn("Append session title event to stream failed (stream may have ended)", "error", err)
}
return nil
}
// handleComplete handles agent complete events
func (h *AgentStreamHandler) handleComplete(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentCompleteData)
if !ok {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
// Update assistant message with final data
if data.MessageID == h.assistantMessageID {
// h.assistantMessage.Content = data.FinalAnswer
h.assistantMessage.IsCompleted = true
h.assistantMessage.AgentDurationMs = data.TotalDurationMs
// Update knowledge references if provided
if len(data.KnowledgeRefs) > 0 {
knowledgeRefs := make([]*types.SearchResult, 0, len(data.KnowledgeRefs))
for _, ref := range data.KnowledgeRefs {
if sr, ok := ref.(*types.SearchResult); ok {
knowledgeRefs = append(knowledgeRefs, sr)
}
}
h.assistantMessage.KnowledgeReferences = knowledgeRefs
}
h.assistantMessage.Content += data.FinalAnswer
// Update agent steps if provided
if data.AgentSteps != nil {
if steps, ok := data.AgentSteps.([]types.AgentStep); ok {
h.assistantMessage.AgentSteps = agenttools.SanitizeAgentStepsForStorage(steps)
}
}
// Persist the turn's aggregated LLM usage with the message so history
// reads still carry it after the live stream is gone.
if usage, ok := data.Usage.(*types.TokenUsage); ok && usage != nil {
h.assistantMessage.Usage = usage
}
// Drain skill-generated files from the sandbox into persistent
// storage. Best-effort: any failure is logged and the turn is
// persisted without artifacts. Collect is a no-op when either the
// collector wasn't wired in, no sandbox is bound, or no files were
// produced — those cases must not disturb the completion path.
if h.artifactCollector != nil {
collectCtx := context.WithoutCancel(h.ctx)
artifacts, err := h.artifactCollector.CollectWithNotify(
collectCtx,
h.sessionID,
h.assistantMessageID,
h.tenantID,
skills.ArtifactOutputDir(),
h.emitArtifactsPending,
)
if err != nil {
logger.GetLogger(h.ctx).Warnf(
"artifact collect failed session=%s message=%s: %v",
h.sessionID, h.assistantMessageID, err,
)
} else if len(artifacts) > 0 {
h.assistantMessage.Artifacts = artifacts
// The answer text names generated files the way the model saw
// them in the sandbox. Bind those names to artifact indices now
// that the index space is final, so a reloaded conversation
// renders them instead of showing a broken link.
h.assistantMessage.Content = rewriteArtifactReferences(
h.assistantMessage.Content, artifacts,
)
logger.GetLogger(h.ctx).Infof(
"artifact collect attached %d file(s) to message=%s session=%s",
len(artifacts), h.assistantMessageID, h.sessionID,
)
}
}
}
// Fallback: if no answer events were streamed but we have a final answer,
// emit it as answer events so the frontend can render it properly.
// This guards against edge cases where the LLM stops without calling final_answer.
if h.finalAnswer == "" && data.FinalAnswer != "" {
logger.GetLogger(h.ctx).Warnf(
"No answer events were streamed, emitting fallback answer (len=%d). "+
"This typically happens when: (1) model stopped naturally and content was sent as thought events, "+
"or (2) Ollama model returned tool calls non-incrementally. "+
"total_steps=%d, total_duration_ms=%d",
len(data.FinalAnswer), data.TotalSteps, data.TotalDurationMs,
)
fallbackID := fmt.Sprintf("answer-fallback-%d", time.Now().UnixMilli())
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fallbackID,
Type: types.ResponseTypeAnswer,
Content: data.FinalAnswer,
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{
"event_id": fallbackID,
"is_fallback": true,
},
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append fallback answer event failed: %v", err)
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fallbackID,
Type: types.ResponseTypeAnswer,
Content: "",
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"event_id": fallbackID,
"is_fallback": true,
},
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append fallback answer done event failed: %v", err)
}
}
// Send completion event to stream manager so SSE can detect completion
completeData := map[string]interface{}{
"total_steps": data.TotalSteps,
"total_duration_ms": data.TotalDurationMs,
}
// Attach the freshly-collected artifacts so the frontend can render the
// download button without waiting for a page refresh. We strip the
// storage URL and any other server-only fields via publicArtifactViews
// — clients only ever download through /artifacts/:index which enforces
// tenant ownership.
if len(h.assistantMessage.Artifacts) > 0 {
completeData["artifacts"] = publicArtifactViews(h.assistantMessage.Artifacts)
}
// Carry the turn's aggregated LLM usage both inside data (map consumers)
// and on the typed event field, which buildStreamResponse promotes to the
// response's top-level usage.
turnUsage, _ := data.Usage.(*types.TokenUsage)
if turnUsage != nil {
completeData["usage"] = turnUsage
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeComplete,
Content: "",
Done: true,
Timestamp: time.Now(),
Data: completeData,
Usage: turnUsage,
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append complete event to stream failed: %v", err)
}
return nil
}
// emitArtifactsPending tells the live UI that sandbox files exist and are
// being uploaded. It must not take h.mu — Collect calls it while
// handleComplete already holds the lock.
func (h *AgentStreamHandler) emitArtifactsPending(count int) {
if h == nil || h.streamManager == nil || count <= 0 {
return
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fmt.Sprintf("artifacts-pending-%d", time.Now().UnixMilli()),
Type: types.ResponseTypeArtifactsPending,
Timestamp: time.Now(),
Data: map[string]interface{}{
"count": count,
},
}); err != nil {
logger.GetLogger(h.ctx).Warnf(
"append artifacts_pending failed session=%s message=%s: %v",
h.sessionID, h.assistantMessageID, err,
)
}
}
// publicArtifactViews returns a redacted view of the artifact list suitable
// for direct serialization onto the SSE stream. The physical storage path is
// stripped; the resource handle is kept because it is what the answer body
// references, and the frontend needs it to tie an inline reference to the file
// it names. Bytes are fetched through /artifacts/:index/download.
func publicArtifactViews(list types.MessageArtifacts) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(list))
for i, a := range list {
out = append(out, map[string]interface{}{
"index": i,
"handle": artifactHandle(a),
"file_name": a.FileName,
"file_type": a.FileType,
"file_size": a.FileSize,
"source_path": a.SourcePath,
"mod_time": a.ModTime,
"created_at": a.CreatedAt,
})
}
return out
}
@@ -0,0 +1,317 @@
package session
import (
stderrors "errors"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// paramSessionID resolves the session-id URL parameter regardless of which
// wildcard name the current route uses. GET-tree routes bind :id (to align
// with /sessions/:id), while POST-tree routes typically bind :session_id.
// Handlers call this helper instead of hard-coding one name so the same
// function serves both trees.
func paramSessionID(c *gin.Context) string {
if v := c.Param("session_id"); v != "" {
return v
}
return c.Param("id")
}
// ListSessionArtifacts godoc
// @Summary 列出会话生成的产物文件
// @Description 返回本会话中所有 assistant 消息产生的技能产物元数据(不含 URL)
// @Tags 会话
// @Produce json
// @Param session_id path string true "会话ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} errors.AppError
// @Security Bearer
// @Router /sessions/{session_id}/artifacts [get]
//
// The endpoint powers the drawer that lists every file generated in the
// session; it does NOT return the storage URL (only names/sizes/mtimes), so
// clients cannot reach around the download endpoint by reading a
// provider:// path from the API response.
func (h *Handler) ListSessionArtifacts(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
if sessionID == "" {
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Ownership + tenant check: GetSession enforces both. Returning 404 for
// unknown / non-owned sessions matches the rest of the session routes.
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
artifacts, err := h.messageService.GetSessionArtifacts(ctx, sessionID)
if err != nil {
logger.Errorf(ctx, "list session artifacts failed: session=%s err=%v", sessionID, err)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
items := make([]artifactListItem, 0, len(artifacts))
for i, a := range artifacts {
items = append(items, artifactListItem{
Index: i,
Handle: artifactHandle(a),
FileName: a.FileName,
FileType: a.FileType,
FileSize: a.FileSize,
SourcePath: a.SourcePath,
ModTime: a.ModTime,
CreatedAt: a.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": items,
})
}
// ListMessageArtifacts returns just the artifacts attached to a single
// assistant message. Used by the "download files from this reply" button on
// each bot message.
//
// Same-tenant/same-owner check flows through h.sessionService.GetSession
// exactly like ListSessionArtifacts.
//
// @Router /sessions/{session_id}/messages/{message_id}/artifacts [get]
func (h *Handler) ListMessageArtifacts(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
messageID := secutils.SanitizeForLog(c.Param("message_id"))
if sessionID == "" || messageID == "" {
c.Error(errors.NewBadRequestError("session_id and message_id are required"))
return
}
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
msg, err := h.messageService.GetMessage(ctx, sessionID, messageID)
if err != nil || msg == nil {
c.Error(errors.NewNotFoundError("message not found"))
return
}
items := make([]artifactListItem, 0, len(msg.Artifacts))
for i, a := range msg.Artifacts {
items = append(items, artifactListItem{
Index: i,
Handle: artifactHandle(a),
FileName: a.FileName,
FileType: a.FileType,
FileSize: a.FileSize,
SourcePath: a.SourcePath,
ModTime: a.ModTime,
CreatedAt: a.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": items,
})
}
// DownloadMessageArtifact streams a single skill-generated file to the
// client. Clients reference the artifact by its position (:index) in the
// assistant message's Artifacts array; the storage URL never leaves the
// server so callers cannot pivot to arbitrary blobs.
//
// @Router /sessions/{session_id}/messages/{message_id}/artifacts/{index}/download [get]
func (h *Handler) DownloadMessageArtifact(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
messageID := secutils.SanitizeForLog(c.Param("message_id"))
indexParam := c.Param("index")
if sessionID == "" || messageID == "" || indexParam == "" {
c.Error(errors.NewBadRequestError("session_id, message_id and index are required"))
return
}
index, err := strconv.Atoi(indexParam)
if err != nil || index < 0 {
c.Error(errors.NewBadRequestError("invalid artifact index"))
return
}
// Ownership check: GetSession returns ErrSessionNotFound when the
// session doesn't belong to the calling tenant/user, so a 404 covers
// both "not found" and "forbidden" without leaking existence.
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
msg, err := h.messageService.GetMessage(ctx, sessionID, messageID)
if err != nil || msg == nil {
c.Error(errors.NewNotFoundError("message not found"))
return
}
if index >= len(msg.Artifacts) {
c.Error(errors.NewNotFoundError("artifact index out of range"))
return
}
artifact := msg.Artifacts[index]
if artifact.URL == "" {
c.Error(errors.NewNotFoundError("artifact storage path missing"))
return
}
if h.fileService == nil {
c.Error(errors.NewInternalServerError("file service unavailable"))
return
}
reader, err := h.fileService.GetFile(ctx, artifact.URL)
if err != nil {
logger.Warnf(ctx, "artifact download read failed: session=%s message=%s idx=%d err=%v",
sessionID, messageID, index, err)
c.Error(errors.NewNotFoundError("artifact blob missing"))
return
}
defer reader.Close()
// Force download semantics — artifacts are never rendered inline, matching
// the /files endpoint's active-content protection.
c.Header("Content-Type", mimeTypeFor(artifact.FileName))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", buildAttachmentHeader(artifact.FileName))
if artifact.FileSize > 0 {
c.Header("Content-Length", strconv.FormatInt(artifact.FileSize, 10))
}
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, reader); err != nil {
logger.Warnf(ctx, "artifact download stream failed: session=%s message=%s idx=%d err=%v",
sessionID, messageID, index, err)
}
}
// artifactListItem is the JSON shape returned by ListSessionArtifacts /
// ListMessageArtifacts. It carries the resource handle but never the storage
// path: the handle is the artifact's public identity — it is what the answer
// body references and what an authorizing proxy resolves — while the physical
// bucket/key stays server side.
type artifactListItem struct {
Index int `json:"index"`
// Handle is the artifact's `resource://<handle>` reference, matching the
// destinations in the message body. Empty when the deployment runs without
// a resource catalog, in which case the body references files by name.
Handle string `json:"handle,omitempty"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
SourcePath string `json:"source_path"`
// time-typed fields serialise as RFC3339 strings — same convention as
// the rest of the messages API.
ModTime any `json:"mod_time"`
CreatedAt any `json:"created_at"`
}
// mimeTypeFor picks a Content-Type by extension and falls back to
// application/octet-stream so unknown types force a download prompt rather
// than being sniffed. Kept private to this file — the /files route has a
// stricter version with an SVG-neutralising branch; we don't need that here
// because Content-Disposition already blocks inline rendering.
func mimeTypeFor(name string) string {
ext := strings.ToLower(filepath.Ext(name))
if ext == "" {
return "application/octet-stream"
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
// buildAttachmentHeader returns a Content-Disposition value that preserves
// non-ASCII filenames (RFC 5987) while providing a safe fallback for
// ASCII-only clients.
func buildAttachmentHeader(name string) string {
// Strip control characters + quotes; keep the human-readable name.
ascii := strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
if r == '"' || r == '\\' {
return '_'
}
if r > 0x7e {
return -1
}
return r
}, name)
if ascii == "" {
ascii = "download"
}
encoded := (&urlPathEscaper{}).escape(name)
return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded
}
// urlPathEscaper is a minimal RFC 3986 percent-encoder for the subset of
// bytes allowed in a filename*= value. We inline it to avoid importing
// net/url just for a two-line call, and because url.PathEscape encodes
// spaces as "+" (form-encoding) which HTTP clients then decode as literal
// "+" characters in the filename.
type urlPathEscaper struct{}
// escape percent-encodes every byte outside the "attr-char" grammar of RFC 5987.
// See https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1
func (urlPathEscaper) escape(s string) string {
var b strings.Builder
b.Grow(len(s))
const hex = "0123456789ABCDEF"
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case (c >= 'A' && c <= 'Z'), (c >= 'a' && c <= 'z'), (c >= '0' && c <= '9'):
b.WriteByte(c)
case c == '-' || c == '.' || c == '_' || c == '~':
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0f])
}
}
return b.String()
}
// artifactHandle returns the artifact's `resource://<handle>` reference, or ""
// when the deployment stores artifacts without a resource catalog.
func artifactHandle(artifact types.MessageArtifact) string {
if handle, ok := types.ParseResourcePath(artifact.URL); ok {
return types.BuildResourcePath(handle)
}
return ""
}

Some files were not shown because too many files have changed in this diff Show More