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,444 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// MarketItem is the lightweight unified catalog row for a published resource,
|
||||
// regardless of which of the three resource tables it lives in.
|
||||
type MarketItem struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Tags []string `json:"tags"`
|
||||
DepartmentIDs []string `json:"department_ids"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Category is a shared marketplace category, optionally scoped to one resource
|
||||
// type (empty resource_type = global).
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// MarketplaceService provides the shared cross-resource surface: categories,
|
||||
// the unified catalog, and portal workspace installations.
|
||||
type MarketplaceService struct {
|
||||
assets *Service
|
||||
mcpServers *MCPServerService
|
||||
skills *SkillService
|
||||
employees *DigitalEmployeeService
|
||||
}
|
||||
|
||||
func NewMarketplaceService(assets *Service, mcpServers *MCPServerService, skills *SkillService, employees *DigitalEmployeeService) *MarketplaceService {
|
||||
return &MarketplaceService{assets: assets, mcpServers: mcpServers, skills: skills, employees: employees}
|
||||
}
|
||||
|
||||
// --- 分类 ---
|
||||
|
||||
func (s *MarketplaceService) ListCategories(ctx context.Context, resourceType string) ([]Category, error) {
|
||||
rows, err := s.assets.pool.Query(ctx, `SELECT id::text,name,description,resource_type,sort_order,created_at FROM gateway.marketplace_categories WHERE resource_type=$1 OR resource_type='' ORDER BY sort_order,created_at`, resourceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Category{}
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
if err = rows.Scan(&c.ID, &c.Name, &c.Description, &c.ResourceType, &c.SortOrder, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, c)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) CreateCategory(ctx context.Context, input Category, actorID string) (Category, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Description = strings.TrimSpace(input.Description)
|
||||
if input.Name == "" || len(input.Name) > 64 || len(input.Description) > 1000 {
|
||||
return Category{}, errors.New("分类名称或描述格式无效")
|
||||
}
|
||||
switch input.ResourceType {
|
||||
case "", "mcp_server", "skill", "digital_employee":
|
||||
default:
|
||||
return Category{}, errors.New("无效的分类资源类型")
|
||||
}
|
||||
if input.SortOrder < 0 {
|
||||
input.SortOrder = 0
|
||||
}
|
||||
id, err := newUUID()
|
||||
if err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.marketplace_categories(id,name,description,resource_type,sort_order,created_by) VALUES($1,$2,$3,$4,$5,$6)`, id, input.Name, input.Description, input.ResourceType, input.SortOrder, actorID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate") {
|
||||
return Category{}, ErrConflict
|
||||
}
|
||||
return Category{}, err
|
||||
}
|
||||
if err = emit(ctx, tx, "marketplace_category.created", "marketplace_category", id, actorID, nil); err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Category{}, err
|
||||
}
|
||||
input.ID = id
|
||||
input.CreatedAt = time.Now()
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) DeleteCategory(ctx context.Context, id, actorID string) error {
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM gateway.marketplace_categories WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err = emit(ctx, tx, "marketplace_category.deleted", "marketplace_category", id, actorID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// validCategoryID resolves and verifies an optional category reference. A nil
|
||||
// or blank id yields a nil category; a non-empty id must exist and match the
|
||||
// resource type scope (global categories apply to every type).
|
||||
func validCategoryID(ctx context.Context, pool *pgxpool.Pool, id *string, resourceType string) (*string, error) {
|
||||
if id == nil || strings.TrimSpace(*id) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.marketplace_categories WHERE id=$1 AND (resource_type='' OR resource_type=$2))`, *id, resourceType).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, errors.New("分类不存在或与资源类型不匹配")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- 统一目录 ---
|
||||
|
||||
// Catalog returns published, enabled resources across all three types, merged
|
||||
// and sorted by update time. Filters are optional and compose with AND.
|
||||
func (s *MarketplaceService) Catalog(ctx context.Context, resourceType, categoryID, tag, query string, limit int) ([]MarketItem, error) {
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
items := make([]MarketItem, 0, limit)
|
||||
types := []string{"mcp_server", "skill", "digital_employee"}
|
||||
if resourceType != "" {
|
||||
types = []string{resourceType}
|
||||
}
|
||||
for _, typ := range types {
|
||||
// Filters are built per table alias so name/description don't clash
|
||||
// with the LEFT JOINed category columns.
|
||||
where, args := marketFilters(aliasFor(typ), categoryID, tag, query)
|
||||
if err := s.catalogTable(ctx, typ, where, args, limit, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func aliasFor(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "mcp_server":
|
||||
return "m"
|
||||
case "skill":
|
||||
return "s"
|
||||
default:
|
||||
return "d"
|
||||
}
|
||||
}
|
||||
|
||||
func marketFilters(alias, categoryID, tag, query string) (string, []any) {
|
||||
clauses := []string{}
|
||||
args := []any{}
|
||||
if categoryID != "" {
|
||||
args = append(args, categoryID)
|
||||
clauses = append(clauses, fmt.Sprintf("%s.category_id=$%d", alias, len(args)))
|
||||
}
|
||||
if tag != "" {
|
||||
args = append(args, tag)
|
||||
clauses = append(clauses, fmt.Sprintf("$%d = ANY(%s.tags)", len(args), alias))
|
||||
}
|
||||
if query != "" {
|
||||
args = append(args, "%"+query+"%")
|
||||
clauses = append(clauses, fmt.Sprintf("(%s.name ILIKE $%d OR %s.code ILIKE $%d OR %s.description ILIKE $%d)", alias, len(args), alias, len(args), alias, len(args)))
|
||||
}
|
||||
where := "status='published' AND enabled"
|
||||
if len(clauses) > 0 {
|
||||
where += " AND " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) catalogTable(ctx context.Context, typ, where string, args []any, limit int, out *[]MarketItem) error {
|
||||
// Filters occupy $1..$N in the WHERE clause; the LIMIT placeholder must be
|
||||
// the next position (N+1), not a hardcoded $1 which would collide with a
|
||||
// category filter arg and land a uuid in LIMIT.
|
||||
limitParam := fmt.Sprintf("$%d", len(args)+1)
|
||||
var query string
|
||||
switch typ {
|
||||
case "mcp_server":
|
||||
query = `SELECT 'mcp_server',m.code,m.name,m.description,m.category_id::text,coalesce(c.name,''),m.tags,m.department_ids::text[],m.updated_at FROM gateway.mcp_servers m LEFT JOIN gateway.marketplace_categories c ON c.id=m.category_id WHERE ` + where + ` ORDER BY m.updated_at DESC LIMIT ` + limitParam
|
||||
case "skill":
|
||||
query = `SELECT 'skill',s.code,s.name,s.description,s.category_id::text,coalesce(c.name,''),s.tags,s.department_ids::text[],s.updated_at FROM gateway.skills s LEFT JOIN gateway.marketplace_categories c ON c.id=s.category_id WHERE ` + where + ` ORDER BY s.updated_at DESC LIMIT ` + limitParam
|
||||
case "digital_employee":
|
||||
query = `SELECT 'digital_employee',d.code,d.name,d.description,d.category_id::text,coalesce(c.name,''),d.tags,d.department_ids::text[],d.updated_at FROM gateway.digital_employees d LEFT JOIN gateway.marketplace_categories c ON c.id=d.category_id WHERE ` + where + ` ORDER BY d.updated_at DESC LIMIT ` + limitParam
|
||||
default:
|
||||
return errors.New("未知的资源类型")
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), limit)
|
||||
rows, err := s.assets.pool.Query(ctx, query, queryArgs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item MarketItem
|
||||
if err = rows.Scan(&item.Type, &item.Code, &item.Name, &item.Description, &item.CategoryID, &item.CategoryName, &item.Tags, &item.DepartmentIDs, &item.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
*out = append(*out, item)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// Detail returns the full resource payload for a published item by type+code.
|
||||
// MCP servers are returned without their encrypted headers; the consumer calls
|
||||
// a dedicated endpoint to test connectivity.
|
||||
func (s *MarketplaceService) Detail(ctx context.Context, resourceType, code string) (MarketItem, json.RawMessage, error) {
|
||||
var item MarketItem
|
||||
var detail any
|
||||
var err error
|
||||
switch resourceType {
|
||||
case "mcp_server":
|
||||
server, getErr := s.mcpServers.GetPublishedByCode(ctx, code)
|
||||
if getErr != nil {
|
||||
return item, nil, getErr
|
||||
}
|
||||
item = MarketItem{Type: "mcp_server", Code: server.Code, Name: server.Name, Description: server.Description, CategoryID: server.CategoryID, CategoryName: server.CategoryName, Tags: server.Tags, DepartmentIDs: server.DepartmentIDs, UpdatedAt: server.UpdatedAt}
|
||||
detail = server
|
||||
case "skill":
|
||||
skill, getErr := s.skills.GetPublishedByCode(ctx, code)
|
||||
if getErr != nil {
|
||||
return item, nil, getErr
|
||||
}
|
||||
item = MarketItem{Type: "skill", Code: skill.Code, Name: skill.Name, Description: skill.Description, CategoryID: skill.CategoryID, CategoryName: skill.CategoryName, Tags: skill.Tags, DepartmentIDs: skill.DepartmentIDs, UpdatedAt: skill.UpdatedAt}
|
||||
detail = skill
|
||||
case "digital_employee":
|
||||
employee, getErr := s.employees.GetPublishedByCode(ctx, code)
|
||||
if getErr != nil {
|
||||
return item, nil, getErr
|
||||
}
|
||||
item = MarketItem{Type: "digital_employee", Code: employee.Code, Name: employee.Name, Description: employee.Description, CategoryID: employee.CategoryID, CategoryName: employee.CategoryName, Tags: employee.Tags, DepartmentIDs: employee.DepartmentIDs, UpdatedAt: employee.UpdatedAt}
|
||||
detail = employee
|
||||
default:
|
||||
return item, nil, errors.New("未知的资源类型")
|
||||
}
|
||||
raw, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
return item, nil, err
|
||||
}
|
||||
return item, raw, nil
|
||||
}
|
||||
|
||||
// --- 安装(工作区绑定 + 权限) ---
|
||||
|
||||
// Install records a portal user's workspace binding to a published resource.
|
||||
// It is the permission grant that lets a cross-department user invoke a
|
||||
// resource that would otherwise be invisible to them.
|
||||
func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, portalUserID string) (bool, error) {
|
||||
resourceID, err := s.publishedResourceID(ctx, resourceType, code)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
id, err := newUUID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO gateway.marketplace_installations(id,resource_type,resource_id,portal_user_id) VALUES($1,$2,$3,$4) ON CONFLICT(resource_type,resource_id,portal_user_id) DO NOTHING`, id, resourceType, resourceID, portalUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created := tag.RowsAffected() == 1
|
||||
// Idempotent re-install: only emit the audit event when a row was created.
|
||||
if created {
|
||||
if err = emit(ctx, tx, "marketplace.installed", resourceType, resourceID, "", map[string]any{"code": code, "portal_user_id": portalUserID}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) Uninstall(ctx context.Context, resourceType, code, portalUserID string) error {
|
||||
resourceID, err := s.publishedResourceID(ctx, resourceType, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.assets.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM gateway.marketplace_installations WHERE resource_type=$1 AND resource_id=$2 AND portal_user_id=$3`, resourceType, resourceID, portalUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err = emit(ctx, tx, "marketplace.uninstalled", resourceType, resourceID, "", map[string]any{"code": code, "portal_user_id": portalUserID}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListInstalled returns the codes the portal user has installed, grouped by
|
||||
// resource type, joined with the live resource name for display.
|
||||
func (s *MarketplaceService) ListInstalled(ctx context.Context, portalUserID string) ([]MarketItem, error) {
|
||||
rows, err := s.assets.pool.Query(ctx, `SELECT resource_type,resource_id::text FROM gateway.marketplace_installations WHERE portal_user_id=$1 ORDER BY created_at DESC`, portalUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type bound struct{ typ, id string }
|
||||
bounds := []bound{}
|
||||
for rows.Next() {
|
||||
var b bound
|
||||
if err = rows.Scan(&b.typ, &b.id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
bounds = append(bounds, b)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]MarketItem, 0, len(bounds))
|
||||
for _, b := range bounds {
|
||||
item, ok, err := s.resourceByID(ctx, b.typ, b.id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Installed reports whether a portal user has an installation for the resource.
|
||||
func (s *MarketplaceService) Installed(ctx context.Context, resourceType, resourceID, portalUserID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.marketplace_installations WHERE resource_type=$1 AND resource_id=$2 AND portal_user_id=$3)`, resourceType, resourceID, portalUserID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// PortalUserForAPIKey resolves the portal user that owns an API key, used to
|
||||
// evaluate installation-based visibility at runtime.
|
||||
func (s *MarketplaceService) PortalUserForAPIKey(ctx context.Context, apiKeyID string) (string, bool, error) {
|
||||
var userID *string
|
||||
err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.api_keys WHERE id=$1`, apiKeyID).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if userID == nil || *userID == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return *userID, true, nil
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) publishedResourceID(ctx context.Context, resourceType, code string) (string, error) {
|
||||
var id string
|
||||
var err error
|
||||
switch resourceType {
|
||||
case "mcp_server":
|
||||
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.mcp_servers WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id)
|
||||
case "skill":
|
||||
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.skills WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id)
|
||||
case "digital_employee":
|
||||
err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.digital_employees WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id)
|
||||
default:
|
||||
return "", errors.New("未知的资源类型")
|
||||
}
|
||||
return id, mapNotFound(err)
|
||||
}
|
||||
|
||||
func (s *MarketplaceService) resourceByID(ctx context.Context, resourceType, id string) (MarketItem, bool, error) {
|
||||
var item MarketItem
|
||||
switch resourceType {
|
||||
case "mcp_server":
|
||||
server, err := s.mcpServers.Get(ctx, id)
|
||||
if err != nil {
|
||||
return item, false, nil
|
||||
}
|
||||
item = MarketItem{Type: "mcp_server", Code: server.Code, Name: server.Name, Description: server.Description, CategoryID: server.CategoryID, CategoryName: server.CategoryName, Tags: server.Tags, UpdatedAt: server.UpdatedAt}
|
||||
case "skill":
|
||||
skill, err := s.skills.Get(ctx, id)
|
||||
if err != nil {
|
||||
return item, false, nil
|
||||
}
|
||||
item = MarketItem{Type: "skill", Code: skill.Code, Name: skill.Name, Description: skill.Description, CategoryID: skill.CategoryID, CategoryName: skill.CategoryName, Tags: skill.Tags, UpdatedAt: skill.UpdatedAt}
|
||||
case "digital_employee":
|
||||
employee, err := s.employees.Get(ctx, id)
|
||||
if err != nil {
|
||||
return item, false, nil
|
||||
}
|
||||
item = MarketItem{Type: "digital_employee", Code: employee.Code, Name: employee.Name, Description: employee.Description, CategoryID: employee.CategoryID, CategoryName: employee.CategoryName, Tags: employee.Tags, UpdatedAt: employee.UpdatedAt}
|
||||
default:
|
||||
return item, false, nil
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user