完善 MCP 工具

This commit is contained in:
2026-06-11 08:21:09 +08:00
parent c0f15050d0
commit 1415d3b43b
20 changed files with 3722 additions and 61 deletions
+6 -2
View File
@@ -219,11 +219,15 @@ type SSEEvent struct {
}
// TemplateService — prompt_templates CRUD。
//
// 鉴权语义:user 模板仅 owner 可改/删;system 模板仅 admin 可改/删
// (isAdmin 由调用方解析后传入;Create 的 system-scope admin 校验
// 仍在 HTTP handler / MCP 工具层,与既有行为一致)。
type TemplateService interface {
List(ctx context.Context, userID uuid.UUID) ([]PromptTemplate, error)
Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error)
Update(ctx context.Context, userID, id uuid.UUID, name, content string) error
Delete(ctx context.Context, userID, id uuid.UUID) error
Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error
Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error
}
// EndpointService — admin LLM endpoints / models CRUD(管理员守卫上层处理)。
+12 -2
View File
@@ -492,7 +492,12 @@ func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return
}
if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil {
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
if err != nil {
writeErr(w, r, err)
return
}
if err := h.tplSvc.Update(r.Context(), uid, isAdmin, id, req.Name, req.Content); err != nil {
writeErr(w, r, err)
return
}
@@ -510,7 +515,12 @@ func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil {
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
if err != nil {
writeErr(w, r, err)
return
}
if err := h.tplSvc.Delete(r.Context(), uid, isAdmin, id); err != nil {
writeErr(w, r, err)
return
}
+4 -2
View File
@@ -118,8 +118,10 @@ func (f *fakeTplSvc) Create(ctx context.Context, userID uuid.UUID, name, content
}
return &PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
}
func (f *fakeTplSvc) Update(_ context.Context, _, _ uuid.UUID, _, _ string) error { return nil }
func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil }
func (f *fakeTplSvc) Update(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID, _, _ string) error {
return nil
}
func (f *fakeTplSvc) Delete(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) error { return nil }
// ===== Fake EndpointService =====
+18 -6
View File
@@ -54,7 +54,7 @@ func (s *templateService) Create(ctx context.Context, userID uuid.UUID, name, co
return t, nil
}
func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name, content string) error {
func (s *templateService) Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error {
t, err := s.repo.GetPromptTemplate(ctx, id)
if err != nil {
return err
@@ -62,8 +62,8 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
if t == nil {
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
}
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
return errs.New(errs.CodeForbidden, "not template owner")
if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
return err
}
if err := s.repo.UpdatePromptTemplate(ctx, id, name, content); err != nil {
return err
@@ -77,7 +77,7 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
return nil
}
func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) error {
func (s *templateService) Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error {
t, err := s.repo.GetPromptTemplate(ctx, id)
if err != nil {
return err
@@ -85,8 +85,8 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
if t == nil {
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
}
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
return errs.New(errs.CodeForbidden, "not template owner")
if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
return err
}
if err := s.repo.DeletePromptTemplate(ctx, id); err != nil {
return err
@@ -100,6 +100,18 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
return nil
}
// checkTemplateWriteAccess 是 Update/Delete 共用的写权限判定:
// user 模板仅 owner;system 模板仅 admin。
func checkTemplateWriteAccess(t *PromptTemplate, userID uuid.UUID, isAdmin bool) error {
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
return errs.New(errs.CodeForbidden, "not template owner")
}
if t.Scope == TemplateScopeSystem && !isAdmin {
return errs.New(errs.CodeForbidden, "admin only: system-scoped template")
}
return nil
}
func (s *templateService) tryAudit(ctx context.Context, e audit.Entry) {
if err := s.auditor.Record(ctx, e); err != nil {
s.log.Warn("audit record failed", "action", e.Action, "err", err)
+102
View File
@@ -0,0 +1,102 @@
package chat
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// tplFakeRepo embeds fakeRepo and overrides prompt template methods.
type tplFakeRepo struct {
*fakeRepo
templates map[uuid.UUID]*PromptTemplate
}
func newTplFakeRepo() *tplFakeRepo {
return &tplFakeRepo{fakeRepo: newFakeRepo(), templates: map[uuid.UUID]*PromptTemplate{}}
}
func (r *tplFakeRepo) GetPromptTemplate(_ context.Context, id uuid.UUID) (*PromptTemplate, error) {
return r.templates[id], nil // 未命中返回 (nil, nil),与真实 repo 语义一致
}
func (r *tplFakeRepo) UpdatePromptTemplate(_ context.Context, id uuid.UUID, name, content string) error {
t, ok := r.templates[id]
if !ok {
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
}
t.Name, t.Content = name, content
return nil
}
func (r *tplFakeRepo) DeletePromptTemplate(_ context.Context, id uuid.UUID) error {
delete(r.templates, id)
return nil
}
func newTplSvc(repo *tplFakeRepo) TemplateService {
return NewTemplateService(repo, noopAuditor{}, discardLogger())
}
func addTplFixture(repo *tplFakeRepo, scope TemplateScope, owner *uuid.UUID) *PromptTemplate {
t := &PromptTemplate{ID: uuid.New(), Name: "tpl", Content: "c", Scope: scope, OwnerID: owner}
repo.templates[t.ID] = t
return t
}
func requireForbidden(t *testing.T, err error) {
t.Helper()
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeForbidden, ae.Code)
}
func TestTemplateUpdate_SystemScopeRequiresAdmin(t *testing.T) {
repo := newTplFakeRepo()
svc := newTplSvc(repo)
sys := addTplFixture(repo, TemplateScopeSystem, nil)
uid := uuid.New()
// 非 admin → 拒绝
err := svc.Update(context.Background(), uid, false, sys.ID, "x", "y")
requireForbidden(t, err)
assert.Equal(t, "tpl", repo.templates[sys.ID].Name)
// admin → 允许
require.NoError(t, svc.Update(context.Background(), uid, true, sys.ID, "x", "y"))
assert.Equal(t, "x", repo.templates[sys.ID].Name)
}
func TestTemplateDelete_SystemScopeRequiresAdmin(t *testing.T) {
repo := newTplFakeRepo()
svc := newTplSvc(repo)
sys := addTplFixture(repo, TemplateScopeSystem, nil)
uid := uuid.New()
err := svc.Delete(context.Background(), uid, false, sys.ID)
requireForbidden(t, err)
assert.Contains(t, repo.templates, sys.ID)
require.NoError(t, svc.Delete(context.Background(), uid, true, sys.ID))
assert.NotContains(t, repo.templates, sys.ID)
}
func TestTemplateUpdateDelete_UserScopeOwnerOnly(t *testing.T) {
repo := newTplFakeRepo()
svc := newTplSvc(repo)
owner := uuid.New()
tpl := addTplFixture(repo, TemplateScopeUser, &owner)
stranger := uuid.New()
// 非 owner(即使 admin)→ 拒绝,保持既有 owner-only 语义
requireForbidden(t, svc.Update(context.Background(), stranger, true, tpl.ID, "x", "y"))
requireForbidden(t, svc.Delete(context.Background(), stranger, true, tpl.ID))
// owner → 允许
require.NoError(t, svc.Update(context.Background(), owner, false, tpl.ID, "x", "y"))
require.NoError(t, svc.Delete(context.Background(), owner, false, tpl.ID))
assert.NotContains(t, repo.templates, tpl.ID)
}