备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.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:
+151
-18
@@ -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 是单个城市(或省份)的迷雾点亮统计:地点里有多少个落在已探索格内。
|
||||
|
||||
Reference in New Issue
Block a user