口径:已并入 / 子段的到访不再计入时间线与统计(导出侧一个字节不动)
真机里两行到访可能「不独立成立」: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:
@@ -14,6 +14,7 @@ import (
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/ingest"
|
||||
"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 重算地点聚合与数据集汇总。全量重算而不是只动受影响的行:
|
||||
// 367 个地点两条 SQL 就够,省得漏掉任何一条派生列。
|
||||
//
|
||||
// 到访数与停留按**访客口径**(不含已并入 / 子段,见 stats.SoloVisitSQL),与 stats.Filter 一致;
|
||||
// 首末到访与数据集计数保持全量(last_visit_at 会写进导出包,datasets.visit_count 是后台口径)。
|
||||
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,
|
||||
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,
|
||||
first_visit_at=sub.f, last_visit_at=sub.l
|
||||
FROM (SELECT place_id, count(*) c,
|
||||
COALESCE(sum(LEAST(COALESCE(duration_min,0),10080)),0) d,
|
||||
min(arrival) f, max(arrival) l
|
||||
FROM visits WHERE dataset_id=$1 AND place_id IS NOT NULL GROUP BY place_id) sub
|
||||
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`, dsID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ type dupPlace struct {
|
||||
// 模式(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)")
|
||||
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -867,7 +867,7 @@ func (s *Server) firstArrivalCities(ctx context.Context, datasetID int64, from,
|
||||
regSQL := regionSQL("p", regions, exclude, &args)
|
||||
q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''), min(v.arrival)::date
|
||||
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
|
||||
HAVING min(v.arrival) >= $2 AND min(v.arrival) < $3
|
||||
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}
|
||||
q := `SELECT ` + stats.PlaceCols + ` FROM places p
|
||||
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) + `
|
||||
ORDER BY p.lat ` + dir + ` LIMIT 1`
|
||||
if err := stats.ScanPlace(s.db.QueryRowContext(ctx, q, args...), &p); err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"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),
|
||||
COALESCE(p.city,''),
|
||||
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),''),
|
||||
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),''),
|
||||
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
|
||||
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) + `
|
||||
GROUP BY p.id ORDER BY count(*) DESC, p.id`
|
||||
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)
|
||||
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
|
||||
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 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,'')),
|
||||
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0)
|
||||
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)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, b.String(), args...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,7 +416,7 @@
|
||||
{{range .Visits}}
|
||||
<tr>
|
||||
<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="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
|
||||
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
|
||||
|
||||
@@ -1056,6 +1056,9 @@ type EditVisit struct {
|
||||
Emoji string
|
||||
Bookmarked bool
|
||||
UserAdded bool
|
||||
// Hidden 非空表示这一行在真机里不独立成立(「已并入 #562」/「子段 #374」):
|
||||
// 访客侧(时间线、统计、地点计数)不显示它,编辑页要看得到全部原始行,所以只打标不过滤
|
||||
Hidden string
|
||||
}
|
||||
|
||||
type SettingsData struct {
|
||||
|
||||
Reference in New Issue
Block a user