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

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
+107
View File
@@ -0,0 +1,107 @@
package web
import (
"context"
"database/sql"
"testing"
)
// 列表页(fillTopicStatsBatch:一条 UNION ALL)与详情页(topicContent:逐专题查询)
// 对同一专题必须给出同样的统计 —— 列表上的数字点进去要对得上。
// 顺带钉住批量版的占位符编号:错一个就是 pgx「expected N arguments, got M」。
func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
f := newLoginFixture(t)
ctx := context.Background()
var ds int64
if err := f.srv.db.QueryRowContext(ctx, `SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&ds); err != nil {
t.Skipf("没有生效数据集:%v", err)
}
var minAt, maxAt sql.NullTime
if err := f.srv.db.QueryRowContext(ctx,
`SELECT min(arrival), max(arrival) FROM visits WHERE dataset_id=$1`, ds).Scan(&minAt, &maxAt); err != nil {
t.Fatal(err)
}
if !minAt.Valid || !maxAt.Valid {
t.Skip("生效数据集里没有到访记录")
}
var lat, lon float64
if err := f.srv.db.QueryRowContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
WHERE dataset_id=$1 ORDER BY visit_count DESC LIMIT 1`, ds).Scan(&lat, &lon); err != nil {
t.Fatal(err)
}
var city string
_ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1
AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city)
mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2)
base := []Topic{
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
{ID: 2, Title: "前半段", StartAt: minAt.Time, EndAt: mid},
{ID: 3, Title: "后半段", StartAt: mid, EndAt: maxAt.Time},
{ID: 4, Title: "按区域圈定", StartAt: minAt.Time, EndAt: maxAt.Time,
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
CenterLat: 1, CenterLon: 1, RadiusKm: 1},
}
type round struct {
name string
regions []string
exclude bool
wantNonZero bool
}
rounds := []round{{name: "无区域过滤", wantNonZero: true}}
if city != "" {
rounds = append(rounds,
round{name: "白名单:" + city, regions: []string{city}, wantNonZero: true},
round{name: "黑名单:" + city, regions: []string{city}, exclude: true},
)
}
for _, rd := range rounds {
// 详情页路径:逐专题查询
want := append([]Topic(nil), base...)
for i := range want {
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
if err != nil {
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
}
want[i].PlaceCount, want[i].VisitCount = len(points), visits
want[i].DayCount, want[i].CityCount = days, cities
for _, p := range points {
want[i].DwellMin += p.Dwell
}
}
// 列表页路径:一条 UNION ALL
got := append([]Topic(nil), base...)
if err := f.srv.fillTopicStatsBatch(ctx, got, ds, rd.regions, rd.exclude); err != nil {
t.Fatalf("[%s] fillTopicStatsBatch: %v", rd.name, err)
}
nonZero := 0
for i := range got {
if got[i].PlaceCount != want[i].PlaceCount || got[i].VisitCount != want[i].VisitCount ||
got[i].DayCount != want[i].DayCount || got[i].CityCount != want[i].CityCount ||
got[i].DwellMin != want[i].DwellMin {
t.Errorf("[%s] 专题 #%d 批量与详情页不一致:\n 批量 地点/到访/天数/城市/停留 = %d/%d/%d/%d/%d\n 详情页同项 = %d/%d/%d/%d/%d",
rd.name, got[i].ID, got[i].PlaceCount, got[i].VisitCount, got[i].DayCount,
got[i].CityCount, got[i].DwellMin,
want[i].PlaceCount, want[i].VisitCount, want[i].DayCount, want[i].CityCount, want[i].DwellMin)
}
if got[i].PlaceCount > 0 {
nonZero++
}
// 半径 1km 圈在 (1,1) 是海面,必须一个地点都圈不到(证明区域条件真的生效)
if got[i].ID == 5 && (got[i].PlaceCount != 0 || got[i].VisitCount != 0 || got[i].DwellMin != 0) {
t.Errorf("[%s] 半径 1km 圈在 (1,1) 的专题不应有内容,实得 %d 地点 %d 到访",
rd.name, got[i].PlaceCount, got[i].VisitCount)
}
}
if rd.wantNonZero && nonZero == 0 {
t.Fatalf("[%s] 所有专题统计都是 0,用例形同虚设", rd.name)
}
t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero)
}
}