feat: harden loose-offline sync for user JWT, schema, and console ops

Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 09:47:35 +08:00
parent 76cdcd760e
commit b04b180d30
59 changed files with 4762 additions and 308 deletions

View File

@@ -1,4 +1,4 @@
package config
package config
type Config struct {
Name string `json:",optional"`
@@ -7,7 +7,7 @@ type Config struct {
PlatformURL string `json:",default=http://127.0.0.1:8888"`
AIURL string `json:",default=http://127.0.0.1:8001"`
RateLimitPerMin int `json:",default=240"`
ProxyTimeoutSec int `json:",default=60"`
ProxyTimeoutSec int `json:",default=120"`
CorsEnable bool `json:",default=true"`
// 与中台 Auth.AccessSecret 保持一致
JWTSecret string `json:",optional"`

View File

@@ -1,11 +1,15 @@
package proxy
import (
"context"
"errors"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
)
@@ -26,36 +30,102 @@ func New(platformURL, aiURL string, timeoutSec int) (*Director, error) {
return nil, err
}
if timeoutSec <= 0 {
timeoutSec = 60
timeoutSec = 180
}
return &Director{Platform: p, AI: a, Timeout: time.Duration(timeoutSec) * time.Second}, nil
}
// eofRetryTransport空闲连接被 platform 关掉时首包 EOF自动换新连接重试一次。
type eofRetryTransport struct {
base http.RoundTripper
}
func (t eofRetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
resp, err := base.RoundTrip(req)
if err == nil || req.Context().Err() != nil {
return resp, err
}
if !isTransientUpstreamEOF(err) {
return resp, err
}
// Body 可能已部分读;仅在尚未发出/可读失败时安全重试 GET/有 GetBody 的请求
if req.Body != nil && req.GetBody == nil && req.ContentLength != 0 {
return resp, err
}
if req.GetBody != nil {
body, gerr := req.GetBody()
if gerr != nil {
return resp, err
}
req = req.Clone(req.Context())
req.Body = body
}
return base.RoundTrip(req)
}
func isTransientUpstreamEOF(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "eof") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "broken pipe") ||
strings.Contains(msg, "server closed idle connection")
}
func (d *Director) Handler() http.HandlerFunc {
transport := &http.Transport{
baseTransport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: d.Timeout,
// Windows 上 platform 重启后空闲连接易 EOF关掉 keep-alive 比偶发 502 更稳
DisableKeepAlives: true,
}
transport := eofRetryTransport{base: baseTransport}
return func(w http.ResponseWriter, r *http.Request) {
target, path := d.route(r.URL.Path)
timeout := d.Timeout
// dbsync agent push/pullWindows SQLite 冷打开可超过默认超时
if isDBSyncHeavyPath(path) && timeout < 180*time.Second {
timeout = 180 * time.Second
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = transport
proxy.FlushInterval = 100 * time.Millisecond
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadGateway)
_, _ = rw.Write([]byte(`{"code":502,"message":"upstream unavailable: ` + escape(err.Error()) + `"}`))
msg := escape(err.Error())
code := http.StatusBadGateway
// 客户端/网关超时 → 503便于 agent 按 retryable 重试(与 platform 语义一致)
if errors.Is(err, context.DeadlineExceeded) || errors.Is(req.Context().Err(), context.DeadlineExceeded) ||
strings.Contains(strings.ToLower(err.Error()), "deadline exceeded") ||
strings.Contains(strings.ToLower(err.Error()), "context canceled") {
code = http.StatusServiceUnavailable
}
rw.WriteHeader(code)
_, _ = rw.Write([]byte(
`{"code":` + strconv.Itoa(code) + `,"message":"upstream unavailable: ` + msg + `","retryable":true}`,
))
}
proxy.ModifyResponse = func(resp *http.Response) error {
// 网关已统一加 CORS去掉上游重复头避免浏览器报 Failed to fetch
for _, h := range []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
@@ -70,7 +140,6 @@ func (d *Director) Handler() http.HandlerFunc {
return nil
}
// 重写路径
r.URL.Path = path
r.Host = target.Host
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
@@ -78,15 +147,46 @@ func (d *Director) Handler() http.HandlerFunc {
if r.Header.Get("X-Request-Id") == "" {
r.Header.Set("X-Request-Id", newRequestID())
}
reqID := r.Header.Get("X-Request-Id")
started := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
// 超时上下文
ctx := r.Context()
proxy.ServeHTTP(w, r.WithContext(ctx))
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
proxy.ServeHTTP(rec, r.WithContext(ctx))
if isDBSyncHeavyPath(path) || rec.status >= 500 {
log.Printf("gateway %s %s status=%d dur=%s req=%s", r.Method, path, rec.status, time.Since(started), reqID)
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func isDBSyncHeavyPath(path string) bool {
if strings.Contains(path, "/agent/sync/") {
return true
}
if !strings.Contains(path, "/admin/sync/") {
return false
}
return strings.Contains(path, "/push") ||
strings.HasSuffix(path, "/reconcile") ||
strings.HasSuffix(path, "/ingest") ||
strings.HasSuffix(path, "/prepare") ||
strings.HasSuffix(path, "/start") ||
strings.HasSuffix(path, "/pull") ||
strings.HasSuffix(path, "/bootstrap")
}
func (d *Director) route(path string) (*url.URL, string) {
// AI/ai/* → 上游去掉 /ai 前缀
if strings.HasPrefix(path, "/ai/") || path == "/ai" {
next := strings.TrimPrefix(path, "/ai")
if next == "" {
@@ -94,14 +194,12 @@ func (d *Director) route(path string) (*url.URL, string) {
}
return d.AI, next
}
// 生成蓝图:统一 /api/v1/apps/generate兼容旧冒号路径避免重复实现
if path == "/api/v1/apps/generate" || strings.HasPrefix(path, "/api/v1/apps/generate?") {
return d.AI, path
}
if strings.HasPrefix(path, "/api/v1/apps:generate") || strings.Contains(path, "/apps%3Agenerate") {
return d.AI, "/api/v1/apps/generate"
}
// 其余 /api 走中台
return d.Platform, path
}
@@ -120,13 +218,11 @@ func newRequestID() string {
// Health 聚合探测
func (d *Director) Health(w http.ResponseWriter, _ *http.Request) {
type st struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}
client := &http.Client{Timeout: 2 * time.Second}
out := []st{
out := []struct {
Name string `json:"name"`
OK bool `json:"ok"`
}{
probe(client, "platform", d.Platform.String()+"/api/v1/auth/login"),
probe(client, "ai", d.AI.String()+"/health"),
}
@@ -158,29 +254,24 @@ func (d *Director) Health(w http.ResponseWriter, _ *http.Request) {
}
func probe(client *http.Client, name, rawURL string) struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
Name string `json:"name"`
OK bool `json:"ok"`
} {
// login 用 OPTIONS/GET 可能 405用短超时 HEAD/GET health 风格
req, _ := http.NewRequest(http.MethodGet, rawURL, nil)
if name == "platform" {
// 未登录会 401/405 都说明服务活着;连接失败才算挂
req, _ = http.NewRequest(http.MethodPost, rawURL, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}{Name: name, OK: false, Detail: err.Error()}
Name string `json:"name"`
OK bool `json:"ok"`
}{Name: name, OK: false}
}
defer resp.Body.Close()
return struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
Name string `json:"name"`
OK bool `json:"ok"`
}{Name: name, OK: true}
}