Files
rond-with-you/internal/stats/placecols_test.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

170 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}