5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
366 lines
12 KiB
Go
366 lines
12 KiB
Go
package workbench
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"aigateway.local/core/internal/platform/cryptox"
|
|
"aigateway.local/core/internal/provider"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type ToolService struct {
|
|
assets *Service
|
|
cipher cryptox.Cipher
|
|
allowPrivate bool
|
|
}
|
|
|
|
func NewToolService(assets *Service, cipher cryptox.Cipher, allowPrivate bool) *ToolService {
|
|
return &ToolService{assets: assets, cipher: cipher, allowPrivate: allowPrivate}
|
|
}
|
|
|
|
func (s *ToolService) validate(ctx context.Context, input *ToolInput, create bool) error {
|
|
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Description = strings.TrimSpace(input.Description)
|
|
input.EndpointURL = strings.TrimSpace(input.EndpointURL)
|
|
input.HTTPMethod = strings.ToUpper(strings.TrimSpace(input.HTTPMethod))
|
|
if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 {
|
|
return errors.New("工具编码、名称或描述格式无效")
|
|
}
|
|
if input.HTTPMethod == "" {
|
|
input.HTTPMethod = "POST"
|
|
}
|
|
switch input.HTTPMethod {
|
|
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
|
default:
|
|
return errors.New("不支持的 HTTP 方法")
|
|
}
|
|
validated, err := provider.ValidateBaseURL(ctx, input.EndpointURL, s.allowPrivate)
|
|
if err != nil {
|
|
return fmt.Errorf("工具端点校验失败: %w", err)
|
|
}
|
|
input.EndpointURL = validated
|
|
if input.TimeoutSeconds == 0 {
|
|
input.TimeoutSeconds = 15
|
|
}
|
|
if input.TimeoutSeconds < 1 || input.TimeoutSeconds > 120 {
|
|
return errors.New("超时应在 1-120 秒之间")
|
|
}
|
|
input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(input.InputSchema) == 0 {
|
|
input.InputSchema = json.RawMessage(`{}`)
|
|
}
|
|
var schema map[string]any
|
|
if err = json.Unmarshal(input.InputSchema, &schema); err != nil {
|
|
return errors.New("input_schema 必须是 JSON 对象")
|
|
}
|
|
normalized, err := json.Marshal(schema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
input.InputSchema = normalized
|
|
if create && input.Headers == nil {
|
|
input.Headers = map[string]string{}
|
|
}
|
|
for key, value := range input.Headers {
|
|
if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsAny(key, "\r\n") || len(value) > 8192 || strings.ContainsAny(value, "\r\n") {
|
|
return errors.New("工具请求头格式无效")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const toolSelect = `SELECT id::text,code,name,description,endpoint_url,http_method,input_schema,timeout_seconds,department_ids::text[],enabled,octet_length(encrypted_headers)>0,revision,created_at,updated_at,encrypted_headers,headers_kek_version FROM gateway.tool_definitions`
|
|
|
|
func scanTool(row pgx.Row) (Tool, error) {
|
|
var t Tool
|
|
err := row.Scan(&t.ID, &t.Code, &t.Name, &t.Description, &t.EndpointURL, &t.HTTPMethod, &t.InputSchema, &t.TimeoutSeconds, &t.DepartmentIDs, &t.Enabled, &t.HasSecretHeaders, &t.Revision, &t.CreatedAt, &t.UpdatedAt, &t.EncryptedHeaders, &t.HeadersKEKVersion)
|
|
return t, mapNotFound(err)
|
|
}
|
|
func (s *ToolService) List(ctx context.Context) ([]Tool, error) {
|
|
rows, err := s.assets.pool.Query(ctx, toolSelect+` ORDER BY updated_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Tool{}
|
|
for rows.Next() {
|
|
t, err := scanTool(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, t)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
func (s *ToolService) Get(ctx context.Context, id string) (Tool, error) {
|
|
return scanTool(s.assets.pool.QueryRow(ctx, toolSelect+` WHERE id=$1`, id))
|
|
}
|
|
func (s *ToolService) GetByCode(ctx context.Context, code string) (Tool, error) {
|
|
return scanTool(s.assets.pool.QueryRow(ctx, toolSelect+` WHERE code=$1 AND enabled`, code))
|
|
}
|
|
|
|
func (s *ToolService) Save(ctx context.Context, id string, input ToolInput, actorID string, create bool) (Tool, error) {
|
|
if err := s.validate(ctx, &input, create); err != nil {
|
|
return Tool{}, err
|
|
}
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return Tool{}, err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
var encrypted []byte
|
|
var version int
|
|
if input.Headers != nil {
|
|
raw, _ := json.Marshal(input.Headers)
|
|
encrypted, version, err = s.cipher.Encrypt(raw)
|
|
if err != nil {
|
|
return Tool{}, err
|
|
}
|
|
}
|
|
if create {
|
|
id, err = newUUID()
|
|
if err != nil {
|
|
return Tool{}, err
|
|
}
|
|
_, err = tx.Exec(ctx, `INSERT INTO gateway.tool_definitions(id,code,name,description,endpoint_url,http_method,encrypted_headers,headers_kek_version,input_schema,timeout_seconds,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled, actorID)
|
|
} else {
|
|
if input.Headers == nil {
|
|
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,input_schema=$7,timeout_seconds=$8,department_ids=$9,enabled=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled)
|
|
err = updateErr
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return Tool{}, ErrNotFound
|
|
}
|
|
} else {
|
|
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,encrypted_headers=$7,headers_kek_version=$8,input_schema=$9,timeout_seconds=$10,department_ids=$11,enabled=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled)
|
|
err = updateErr
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return Tool{}, ErrNotFound
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return Tool{}, err
|
|
}
|
|
event := "tool.updated"
|
|
if create {
|
|
event = "tool.created"
|
|
}
|
|
if err = emit(ctx, tx, event, "tool", id, actorID, nil); err != nil {
|
|
return Tool{}, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return Tool{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *ToolService) Delete(ctx context.Context, id, actorID string) error {
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
var used bool
|
|
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.applications WHERE draft_config->'tool_ids' ? $1 UNION ALL SELECT 1 FROM gateway.application_versions WHERE config->'tool_ids' ? $1)`, id).Scan(&used); err != nil {
|
|
return err
|
|
}
|
|
if used {
|
|
return ErrConflict
|
|
}
|
|
tag, err := tx.Exec(ctx, `DELETE FROM gateway.tool_definitions WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if err = emit(ctx, tx, "tool.deleted", "tool", id, actorID, nil); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *ToolService) headers(tool Tool) (map[string]string, error) {
|
|
plain, err := s.cipher.Decrypt(tool.EncryptedHeaders, tool.HeadersKEKVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers := map[string]string{}
|
|
if err = json.Unmarshal(plain, &headers); err != nil {
|
|
return nil, errors.New("工具请求头密文内容无效")
|
|
}
|
|
return headers, nil
|
|
}
|
|
|
|
func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]any, apiKeyID, requestID string) (result map[string]any, err error) {
|
|
started := time.Now()
|
|
status := "success"
|
|
var responseStatus *int
|
|
defer func() {
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
runID, idErr := newUUID()
|
|
if idErr == nil {
|
|
message := ""
|
|
if err != nil {
|
|
message = err.Error()
|
|
if len(message) > 1000 {
|
|
message = message[:1000]
|
|
}
|
|
}
|
|
_, _ = s.assets.pool.Exec(context.WithoutCancel(ctx), `INSERT INTO gateway.tool_runs(id,tool_id,api_key_id,request_id,status,response_status,latency_ms,error) VALUES($1,$2,nullif($3,'')::uuid,$4,$5,$6,$7,$8)`, runID, tool.ID, apiKeyID, requestID, status, responseStatus, time.Since(started).Milliseconds(), message)
|
|
}
|
|
}()
|
|
if err = validateToolInput(tool.InputSchema, input); err != nil {
|
|
return nil, err
|
|
}
|
|
headers, err := s.headers(tool)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload, err := json.Marshal(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var body io.Reader
|
|
parsed, err := url.Parse(tool.EndpointURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tool.HTTPMethod == http.MethodGet {
|
|
query := parsed.Query()
|
|
for key, value := range input {
|
|
query.Set(key, toString(value))
|
|
}
|
|
parsed.RawQuery = query.Encode()
|
|
} else {
|
|
body = bytes.NewReader(payload)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, tool.HTTPMethod, parsed.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, value := range headers {
|
|
request.Header.Set(key, value)
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
client := &http.Client{Timeout: time.Duration(tool.TimeoutSeconds) * time.Second, Transport: &http.Transport{DialContext: safeToolDial(s.allowPrivate), ForceAttemptHTTP2: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: time.Duration(tool.TimeoutSeconds) * time.Second}, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("工具端点不允许重定向") }}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("工具调用失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
code := response.StatusCode
|
|
responseStatus = &code
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) > 1<<20 {
|
|
return nil, errors.New("工具响应超过 1 MiB")
|
|
}
|
|
var decoded any
|
|
if json.Unmarshal(raw, &decoded) != nil {
|
|
decoded = string(raw)
|
|
}
|
|
return map[string]any{"status_code": response.StatusCode, "body": decoded}, nil
|
|
}
|
|
|
|
func validateToolInput(raw json.RawMessage, input map[string]any) error {
|
|
var schema struct {
|
|
Required []string `json:"required"`
|
|
Properties map[string]struct {
|
|
Type string `json:"type"`
|
|
} `json:"properties"`
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
|
return errors.New("工具 input_schema 无效")
|
|
}
|
|
for _, name := range schema.Required {
|
|
if _, ok := input[name]; !ok {
|
|
return fmt.Errorf("缺少工具必填参数 %s", name)
|
|
}
|
|
}
|
|
for name, property := range schema.Properties {
|
|
value, ok := input[name]
|
|
if !ok || property.Type == "" {
|
|
continue
|
|
}
|
|
valid := false
|
|
switch property.Type {
|
|
case "string":
|
|
_, valid = value.(string)
|
|
case "number":
|
|
switch value.(type) {
|
|
case float64, float32, int, int64, json.Number:
|
|
valid = true
|
|
}
|
|
case "integer":
|
|
switch v := value.(type) {
|
|
case int, int64:
|
|
valid = true
|
|
case float64:
|
|
valid = v == float64(int64(v))
|
|
}
|
|
case "boolean":
|
|
_, valid = value.(bool)
|
|
case "object":
|
|
_, valid = value.(map[string]any)
|
|
case "array":
|
|
_, valid = value.([]any)
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("工具参数 %s 类型应为 %s", name, property.Type)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safeToolDial(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
|
if allowPrivate {
|
|
return dialer.DialContext
|
|
}
|
|
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(addresses) == 0 {
|
|
return nil, errors.New("工具主机没有解析结果")
|
|
}
|
|
for _, candidate := range addresses {
|
|
ip := candidate.IP
|
|
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
|
return nil, fmt.Errorf("工具主机解析到受限地址 %s", ip)
|
|
}
|
|
}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port))
|
|
}
|
|
}
|