Files
rond-with-you/internal/web/backup_merge_test.go
T
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

274 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// mergeBackup 走真实的合并处理函数;失败(重定向里带 err=)直接判错。
func mergeBackup(t *testing.T, f *movementFixture, token, body string) {
t.Helper()
if loc := mergeBackupAllowErr(t, f, token, body); strings.Contains(loc, "err=") {
t.Fatalf("合并失败: %s", loc)
}
}
// mergeBackupAllowErr 同上,但把落点交出来,供「预期会失败」的用例断言。
func mergeBackupAllowErr(t *testing.T, f *movementFixture, token, body string) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/preview/"+token+"/merge", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("token", token)
f.srv.adminBackupMerge(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("合并应跳回后台: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Header().Get("Location")
}
// addExtraVisit 造一条「仅站内有」的到访:没有留档,于是没有 UUID,比对时必然落在仅站内有里。
func (f *movementFixture) addExtraVisit(t *testing.T, pk int, remark string) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
(dataset_id, src_pk, arrival, departure, is_user_added, remark)
VALUES ($1,$2,now(),now(),true,$3)`, f.dsID, pk, remark); err != nil {
t.Fatalf("插入补录到访失败: %v", err)
}
}
func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
t.Helper()
var n int
if err := f.pg.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil {
t.Fatalf("查询失败: %v", err)
}
return n
}
// 合并把备份里多出来的记录写进去,而且写完要「复原」——同一份备份再比一次必须一条不差。
//
// 这条测试同时钉住三件事:
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
// - 合并是幂等的:再传同一份备份,差异归零。
func TestBackupMergeRestoresMissingVisits(t *testing.T) {
f := newMovementFixture(t)
visitCount := func() int {
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
}
all := visitCount()
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
// 原始数据里就有指向「因缺到达时间而没入库」的到访的位移,先记下基线再比
orphanQ := `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
AND (m.from_visit_src IS NULL OR NOT EXISTS (
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src))`
orphans0 := f.scalar(t, orphanQ, f.dsID)
// 制造差异:按**站点删到访的方式**删掉最早的两条(见 editVisitDelete)——
// 位移删掉、天气的归属置空、到访删掉。直接 DELETE visits 会留下孤儿天气,
// 那是测试手法造成的,不是产品行为。
var pk1, pk2 int
if err := f.pg.QueryRowContext(f.ctx, `SELECT
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
f.dsID).Scan(&pk1, &pk2); err != nil {
t.Fatalf("取待删到访失败: %v", err)
}
for _, q := range []string{
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
} {
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
t.Fatalf("预置删除失败: %v", err)
}
}
if got := visitCount(); got != all-2 {
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
}
token, page := previewBackupToken(t, f, f.src)
if a, _, _ := visitCounts(t, page); a != 2 {
t.Fatalf("差异页应报 2 条新增,实际 %d", a)
}
mergeBackup(t, f, token, "add_new=1")
if got := visitCount(); got != all {
t.Fatalf("合并后到访数应回到 %d,实际 %d", all, got)
}
// 新补进来的那两条必须各有一份留档(下次比对就靠它认 UUID)。
// 被删掉的行会留下孤儿留档——那是老数据,不影响,所以只查新编号。
if got := f.scalar(t, `SELECT count(*) FROM entity_raw er
WHERE er.dataset_id=$1 AND er.entity='ZVISIT' AND EXISTS (
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=er.src_pk)`, f.dsID); got != all {
t.Errorf("每条在库的到访都该有原始行留档,实际 %d / %d", got, all)
}
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
t.Errorf("位移应恢复到 %d,实际 %d", movCount, got)
}
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
}
// 合并不该新增孤儿位移(原始数据里本来就有一些,见 orphanQ 的注释)
if orphans := f.scalar(t, orphanQ, f.dsID); orphans > orphans0 {
t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans)
}
// 幂等:再比一次应当完全一致
page = previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c)
}
}
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
func TestBackupMergeConflictChoice(t *testing.T) {
f := newMovementFixture(t)
var pk int
if err := f.pg.QueryRowContext(f.ctx, `UPDATE visits SET remark='站内改过的备注'
WHERE id = (SELECT id FROM visits WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1)
RETURNING src_pk`, f.dsID).Scan(&pk); err != nil {
t.Fatalf("改备注失败: %v", err)
}
// 这条的 UUID 就是页面上的冲突键
var b64 string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','')
FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, f.dsID, pk).Scan(&b64); err != nil {
t.Fatalf("取 UUID 失败: %v", err)
}
uuid := uuidFromBase64(b64)
if uuid == "" {
t.Fatal("这条到访应当有 UUID")
}
remark := func() string {
var s string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(remark,'') FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&s); err != nil {
t.Fatalf("读备注失败: %v", err)
}
return s
}
// 用站内:备注保持站里那份
token, _ := previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "vc_"+uuid+"=db")
if got := remark(); got != "站内改过的备注" {
t.Fatalf("选「用站内」不该改动,实际 %q", got)
}
// 用实机:备注被备份里的值(空)覆盖回去
token, _ = previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "vc_"+uuid+"=backup")
if got := remark(); got != "" {
t.Fatalf("选「用实机」应覆盖成备份里的值,实际 %q", got)
}
}
// 什么都不勾、冲突全跳过时,合并应当什么都不写。
func TestBackupMergeNoopKeepsData(t *testing.T) {
f := newMovementFixture(t)
before := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
token, _ := previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "add_new=0")
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID); got != before {
t.Fatalf("没勾任何改动却动了数据: %d → %d", before, got)
}
}
// 删除必须「勾了才删」:没勾的一个都不动,勾了的那条(连带它的位移)才消失。
func TestBackupMergeDeletesOnlyChecked(t *testing.T) {
f := newMovementFixture(t)
visitCount := func() int {
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
}
before := visitCount()
f.addExtraVisit(t, 900001, "稍后删掉")
f.addExtraVisit(t, 900002, "留着不动")
if got := visitCount(); got != before+2 {
t.Fatalf("预置失败: %d", got)
}
// 只勾一条
token, page := previewBackupToken(t, f, f.src)
if _, g, _ := visitCounts(t, page); g != 2 {
t.Fatalf("差异页应报 2 条仅站内有,实际 %d", g)
}
mergeBackup(t, f, token, "dv_900001=1")
if got := visitCount(); got != before+1 {
t.Fatalf("应只删掉勾选的那条,实际剩 %d(原 %d)", got, before)
}
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=900002`, f.dsID); n != 1 {
t.Error("没勾的那条不该被动")
}
// 删掉的到访不该留下留档
if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1
AND entity='ZVISIT' AND src_pk=900001`, f.dsID); n != 0 {
t.Error("删除到访时要连它的原始行留档一起清掉")
}
// 再合并一次、一条都不勾:剩下那条必须还在
token, _ = previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "add_new=0")
if got := visitCount(); got != before+1 {
t.Fatalf("不勾就不该删,实际 %d", got)
}
}
// 名下有到访的地点必须拒绝删除——那个外键是 ON DELETE CASCADE,
// 真删下去会把到访一起带走。拒绝后还要整体回滚,别留下半截。
func TestBackupMergeRefusesDeletingPlaceInUse(t *testing.T) {
f := newMovementFixture(t)
visitCount := func() int {
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
}
beforeVisits := visitCount()
// 一个没有到访的空地点:允许删
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1, 910001, '临时空地点', 30.0, 120.0)`, f.dsID); 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, 910002, '有到访的地点', 31.0, 121.0)`, f.dsID); err != nil {
t.Fatalf("插入地点失败: %v", err)
}
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, arrival, departure)
VALUES ($1, 910003, (SELECT id FROM places WHERE dataset_id=$1 AND src_pk=910002), now(), now())`,
f.dsID); err != nil {
t.Fatalf("插入到访失败: %v", err)
}
// 先删空地点:应当成功
token, _ := previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "dp_910001=1")
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910001`, f.dsID); n != 0 {
t.Fatal("空地点应当被删掉")
}
// 再删有到访的地点:应当被拒绝
token, _ = previewBackupToken(t, f, f.src)
loc := mergeBackupAllowErr(t, f, token, "dp_910002=1")
if !strings.Contains(loc, "err=") {
t.Fatal("名下有到访的地点应当拒绝删除")
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910002`, f.dsID); n != 1 {
t.Error("被拒绝后地点不该消失")
}
if got := visitCount(); got != beforeVisits+1 {
t.Errorf("被拒绝后到访数不该变,实际 %d(期望 %d)", got, beforeVisits+1)
}
}