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

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
+104
View File
@@ -0,0 +1,104 @@
package web
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestEditVisitUpdateAtomicOnClampFailure 证明「改到访」整组写入是一个事务:
// 其中夹行程那步失败时,前面已经写进去的到访时间必须一并回滚。
//
// 手法是给 movements 挂一个必然报错的 BEFORE UPDATE 触发器——夹行程(clampVisitMovements)
// 就是靠 UPDATE movements 落地的,触发器一炸它就返回错误。
// 回滚生效 ⇒ 到访时间保持原值;回滚失效 ⇒ 到访时间已被改掉(旧代码各写各的、没有事务,
// 到访那条 UPDATE 早就提交了)。
func TestEditVisitUpdateAtomicOnClampFailure(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 挑一条被行程引用的到访,连同它那条行程的开始时间
var visitSrc, movePK int
var mvSt, mvEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT m.from_visit_src, m.src_pk, m.started_at, m.ended_at
FROM movements m WHERE m.dataset_id=$1 AND m.from_visit_src IS NOT NULL
AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
ORDER BY m.src_pk LIMIT 1`, dsID).Scan(&visitSrc, &movePK, &mvSt, &mvEn); err != nil {
t.Fatalf("挑不出「被行程引用的到访」: %v", err)
}
var origArrival time.Time
var origDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&origArrival, &origDeparture); err != nil {
t.Fatalf("读取到访失败: %v", err)
}
// 把离开时间推到行程开始之后,夹行程就必然要动手改这条行程
newArrival := mvSt.Add(-2 * time.Hour).Truncate(time.Minute)
newDeparture := mvSt.Add(time.Hour).Truncate(time.Minute)
installMovementUpdateFailTrigger(t, ctx, pg)
form := url.Values{
"src_pk": {fmt.Sprint(visitSrc)},
"arrival": {newArrival.Local().Format("2006-01-02T15:04")},
"departure": {newDeparture.Local().Format("2006-01-02T15:04")},
}
r := httptest.NewRequest("POST", "/admin/edit/visit/update", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
f.srv.editVisitUpdate(w, r.WithContext(f.ctx))
if w.Code != http.StatusInternalServerError {
t.Fatalf("夹行程失败应报 500,实际 code=%d body=%s", w.Code, w.Body.String())
}
var gotArrival time.Time
var gotDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&gotArrival, &gotDeparture); err != nil {
t.Fatalf("回读到访失败: %v", err)
}
same := gotArrival.Equal(origArrival) && gotDeparture.Valid == origDeparture.Valid &&
(!gotDeparture.Valid || gotDeparture.Time.Equal(origDeparture.Time))
if !same {
t.Errorf("夹行程失败后到访 #%d 的时间被改掉了:%v/%v → %v/%v(整组写入应当回滚)",
visitSrc, origArrival, origDeparture, gotArrival, gotDeparture)
}
// 行程那一端也不能被改
var gotSt, gotEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, movePK).Scan(&gotSt, &gotEn); err != nil {
t.Fatalf("回读行程失败: %v", err)
}
if !gotSt.Equal(mvSt) || !gotEn.Equal(mvEn) {
t.Errorf("行程 #%d 的时间被改掉了:%v/%v → %v/%v", movePK, mvSt, mvEn, gotSt, gotEn)
}
t.Logf("夹行程失败后到访 #%d(%v ~ %v)与行程 #%d 均保持原值 ✓", visitSrc, origArrival, gotDeparture, movePK)
}
// installMovementUpdateFailTrigger 给 movements 挂一个必然报错的 BEFORE UPDATE 触发器。
// 名字带 e2e_ 前缀以免撞上别的对象;用完即弃(t.Cleanup 里 drop,且在连接关闭之前跑)。
func installMovementUpdateFailTrigger(t *testing.T, ctx context.Context, pg *sql.DB) {
t.Helper()
if _, err := pg.ExecContext(ctx, `CREATE OR REPLACE FUNCTION e2e_fail_movement_update() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'e2e: 事务原子性用例故意失败'; END $$`); err != nil {
t.Fatalf("建触发器函数失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`); err != nil {
t.Fatalf("清理旧触发器失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `CREATE TRIGGER e2e_fail_movement_update
BEFORE UPDATE ON movements FOR EACH ROW EXECUTE FUNCTION e2e_fail_movement_update()`); err != nil {
t.Fatalf("建触发器失败: %v", err)
}
t.Cleanup(func() {
pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`)
pg.ExecContext(ctx, `DROP FUNCTION IF EXISTS e2e_fail_movement_update()`)
})
}