Files
ai-gateway-go/internal/workbench/marketplace.go
T
superidou 5759c1862e 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>
2026-08-12 11:45:54 +08:00

445 lines
16 KiB
Go

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
}