备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user