Compare commits

...
2 Commits
Author SHA1 Message Date
tamakyi 4597047989 口径:已并入 / 子段的到访不再计入时间线与统计(导出侧一个字节不动)
真机里两行到访可能「不独立成立」: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 个用例:标记后导出仍带该行、存量回填拉正、
判据只出现在读侧)。
2026-09-29 21:15:25 +08:00
tamakyi 152af81f6a 编辑页:新增「重复到访」卡片 —— 以基线(手机包)为参照清掉站点侧的历史副本
站点库里有一批「到达时间 + 地点与手机包里某条完全相同、但 UUID 不同」的到访副本,
合并/差异按 UUID 认人会把它们当成备份新增,导出到 rond 就成了成对的两条重复记录。
判定与 tools/dedup_site_visits.py 同一套语义:UUID 是前提(只按时间+地点判会误删手机包
自身合法的成对条目);基线跟本站一条都对不上时只给说明、不给按钮。清理在单事务里删
副本 + 其位移/天气/留档,并把指向副本的 ZMERGEDTO/ZPARENT 改指到保留项——只动本来
就有的那个键,工具那版会凭空补出一个 ZMERGEDTO(真机里是「已并入别处」,会白丢记录)。

顺带修一个「一补录天气就整包导出失败」的洞:rond 导出把 weather.is_daylight 扫进裸 bool,
而站点「新增天气」不写这一列 ⇒ NULL ⇒ sql.Scan 报错。改成 COALESCE(is_daylight,false),
与导入侧读真机包同口径。

