package web import ( "bytes" "context" "errors" "fmt" "image" "image/png" "io" "log" "math" "net/http" "path/filepath" "sort" "strconv" "strings" "sync" "time" "rond-with-you/internal/fog" "rond-with-you/internal/geo" ) const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB // 迷雾是站点级数据(与公开页读数据不区分归属的约定一致), // 内存里只持一份索引,首次访问时从库里加载。 var ( fogOnce sync.Once fogIndexV *fog.Index ) func (s *Server) fogIndex() (*fog.Index, error) { fogOnce.Do(func() { fogIndexV = fog.NewIndex() if err := fogIndexV.LoadAll(s.db); err != nil { fogIndexV = nil } }) if fogIndexV == nil { return nil, fmt.Errorf("迷雾数据加载失败") } return fogIndexV, nil } // handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是 // GCJ-02(高德),前端会带 ?crs=gcj02,服务端把每个块做 WGS→GCJ 正偏移后再画。 func (s *Server) handleFogTile(w http.ResponseWriter, r *http.Request) { z, err1 := strconv.Atoi(r.PathValue("z")) x, err2 := strconv.Atoi(r.PathValue("x")) y, err3 := strconv.Atoi(r.PathValue("y")) if err1 != nil || err2 != nil || err3 != nil || z < 0 || z > 22 || x < 0 || y < 0 || x >= 1<= 1< maxFogUploadBytes { return nil, errors.New("迷雾快照超过 64MB,疑似文件有误") } return data, nil } // adminFogUpload 导入 .fwss:mode=overlay 按位或合并进现有迷雾, // mode=cover 清空后整体替换。 // // 带 stash 令牌的请求来自差异页的确认按钮,快照已经在暂存目录里, // 不用让浏览器把几十 MB 再传一遍。 func (s *Server) adminFogUpload(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u := userFrom(ctx) mode := r.PostFormValue("mode") if mode != "overlay" && mode != "cover" { mode = "overlay" } token := r.URL.Query().Get("stash") var data []byte var err error if token != "" { if data, err = s.loadStashedFog(token); err != nil { s.adminFlash(w, r, "err", err.Error()) return } } else if data, err = readFogSnapshot(w, r); err != nil { s.adminFlash(w, r, "err", err.Error()) return } blocks, cells, err := fog.Decode(data) if err != nil { s.adminFlash(w, r, "err", "解析迷雾快照失败:"+err.Error()) return } // 快照里还有 Model/~ 与 Model/# 两层(约占一半体积):站点不用它们渲染, // 但导出时要原样写回,否则重导出的快照在 fog of world 里是残缺的 layers, err := fog.RawLayers(data) if err != nil { s.adminFlash(w, r, "err", "解析快照的其它图层失败:"+err.Error()) return } if len(blocks) == 0 { s.adminFlash(w, r, "err", "快照里没有找到迷雾瓦片数据") return } ix, err := s.fogIndex() if err != nil { s.serverError(w, r, err) return } applied, err := ix.Import(s.db, u.ID, blocks, mode == "overlay") if err != nil { s.serverError(w, r, err) return } // 「整体替换」时图层也整体换掉,避免留下上一份快照里已经不存在的图层条目 if err := ix.SaveRawLayers(s.db, u.ID, layers, mode == "cover"); err != nil { log.Printf("留存迷雾其它图层失败: %v", err) } b, c := ix.Stats() verb := "叠加合并" if mode == "cover" { verb = "整体替换" } // 导入成功才丢掉暂存件;失败时留着,站长可以回差异页重试 if token != "" { s.dropStashedFog(token) } s.adminFlash(w, r, "ok", fmt.Sprintf( "迷雾导入成功(%s):本次写入 %d 块 / %d 格,当前共 %d 块 / %d 格", verb, applied, cells, b, c)) } // adminFogPreview 先把快照收下暂存,不写库,跳到差异页—— // 叠加能补多少、整体替换会丢多少,都得让站长在下手之前看见。 func (s *Server) adminFogPreview(w http.ResponseWriter, r *http.Request) { data, err := readFogSnapshot(w, r) if err != nil { s.adminFlash(w, r, "err", err.Error()) return } // 先解一遍确认是真快照,免得把坏文件暂存下来、到差异页才发现 if _, _, err := fog.Decode(data); err != nil { s.adminFlash(w, r, "err", "解析迷雾快照失败:"+err.Error()) return } token, err := s.stashFog(data) if err != nil { s.serverError(w, r, err) return } http.Redirect(w, r, "/admin/fog/preview/"+token, http.StatusSeeOther) } // adminFogPreviewPage 渲染差异页。每次都重新算一遍:库里可能刚被别的操作改过。 func (s *Server) adminFogPreviewPage(w http.ResponseWriter, r *http.Request) { p, err := s.pageBase(r, "admin", "迷雾导入预览") if err != nil { s.serverError(w, r, err) return } token := r.PathValue("token") data, err := s.loadStashedFog(token) if err != nil { s.adminFlash(w, r, "err", err.Error()) return } blocks, _, err := fog.Decode(data) if err != nil { s.adminFlash(w, r, "err", "解析迷雾快照失败:"+err.Error()) return } if len(blocks) == 0 { s.adminFlash(w, r, "err", "快照里没有找到迷雾瓦片数据") return } layers, err := fog.RawLayers(data) if err != nil { log.Printf("解析快照的其它图层失败: %v", err) } ix, err := s.fogIndex() if err != nil { s.serverError(w, r, err) return } var cur []fog.Block ix.View(func(m map[[2]int][]byte) { cur = make([]fog.Block, 0, len(m)) for k, bm := range m { cur = append(cur, fog.Block{GX: k[0], GY: k[1], Bitmap: bm}) } }) tilde, hash, other := fogLayerCounts(layers) d := FogPreviewData{ Page: p, Token: token, Diff: fog.DiffBlocks(cur, blocks), TildeLayers: tilde, HashLayers: hash, OtherLayers: other, } d.MinLat, d.MinLon, d.MaxLat, d.MaxLon, d.HasBounds = diffBounds(cur, blocks, s.cfg.MapCRS) // 布局按这个标记引入 Leaflet,漏了地图整块不出现 d.Page.HasMap = d.HasBounds s.render(w, "admin/fog-preview", d) } // diffBounds 返回两边块集覆盖范围的包围盒,供差异地图定位。迷雾网格是 WGS-84, // 高德这类底图按 GCJ-02 摆放,所以坐标要跟着底图走,否则视野会偏出几百米。 func diffBounds(cur, inc []fog.Block, crs string) (minLat, minLon, maxLat, maxLon float64, ok bool) { visit := func(gbx, gby int) { lat, lon := fog.BlockLonLat(gbx, gby) if crs == "gcj02" { lat, lon = geo.WGS84ToGCJ02(lat, lon) } if !ok { minLat, maxLat, minLon, maxLon, ok = lat, lat, lon, lon, true return } minLat, maxLat = math.Min(minLat, lat), math.Max(maxLat, lat) minLon, maxLon = math.Min(minLon, lon), math.Max(maxLon, lon) } for _, b := range cur { visit(b.GX, b.GY) } for _, b := range inc { visit(b.GX, b.GY) } return } // fogLayerCounts 按前缀归类快照里「已探索层之外」的条目。 // Model/~ 与 Model/# 两层站点不解析、只原样留存,但整体替换会把它们一并换掉, // 差异页要把这件事说清楚。 func fogLayerCounts(layers map[string][]byte) (tilde, hash, other int) { for p := range layers { switch { case strings.HasPrefix(p, "Model/~/") || strings.HasPrefix(p, "Model~/"): tilde++ case strings.HasPrefix(p, "Model/#/") || strings.HasPrefix(p, "Model#/"): hash++ default: other++ } } return } // ---------- 迷雾覆盖率 ---------- // FogCity 是单个城市(或省份)的迷雾点亮统计:地点里有多少个落在已探索格内。 type FogCity struct { Name string `json:"name"` Lit int `json:"lit"` Total int `json:"total"` } // FogCoverage 是统计页展示的迷雾覆盖率数据。 type FogCoverage struct { AreaKm2 float64 `json:"area_km2"` Blocks int `json:"blocks"` Cells int `json:"cells"` LitPlaces int `json:"lit_places"` Places int `json:"places"` Cities []FogCity `json:"cities"` Provinces []FogCity `json:"provinces"` } type fogCounter struct{ lit, total map[string]int } func newFogCounter() *fogCounter { return &fogCounter{lit: map[string]int{}, total: map[string]int{}} } func (fc *fogCounter) add(name string, lit bool) { fc.total[name]++ if lit { fc.lit[name]++ } } func (fc *fogCounter) top() []FogCity { out := make([]FogCity, 0, len(fc.total)) for name, total := range fc.total { out = append(out, FogCity{Name: name, Lit: fc.lit[name], Total: total}) } sort.Slice(out, func(i, j int) bool { if out[i].Lit != out[j].Lit { return out[i].Lit > out[j].Lit } return out[i].Total > out[j].Total }) if len(out) > 12 { out = out[:12] } return out } // fogCoverage 计算当前数据集地点与迷雾位图的叠加:迷雾网格是 WGS-84, // 库里的地点是 GCJ-02,先反算到 WGS 再查格位图。 // 没导入迷雾或没数据时返回 nil,模板据此隐藏整个区块。 // fogCoverage 统计迷雾覆盖率。regions/exclude 来自 filterOf(站长为空), // 与其它公开统计保持一致——否则黑名单里的城市名照样会出现在「涉及城市」里。 func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []string, exclude bool, admin bool) *FogCoverage { ix, err := s.fogIndex() if err != nil { return nil } blocks, cells := ix.Stats() if blocks == 0 { return nil } // 遮罩内的迷雾对访客不算数:地点要跳过,面积也要扣掉这部分格子 masks := s.maskSetFor(admin) args := []any{datasetID} q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.lat,0), COALESCE(p.lon,0), p.lat IS NULL FROM places p WHERE p.dataset_id=$1` + regionSQL("p", regions, exclude, &args) rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil } defer rows.Close() cov := &FogCoverage{Blocks: blocks, Cells: cells} cities, provs := newFogCounter(), newFogCounter() for rows.Next() { var city, province string var lat, lon float64 var nullCoord bool if err := rows.Scan(&city, &province, &lat, &lon, &nullCoord); err != nil { return nil } cov.Places++ if nullCoord { continue } if masks.HitLatLon(lat, lon) { continue } wlat, wlon := geo.GCJ02ToWGS84(lat, lon) cx, cy := fog.CellOf(wlat, wlon) lit := ix.CellLit(cx, cy) if lit { cov.LitPlaces++ } if city != "" { cities.add(city, lit) } if province != "" { provs.add(province, lit) } } if err := rows.Err(); err != nil { return nil } cov.Cities = cities.top() cov.Provinces = provs.top() // 面积与格数都用索引里预计算好的总量,再减掉遮罩盖住的部分 // (以前是每次现扫一遍全部位图、还顺手复制排序了三次整张块表)。 cov.AreaKm2 = ix.AreaKm2() if mc, ma := ix.MaskedStats(masks); mc > 0 { cov.Cells -= mc cov.AreaKm2 -= ma / 1e6 } return cov } // fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用), // 下次访问时按空库重建。 func fogResetIndex() { fogIndexV = fog.NewIndex() } // adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。 func (s *Server) adminFogClear(w http.ResponseWriter, r *http.Request) { if _, err := s.db.ExecContext(r.Context(), `DELETE FROM fog_blocks`); err != nil { s.serverError(w, r, err) return } // 其它图层一并丢弃:留着的话导出的快照会有图层条目却没有对应的 Model/* 瓦片 if err := fog.DropRawLayers(s.db); err != nil { log.Printf("清空迷雾其它图层失败: %v", err) } fogResetIndex() s.adminFlash(w, r, "ok", "迷雾已清空") } // adminFogExport 把当前迷雾还原成 .fwss 供迁移。 func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) { ix, err := s.fogIndex() if err != nil { s.serverError(w, r, err) return } blocks := ix.All() if len(blocks) == 0 { s.adminFlash(w, r, "err", "还没有导入任何迷雾数据") return } layers, err := fog.LoadRawLayers(s.db, userFrom(r.Context()).ID) if err != nil { log.Printf("读取迷雾其它图层失败: %v", err) } out, err := fog.Encode(blocks, layers) if err != nil { s.serverError(w, r, err) return } _, cells := ix.Stats() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="fog-export-%s.fwss"`, time.Now().Format("20060102-150405"))) w.Header().Set("X-Fog-Cells", strconv.Itoa(cells)) w.Write(out) }