onesvm-browser-server/server/internal/gateway/pipeline.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

192 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package gateway 统一出口网关(design-arch §2):
// MCP POST /mcp(2026 无状态)+ HTTP 兜底 /v1/search /v1/read + admin 面 + healthz/readyz。
//
// 请求管线(design §5.4):入参校验 → 认证 → scope → 429 令牌桶 → 402 配额预扣
// → 403 合规预检(SSRF/域名/robots,denied 落审计)→ POST scheduler /enqueue
// → 轮询 /result → 统一信封。scheduler 不可达 503+Retry-After,gateway 零落盘。
package gateway
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"onesvm.com/onesvm/browser-server/internal/contract"
)
// 入参边界(mcp-usage §2 参数表 + T7 边界纪律)。
const (
MaxResultsCap = 20 // max_results 上限
MaxCharsCap = 100_000 // max_chars 上限(默认 20000,允许显式调大但不越限)
MaxQueryRunes = 512 // query 最大字符数
MaxURLLen = 2048 // url 最大长度
MaxFormats = 8 // formats 数组上限
DefaultMaxChars = 20_000 // mcp-usage §2.2 默认截断
ReadTimeoutS = 30 // read 默认任务超时(job 参数带)
SearchTimeoutS = 30 // search 任务超时
WaitBudget = 120 * time.Second // 等待结果硬顶
PollInterval = 200 * time.Millisecond
)
// formatsWhitelist formats 白名单(特权项另有 scope 校验)。
var formatsWhitelist = map[string]bool{
"markdown": true, "links": true, "images": true,
"html": true, "screenshot": true,
}
// formatScope 需要特权的 format。
var formatScope = map[string]string{
"html": "rawHtml", "screenshot": "screenshot",
}
// validateSearch 入参校验(SearchInput)。
func validateSearch(in *contract.SearchInput) error {
in.Query = strings.TrimSpace(in.Query)
if in.Query == "" {
return fmt.Errorf("query 必填")
}
if n := len([]rune(in.Query)); n > MaxQueryRunes {
return fmt.Errorf("query 超长(≤%d 字符)", MaxQueryRunes)
}
if _, err := contract.ValidRegion(in.Region); err != nil {
return err
}
if in.MaxResults < 0 || in.MaxResults > MaxResultsCap {
return fmt.Errorf("max_results 须在 1..%d", MaxResultsCap)
}
if in.MaxResults == 0 {
in.MaxResults = 5 // mcp-usage §2.1 默认
}
if in.TimeRange != nil {
switch *in.TimeRange {
case "day", "week", "month", "year":
default:
return fmt.Errorf("time_range 须为 day|week|month|year")
}
}
if in.Lang != nil && len(*in.Lang) > 16 {
return fmt.Errorf("lang 超长(≤16 字符)")
}
return nil
}
// validateRead 入参校验(ReadInput);返回所需 scope 检查项(特权 formats/extract)。
func validateRead(in *contract.ReadInput) error {
in.URL = strings.TrimSpace(in.URL)
if in.URL == "" {
return fmt.Errorf("url 必填")
}
if len(in.URL) > MaxURLLen {
return fmt.Errorf("url 超长(≤%d 字符)", MaxURLLen)
}
if _, err := contract.ValidRegion(in.Region); err != nil {
return err
}
if len(in.Formats) > MaxFormats {
return fmt.Errorf("formats 项数超限(≤%d)", MaxFormats)
}
if len(in.Formats) == 0 {
in.Formats = contract.DefaultFormats()
}
for _, f := range in.Formats {
if !formatsWhitelist[f] {
return fmt.Errorf("formats 含不支持项 %q", f)
}
}
if in.MaxChars < 0 || in.MaxChars > MaxCharsCap {
return fmt.Errorf("max_chars 须在 1..%d", MaxCharsCap)
}
if in.MaxChars == 0 {
in.MaxChars = DefaultMaxChars
}
if in.Extract != nil && len(in.Extract.Schema) == 0 {
return fmt.Errorf("extract.schema 必填")
}
return nil
}
// privilegedRead read 请求触发的特权 scope 集合(extract/screenshot/rawHtml)。
func privilegedRead(in *contract.ReadInput) []string {
var need []string
if in.Extract != nil {
need = append(need, "extract")
}
for _, f := range in.Formats {
if s, ok := formatScope[f]; ok {
need = append(need, s)
}
}
return need
}
// jobTimeoutS 任务执行超时(job 参数带,gateway 等待另算 120s 硬顶)。
func jobTimeoutS(intent string) int {
if intent == "read" {
return ReadTimeoutS
}
return SearchTimeoutS
}
// enqueueResult scheduler /enqueue 响应(W2 内部契约,与 W3 对齐;见回执 §4)。
// 只约束 gateway 实际读取的字段:ack 与 job_id/request_id/queued_position。
type enqueueResult struct {
OK bool `json:"ok"`
JobID int64 `json:"job_id"`
RequestID string `json:"request_id"`
QueuedPosition int `json:"queued_position"`
// 拒绝面(503/429 由 HTTP 状态承载,body 亦带 code 便于断言)
Error *contract.ErrBody `json:"error,omitempty"`
}
// resultState /result 响应(ITER-1 F1 统一形状,golden:contract.ResultShapeDone/
// ResultShapeAccepted/ResultShapeFailed 双端同源):
// 终态(done/failed/dead)HTTP 200 + {request_id,status,envelope};
// 非终态(queued/running)HTTP 202 + {request_id,status,position?}。
type resultState struct {
RequestID string `json:"request_id"`
Status string `json:"status"` // queued|running|done|failed|dead|unknown
Position int `json:"position,omitempty"`
Envelope json.RawMessage `json:"envelope,omitempty"` // 终态完整统一信封(嵌套)
}
// waitResult 轮询终态(200ms 间隔,120s 硬顶)。
// 返回终态信封原始字节;等待预算耗尽返回 timeout 错误。
func waitResult(ctx context.Context, sc *SchedulerClient, requestID string) (json.RawMessage, error) {
deadline := time.Now().Add(WaitBudget)
for {
st, err := sc.Result(ctx, requestID)
if err != nil {
return nil, err
}
switch st.Status {
case contract.JobDone:
if len(st.Envelope) > 0 {
return st.Envelope, nil
}
return nil, fmt.Errorf("scheduler 终态缺 envelope")
case contract.JobFailed, contract.JobDead:
// scheduler 侧任务失败:若带信封错误则透传,否则归一 upstream。
if len(st.Envelope) > 0 {
return st.Envelope, nil
}
return nil, fmt.Errorf("scheduler 任务终态 %s", st.Status)
}
if time.Now().After(deadline) {
return nil, errWaitTimeout
}
sleep := PollInterval
if st.Status == contract.JobRunning {
sleep = PollInterval
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(sleep):
}
}
}
// errWaitTimeout 等待硬顶超时(error.code=timeout)。
var errWaitTimeout = fmt.Errorf("等待结果超时(120s 硬顶)")