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

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
+108
View File
@@ -0,0 +1,108 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
)
// adminNotesBulk 必须走批量语句。
//
// 旧实现是 `for _, id := range ids` 里逐条 Delete/SetNote/SaveVerdict:勾 50 个地点就是
// 50(清空是 100)次数据库往返,在 1C1G 的机器上体感很明显。这条守卫钉住「不许退回逐条循环」。
func TestAdminNotesBulkUsesBulkStatements(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "handlers_admin.go"))
if err != nil {
t.Fatalf("读取 handlers_admin.go 失败: %v", err)
}
fn := funcBody(string(b), "func (s *Server) adminNotesBulk(")
if fn == "" {
t.Fatal("在 handlers_admin.go 里找不到 adminNotesBulk(写法变了,这条守卫就失效了)")
}
for _, banned := range []string{"s.notes.Delete(", "s.notes.SetNote(", "s.votes.SaveVerdict("} {
if strings.Contains(fn, banned) {
t.Errorf("adminNotesBulk 不该再逐条调用 %s —— 应改用对应的 *Bulk 方法", banned)
}
}
for _, want := range []string{"s.notes.DeleteBulk(", "s.notes.SetNoteBulk(", "s.votes.SaveVerdictBulk("} {
if !strings.Contains(fn, want) {
t.Errorf("adminNotesBulk 未改用批量方法 %s", want)
}
}
}
// 批量勾选必须真的作用到**每一个**被勾的地点(不是只改第一条)。
func TestAdminNotesBulkAppliesToEveryCheckedPlace(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pks := []int64{680001, 680002, 680003}
for i, pk := range pks {
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'批量点',$3,$4)`, f.dsID, pk,
30.0+float64(i)*0.01, 120.0+float64(i)*0.01); err != nil {
t.Fatalf("建地点失败: %v", err)
}
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM place_notes WHERE user_id=$1`, uid)
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid)
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[])`,
f.dsID, pks)
})
post := func(vals url.Values) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/notes/bulk", strings.NewReader(vals.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminNotesBulk(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("批量操作应跳回页面: code=%d body=%s", rec.Code, rec.Body.String())
}
}
bulk := func(action string, extra ...string) url.Values {
v := url.Values{"action": {action}}
for i := 0; i+1 < len(extra); i += 2 {
v.Set(extra[i], extra[i+1])
}
for _, pk := range pks {
v.Add("src_pk", strconv.FormatInt(pk, 10))
}
return v
}
// 先把三个地点都写上别名 + 备注
for _, pk := range pks {
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, int(pk), "别名", "备注"); err != nil {
t.Fatalf("预置备注失败: %v", err)
}
}
post(bulk("verdict", "verdict", "1"))
if n := f.scalar(t, `SELECT count(*) FROM place_votes WHERE user_id=$1 AND verdict=1`, uid); n != len(pks) {
t.Fatalf("三个地点都该设上结论,实际 %d", n)
}
post(bulk("clear"))
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1 AND note <> ''`, uid); n != 0 {
t.Fatalf("清空后不该还有手写备注,剩 %d 条", n)
}
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1 AND alias='别名'`, uid); n != len(pks) {
t.Fatalf("清空备注要保留别名,实际剩 %d 条", n)
}
post(bulk("verdict", "verdict", "0"))
if n := f.scalar(t, `SELECT count(*) FROM place_votes WHERE user_id=$1`, uid); n != 0 {
t.Fatalf("取消结论后不该还有记录,剩 %d 条", n)
}
post(bulk("delete"))
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1`, uid); n != 0 {
t.Fatalf("删除后不该还有备注,剩 %d 条", n)
}
}