口径:已并入 / 子段的到访不再计入时间线与统计(导出侧一个字节不动)
真机里两行到访可能「不独立成立」: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:
+52
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user