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

备份(.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
+438
View File
@@ -0,0 +1,438 @@
// 一次性探针:把一份 .rondbackup 与 PG 里某个数据集的记录逐条对齐,输出差异画像。
// 用完即可删,不属于站点功能。
package main
import (
"context"
"database/sql"
"fmt"
"os"
"sort"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"rond-with-you/internal/rond"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("用法: diffprobe <archive.rondbackup> <dsn> <datasetID>")
os.Exit(2)
}
archive, dsn := os.Args[1], os.Args[2]
var datasetID int64
fmt.Sscanf(os.Args[3], "%d", &datasetID)
tmp, err := os.MkdirTemp("", "diffprobe")
must(err)
defer os.RemoveAll(tmp)
sqlDB, err := rond.Open(archive, tmp)
must(err)
b, err := rond.Parse(sqlDB)
sqlDB.Close()
must(err)
pg, err := sql.Open("pgx", dsn)
must(err)
defer pg.Close()
ctx := context.Background()
must(pg.PingContext(ctx))
fmt.Printf("=== 备份 %s ===\n", archive)
fmt.Printf("备份: 到访 %d / 地点 %d / 位移 %d / 天气 %d / 活动 %d / 标签 %d / 原始定位 %d\n",
len(b.Visits), len(b.Locations), len(b.Movements), len(b.Weather),
len(b.Activities), len(b.Tags), b.RawVisitNum)
var bFirst, bLast sql.NullTime
for _, v := range b.Visits {
if v.Arrival == nil {
continue
}
if !bFirst.Valid || v.Arrival.Before(bFirst.Time) {
bFirst = sql.NullTime{Time: *v.Arrival, Valid: true}
}
if !bLast.Valid || v.Arrival.After(bLast.Time) {
bLast = sql.NullTime{Time: *v.Arrival, Valid: true}
}
}
fmt.Printf("备份时间跨度: %s ~ %s\n", ts(bFirst), ts(bLast))
fmt.Printf("\n=== 库内 dataset #%d ===\n", datasetID)
var name string
var cnt, pcnt, raw int
var gFirst, gLast sql.NullTime
must(pg.QueryRowContext(ctx, `SELECT original_name, visit_count, place_count, raw_visit_count,
first_visit_at, last_visit_at FROM datasets WHERE id=$1`, datasetID).
Scan(&name, &cnt, &pcnt, &raw, &gFirst, &gLast))
fmt.Printf("%s: 到访 %d / 地点 %d / 原始定位 %d,跨度 %s ~ %s\n", name, cnt, pcnt, raw, ts(gFirst), ts(gLast))
diffVisits(ctx, pg, datasetID, b)
diffPlaces(ctx, pg, datasetID, b)
diffMovements(ctx, pg, datasetID, b)
diffWeather(ctx, pg, datasetID, b)
diffActivities(ctx, pg, datasetID, b)
diffTags(ctx, pg, datasetID, b)
}
func ts(t sql.NullTime) string {
if !t.Valid {
return "-"
}
return t.Time.Local().Format("2006-01-02 15:04")
}
type visitRow struct {
pk, placeSrc, actSrc int
arrival, departure *time.Time
remark, emoji string
bookmarked, userAdd bool
}
func diffVisits(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
fmt.Printf("\n---------- 到访 ----------\n")
rows, err := pg.QueryContext(ctx, `SELECT v.src_pk, COALESCE(p.src_pk,0), COALESCE(a.src_pk,0),
v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''), v.bookmarked, v.is_user_added
FROM visits v LEFT JOIN places p ON p.id=v.place_id LEFT JOIN activities a ON a.id=v.activity_id
WHERE v.dataset_id=$1`, dsID)
must(err)
db := map[int]visitRow{}
for rows.Next() {
var r visitRow
var arr time.Time
var dep sql.NullTime
must(rows.Scan(&r.pk, &r.placeSrc, &r.actSrc, &arr, &dep, &r.remark, &r.emoji, &r.bookmarked, &r.userAdd))
r.arrival = &arr
if dep.Valid {
r.departure = &dep.Time
}
db[r.pk] = r
}
rows.Close()
bk := map[int]rond.Visit{}
for _, v := range b.Visits {
bk[v.SrcPK] = v
}
var onlyBackup, onlyDB, bothDiff, same []int
for pk, v := range bk {
r, ok := db[pk]
if !ok {
onlyBackup = append(onlyBackup, pk)
continue
}
if vdiff(v, r) != "" {
bothDiff = append(bothDiff, pk)
continue
}
same = append(same, pk)
}
for pk := range db {
if _, ok := bk[pk]; !ok {
onlyDB = append(onlyDB, pk)
}
}
sort.Ints(onlyBackup)
sort.Ints(onlyDB)
sort.Ints(bothDiff)
fmt.Printf("同号且一致: %d\n", len(same))
fmt.Printf("仅备份有(实机新增,库里没有): %d\n", len(onlyBackup))
fmt.Printf("仅库里有(站点侧新增/实机已删除): %d\n", len(onlyDB))
fmt.Printf("同号但内容不同(冲突): %d\n", len(bothDiff))
// 按到达时间做多重集匹配:判断「同号冲突」到底是同批数据换了主键,还是两批无关数据
byTime := map[string][]int{}
for pk, r := range db {
if r.arrival != nil {
byTime[r.arrival.UTC().Format(time.RFC3339)] = append(byTime[r.arrival.UTC().Format(time.RFC3339)], pk)
}
}
matched, miss := 0, 0
pair := [][2]int{}
for pk, v := range bk {
if v.Arrival == nil {
continue
}
k := v.Arrival.UTC().Format(time.RFC3339)
if list := byTime[k]; len(list) > 0 {
matched++
pair = append(pair, [2]int{pk, list[0]})
byTime[k] = list[1:]
} else {
miss++
}
}
fmt.Printf("\n[时间对齐] 备份到访按「到达时刻」能在库内找到同刻记录: %d 条,找不到: %d 条\n", matched, miss)
sort.Slice(pair, func(i, j int) bool { return pair[i][0] < pair[j][0] })
fmt.Printf("[时间对齐] 同刻对的 (备份pk→库pk) 前 12 组: ")
for i, p := range pair {
if i >= 12 {
break
}
fmt.Printf("%d→%d ", p[0], p[1])
}
fmt.Println()
nDiff := 0
for _, p := range pair {
if p[0] != p[1] {
nDiff++
}
}
fmt.Printf("[时间对齐] 同刻但主键不同: %d / %d\n", nDiff, len(pair))
show := func(label string, pks []int, f func(pk int) string) {
if len(pks) == 0 {
return
}
fmt.Printf("\n[%s] 共 %d 条,前 8 条:\n", label, len(pks))
for i, pk := range pks {
if i >= 8 {
break
}
fmt.Printf(" #%d %s\n", pk, f(pk))
}
}
show("仅备份有", onlyBackup, func(pk int) string {
v := bk[pk]
return fmt.Sprintf("到访 %s 离开 %s 地点src=%s", ft(v.Arrival), ft(v.Departure), ps(v.LocationSrcPK))
})
show("仅库里有", onlyDB, func(pk int) string {
r := db[pk]
return fmt.Sprintf("到访 %s 离开 %s 地点src=%d 站点新增=%v 备注=%q",
ft(r.arrival), ftp(r.departure), r.placeSrc, r.userAdd, r.remark)
})
show("冲突", bothDiff, func(pk int) string {
return vdiff(bk[pk], db[pk])
})
}
func vdiff(v rond.Visit, r visitRow) string {
var d string
if !eq(v.Arrival, r.arrival) {
d += fmt.Sprintf("到达 %s→%s ", ft(v.Arrival), ft(r.arrival))
}
if !eq(v.Departure, r.departure) {
d += fmt.Sprintf("离开 %s→%s ", ft(v.Departure), ft(r.departure))
}
if pv(v.LocationSrcPK) != r.placeSrc {
d += fmt.Sprintf("地点 %s→%d ", ps(v.LocationSrcPK), r.placeSrc)
}
if av(v.ActivitySrcPK) != r.actSrc {
d += fmt.Sprintf("活动 %s→%d ", ps(v.ActivitySrcPK), r.actSrc)
}
if v.Remark != r.remark {
d += fmt.Sprintf("备注 %q→%q ", v.Remark, r.remark)
}
if v.Emoji != r.emoji {
d += fmt.Sprintf("表情 %q→%q ", v.Emoji, r.emoji)
}
if v.Bookmarked != r.bookmarked {
d += fmt.Sprintf("收藏 %v→%v ", v.Bookmarked, r.bookmarked)
}
return d
}
func diffPlaces(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
fmt.Printf("\n---------- 地点 ----------\n")
type prow struct {
name, poi, cc, prov, city, dist, sub, thor, tz string
lat, lon float64
}
rows, err := pg.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(poi_category,''),
COALESCE(country_code,''), COALESCE(province,''), COALESCE(city,''), COALESCE(district,''),
COALESCE(sublocality,''), COALESCE(thoroughfare,''), COALESCE(timezone,''),
COALESCE(lat,0), COALESCE(lon,0) FROM places WHERE dataset_id=$1`, dsID)
must(err)
db := map[int]prow{}
for rows.Next() {
var pk int
var r prow
must(rows.Scan(&pk, &r.name, &r.poi, &r.cc, &r.prov, &r.city, &r.dist, &r.sub, &r.thor, &r.tz, &r.lat, &r.lon))
db[pk] = r
}
rows.Close()
bk := map[int]rond.Location{}
for _, l := range b.Locations {
bk[l.SrcPK] = l
}
var onlyB, onlyD, diff []int
for pk, l := range bk {
r, ok := db[pk]
if !ok {
onlyB = append(onlyB, pk)
continue
}
if l.Name != r.name || l.POICategory != r.poi || l.Province != r.prov || l.City != r.city ||
l.District != r.dist || l.Sublocality != r.sub || l.Thoroughfare != r.thor || l.Timezone != r.tz ||
l.CountryCode != r.cc || !near(l.Lat, r.lat) || !near(l.Lon, r.lon) {
diff = append(diff, pk)
}
}
for pk := range db {
if _, ok := bk[pk]; !ok {
onlyD = append(onlyD, pk)
}
}
sort.Ints(onlyB)
sort.Ints(onlyD)
sort.Ints(diff)
fmt.Printf("仅备份有: %d,仅库里有: %d,同号内容不同: %d\n", len(onlyB), len(onlyD), len(diff))
for i, pk := range onlyB {
if i >= 6 {
break
}
fmt.Printf(" 仅备份 #%d %s (%s %s %s)\n", pk, bk[pk].Name, bk[pk].Province, bk[pk].City, bk[pk].POICategory)
}
for i, pk := range onlyD {
if i >= 6 {
break
}
fmt.Printf(" 仅库内 #%d %s (%s %s)\n", pk, db[pk].name, db[pk].prov, db[pk].city)
}
for i, pk := range diff {
if i >= 6 {
break
}
l, r := bk[pk], db[pk]
fmt.Printf(" 冲突 #%d 备份[name=%q city=%q %.6f,%.6f] 库[name=%q city=%q %.6f,%.6f]\n",
pk, l.Name, l.City, l.Lat, l.Lon, r.name, r.city, r.lat, r.lon)
}
}
func diffMovements(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
fmt.Printf("\n---------- 位移 ----------\n")
rows, err := pg.QueryContext(ctx, `SELECT src_pk, COALESCE(transport_src,0),
COALESCE(from_visit_src,0), COALESCE(to_visit_src,0), started_at, ended_at
FROM movements WHERE dataset_id=$1`, dsID)
must(err)
type mrow struct {
t, f, to int
s, e *time.Time
}
db := map[int]mrow{}
for rows.Next() {
var pk int
var r mrow
var s, e sql.NullTime
must(rows.Scan(&pk, &r.t, &r.f, &r.to, &s, &e))
if s.Valid {
r.s = &s.Time
}
if e.Valid {
r.e = &e.Time
}
db[pk] = r
}
rows.Close()
var onlyB, onlyD int
for _, m := range b.Movements {
if _, ok := db[m.SrcPK]; !ok {
onlyB++
}
}
for pk := range db {
found := false
for _, m := range b.Movements {
if m.SrcPK == pk {
found = true
break
}
}
if !found {
onlyD++
}
}
fmt.Printf("备份 %d 条 / 库内 %d 条;仅备份有 %d,仅库里有 %d\n", len(b.Movements), len(db), onlyB, onlyD)
}
func diffWeather(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
var n int
must(pg.QueryRowContext(ctx, `SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&n))
fmt.Printf("\n---------- 天气 ----------\n备份 %d 条 / 库内 %d 条\n", len(b.Weather), n)
}
func diffActivities(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
fmt.Printf("\n---------- 活动类型 ----------\n")
rows, err := pg.QueryContext(ctx, `SELECT src_pk, name FROM activities WHERE dataset_id=$1`, dsID)
must(err)
db := map[int]string{}
for rows.Next() {
var pk int
var n string
must(rows.Scan(&pk, &n))
db[pk] = n
}
rows.Close()
for _, a := range b.Activities {
n, ok := db[a.SrcPK]
if !ok {
fmt.Printf(" 仅备份 #%d %q\n", a.SrcPK, a.Name)
} else if n != a.Name {
fmt.Printf(" 冲突 #%d 备份=%q 库=%q\n", a.SrcPK, a.Name, n)
}
}
for pk, n := range db {
found := false
for _, a := range b.Activities {
if a.SrcPK == pk {
found = true
}
}
if !found {
fmt.Printf(" 仅库内 #%d %q\n", pk, n)
}
}
}
func diffTags(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) {
var n int
must(pg.QueryRowContext(ctx, `SELECT count(*) FROM tags WHERE dataset_id=$1`, dsID).Scan(&n))
fmt.Printf("\n---------- 标签 ----------\n备份 %d 条 / 库内 %d 条\n", len(b.Tags), n)
}
func eq(a, b *time.Time) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return a.Equal(*b)
}
func pv(p *int) int {
if p == nil {
return 0
}
return *p
}
func av(p *int) int { return pv(p) }
func ps(p *int) string {
if p == nil {
return "-"
}
return fmt.Sprint(*p)
}
func ft(t *time.Time) string {
if t == nil {
return "-"
}
return t.Local().Format("01-02 15:04")
}
func ftp(t *time.Time) string { return ft(t) }
func near(a, b float64) bool {
d := a - b
return d < 1e-9 && d > -1e-9
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
}