Files
tamakyi 30c502ce7b 备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」:

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

导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
2026-09-19 16:04:00 +08:00

423 lines
13 KiB
Go

package web
import (
"bytes"
"context"
"database/sql"
"fmt"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
"rond-with-you/internal/fog"
)
// fogPreviewFixture 是一个迷雾为空、带一次性账号的测试库。
//
// ROND_TEST_CONF=conf/app-test.ini go test ./internal/web -run FogPreview -v
//
// 迷雾是站点级数据(公开读路径不区分归属),所以测试必须从空库出发;
// 用的是独立测试库(rond_fog_test),不会碰到开发库里的迷雾。
type fogPreviewFixture struct {
cfg *config.Config
pg *sql.DB
srv *Server
ctx context.Context
uid int64
ix *fog.Index
}
func newFogPreviewFixture(t *testing.T) *fogPreviewFixture {
t.Helper()
confPath := os.Getenv("ROND_TEST_CONF")
if confPath == "" {
t.Skip("设置 ROND_TEST_CONF 后运行迷雾导入校验")
}
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
user := fmt.Sprintf("e2e-fogpreview-%d", time.Now().UnixNano())
hash, err := auth.HashPassword("e2e-password")
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
clearFog(t, ctx, pg)
t.Cleanup(func() {
clearFog(t, ctx, pg)
pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid)
os.RemoveAll(filepath.Join(cfg.DataDir, "fog-staging"))
fogResetIndex()
})
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
f := &fogPreviewFixture{cfg: cfg, pg: pg, srv: srv, uid: uid,
ctx: ctxWithUser(&auth.User{ID: uid, Username: user})}
f.ix = f.reloadIndex(t)
return f
}
func clearFog(t *testing.T, ctx context.Context, pg *sql.DB) {
t.Helper()
for _, q := range []string{`DELETE FROM fog_blocks`, `DELETE FROM fog_raw_files`} {
if _, err := pg.ExecContext(ctx, q); err != nil {
t.Fatalf("清空迷雾失败: %v", err)
}
}
}
// reloadIndex 让内存索引与库对齐:fogIndex 的首次加载只跑一次(sync.Once),
// 测试里反复清库写库,必须手动同步,否则差异页算的是上一轮的残留。
func (f *fogPreviewFixture) reloadIndex(t *testing.T) *fog.Index {
t.Helper()
ix, err := f.srv.fogIndex()
if err != nil {
t.Fatalf("获取迷雾索引失败: %v", err)
}
if err := ix.LoadAll(f.pg); err != nil {
t.Fatalf("重载迷雾索引失败: %v", err)
}
return ix
}
// upload 走后台真实的上传处理函数,返回差异页的令牌。
func (f *fogPreviewFixture) upload(t *testing.T, data []byte) string {
t.Helper()
rec := httptest.NewRecorder()
f.srv.adminFogPreview(rec, fogUploadRequest(t, data).WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("上传应跳转到差异页: code=%d body=%s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
token := strings.TrimPrefix(loc, "/admin/fog/preview/")
if token == loc || len(token) != fogStashTokenLen {
t.Fatalf("差异页地址不对: %q", loc)
}
return token
}
// preview 渲染差异页。
func (f *fogPreviewFixture) preview(t *testing.T, token string) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fog/preview/"+token, nil).WithContext(f.ctx)
req.SetPathValue("token", token)
f.srv.adminFogPreviewPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("差异页渲染失败: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// confirm 走差异页确认按钮那条路径:带 stash 令牌、只提交 mode。
func (f *fogPreviewFixture) confirm(t *testing.T, token, mode string) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/fog?stash="+token, strings.NewReader("mode="+mode))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminFogUpload(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("确认导入应跳回后台: code=%d body=%s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/admin?ok=") {
t.Fatalf("确认导入应报成功: %s", loc)
}
}
func (f *fogPreviewFixture) cells(t *testing.T) int {
t.Helper()
_, cells := f.reloadIndex(t).Stats()
return cells
}
func (f *fogPreviewFixture) blocks(t *testing.T) int {
t.Helper()
blocks, _ := f.reloadIndex(t).Stats()
return blocks
}
// TestFogPreviewShowsDiffBeforeWriting 端到端验证「上传 → 差异页 → 确认导入」:
// 差异页必须先把两种模式的后果摆出来,而且在上传那一刻格子里一行都不能写。
func TestFogPreviewShowsDiffBeforeWriting(t *testing.T) {
f := newFogPreviewFixture(t)
// 库里先有一块 3 格的区域
if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{
newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}),
}, false); err != nil {
t.Fatalf("预置迷雾失败: %v", err)
}
// 新快照:同一块补亮 2 格,外加一整块新的
inc := []fog.Block{
newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{5, 5}, [2]int{6, 5}),
newFogBlock(101, 200, [2]int{1, 1}),
}
data, err := fog.Encode(inc, map[string][]byte{
"Model/~/aaa": {1, 2, 3},
"Model/#/bbb": {4, 5, 6},
})
if err != nil {
t.Fatalf("构造快照失败: %v", err)
}
token := f.upload(t, data)
// 差异页只是预览:格数必须还是 3
if got := f.cells(t); got != 3 {
t.Fatalf("上传阶段不该写库,当前格数 %d", got)
}
page := f.preview(t, token)
for _, want := range []string{
"迷雾导入预览",
"叠加导入",
"整体替换",
"会补上当前迷雾没有的", // 叠加卡片给出的实际收益
"替换不会丢数据", // 库里的 3 格都在快照里
"Model/~", // 图层提示
"/admin/fog?stash=" + token,
`value="overlay"`,
`value="cover"`,
} {
if !strings.Contains(page, want) {
t.Errorf("差异页缺少 %q", want)
}
}
if strings.Contains(page, "完全覆盖") {
t.Error("这份快照有新内容,不该报「已被完全覆盖」")
}
// 确认叠加:3 格 + 补亮的 2 格 + 新块 1 格 = 6 格,两块
f.confirm(t, token, "overlay")
if got := f.cells(t); got != 6 {
t.Fatalf("叠加后应有 6 格,实际 %d", got)
}
if got := f.blocks(t); got != 2 {
t.Fatalf("叠加后应有 2 块,实际 %d", got)
}
}
// TestFogPreviewWarnsBeforeCoverLosesData 整体替换必须在下手前算出「会丢多少」,
// 并且差异页要看得出这份快照已经被完全覆盖过(叠加没有意义)。
func TestFogPreviewWarnsBeforeCoverLosesData(t *testing.T) {
f := newFogPreviewFixture(t)
// 库里两块共 5 格
if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{
newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}),
newFogBlock(101, 200, [2]int{7, 7}, [2]int{8, 8}),
}, false); err != nil {
t.Fatalf("预置迷雾失败: %v", err)
}
// 快照只有第一块、且一格不多:叠加零收益,替换会丢掉第二块的两格
inc := []fog.Block{newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0})}
data, err := fog.Encode(inc, nil)
if err != nil {
t.Fatalf("构造快照失败: %v", err)
}
token := f.upload(t, data)
page := f.preview(t, token)
for _, want := range []string{
"已经被当前迷雾", // 叠加无收益
"从点亮变回未探索",
"Model/~", // 快照没有这两层,替换会清掉现有的
} {
if !strings.Contains(page, want) {
t.Errorf("差异页缺少 %q", want)
}
}
// 确认整体替换:只该剩下快照里的 1 块 3 格
f.confirm(t, token, "cover")
if got := f.cells(t); got != 3 {
t.Fatalf("替换后应有 3 格,实际 %d", got)
}
if got := f.blocks(t); got != 1 {
t.Fatalf("替换后应有 1 块,实际 %d", got)
}
// 暂存件用掉就该删掉,且过期令牌不该还能用
if _, err := os.Stat(filepath.Join(f.cfg.DataDir, "fog-staging", token+".fwss")); !os.IsNotExist(err) {
t.Error("确认导入后应删除暂存文件")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fog/preview/"+token, nil).WithContext(f.ctx)
req.SetPathValue("token", token)
f.srv.adminFogPreviewPage(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("过期令牌应跳回后台报错: code=%d", rec.Code)
}
}
// 差异瓦片要按请求坐标渲染出正确的三色,并挡住越界坐标与过期令牌。
func TestFogDiffTileColors(t *testing.T) {
f := newFogPreviewFixture(t)
if _, err := f.ix.Import(f.pg, f.uid, []fog.Block{
newFogBlock(100, 200, [2]int{0, 0}, [2]int{4, 0}),
}, false); err != nil {
t.Fatalf("预置迷雾失败: %v", err)
}
data, err := fog.Encode([]fog.Block{newFogBlock(100, 200, [2]int{0, 0}, [2]int{1, 0})}, nil)
if err != nil {
t.Fatalf("构造快照失败: %v", err)
}
token := f.upload(t, data)
// z=18 时一格 16 像素,块 (100,200) 完整落在瓦片 (400,800) 里
img := f.diffTile(t, token, 18, 400, 800)
if got := kindOf(pixAtImg(img, 8, 8)); got != "both" {
t.Errorf("两边都有的格应为灰色,实际 %s", got)
}
if got := kindOf(pixAtImg(img, 24, 8)); got != "gain" {
t.Errorf("只有快照有的格应为绿色,实际 %s", got)
}
if got := kindOf(pixAtImg(img, 72, 8)); got != "lost" {
t.Errorf("只有库里有、替换会丢的格应为红色,实际 %s", got)
}
if got := kindOf(pixAtImg(img, 200, 200)); got != "none" {
t.Errorf("没探索过的格应为透明,实际 %s", got)
}
// 越界坐标与过期令牌都只给 404,不能穿透到渲染
for _, c := range []struct{ z, x, y string }{
{"18", "262144", "0"}, // x 已达该级别的上限
{"18", "0", "-1"},
{"99", "0", "0"}, // z 越界
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fog/preview/"+token+"/x", nil).WithContext(f.ctx)
req.SetPathValue("token", token)
req.SetPathValue("z", c.z)
req.SetPathValue("x", c.x)
req.SetPathValue("y", c.y)
f.srv.adminFogDiffTile(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("越界坐标 %v 应给 404,实际 %d", c, rec.Code)
}
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fog/preview/deadbeef/18/400/800", nil).WithContext(f.ctx)
req.SetPathValue("token", strings.Repeat("f", 32))
req.SetPathValue("z", "18")
req.SetPathValue("x", "400")
req.SetPathValue("y", "800")
f.srv.adminFogDiffTile(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("无效令牌应给 404,实际 %d", rec.Code)
}
}
func (f *fogPreviewFixture) diffTile(t *testing.T, token string, z, x, y int) image.Image {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", fmt.Sprintf("/admin/fog/preview/%s/%d/%d/%d?crs=wgs84", token, z, x, y), nil).
WithContext(f.ctx)
req.SetPathValue("token", token)
req.SetPathValue("z", strconv.Itoa(z))
req.SetPathValue("x", strconv.Itoa(x))
req.SetPathValue("y", strconv.Itoa(y))
f.srv.adminFogDiffTile(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("差异瓦片渲染失败: code=%d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("差异瓦片应为 PNG,实际 %q", ct)
}
img, err := png.Decode(bytes.NewReader(rec.Body.Bytes()))
if err != nil {
t.Fatalf("解码差异瓦片失败: %v", err)
}
return img
}
// kindOf 按「哪个通道最亮」判类别,不去比对精确色值——PNG 解码回来的是
// 预乘 alpha 的像素,硬比色值会被 alpha 换算绕进去。
func kindOf(c [4]byte) string {
switch {
case c[3] == 0:
return "none"
case c[1] > c[0] && c[1] > c[2]:
return "gain"
case c[0] > c[1] && c[0] > c[2]:
return "lost"
}
return "both"
}
func pixAtImg(img image.Image, px, py int) [4]byte {
r, g, b, a := img.At(px, py).RGBA()
return [4]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)}
}
// 令牌来自 URL,必须挡死路径穿越。
func TestFogStashTokenRejectsTraversal(t *testing.T) {
s := &Server{cfg: &config.Config{DataDir: t.TempDir()}}
for _, bad := range []string{"", "..", "../../etc/passwd", strings.Repeat("a", 32) + "/../x", strings.ToUpper(strings.Repeat("a", 32))} {
if p, ok := s.fogStashPath(bad); ok {
t.Errorf("令牌 %q 不该通过校验,得到 %s", bad, p)
}
}
if _, ok := s.fogStashPath(strings.Repeat("0", 32)); !ok {
t.Error("32 位十六进制令牌应当通过")
}
}
func newFogBlock(gx, gy int, cells ...[2]int) fog.Block {
bm := make([]byte, 512)
for _, c := range cells {
bm[c[1]*8+c[0]/8] |= 0x80 >> (c[0] % 8)
}
return fog.Block{GX: gx, GY: gy, Bitmap: bm}
}
func fogUploadRequest(t *testing.T, data []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("fwss", "Snapshot-test.fwss")
if err != nil {
t.Fatalf("构造上传表单失败: %v", err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("写入上传内容失败: %v", err)
}
mw.Close()
req := httptest.NewRequest("POST", "/admin/fog/preview", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}