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
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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":`)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ")
|
||||
|
||||
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 ")
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ",
|
||||
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 ",
|
||||
}},
|
||||
}
|
||||
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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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 "},
|
||||
})
|
||||
assert.Equal(t, "see ", loadMessageContent(t, router, ""))
|
||||
}
|
||||
|
||||
func TestLoadMessages_PublicModeReturnsLoadableURLs(t *testing.T) {
|
||||
router := newResourceURLTestRouter(t, []*types.Message{
|
||||
{
|
||||
Content: "see ",
|
||||
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 "},
|
||||
})
|
||||
assert.Equal(t, "see ",
|
||||
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 "},
|
||||
})
|
||||
assert.Equal(t, "see ",
|
||||
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 "},
|
||||
})
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user