// Package contract 定义本项目唯一的公共契约面:统一信封、任务信封、 // 原始结果、拓展坞五方法接口与错误码。 // // 字段权威:docs/mcp-usage-20260901.md §2(响应字段承诺)与 // docs/design-arch-20260901.md §3.2/§3.3/§3.4。任何字段增删必须先改文档再改此处。 // // 序列化纪律(mcp-usage §2 字段纪律): // - 空数组序列化为 [] 而非 null; // - 时间字段一律 Asia/Shanghai +08:00(RFC3339 带偏移)。 package contract import ( "context" "fmt" "net/http" "strings" "time" "onesvm.com/onesvm/browser-server/internal/config" ) // ---------- 时间序列化 ---------- // Time 统一时间类型:JSON 序列化恒为东八区 RFC3339(+08:00)。 type Time struct{ time.Time } // NewTime 把任意 time.Time 归一到东八区。 func NewTime(t time.Time) Time { return Time{t.In(config.TZ)} } // NowTime 返回东八区当前时间的 Time。 func NowTime() Time { return NewTime(config.Now()) } // MarshalJSON 强制 +08:00 格式(RFC3339 带固定时区)。 func (t Time) MarshalJSON() ([]byte, error) { if t.IsZero() { return []byte("null"), nil } return []byte(`"` + t.In(config.TZ).Format(time.RFC3339) + `"`), nil } // UnmarshalJSON 解析任意合法 RFC3339 时间并归一到东八区。 func (t *Time) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), `"`) if s == "" || s == "null" { t.Time = time.Time{} return nil } parsed, err := time.Parse(time.RFC3339, s) if err != nil { return err } t.Time = parsed.In(config.TZ) return nil } // ---------- 错误码(design-arch §3.4 / mcp-usage §3) ---------- // 错误码常量,与 mcp-usage §3 表一一对应。 const ( CodeRateLimited = "rate_limited" // 429 CodeQuota = "quota" // 402 CodeTimeout = "timeout" // 504 或 200 信封 CodeUpstream = "upstream" // 502 或 200 信封 CodeBlocked = "blocked" // 200 信封(目标站拦截/超限) CodeDenied = "denied" // 403(合规拦截) CodeExtractFailed = "extract_failed" // 200 信封(结构化抽取失败) CodeUnavailable = "unavailable" // 503(队列满/系统压力) CodeUnauthorized = "unauthorized" // 401(key 缺失/无效) ) // ErrBody 信封 error 字段(design-arch §3.3)。 type ErrBody struct { Code string `json:"code"` Message string `json:"message"` RetryAfterS *int `json:"retry_after_s,omitempty"` } // HTTPStatus 返回错误码对应的 HTTP 状态映射(mcp-usage §3 表)。 // 200 信封类(blocked/extract_failed/timeout/upstream)返回 200, // 由调用方决定走 HTTP 错误还是 200+信封 error;本函数给默认 HTTP 面。 func (e ErrBody) HTTPStatus() int { switch e.Code { case CodeRateLimited: return http.StatusTooManyRequests // 429 case CodeQuota: return http.StatusPaymentRequired // 402 case CodeDenied: return http.StatusForbidden // 403 case CodeUnauthorized: return http.StatusUnauthorized // 401 case CodeUnavailable: return http.StatusServiceUnavailable // 503 case CodeTimeout: return http.StatusGatewayTimeout // 504 case CodeUpstream: return http.StatusBadGateway // 502 case CodeBlocked, CodeExtractFailed: return http.StatusOK // 200 信封:有信封无数据 default: return http.StatusInternalServerError } } // Retryable 判断错误码是否属瞬时错误可重试(design-arch §4.2:仅 timeout/upstream)。 func Retryable(code string) bool { return code == CodeTimeout || code == CodeUpstream } // ---------- 统一信封(mcp-usage §2 响应形状) ---------- // Usage 用量块。 type Usage struct { Credits int `json:"credits"` Engine string `json:"engine"` TokensEstimate int `json:"tokens_estimate"` } // Provenance 溯源块(一等公民,design-arch §3.3)。 type Provenance struct { URL string `json:"url,omitempty"` FinalURL string `json:"final_url,omitempty"` RetrievedAt Time `json:"retrieved_at"` Adapter string `json:"adapter"` ProxyExit string `json:"proxy_exit"` // direct | pool: | none Cached bool `json:"cached"` } // SearchResult kind=search 的单条结果(mcp-usage §2.1)。 type SearchResult struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` // ≤800 字符 query 相关片段,非全文 Score float64 `json:"score"` // 0..1 Engine string `json:"engine"` PublishedAt *Time `json:"published_at,omitempty"` } // SearchPayload kind=search 数据块。results 空时为 [](MarshalJSON 锁死)。 type SearchPayload struct { Query string `json:"query"` Answer *string `json:"answer"` // 恒 null:本服务不做 LLM 答案合成 Results []SearchResult `json:"results"` } // ReadMetadata kind=read 的 metadata 块。 type ReadMetadata struct { StatusCode int `json:"status_code"` ContentType string `json:"content_type"` Language string `json:"language"` RetrievedAt Time `json:"retrieved_at"` } // ReadPayload kind=read 数据块(mcp-usage §2.2)。 type ReadPayload struct { URL string `json:"url"` FinalURL string `json:"final_url"` Title string `json:"title"` Description *string `json:"description"` Markdown string `json:"markdown"` Truncated bool `json:"truncated"` CharCount int `json:"char_count"` Metadata ReadMetadata `json:"metadata"` Links []string `json:"links"` Images []string `json:"images"` HTML *string `json:"html"` // 特权 formats 点名才非 null ScreenshotURL *string `json:"screenshot_url"` // 只给可过期 URL,禁 base64 Extracted map[string]any `json:"extracted"` Warnings []string `json:"warnings"` } // EnsureEmptySlice 把 nil 切片归一为空切片(信封纪律:[] 而非 null)。 // 在构造 payload 后调用;单测有 golden JSON 锁死。 func (p *SearchPayload) EnsureEmptySlice() { if p.Results == nil { p.Results = []SearchResult{} } } // EnsureEmptySlice 归一 read 侧数组字段。 func (p *ReadPayload) EnsureEmptySlice() { if p.Links == nil { p.Links = []string{} } if p.Images == nil { p.Images = []string{} } if p.Warnings == nil { p.Warnings = []string{} } } // Envelope 三类 kind 共用顶层信封。 // Data 为 *SearchPayload / *ReadPayload / extract 旁路 map(kind=extract,首版未启用)。 type Envelope struct { OK bool `json:"ok"` Kind string `json:"kind"` // search | read | extract RequestID string `json:"request_id"` TookMs int64 `json:"took_ms"` Data any `json:"-"` Usage Usage `json:"usage"` Provenance Provenance `json:"provenance"` Error *ErrBody `json:"error"` } // envelopeWire 信封线格式:search/read 全字段集并列,kind 裁剪块负责互斥。 // 平铺字段一律无 omitempty:nil 指针/nil 切片输出显式 null,空切片输出 []。 type envelopeWire struct { OK bool `json:"ok"` Kind string `json:"kind"` RequestID string `json:"request_id"` TookMs int64 `json:"took_ms"` Usage Usage `json:"usage"` Provenance Provenance `json:"provenance"` Error *ErrBody `json:"error"` // search 平铺字段(answer 恒 null、results 恒 []) Query string `json:"query"` Answer *string `json:"answer"` Results []SearchResult `json:"results"` // read 平铺字段(links/images/warnings 恒 [];html/screenshot_url/extracted 恒存在) URL *string `json:"url"` FinalURL *string `json:"final_url"` Title *string `json:"title"` Description *string `json:"description"` Markdown *string `json:"markdown"` Truncated *bool `json:"truncated"` CharCount *int `json:"char_count"` Metadata *ReadMetadata `json:"metadata"` Links []string `json:"links"` Images []string `json:"images"` HTML *string `json:"html"` ScreenshotURL *string `json:"screenshot_url"` Extracted map[string]any `json:"extracted"` Warnings []string `json:"warnings"` } // envelopeTop 信封顶层公共字段(search/read wire 内嵌)。 type envelopeTop struct { OK bool `json:"ok"` Kind string `json:"kind"` RequestID string `json:"request_id"` TookMs int64 `json:"took_ms"` Usage Usage `json:"usage"` Provenance Provenance `json:"provenance"` Error *ErrBody `json:"error"` } // searchWire kind=search 线格式(仅 search 字段)。 type searchWire struct { envelopeTop Query string `json:"query"` Answer *string `json:"answer"` Results []SearchResult `json:"results"` } // readWire kind=read 线格式(仅 read 字段)。 type readWire struct { envelopeTop URL *string `json:"url"` FinalURL *string `json:"final_url"` Title *string `json:"title"` Description *string `json:"description"` Markdown *string `json:"markdown"` Truncated *bool `json:"truncated"` CharCount *int `json:"char_count"` Metadata *ReadMetadata `json:"metadata"` Links []string `json:"links"` Images []string `json:"images"` HTML *string `json:"html"` ScreenshotURL *string `json:"screenshot_url"` Extracted map[string]any `json:"extracted"` Warnings []string `json:"warnings"` } // MarshalJSON 平铺信封:search/read 各出独立 wire 形状(字段互斥、无串味)。 // 空数组纪律在组装处执行:nil → [](Results/Links/Images/Warnings)。 func (e Envelope) MarshalJSON() ([]byte, error) { top := envelopeTop{ OK: e.OK, Kind: e.Kind, RequestID: e.RequestID, TookMs: e.TookMs, Usage: e.Usage, Provenance: e.Provenance, Error: e.Error, } switch d := e.Data.(type) { case *SearchPayload: w := searchWire{envelopeTop: top} if d != nil { w.Query = d.Query w.Answer = d.Answer // 恒 null(不做 LLM answer) w.Results = d.Results } if w.Results == nil { w.Results = []SearchResult{} } return jsonMarshal(w) case *ReadPayload: w := readWire{envelopeTop: top} if d != nil { u, fu, ti, md := d.URL, d.FinalURL, d.Title, d.Markdown tr, cc := d.Truncated, d.CharCount w.URL, w.FinalURL, w.Title, w.Markdown = &u, &fu, &ti, &md w.Truncated, w.CharCount = &tr, &cc w.Description = d.Description // 可空字段透传指针 w.Metadata = &d.Metadata w.HTML = d.HTML w.ScreenshotURL = d.ScreenshotURL w.Extracted = d.Extracted w.Links, w.Images, w.Warnings = d.Links, d.Images, d.Warnings } if w.Links == nil { w.Links = []string{} } if w.Images == nil { w.Images = []string{} } if w.Warnings == nil { w.Warnings = []string{} } return jsonMarshal(w) default: // extract 旁路或未知 kind:仅顶层 return jsonMarshal(top) } } // ---------- 输入与任务信封 ---------- // Region 出口区域(design-arch §4.4 标签)。 const ( RegionDomestic = "domestic" RegionOverseas = "overseas" ) // ValidRegion 校验 region 值;空串给 domestic 默认。 func ValidRegion(r string) (string, error) { switch r { case "": return RegionDomestic, nil case RegionDomestic, RegionOverseas: return r, nil default: return "", fmt.Errorf("region 必须为 %s|%s,得 %q", RegionDomestic, RegionOverseas, r) } } // SearchInput 搜索输入(mcp-usage §2.1 参数表)。 type SearchInput struct { Query string `json:"query"` Region string `json:"region"` MaxResults int `json:"max_results"` TimeRange *string `json:"time_range,omitempty"` // day|week|month|year Lang *string `json:"lang,omitempty"` } // ReadInput 精读输入(mcp-usage §2.2 参数表)。 type ReadInput struct { URL string `json:"url"` Formats []string `json:"formats"` // markdown 默认;links/images;html/screenshot 特权 MaxChars int `json:"max_chars"` Extract *ExtractSpec `json:"extract,omitempty"` // 特权 scope Region string `json:"region"` } // ExtractSpec 结构化抽取旁路(特权)。 type ExtractSpec struct { Schema map[string]any `json:"schema"` Prompt string `json:"prompt,omitempty"` } // DefaultFormats 默认产出格式。 func DefaultFormats() []string { return []string{"markdown"} } // JobEnvelope 队列任务信封(design-arch §3.5;scheduler 落 WAL 的 payload 形状)。 type JobEnvelope struct { ID string `json:"id"` RequestID string `json:"request_id"` Intent string `json:"intent"` // search | read Search *SearchInput `json:"search,omitempty"` Read *ReadInput `json:"read,omitempty"` KeyID string `json:"key_id"` ConsumerID string `json:"consumer_id"` Priority int `json:"priority"` SubmittedAt Time `json:"submitted_at"` // 东八区 Status string `json:"status"` Session *SessionAttach `json:"-"` // Execute 注入;禁止落 jobs.payload / MCP } // 任务状态。 const ( JobQueued = "queued" JobRunning = "running" JobDone = "done" JobFailed = "failed" JobDead = "dead" ) // RawResult 适配器原生输出(原生结构不出适配器,design-arch §3.2)。 type RawResult struct { Title string `json:"title"` Text string `json:"text"` Markdown string `json:"markdown"` HTML string `json:"html"` FinalURL string `json:"final_url"` StatusCode int `json:"status_code"` Headers map[string]string `json:"headers"` Engine string `json:"engine"` Extra map[string]any `json:"extra"` // 引擎私有附加数据(score 位次、published_at 等) SetCookies []Cookie `json:"-"` // 内部回写罐;禁止进信封 Poisoned bool `json:"-"` // 验证页/403;调度器整域丢罐 } // Render 渲染能力分级(design-arch §4.4)。 const ( RenderNone = "none" RenderLight = "light" RenderFull = "full" ) // Caps 适配器能力声明。 type Caps struct { Intents []string `json:"intents"` // [search, read] Render string `json:"render"` // none | light | full Regions []string `json:"regions"` // [domestic, overseas] MaxConcurrent int `json:"max_concurrent"` ProxyRequired bool `json:"proxy_required"` Formats []string `json:"formats"` } // Match 判断 Caps 是否满足任务需求(能力路由标签匹配,W2 派发用)。 // needRegion 非空时必须在 Regions 内;render 需求 none 总可满足, // light 要求 ≥light,full 仅 full。 func (c Caps) Match(intent, region, minRender string) error { found := false for _, i := range c.Intents { if i == intent { found = true break } } if !found { return fmt.Errorf("adapter 不支持意图 %s", intent) } if region != "" && region != RegionDomestic && region != RegionOverseas { return fmt.Errorf("非法 region %q", region) } if region == RegionOverseas && !c.supportsRegion(region) { return fmt.Errorf("adapter 不支持 region=overseas") } if !renderGE(c.Render, minRender) { return fmt.Errorf("adapter 渲染能力 %s < 需求 %s", c.Render, minRender) } return nil } func (c Caps) supportsRegion(r string) bool { for _, x := range c.Regions { if x == r { return true } } return false } // renderGE 渲染分级比较:a 是否 ≥ b。 func renderGE(a, b string) bool { rank := map[string]int{RenderNone: 0, RenderLight: 1, RenderFull: 2} return rank[a] >= rank[b] } // ---------- Dock 五方法接口(design-arch §3.2) ---------- // Health 适配器健康上报(research/04 §4.6 强制项)。 type Health struct { OK bool `json:"ok"` RSSBytes uint64 `json:"rss_bytes"` StartupMs int64 `json:"startup_ms"` SlotsFree int `json:"slots_free"` Details string `json:"details,omitempty"` } // DockAdapter 拓展坞五方法。W2–W4 适配器按此实现并注册进 scheduler 注册表。 type DockAdapter interface { // Init 初始化:拉进程/连远端/预热 HTTP 客户端。 Init(ctx context.Context) error // Health 上报内存与启动耗时(协议强制)。 Health() Health // Execute 执行任务;原生结构不出适配器,只产 RawResult。 Execute(ctx context.Context, job JobEnvelope) (*RawResult, *ErrBody) // Teardown 优雅停机:停新活、归还槽位、杀进程回收。 Teardown(ctx context.Context) error // Capabilities 能力标签(能力路由依据)。 Capabilities() Caps }