// 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 硬顶)")