Files
rond-with-you/internal/web/geocode.go
T
2026-09-18 13:05:49 +08:00

412 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}