验收:301 PASS / 0 FAIL / 0 SKIP(新增 4 个用例,含变异验证:去掉 UUID 前提/改回
create_if_missing=true/不删副本位移,三种改法都被用例抓到)。
2026-09-29 19:17:29 +08:00
15 changed files with 1500 additions and 61 deletions
+34
View File
@@ -558,3 +558,37 @@ END $$;
CREATE INDEX IF NOT EXISTS idx_place_images_key ON place_images(place_key, position); CREATE INDEX IF NOT EXISTS idx_place_images_key ON place_images(place_key, position);
CREATE INDEX IF NOT EXISTS idx_pvv_key ON place_visitor_votes(place_key); CREATE INDEX IF NOT EXISTS idx_pvv_key ON place_visitor_votes(place_key);
-- 已并入 / 子段到访不收进地点的到访数与停留(2026-09-29)
--
-- 真机里两行到访可能「不独立成立」:ZVISIT.ZMERGEDTO 指向它被并入的那条,
-- ZPARENT 说明它只是某个父段的一部分。app 对这两种行既不单独显示也不计数,本站以前
-- 当成普通到访 ⇒ 时间线上出现「同一时刻同一地点两条」、日停留重复计入(实测数据集 9
-- 有 54 + 10 条,26 条的到达时间与原件完全相同)。
--
-- 判据与 internal/stats/stats.go 的 SoloVisitSQL 同口径,只在**读**的一侧收口:
-- 导出的包照旧原样带着这些行(rond 靠它们认这次合并发生过),所以这里只动
-- visit_count / dwell_minutes 两列,first_visit_at / last_visit_at 保持全量
-- (后者会被导出成 ZLOCATION.ZLASTARRIVALDATE_)。
--
-- 只动「名下真有这种行」的地点,且幂等:重跑到收敛,没有这种行的库一次也不改行。
UPDATE places p SET
visit_count = sub.c,
dwell_minutes = sub.d
FROM (
SELECT v.place_id,
count(*) FILTER (WHERE 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))) AS c,
COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)) FILTER (WHERE 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))), 0) AS d
FROM visits v
WHERE v.place_id IS NOT NULL
GROUP BY v.place_id
) sub
WHERE p.id = sub.place_id
AND EXISTS (SELECT 1 FROM visits v JOIN entity_raw er
ON er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
WHERE v.place_id = p.id
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL));
+18 -28
View File
@@ -17,6 +17,7 @@ import (
"rond-with-you/internal/geo" "rond-with-you/internal/geo"
"rond-with-you/internal/rond" "rond-with-you/internal/rond"
"rond-with-you/internal/stats"
) )
type Stats struct { type Stats struct {
@@ -205,13 +206,6 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str
// 地点 // 地点
placeID := map[int]int64{} 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{} locCoord := map[int][2]float64{}
for _, l := range b.Locations { for _, l := range b.Locations {
var id int64 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) return 0, nil, fmt.Errorf("写入地点: %w", err)
} }
placeID[l.SrcPK] = id placeID[l.SrcPK] = id
agg[l.SrcPK] = &locAgg{}
locCoord[l.SrcPK] = [2]float64{l.Lat, l.Lon} 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++ vcount++
if v.LocationSrcPK != nil { 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 visitPlace[v.SrcPK] = *v.LocationSrcPK
} }
if first == nil || v.Arrival.Before(*first) { 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} 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, 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 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 只在留档行里)。 // 坐标来源留档要等 entity_raw 写完才能做(原始 GPS 只在留档行里)。
if _, err := BackfillCoordSource(ctx, tx, datasetID); err != nil { if _, err := BackfillCoordSource(ctx, tx, datasetID); err != nil {
return 0, nil, err return 0, nil, err
+4 -1
View File
@@ -1322,7 +1322,10 @@ type pgWeather struct {
} }
func queryWeather(ctx context.Context, db *sql.DB, datasetID int64) ([]pgWeather, error) { func queryWeather(ctx context.Context, db *sql.DB, datasetID int64) ([]pgWeather, error) {
rows, err := db.QueryContext(ctx, `SELECT src_pk, visit_src, at, is_daylight, temperature_c, apparent_c, humidity, // is_daylight 必须兜底成 false:真机 557/557 都有值,但站点「补录天气」建的行
// 没填昼夜标记(editWeatherCreate 不写这一列),而扫进 bool 遇到 NULL 会让**整包导出失败**。
// 取 0 与导入侧同口径(parse.go 读真机包用的就是 COALESCE(ZISDAYLIGHT,0))。
rows, err := db.QueryContext(ctx, `SELECT src_pk, visit_src, at, COALESCE(is_daylight,false), temperature_c, apparent_c, humidity,
precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index, precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index,
COALESCE(condition,''), COALESCE(symbol,''), COALESCE(uv_category,''), COALESCE(wind_direction,'') COALESCE(condition,''), COALESCE(symbol,''), COALESCE(uv_category,''), COALESCE(wind_direction,'')
FROM weather WHERE dataset_id=$1 ORDER BY id`, datasetID) FROM weather WHERE dataset_id=$1 ORDER BY id`, datasetID)
+52 -15
View File
@@ -108,11 +108,40 @@ func (w *where) andSQL() string {
return " AND " + strings.Join(w.conds, " AND ") 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 开始编号,避免与外层冲突。 // 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 := &where{off: off}
w.cond("v.dataset_id = ?", f.DatasetID) w.cond("v.dataset_id = ?", f.DatasetID)
// 已并入 / 子段不算一条独立到访(判据见 SoloVisitSQL)。放在这里是为了**全域一致**:
// 时间线、首页、统计、城市 / 省份、活动计数、星期分布、地点列的到访数都从这份条件派生。
if solo {
w.cond(SoloVisitSQL("v"))
}
if f.From != nil { if f.From != nil {
w.cond("v.arrival >= ?", *f.From) w.cond("v.arrival >= ?", *f.From)
} }
@@ -204,7 +233,7 @@ func (f Filter) weatherWhere() *where {
if f.hasVisitConds() { if f.hasVisitConds() {
rest := f rest := f
rest.From, rest.To = nil, nil // 时间已经按 w.at 判过了 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, w.conds = append(w.conds,
"EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")") "EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")")
w.args = append(w.args, vw.args...) w.args = append(w.args, vw.args...)
@@ -221,13 +250,14 @@ func (f Filter) weatherWhere() *where {
// 清掉三类条件,剩下的正是可以按到访判的: // 清掉三类条件,剩下的正是可以按到访判的:
// - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径; // - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径;
// - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere; // - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere;
// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」。 // - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」;
// - 已并入 / 子段:位移照算(里程与轨迹是真实发生过的),见 visitConds 的 solo 参数。
func (f Filter) movementVisitConds(off int) *where { func (f Filter) movementVisitConds(off int) *where {
rest := f rest := f
rest.From, rest.To = nil, nil rest.From, rest.To = nil, nil
rest.Regions = nil rest.Regions = nil
rest.ExcludeHome, rest.ExcludeWork = false, false rest.ExcludeHome, rest.ExcludeWork = false, false
return rest.visitsWhere(off) return rest.visitConds(off, false)
} }
// hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。 // hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。
@@ -735,23 +765,28 @@ type Place struct {
// 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id) // 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id)
// 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。 // 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。
// 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。 // 谓词本身冗余(到访与所属地点的 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,''), 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 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 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 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),''), 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), 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), 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, p.src_pk,
EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key = EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key =
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))` to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))`
// PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。 // PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。
const PlaceCols = placeCols var PlaceCols = placeCols
func scanPlace(s interface{ Scan(...any) error }) (Place, error) { func scanPlace(s interface{ Scan(...any) error }) (Place, error) {
var p Place 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 q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_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) rows, err := d.QueryContext(ctx, q, datasetID, placeID)
if err != nil { if err != nil {
return nil, err 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 q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_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) rows, err := d.QueryContext(ctx, q, datasetID)
if err != nil { if err != nil {
return nil, err return nil, err
+8 -1
View File
@@ -43,9 +43,16 @@ func TestOverviewMatchesManualSQL(t *testing.T) {
} }
var places, cities, days, visits, movements int var places, cities, days, visits, movements int
var dist float64 var dist float64
// 判据按**访客口径**在手写 SQL 里独立写一遍(不复用 stats.SoloVisitSQL):已并入 / 子段的
// 到访在 app 里既不算次数也不占天数——独立写一份,正好在口径写错时把它抓出来。
// 位移/里程那两行**不**加这个条件:那段路是真的发生过的,收口只管到访本身。
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city), 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(*) 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 { Scan(&places, &cities, &days, &visits); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+9 -4
View File
@@ -14,6 +14,7 @@ import (
"rond-with-you/internal/geo" "rond-with-you/internal/geo"
"rond-with-you/internal/ingest" "rond-with-you/internal/ingest"
"rond-with-you/internal/rond" "rond-with-you/internal/rond"
"rond-with-you/internal/stats"
) )
// 差异页确认后的合并执行。 // 差异页确认后的合并执行。
@@ -755,6 +756,9 @@ func (s *Server) applyDeletions(ctx context.Context, ex execer, dsID int64, plan
// recountPlaces 重算地点聚合与数据集汇总。全量重算而不是只动受影响的行: // recountPlaces 重算地点聚合与数据集汇总。全量重算而不是只动受影响的行:
// 367 个地点两条 SQL 就够,省得漏掉任何一条派生列。 // 367 个地点两条 SQL 就够,省得漏掉任何一条派生列。
//
// 到访数与停留按**访客口径**(不含已并入 / 子段,见 stats.SoloVisitSQL),与 stats.Filter 一致;
// 首末到访与数据集计数保持全量(last_visit_at 会写进导出包,datasets.visit_count 是后台口径)。
func (s *Server) recountPlaces(ctx context.Context, ex execer, dsID int64) error { func (s *Server) recountPlaces(ctx context.Context, ex execer, dsID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE places SET visit_count=0, dwell_minutes=0, if _, err := ex.ExecContext(ctx, `UPDATE places SET visit_count=0, dwell_minutes=0,
first_visit_at=NULL, last_visit_at=NULL WHERE dataset_id=$1`, dsID); err != nil { first_visit_at=NULL, last_visit_at=NULL WHERE dataset_id=$1`, dsID); err != nil {
@@ -762,10 +766,11 @@ func (s *Server) recountPlaces(ctx context.Context, ex execer, dsID int64) error
} }
if _, err := ex.ExecContext(ctx, `UPDATE places p SET visit_count=sub.c, dwell_minutes=sub.d, if _, err := ex.ExecContext(ctx, `UPDATE places p SET visit_count=sub.c, dwell_minutes=sub.d,
first_visit_at=sub.f, last_visit_at=sub.l first_visit_at=sub.f, last_visit_at=sub.l
FROM (SELECT place_id, count(*) c, FROM (SELECT vis.place_id,
COALESCE(sum(LEAST(COALESCE(duration_min,0),10080)),0) d, count(*) FILTER (WHERE `+stats.SoloVisitSQL("vis")+`) c,
min(arrival) f, max(arrival) l COALESCE(sum(LEAST(COALESCE(vis.duration_min,0),10080)) FILTER (WHERE `+stats.SoloVisitSQL("vis")+`),0) d,
FROM visits WHERE dataset_id=$1 AND place_id IS NOT NULL GROUP BY place_id) sub 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`, dsID); err != nil { WHERE p.id = sub.place_id`, dsID); err != nil {
return err return err
} }
+633
View File
@@ -0,0 +1,633 @@
package web
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/rond"
)
// 重复到访(站点侧副本)的判定与清理。夹具是「刚导入、还没被改过」的真实数据集,
// 基线就是它当初那份手机包——判定逻辑必须在这种真数据形状上跑,不能只喂人造的小库。
// dupBlob 是一条到访的 UUID(既要有 hex 用来比,也要有 base64 用来写留档)。
type dupBlob struct {
hex, b64 string
}
// newDupBlob 造一个基线里绝不会有的 UUID——副本的特征就是「UUID 在基线里找不到」。
func newDupBlob(t *testing.T) dupBlob {
t.Helper()
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatal(err)
}
return dupBlob{hex: fmt.Sprintf("%x", b), b64: base64.StdEncoding.EncodeToString(b[:])}
}
// dupSeedBase 是被拿来当「原件」的那条到访。
type dupSeedBase struct {
srcPK int
arrival time.Time
}
// pickDupBase 挑一条「UUID 在手机包里也有」的到访当原件。skip 里的编号不选
// (同一份数据里要造两对时,别把上一对的双方再拿去当原件)。
func pickDupBase(t *testing.T, srv *Server, ctx context.Context, dsID int64, skip ...int) dupSeedBase {
t.Helper()
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatalf("读基线库失败: %v", err)
}
site, err := srv.loadDupSiteVisits(ctx, dsID)
if err != nil {
t.Fatalf("读站点到访失败: %v", err)
}
used := map[int]bool{}
for _, s := range skip {
used[s] = true
}
for _, sv := range site {
if sv.uuid == "" || !base.uuids[sv.uuid] || used[sv.srcPK] {
continue
}
var placeID int64
if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0)
FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, sv.srcPK).Scan(&placeID); err != nil {
t.Fatal(err)
}
if placeID > 0 {
return dupSeedBase{srcPK: sv.srcPK, arrival: sv.arrival}
}
}
t.Fatal("数据集里挑不出可当原件的到访(UUID 得在基线里)")
return dupSeedBase{}
}
// seedDupVictim 复制一条到访造出「站点副本」,并给它配上位移 / 天气 / 留档。
// 返回副本的 src_pk 与 UUID。
func seedDupVictim(t *testing.T, srv *Server, ctx context.Context, dsID int64,
base dupSeedBase, keeper int) (int, dupBlob) {
t.Helper()
pg := srv.db
src, err := srv.nextSrcPK(ctx, pg, dsID, visitSeq)
if err != nil {
t.Fatalf("分配副本编号失败: %v", err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZVISIT", src); err != nil {
t.Fatal(err)
}
// 到访行照抄原件:现实里的副本就是合并时多出来的那一份拷贝,字段一模一样
// (所以才会「时间 + 地点完全相同」,光看时间是分不出来的)
if _, err := pg.ExecContext(ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min, is_home, is_work,
bookmarked, is_user_added, remark, emoji, weather_symbol)
SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min, is_home, is_work,
bookmarked, is_user_added, remark, emoji, weather_symbol
FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil {
t.Fatalf("造副本到访失败: %v", err)
}
blob := newDupBlob(t)
// 留档也照抄原件那一行,只改主键与 UUID,合并标记清成 null(副本不该自称已并入别处)。
// 这样导出时这一行的列形状和原件一致,往返对比才有意义。
if _, err := pg.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw)
SELECT dataset_id, entity, $3,
jsonb_set(jsonb_set(jsonb_set(jsonb_set(raw,
'{Z_PK}', to_jsonb($3::int), true),
'{ZIDENTIFIER_}', jsonb_build_object('$blob', $4::text), true),
'{ZMERGEDTO}', 'null'::jsonb, true),
'{ZPARENT}', 'null'::jsonb, true)
FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$5`,
dsID, "ZVISIT", src, blob.b64, base.srcPK); err != nil {
t.Fatalf("造副本留档失败: %v", err)
}
// 附属记录:两端各挂一条位移 + 一条天气。清理时必须连着一起走,
// 否则导出包里会留下指着不存在到访的悬空引用。
for _, ends := range [][2]int{{src, keeper}, {keeper, src}} {
mv, err := srv.nextSrcPK(ctx, pg, dsID, movementSeq)
if err != nil {
t.Fatal(err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZMOVEMENT", mv); err != nil {
t.Fatal(err)
}
if _, err := pg.ExecContext(ctx, `INSERT INTO movements
(dataset_id, src_pk, from_visit_src, to_visit_src) VALUES ($1,$2,$3,$4)`,
dsID, mv, ends[0], ends[1]); err != nil {
t.Fatalf("造副本位移失败: %v", err)
}
}
w, err := srv.nextSrcPK(ctx, pg, dsID, weatherSeq)
if err != nil {
t.Fatal(err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZHOURLYWEATHER", w); err != nil {
t.Fatal(err)
}
if _, err := pg.ExecContext(ctx, `INSERT INTO weather (dataset_id, src_pk, visit_src, at)
VALUES ($1,$2,$3,$4)`, dsID, w, src, base.arrival); err != nil {
t.Fatalf("造副本天气失败: %v", err)
}
return src, blob
}
// pickReferrers 挑两条跟副本无关、两个合并标记都是空的到访留档。
// 两条必须不同,否则「只带其中一个键」的两种情形就分不出来了。
func pickReferrers(t *testing.T, srv *Server, ctx context.Context, dsID int64, exclude ...int64) (int, int) {
t.Helper()
rows, err := srv.db.QueryContext(ctx, `SELECT src_pk FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND NOT (src_pk = ANY($2::bigint[]))
AND raw->>'ZMERGEDTO' IS NULL AND raw->>'ZPARENT' IS NULL
ORDER BY src_pk LIMIT 2`, dsID, exclude)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []int
for rows.Next() {
var pk int
if err := rows.Scan(&pk); err != nil {
t.Fatal(err)
}
out = append(out, pk)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(out) < 2 {
t.Fatalf("挑不出两条指向副本的引用行(找到 %d 条)", len(out))
}
return out[0], out[1]
}
// pointRef 把某条到访留档里的一个合并标记指到 target(另一个保持 null),
// 模拟「别的到访的留档指着副本」这种要改指的情形。
func pointRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, referrer int, col string, target int) {
t.Helper()
other := "ZPARENT"
if col == "ZPARENT" {
other = "ZMERGEDTO"
}
if _, err := srv.db.ExecContext(ctx, `UPDATE entity_raw
SET raw = jsonb_set(jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), true),
ARRAY[$5::text], 'null'::jsonb, true)
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`,
dsID, referrer, target, col, other); err != nil {
t.Fatalf("设置合并标记失败: %v", err)
}
}
// readRef 读某条到访留档里的一个合并标记。
func readRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int, col string) sql.NullString {
t.Helper()
var v sql.NullString
if err := srv.db.QueryRowContext(ctx, `SELECT raw->>$3 FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK, col).Scan(&v); err != nil {
t.Fatalf("读合并标记失败: %v", err)
}
return v
}
// openExport 把导出的字节落到临时文件并打开,返回可直接查询的库。
// 一份包只开一次:每次打开都要写盘 + 解压,动辄几秒。
func openExport(t *testing.T, blob []byte) *sql.DB {
t.Helper()
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
t.Cleanup(func() { edb.Close() })
return edb
}
// countExportTwin 数导出包里「跟某条到访同地点、同到达时间」的到访有几条——
// 这正是用户看到的那对重复(清理前 2、清理后 1)。
//
// 只能按「时间 + 地点」数,不能按 UUID:站点侧的行(副本就是)在导出时会被补上本站
// 派生的确定性 UUID(coreDataUUID),留档里那个 UUID 本来就不会写进包里。
func countExportTwin(t *testing.T, edb *sql.DB, keeperHex string, arrival time.Time) int {
t.Helper()
var n int
if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT
WHERE ZLOCATION = (SELECT ZLOCATION FROM ZVISIT WHERE hex(ZIDENTIFIER_)=?)
AND abs(ZARRIVALDATE_ - ?) < 0.5`, strings.ToUpper(keeperHex), appleSec(arrival)).Scan(&n); err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return n
}
// countExportAll 数导出包里的到访总条数。
func countExportAll(t *testing.T, edb *sql.DB) int {
t.Helper()
var n int
if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT`).Scan(&n); err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return n
}
// visitUUIDHex 读某条到访留档里的 UUID(小写 hex)。
func visitUUIDHex(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int) string {
t.Helper()
var b64 string
if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','')
FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK).Scan(&b64); err != nil {
t.Fatal(err)
}
return blobHex(b64)
}
// countDangling 数站点里所有悬空引用:留档指向不存在的到访 / 位移指向不存在的到访。
func countDangling(t *testing.T, srv *Server, ctx context.Context, dsID int64) (int, int) {
t.Helper()
var raw, mv int
if err := srv.db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM entity_raw r WHERE r.dataset_id=$1 AND r.entity='ZVISIT' AND NOT r.skipped
AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk)),
(SELECT count(*) FROM movements m WHERE m.dataset_id=$1
AND ((m.from_visit_src IS NOT NULL AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.from_visit_src))
OR (m.to_visit_src IS NOT NULL AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.to_visit_src))))`,
dsID).Scan(&raw, &mv); err != nil {
t.Fatal(err)
}
return raw, mv
}
// TestDuplicateVisitsCleanE2E 端到端验证「检出重复到访 → 勾选清理 → 导出不再重复」这条链路。
//
// 造两对副本(各带位移 / 天气 / 留档),只提交其中一对,然后逐项核对:
// 勾掉的那条连附属记录一起没了、没勾的纹丝不动、指向副本的合并标记改指到保留项、
// 没有任何悬空引用,并且导出包里那一处重复真的从 2 条变回 1 条。
func TestDuplicateVisitsCleanE2E(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
// 起点:夹具是「刚导入的包」,每条到访的 UUID 都来自这个包,站点侧副本必然是 0 对
before, unavailable, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil {
t.Fatalf("检测失败: %v", err)
}
if unavailable != "" {
t.Fatalf("夹具的基线判定不了重复(%s),用例前提不成立", unavailable)
}
if len(before) != 0 {
t.Fatalf("刚导入的包不该检出站点副本,实际 %d 对——判定的前提被改坏了", len(before))
}
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatalf("读基线库失败: %v", err)
}
var startVisits int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits); err != nil {
t.Fatal(err)
}
dangRaw0, dangMv0 := countDangling(t, srv, ctx, dsID)
// 造两对:keeper1/victim1、keeper2/victim2
keeper1 := pickDupBase(t, srv, ctx, dsID)
victim1, blob1 := seedDupVictim(t, srv, ctx, dsID, keeper1, keeper1.srcPK)
keeper2 := pickDupBase(t, srv, ctx, dsID, keeper1.srcPK, victim1)
victim2, _ := seedDupVictim(t, srv, ctx, dsID, keeper2, keeper2.srcPK)
t.Logf("造副本:原件 #%d → 副本 #%d(UUID %s…);原件 #%d → 副本 #%d",
keeper1.srcPK, victim1, blob1.hex[:8], keeper2.srcPK, victim2)
// 复制品是「同地点同时间、UUID 不同」,判定必须刚好揪出这两条
pairs, unavailable, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil || unavailable != "" {
t.Fatalf("检测失败: err=%v unavailable=%s", err, unavailable)
}
got := map[int]int{}
for _, p := range pairs {
got[p.Victim] = p.Keep
}
if len(pairs) != len(before)+2 {
t.Fatalf("应检出 %d 对,实际 %d 对", len(before)+2, len(pairs))
}
if got[victim1] != keeper1.srcPK || got[victim2] != keeper2.srcPK {
t.Fatalf("配对不对:%v(应 #%d→#%d、#%d→#%d)",
got, victim1, keeper1.srcPK, victim2, keeper2.srcPK)
}
// 判定的定义性不变量:**副本的 UUID 一定不在基线里,保留项的 UUID 一定在**。
// 只按「时间 + 地点」判(漏了 UUID 前提)时,同一处会冒出一大堆「自己配自己」的配对,
// 而计数断言是会跟着一起膨胀的——只有这条不变量不随样本量走。
for _, p := range pairs {
if vh := visitUUIDHex(t, srv, ctx, dsID, p.Victim); vh == "" || base.uuids[vh] {
t.Errorf("把 #%d 判成副本,可它的 UUID(%s)在基线里也有——UUID 是判定的前提", p.Victim, vh)
}
if kh := visitUUIDHex(t, srv, ctx, dsID, p.Keep); kh == "" || !base.uuids[kh] {
t.Errorf("#%d 被当成保留项,可它的 UUID(%s)不在基线里", p.Keep, kh)
}
}
// 指向副本的合并标记:一条只带 ZMERGEDTO,一条只带 ZPARENT
refMerged, refParent := pickReferrers(t, srv, ctx, dsID,
int64(keeper1.srcPK), int64(victim1), int64(keeper2.srcPK), int64(victim2))
pointRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO", victim1)
pointRef(t, srv, ctx, dsID, refParent, "ZPARENT", victim1)
// 页面要能渲染出卡片:标题里带对数,每行一个默认勾上的勾选框。
//
// ⚠️ render 是**流式直写**的:模板执行到一半出错也只记日志、状态码照样 200,body 就此截断。
// 所以不能只看 code,必须断言「卡片之后的内容也在」(「全部保留」在 {{.EditLink}} 之后,
// 「到访记录」在整张卡片之后),末尾还得是闭合的 </html>。
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
fmt.Sprintf("重复到访 %d 对", len(pairs)),
fmt.Sprintf(`name="victim" value="%d" checked`, victim1),
"全部保留",
"到访记录",
} {
if !strings.Contains(body, want) {
t.Errorf("页面里没有 %q(模板可能执行到一半就断了)", want)
}
}
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
t.Error("页面没有渲染完(body 被截断)")
}
// 清理前导出:那一处就是成对的两条(用户看到的现象)
keeperHex1 := visitUUIDHex(t, srv, ctx, dsID, keeper1.srcPK)
edbBefore := openExport(t, f.export(t))
if n := countExportTwin(t, edbBefore, keeperHex1, keeper1.arrival); n != 2 {
t.Fatalf("清理前导出包里同时间同地点应有 2 条,实际 %d", n)
}
exportVisitsBefore := countExportAll(t, edbBefore)
// 只提交 victim1:victim2 必须原地不动
loc := post(t, srv.editDuplicateVisitsApply, ctx, "/admin/edit/dup/clean",
url.Values{"victim": {fmt.Sprint(victim1)}})
if !strings.Contains(loc, "ok=") {
t.Errorf("清理后没有 ok= 闪信: %s", loc)
}
var n int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n)
if n != startVisits+2-1 {
t.Errorf("到访数应为 %d,实际 %d", startVisits+2-1, n)
}
for _, c := range []struct {
name string
sql string
}{
{"副本到访", `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`},
{"副本留档", `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`},
{"副本位移(起点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND from_visit_src=$2`},
{"副本位移(终点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND to_visit_src=$2`},
{"副本天气", `SELECT count(*) FROM weather WHERE dataset_id=$1 AND visit_src=$2`},
} {
pg.QueryRowContext(ctx, c.sql, dsID, victim1).Scan(&n)
if n != 0 {
t.Errorf("%s 没清掉(还剩 %d 条)", c.name, n)
}
}
// 保留项与未被勾选的那对都在
for _, pk := range []int{keeper1.srcPK, victim2, keeper2.srcPK} {
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&n)
if n != 1 {
t.Errorf("到访 #%d 不该被删(实际存在 %d 条)", pk, n)
}
}
pg.QueryRowContext(ctx, `SELECT count(*) FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`,
dsID, victim2).Scan(&n)
if n != 2 {
t.Errorf("未勾选的副本 #%d 的位移不该被清(还剩 %d 条)", victim2, n)
}
// 合并标记改指到保留项,且**不凭空补出另一个键**:
// 「只有 ZPARENT 指向副本」的那条不许长出 ZMERGEDTO(真机里那是「已并入别处」)
if got := readRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO"); got.String != fmt.Sprint(keeper1.srcPK) {
t.Errorf("ZMERGEDTO 应改指到 #%d,实际 %q", keeper1.srcPK, got.String)
}
if got := readRef(t, srv, ctx, dsID, refMerged, "ZPARENT"); got.Valid {
t.Errorf("只带 ZMERGEDTO 的那条不该被补上 ZPARENT,实际 %q", got.String)
}
if got := readRef(t, srv, ctx, dsID, refParent, "ZPARENT"); got.String != fmt.Sprint(keeper1.srcPK) {
t.Errorf("ZPARENT 应改指到 #%d,实际 %q", keeper1.srcPK, got.String)
}
if got := readRef(t, srv, ctx, dsID, refParent, "ZMERGEDTO"); got.Valid {
t.Errorf("只带 ZPARENT 的那条不该被补上 ZMERGEDTO,实际 %q", got.String)
}
// 不得留下任何悬空引用(清理前是多少,清理后还得是多少)
if dangRaw, dangMv := countDangling(t, srv, ctx, dsID); dangRaw != dangRaw0 || dangMv != dangMv0 {
t.Errorf("清理后出现悬空引用:留档 %d→%d,位移 %d→%d", dangRaw0, dangRaw, dangMv0, dangMv)
}
// 数据集计数(页脚「数据截至」与后台 flash 用的那份)也要跟着减
var dsCount int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCount)
if dsCount != startVisits+2-1 {
t.Errorf("datasets.visit_count 应为 %d,实际 %d", startVisits+2-1, dsCount)
}
// 再检出一次:未勾选的那对还在(留着让用户自己决定)
pairs, unavailable, err = srv.detectDuplicateVisits(ctx, dsID)
if err != nil || unavailable != "" {
t.Fatalf("复核检测失败: err=%v unavailable=%s", err, unavailable)
}
still := false
for _, p := range pairs {
if p.Victim == victim2 {
still = true
}
if p.Victim == victim1 {
t.Errorf("已清理的副本 #%d 仍被检出", victim1)
}
}
if !still {
t.Errorf("未勾选的副本 #%d 不该消失", victim2)
}
// 导出:那一处重复从 2 条变回 1 条,整包到访数只少了被清掉的那一条
edbAfter := openExport(t, f.export(t))
if n := countExportTwin(t, edbAfter, keeperHex1, keeper1.arrival); n != 1 {
t.Errorf("清理后导出包里同时间同地点应只剩 1 条,实际 %d", n)
}
if n := countExportAll(t, edbAfter); n != exportVisitsBefore-1 {
t.Errorf("清理后导出包的到访数应为 %d,实际 %d", exportVisitsBefore-1, n)
}
// 导出包里那条引用的 ZMERGEDTO 必须正好指向保留项(编号对齐后也要对上)
var mergedTo sql.NullInt64
if err := edbAfter.QueryRow(`SELECT ZMERGEDTO FROM ZVISIT WHERE Z_PK=?`, refMerged).Scan(&mergedTo); err != nil {
t.Fatalf("导出包里找不到引用行 #%d: %v", refMerged, err)
}
if !mergedTo.Valid || int(mergedTo.Int64) != keeper1.srcPK {
t.Errorf("导出包里 ZMERGEDTO 应为 %d,实际 %v", keeper1.srcPK, mergedTo)
}
}
// TestDuplicateVisitsRefusesWrongBaseline 基线不可用 / 跟本站对不上时必须判定不了、且不给按钮。
//
// 这是这个功能唯一会造成不可逆损失的地方:照着对不上的参照跑,站内数据会被成片清掉
// (所有 UUID 都比不上 ⇒ 所有到访都算副本)。所以宁可什么都不做。
func TestDuplicateVisitsRefusesWrongBaseline(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID := f.ctx, f.dsID
srv := f.srv
path, err := srv.ensureBaseline(ctx, dsID)
if err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
// 缺基线:判定不了,而不是「没有重复」
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if _, unavail, err := srv.detectDuplicateVisits(ctx, dsID); err != nil || unavail == "" {
t.Errorf("基线缺失时应当判定不了(err=%v unavail=%q)", err, unavail)
}
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("重建基线库失败: %v", err)
}
// 基线里的 UUID 全换掉 ⇒ 跟本站一条都对不上
base, err := readDupBaseline(ctx, path)
if err != nil {
t.Fatal(err)
}
if len(base.uuids) == 0 {
t.Fatal("基线里没有 UUID,用例前提不成立")
}
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatal(err)
}
if _, err := sdb.Exec(`UPDATE ZVISIT SET ZIDENTIFIER_ = randomblob(16) WHERE ZIDENTIFIER_ IS NOT NULL`); err != nil {
sdb.Close()
t.Fatalf("改写基线库失败: %v", err)
}
if err := sdb.Close(); err != nil {
t.Fatal(err)
}
pairs, unavail, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil {
t.Fatalf("检测失败: %v", err)
}
if unavail == "" {
t.Fatalf("基线对不上时必须判定不了,却检出 %d 对", len(pairs))
}
if len(pairs) != 0 {
t.Errorf("判定不了时不该给出任何配对,实际 %d 对", len(pairs))
}
// 页面:只给一行说明,不给「清理」入口
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, unavail) {
t.Error("页面里没有显示判定不了的说明")
}
if strings.Contains(body, "/admin/edit/dup/clean") {
t.Error("判定不了时页面不该出现清理入口")
}
}
// TestDupCleanSkipsStaleSelection 提交「已经不是副本」的编号时必须拒绝,一条都不删。
//
// 渲染到点击之间库可能已经变过(另一处清理过、或用户拿着旧页面点),
// 照着表单里的编号删就是删错行。
func TestDupCleanSkipsStaleSelection(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
var startVisits int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits)
// 挑一条**不是**副本的到访(UUID 就在基线里)提交上去
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatal(err)
}
site, err := srv.loadDupSiteVisits(ctx, dsID)
if err != nil {
t.Fatal(err)
}
var innocent int
for _, sv := range site {
if sv.uuid != "" && base.uuids[sv.uuid] {
innocent = sv.srcPK
break
}
}
if innocent == 0 {
t.Skip("夹具里挑不出「UUID 在基线里」的到访")
}
r := httptest.NewRequest("POST", "/admin/edit/dup/clean",
strings.NewReader(url.Values{"victim": {fmt.Sprint(innocent)}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(ctx)
w := httptest.NewRecorder()
srv.editDuplicateVisitsApply(w, r)
if w.Code >= 500 {
t.Fatalf("处理失败: code=%d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "err=") {
t.Error("提交一条不是副本的到访应当被拒绝")
}
var n int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n)
if n != startVisits {
t.Errorf("被拒绝的提交不该删掉任何到访:%d → %d", startVisits, n)
}
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, innocent).Scan(&n)
if n != 1 {
t.Error("那条到访被误删了")
}
}
// TestDupCleanKeepsMergeKeysAsIs 静态守卫:改指合并标记必须「只动本来就有的那个键」。
//
// tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。
// 照抄的话,「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO——真机里那是
// 「这条已并入别处」的意思,会白丢一条记录。实测基线 956 条留档都带着这两个键、
// 只有 54 / 10 条有值,凭空补键很容易踩到。
// 另外 raw->> 取出来是 text,必须按字符串比(拿数字比会报「操作符不存在」,整个清理会失败)。
func TestDupCleanKeepsMergeKeysAsIs(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "edit_duplicates.go"))
if err != nil {
t.Fatalf("读 edit_duplicates.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) cleanDuplicateVisits(")
if !strings.Contains(body, "jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), false)") {
t.Error("改指合并标记必须用 create_if_missing=false,只动本来就存在的那个键")
}
if strings.Contains(body, "to_jsonb($3::int), true)") {
t.Error("不许用 create_if_missing=true:那会凭空补出副本本来没有的合并标记")
}
if !strings.Contains(body, "raw->>$4 = $2") {
t.Error("raw->> 取出来是 text,必须按字符串比")
}
if !strings.Contains(body, "s.pruneEntityRaw") {
t.Error("删完必须 pruneEntityRaw,否则留档里会留下指向已删到访的行")
}
}
+401
View File
@@ -0,0 +1,401 @@
package web
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// ---------- 重复到访:站点侧的历史副本 ----------
//
// 背景(2026-09-29):站点库里有一批**到达时间 + 地点与手机包里某条完全相同、但 UUID 不同**
// 的到访副本。差异页 / 合并都是按 UUID 认人的,于是它们被判成「备份新增」,
// 导出到 rond 后就是成对的两条重复记录。
//
// 判定与 tools/dedup_site_visits.py 是同一套语义(那里已经演练通过,两边不能各写一份):
// - 参照是这份数据集的**基线**(导入时留下的手机包底稿,见 baselinePath);
// - 站点到访的 UUID 在基线里找不到、而基线里有一条「到达时间 + 地点」完全相同的 → 副本。
//
// UUID 是前提:只按「时间 + 地点」判,会把手机包自身那些合法的成对条目一起删掉。
// appleEpoch 是 Core Data 的时间原点(2001-01-01 UTC)。基线的 ZARRIVALDATE_ 就是它起算的秒数。
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
// EditDupVisit 是「重复到访」卡片里的一对:站点侧的副本,与它该保留的那条(手机包里也有)。
type EditDupVisit struct {
Victim int // 站点侧副本的 src_pk —— 勾选后删的就是它
Keep int // 保留项的 src_pk —— UUID 在基线里能找到的那条
Arrival time.Time
PlaceName string
}
// ArrivalStr 是模板里显示的到达时间(本地时区,分钟精度)。
func (v EditDupVisit) ArrivalStr() string { return v.Arrival.Local().Format("2006-01-02 15:04") }
// dupKey 把「到达时间 + 地点」折成一个比较键。时间取到毫秒:副本与原件的到达时间本来
// 就是同一个瞬间,而基线与站点两侧都用这一个函数,取整方式一致就不会错配。
func dupKey(secs float64, who string) string {
return strconv.FormatInt(int64(math.Round(secs*1000)), 10) + "|" + who
}
// dupCoordKey 是地点坐标的比较键(6 位小数,与工具一致)。
func dupCoordKey(lat, lon float64) string { return fmt.Sprintf("%.6f|%.6f", lat, lon) }
// appleSeconds 把时间换算成 Core Data 的秒数(2001-01-01 UTC 起算),与基线的 ZARRIVALDATE_ 同系。
func appleSeconds(t time.Time) float64 { return t.UTC().Sub(appleEpoch).Seconds() }
// dupSet 只在键还没被占时写入。Python 那边用的是 dict.setdefault:同一个「时间 + 地点」
// 在库里有多条时取先到的那条,顺序照抄,两边结果才一致。
func dupSet[T any](m map[string]T, k string, v T) {
if _, ok := m[k]; !ok {
m[k] = v
}
}
// dupBaseline 是从基线库读出的判定依据。
type dupBaseline struct {
uuids map[string]bool // 基线里出现过的到访 UUID(小写 hex)
byKey map[string]string // 「到达时间 + 地点」→ 基线里那条的 UUID
}
// dupPlace 是基线里一个地点的名字与坐标。
type dupPlace struct {
name string
lat, lon float64
}
// readDupBaseline 只读打开基线库(Core Data 单文件),取出判定要用的两组信息。
//
// 驱动只认 _pragma / _time_format 这几个 DSN 参数,没有 mode=ro;基线库是 rollback journal
// 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住
// 「误写基线库」这条路——基线是导出的底稿,写坏了整个数据集就导不出去了。
func readDupBaseline(ctx context.Context, path string) (*dupBaseline, error) {
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
defer sdb.Close()
sdb.SetMaxOpenConns(1)
places, err := readDupPlaces(ctx, sdb)
if err != nil {
return nil, err
}
b := &dupBaseline{uuids: map[string]bool{}, byKey: map[string]string{}}
rows, err := sdb.QueryContext(ctx, `SELECT hex(ZIDENTIFIER_), ZARRIVALDATE_, ZLOCATION
FROM ZVISIT WHERE ZIDENTIFIER_ IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var uuid string
var arrival sql.NullFloat64
var loc sql.NullInt64
if err := rows.Scan(&uuid, &arrival, &loc); err != nil {
return nil, err
}
uuid = strings.ToLower(uuid)
if uuid == "" {
continue
}
b.uuids[uuid] = true
if !arrival.Valid || !loc.Valid {
continue
}
pl, ok := places[loc.Int64]
if !ok {
continue
}
dupSet(b.byKey, dupKey(arrival.Float64, pl.name), uuid)
if pl.lat != 0 || pl.lon != 0 {
dupSet(b.byKey, dupKey(arrival.Float64, dupCoordKey(pl.lat, pl.lon)), uuid)
}
}
return b, rows.Err()
}
func readDupPlaces(ctx context.Context, sdb *sql.DB) (map[int64]dupPlace, error) {
rows, err := sdb.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,''),
COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0) FROM ZLOCATION`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]dupPlace{}
for rows.Next() {
var pk int64
var pl dupPlace
if err := rows.Scan(&pk, &pl.name, &pl.lat, &pl.lon); err != nil {
return nil, err
}
out[pk] = pl
}
return out, rows.Err()
}
// siteDupVisit 是判定用的站点侧面貌。
type siteDupVisit struct {
srcPK int
uuid string // 留档里的 ZIDENTIFIER_(小写 hex),站点新建的行没有
arrival time.Time
place string
lat, lon float64
}
// loadDupSiteVisits 读出站点侧的到访(含留档里的 UUID 与所属地点的名称/坐标),按 src_pk 排序。
func (s *Server) loadDupSiteVisits(ctx context.Context, datasetID int64) ([]siteDupVisit, error) {
rows, err := s.db.QueryContext(ctx, `SELECT v.src_pk,
COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}',''),
v.arrival, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0)
FROM visits v
LEFT JOIN places p ON p.id = v.place_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 v.dataset_id=$1
ORDER BY v.src_pk`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []siteDupVisit
for rows.Next() {
var sv siteDupVisit
var b64 string
if err := rows.Scan(&sv.srcPK, &b64, &sv.arrival, &sv.place, &sv.lat, &sv.lon); err != nil {
return nil, err
}
sv.uuid = blobHex(b64)
out = append(out, sv)
}
return out, rows.Err()
}
// blobHex 把留档里的 base64 blob 转成小写 hex。基线侧用的是 hex(ZIDENTIFIER_),
// 两边同一个形式才比得上(大小写也要一致,否则一条都认不出来)。
func blobHex(s string) string {
if s == "" {
return ""
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(b) == 0 {
return ""
}
return strings.ToLower(fmt.Sprintf("%x", b))
}
// detectDuplicateVisits 检出站点侧的重复到访,并给出每条该保留哪一行。
//
// 第二个返回值非空表示**判定不了**(基线不可用 / 跟本站到访对不上),此时页面只给一行
// 说明、不给按钮——宁可不动,也不能拿一份对不上的参照去删用户的数据。
func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([]EditDupVisit, string, error) {
path := s.baselinePath(datasetID)
if _, err := os.Stat(path); err != nil {
return nil, "这份数据集还没有可用的基线库(当初上传的原始 .rondbackup 已不在),没法当参照,判定不了重复到访。", nil
}
base, err := readDupBaseline(ctx, path)
if err != nil {
log.Printf("读取基线库失败(数据集 %d,%s): %v", datasetID, path, err)
return nil, "读取基线库失败,判定不了重复到访。", nil
}
site, err := s.loadDupSiteVisits(ctx, datasetID)
if err != nil {
return nil, "", err
}
var withUUID, matched int
for _, sv := range site {
if sv.uuid == "" {
continue
}
withUUID++
if base.uuids[sv.uuid] {
matched++
}
}
// 两道闸都防着「拿一份不相干的库当参照」:基线的 UUID 一个都比不上时,
// 所有站内到访都会被算成副本,照着删就是把整库搬走。
if len(base.uuids) == 0 {
return nil, "基线里没有带 UUID 的到访,没法当参照,判定不了重复到访。", nil
}
if withUUID > 0 && matched == 0 {
return nil, "基线跟本站到访一条都对不上,它多半不是这份数据集的手机包,判定不可靠,先不动它。", nil
}
// 保留项:站点侧 UUID 在基线里能找到的那些(手机包里也有这条),按「时间 + 地点」建索引。
keepByKey := map[string]int{}
for _, sv := range site {
if sv.uuid == "" || !base.uuids[sv.uuid] {
continue
}
secs := appleSeconds(sv.arrival)
dupSet(keepByKey, dupKey(secs, sv.place), sv.srcPK)
if sv.lat != 0 || sv.lon != 0 {
dupSet(keepByKey, dupKey(secs, dupCoordKey(sv.lat, sv.lon)), sv.srcPK)
}
}
var out []EditDupVisit
for _, sv := range site {
if sv.uuid == "" || base.uuids[sv.uuid] {
continue
}
secs := appleSeconds(sv.arrival)
byName, byCoord := dupKey(secs, sv.place), ""
if sv.lat != 0 || sv.lon != 0 {
byCoord = dupKey(secs, dupCoordKey(sv.lat, sv.lon))
}
// 基线里得有「同一时间同一地点」那条,否则这不是副本,是站点自己新增的到访
if base.byKey[byName] == "" && (byCoord == "" || base.byKey[byCoord] == "") {
continue
}
// 站点里也得有那条保留项:没有就无从改指,删了就是净丢一条记录
keep, ok := keepByKey[byName]
if !ok && byCoord != "" {
keep, ok = keepByKey[byCoord]
}
if !ok {
continue
}
out = append(out, EditDupVisit{Victim: sv.srcPK, Keep: keep, Arrival: sv.arrival, PlaceName: sv.place})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Arrival.After(out[j].Arrival) })
return out, "", nil
}
// editDuplicateVisitsApply 清理勾选的重复到访副本。
func (s *Server) editDuplicateVisitsApply(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectEdit(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
want := map[int]bool{}
for _, v := range r.PostForm["victim"] {
if n, cerr := strconv.Atoi(strings.TrimSpace(v)); cerr == nil && n > 0 {
want[n] = true
}
}
if len(want) == 0 {
redirectEdit(w, r, "err", "没有勾选要清理的副本")
return
}
// 编号不照着表单删:渲染到点击之间库可能已经变过,这里重算一遍判定,
// 只有当下**仍被判为副本**的编号才会动。
pairs, unavailable, err := s.detectDuplicateVisits(ctx, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if unavailable != "" {
redirectEdit(w, r, "err", unavailable)
return
}
var picked []EditDupVisit
for _, p := range pairs {
if want[p.Victim] {
picked = append(picked, p)
}
}
if len(picked) == 0 {
redirectEdit(w, r, "err", "选中的记录已经不是副本了(库里可能已经变过),请刷新页面重试")
return
}
if err := s.withTx(ctx, func(ex execer) error {
return s.cleanDuplicateVisits(ctx, ex, ds.ID, picked)
}); err != nil {
s.serverError(w, r, err)
return
}
redirectEdit(w, r, "ok", fmt.Sprintf("已清理 %d 条重复到访", len(picked)))
}
// cleanDuplicateVisits 删掉一批副本,并把指向它们的合并标记改指到保留项。
//
// 全程在一个事务里:分开提交会留下「到访没了、留档还指着它」的悬空引用,
// 导出到 rond 里就是坏数据(到访那条 ZMERGEDTO 指向一个不存在的 Z_PK)。
func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID int64, pairs []EditDupVisit) error {
victims := make([]int64, 0, len(pairs))
for _, p := range pairs {
victims = append(victims, int64(p.Victim))
}
// 聚合要按地点重算,先把副本所属地点收下来(删完就在 visits 里查不到了)
placeIDs, err := s.dupVictimPlaces(ctx, ex, datasetID, victims)
if err != nil {
return err
}
// 改指合并标记时**只动本来就存在的那一个键**(jsonb_set 的 create_if_missing=false),
// 与 tools/dedup_site_visits.py 有一处刻意的不同:工具把两个键都写成保留项,于是
// 「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO——那在真机里是「这条已并入
// 别处」的意思,会白丢一条记录。实测基线里 956 条留档都带着这两个键,但只有 54 / 10
// 条有值,凭空补键是很容易踩到的。
//
// raw->> 取出来是 text,必须按字符串比(拿数字去比会报「text = integer 操作符不存在」)。
for _, p := range pairs {
for _, col := range []string{"ZMERGEDTO", "ZPARENT"} {
if _, err := ex.ExecContext(ctx, `UPDATE entity_raw
SET raw = jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), false)
WHERE dataset_id=$1 AND entity='ZVISIT' AND raw->>$4 = $2`,
datasetID, strconv.Itoa(p.Victim), p.Keep, col); err != nil {
return err
}
}
}
// 副本的附属记录:位移(两端任一)、天气。到访本身删掉后,它自己的留档与标签关联
// 分别由 pruneEntityRaw 与 visit_tags 的外键级联清掉。
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src = ANY($2::bigint[]) OR to_visit_src = ANY($2::bigint[]))`,
datasetID, victims); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM weather WHERE dataset_id=$1 AND visit_src = ANY($2::bigint[])`,
datasetID, victims); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[])`,
datasetID, victims); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, datasetID)
for _, id := range placeIDs {
if err := s.refreshPlaceAgg(ctx, ex, id); err != nil {
return err
}
}
return s.refreshDatasetAgg(ctx, ex, datasetID)
}
// dupVictimPlaces 是这批副本所属的地点 id(不含 place_id 为空的)。
func (s *Server) dupVictimPlaces(ctx context.Context, ex execer, datasetID int64, victims []int64) ([]int64, error) {
rows, err := ex.QueryContext(ctx, `SELECT DISTINCT place_id FROM visits
WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[]) AND place_id IS NOT NULL`, datasetID, victims)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
+25 -4
View File
@@ -15,6 +15,7 @@ import (
"rond-with-you/internal/geo" "rond-with-you/internal/geo"
"rond-with-you/internal/rond" "rond-with-you/internal/rond"
"rond-with-you/internal/stats"
) )
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ---------- // ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
@@ -68,6 +69,13 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
} else { } else {
d.CanExport = true d.CanExport = true
} }
// 重复到访(站点侧的历史副本)以基线为参照,见 edit_duplicates.go。
// 这里只读基线库与站点数据,不改任何东西;判定不了时 d.DupUnavailable 非空。
if pairs, unavailable, err := s.detectDuplicateVisits(ctx, ds.ID); err != nil {
log.Printf("检测重复到访失败(数据集 %d): %v", ds.ID, err)
} else {
d.DupVisits, d.DupUnavailable = pairs, unavailable
}
// 活动类型与标签 // 活动类型与标签
if d.Activities, err = s.editOptions(ctx, `SELECT a.id, COALESCE(a.name,'') if d.Activities, err = s.editOptions(ctx, `SELECT a.id, COALESCE(a.name,'')
@@ -275,8 +283,13 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
vargs = append(vargs, editPerPage, (d.VisitPageNo-1)*editPerPage) vargs = append(vargs, editPerPage, (d.VisitPageNo-1)*editPerPage)
vrows, err := s.db.QueryContext(ctx, `SELECT v.src_pk, COALESCE(p.name,''), COALESCE(p.city,''), 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,''), 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 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)), WHERE `+vwhere+fmt.Sprintf(` ORDER BY v.arrival DESC LIMIT $%d OFFSET $%d`, len(vargs)-1, len(vargs)),
vargs...) vargs...)
if err != nil { if err != nil {
@@ -287,7 +300,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
var e EditVisit var e EditVisit
var dep sql.NullTime var dep sql.NullTime
if err := vrows.Scan(&e.SrcPK, &e.PlaceName, &e.City, &e.ActivityName, &e.ActivityID, 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() vrows.Close()
s.serverError(w, r, err) s.serverError(w, r, err)
return return
@@ -2254,13 +2267,21 @@ func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64,
} }
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。 // 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 { func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE places p SET if _, err := ex.ExecContext(ctx, `UPDATE places p SET
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0), visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
first_visit_at = v.f, last_visit_at = v.l first_visit_at = v.f, last_visit_at = v.l
FROM (SELECT $1::bigint AS pid) x 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 LEFT JOIN (SELECT vis.place_id,
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid 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 { WHERE p.id = x.pid`, placeID); err != nil {
return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err) return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err)
} }
+3 -2
View File
@@ -867,7 +867,7 @@ func (s *Server) firstArrivalCities(ctx context.Context, datasetID int64, from,
regSQL := regionSQL("p", regions, exclude, &args) regSQL := regionSQL("p", regions, exclude, &args)
q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''), min(v.arrival)::date q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''), min(v.arrival)::date
FROM visits v JOIN places p ON p.id = v.place_id 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 <> ''` + regSQL + ` WHERE v.dataset_id = $1 AND p.city IS NOT NULL AND p.city <> '' AND ` + stats.SoloVisitSQL("v") + regSQL + `
GROUP BY p.city, p.province GROUP BY p.city, p.province
HAVING min(v.arrival) >= $2 AND min(v.arrival) < $3 HAVING min(v.arrival) >= $2 AND min(v.arrival) < $3
ORDER BY min(v.arrival) ASC` ORDER BY min(v.arrival) ASC`
@@ -899,7 +899,8 @@ func (s *Server) extremePlace(ctx context.Context, datasetID int64, from, to tim
args := []any{datasetID, from, to} args := []any{datasetID, from, to}
q := `SELECT ` + stats.PlaceCols + ` FROM places p q := `SELECT ` + stats.PlaceCols + ` FROM places p
WHERE p.dataset_id=$1 AND p.lat IS NOT NULL WHERE p.dataset_id=$1 AND p.lat IS NOT NULL
AND EXISTS (SELECT 1 FROM visits v WHERE v.place_id=p.id AND v.arrival >= $2 AND v.arrival < $3)` + AND EXISTS (SELECT 1 FROM visits v WHERE v.place_id=p.id AND v.arrival >= $2 AND v.arrival < $3
AND ` + stats.SoloVisitSQL("v") + `)` +
regionSQL("p", regions, exclude, &args) + ` regionSQL("p", regions, exclude, &args) + `
ORDER BY p.lat ` + dir + ` LIMIT 1` ORDER BY p.lat ` + dir + ` LIMIT 1`
if err := stats.ScanPlace(s.db.QueryRowContext(ctx, q, args...), &p); err != nil { if err := stats.ScanPlace(s.db.QueryRowContext(ctx, q, args...), &p); err != nil {
+7 -5
View File
@@ -12,6 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"rond-with-you/internal/stats"
) )
// 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」, // 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」,
@@ -118,14 +120,14 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
COALESCE(p.city,''), COALESCE(p.city,''),
COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL AND ` + stats.SoloVisitSQL("v2") + `
GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''), GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL AND ` + stats.SoloVisitSQL("v2") + `
GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''), GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival) count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival)
FROM places p JOIN visits v ON v.place_id = p.id FROM places p JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3` + area + WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3 AND ` + stats.SoloVisitSQL("v") + area +
regionSQL("p", regions, exclude, &args) + ` regionSQL("p", regions, exclude, &args) + `
GROUP BY p.id ORDER BY count(*) DESC, p.id` GROUP BY p.id ORDER BY count(*) DESC, p.id`
rows, err := s.db.QueryContext(ctx, q, args...) rows, err := s.db.QueryContext(ctx, q, args...)
@@ -168,7 +170,7 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
daysRegion := regionSQL("p", regions, exclude, &dargs) daysRegion := regionSQL("p", regions, exclude, &dargs)
if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date) if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date)
FROM visits v JOIN places p ON p.id = v.place_id FROM visits v JOIN places p ON p.id = v.place_id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3`+area+daysRegion, dargs...).Scan(&days); err != nil { WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3 AND `+stats.SoloVisitSQL("v")+area+daysRegion, dargs...).Scan(&days); err != nil {
return nil, 0, 0, 0, err return nil, 0, 0, 0, err
} }
return points, totalVisits, days, len(cities), nil return points, totalVisits, days, len(cities), nil
@@ -201,7 +203,7 @@ func (s *Server) fillTopicStatsBatch(ctx context.Context, list []Topic, datasetI
count(DISTINCT NULLIF(p.city,'')), count(DISTINCT NULLIF(p.city,'')),
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0) COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0)
FROM places p JOIN visits v ON v.place_id = p.id FROM places p JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d%s%s`, WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d AND `+stats.SoloVisitSQL("v")+`%s%s`,
i, base+1, base+2, base+3, area, region) i, base+1, base+2, base+3, area, region)
} }
rows, err := s.db.QueryContext(ctx, b.String(), args...) rows, err := s.db.QueryContext(ctx, b.String(), args...)
+250
View File
@@ -0,0 +1,250 @@
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/db"
"rond-with-you/internal/stats"
)
// 真机里「不独立成立」的到访(已并入别处 ZMERGEDTO / 某个父段的子段 ZPARENT)只在**读**的一侧收口:
// 时间线、统计、地点计数不显示它们,编辑页照样看得到(打标),而导出的包一个字节都不能少
// ——rond 靠这些行认「这次合并发生过」,删掉或丢掉就是毁真机的记录。
// exportVisitRow 在导出包里按 UUID 找一行到访,返回它在包里的 Z_PK 与 ZMERGEDTO。
// 必须按 UUID 找:(编号也要对,但按编号找会因为对齐而误判)。
func exportVisitRow(t *testing.T, edb *sql.DB, uuidHex string) (int, sql.NullInt64, bool) {
t.Helper()
var pk int
var merged sql.NullInt64
err := edb.QueryRow(`SELECT Z_PK, ZMERGEDTO FROM ZVISIT WHERE hex(ZIDENTIFIER_)=?`,
strings.ToUpper(uuidHex)).Scan(&pk, &merged)
if err == sql.ErrNoRows {
return 0, merged, false
}
if err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return pk, merged, true
}
func TestHiddenVisitStaysInExportButLeavesReads(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
// 夹具导入的是真机包,本来就带这类行;没有的话下面的增量断言就不成立
var fixtureHidden int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT'
AND (raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL)`, dsID).Scan(&fixtureHidden); err != nil {
t.Fatal(err)
}
t.Logf("夹具自带「不独立成立」的到访 %d 条", fixtureHidden)
// 挑一条正常到访,把它标成「已并入 #target」
var victim, target int
var placeID int64
var durMin int
if err := pg.QueryRowContext(ctx, `SELECT v.src_pk, COALESCE(v.place_id,0), COALESCE(v.duration_min,0)
FROM visits v JOIN entity_raw er ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
WHERE v.dataset_id=$1 AND v.place_id IS NOT NULL
AND er.raw->>'ZMERGEDTO' IS NULL AND er.raw->>'ZPARENT' IS NULL
ORDER BY v.src_pk DESC LIMIT 1`, dsID).Scan(&victim, &placeID, &durMin); err != nil {
t.Fatalf("挑不出可标记的到访: %v", err)
}
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM visits
WHERE dataset_id=$1 AND src_pk<>$2 ORDER BY src_pk LIMIT 1`, dsID, victim).Scan(&target); err != nil {
t.Fatal(err)
}
victimUUID := visitUUIDHex(t, srv, ctx, dsID, victim)
if victimUUID == "" {
t.Fatal("挑中的到访没有留档 UUID,用例前提不成立")
}
// 标记前:访客侧看得到它,导出包里它没有合并标记
flt := stats.Filter{DatasetID: dsID}
_, totalBefore, err := srv.q.Visits(ctx, flt, 5, 0, "desc")
if err != nil {
t.Fatal(err)
}
var pcBefore, dwellBefore int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count, dwell_minutes FROM places WHERE id=$1`, placeID).
Scan(&pcBefore, &dwellBefore); err != nil {
t.Fatal(err)
}
var mvCount int
var mvKm float64
pg.QueryRowContext(ctx, `SELECT count(*), COALESCE(sum(distance_km),0) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&mvCount, &mvKm)
var dsCountBefore int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCountBefore)
edbBefore := openExport(t, f.export(t))
if _, merged, ok := exportVisitRow(t, edbBefore, victimUUID); !ok {
t.Fatalf("标记前导出包里就找不到 #%d", victim)
} else if merged.Valid {
t.Fatalf("标记前 ZMERGEDTO 就不该有值,实际 %v", merged.Int64)
}
// 标记成「已并入 #target」
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, victim, target); err != nil {
t.Fatalf("标记失败: %v", err)
}
// 1) 访客侧:时间线少了一条,且被标记的那条不再出现
rows, totalAfter, err := srv.q.Visits(ctx, flt, 5, 0, "desc")
if err != nil {
t.Fatal(err)
}
if totalAfter != totalBefore-1 {
t.Errorf("时间线总数应为 %d,实际 %d", totalBefore-1, totalAfter)
}
for _, r := range rows {
if r.SrcPK == victim {
t.Errorf("被标记为「已并入」的 #%d 仍出现在时间线里", victim)
}
}
// 地点详情页的到访列表、收藏列表走的是另外两条自建 where,也要一起收口
if vs, err := srv.q.VisitsByPlace(ctx, dsID, placeID, 500); err != nil {
t.Fatal(err)
} else {
for _, v := range vs {
if v.SrcPK == victim {
t.Errorf("地点页的到访列表里仍有 #%d", victim)
}
}
}
// 2) 地点的到访数 / 停留跟着少(编辑页删改之后也会重算,口径必须一致)
if err := srv.refreshPlaceAgg(ctx, pg, placeID); err != nil {
t.Fatalf("重算地点聚合失败: %v", err)
}
var pcAfter, dwellAfter int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count, dwell_minutes FROM places WHERE id=$1`, placeID).
Scan(&pcAfter, &dwellAfter); err != nil {
t.Fatal(err)
}
capped := durMin
if capped > 10080 {
capped = 10080
}
if pcAfter != pcBefore-1 || dwellAfter != dwellBefore-int64(capped) {
t.Errorf("地点聚合应为 %d 次 / %d 分,实际 %d 次 / %d 分",
pcBefore-1, dwellBefore-int64(capped), pcAfter, dwellAfter)
}
// 3) 位移(里程 / 轨迹)不动:那条到访不单独显示,但它参与过的行程真实发生过
var mvCountAfter int
var mvKmAfter float64
pg.QueryRowContext(ctx, `SELECT count(*), COALESCE(sum(distance_km),0) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&mvCountAfter, &mvKmAfter)
if mvCountAfter != mvCount || mvKmAfter != mvKm {
t.Errorf("位移不该受影响:%d 段 / %.3f km → %d 段 / %.3f km", mvCount, mvKm, mvCountAfter, mvKmAfter)
}
// 数据集计数是后台口径(原始行数),不跟着访客口径变
var dsCountAfter int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCountAfter)
if dsCountAfter != dsCountBefore {
t.Errorf("datasets.visit_count 是后台口径,不该变成 %d(原 %d)", dsCountAfter, dsCountBefore)
}
// 4) 标了之后仍然导得出去,而且这一行连合并标记一起原样在包里
edbAfter := openExport(t, f.export(t))
pk, merged, ok := exportVisitRow(t, edbAfter, victimUUID)
if !ok {
t.Fatalf("导出包里丢了被标记的到访 #%d —— 读侧收口绝不能影响导出", victim)
}
if !merged.Valid || int(merged.Int64) != target {
t.Errorf("导出包里 ZMERGEDTO 应为 #%d,实际 %v", target, merged)
}
if pk != victim {
t.Errorf("包内编号应为 %d,实际 %d", victim, pk)
}
// 5) 管理端(数据编辑页)照样看得到它,并带上说明——不然站长会以为数据丢了
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", fmt.Sprintf("/admin/edit?vq=%d", victim), nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf("已并入 #%d", target)) {
t.Errorf("编辑页没有给 #%d 打上「已并入 #%d」的标记", victim, target)
}
}
// TestHiddenVisitBackfillFixesStaleAggregates 存量库的那段回填(schema.sql)必须把旧口径的数字拉正。
//
// 只在读的一侧收口的话,已经算好的 places.visit_count / dwell_minutes 会一直偏大
// (要等到有人编辑那个地点才重算),所以迁移里带一段幂等的重算。这里真的跑一次 Migrate,
// 而不是另抄一份 SQL——抄一份就等于没测那段。
func TestHiddenVisitBackfillFixesStaleAggregates(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 造出「旧口径」的偏大值:假设那条已并入的到访也被算进去了
var placeID int64
var want int64
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.visit_count FROM places p
WHERE p.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v JOIN entity_raw er
ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
WHERE v.place_id=p.id AND er.raw->>'ZMERGEDTO' IS NOT NULL) LIMIT 1`, dsID).Scan(&placeID, &want); err != nil {
t.Skipf("夹具里没有「已并入」的到访(%v)", err)
}
if _, err := pg.ExecContext(ctx, `UPDATE places SET visit_count=$2, dwell_minutes=dwell_minutes+1 WHERE id=$1`,
placeID, want+1); err != nil {
t.Fatal(err)
}
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("跑迁移失败: %v", err)
}
var got int64
if err := pg.QueryRowContext(ctx, `SELECT visit_count FROM places WHERE id=$1`, placeID).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("回填后应回到 %d 次,实际 %d 次——存量库的旧口径没被拉正", want, got)
}
}
// TestSoloVisitCondSharedAndOffTheExport 静态守卫:这条判据只能有一份,而且不许出现在导出 / 差异侧。
//
// 分散手抄迟早漂移(区域限制那套就漏过一次,表现是「到访藏了里程还在」);
// 反过来,导出与合并一旦用了它,包里就会少掉真机自己的行。
func TestSoloVisitCondSharedAndOffTheExport(t *testing.T) {
root := moduleRoot(t)
read := func(rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("读 %s 失败: %v", rel, err)
}
return string(b)
}
// 导出与差异必须看到全部行
for _, rel := range []string{"internal/rond/export.go", "internal/web/backup_diff.go"} {
if strings.Contains(read(rel), "SoloVisitSQL") {
t.Errorf("%s 不该用 SoloVisitSQL:导出与差异要看到真机的全部行", rel)
}
}
// 读的一侧共用同一份判据
for _, rel := range []string{
"internal/stats/stats.go", "internal/ingest/ingest.go",
"internal/web/handlers_public.go", "internal/web/handlers_topics.go",
"internal/web/handlers_edit.go", "internal/web/backup_merge.go",
} {
if !strings.Contains(read(rel), "SoloVisitSQL") {
t.Errorf("%s 没有用 SoloVisitSQL:到访数与时间线会对不上", rel)
}
}
}
+2
View File
@@ -272,6 +272,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport)) s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete)) s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete))
s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark)) s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark))
// 重复到访:清掉站点侧跟手机包重复的历史副本(判定见 edit_duplicates.go)
s.mux.HandleFunc("POST /admin/edit/dup/clean", s.requireAdmin(s.editDuplicateVisitsApply))
s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate)) s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate))
s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate)) s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate))
s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete)) s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete))
+46 -1
View File
@@ -346,6 +346,51 @@
{{else}}<div class="empty">没有匹配的地点</div>{{end}} {{else}}<div class="empty">没有匹配的地点</div>{{end}}
</div> </div>
{{/* 重复到访:站点侧跟手机包「同一到达时间 + 同一地点」的历史副本。判定见
edit_duplicates.go(与 tools/dedup_site_visits.py 同一套语义)。
只在检出 >0 对时出现;判定不了时只给一行说明、不给按钮。 */}}
{{if .DupUnavailable}}
<div class="card" data-card="dup-visits">
<h2>重复到访</h2>
<div class="card-sub">{{.DupUnavailable}}</div>
</div>
{{else if .DupVisits}}
<div class="card" data-card="dup-visits">
<h2>重复到访 {{num (len .DupVisits)}} 对</h2>
<div class="card-sub">
这些到访跟手机包里的某条<b>到达时间 + 地点完全相同、但 UUID 不同</b>,是站点侧的历史副本,
导出到 rond 后会变成成对的两条。默认全选:删掉勾选的副本会连它的位移 / 天气 / 留档一起清,
并把指向它的合并标记改指到保留项(不动就会在导出时留下悬空引用)。
</div>
<form method="post" action="/admin/edit/dup/clean">
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>到达时间</th><th>地点</th><th>保留(手机来源)</th><th>删除(站点副本)</th></tr></thead>
<tbody>
{{range .DupVisits}}
<tr>
<td class="muted mono" data-label="到达时间">{{.ArrivalStr}}</td>
<td data-label="地点">{{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">(未命名地点)</span>{{end}}</td>
<td class="mono" data-label="保留">#{{.Keep}}</td>
<td data-label="删除">
<label class="check"><input type="checkbox" name="victim" value="{{.Victim}}" checked>
<span class="mono">#{{.Victim}}</span></label>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="row" style="margin-top:10px;gap:8px;align-items:center">
<button class="btn primary" type="submit"
onclick="return confirm('删除勾选的副本?连它们的位移 / 天气 / 留档一起清掉,不可撤销。')">清理选中的副本</button>
<a class="btn" href="{{.EditLink}}">全部保留</a>
<span class="muted">「全部保留」什么都不改,只是取消勾选。</span>
</div>
</form>
</div>
{{end}}
<div class="card" data-card="visits"> <div class="card" data-card="visits">
<h2>到访记录</h2> <h2>到访记录</h2>
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px"> <form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
@@ -371,7 +416,7 @@
{{range .Visits}} {{range .Visits}}
<tr> <tr>
<td class="muted mono">{{.ArrivalStr}}{{if .DepartureStr}}<br>→ {{.DepartureStr}}{{else}}<br><span class="muted">(未离开)</span>{{end}}</td> <td class="muted mono">{{.ArrivalStr}}{{if .DepartureStr}}<br>→ {{.DepartureStr}}{{else}}<br><span class="muted">(未离开)</span>{{end}}</td>
<td data-label="地点"><span class="muted mono">#{{.SrcPK}}</span> {{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">—</span>{{end}}{{if .City}} <span class="muted">· {{.City}}</span>{{end}}</td> <td data-label="地点"><span class="muted mono">#{{.SrcPK}}</span> {{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">—</span>{{end}}{{if .City}} <span class="muted">· {{.City}}</span>{{end}}{{if .Hidden}} <span class="tag" title="真机里这次到访不独立成立(已并入别处 / 是某个父段的一部分),时间线与统计不显示它">{{.Hidden}}</span>{{end}}</td>
<td class="muted" data-label="类型">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td> <td class="muted" data-label="类型">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
<td class="muted" data-label="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}} <td class="muted" data-label="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline"> <form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
+8
View File
@@ -808,6 +808,11 @@ type EditData struct {
// OpenSrcPK 是「从地点详情页跳进来要定位的地点」(?open=<src_pk>):非 0 时自动翻到 // OpenSrcPK 是「从地点详情页跳进来要定位的地点」(?open=<src_pk>):非 0 时自动翻到
// 该行所在页、展开它的编辑框并高亮,省得在几百行地点里手动搜 // 该行所在页、展开它的编辑框并高亮,省得在几百行地点里手动搜
OpenSrcPK int OpenSrcPK int
// DupVisits 是「重复到访」卡片检出的站点副本(与保留项的配对),空的就没有卡片
DupVisits []EditDupVisit
// DupUnavailable 非空表示判定不了(基线不可用 / 跟本站到访对不上),
// 此时只显示一行说明、不给按钮——拿一份对不上的参照去删到访比不判更糟
DupUnavailable string
} }
// PlaceSources 供模板渲染筛选下拉,顺序固定(与 placeSourceLabels 一致)。 // PlaceSources 供模板渲染筛选下拉,顺序固定(与 placeSourceLabels 一致)。
@@ -1051,6 +1056,9 @@ type EditVisit struct {
Emoji string Emoji string
Bookmarked bool Bookmarked bool
UserAdded bool UserAdded bool
// Hidden 非空表示这一行在真机里不独立成立(「已并入 #562」/「子段 #374」):
// 访客侧(时间线、统计、地点计数)不显示它,编辑页要看得到全部原始行,所以只打标不过滤
Hidden string
} }
type SettingsData struct { type SettingsData struct {