审计修复:后端(安全 / 数据一致性 / 性能)

audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
tamakyi
2026-09-24 20:37:16 +08:00
parent 78865fc736
commit b4c2a8452f
70 changed files with 5417 additions and 360 deletions
+169
View File
@@ -0,0 +1,169 @@
package stats_test
import (
"context"
"regexp"
"strings"
"testing"
"rond-with-you/internal/stats"
)
// placeCols 里每个相关子查询都必须带 `<别名>.dataset_id = p.dataset_id`:
// idx_visits_place 是 (dataset_id, place_id),缺了前导列,每轮循环都要为库里
// 每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669,耗时 53ms → 31ms)。
// 谓词本身冗余,删掉不会报错、也不会算错,只会悄悄变慢 —— 所以只能这么钉。
func TestPlaceColsCarriesDatasetIDInCorrelatedSubqueries(t *testing.T) {
// 去掉全部空白再找,免得因为换行/缩进/等号两边空格的差异误报。
compact := strings.NewReplacer(" ", "", "\n", "", "\t", "", "\r", "").Replace(stats.PlaceCols)
// 必须按子查询分段判断:v2 出现三次,全文 Contains 会被另外两处顶包。
hits := regexp.MustCompile(`FROMvisits(v\d+)`).FindAllStringSubmatchIndex(compact, -1)
if len(hits) != 5 {
t.Fatalf("placeCols 里的 visits 子查询数量变成 %d(期望 5),请同步更新本用例", len(hits))
}
for i, m := range hits {
alias := compact[m[2]:m[3]]
end := len(compact)
if i+1 < len(hits) {
end = hits[i+1][0]
}
if seg := compact[m[1]:end]; !strings.Contains(seg, alias+".dataset_id=p.dataset_id") {
t.Errorf("placeCols 中 %s 的子查询缺少 %s.dataset_id=p.dataset_id(会导致索引前导列缺失)", alias, alias)
}
}
}
// placeCols 里 top 活动三轴(name/color/icon)是三个**互相独立**的众数,
// 不能合并成一个 GROUP BY:合并后同一活动名下的颜色/图标会跟着最多的那一行走,
// 而独立众数允许「名字取 A、颜色取 B」。这里用 Go 侧独立数一遍钉住语义。
// SQL 的并列取法是未定义的,所以只在「三轴都存在唯一严格最大值」的地点上断言。
func TestPlaceColsTopActivityMatchesIndependentCount(t *testing.T) {
db, dsID := setup(t)
ctx := context.Background()
d := stats.DB{DB: db}
type counts map[string]int
type placeAgg struct {
name, color, icon counts
home, work bool
}
aggs := map[int64]*placeAgg{}
var placeIDs []int64
rows, err := db.QueryContext(ctx, `SELECT id FROM places WHERE dataset_id=$1`, dsID)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
placeIDs = append(placeIDs, id)
aggs[id] = &placeAgg{name: counts{}, color: counts{}, icon: counts{}}
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
// 有活动的到访:top 三轴的独立计数。
rows, err = db.QueryContext(ctx, `SELECT v.place_id, a.name, a.color, a.icon
FROM visits v JOIN activities a ON a.id=v.activity_id WHERE v.dataset_id=$1`, dsID)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var pid int64
var name, color, icon string
if err := rows.Scan(&pid, &name, &color, &icon); err != nil {
t.Fatal(err)
}
a := aggs[pid]
if a == nil {
continue
}
a.name[name]++
a.color[color]++
a.icon[icon]++
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
// is_home / is_work 来自 visits 本身,不 join activities。
for _, tc := range []struct {
q string
mark func(*placeAgg)
}{
{`SELECT place_id FROM visits WHERE dataset_id=$1 AND is_home`, func(a *placeAgg) { a.home = true }},
{`SELECT place_id FROM visits WHERE dataset_id=$1 AND is_work`, func(a *placeAgg) { a.work = true }},
} {
rows, err := db.QueryContext(ctx, tc.q, dsID)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var pid int64
if err := rows.Scan(&pid); err != nil {
t.Fatal(err)
}
if a := aggs[pid]; a != nil {
tc.mark(a)
}
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
got, err := d.MapPlaces(ctx, stats.Filter{DatasetID: dsID}, 4000)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Skip("MapPlaces 返回空,无法比对")
}
strict := func(c counts) (string, bool) { // 唯一严格众数
best, n, tie := "", 0, false
for v, k := range c {
switch {
case k > n:
best, n, tie = v, k, false
case k == n:
tie = true
}
}
return best, best != "" && !tie
}
checked := 0
for _, p := range got {
a := aggs[p.ID]
if a == nil {
t.Fatalf("MapPlaces 返回了不属于本数据集的地点 %d", p.ID)
}
if p.IsHome != a.home || p.IsWork != a.work {
t.Errorf("地点 %d: is_home=%v is_work=%v,独立统计为 %v/%v",
p.ID, p.IsHome, p.IsWork, a.home, a.work)
}
name, nOK := strict(a.name)
color, cOK := strict(a.color)
icon, iOK := strict(a.icon)
if !nOK || !cOK || !iOK {
continue // SQL 的并列取法未定义,跳过
}
checked++
if p.TopActivity != name || p.TopColor != color || p.TopIcon != icon {
t.Errorf("地点 %d: top 三轴 = %q/%q/%q,独立数出 %q/%q/%q",
p.ID, p.TopActivity, p.TopColor, p.TopIcon, name, color, icon)
}
}
if checked == 0 {
t.Skip("本数据集没有「三轴唯一众数」的地点,比不了")
}
t.Logf("已比对 %d/%d 个地点(其余有并列,跳过)", checked, len(got))
}
+100 -26
View File
@@ -36,10 +36,23 @@ type Filter struct {
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
Regions []string
RegionsExclude bool
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。
// Verdict 非 0 时只保留站长标记为该结论的地点:**连同它们名下的到访与位移一起**
// (1 推荐 / -1 踩雷)。它不是「只筛地点列表」的镜头——判据下推到 visitsWhere /
// movementsWhere,页面上所有数字跟着一起变,否则会出现「点没了、里程还在」这种
// 自相矛盾的视图。
Verdict int
}
// placeKeyExpr 把 places 某个别名下的坐标换算成 place_key。
// **必须与 Go 侧 rond.PlaceCoordKey 逐位一致**(notes / votes / handlers_images / stats
// 各有一份,改一处搜全仓):
//
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
func placeKeyExpr(alias string) string {
return fmt.Sprintf(
"to_char(%[1]s.lat,'FM999990.000000')||'|'||to_char(%[1]s.lon,'FM999990.000000')", alias)
}
type where struct {
conds []string
args []any
@@ -148,6 +161,15 @@ func (f Filter) visitsWhere(off int) *where {
w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id
AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw)
}
if f.Verdict != 0 {
// 结论挂坐标键(换数据集也跟得住),所以判据是「这次到访所属地点的键」。
//
// 放在 visitsWhere 而不是 Places 里,是为了**全域一致**:地图点、城市/省份统计、
// 类型计数、概览,以及靠 movementVisitConds 派生的里程 / 出行数,全都从这一份
// 条件派生出来,不会各认各的。
w.cond(`EXISTS (SELECT 1 FROM place_votes pv JOIN places pvp ON pvp.id = v.place_id
WHERE pv.place_key = `+placeKeyExpr("pvp")+` AND pv.verdict = ?)`, f.Verdict)
}
return w
}
@@ -157,8 +179,11 @@ func (f Filter) visitsWhere(off int) *where {
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
func (f Filter) hasVisitConds() bool {
// Verdict 也算:movements/weather 只在「有这类条件」时才会包一层 EXISTS 到访,
// 漏掉它就别指望位移会跟着筛选走。
return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork ||
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != ""
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" ||
f.Verdict != 0
}
// weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
@@ -187,6 +212,34 @@ func (f Filter) weatherWhere() *where {
return w
}
// movementVisitConds 是「必须落到某次到访上才能判定」的那几档条件(活动 / 最短停留 /
// 城市 / 省份 / 标签 / 关键词),专门给位移用——movements 自己只有两端到访,
// 没有城市、活动这些字段。
//
// 这里**复用 visitsWhere 拼出来的表达式**,而不是再抄一遍:抄一遍迟早跟 visitsWhere
// 漂移,漏掉一个维度就是「到访数变了、里程还是全量」(区域那档已经犯过一次)。
// 清掉三类条件,剩下的正是可以按到访判的:
// - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径;
// - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere;
// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」。
func (f Filter) movementVisitConds(off int) *where {
rest := f
rest.From, rest.To = nil, nil
rest.Regions = nil
rest.ExcludeHome, rest.ExcludeWork = false, false
return rest.visitsWhere(off)
}
// hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。
// 从 hasVisitConds 派生,免得两处再各写一份清单;减掉的正是 movementVisitConds
// 里被清掉的那两类(家工作、区域),它们由 movementsWhere 自己判。
func (f Filter) hasMovementVisitConds() bool {
rest := f
rest.ExcludeHome, rest.ExcludeWork = false, false
rest.Regions = nil
return rest.hasVisitConds()
}
// movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。
//
// 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places
@@ -215,20 +268,45 @@ func (f Filter) movementsWhere(off int) *where {
AND (` + strings.Join(flags, " OR ") + `))`)
}
if len(f.Regions) > 0 {
var prov, city, dist []string
// 每一段都要**两端分别**过同一套判定,与地图轨迹图层保持一致:apiTrack 就是
// 用 regionSQL 拼出 pf / pt 两段 AND 起来的。只按「任一端命中」的话,白名单下
// 同一段里程在图层里画不出来、却算进了总里程(黑名单下两种写法恰好等价,
// 所以这个洞只在白名单时露出来)。
phs := make([]string, 0, len(f.Regions))
for _, r := range f.Regions {
prov = append(prov, w.ph(r))
city = append(city, w.ph(r))
dist = append(dist, w.ph(r))
phs = append(phs, w.ph(r))
}
expr := `EXISTS (SELECT 1 FROM places pmv
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id)
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` +
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))`
if f.RegionsExclude {
expr = "NOT " + expr
endpoint := func(col string) string {
var conds []string
for _, ph := range phs {
// 必须 COALESCE:district 常为 NULL,NULL = 'x' 得 NULL,
// 黑名单的 NOT (...) 也跟着变 NULL,整行被误删(regionSQL 同样处理)。
conds = append(conds, "COALESCE(pmv.province,'') = "+ph,
"COALESCE(pmv.city,'') = "+ph, "COALESCE(pmv.district,'') = "+ph)
}
expr := "EXISTS (SELECT 1 FROM places pmv WHERE pmv.id = movements." + col +
" AND (" + strings.Join(conds, " OR ") + "))"
if f.RegionsExclude {
return "NOT " + expr
}
return expr
}
w.conds = append(w.conds, expr)
w.conds = append(w.conds, endpoint("from_place_id"), endpoint("to_place_id"))
}
// 活动 / 城市 / 省份 / 标签 / 最短停留 / 关键词这几档只长在到访上,位移得靠两端
// 的到访带过来,否则筛完之后「到访数变了、里程和出行数还是全量」。
// 判定用「**任一端**命中即算」——和上面区域那条同一个理由:位移横跨两个地点,
// 改成「两端都得命中」会让城际段在每张筛选视图里都消失,而「任一端」才和到访列表
// 对得上(一次通勤的起点和终点,在两边各自的视图里都该看得到)。
// 两端都关联不到到访的位移(真机有 31/791 这种)在带这类筛选时不参与:
// 它们没法证明自己落在筛选范围内。
if f.hasMovementVisitConds() {
vc := f.movementVisitConds(len(w.args))
w.conds = append(w.conds, `EXISTS (
SELECT 1 FROM visits v WHERE v.dataset_id = movements.dataset_id
AND (v.src_pk = movements.from_visit_src OR v.src_pk = movements.to_visit_src)
AND `+vc.expr()+`)`)
w.args = append(w.args, vc.args...)
}
return w
}
@@ -654,17 +732,20 @@ type Place struct {
Down int
}
// 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id)
// 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。
// 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。
const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
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,
COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home),
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work),
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home),
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work),
p.src_pk,
EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key =
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))`
@@ -707,15 +788,8 @@ func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset i
}
args := append([]any{f.DatasetID}, inner.args...)
// verdict 条件已经在 visitsWhere 里,这里不用再补一份(补了就是同一个 EXISTS 跑两遍)。
base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)`
if f.Verdict != 0 {
// 结论挂坐标键(换数据集也要跟得住),按本数据集的地点圈定范围;
// 占位符接在内层条件之后。
args = append(args, f.Verdict)
base += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM place_votes pv WHERE pv.place_key =
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')
AND pv.verdict = $%d)`, len(args))
}
q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
rows, err := d.QueryContext(ctx, q, args...)
+72
View File
@@ -250,3 +250,75 @@ func TestWeatherStatsRespectsFilter(t *testing.T) {
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
}
}
// TestVisitSideFiltersApplyToMovements 钉住「按活动 / 城市 / 标签 / 关键词筛选时,
// 里程与出行次数也要跟着变」。
//
// movements 自己只有两端到访,没有活动、城市这些字段。曾经 movementsWhere 只认
// dataset / 时间 / 家工作 / 区域,于是筛完之后「到访数变了、里程还是全量」——
// 区域那档犯过一次(见上一个测试),这几档是同一个洞没堵完。
func TestVisitSideFiltersApplyToMovements(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
// 挑一个真有到访的活动,且它只覆盖一部分到访(否则筛选前后一样,测不出东西)
var actID int64
if err := db.QueryRow(`SELECT activity_id FROM visits
WHERE dataset_id=$1 AND activity_id IS NOT NULL
GROUP BY activity_id ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&actID); err != nil {
t.Skipf("没有带活动的到访:%v", err)
}
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
one, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, ActivityIDs: []int64{actID}})
if err != nil {
t.Fatal(err)
}
if one.Visits >= all.Visits {
t.Skipf("活动 %d 覆盖了全部到访,换个数据集再测", actID)
}
// 手写 SQL:两端**任一端**的到访属于该活动的位移
var dist float64
var n int
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0), count(*) FROM movements m
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=m.dataset_id AND v.activity_id=$2
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
dsID, actID).Scan(&dist, &n); err != nil {
t.Fatal(err)
}
if diff := one.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
t.Errorf("按活动筛选后里程 %.4f,手写 SQL %.4f(筛选没作用到里程上?)", one.DistanceKm, dist)
}
if one.Movements != n {
t.Errorf("按活动筛选后出行次数 %d,手写 SQL %d", one.Movements, n)
}
// 同一条路径也覆盖城市:movementVisitConds 里所有维度共用一个 EXISTS
var city string
if err := db.QueryRow(`SELECT p.city 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<>''
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
t.Skipf("没有带城市的到访:%v", err)
}
byCity, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, City: city})
if err != nil {
t.Fatal(err)
}
var cityDist float64
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0) FROM movements m
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=m.dataset_id
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2)
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
dsID, city).Scan(&cityDist); err != nil {
t.Fatal(err)
}
if diff := byCity.DistanceKm - cityDist; diff > 0.01 || diff < -0.01 {
t.Errorf("按城市「%s」筛选后里程 %.4f,手写 SQL %.4f", city, byCity.DistanceKm, cityDist)
}
}
+355
View File
@@ -0,0 +1,355 @@
package stats_test
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"rond-with-you/internal/stats"
)
// verdictFixture 是一套**自造**的数据集,坐标故意取在远端(89.9 / 100.x),
// 与任何真实备份、任何测试夹具都不会重合。
//
// 为什么不复用 setup(t) 的活跃数据集:`place_key` 是**坐标键**,而所有夹具导入的都是
// 同一份备份、坐标完全相同。往活跃数据集上写 place_votes 会顺着坐标键漏进并行跑的
// 其它包(internal/web 有用例要读某个地点的 verdict),把别人的断言弄脏。
// 这里只建自己的数据集,并且**不碰 is_active**——改它会让并行跑的统计用例选到空库。
type verdictFixture struct {
db *sql.DB
ds int64
uid int64
p1, p2, p3 int64 // p1/p2 打「推荐」,p3 不打
}
func newVerdictFixture(t *testing.T) *verdictFixture {
t.Helper()
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("未设置 ROND_DSN")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
tag := fmt.Sprint(time.Now().UnixNano())
var uid int64
if err := db.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,'x','verdict-scope') RETURNING id`, "verdict-scope-"+tag).Scan(&uid); err != nil {
t.Fatalf("建临时账号失败: %v", err)
}
var ds int64
sha := fmt.Sprintf("%064x", time.Now().UnixNano())
if err := db.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status)
VALUES ($1,'verdict-scope',$2,'done') RETURNING id`, uid, sha).Scan(&ds); err != nil {
t.Fatalf("建临时数据集失败: %v", err)
}
t.Cleanup(func() {
db.ExecContext(ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid)
// 数据集级联带走 places / visits / movements / weather / activities
db.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, ds)
db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid)
})
// 到访的到达时间固定写死,避免「今天」漂移导致天数/月份断言不稳
at := func(iso string) time.Time {
v, err := time.Parse(time.RFC3339, iso)
if err != nil {
t.Fatal(err)
}
return v
}
mkPlace := func(pk int, lat, lon float64, city, prov string) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, lat, lon, city, province)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
ds, pk, fmt.Sprintf("P%d", pk), lat, lon, city, prov).Scan(&id); err != nil {
t.Fatalf("建地点失败: %v", err)
}
return id
}
f := &verdictFixture{db: db, ds: ds, uid: uid}
f.p1 = mkPlace(1, 89.900001, 100.100001, "CityA", "ProvA")
f.p2 = mkPlace(2, 89.900002, 100.100002, "CityB", "ProvA")
f.p3 = mkPlace(3, 89.900003, 100.100003, "CityC", "ProvB")
var actID int64
if err := db.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name)
VALUES ($1,1,'家') RETURNING id`, ds).Scan(&actID); err != nil {
t.Fatalf("建活动失败: %v", err)
}
mkVisit := func(pk int, placeID int64, iso string, dur int) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, activity_id, arrival, duration_min)
VALUES ($1,$2,$3,$4,$5,$6)`, ds, pk, placeID, actID, at(iso), dur); err != nil {
t.Fatalf("建到访失败: %v", err)
}
}
mkVisit(101, f.p1, "2026-01-05T10:00:00+08:00", 60)
mkVisit(102, f.p1, "2026-01-06T10:00:00+08:00", 30)
mkVisit(103, f.p2, "2026-02-10T10:00:00+08:00", 20)
mkVisit(104, f.p3, "2026-02-11T10:00:00+08:00", 10)
mkMove := func(pk, from, to int, placeID int64, km float64, iso string) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO movements
(dataset_id, src_pk, from_visit_src, to_visit_src, from_place_id, to_place_id,
started_at, ended_at, distance_km)
VALUES ($1,$2,$3,$4,$5,$5,$6,$6,$7)`, ds, pk, from, to, placeID, at(iso), km); err != nil {
t.Fatalf("建位移失败: %v", err)
}
}
// m1 跨 p1→p2(两端都在 S 内)· m2 跨 p3→p1(任一端在 S 内就该算)· m3 两端都在 p3(该被排除)
mkMove(201, 101, 103, f.p1, 5.0, "2026-01-07T10:00:00+08:00")
mkMove(202, 104, 101, f.p1, 7.0, "2026-02-12T10:00:00+08:00")
mkMove(203, 104, 104, f.p3, 11.0, "2026-02-13T10:00:00+08:00")
mkWeather := func(pk, visitSrc int, iso, cond string) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO weather
(dataset_id, src_pk, visit_src, at, condition) VALUES ($1,$2,$3,$4,$5)`,
ds, pk, visitSrc, at(iso), cond); err != nil {
t.Fatalf("建天气失败: %v", err)
}
}
mkWeather(301, 101, "2026-01-05T10:00:00+08:00", "Clear")
mkWeather(302, 104, "2026-02-11T10:00:00+08:00", "Cloudy")
// 只给 p1 / p2 打「推荐」。键用 Go 侧的 %.6f —— 顺便验证它与 SQL 的
// to_char(...,'FM999990.000000') 逐位一致(不一致这里就查不到,断言会红)。
mark := func(placeID int64, lat, lon float64, verdict int) {
t.Helper()
key := fmt.Sprintf("%.6f|%.6f", lat, lon)
if _, err := db.ExecContext(ctx, `INSERT INTO place_votes
(user_id, place_key, dataset_id, src_pk, verdict) VALUES ($1,$2,$3,1,$4)`,
uid, key, ds, verdict); err != nil {
t.Fatalf("写 verdict(键 %s)失败: %v", key, err)
}
}
mark(f.p1, 89.900001, 100.100001, 1)
mark(f.p2, 89.900002, 100.100002, 1)
return f
}
func (f *verdictFixture) filter(v int) stats.Filter {
return stats.Filter{DatasetID: f.ds, Verdict: v}
}
func sumBuckets(bs []stats.Bucket) int {
n := 0
for _, b := range bs {
n += b.Count
}
return n
}
// D7 回归:verdict 必须作用到**每一个**统计口径上。
//
// 用户已定口径「全域一致」——筛选条上看得见的这枚条件,页面上所有数字都得跟着变,
// 否则就是「点没了、里程还在」这种自相矛盾的视图(P1-3 是同一类问题的另一个面)。
//
// 判据用**不可能存在的结论值 99**:没有任何地点会被它匹配,于是**每一个**统计都必须退回
// 空集。任何一处漏了下推(地图点、城市/省份、类型计数、概览、里程/出行、天气、时间分布)
// 都会在这一条上暴露成一个非零数——比逐个统计写断言更彻底。
func TestVerdictScopesEveryStatistic(t *testing.T) {
f := newVerdictFixture(t)
ctx := context.Background()
d := stats.DB{DB: f.db}
// 先确认夹具本身有数据(否则「为空」是废话)
all, err := d.Overview(ctx, f.filter(0))
if err != nil {
t.Fatalf("Overview(无筛选) 失败: %v", err)
}
if all.Visits != 4 || all.Places != 3 || all.Movements != 3 {
t.Fatalf("夹具数据不对:visits=%d places=%d movements=%d,期望 4/3/3", all.Visits, all.Places, all.Movements)
}
// ① 不可能存在的结论 ⇒ 全域都该是空的
none := f.filter(99)
ov, err := d.Overview(ctx, none)
if err != nil {
t.Fatal(err)
}
if ov != (stats.Overview{}) {
t.Errorf("Verdict=99 时 Overview 应当全零,实际 %+v", ov)
}
places, total, err := d.Places(ctx, none, "visits", 100, 0)
if err != nil {
t.Fatal(err)
}
if len(places) != 0 || total != 0 {
t.Errorf("Verdict=99 时地点列表应当为空,实际 %d 条(total=%d)", len(places), total)
}
mapped, err := d.MapPlaces(ctx, none, 100)
if err != nil {
t.Fatal(err)
}
if len(mapped) != 0 {
t.Errorf("Verdict=99 时地图点应当为空,实际 %d 个", len(mapped))
}
if rows, n, err := d.Visits(ctx, none, 100, 0, "desc"); err != nil || len(rows) != 0 || n != 0 {
t.Errorf("Verdict=99 时到访列表应当为空,实际 %d 条(total=%d, err=%v)", len(rows), n, err)
}
if as, err := d.ActivityStats(ctx, none); err != nil || len(as) != 0 {
t.Errorf("Verdict=99 时类型分布应当为空,实际 %d 组(err=%v)", len(as), err)
}
if ac, err := d.ActivityCounts(ctx, none); err != nil || len(ac) != 0 {
t.Errorf("Verdict=99 时类型计数应当为空,实际 %d 项(err=%v)", len(ac), err)
}
if cs, err := d.CityStats(ctx, none, 10); err != nil || len(cs) != 0 {
t.Errorf("Verdict=99 时城市分布应当为空,实际 %d 组(err=%v)", len(cs), err)
}
if ps, err := d.ProvinceStats(ctx, none); err != nil || len(ps) != 0 {
t.Errorf("Verdict=99 时省份分布应当为空,实际 %d 组(err=%v)", len(ps), err)
}
if ws, err := d.WeatherStats(ctx, none); err != nil || len(ws) != 0 {
t.Errorf("Verdict=99 时天气汇总应当为空,实际 %d 组(err=%v)", len(ws), err)
}
if ms, err := d.Monthly(ctx, none); err != nil || sumBuckets(ms) != 0 {
t.Errorf("Verdict=99 时月度分布应当为空,实际 %d 次(err=%v)", sumBuckets(ms), err)
}
if cal, err := d.Calendar(ctx, none); err != nil || len(cal) != 0 {
t.Errorf("Verdict=99 时日历应当为空,实际 %d 天(err=%v)", len(cal), err)
}
if bs, err := d.Hourly(ctx, none); err != nil || sumBuckets(bs) != 0 {
t.Errorf("Verdict=99 时小时分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err)
}
if bs, err := d.Weekday(ctx, none); err != nil || sumBuckets(bs) != 0 {
t.Errorf("Verdict=99 时星期分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err)
}
if ts, err := d.TransportStats(ctx, none); err != nil || len(ts) != 0 {
t.Errorf("Verdict=99 时出行方式应当为空,实际 %d 组(err=%v)", len(ts), err)
}
}
// D7 正向:只给 p1/p2 打「推荐」时,每个口径都必须**恰好**收窄到它们名下的数据。
// 上一条证明「不会漏下推」,这一条证明「下推之后选对了」。
func TestVerdictNarrowsEveryStatisticToMarkedPlaces(t *testing.T) {
f := newVerdictFixture(t)
ctx := context.Background()
d := stats.DB{DB: f.db}
on := f.filter(1)
// S = {p1, p2}:3 条到访(60+30+20 分钟)、2 个地点、2 个城市、1 个省、3 个不同日期
ov, err := d.Overview(ctx, on)
if err != nil {
t.Fatal(err)
}
if ov.Visits != 3 || ov.Places != 2 || ov.Cities != 2 || ov.Provinces != 1 || ov.Days != 3 {
t.Errorf("Overview 收窄不对:visits=%d places=%d cities=%d provinces=%d days=%d,期望 3/2/2/1/3",
ov.Visits, ov.Places, ov.Cities, ov.Provinces, ov.Days)
}
if ov.DwellMin != 110 {
t.Errorf("停留 %d 分钟,期望 110", ov.DwellMin)
}
// 位移按「任一端在 S 内」:m1(p1→p2) 与 m2(p3→p1) 算,两端都在 p3 的 m3 不算
if ov.Movements != 2 || ov.DistanceKm < 11.99 || ov.DistanceKm > 12.01 {
t.Errorf("位移收窄不对:movements=%d distance=%.2f,期望 2 / 12.00", ov.Movements, ov.DistanceKm)
}
places, total, err := d.Places(ctx, on, "visits", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 2 || len(places) != 2 {
t.Errorf("地点列表应当只剩 2 个,实际 %d 条(total=%d)", len(places), total)
}
got := map[int64]bool{}
for _, p := range places {
got[p.ID] = true
}
if !got[f.p1] || !got[f.p2] || got[f.p3] {
t.Errorf("地点列表选错:%v(应当只有 p1/p2)", got)
}
mapped, err := d.MapPlaces(ctx, on, 100)
if err != nil {
t.Fatal(err)
}
if len(mapped) != 2 {
t.Errorf("地图点应当只剩 2 个,实际 %d 个(这一处以前完全没认 verdict)", len(mapped))
}
// 每个「按到访计数」的口径都必须与 Overview.Visits 对得上
if ac, err := d.ActivityCounts(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, c := range ac {
n += c
}
if n != ov.Visits {
t.Errorf("类型计数合计 %d,与到访数 %d 不符", n, ov.Visits)
}
}
if as, err := d.ActivityStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, a := range as {
n += a.Count
}
if n != ov.Visits {
t.Errorf("类型分布合计 %d,与到访数 %d 不符", n, ov.Visits)
}
}
if cs, err := d.CityStats(ctx, on, 10); err != nil {
t.Fatal(err)
} else {
n := 0
for _, c := range cs {
n += c.Count
}
if n != ov.Visits || len(cs) != 2 {
t.Errorf("城市分布应当 2 组共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(cs), n)
}
}
if ps, err := d.ProvinceStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, p := range ps {
n += p.Count
}
if n != ov.Visits || len(ps) != 1 || ps[0].Province != "ProvA" {
t.Errorf("省份分布应当只有 ProvA 共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(ps), n)
}
}
if ms, err := d.Monthly(ctx, on); err != nil || sumBuckets(ms) != ov.Visits {
t.Errorf("月度分布合计 %d,与到访数 %d 不符(err=%v)", sumBuckets(ms), ov.Visits, err)
}
if bs, err := d.Hourly(ctx, on); err != nil || sumBuckets(bs) != ov.Visits {
t.Errorf("小时分布合计不对(err=%v)", err)
}
if bs, err := d.Weekday(ctx, on); err != nil || sumBuckets(bs) != ov.Visits {
t.Errorf("星期分布合计不对(err=%v)", err)
}
// 天气挂在到访上:只有 p1 那条该留下
if ws, err := d.WeatherStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, w := range ws {
n += w.Count
}
if n != 1 {
t.Errorf("天气应当只剩 1 条(挂在 p1 上那条),实际 %d 条", n)
}
}
// 到访列表也要跟着收窄
if rows, n, err := d.Visits(ctx, on, 100, 0, "desc"); err != nil || len(rows) != 3 || n != 3 {
t.Errorf("到访列表应当只剩 3 条,实际 %d 条(total=%d, err=%v)", len(rows), n, err)
}
}