审计修复:后端(安全 / 数据一致性 / 性能)
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:
@@ -54,6 +54,10 @@ func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
|
||||
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
|
||||
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
|
||||
// - 合并是幂等的:再传同一份备份,差异归零。
|
||||
//
|
||||
// 注意:这里库内编号与备份逐条相同(夹具导入的就是 f.src 本身,实测 823/823),
|
||||
// 所以走的是「编号直接命中」的快路径。编号重排过的真实场景见
|
||||
// TestBackupMergeRenumberedBackupRestoresOnlyMissing。
|
||||
func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
@@ -127,6 +131,86 @@ func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过,
|
||||
// 与库里的编号对不上,而备份里还多了几条新记录。
|
||||
//
|
||||
// 这条要钉住的是最贵的一种错:如果比对退回按编号对齐,那 800 多条老记录会被判成
|
||||
// 「备份新增 + 仅站内有」,用户勾一下「并进来」就把整份足迹**复制一遍**(数量翻倍、
|
||||
// 地图上每个点都重影)。必须在平移编号的前提下,差异仍然只有真正缺的那 2 条。
|
||||
func TestBackupMergeRenumberedBackupRestoresOnlyMissing(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
visitCount := func() int {
|
||||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||||
}
|
||||
all := visitCount()
|
||||
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
|
||||
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
|
||||
if all == 0 || movCount == 0 {
|
||||
t.Skip("这份备份没有足够的到访/位移可测")
|
||||
}
|
||||
|
||||
// ① 库里的编号整体平移:等价于「这批数据其实来自另一份编号完全不同的备份」
|
||||
f.shiftSrcPKs(t, 500000)
|
||||
|
||||
// ② 再按**站点删到访的方式**删掉最早两条:备份里还有它们,所以差异应当正好是 2 条。
|
||||
// 和平移前的用例一样,必须连带清位移、清天气归属,否则留下的是测试手法造成的孤儿。
|
||||
var pk1, pk2 int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
|
||||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
|
||||
f.dsID).Scan(&pk1, &pk2); err != nil {
|
||||
t.Fatalf("取待删到访失败: %v", err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
|
||||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
|
||||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
|
||||
} {
|
||||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
|
||||
t.Fatalf("预置删除失败: %v", err)
|
||||
}
|
||||
}
|
||||
if got := visitCount(); got != all-2 {
|
||||
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
|
||||
}
|
||||
|
||||
token, page := previewBackupToken(t, f, f.src)
|
||||
if a, g, c := visitCounts(t, page); a != 2 || g != 0 || c != 0 {
|
||||
t.Fatalf("编号重排过、只缺 2 条 ⇒ 差异应当正好 2 条新增;实际 新增 %d 仅站内有 %d 冲突 %d"+
|
||||
"(判成 800+ 就是把整份足迹当新记录,合并会复制一遍)", a, g, c)
|
||||
}
|
||||
|
||||
mergeBackup(t, f, token, "add_new=1")
|
||||
|
||||
if got := visitCount(); got != all {
|
||||
t.Fatalf("合并后到访数应回到 %d 且不重不漏,实际 %d", all, got)
|
||||
}
|
||||
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
|
||||
t.Errorf("位移应恢复到 %d,实际 %d(恢复到新编号时外键映射错了)", movCount, got)
|
||||
}
|
||||
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
|
||||
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
|
||||
}
|
||||
// 补进来的那两条必须真的挂回了某个经历,不能因为编号变了就成孤儿
|
||||
if got := f.scalar(t, `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
|
||||
AND m.from_visit_src IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src)`, f.dsID); got != 0 {
|
||||
t.Errorf("恢复后出现了 %d 条起点悬空的位移", got)
|
||||
}
|
||||
// 地点是按**坐标**对齐的(不是按编号),所以编号平移不该让补回来的到访挂空 ——
|
||||
// place_id 可空,映射错了不会报错,只会变成地图上没地点的孤点到访。
|
||||
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id IS NULL`, f.dsID); got != 0 {
|
||||
t.Errorf("恢复后有 %d 条到访没挂上地点(按编号映射就会这样)", got)
|
||||
}
|
||||
|
||||
// 幂等:再比一次必须归零(否则每合并一次就多一批)
|
||||
page = previewBackup(t, f, f.src)
|
||||
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
||||
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
|
||||
func TestBackupMergeConflictChoice(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
Reference in New Issue
Block a user