口径:已并入 / 子段的到访不再计入时间线与统计(导出侧一个字节不动)

真机里两行到访可能「不独立成立」:ZMERGEDTO 指向它被并入的那条、ZPARENT 说明它只是某个
父段的一部分。app 对这两种行既不显示也不计数,本站以前当成普通到访 ⇒ 时间线上出现
「同一时刻同一地点两条」、日停留重复计入(实测数据集 9 有 64 条,26 条到达时间与原件相同)。
这些行**不能删**——导出要靠它们认这次合并发生过,所以判据只立一份 stats.SoloVisitSQL,
在读的一侧收口:visitsWhere / VisitsByPlace / Bookmarked / placeCols 子查询、首次到达城市、
地图极值点、专题(详情 + 列表批量)、三处聚合的 visit_count 与 dwell_minutes,
外加 schema.sql 一段幂等回填把存量库拉正。

天气与位移**不收口**(那几小时、那段路真实发生过);导出侧一个字节不动(rond 只读
places.last_visit_at,保持全量,压根不读 visit_count / dwell_minutes)。编辑页照样列出
这些行,但打上「已并入 #562 / 子段 #374」,免得以为数据丢了。

顺带修两个自测抓出来的问题:
- ingest 的地点聚合回填排在 insertEntityRaw 之前 ⇒ 判据读到空留档 ⇒ 每行都算独立到访
  (整整多算一个「已并入」的量)。
- 收口后 last_visit_at 保持全量,会凑出「0 次却有最近到访」(数据集 9 有 4 个地点),
  展示侧按可见到访数兜住。

验收:304 PASS / 0 FAIL / 0 SKIP(新增 3 个用例:标记后导出仍带该行、存量回填拉正、
判据只出现在读侧)。
This commit is contained in:
tamakyi
2026-09-29 21:15:25 +08:00
parent 152af81f6a
commit 4597047989
12 changed files with 404 additions and 61 deletions
+18 -28
View File
@@ -17,6 +17,7 @@ import (
"rond-with-you/internal/geo"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
type Stats struct {
@@ -205,13 +206,6 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
// 地点
placeID := map[int]int64{}
type locAgg struct {
count int
dwell int64
first *time.Time
last *time.Time
}
agg := map[int]*locAgg{}
locCoord := map[int][2]float64{}
for _, l := range b.Locations {
var id int64
@@ -225,7 +219,6 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
return 0, nil, fmt.Errorf("写入地点: %w", err)
}
placeID[l.SrcPK] = id
agg[l.SrcPK] = &locAgg{}
locCoord[l.SrcPK] = [2]float64{l.Lat, l.Lon}
}
@@ -282,18 +275,6 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
vcount++
if v.LocationSrcPK != nil {
if a, ok := agg[*v.LocationSrcPK]; ok {
a.count++
if dur != nil {
a.dwell += int64(dur.(int))
}
if a.first == nil || v.Arrival.Before(*a.first) {
a.first = v.Arrival
}
if a.last == nil || v.Arrival.After(*a.last) {
a.last = v.Arrival
}
}
visitPlace[v.SrcPK] = *v.LocationSrcPK
}
if first == nil || v.Arrival.Before(*first) {
@@ -374,14 +355,6 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
}
}
// 回填地点聚合
for src, a := range agg {
if _, err := tx.ExecContext(ctx, `UPDATE places SET visit_count=$1, dwell_minutes=$2,
first_visit_at=$3, last_visit_at=$4 WHERE id=$5`, a.count, a.dwell, a.first, a.last, placeID[src]); err != nil {
return 0, nil, fmt.Errorf("回填地点统计: %w", err)
}
}
// 汇总
st := &Stats{Visits: vcount, Skipped: skipped, Places: len(b.Locations), RawVisits: b.RawVisitNum, FirstVisit: first, LastVisit: last}
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET status='done', visit_count=$1, place_count=$2,
@@ -403,6 +376,23 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
return 0, nil, err
}
// 回填地点聚合:走与刷新路径(refreshPlaceAgg / recountPlaces)同一条 SQL,
// 口径才不会在两处漂移——到访数与停留按访客口径(stats.SoloVisitSQL,不含已并入 / 子段),
// 首末到访保持全量。原来这里在 Go 里另算一遍,还漏了停留上限(LEAST(...,10080))。
//
// ⚠️ 必须排在 insertEntityRaw **之后**:判据读的是 entity_raw,留档还没写盘时
// 每一行都会被当成独立到访(实测会整整多算一个「已并入」的量,26 条)。
if _, err := tx.ExecContext(ctx, `UPDATE places p SET visit_count=sub.c, dwell_minutes=sub.d,
first_visit_at=sub.f, last_visit_at=sub.l
FROM (SELECT vis.place_id,
count(*) FILTER (WHERE `+stats.SoloVisitSQL("vis")+`) c,
COALESCE(sum(LEAST(COALESCE(vis.duration_min,0),10080)) FILTER (WHERE `+stats.SoloVisitSQL("vis")+`),0) d,
min(vis.arrival) f, max(vis.arrival) l
FROM visits vis WHERE vis.dataset_id=$1 AND vis.place_id IS NOT NULL GROUP BY vis.place_id) sub
WHERE p.id = sub.place_id`, datasetID); err != nil {
return 0, nil, fmt.Errorf("回填地点统计: %w", err)
}
// 坐标来源留档要等 entity_raw 写完才能做(原始 GPS 只在留档行里)。
if _, err := BackfillCoordSource(ctx, tx, datasetID); err != nil {
return 0, nil, err