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

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))
}