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

备份(.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
+149
View File
@@ -0,0 +1,149 @@
package web
import (
"fmt"
"testing"
"time"
"rond-with-you/internal/votes"
)
// testPlace 在两个数据集里各建一个**同坐标**的地点,返回它们的编号与第二个数据集。
//
// 坐标是随手挑的专属值:备注按坐标共享,而读取按设计不按 user_id 过滤(单站长站点),
// 借用真实数据里的坐标会把别的测试残留的记录串进来。
func testPlace(t *testing.T, f *movementFixture, uid int64, lat, lon float64) (pkA, pkB int, dsB int64) {
t.Helper()
const pkAVal, pkBVal = 660001, 660002
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'甲侧地点',$3,$4)`, f.dsID, pkAVal, lat, lon); err != nil {
t.Fatalf("建地点失败: %v", err)
}
sha := fmt.Sprintf("%064d", time.Now().UnixNano())
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO datasets (user_id, original_name, sha256, status, is_active)
VALUES ($1,'另一个数据集',$2,'done',false) RETURNING id`, uid, sha).Scan(&dsB); err != nil {
t.Fatalf("建数据集失败: %v", err)
}
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'乙侧地点',$3,$4)`, dsB, pkBVal, lat, lon); err != nil {
t.Fatalf("建地点失败: %v", err)
}
key := fmt.Sprintf("%.6f|%.6f", lat, lon)
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM place_notes WHERE place_key=$1`, key)
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE place_key=$1`, key)
f.pg.ExecContext(f.ctx, `DELETE FROM place_visitor_votes WHERE place_key=$1`, key)
f.pg.ExecContext(f.ctx, `DELETE FROM datasets WHERE id=$1`, dsB)
})
return pkAVal, pkBVal, dsB
}
// 访客票也一样:它是站点自己的数据(rondbackup 里没有这一项),
// 但同样要认得出「是哪个地点」,所以也挂坐标键。
func TestVisitorVotesFollowPlaceAcrossDatasets(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pkA, pkB, dsB := testPlace(t, f, uid, 77.777777, 88.888888)
if _, mine, err := f.srv.votes.Cast(f.ctx, f.dsID, pkA, "visitor-a", 1); err != nil || mine != 1 {
t.Fatalf("投票失败: mine=%d err=%v", mine, err)
}
tally, err := f.srv.votes.Tally(f.ctx, dsB, pkB)
if err != nil {
t.Fatalf("读票数失败: %v", err)
}
if tally.Up != 1 || tally.Down != 0 {
t.Fatalf("换数据集后票数应照旧,实际 %+v", tally)
}
if v := f.srv.votes.Voter(f.ctx, dsB, pkB, "visitor-a"); v != 1 {
t.Fatalf("「我投的」也该认得出来,实际 %d", v)
}
// 再点一次是撤销:从哪一侧点都该撤掉同一张票
if _, mine, err := f.srv.votes.Cast(f.ctx, dsB, pkB, "visitor-a", 1); err != nil || mine != 0 {
t.Fatalf("再点一次应撤销: mine=%d err=%v", mine, err)
}
if tally, err := f.srv.votes.Tally(f.ctx, f.dsID, pkA); err != nil || tally.Up != 0 {
t.Fatalf("撤销后另一边也该归零: %+v err=%v", tally, err)
}
}
// 备注、结论、图片挂的是坐标键,所以**换数据集之后仍然跟着同一个地点**——
// 这正是这次迁移要解决的问题。老结构挂 (dataset_id, src_pk),
// 换一份备份同编号就是另一个地点,内容全部失联。
func TestNotesAndVerdictsFollowPlaceAcrossDatasets(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pkA, pkB, dsB := testPlace(t, f, uid, 11.111111, 22.222222)
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, pkA, "别名甲", "备注甲"); err != nil {
t.Fatalf("写备注失败: %v", err)
}
if err := f.srv.votes.SaveVerdict(f.ctx, uid, f.dsID, pkA, votes.VerdictRecommend); err != nil {
t.Fatalf("写结论失败: %v", err)
}
// 从「另一个数据集」读:编号完全不同,但坐标一致,应当照样读得到
all, err := f.srv.notes.All(f.ctx, dsB)
if err != nil {
t.Fatalf("读备注失败: %v", err)
}
got, ok := all[pkB]
if !ok {
t.Fatal("换数据集后应当仍能读到这个地点的备注")
}
if got.Alias != "别名甲" || got.Content != "备注甲" {
t.Fatalf("备注应原样跟过来,实际 alias=%q note=%q", got.Alias, got.Content)
}
v, err := f.srv.votes.Verdict(f.ctx, dsB, pkB)
if err != nil {
t.Fatalf("读结论失败: %v", err)
}
if v != votes.VerdictRecommend {
t.Fatalf("结论应跟过来,实际 %d", v)
}
}
// 两个数据集里的编号完全不同,但备注与结论是同一份——改一处两边都变。
func TestNotesUpdateVisibleFromOtherDataset(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pkA, pkB, dsB := testPlace(t, f, uid, 33.333333, 44.444444)
if err := f.srv.notes.Save(f.ctx, uid, dsB, pkB, "别名乙", "备注乙"); err != nil {
t.Fatalf("在新数据集写备注失败: %v", err)
}
back, err := f.srv.notes.All(f.ctx, f.dsID)
if err != nil {
t.Fatalf("回读失败: %v", err)
}
if back[pkA].Content != "备注乙" {
t.Fatalf("备注是同一份数据,改一处两边都该变,实际 %q", back[pkA].Content)
}
}
// 坐标不同的地点绝不能互相认领——键是坐标,不是「大概位置」。
func TestNotesDoNotLeakBetweenDifferentPlaces(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pkA, _, dsB := testPlace(t, f, uid, 55.555555, 66.666666)
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, pkA, "别名甲", "备注甲"); err != nil {
t.Fatalf("写备注失败: %v", err)
}
// 在乙数据集里放一个坐标差 0.001 度(约 100m)的地点,是完全不同的地方
const otherPK = 660003
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'隔壁那家', 55.556555, 66.666666)`, dsB, otherPK); err != nil {
t.Fatalf("建地点失败: %v", err)
}
all, err := f.srv.notes.All(f.ctx, dsB)
if err != nil {
t.Fatalf("读备注失败: %v", err)
}
if _, ok := all[otherPK]; ok {
t.Fatal("坐标不同的地点不该认领到别人的备注")
}
}