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

真机里两行到访可能「不独立成立」: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
+52 -15
View File
@@ -108,11 +108,40 @@ func (w *where) andSQL() string {
return " AND " + strings.Join(w.conds, " AND ")
}
// visitsWhere 构造作用于 visits 表别名 v 的过滤条件。
// SoloVisitSQL 生成「这次到访是独立成立的」SQL 条件(alias 是 visits 表的别名)。
//
// 真机把它**并入了别的到访**(ZVISIT.ZMERGEDTO),或它**只是某个父段的一部分**(ZVISIT.ZPARENT)时,
// app 既不单独显示它、也不把它算进次数与停留——本站以前把这两种行当成普通到访,
// 于是时间线上出现「同一时刻同一地点两条」(实测数据集 9 有 54 + 10 条,26 条的到达时间与原件完全相同),
// 日停留也跟着重复计入。
//
// ⚠️ 只在**读**的一侧收口:导出包必须原样带着这些行(rond 靠它们认这次合并发生过),
// 所以 internal/rond 与 backup_diff / backup_merge 一律不加这个条件。
//
// 判据只此一份,别在别处手抄——抄一份迟早跟这里漂移(区域限制那套就犯过一次)。
func SoloVisitSQL(alias string) string {
return `NOT EXISTS (SELECT 1 FROM entity_raw er WHERE er.dataset_id=` + alias + `.dataset_id` +
` AND er.entity='ZVISIT' AND er.src_pk=` + alias + `.src_pk` +
` AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))`
}
// visitsWhere 构造作用于 visits 表别名 v 的过滤条件(**访客口径**:已并入 / 子段的到访不算数)。
// off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。
func (f Filter) visitsWhere(off int) *where {
func (f Filter) visitsWhere(off int) *where { return f.visitConds(off, true) }
// visitConds 是 visitsWhere 的实现。
//
// solo=false 时不筛「已并入 / 子段」——天气与位移用的是这一支:那几小时、那段路是真的
// 发生过的,不该因为这次到访在 app 里不单独显示就从统计里消失(实测数据集 9 有 12 条
// 天气行、5 条位移挂在被并入的行上)。
func (f Filter) visitConds(off int, solo bool) *where {
w := &where{off: off}
w.cond("v.dataset_id = ?", f.DatasetID)
// 已并入 / 子段不算一条独立到访(判据见 SoloVisitSQL)。放在这里是为了**全域一致**:
// 时间线、首页、统计、城市 / 省份、活动计数、星期分布、地点列的到访数都从这份条件派生。
if solo {
w.cond(SoloVisitSQL("v"))
}
if f.From != nil {
w.cond("v.arrival >= ?", *f.From)
}
@@ -204,7 +233,7 @@ func (f Filter) weatherWhere() *where {
if f.hasVisitConds() {
rest := f
rest.From, rest.To = nil, nil // 时间已经按 w.at 判过了
vw := rest.visitsWhere(len(w.args))
vw := rest.visitConds(len(w.args), false)
w.conds = append(w.conds,
"EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")")
w.args = append(w.args, vw.args...)
@@ -221,13 +250,14 @@ func (f Filter) weatherWhere() *where {
// 清掉三类条件,剩下的正是可以按到访判的:
// - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径;
// - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere;
// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」。
// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」;
// - 已并入 / 子段:位移照算(里程与轨迹是真实发生过的),见 visitConds 的 solo 参数。
func (f Filter) movementVisitConds(off int) *where {
rest := f
rest.From, rest.To = nil, nil
rest.Regions = nil
rest.ExcludeHome, rest.ExcludeWork = false, false
return rest.visitsWhere(off)
return rest.visitConds(off, false)
}
// hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。
@@ -735,23 +765,28 @@ type Place struct {
// 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id)
// 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。
// 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。
const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
var placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''),
p.visit_count, p.dwell_minutes, p.first_visit_at, p.last_visit_at,
p.visit_count, p.dwell_minutes,
-- 首末到访这两列**存的是全量**(last_visit_at 会写成导出包里的 ZLOCATION.ZLASTARRIVALDATE_,
-- 不能在库里改口径)。于是「到访数按访客口径、日期按全量」会凑出「0 次却有最近到访」这种
-- 自相矛盾的行(实测数据集 9 有 4 个地点名下只剩已并入 / 子段到访),这里按可见到访数兜一下。
CASE WHEN p.visit_count > 0 THEN p.first_visit_at END,
CASE WHEN p.visit_count > 0 THEN p.last_visit_at END,
COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home),
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id AND ` + SoloVisitSQL("v2") + ` GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home AND ` + SoloVisitSQL("v3") + `),
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work AND ` + SoloVisitSQL("v4") + `),
p.src_pk,
EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key =
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))`
// PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。
const PlaceCols = placeCols
var PlaceCols = placeCols
func scanPlace(s interface{ Scan(...any) error }) (Place, error) {
var p Place
@@ -946,7 +981,8 @@ func (d DB) VisitsByPlace(ctx context.Context, datasetID, placeID int64, limit i
q := `SELECT ` + visitCols + ` 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 AND v.place_id=$2 ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
WHERE v.dataset_id=$1 AND v.place_id=$2 AND ` + SoloVisitSQL("v") +
` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, datasetID, placeID)
if err != nil {
return nil, err
@@ -967,7 +1003,8 @@ func (d DB) Bookmarked(ctx context.Context, datasetID int64, limit int) ([]Visit
q := `SELECT ` + visitCols + ` 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 AND v.bookmarked ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
WHERE v.dataset_id=$1 AND v.bookmarked AND ` + SoloVisitSQL("v") +
` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, datasetID)
if err != nil {
return nil, err
+8 -1
View File
@@ -43,9 +43,16 @@ func TestOverviewMatchesManualSQL(t *testing.T) {
}
var places, cities, days, visits, movements int
var dist float64
// 判据按**访客口径**在手写 SQL 里独立写一遍(不复用 stats.SoloVisitSQL):已并入 / 子段的
// 到访在 app 里既不算次数也不占天数——独立写一份,正好在口径写错时把它抓出来。
// 位移/里程那两行**不**加这个条件:那段路是真的发生过的,收口只管到访本身。
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city),
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(*)
FROM visits v LEFT JOIN places p ON p.id = v.place_id WHERE v.dataset_id=$1`, dsID).
FROM visits v LEFT JOIN places p ON p.id = v.place_id
WHERE v.dataset_id=$1
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))`, dsID).
Scan(&places, &cities, &days, &visits); err != nil {
t.Fatal(err)
}