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

真机里两行到访可能「不独立成立」: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 -4
View File
@@ -15,6 +15,7 @@ import (
"rond-with-you/internal/geo"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
@@ -282,8 +283,13 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
vargs = append(vargs, editPerPage, (d.VisitPageNo-1)*editPerPage)
vrows, err := s.db.QueryContext(ctx, `SELECT v.src_pk, COALESCE(p.name,''), COALESCE(p.city,''),
COALESCE(a.name,''), COALESCE(a.id,0), v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''),
v.bookmarked, v.is_user_added
v.bookmarked, v.is_user_added,
-- 真机里「不独立成立」的那两种行(别处已并入 / 只是某个父段的一部分):访客侧不显示它们,
-- 这一页要看得到全部原始行,所以只打标、不过滤(口径见 stats.SoloVisitSQL)
CASE WHEN er.raw->>'ZMERGEDTO' IS NOT NULL THEN '已并入 #'||(er.raw->>'ZMERGEDTO')
WHEN er.raw->>'ZPARENT' IS NOT NULL THEN '子段 #'||(er.raw->>'ZPARENT') ELSE '' END
FROM visits v LEFT JOIN places p ON p.id=v.place_id LEFT JOIN activities a ON a.id=v.activity_id
LEFT JOIN entity_raw er ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
WHERE `+vwhere+fmt.Sprintf(` ORDER BY v.arrival DESC LIMIT $%d OFFSET $%d`, len(vargs)-1, len(vargs)),
vargs...)
if err != nil {
@@ -294,7 +300,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
var e EditVisit
var dep sql.NullTime
if err := vrows.Scan(&e.SrcPK, &e.PlaceName, &e.City, &e.ActivityName, &e.ActivityID,
&e.Arrival, &dep, &e.Remark, &e.Emoji, &e.Bookmarked, &e.UserAdded); err != nil {
&e.Arrival, &dep, &e.Remark, &e.Emoji, &e.Bookmarked, &e.UserAdded, &e.Hidden); err != nil {
vrows.Close()
s.serverError(w, r, err)
return
@@ -2261,13 +2267,21 @@ func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64,
}
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
//
// 次数与停留按**访客口径**算(不含已并入 / 子段,判据见 stats.SoloVisitSQL)——编辑页删掉一条
// 普通到访后,「地点库」上那个「到访 N 次」要跟时间线对得上。
// first_visit_at / last_visit_at 保持全量:last_visit_at 会被导出成 ZLOCATION.ZLASTARRIVALDATE_,
// 这里改口径就等于动导出包(实测数据集 9 有 7 个地点的「最晚到达」是由被并入的行决定的)。
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE places p SET
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
first_visit_at = v.f, last_visit_at = v.l
FROM (SELECT $1::bigint AS pid) x
LEFT JOIN (SELECT place_id, count(*) c, COALESCE(sum(LEAST(COALESCE(duration_min,0), 10080)),0) d, min(arrival) f, max(arrival) l
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid
LEFT JOIN (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.place_id=$1 GROUP BY vis.place_id) v ON v.place_id = x.pid
WHERE p.id = x.pid`, placeID); err != nil {
return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err)
}