feat(platform): add amap reverse geocode for trajectory
This commit is contained in:
@@ -8,8 +8,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,6 +63,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
|
||||
}))
|
||||
handler := static.Handler(cfg.StaticDir, api)
|
||||
handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
@@ -195,6 +198,157 @@ func amapProxyURL(upstreams amapProxyUpstreams, serviceHost string, securityCode
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
type mapReverseGeocodeResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
FormattedAddress string `json:"formattedAddress"`
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Township string `json:"township,omitempty"`
|
||||
Adcode string `json:"adcode,omitempty"`
|
||||
}
|
||||
|
||||
func withAMapReverseGeocodeAPI(next http.Handler, cfg config.Config, upstream string, client *http.Client) http.Handler {
|
||||
apiKey := strings.TrimSpace(cfg.AMapAPIKey)
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
upstream = strings.TrimRight(upstream, "/")
|
||||
if upstream == "" {
|
||||
upstream = "https://restapi.amap.com"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/map/reverse-geocode" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "请求方法不支持", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if apiKey == "" {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "AMAP_API_UNCONFIGURED", "高德服务端 API 未配置", "请配置 AMAP_API_KEY 后再解析地址", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
longitude, latitude, err := parseReverseGeocodeCoordinate(r.URL.Query())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_COORDINATE", "经纬度参数无效", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
target, err := url.Parse(upstream + "/v3/geocode/regeo")
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_URL_INVALID", "高德逆地理编码地址无效", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
query := target.Query()
|
||||
query.Set("key", apiKey)
|
||||
query.Set("location", fmt.Sprintf("%.6f,%.6f", longitude, latitude))
|
||||
query.Set("extensions", "base")
|
||||
query.Set("radius", "1000")
|
||||
query.Set("output", "JSON")
|
||||
target.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_REQUEST_INVALID", "高德逆地理编码请求无效", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_FAILED", "高德逆地理编码请求失败", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_BAD_STATUS", "高德逆地理编码返回异常", resp.Status, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Info string `json:"info"`
|
||||
Infocode string `json:"infocode"`
|
||||
Regeocode struct {
|
||||
FormattedAddress string `json:"formatted_address"`
|
||||
AddressComponent struct {
|
||||
Province json.RawMessage `json:"province"`
|
||||
City json.RawMessage `json:"city"`
|
||||
District json.RawMessage `json:"district"`
|
||||
Township json.RawMessage `json:"township"`
|
||||
Adcode json.RawMessage `json:"adcode"`
|
||||
} `json:"addressComponent"`
|
||||
} `json:"regeocode"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_BAD_JSON", "高德逆地理编码响应解析失败", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if body.Status != "1" {
|
||||
detail := strings.TrimSpace(body.Info)
|
||||
if body.Infocode != "" {
|
||||
detail = strings.TrimSpace(detail + " " + body.Infocode)
|
||||
}
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_REVERSE_GEOCODE_REJECTED", "高德逆地理编码未成功", detail, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
result := mapReverseGeocodeResponse{
|
||||
Provider: "AMap",
|
||||
Longitude: longitude,
|
||||
Latitude: latitude,
|
||||
FormattedAddress: body.Regeocode.FormattedAddress,
|
||||
Province: amapJSONText(body.Regeocode.AddressComponent.Province),
|
||||
City: amapJSONText(body.Regeocode.AddressComponent.City),
|
||||
District: amapJSONText(body.Regeocode.AddressComponent.District),
|
||||
Township: amapJSONText(body.Regeocode.AddressComponent.Township),
|
||||
Adcode: amapJSONText(body.Regeocode.AddressComponent.Adcode),
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), result)
|
||||
})
|
||||
}
|
||||
|
||||
func parseReverseGeocodeCoordinate(query url.Values) (float64, float64, error) {
|
||||
longitude, err := strconv.ParseFloat(strings.TrimSpace(firstNonEmpty(query.Get("longitude"), query.Get("lng"))), 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("longitude 必须是数字")
|
||||
}
|
||||
latitude, err := strconv.ParseFloat(strings.TrimSpace(firstNonEmpty(query.Get("latitude"), query.Get("lat"))), 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("latitude 必须是数字")
|
||||
}
|
||||
if !isCoordinate(longitude, -180, 180) || !isCoordinate(latitude, -90, 90) || (longitude == 0 && latitude == 0) {
|
||||
return 0, 0, fmt.Errorf("longitude/latitude 超出范围或为空坐标")
|
||||
}
|
||||
return longitude, latitude, nil
|
||||
}
|
||||
|
||||
func isCoordinate(value float64, min float64, max float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= min && value <= max
|
||||
}
|
||||
|
||||
func amapJSONText(value json.RawMessage) string {
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(value, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
var texts []string
|
||||
if err := json.Unmarshal(value, &texts); err == nil {
|
||||
return strings.Join(texts, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
||||
if timeout <= 0 {
|
||||
return next
|
||||
|
||||
@@ -156,3 +156,91 @@ func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) {
|
||||
t.Fatalf("unexpected proxy routing: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapReverseGeocodeAPIUsesServerSideKey(t *testing.T) {
|
||||
var gotKey string
|
||||
var gotLocation string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey = r.URL.Query().Get("key")
|
||||
gotLocation = r.URL.Query().Get("location")
|
||||
if r.URL.Path != "/v3/geocode/regeo" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"status":"1",
|
||||
"info":"OK",
|
||||
"regeocode":{
|
||||
"formatted_address":"广东省广州市天河区测试路",
|
||||
"addressComponent":{
|
||||
"province":"广东省",
|
||||
"city":"广州市",
|
||||
"district":"天河区",
|
||||
"township":"五山街道",
|
||||
"adcode":"440106"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, upstream.URL, http.DefaultClient)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1234567&latitude=23.7654321", nil)
|
||||
req.Header.Set("X-Trace-Id", "trace-map")
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotKey != "server-api-key" || gotLocation != "113.123457,23.765432" {
|
||||
t.Fatalf("key=%q location=%q", gotKey, gotLocation)
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Provider string `json:"provider"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
FormattedAddress string `json:"formattedAddress"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Township string `json:"township"`
|
||||
Adcode string `json:"adcode"`
|
||||
} `json:"data"`
|
||||
TraceID string `json:"traceId"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response should be JSON: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.TraceID != "trace-map" || body.Data.Provider != "AMap" || body.Data.FormattedAddress != "广东省广州市天河区测试路" || body.Data.Adcode != "440106" {
|
||||
t.Fatalf("unexpected reverse geocode body: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapReverseGeocodeAPIRequiresServerSideKey(t *testing.T) {
|
||||
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{}, "https://example.com", http.DefaultClient)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=113.1&latitude=23.1", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "AMAP_API_UNCONFIGURED") {
|
||||
t.Fatalf("body should mention missing AMap API config: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapReverseGeocodeAPIRejectsBadCoordinate(t *testing.T) {
|
||||
handler := withAMapReverseGeocodeAPI(http.NotFoundHandler(), config.Config{AMapAPIKey: "server-api-key"}, "https://example.com", http.DefaultClient)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=0&latitude=0", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "BAD_COORDINATE") {
|
||||
t.Fatalf("body should mention bad coordinate: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user