// 一次性探针:把一份 .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 ") 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) } }