备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
490 lines
15 KiB
Go
490 lines
15 KiB
Go
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<<uint(z) || y >= 1<<uint(z) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
ix, err := s.fogIndex()
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
crs := r.URL.Query().Get("crs")
|
||
if crs != "gcj02" && crs != "wgs84" {
|
||
crs = s.cfg.MapCRS
|
||
}
|
||
// 遮罩只对访客生效:站长自己要能看到完整迷雾,否则没法核对遮哪里
|
||
isAdmin := false
|
||
if u, _ := s.sessionUser(r); u != nil {
|
||
isAdmin = true
|
||
}
|
||
// 渲染结果缓存:一张瓦片要逐格画位图再做 PNG 编码,而内容只随
|
||
// 「迷雾数据版本 + 遮罩 + 底图坐标系」变化。站长的瓦片不含遮罩,指纹用 "-",
|
||
// 这样改遮罩时不必连带丢掉站长的缓存。
|
||
fp := "-"
|
||
if !isAdmin {
|
||
fp = s.maskFP()
|
||
}
|
||
cacheKey := strconv.Itoa(z) + "/" + strconv.Itoa(x) + "/" + strconv.Itoa(y) + "/" + crs + "/" + fp
|
||
if t, ok := s.fogTiles.get(cacheKey); ok {
|
||
w.Header().Set("Content-Type", "image/png")
|
||
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
|
||
w.Write(t.body)
|
||
return
|
||
}
|
||
masks := s.maskSetFor(isAdmin)
|
||
var img *image.NRGBA
|
||
ix.View(func(blocks map[[2]int][]byte) {
|
||
img = fog.RenderTile(blocks, z, x, y, crs, masks)
|
||
})
|
||
if img == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "image/png")
|
||
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.fogTiles.put(cacheKey, cachedTile{body: buf.Bytes(), ct: "image/png", at: time.Now()})
|
||
w.Write(buf.Bytes())
|
||
}
|
||
|
||
// fogTileCacheControl 决定迷雾瓦片的缓存策略。站长与访客的瓦片内容不同(一个含遮罩、
|
||
// 一个不含)而 URL 相同,所以站长的响应绝不能进共享缓存——否则 CDN / 反向代理会把
|
||
// 未遮罩的瓦片原样发给访客。访客那份内容对所有人一致,允许公开缓存。
|
||
func fogTileCacheControl(isAdmin bool) string {
|
||
if isAdmin {
|
||
return "private, no-store"
|
||
}
|
||
return "public, max-age=3600"
|
||
}
|
||
|
||
// readFogSnapshot 从 multipart 表单里读出 .fwss 的字节并做基本校验。
|
||
func readFogSnapshot(w http.ResponseWriter, r *http.Request) ([]byte, error) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxFogUploadBytes+1<<20)
|
||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||
return nil, fmt.Errorf("读取上传内容失败:%w", err)
|
||
}
|
||
file, hdr, err := r.FormFile("fwss")
|
||
if err != nil {
|
||
return nil, errors.New("请选择要上传的 .fwss 迷雾快照")
|
||
}
|
||
defer file.Close()
|
||
name := strings.ToLower(filepath.Base(hdr.Filename))
|
||
if !strings.HasSuffix(name, ".fwss") && !strings.HasSuffix(name, ".zip") {
|
||
return nil, errors.New("文件类型不正确,请上传 Fog of World 导出的 .fwss 快照")
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(file, maxFogUploadBytes+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取文件失败:%w", err)
|
||
}
|
||
if int64(len(data)) > 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)
|
||
}
|