package web import ( "fmt" "io" "net/http" "regexp" "strconv" "strings" "sync" "time" "rond-with-you/internal/config" ) // mapLayer 描述一个可供前端使用的底图/叠加图层。 // crs 表示该底图自身的坐标系:国内底图是 gcj02,天地图与 OSM 是 wgs84。 type mapLayer struct { Key string `json:"key"` Label string `json:"label"` CRS string `json:"crs"` URL string `json:"url"` MaxZoom int `json:"maxZoom"` Attribution string `json:"attr"` } // tileSet 是当前部署的底图集合。 // upstream 记录哪些图层必须经服务端转发:命中即说明该图层带密钥, // 密钥只在上游模板里拼接,不会出现在页面源码中。 type tileSet struct { base []mapLayer overlays []mapLayer upstream map[string]string subStart int // 瓦片子域起始序号:高德从 1 起,天地图从 0 起 subCount int // 瓦片子域个数:高德 4 个,天地图 8 个 } // 高德免密钥瓦片(三种样式实测:同一块的「近黑像素占比」≈ 文字量) // // style=8 街道图,带 POI 与路名标注(长沙一块 dark≈1.5%,wprd 主机更密≈3.5%) // style=7 纯路网,只画道路、几乎不标注(同一块 dark≈0.2%)—— 点密集时用它才看得清自己的点 // style=6 卫星影像(走 wprd 主机) const ( amapStyleStreet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}" amapStyleRoadnet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}" amapStyleSatellite = "https://wprd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=6&x={x}&y={y}&z={z}" ) const tdtBase = "https://t{s}.tianditu.gov.cn/%s/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" + "&LAYER=%s&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}" func buildTileSet(cfg *config.Config) tileSet { ts := tileSet{upstream: map[string]string{}, overlays: []mapLayer{}, subStart: 1, subCount: 4} switch cfg.MapProvider { case "tianditu": ts.subStart, ts.subCount = 0, 8 ts.upstream["tdt-vec"] = fmt.Sprintf(tdtBase, "vec_w", "vec") ts.upstream["tdt-img"] = fmt.Sprintf(tdtBase, "img_w", "img") ts.upstream["tdt-cva"] = fmt.Sprintf(tdtBase, "cva_w", "cva") ts.upstream["tdt-cia"] = fmt.Sprintf(tdtBase, "cia_w", "cia") ts.base = []mapLayer{ {Key: "tdt-vec", Label: "矢量", CRS: "wgs84", URL: "/tiles/tdt-vec/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"}, {Key: "tdt-img", Label: "影像", CRS: "wgs84", URL: "/tiles/tdt-img/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"}, } // 天地图的注记是独立图层,缺了会看不清地名 ts.overlays = []mapLayer{ {Key: "tdt-cva", Label: "矢量注记", CRS: "wgs84", URL: "/tiles/tdt-cva/{z}/{x}/{y}", MaxZoom: 18}, {Key: "tdt-cia", Label: "影像注记", CRS: "wgs84", URL: "/tiles/tdt-cia/{z}/{x}/{y}", MaxZoom: 18}, } case "custom": key := cfg.MapTileURL if cfg.MapKey != "" { ts.upstream["custom"] = key ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: "/tiles/custom/{z}/{x}/{y}", MaxZoom: 19}} } else { ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: key, MaxZoom: 19}} } default: // amap // OSM 免密钥全球瓦片作为兜底:高德瓦片加载持续失败时前端会自动切过去 osm := mapLayer{Key: "osm", Label: "OSM", CRS: "wgs84", URL: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", MaxZoom: 19, Attribution: "© OpenStreetMap"} if cfg.MapKey == "" { // 免密钥瓦片,浏览器直连,服务端零负担 ts.base = []mapLayer{ {Key: "amap-street", Label: "街道", CRS: "gcj02", URL: amapStyleStreet, MaxZoom: 18, Attribution: "© 高德地图"}, {Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: amapStyleRoadnet, MaxZoom: 18, Attribution: "© 高德地图"}, {Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: amapStyleSatellite, MaxZoom: 18, Attribution: "© 高德地图"}, osm, } } else { ts.upstream["amap-street"] = amapStyleStreet ts.upstream["amap-roadnet"] = amapStyleRoadnet ts.upstream["amap-satellite"] = amapStyleSatellite ts.base = []mapLayer{ {Key: "amap-street", Label: "街道", CRS: "gcj02", URL: "/tiles/amap-street/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, {Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: "/tiles/amap-roadnet/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, {Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: "/tiles/amap-satellite/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, osm, } } } return ts } // ---------- 瓦片代理 ---------- var tilePathRe = regexp.MustCompile(`^/tiles/([a-z0-9-]+)/(\d{1,2})/(\d{1,8})/(\d{1,8})$`) type cachedTile struct { body []byte ct string at time.Time } type tileCache struct { mu sync.Mutex items map[string]cachedTile limit int } func newTileCache(limit int) *tileCache { return &tileCache{items: map[string]cachedTile{}, limit: limit} } func (c *tileCache) get(k string) (cachedTile, bool) { c.mu.Lock() defer c.mu.Unlock() t, ok := c.items[k] return t, ok } // clear 清空缓存(数据变更后调用)。 func (c *tileCache) clear() { c.mu.Lock() c.items = map[string]cachedTile{} c.mu.Unlock() } func (c *tileCache) put(k string, t cachedTile) { c.mu.Lock() defer c.mu.Unlock() if len(c.items) >= c.limit { // 简单的整表淘汰:够用且不会让内存无上限增长 c.items = map[string]cachedTile{} } c.items[k] = t } var tileHTTPClient = &http.Client{Timeout: 15 * time.Second} // handleTile 代理瓦片请求。上游地址与密钥都在服务端拼接,前端只拿到本站路径。 func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) { m := tilePathRe.FindStringSubmatch(r.URL.Path) if m == nil { http.NotFound(w, r) return } layer := m[1] tpl, ok := s.tiles.upstream[layer] if !ok { http.NotFound(w, r) return } z, _ := strconv.Atoi(m[2]) x, _ := strconv.Atoi(m[3]) y, _ := strconv.Atoi(m[4]) if z < 0 || z > 22 { http.NotFound(w, r) return } if x < 0 || y < 0 || x >= 1<= 1< 0 { // 子域按瓦片坐标轮转。写死 0 会打到不存在的域名(高德只有 01~04) up = strings.ReplaceAll(up, "{s}", strconv.Itoa(s.tiles.subStart+(x+y)%s.tiles.subCount)) } up = strings.ReplaceAll(up, "{z}", strconv.Itoa(z)) up = strings.ReplaceAll(up, "{x}", strconv.Itoa(x)) up = strings.ReplaceAll(up, "{y}", strconv.Itoa(y)) up = strings.ReplaceAll(up, "{key}", s.cfg.MapKey) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, up, nil) if err != nil { http.Error(w, "上游地址无效", http.StatusBadGateway) return } req.Header.Set("User-Agent", "rond-with-you/1.0") resp, err := tileHTTPClient.Do(req) if err != nil { http.Error(w, "拉取瓦片失败", http.StatusBadGateway) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { http.Error(w, "上游返回 "+resp.Status, http.StatusBadGateway) return } body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { http.Error(w, "读取瓦片失败", http.StatusBadGateway) return } ct := resp.Header.Get("Content-Type") if !strings.HasPrefix(ct, "image/") { ct = "image/png" } s.tileCache.put(cacheKey, cachedTile{body: body, ct: ct, at: time.Now()}) writeTile(w, body, ct, false) } func writeTile(w http.ResponseWriter, body []byte, ct string, cached bool) { w.Header().Set("Content-Type", ct) w.Header().Set("Cache-Control", "public, max-age=86400") if cached { w.Header().Set("X-Tile-Cache", "hit") } w.Write(body) } // mapConfig 返回前端构建底图所需的全部信息。 // subs 用于浏览器直连的免密钥底图:这类 URL 里带 {s} 占位符, // 必须由前端交给 Leaflet 展开,服务端不参与,因此子域范围得下发。 // default 是后台设的「默认铺哪张底图」(空 = 第一张); // 访客在图上手动切换过的话,前端会优先用他自己记在 localStorage 里的选择。 func (s *Server) mapConfig(w http.ResponseWriter, r *http.Request) { subs := make([]string, 0, s.tiles.subCount) for i := 0; i < s.tiles.subCount; i++ { subs = append(subs, strconv.Itoa(s.tiles.subStart+i)) } def := "" if st, err := s.sets.Load(r.Context()); err == nil { def = st.MapDefaultBase } resp := map[string]any{ "base": s.tiles.base, "overlays": s.tiles.overlays, "subs": subs, "default": def, } if ix, err := s.fogIndex(); err == nil { if blocks, cells := ix.Stats(); blocks > 0 { // ver 里带上遮罩指纹:访客的瓦片允许公共缓存,遮罩改了 URL 必须跟着变, // 否则访客会一直看到缓存里那份(最长 1 小时),像「遮罩没生效」。 ver := strconv.FormatInt(ix.Version(), 10) + "-" + s.maskFP() resp["fog"] = map[string]any{ "blocks": blocks, "cells": cells, "ver": ver, } } } s.json(w, resp) }