package web import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" "rond-with-you/internal/geo" ) // 按名称搜坐标:调用高德 Web 服务接口,密钥只在服务端拼接。 // 高德返回的就是 GCJ-02,与库内坐标同系,前端拿到即可直接入库,无需任何坐标转换。 const ( amapPlaceTextURL = "https://restapi.amap.com/v3/place/text" // 关键字/POI 搜索,适合地名 amapGeocodeURL = "https://restapi.amap.com/v3/geocode/geo" // 结构化地址解析,POI 无结果时兜底 ) var geocodeHTTPClient = &http.Client{Timeout: 8 * time.Second} // geocodeItem 是返回给前端的候选点。 type geocodeItem struct { Name string `json:"name"` Address string `json:"address"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` Province string `json:"province"` City string `json:"city"` District string `json:"district"` } // apiGeocode 供后台「新增地点」的搜索框使用,仅管理员可调用。 func (s *Server) apiGeocode(w http.ResponseWriter, r *http.Request) { if s.cfg.GeocodeKey == "" { s.json(w, map[string]any{"error": "服务端未配置 geocode_key,暂时无法按名称搜索坐标"}) return } q := strings.TrimSpace(r.URL.Query().Get("q")) if q == "" { s.json(w, map[string]any{"error": "请输入要搜索的名称"}) return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() items, err := s.amapSearchPOI(ctx, q) if err != nil { s.json(w, map[string]any{"error": err.Error()}) return } if len(items) == 0 { // 地块/门牌这类不是 POI 的地址,再走一次结构化地理编码 items, err = s.amapGeocode(ctx, q) if err != nil { s.json(w, map[string]any{"error": err.Error()}) return } } if len(items) == 0 { s.json(w, map[string]any{"error": "没有找到匹配的地点,换个更完整的名称试试"}) return } s.json(w, map[string]any{"items": items}) } func (s *Server) amapSearchPOI(ctx context.Context, q string) ([]geocodeItem, error) { v := url.Values{} v.Set("key", s.cfg.GeocodeKey) v.Set("keywords", q) v.Set("offset", "8") v.Set("page", "1") v.Set("extensions", "base") body, err := s.amapGet(ctx, amapPlaceTextURL+"?"+v.Encode()) if err != nil { return nil, err } var raw struct { Status string `json:"status"` Info string `json:"info"` Pois []struct { Name string `json:"name"` Location string `json:"location"` Address json.RawMessage `json:"address"` Pname json.RawMessage `json:"pname"` Cityname json.RawMessage `json:"cityname"` Adname json.RawMessage `json:"adname"` } `json:"pois"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, err } if raw.Status != "1" { return nil, &geoErr{"高德返回:" + raw.Info} } out := make([]geocodeItem, 0, len(raw.Pois)) for _, p := range raw.Pois { lon, lat, ok := splitLocation(p.Location) if !ok { continue } out = append(out, geocodeItem{ Name: p.Name, Address: amapStr(p.Address), Lat: lat, Lon: lon, Province: amapStr(p.Pname), City: amapStr(p.Cityname), District: amapStr(p.Adname), }) } return out, nil } func (s *Server) amapGeocode(ctx context.Context, q string) ([]geocodeItem, error) { v := url.Values{} v.Set("key", s.cfg.GeocodeKey) v.Set("address", q) body, err := s.amapGet(ctx, amapGeocodeURL+"?"+v.Encode()) if err != nil { return nil, err } var raw struct { Status string `json:"status"` Info string `json:"info"` Geocodes []struct { FormattedAddress string `json:"formatted_address"` Location string `json:"location"` Province json.RawMessage `json:"province"` City json.RawMessage `json:"city"` District json.RawMessage `json:"district"` } `json:"geocodes"` } if err := json.Unmarshal(body, &raw); err != nil { return nil, err } if raw.Status != "1" { return nil, &geoErr{"高德返回:" + raw.Info} } out := make([]geocodeItem, 0, len(raw.Geocodes)) for _, g := range raw.Geocodes { lon, lat, ok := splitLocation(g.Location) if !ok { continue } out = append(out, geocodeItem{ Name: g.FormattedAddress, Address: g.FormattedAddress, Lat: lat, Lon: lon, Province: amapStr(g.Province), City: amapStr(g.City), District: amapStr(g.District), }) } return out, nil } func (s *Server) amapGet(ctx context.Context, u string) ([]byte, error) { // 网络抖动很常见,失败退避重试几次;限速令牌每次请求都要重新领 var lastErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { select { case <-time.After(time.Duration(attempt) * 400 * time.Millisecond): case <-ctx.Done(): return nil, ctx.Err() } } select { case <-amapLimiter.C: case <-ctx.Done(): return nil, ctx.Err() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "rond-with-you/1.0") resp, err := geocodeHTTPClient.Do(req) if err != nil { lastErr = &geoErr{"请求高德失败:" + err.Error()} continue } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() if resp.StatusCode != http.StatusOK { lastErr = &geoErr{"高德返回 " + resp.Status} continue } if readErr != nil { lastErr = &geoErr{"读取高德响应失败:" + readErr.Error()} continue } return body, nil } return nil, lastErr } // amapStr 处理高德字段既可能是字符串、也可能是空数组的情况(直辖市常为空数组)。 func amapStr(raw json.RawMessage) string { if len(raw) == 0 { return "" } var s string if json.Unmarshal(raw, &s) == nil { return s } var arr []string if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 { return arr[0] } return "" } // splitLocation 解析高德的 "经度,纬度"。 func splitLocation(v string) (lon, lat float64, ok bool) { a, b, found := strings.Cut(strings.TrimSpace(v), ",") if !found { return 0, 0, false } lon, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64) lat, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64) if err1 != nil || err2 != nil { return 0, 0, false } return lon, lat, true } type geoErr struct{ msg string } func (e *geoErr) Error() string { return e.msg } // ---------- 逆地理编码(坐标 -> 地址),照片导入用 ---------- // regeoItem 是一个坐标反查到的地址信息。坐标是**转换后**的 GCJ-02 // (EXIF 是 WGS-84,前端把原始值发上来,这里统一转,避免两份实现)。 type regeoItem struct { // Key 由调用方给(照片导入里是行号):同一坐标可能被多张照片共用, // 服务端做了去重,没有这个 key 就配不回原来的行。 Key string `json:"key"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` Address string `json:"address"` Province string `json:"province"` City string `json:"city"` District string `json:"district"` Street string `json:"street"` Township string `json:"township"` // Poi / PoiDist 是最近的兴趣点(店名)与距离(米)—— // 照片导入里用它判断「这个点到底在哪儿」,比只给街道好认得多 Poi string `json:"poi"` PoiDist float64 `json:"poiDist"` } const regeoURL = "https://restapi.amap.com/v3/geocode/regeo" const aroundURL = "https://restapi.amap.com/v3/place/around" // 高德对免费 key 有 QPS 限制,一次性甩几十个请求容易被限流(返回 // CUQPS_HAS_EXCEEDED_THE_LIMIT)。所有高德请求统一在这里排队,按 10 次/秒放行。 const amapQPS = 10 // amapLimiter 是全局限速器:令牌在每次真正发请求前领取, // 所以一个点位内部发几次请求(regeo + 周边搜索)也照样受限。 var amapLimiter = time.NewTicker(time.Second / amapQPS) // apiRegeo 批量把坐标换成地址:照片导入时用来让用户判断「这个点对不对」。 // 只传坐标、不传照片;相同坐标会去重,避免同一地点拍多张时重复调用。 func (s *Server) apiRegeo(w http.ResponseWriter, r *http.Request) { if s.cfg.GeocodeKey == "" { s.json(w, map[string]any{"error": "服务端未配置 geocode_key,无法反查地址"}) return } var req struct { Coord string `json:"coord"` Points []struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` Key string `json:"key"` } `json:"points"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { s.json(w, map[string]any{"error": "请求格式不对"}) return } if len(req.Points) > 200 { s.json(w, map[string]any{"error": "一次最多反查 200 个坐标"}) return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() // 先把坐标规整成 GCJ-02(EXIF 是 WGS-84;部分国产手机可能已经是 GCJ-02,由前端选择) type job struct { lat, lon float64 dedup string // 去重用 keys []string } jobs := make([]job, 0, len(req.Points)) at := map[string]int{} for _, p := range req.Points { lat, lon := p.Lat, p.Lon if req.Coord != "gcj02" { lat, lon = geo.WGS84ToGCJ02(lat, lon) } // 5 位小数约 1 米:同一地点拍的多张只查一次,省配额也省时间 k := fmt.Sprintf("%.5f,%.5f", lat, lon) if i, ok := at[k]; ok { jobs[i].keys = append(jobs[i].keys, p.Key) continue } at[k] = len(jobs) jobs = append(jobs, job{lat: lat, lon: lon, dedup: k, keys: []string{p.Key}}) } // 一个去重后的点位可能对应多行,结果按行返回(key 原样带回) perJob := make([]regeoItem, len(jobs)) sem := make(chan struct{}, 4) // 限并发;速率由 amapGet 里的全局限速器控制 var wg sync.WaitGroup for i, j := range jobs { if ctx.Err() != nil { break } wg.Add(1) go func(i int, j job) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() item := regeoItem{Lat: j.lat, Lon: j.lon} if addr, prov, city, dist, street, town, ok := s.amapRegeo(ctx, j.lat, j.lon); ok { item.Address, item.Province, item.City = addr, prov, city item.District, item.Street, item.Township = dist, street, town } // 最近的地点(店名):regeo 返回的 pois 多是行政单位, // 用周边搜索按距离排序取第一条才是店名 if name, dist, ok := s.amapNearestPOI(ctx, j.lat, j.lon); ok { item.Poi, item.PoiDist = name, dist } perJob[i] = item }(i, j) } wg.Wait() items := make([]regeoItem, 0, len(req.Points)) for i, j := range jobs { for _, k := range j.keys { it := perJob[i] it.Key = k items = append(items, it) } } s.json(w, map[string]any{"items": items}) } // amapNearestPOI 找坐标附近最近的一个兴趣点(店名)。 // 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false, // 界面上只显示地址。 func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) { v := url.Values{} v.Set("key", s.cfg.GeocodeKey) v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat)) v.Set("radius", "200") v.Set("offset", "1") v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的 body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode()) if err != nil { return "", 0, false } var raw struct { Status string `json:"status"` Pois []struct { Name string `json:"name"` Distance string `json:"distance"` } `json:"pois"` } if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 { return "", 0, false } d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64) return raw.Pois[0].Name, d, true } // amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。 func (s *Server) amapRegeo(ctx context.Context, lat, lon float64) (addr, prov, city, dist, street, town string, ok bool) { v := url.Values{} v.Set("key", s.cfg.GeocodeKey) // 高德要的是「经度,纬度」 v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat)) v.Set("extensions", "base") body, err := s.amapGet(ctx, regeoURL+"?"+v.Encode()) if err != nil { return } var raw struct { Status string `json:"status"` Regeocode struct { Formatted string `json:"formatted_address"` Comp struct { Province json.RawMessage `json:"province"` City json.RawMessage `json:"city"` District json.RawMessage `json:"district"` Township json.RawMessage `json:"township"` StreetN struct { Street json.RawMessage `json:"street"` } `json:"streetNumber"` } `json:"addressComponent"` } `json:"regeocode"` } if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" { return "", "", "", "", "", "", false } return raw.Regeocode.Formatted, amapStr(raw.Regeocode.Comp.Province), amapStr(raw.Regeocode.Comp.City), amapStr(raw.Regeocode.Comp.District), amapStr(raw.Regeocode.Comp.StreetN.Street), amapStr(raw.Regeocode.Comp.Township), true }