diff --git a/README.md b/README.md index 9363bb9..e634fbf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,28 @@ | 设置 | 站点标题与头像、主题色、主页模块开关、访客可见页面、访客可见区域、隐家/隐工作、图片存储、默认底图 | | 账号 | 改密码、两步验证(TOTP) | +### 数据编辑页上的「重复到访」 + +站点里可能残留**历史副本**:同样一次到访存了两份,差别只在 UUID(站点侧生成的那份, +在手机包里找不到)。合并与差异页都是按 UUID 认人的,于是它们会被当成「备份新增」, +导出到 rond 后就是成对的两条。 + +判定以**这份数据集的基线(导入时留下的手机包)**为参照:到访的 UUID 在基线里找不到、 +而基线里有一条「到达时间 + 地点」完全相同的,判为副本。卡片只在检出时出现在数据编辑页, +默认全选 + 二次确认;清理会连它的位移 / 天气 / 留档一起删,并把指向它的合并标记改指到保留项 +(不动就会在导出时留下悬空引用)。基线不可用或与本站一条都对不上时,卡片只给说明、不给按钮。 + +命令行也有一条等价的工具:`tools/dedup_site_visits.py`(默认只预演,加 `--apply` 才动手)。 + +### 为什么有些到访在页面上看不到 + +手机包里有两种「不独立成立」的到访:被别的到访**并入**了的(`ZVISIT.ZMERGEDTO`), +以及某个长驻记录下的**子段**(`ZVISIT.ZPARENT`)。rond 自己既不显示、也不把它们算进次数与停留, +本站因此也不算——时间线、统计、地点计数、地图城市圈都不含它们。 + +⚠️ 这些行**不删**:导出包必须原样带着它们,rond 要靠它们认这次合并发生过。 +数据编辑页会照常列出,并打上「已并入 #562 / 子段 #374」,方便核对。 + ## 技术栈 | 层 | 选型 | @@ -304,3 +326,8 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o ron - 时间、坐标这类「原样回贴」的字段有讲究:表单精度(分钟 / 6 位小数)往往低于库里真值, 保存时若与库里已有值落在同一精度上要沿用库里那份,否则只改个备注也会把时间或坐标悄悄改掉。 - `tools/verify_rondbackup.py` 可以做导入→导出→逐表对账,检查改动有没有破坏备份保真度。 +- 全量验收 `scripts/verify.sh`(要 **0 FAIL / 0 SKIP**)。每个夹具都会导入一整包再删掉, + 一轮下来测试库能留几百 MB 死元组,级联删除随之变慢(实测 16MB 时约 6 分钟跑完、 + 几百 MB 时半小时都跑不完,看着像卡死)。脚本会在库超过 `VACUUM_MIN_MB`(默认 200MB) + 时先清一次死元组;手动来一次 `VACUUM (FULL, ANALYZE)` 还能把文件也缩回去。 + 另外要确认库里有生效数据集(`datasets.is_active`),否则 `internal/stats` 的对照用例会假 skip。 diff --git a/internal/web/city_agg_test.go b/internal/web/city_agg_test.go index b0ca643..63acc0f 100644 --- a/internal/web/city_agg_test.go +++ b/internal/web/city_agg_test.go @@ -26,6 +26,8 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { Visits int `json:"visits"` Places int `json:"places"` Dwell int64 `json:"dwell"` + First string `json:"first,omitempty"` + Last string `json:"last,omitempty"` } `json:"cities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { @@ -38,7 +40,8 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { // 手写一份同一口径的对照(判据独立写,不复用 stats.SoloVisitSQL): // 不含已并入 / 子段,也不含「家 / 工作」地点——与 apiCityAgg 保持一致。 rows, err := pg.QueryContext(ctx, `SELECT p.city, count(*), count(DISTINCT p.id), - COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0) + COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0), + to_char(min(v.arrival), 'YYYY-MM-DD'), to_char(max(v.arrival), 'YYYY-MM-DD') FROM places p JOIN visits v ON v.place_id = p.id AND v.dataset_id = p.dataset_id WHERE p.dataset_id = $1 AND p.city IS NOT NULL AND p.city <> '' AND p.lat IS NOT NULL AND NOT EXISTS (SELECT 1 FROM entity_raw er @@ -54,12 +57,13 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { type want struct { visits, places int dwell int64 + first, last string } truth := map[string]want{} for rows.Next() { var city string var w want - if err := rows.Scan(&city, &w.visits, &w.places, &w.dwell); err != nil { + if err := rows.Scan(&city, &w.visits, &w.places, &w.dwell, &w.first, &w.last); err != nil { t.Fatal(err) } truth[city] = w @@ -78,6 +82,11 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { t.Errorf("城市「%s」聚合对不上:到访 %d/%d、地点 %d/%d、停留 %d/%d", c.City, c.Visits, w.visits, c.Places, w.places, c.Dwell, w.dwell) } + // 首末到访必须按「可见到访的到达时间」算,不能拿 places 的全量列值 + if c.First != w.first || c.Last != w.last { + t.Errorf("城市「%s」首末到访对不上:%s~%s,应为 %s~%s(按可见到访算)", + c.City, c.First, c.Last, w.first, w.last) + } } // 保底的不变量:地点数不可能多于到访数(旧写法里这两个数就换位了) for _, c := range payload.Cities { @@ -85,4 +94,86 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { t.Errorf("城市「%s」的地点数 %d 多于到访数 %d,说明又按到访条数重复累加了", c.City, c.Places, c.Visits) } } + + // 把某个地点**最晚的那次到访**标成「已并入」:该城市的最近到访必须跟着退到更早那条。 + // 这一条专治「偷懒直接取 places.last_visit_at」——那是全量列值,不会跟着退。 + // (光比对相同口径还不够:被并入的行大多落在父段区间内,两种算法在夹具数据上结果一样, + // 不做这一步就是一条假绿的断言。) + var placeID int64 + var latestVisit int + if err := pg.QueryRowContext(ctx, `SELECT p.id, v.src_pk + FROM visits v JOIN places p ON p.id = v.place_id + WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city <> '' AND p.lat IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id AND (vh.is_home OR vh.is_work)) + AND (SELECT count(*) FROM visits v2 WHERE v2.place_id = p.id) >= 2 + ORDER BY v.arrival DESC LIMIT 1`, dsID).Scan(&placeID, &latestVisit); err != nil { + t.Skipf("挑不出「同一地点有多条到访」的城市:%v", err) + } + var cityName string + if err := pg.QueryRowContext(ctx, `SELECT city FROM places WHERE id=$1`, placeID).Scan(&cityName); err != nil { + t.Fatal(err) + } + lastBefore := "" + lastAfterWant := "" + for _, c := range payload.Cities { + if c.City == cityName { + lastBefore = c.Last + } + } + // 真值(按城市取,标记前后各来一次):与上面那条对账同一套口径,只是限定到某个城市 + truthLast := func() string { + var s string + if err := pg.QueryRowContext(ctx, `SELECT to_char(max(v.arrival),'YYYY-MM-DD') + FROM places p JOIN visits v ON v.place_id = p.id AND v.dataset_id = p.dataset_id + WHERE p.dataset_id = $1 AND p.city = $2 AND p.city <> '' AND p.lat IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM entity_raw er + WHERE er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk + AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL)) + AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id + AND (vh.is_home OR vh.is_work))`, dsID, cityName).Scan(&s); err != nil { + t.Fatal(err) + } + return s + } + lastBeforeWant := truthLast() + if lastBeforeWant != lastBefore { + t.Fatalf("标记前该城市的最近到访就对不上(%s vs %s),用例前提不成立", lastBefore, lastBeforeWant) + } + if _, err := pg.ExecContext(ctx, `UPDATE entity_raw + SET raw = jsonb_set(raw, '{ZMERGEDTO}', to_jsonb($3::int), true) + WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, latestVisit, latestVisit); err != nil { + t.Fatal(err) + } + lastAfterWant = truthLast() + if lastAfterWant == lastBeforeWant { + t.Skipf("城市「%s」里最晚那次不是唯一的最晚记录,换一个再测", cityName) + } + + rec2 := httptest.NewRecorder() + f.srv.apiCityAgg(rec2, httptest.NewRequest("GET", "/api/cities", nil).WithContext(ctx)) + if rec2.Code != 200 { + t.Fatalf("取城市聚合失败: code=%d", rec2.Code) + } + var after struct { + Cities []struct { + City string `json:"city"` + Last string `json:"last,omitempty"` + } `json:"cities"` + } + if err := json.Unmarshal(rec2.Body.Bytes(), &after); err != nil { + t.Fatalf("解析城市聚合失败: %v", err) + } + gotLast := "" + for _, c := range after.Cities { + if c.City == cityName { + gotLast = c.Last + } + } + if gotLast == lastBeforeWant { + t.Errorf("把 #%d 标成已并入后,城市「%s」的最近到访仍是 %s(应退到 %s)—— 说明取的是 places 的全量列值而不是可见到访", + latestVisit, cityName, gotLast, lastAfterWant) + } + if gotLast != lastAfterWant { + t.Errorf("城市「%s」的最近到访应为 %s,实际 %s", cityName, lastAfterWant, gotLast) + } } diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index 4ca2b79..28c2c9a 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -956,7 +956,10 @@ func (s *Server) apiCityAgg(w http.ResponseWriter, r *http.Request) { COALESCE(sum(pa.visits),0), count(*), COALESCE(sum(pa.dwell),0), min(pa.first_at), max(pa.last_at) FROM ( - SELECT p.id, p.city, p.province, p.lat, p.lon, p.first_visit_at AS first_at, p.last_visit_at AS last_at, + SELECT p.id, p.city, p.province, p.lat, p.lon, + -- 首末到访按**可见到访**的到达时间算,不取 places 列值(那两列是全量, + -- 会被已并入 / 子段的行推早推晚),与 visit_count 之类的口径保持一致 + min(v.arrival) AS first_at, max(v.arrival) AS last_at, count(*) AS visits, COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0) AS dwell FROM places p diff --git a/scripts/verify.sh b/scripts/verify.sh index d49e58a..720a1b6 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -103,6 +103,26 @@ export ROND_CLEAN_OUT="$CLEAN_OUT" ROND_EXPORT="$CLEAN_OUT" mkdir -p "$OUT" echo "== 库:$DBNAME 备份:$(basename "$ROND_SRC") / $(basename "$FOG_SRC")" +# --- 死元组回收:库一胖,级联删除就变 IO 爬虫,整套验收到点跑不完 ---------------- +# +# 每个夹具都导入一整包(约千条到访 + 每行一整份 Core Data 原始行)再删掉, +# 一轮全量下来能留 ~370MB 死元组;实测 16MB 时 5m49s 跑完、385MB 时 30 分钟还在爬 +# (看着像 hang:测试进程 CPU 长时间不动)。这里在库超过阈值时先收一次。 +# +# 用普通 VACUUM 而不是 VACUUM FULL:FULL 要 ACCESS EXCLUSIVE 锁,万一有别的服务连着 +# 这个库就会把验收卡住;普通 VACUUM 是并发的,清掉死元组即可恢复扫描速度。 +# 没有 psql 就跳过(不拦人),真慢了手动来一次 VACUUM FULL 把文件也缩回去。 +VACUUM_MIN_MB="${VACUUM_MIN_MB:-200}" +if command -v psql >/dev/null 2>&1; then + DB_MB="$(psql "$DSN" -A -t -c "SELECT pg_database_size(current_database())/1024/1024" 2>/dev/null || echo 0)" + if [ "${DB_MB:-0}" -ge "$VACUUM_MIN_MB" ]; then + echo "== 测试库 ${DB_MB}MB ≥ ${VACUUM_MIN_MB}MB,先清死元组(否则级联删除会慢到跑不完)" + psql "$DSN" -q -c "VACUUM (ANALYZE)" || echo "-- VACUUM 失败,照跑(不影响正确性,只是会慢)" + fi +else + echo "-- 本机没有 psql,跳过死元组回收;若验收跑得很慢,先手动 VACUUM (FULL, ANALYZE) 一次" +fi + GOFLAGS_EXTRA="" [ "$RACE" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -race" # 必须 -coverpkg:-cover 只统计「测试文件所在包」自己的执行,凡是主要由别的包驱动的