From b7dd65e4350ca71427075e7a4a814c91d6ce98c7 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 14:39:57 +0800 Subject: [PATCH] feat(platform): proxy amap security requests --- .../apps/api/internal/app/server.go | 113 +++++++++++++++++- .../apps/api/internal/app/server_test.go | 74 +++++++++++- vehicle-data-platform/docs/deployment.md | 3 +- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index cd985f82..105801d0 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -6,8 +6,11 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "log" "net/http" + "net/url" + "strings" "sync" "time" @@ -51,7 +54,10 @@ func NewServer(cfg config.Config) http.Handler { api := platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{ RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond), })) - return withRequestTimeout(withAppConfig(static.Handler(cfg.StaticDir, api), cfg), cfg.RequestTimeout) + handler := static.Handler(cfg.StaticDir, api) + handler = withAppConfig(handler, cfg) + handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) + return withRequestTimeout(handler, cfg.RequestTimeout) } func withAppConfig(next http.Handler, cfg config.Config) http.Handler { @@ -69,7 +75,7 @@ func withAppConfig(next http.Handler, cfg config.Config) http.Handler { w.Header().Set("Cache-Control", "no-store") body, err := json.Marshal(appConfig{ AMapWebJSKey: cfg.AMapWebJSKey, - AMapSecurityCode: cfg.AMapSecurityCode, + AMapSecurityCode: exposedAMapSecurityCode(cfg), AMapServiceHost: cfg.AMapServiceHost, }) if err != nil { @@ -80,6 +86,109 @@ func withAppConfig(next http.Handler, cfg config.Config) http.Handler { }) } +func exposedAMapSecurityCode(cfg config.Config) string { + if cfg.AMapServiceHost != "" { + return "" + } + return cfg.AMapSecurityCode +} + +type amapProxyUpstreams struct { + RestAPI string + WebAPI string + FMap string +} + +func defaultAMapProxyUpstreams() amapProxyUpstreams { + return amapProxyUpstreams{ + RestAPI: "https://restapi.amap.com", + WebAPI: "https://webapi.amap.com", + FMap: "https://fmap01.amap.com", + } +} + +func (upstreams amapProxyUpstreams) normalized() amapProxyUpstreams { + defaults := defaultAMapProxyUpstreams() + if upstreams.RestAPI == "" { + upstreams.RestAPI = defaults.RestAPI + } + if upstreams.WebAPI == "" { + upstreams.WebAPI = defaults.WebAPI + } + if upstreams.FMap == "" { + upstreams.FMap = defaults.FMap + } + upstreams.RestAPI = strings.TrimRight(upstreams.RestAPI, "/") + upstreams.WebAPI = strings.TrimRight(upstreams.WebAPI, "/") + upstreams.FMap = strings.TrimRight(upstreams.FMap, "/") + return upstreams +} + +func withAMapSecurityProxy(next http.Handler, cfg config.Config, upstreams amapProxyUpstreams, client *http.Client) http.Handler { + serviceHost := strings.TrimRight(cfg.AMapServiceHost, "/") + securityCode := strings.TrimSpace(cfg.AMapSecurityCode) + if serviceHost == "" || securityCode == "" { + return next + } + if client == nil { + client = http.DefaultClient + } + upstreams = upstreams.normalized() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != serviceHost && !strings.HasPrefix(r.URL.Path, serviceHost+"/") { + next.ServeHTTP(w, r) + return + } + target, err := amapProxyURL(upstreams, serviceHost, securityCode, r) + if err != nil { + httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_URL_INVALID", "高德地图代理地址无效", err.Error(), requestTraceID(r)) + return + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) + if err != nil { + httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_REQUEST_INVALID", "高德地图代理请求无效", err.Error(), requestTraceID(r)) + return + } + req.Header = r.Header.Clone() + req.Host = "" + resp, err := client.Do(req) + if err != nil { + httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_FAILED", "高德地图代理请求失败", err.Error(), requestTraceID(r)) + return + } + defer resp.Body.Close() + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + }) +} + +func amapProxyURL(upstreams amapProxyUpstreams, serviceHost string, securityCode string, r *http.Request) (string, error) { + targetPath := strings.TrimPrefix(r.URL.Path, serviceHost) + if targetPath == "" { + targetPath = "/" + } + upstreamBase := upstreams.RestAPI + if strings.HasPrefix(targetPath, "/v4/map/styles") { + upstreamBase = upstreams.WebAPI + } else if strings.HasPrefix(targetPath, "/v3/vectormap") { + upstreamBase = upstreams.FMap + } + base, err := url.Parse(upstreamBase) + if err != nil { + return "", err + } + base.Path = strings.TrimRight(base.Path, "/") + targetPath + query := r.URL.Query() + query.Set("jscode", securityCode) + base.RawQuery = query.Encode() + return base.String(), nil +} + func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler { if timeout <= 0 { return next diff --git a/vehicle-data-platform/apps/api/internal/app/server_test.go b/vehicle-data-platform/apps/api/internal/app/server_test.go index ea9508e9..39042922 100644 --- a/vehicle-data-platform/apps/api/internal/app/server_test.go +++ b/vehicle-data-platform/apps/api/internal/app/server_test.go @@ -77,9 +77,81 @@ func TestAppConfigScriptIsRuntimeRendered(t *testing.T) { t.Fatalf("Cache-Control = %q, want no-store", got) } body := rec.Body.String() - for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityJsCode":"security-code"`, `"amapSecurityServiceHost":"/_AMapService"`} { + for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityServiceHost":"/_AMapService"`} { if !strings.Contains(body, want) { t.Fatalf("app config script missing %q: %s", want, body) } } + if strings.Contains(body, "security-code") || strings.Contains(body, "amapSecurityJsCode") { + t.Fatalf("app config script should not expose AMap security code: %s", body) + } +} + +func TestAMapSecurityProxyAppendsServerSideJscode(t *testing.T) { + var gotPath string + var gotJscode string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotJscode = r.URL.Query().Get("jscode") + w.Header().Set("X-AMap-Test", "ok") + _, _ = w.Write([]byte("proxied")) + })) + defer upstream.Close() + + handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{ + AMapSecurityCode: "security-code", + AMapServiceHost: "/_AMapService", + }, amapProxyUpstreams{RestAPI: upstream.URL}, http.DefaultClient) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/_AMapService/v3/weather/weatherInfo?city=440100", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if gotPath != "/v3/weather/weatherInfo" || gotJscode != "security-code" { + t.Fatalf("upstream path=%q jscode=%q", gotPath, gotJscode) + } + if rec.Body.String() != "proxied" || rec.Header().Get("X-AMap-Test") != "ok" { + t.Fatalf("proxy response not copied: headers=%v body=%s", rec.Header(), rec.Body.String()) + } +} + +func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) { + seen := map[string]string{} + newUpstream := func(name string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen[name] = r.URL.Path + _, _ = w.Write([]byte(name)) + })) + } + rest := newUpstream("rest") + webapi := newUpstream("webapi") + fmap := newUpstream("fmap") + defer rest.Close() + defer webapi.Close() + defer fmap.Close() + + handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{ + AMapSecurityCode: "security-code", + AMapServiceHost: "/_AMapService", + }, amapProxyUpstreams{RestAPI: rest.URL, WebAPI: webapi.URL, FMap: fmap.URL}, http.DefaultClient) + + for _, path := range []string{ + "/_AMapService/v4/map/styles", + "/_AMapService/v3/vectormap", + "/_AMapService/v3/weather/weatherInfo", + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String()) + } + } + + if seen["webapi"] != "/v4/map/styles" || seen["fmap"] != "/v3/vectormap" || seen["rest"] != "/v3/weather/weatherInfo" { + t.Fatalf("unexpected proxy routing: %+v", seen) + } } diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 7c06974f..de9c7b0f 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -37,10 +37,11 @@ CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check AUTH_TOKEN=*** REQUEST_TIMEOUT_MS=5000 AMAP_WEB_JS_KEY=*** +AMAP_SECURITY_JS_CODE=*** AMAP_SECURITY_SERVICE_HOST=/_AMapService ``` -`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. Prefer `AMAP_SECURITY_SERVICE_HOST` with a server-side proxy for production. `AMAP_SECURITY_JS_CODE` is also supported for debugging or controlled internal deployments, but it is exposed to the browser by design and should not be committed. +`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. ## Health