备份与迷雾的差异分析、合并执行,附属数据改挂坐标键

备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」:

- 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上
  用绿/灰/红标出会新增 / 不变 / 会丢的区域
- 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人),
  新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行;
  合入的行写回 entity_raw,保证再传同一份备份时差异归零
- 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的
  到访全变成假冲突
- 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联;
  访客票没有 dataset_id,回填按活跃数据集认领
- 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释

导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
This commit is contained in:
tamakyi
2026-09-19 16:04:00 +08:00
parent 9e01923244
commit 30c502ce7b
43 changed files with 5968 additions and 170 deletions
+151 -18
View File
@@ -3,11 +3,13 @@ package web
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/png"
"io"
"log"
"math"
"net/http"
"path/filepath"
"sort"
@@ -111,41 +113,57 @@ func fogTileCacheControl(isAdmin bool) string {
return "public, max-age=3600"
}
// adminFogUpload 导入 .fwss:mode=overlay 按位或合并进现有迷雾,
// mode=cover 清空后整体替换。
func (s *Server) adminFogUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
// 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 {
s.adminFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
return nil, fmt.Errorf("读取上传内容失败:%w", err)
}
file, hdr, err := r.FormFile("fwss")
if err != nil {
s.adminFlash(w, r, "err", "请选择要上传的 .fwss 迷雾快照")
return
return nil, errors.New("请选择要上传的 .fwss 迷雾快照")
}
defer file.Close()
name := strings.ToLower(filepath.Base(hdr.Filename))
if !strings.HasSuffix(name, ".fwss") && !strings.HasSuffix(name, ".zip") {
s.adminFlash(w, r, "err", "文件类型不正确,请上传 Fog of World 导出的 .fwss 快照")
return
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"
}
data, err := io.ReadAll(io.LimitReader(file, maxFogUploadBytes+1))
if err != nil {
s.adminFlash(w, r, "err", "读取文件失败:"+err.Error())
return
}
if int64(len(data)) > maxFogUploadBytes {
s.adminFlash(w, r, "err", "迷雾快照超过 64MB,疑似文件有误")
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())
@@ -181,10 +199,125 @@ func (s *Server) adminFogUpload(w http.ResponseWriter, r *http.Request) {
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 是单个城市(或省份)的迷雾点亮统计:地点里有多少个落在已探索格内。