AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"aigateway.local/core/internal/platform/config"
|
||||
"aigateway.local/core/internal/platform/cryptox"
|
||||
"aigateway.local/core/internal/platform/database"
|
||||
)
|
||||
|
||||
func TestMarketplaceLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("WORKBENCH_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
actorID := "22222222-2222-4222-8222-222222222222"
|
||||
portalUserID := "33333333-3333-4333-8333-333333333333"
|
||||
cleanup := func() {
|
||||
// pgx v5 Exec uses the extended protocol, which rejects multi-command
|
||||
// strings, so each statement must run in its own Exec.
|
||||
for _, q := range []string{
|
||||
`DELETE FROM gateway.marketplace_installations WHERE portal_user_id='` + portalUserID + `'`,
|
||||
`DELETE FROM gateway.digital_employees WHERE code='mkt_employee'`,
|
||||
`DELETE FROM gateway.skills WHERE code='mkt_skill'`,
|
||||
`DELETE FROM gateway.mcp_servers WHERE code='mkt_mcp'`,
|
||||
`DELETE FROM gateway.marketplace_categories WHERE name IN ('mkt-分类','mkt-技能分类')`,
|
||||
`DELETE FROM gateway.portal_users WHERE id='` + portalUserID + `'`,
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, q); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
// Inserts must come after the initial cleanup, which would otherwise
|
||||
// delete the portal user we just created (it is referenced by the
|
||||
// marketplace_installations FK).
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'market-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name) VALUES($1,'market-user','市场测试') ON CONFLICT(id) DO NOTHING`, portalUserID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assets := NewService(pool)
|
||||
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
cipher, err := cryptox.NewKeyring(key, 1, "", "mkt-test-headers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mcpSvc := NewMCPServerService(assets, cipher, true)
|
||||
toolSvc := NewToolService(assets, cipher, true)
|
||||
skillSvc := NewSkillService(assets)
|
||||
deSvc := NewDigitalEmployeeService(assets, skillSvc, toolSvc, mcpSvc)
|
||||
market := NewMarketplaceService(assets, mcpSvc, skillSvc, deSvc)
|
||||
|
||||
globalCat, err := market.CreateCategory(ctx, Category{Name: "mkt-分类", Description: "全局", ResourceType: ""}, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillCat, err := market.CreateCategory(ctx, Category{Name: "mkt-技能分类", Description: "技能专用", ResourceType: "skill"}, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A skill category must reject binding to an MCP server.
|
||||
if _, err := market.CreateCategory(ctx, Category{Name: "mkt-冲突分类", Description: "", ResourceType: "mcp_server"}, actorID); err != nil {
|
||||
t.Fatalf("mcp_server category should be creatable: %v", err)
|
||||
}
|
||||
cleanupConflict := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.marketplace_categories WHERE name='mkt-冲突分类'`)
|
||||
}
|
||||
cleanupConflict()
|
||||
defer cleanupConflict()
|
||||
|
||||
mcp, err := mcpSvc.Save(ctx, "", MCPServerInput{
|
||||
Code: "mkt_mcp", Name: "测试 MCP", Transport: "streamable-http",
|
||||
EndpointURL: "https://mcp.example.com/mcp", Status: "published",
|
||||
CategoryID: &globalCat.ID, Enabled: true, Headers: map[string]string{"Authorization": "Bearer test"},
|
||||
}, actorID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := skillSvc.Save(ctx, "", SkillInput{
|
||||
Code: "mkt_skill", Name: "测试技能", Content: "你是客服,请回答 {{question}}",
|
||||
Variables: []Variable{{Name: "question", Required: true}}, Status: "published",
|
||||
CategoryID: &skillCat.ID, Enabled: true, MCPServerIDs: []string{mcp.ID},
|
||||
}, actorID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered, err := skillSvc.Render(skill, map[string]any{"question": "你好"})
|
||||
if err != nil || rendered != "你是客服,请回答 你好" {
|
||||
t.Fatalf("render=%q err=%v", rendered, err)
|
||||
}
|
||||
|
||||
employee, err := deSvc.Save(ctx, "", DigitalEmployeeInput{
|
||||
Code: "mkt_employee", Name: "客服机器人", Persona: "你是一位耐心的客服", Model: "deepseek-chat",
|
||||
SkillIDs: []string{skill.ID}, Status: "draft", Enabled: true,
|
||||
}, actorID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if employee.Status != "draft" {
|
||||
t.Fatalf("expected draft, got %s", employee.Status)
|
||||
}
|
||||
published, err := deSvc.Publish(ctx, employee.ID, actorID)
|
||||
if err != nil {
|
||||
t.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
if published.Status != "published" {
|
||||
t.Fatalf("expected published, got %s", published.Status)
|
||||
}
|
||||
|
||||
// Unified catalog merges all three published resource types.
|
||||
items, err := market.Catalog(ctx, "", "", "", "", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, item := range items {
|
||||
found[item.Code] = true
|
||||
}
|
||||
for _, code := range []string{"mkt_mcp", "mkt_skill", "mkt_employee"} {
|
||||
if !found[code] {
|
||||
t.Fatalf("catalog missing %s (got %#v)", code, found)
|
||||
}
|
||||
}
|
||||
// Category filter narrows to the skill category.
|
||||
skillItems, err := market.Catalog(ctx, "skill", skillCat.ID, "", "", 50)
|
||||
if err != nil || len(skillItems) != 1 || skillItems[0].Code != "mkt_skill" {
|
||||
t.Fatalf("category filter failed: %#v err=%v", skillItems, err)
|
||||
}
|
||||
|
||||
detailItem, raw, err := market.Detail(ctx, "digital_employee", "mkt_employee")
|
||||
if err != nil || detailItem.Code != "mkt_employee" || len(raw) == 0 {
|
||||
t.Fatalf("detail=%#v err=%v", detailItem, err)
|
||||
}
|
||||
|
||||
// Install/uninstall is idempotent and gated by published status.
|
||||
created, err := market.Install(ctx, "skill", "mkt_skill", portalUserID)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("install created=%v err=%v", created, err)
|
||||
}
|
||||
installed, err := market.Installed(ctx, "skill", skill.ID, portalUserID)
|
||||
if err != nil || !installed {
|
||||
t.Fatalf("installed=%v err=%v", installed, err)
|
||||
}
|
||||
createdAgain, err := market.Install(ctx, "skill", "mkt_skill", portalUserID)
|
||||
if err != nil || createdAgain {
|
||||
t.Fatalf("re-install should be a no-op: created=%v err=%v", createdAgain, err)
|
||||
}
|
||||
installedList, err := market.ListInstalled(ctx, portalUserID)
|
||||
if err != nil || len(installedList) != 1 || installedList[0].Code != "mkt_skill" {
|
||||
t.Fatalf("installed list=%#v err=%v", installedList, err)
|
||||
}
|
||||
if err := market.Uninstall(ctx, "skill", "mkt_skill", portalUserID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed, err = market.Installed(ctx, "skill", skill.ID, portalUserID)
|
||||
if err != nil || installed {
|
||||
t.Fatalf("expected uninstalled, got installed=%v err=%v", installed, err)
|
||||
}
|
||||
|
||||
// Runs mirroring: publishing a digital employee whose skill is bound to an
|
||||
// MCP server must still revalidate cleanly (strict bindings).
|
||||
if _, err := deSvc.Publish(ctx, employee.ID, actorID); err != nil {
|
||||
t.Fatalf("re-publish after install should pass: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user