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

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)
}
}
+81
View File
@@ -0,0 +1,81 @@
package web
import (
"context"
"os"
"regexp"
"strings"
"testing"
)
// TestAggHelpersPropagateErrors 是 D1 的行为守卫:四个「重算聚合 / 写标签」的辅助函数
// 必须把数据库错误返回给调用方。
//
// 它们原先的签名是 `(isHome, isWork bool)` / 无返回值,Scan 的错误被直接丢掉——
// 于是「查询炸了」和「查到了 false/false」在调用方看来一模一样:到访会被写得没有归属活动,
// 计数会静默地停在旧值上。用一个已取消的 context 就能逼出这一层错误。
func TestAggHelpersPropagateErrors(t *testing.T) {
f := newMovementFixture(t)
// 已取消的 context:任何一次 Query/Exec 都会立刻以 context.Canceled 返回。
ctx, cancel := context.WithCancel(f.ctx)
cancel()
if err := f.srv.refreshDatasetAgg(ctx, f.pg, f.dsID); err == nil {
t.Error("refreshDatasetAgg 吞掉了错误:ctx 已取消却返回 nil")
}
if err := f.srv.refreshPlaceAgg(ctx, f.pg, 1); err == nil {
t.Error("refreshPlaceAgg 吞掉了错误:ctx 已取消却返回 nil")
}
if err := f.srv.saveVisitTags(ctx, f.pg, f.dsID, 1, nil); err == nil {
t.Error("saveVisitTags 吞掉了错误:ctx 已取消却返回 nil")
}
if _, _, err := f.srv.activityFlags(ctx, f.pg, 1); err == nil {
t.Error("activityFlags 吞掉了错误:ctx 已取消却返回 nil")
}
}
// TestNoAggCallSiteSwallowsError 是 D1 的静态守卫:这三个辅助函数「返回 error」只有在
// 每个调用点都接住它时才有意义。裸调用(语句形式、丢掉返回值)会随第一个新加的调用点回归,
// 而且编译器不会拦——这里按行扫描源码,把裸调用钉死。
func TestNoAggCallSiteSwallowsError(t *testing.T) {
// 只匹配「整行就是一次调用」的裸语句,合法写法前面一定还有 `if err := `、`return `、
// `logAggFailure(` 等前缀,因此不会被误伤。
bare := regexp.MustCompile(`^\s*s\.(refreshDatasetAgg|refreshPlaceAgg|saveVisitTags)\(`)
for _, name := range []string{
"handlers_edit.go",
"handlers_photos.go",
"fillup.go",
} {
path := pkgFile(t, name)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
for i, line := range strings.Split(string(data), "\n") {
if bare.MatchString(line) {
t.Errorf("%s:%d 裸调用吞掉了 error:%s", name, i+1, strings.TrimSpace(line))
}
}
}
}
// TestAggHelpersHaveErrorReturn 钉住签名本身:如果谁把它们改回无返回值,
// 上面两条用例会失去意义(编译期就不会再有 error 可断言)。
func TestAggHelpersHaveErrorReturn(t *testing.T) {
data, err := os.ReadFile(pkgFile(t, "handlers_edit.go"))
if err != nil {
t.Fatalf("读 handlers_edit.go 失败: %v", err)
}
src := string(data)
for _, sig := range []string{
"func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) error",
"func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error",
"func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error",
} {
if !strings.Contains(src, sig) {
t.Errorf("签名被改掉了(不再返回 error):%s", sig)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// mkFile 造一个指定字节数的候选文件。
func mkFile(t *testing.T, dir, name string, n int) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), n), 0o644); err != nil {
t.Fatalf("造 %s 失败: %v", name, err)
}
return p
}
// P5 回归:按摘要找回备份包时,必须先按字节数筛一遍再算摘要。
//
// 只看「最终返回哪个文件」新旧写法完全一样——真正的差别是**算了几次 sha256**,
// 而 uploads 里每份备份动辄几百 MB,多算一次就是几百 MB 的读盘。所以这里注入一个
// 会计数的 hash,把「有没有白算」直接钉住。
func TestPickArchiveSkipsWrongSizedFilesWithoutHashing(t *testing.T) {
dir := t.TempDir()
// 两个诱饵都比正主新(会排在候选前面),大小都和正主不同
d1 := mkFile(t, dir, "decoy-newest.rondbackup", 10)
d2 := mkFile(t, dir, "decoy-mid.rondbackup", 20)
right := mkFile(t, dir, "right.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{d1, d2, right}, "sha-right", 30, func(f string) (string, error) {
hashed++
// 按文件名给摘要:这样「选中谁」和「算了几次」互相独立——
// 去掉预筛时选中的仍然是 right,但白读了两个诱饵(hashed=3)。
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != right {
t.Errorf("应当选中 %s,实际 %s", filepath.Base(right), filepath.Base(got))
}
if hashed != 1 {
t.Errorf("只该对大小相符的那份算摘要,实际算了 %d 次(白读了 %d 份整包)", hashed, hashed-1)
}
}
// 反向约束:筛选只许按大小筛,不许把大小恰好相符的候选也一起跳过。
// 三份大小相同、只有最后一份摘要对得上 ⇒ 必须老老实实算三次。
func TestPickArchiveHashesEverySameSizedCandidate(t *testing.T) {
dir := t.TempDir()
a := mkFile(t, dir, "a.rondbackup", 30)
b := mkFile(t, dir, "b.rondbackup", 30)
c := mkFile(t, dir, "c.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{a, b, c}, "sha-c", 30, func(f string) (string, error) {
hashed++
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != c {
t.Errorf("应当选中 c.rondbackup,实际 %s", filepath.Base(got))
}
if hashed != 3 {
t.Errorf("大小都相符时不能跳过任何一份,实际只算了 %d 次", hashed)
}
}
// 退化路径:库里没记 sha(老数据)时不按大小筛,保持「取最新的那份」的旧行为——
// 否则会因为记不到 size 而突然一份都选不中,导出直接失败。
func TestPickArchiveKeepsNewestWhenNoSha(t *testing.T) {
dir := t.TempDir()
newest := mkFile(t, dir, "newest.rondbackup", 10)
older := mkFile(t, dir, "older.rondbackup", 999)
hashed := 0
got := pickArchiveBySha([]string{newest, older}, "", 0, func(f string) (string, error) {
hashed++
return "whatever", nil
})
if got != newest {
t.Errorf("没有 sha 时应当取第一份可读的,实际 %s", filepath.Base(got))
}
if hashed != 1 {
t.Errorf("没有 sha 时不该多算,实际算了 %d 次", hashed)
}
}
// 目录与读不到的文件要跳过,不能因为一份坏候选就整段失败。
func TestPickArchiveSkipsDirsAndUnreadable(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
missing := filepath.Join(dir, "does-not-exist.rondbackup")
ok := mkFile(t, dir, "ok.rondbackup", 5)
got := pickArchiveBySha([]string{missing, filepath.Join(dir, "subdir"), ok}, "sha-ok", 5, func(f string) (string, error) {
if f == ok {
return "sha-ok", nil
}
t.Errorf("不该对 %s 算摘要", f)
return "nope", nil
})
if got != ok {
t.Errorf("应当跳过缺失与目录后选中 ok.rondbackup,实际 %q", got)
}
// 找不到时返回空串(调用方据此报「请重新上传」)
if s := pickArchiveBySha([]string{ok}, "sha-none", 5, func(string) (string, error) { return "other", nil }); s != "" {
t.Errorf("没有命中时应返回空串,实际 %q", s)
}
}
// findSourceArchive 必须把 size_bytes 一起查出来——只查 sha 的话上面那道筛选就永远不生效,
// 而且编译器不会拦(筛挂掉、越筛越慢,行为测试看不出来)。
func TestFindSourceArchiveConsultsSizeBytes(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_export.go"))
if err != nil {
t.Fatalf("读 handlers_export.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) findSourceArchive(")
if !strings.Contains(body, "size_bytes") {
t.Error("findSourceArchive 没有读取 size_bytes:按大小预筛会永远拿不到目标大小")
}
if !strings.Contains(body, "pickArchiveBySha(") {
t.Error("findSourceArchive 没有走 pickArchiveBySha:按大小预筛没有生效")
}
}
+45 -2
View File
@@ -87,10 +87,37 @@ func visitCounts(t *testing.T, page string) (added, gone, clash int) {
return
}
// shiftSrcPKs 把库里的编号整体平移,模拟「这份数据其实来自另一份编号完全不同的备份」。
//
// 这不是臆造的场景:rond 每次导出/同步都会重排 Z_PK,所以「把本地和后来新增的备份合并」
// 时,两边编号一定对不上,唯一稳定的键只有到访 `ZIDENTIFIER_` / 活动 `ZUID_`。
// 夹具导入的就是 ROND_SRC 本身,编号与备份逐条相同(实测 823/823),
// 所以不平移的话,比对会走「编号直接命中的快路径」,UUID 对齐这条真正在用的路径反而测不到。
//
// 平移量取得足够大(远超库里最大编号),单条 UPDATE 就不会在语句中途撞上唯一约束;
// 同时把指向到访编号的外键(movements.from/to_visit_src、weather.visit_src)和留档一起挪。
func (f *movementFixture) shiftSrcPKs(t *testing.T, delta int) {
t.Helper()
for _, q := range []string{
`UPDATE visits SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE places SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE activities SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE movements SET src_pk = src_pk + $2,
from_visit_src = from_visit_src + $2, to_visit_src = to_visit_src + $2 WHERE dataset_id=$1`,
`UPDATE weather SET src_pk = src_pk + $2, visit_src = visit_src + $2 WHERE dataset_id=$1`,
`UPDATE entity_raw SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
} {
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, delta); err != nil {
t.Fatalf("平移编号失败: %v\n%s", err, q)
}
}
}
// 同一份备份再传一次:UUID 对得上、字段也没变,到访应当一条不差。
//
// 这条也顺带钉住「按 UUID 而不是编号对齐」:库内那些行的编号与备份里完全不同
// (rond 重排过),只按编号比会让 800 多条全变冲突。
// ⚠️ 注意这条用例**没有**验证「按 UUID 对齐」:夹具导入的就是 f.src 本身,
// 库里的 src_pk 与备份的 Z_PK 逐条相同(实测 823/823),它是靠编号直接命中的。
// 真正钉住 UUID 对齐的是 TestBackupDiffAlignsByUUIDNotSrcPK / TestBackupMergeRenumberedBackup。
//
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
@@ -102,6 +129,22 @@ func TestBackupDiffIdentical(t *testing.T) {
}
}
// 编号全被平移后(= 数据来自另一份重排过 Z_PK 的备份),同一份内容再比一次仍须零差异。
//
// 这是「本地 + 后来新增的备份」合并的真正前提:rond 每次导出都重排编号,
// 若比对退回按编号对齐,这里会报成「几百条备份新增 + 几百条仅站内有」,
// 勾一下「并进来」就会把整份足迹**复制一遍**。
func TestBackupDiffAlignsByUUIDNotSrcPK(t *testing.T) {
f := newMovementFixture(t)
f.shiftSrcPKs(t, 500000)
page := previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("编号平移后内容其实没变,差异应为 0;实际 新增 %d 仅站内有 %d 冲突 %d"+
" ⇒ 比对是靠编号对齐的,跨备份合并会把足迹复制一遍", a, g, c)
}
}
// 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。
//
// 这里的关键是「按 UUID 对齐」:库里那些行的 Z_PK 与备份里完全不同
+4 -2
View File
@@ -731,8 +731,10 @@ func writeRawRow(ctx context.Context, ex execer, dsID int64, entity string, pk i
}
blob, err := json.Marshal(raw)
if err != nil {
log.Printf("留档 %s#%d 序列化失败: %v", entity, pk, err)
return nil
// 这里以前只 log 后 return nil:调用方会以为留档成功,合并照常提交,
// 于是这行的原始列永久缺失(下次比对认不出 UUID、导出也还原不回去)。
// 必须上抛,让整个合并事务回滚。
return fmt.Errorf("留档 %s#%d 序列化失败: %w", entity, pk, err)
}
_, err = ex.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
VALUES ($1,$2,$3,$4,false)
+84
View File
@@ -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)
+65
View File
@@ -0,0 +1,65 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// S6 回归:X-Forwarded-For 只有**最后一段**可信。
//
// 反代(nginx 的 `$proxy_add_x_forwarded_for`)是把客户端发来的值原样接在前面、
// 再把真实对端补在后面。所以取第一段等于「谁想冒充谁自己填」——访客随手改个请求头
// 就能换一个投票身份。取最后一段才是反代自己写、客户端改不动的那一个。
func TestClientIPTrustsOnlyProxyWrittenSegment(t *testing.T) {
cases := []struct {
name string
remote string
xff []string
want string
}{
{"没有 XFF 时用 RemoteAddr", "203.0.113.7:51234", nil, "203.0.113.7"},
{"RemoteAddr 不带端口", "203.0.113.7", nil, "203.0.113.7"},
{"单段 XFF(反代覆盖式写入)", "10.0.0.1:80", []string{"203.0.113.7"}, "203.0.113.7"},
{"多段 XFF 取最后一段", "10.0.0.1:80", []string{"198.51.100.9, 203.0.113.7"}, "203.0.113.7"},
{"最后一段两边有空格", "10.0.0.1:80", []string{"198.51.100.9 , 203.0.113.7 "}, "203.0.113.7"},
{"多行 XFF 拼接后取最后", "10.0.0.1:80", []string{"198.51.100.9", "203.0.113.7"}, "203.0.113.7"},
{"最后一段带端口", "10.0.0.1:80", []string{"198.51.100.9, 203.0.113.7:51234"}, "203.0.113.7"},
{"IPv6", "10.0.0.1:80", []string{"198.51.100.9, 2001:db8::1"}, "2001:db8::1"},
{"IPv4-mapped IPv6 归一", "10.0.0.1:80", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
// 最后一段不是 IP ⇒ 整个头作废,别把垃圾当身份
{"最后一段是垃圾", "203.0.113.7:51234", []string{"198.51.100.9, not-an-ip"}, "203.0.113.7"},
{"XFF 只有垃圾", "203.0.113.7:51234", []string{"whatever"}, "203.0.113.7"},
{"XFF 为空串", "203.0.113.7:51234", []string{""}, "203.0.113.7"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/vote", nil)
r.RemoteAddr = c.remote
for _, v := range c.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != c.want {
t.Errorf("clientIP = %q,期望 %q", got, c.want)
}
})
}
}
// 真正要守的性质:只改伪造的前缀,不该改变访客身份。
// 这条比上面任何单点断言都硬——它直接对应「换个请求头就换一票」这个攻击。
func TestClientIPIgnoresForgedPrefix(t *testing.T) {
mk := func(forged string) *http.Request {
r := httptest.NewRequest("POST", "/api/vote", nil)
r.RemoteAddr = "10.0.0.1:80"
r.Header.Set("X-Forwarded-For", forged+", 203.0.113.7")
return r
}
base := clientIP(mk(""))
for _, forged := range []string{"1.2.3.4", "9.9.9.9", "::1", "198.51.100.9"} {
got := clientIP(mk(forged))
if got != base {
t.Errorf("伪造前缀 %q 让身份变成了 %q(基线 %q)——换个头就能换一票", forged, got, base)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"log"
"net/http"
"net/url"
"strings"
)
// csrfOriginGuard 挡「跨站发起的写请求」。
//
// 会话 cookie 是 SameSite=Lax,跨站 POST 本来就带不上 cookie;这层是纵深防御,
// 顺手把「将来有人把 SameSite 放宽」和「同站不同源」这两条路一起堵掉。
//
// 判据只认浏览器一定会带的两个头,不需要给每个表单塞 token,因此模板一行都不用改:
// - 有 Origin:必须是本站;`Origin: null`(沙箱 iframe / file://)一律拒。
// - 没有 Origin 但有 Referer:必须是本站。
// - 两个都没有:放行 —— 跨站 POST 浏览器必带 Origin(只有 Referrer-Policy 把它
// 一起抹掉的情况,而那时 Lax 已经把 cookie 拦在外面了),所以这里只可能是
// curl / 脚本这类非浏览器客户端。
//
// 注意:反代必须把**原始 Host** 传过来(nginx 的 `proxy_set_header Host $host`),
// 并且改了 Host 时要带 X-Forwarded-Host;否则本站自己的表单也会被拒。
func (s *Server) csrfOriginGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
next.ServeHTTP(w, r)
return
}
if !sameOriginRequest(r) {
log.Printf("拒绝跨站写请求 %s %s:Origin=%q Referer=%q Host=%q",
r.Method, r.URL.Path, r.Header.Get("Origin"), r.Header.Get("Referer"), r.Host)
http.Error(w, "跨站请求已被拒绝", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// sameOriginRequest 判断写请求是不是本站自己发起的。
func sameOriginRequest(r *http.Request) bool {
if o := r.Header.Get("Origin"); o != "" {
return matchesRequestHost(r, o)
}
if ref := r.Header.Get("Referer"); ref != "" {
return matchesRequestHost(r, ref)
}
return true
}
// matchesRequestHost 比较 raw(Origin 或 Referer)的 host 与本请求的 host。
// X-Forwarded-Host 也在候选里:反代改写 Host 时靠它兜底。
func matchesRequestHost(r *http.Request, raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false // 含 "null"、相对地址等无法定位来源的形态
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
if i := strings.IndexByte(xfh, ','); i >= 0 {
xfh = xfh[:i]
}
return strings.EqualFold(u.Host, strings.TrimSpace(xfh))
}
return false
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// sameOriginRequest 是 CSRF 纵深防御的核心判据。会话 cookie 已经是 SameSite=Lax,
// 这层是防「将来有人把 SameSite 放宽」以及同站不同源。
func TestSameOriginRequest(t *testing.T) {
cases := []struct {
name string
host string
origin string
ref string
xfh string
want bool
}{
{name: "无任何来源头(curl/脚本)", host: "site.example", want: true},
{name: "本站 Origin", host: "site.example", origin: "http://site.example", want: true},
{name: "本站 Origin(带端口)", host: "site.example:8080", origin: "http://site.example:8080", want: true},
{name: "本站 Origin(https)", host: "site.example", origin: "https://site.example", want: true},
{name: "跨站 Origin", host: "site.example", origin: "http://evil.com", want: false},
{name: "跨站 Origin 同后缀", host: "site.example", origin: "http://site.example.evil.com", want: false},
{name: "Origin 为 null(沙箱 iframe)", host: "site.example", origin: "null", want: false},
{name: "Origin 不可解析", host: "site.example", origin: "://", want: false},
{name: "跨站 Referer", host: "site.example", ref: "http://evil.com/admin", want: false},
{name: "本站 Referer", host: "site.example", ref: "http://site.example/admin/notes", want: true},
{name: "Origin 优先于 Referer", host: "site.example", origin: "http://evil.com", ref: "http://site.example/x", want: false},
{name: "反代改写 Host,靠 XFH 兜底", host: "10.0.0.5:8080", origin: "https://site.example",
xfh: "site.example", want: true},
{name: "反代 XFH 多段取首段", host: "10.0.0.5:8080", origin: "https://site.example",
xfh: "site.example, proxy.internal", want: true},
{name: "反代 XFH 也不匹配", host: "10.0.0.5:8080", origin: "https://evil.com",
xfh: "site.example", want: false},
}
for _, c := range cases {
r := httptest.NewRequest("POST", "/admin/logout", nil)
r.Host = c.host
if c.origin != "" {
r.Header.Set("Origin", c.origin)
}
if c.ref != "" {
r.Header.Set("Referer", c.ref)
}
if c.xfh != "" {
r.Header.Set("X-Forwarded-Host", c.xfh)
}
if got := sameOriginRequest(r); got != c.want {
t.Errorf("%s: sameOriginRequest = %v,期望 %v", c.name, got, c.want)
}
}
}
// 走完整中间件链验证接线:写请求跨站被 403,读请求不受影响。
func TestHandlerRejectsCrossOriginWrite(t *testing.T) {
f := newLoginFixture(t)
h := f.srv.Handler()
post := func(method, path, origin, ref string) int {
t.Helper()
r := httptest.NewRequest(method, path, nil)
if origin != "" {
r.Header.Set("Origin", origin)
}
if ref != "" {
r.Header.Set("Referer", ref)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
if code := post("POST", "/admin/logout", "http://evil.com", ""); code != http.StatusForbidden {
t.Errorf("跨站 POST 应 403,实际 %d", code)
}
if code := post("POST", "/admin/logout", "null", ""); code != http.StatusForbidden {
t.Errorf("Origin: null 的 POST 应 403,实际 %d", code)
}
if code := post("POST", "/admin/logout", "", "http://evil.com/x"); code != http.StatusForbidden {
t.Errorf("跨站 Referer 的 POST 应 403,实际 %d", code)
}
// 本站来源与「无来源头」都要放行到业务逻辑(不是 403 即可)
for _, c := range []struct{ name, origin, ref string }{
{"本站 Origin", "http://example.com", ""},
{"无来源头", "", ""},
} {
if code := post("POST", "/admin/logout", c.origin, c.ref); code == http.StatusForbidden {
t.Errorf("%s 的 POST 不该被拦,实际 403", c.name)
}
}
// GET 是读,带跨站 Origin 也不能拦(否则外链图片/预取就废了)
if code := post("GET", "/healthz", "http://evil.com", ""); code == http.StatusForbidden {
t.Errorf("GET 不该被 CSRF 守卫拦,实际 403")
}
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// S9 回归:下载文件名不能从引号里「逃出去」。
//
// 导出包名里带着 `datasets.original_name`,也就是上传时的文件名。它原样拼进
// `filename="…"` 时,一个 `"` 就能提前闭合引号、把后面的内容变成新的头参数
// (`x"; filename="evil.exe`)。net/http 只把 CR/LF 换成空格,引号它不管。
func TestDownloadDispositionCannotEscapeTheHeader(t *testing.T) {
// 结构:attachment; filename="<纯 ASCII 安全串>"; filename*=UTF-8''<百分号编码>
// 集合里必须带上 `-`:attr-char 允许它,而日期段 `-export-20260921` 全是它。
shape := regexp.MustCompile(`^attachment; filename="([^"]*)"; filename\*=UTF-8''([-A-Za-z0-9._~%]*)$`)
cases := []struct {
in string
want string // 期望 filename* 解回来的原名;空表示与 in 相同
}{
{`普通名字-export-20260921.rondbackup`, ""},
{`a"b.rondbackup`, ""},
{`a"; filename="evil.exe`, ""},
{"换\n行\r.rondbackup", ""},
{`..%2f..%2fetc%2fpasswd`, ""},
{`C:\Windows\system32\x.rondbackup`, ""},
{`a;b=c:d*e?f<g>h|i.rondbackup`, ""},
{`日本語のバックアップ.rondbackup`, ""},
{"", "download"},
{" ", "download"},
}
for _, c := range cases {
got := downloadDisposition(c.in)
if strings.ContainsAny(got, "\r\n") {
t.Errorf("%q → 头部里出现了裸的换行: %q", c.in, got)
}
if n := strings.Count(got, `"`); n != 2 {
t.Errorf("%q → 引号有 %d 个(必须恰好一对,多了就能逃逸): %q", c.in, n, got)
}
m := shape.FindStringSubmatch(got)
if m == nil {
t.Errorf("%q → 头部形状不对: %q", c.in, got)
continue
}
if strings.ContainsAny(m[1], `"\\/:;:=*?<>|`) {
t.Errorf("%q → filename= 里还有危险字符: %q", c.in, m[1])
}
// 非 ASCII 名字才需要 filename* 兜底;这里统一验「能解回原名」
back, err := url.PathUnescape(m[2])
if err != nil {
t.Fatalf("%q → filename*= 不是合法的百分号编码: %v", c.in, err)
}
want := c.want
if want == "" {
want = c.in
}
if back != want {
t.Errorf("%q → filename*= 解回来是 %q,期望 %q", c.in, back, want)
}
}
}
// 纯中文名会把 filename= 整串换成下划线,此时必须保留扩展名,
// 否则用户下到手里是个没法双击打开的「download」。
func TestDownloadDispositionKeepsExtensionOnNonASCII(t *testing.T) {
got := downloadDisposition("足迹备份.rondbackup")
if !strings.Contains(got, `.rondbackup"`) {
t.Errorf("非 ASCII 名的 ASCII 兜底丢了扩展名: %q", got)
}
if !strings.HasPrefix(got, `attachment; filename="download`) {
t.Errorf("非 ASCII 名应当退到可读的兜底名: %q", got)
}
}
// S9 静态守卫:所有 Content-Disposition 都必须走 downloadDisposition,
// 不许再手拼 f-string——手拼就是这次的成因,而且它只在特定文件名下出问题。
func TestAllContentDispositionGoThroughHelper(t *testing.T) {
dir := pkgFile(t, "")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读包目录失败: %v", err)
}
scanned, found := 0, 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
scanned++
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("读 %s 失败: %v", e.Name(), err)
}
src := string(data)
for i := 0; ; {
j := strings.Index(src[i:], `"Content-Disposition"`)
if j < 0 {
break
}
j += i
found++
// 取一小段窗口看这次 Set 是不是用的助手
win := src[j:]
if len(win) > 200 {
win = win[:200]
}
if !strings.Contains(win, "downloadDisposition(") {
line := 1 + strings.Count(src[:j], "\n")
t.Errorf("%s:%d 的 Content-Disposition 没有走 downloadDisposition:%s",
e.Name(), line, strings.TrimSpace(strings.SplitN(win, "\n", 2)[0]))
}
i = j + 1
}
}
if scanned < 5 {
t.Fatalf("只扫了 %d 个源文件,守卫形同虚设", scanned)
}
if found < 3 {
t.Fatalf("只找到 %d 处 Content-Disposition(期望至少 3 处),守卫判据需要更新", found)
}
}
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"context"
"math"
"testing"
)
// D2 回归:留档序列化失败必须把错误上抛,让整个合并事务回滚。
// 以前是只 log 后 return nil —— 调用方以为写成功了,合并照常提交,
// 这一行的原始列就永久缺失(下次跨备份比对认不出 UUID,导出也还原不回去)。
func TestWriteRawRowFailsLoudlyOnMarshalError(t *testing.T) {
f := newLoginFixture(t)
ctx := context.Background()
var ds int64
if err := f.pg.QueryRowContext(ctx, `SELECT id FROM datasets ORDER BY id LIMIT 1`).Scan(&ds); err != nil {
t.Skipf("库里没有数据集:%v", err)
}
const pk = 987654321
t.Cleanup(func() {
ctx := context.Background()
for _, e := range []string{"ZVISIT", "ZLOCATION"} {
f.pg.ExecContext(ctx, `DELETE FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$3`, ds, e, pk)
}
})
// NaN 是 json.Marshal 明确拒绝的值,拿来触发真实的序列化失败
err := writeRawRow(ctx, f.pg, ds, "ZVISIT", pk, map[string]any{"ZBAD": math.NaN()})
if err == nil {
t.Fatal("序列化失败必须返回错误;返回 nil 等于让留档静默丢失")
}
var n int
if err := f.pg.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, ds, pk).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("失败时不应留下 entity_raw 行,实际 %d 行", n)
}
// 正常值必须写得进去(否则上面的断言可能只是「这张表根本写不动」)
if err := writeRawRow(ctx, f.pg, ds, "ZLOCATION", pk, map[string]any{"ZNAME": "回归测试"}); err != nil {
t.Fatalf("正常留档不应失败: %v", err)
}
var got string
if err := f.pg.QueryRowContext(ctx, `SELECT raw->>'ZNAME' FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, ds, pk).Scan(&got); err != nil {
t.Fatalf("读取刚写入的留档失败: %v", err)
}
if got != "回归测试" {
t.Errorf("留档内容对不上:%q", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
// deadlineRecorder 冒充 http.response:只有实现了 SetWriteDeadline,
// 才看得出 http.NewResponseController 有没有被中间件挡住。
type deadlineRecorder struct {
*httptest.ResponseRecorder
deadline time.Time
got bool
}
func (d *deadlineRecorder) SetWriteDeadline(t time.Time) error {
d.deadline, d.got = t, true
return nil
}
// P4 回归:gzipWriter 必须实现 Unwrap()。少了它,被 gzip 中间件包住的请求调
// http.NewResponseController(...).SetWriteDeadline/Flush 只会拿到 ErrNotSupported。
func TestGzipWriterUnwrapsToUnderlyingWriter(t *testing.T) {
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
gw := &gzipWriter{ResponseWriter: rec}
if err := http.NewResponseController(gw).SetWriteDeadline(time.Now().Add(time.Minute)); err != nil {
t.Fatalf("ResponseController 没能穿过 gzipWriter: %v", err)
}
if !rec.got {
t.Error("SetWriteDeadline 没有落到真正的 ResponseWriter 上")
}
}
// P4 回归:导出是「一次性大响应」,得带上 Content-Length(下载进度条、
// 也顺带锁住「不再依赖隐式 200」),并把写死线放宽到全站 120s 之外。
func TestExportSetsContentLengthAndExtendsWriteDeadline(t *testing.T) {
f := newMovementFixture(t)
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest("GET", "/admin/export/rondbackup", nil).WithContext(f.ctx)
f.srv.exportRondbackup(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("导出返回 %d:%s", rec.Code, firstLine(rec.Body.String()))
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length=%q,实际写出 %d 字节", cl, rec.Body.Len())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type 不对: %q", ct)
}
if !rec.got {
t.Error("导出没有延长写死线(慢链路下会被全站 120s 截断)")
}
}
+24 -3
View File
@@ -31,11 +31,29 @@ type fillupHint struct {
var (
fillupMu sync.Mutex
fillupCache = map[int64]fillupHint{}
fillupOrder []int64 // 键的插入顺序,超限时据此淘汰最旧的一批
)
// fillupCacheMax 是内存提示缓存的条目上限。
//
// 个人站点的地点数是有限的,这里兜的是「站点长期运行、数据集换了一轮又一轮」时的无界增长:
// 光写不清的全局 map 会一路涨到把 1C1G 的机器吃干净。超限就丢掉最旧的四分之一,
// 让缓存自己收敛——比整表清空温和,不会一次性把所有高德建议都丢掉。
const fillupCacheMax = 2048
func fillupCachePut(placeID int64, h fillupHint) {
fillupMu.Lock()
defer fillupMu.Unlock()
if _, ok := fillupCache[placeID]; !ok {
if len(fillupOrder) >= fillupCacheMax {
n := fillupCacheMax / 4
for _, k := range fillupOrder[:n] {
delete(fillupCache, k)
}
fillupOrder = append(fillupOrder[:0], fillupOrder[n:]...)
}
fillupOrder = append(fillupOrder, placeID)
}
fillupCache[placeID] = h
}
@@ -381,7 +399,10 @@ func (s *Server) adminFillupApply(w http.ResponseWriter, r *http.Request) {
actByName[name] = actID
newActs++
}
isHome, isWork := s.activityFlags(ctx, ex, actID)
isHome, isWork, err := s.activityFlags(ctx, ex, actID)
if err != nil {
return err
}
res, err := ex.ExecContext(ctx, `UPDATE visits v SET activity_id=$1, is_home=$2, is_work=$3
WHERE v.dataset_id=$4 AND v.activity_id IS NULL
AND v.place_id = (SELECT p.id FROM places p WHERE p.dataset_id=$4 AND p.id=$5)`,
@@ -451,8 +472,8 @@ func (s *Server) fillupTargets(ctx context.Context, ex execer, datasetID int64)
return out, rows.Err()
}
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool) {
ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool, err error) {
err = ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
return
}
+51
View File
@@ -0,0 +1,51 @@
package web
import (
"strconv"
"testing"
)
// fillupCache 是全局的、只写不清的内存缓存:长期运行必须有上限,
// 否则站点换了几轮数据集之后 map 会一路涨下去,把 1C1G 的机器吃干净。
func TestFillupCacheIsBounded(t *testing.T) {
fillupMu.Lock()
savedCache, savedOrder := fillupCache, fillupOrder
fillupCache, fillupOrder = map[int64]fillupHint{}, nil
fillupMu.Unlock()
t.Cleanup(func() {
fillupMu.Lock()
fillupCache, fillupOrder = savedCache, savedOrder
fillupMu.Unlock()
})
total := fillupCacheMax + fillupCacheMax/2
for i := 0; i < total; i++ {
fillupCachePut(int64(i), fillupHint{Act: strconv.Itoa(i)})
}
fillupMu.Lock()
size, order := len(fillupCache), len(fillupOrder)
fillupMu.Unlock()
if size > fillupCacheMax {
t.Fatalf("缓存条目应被限制在 %d 以内,实际 %d(又变成无界增长了)", fillupCacheMax, size)
}
if order != size {
t.Fatalf("淘汰后 order 应与 cache 等长:order=%d cache=%d", order, size)
}
if _, ok := fillupHintOf(int64(total - 1)); !ok {
t.Error("最后写入的条目不该被淘汰")
}
if _, ok := fillupHintOf(0); ok {
t.Error("最旧的条目应当已被淘汰")
}
// 覆盖写同一个键不该让 order 重复记账(否则 order 会比 cache 长,淘汰时错删)。
before := order
fillupCachePut(int64(total-1), fillupHint{Act: "again"})
fillupMu.Lock()
after := len(fillupOrder)
fillupMu.Unlock()
if after != before {
t.Errorf("重复写同一个键不该增加 order 长度:%d -> %d", before, after)
}
}
+17 -8
View File
@@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"rond-with-you/internal/fog"
@@ -26,22 +27,27 @@ const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB
// 迷雾是站点级数据(与公开页读数据不区分归属的约定一致),
// 内存里只持一份索引,首次访问时从库里加载。
//
// 指针用 atomic 而不是普通变量:清空迷雾(fogResetIndex)会在请求 goroutine 里换掉它,
// 而瓦片/导出/统计同时在读 —— 直接赋值就是一个真的 data race。
var (
fogOnce sync.Once
fogIndexV *fog.Index
fogIndexV atomic.Pointer[fog.Index]
)
func (s *Server) fogIndex() (*fog.Index, error) {
fogOnce.Do(func() {
fogIndexV = fog.NewIndex()
if err := fogIndexV.LoadAll(s.db); err != nil {
fogIndexV = nil
ix := fog.NewIndex()
// 加载完才发布:中途的实例不能被别的 goroutine 看到(这也是Once的意义)
if err := ix.LoadAll(s.db); err == nil {
fogIndexV.Store(ix)
}
})
if fogIndexV == nil {
ix := fogIndexV.Load()
if ix == nil {
return nil, fmt.Errorf("迷雾数据加载失败")
}
return fogIndexV, nil
return ix, nil
}
// handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是
@@ -441,8 +447,11 @@ func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []str
// fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用),
// 下次访问时按空库重建。
//
// 被清掉的那个索引对象可能还有请求正拿着用(Import/View/All 都走它自己的锁),
// 这里只换指针、不动旧对象,那些请求会安全地跑完。
func fogResetIndex() {
fogIndexV = fog.NewIndex()
fogIndexV.Store(fog.NewIndex())
}
// adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。
@@ -483,7 +492,7 @@ func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) {
_, cells := ix.Stats()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="fog-export-%s.fwss"`, time.Now().Format("20060102-150405")))
downloadDisposition("fog-export-"+time.Now().Format("20060102-150405")+".fwss"))
w.Header().Set("X-Fog-Cells", strconv.Itoa(cells))
w.Write(out)
}
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"sync"
"testing"
"rond-with-you/internal/fog"
)
// S1 回归:清空迷雾时换索引指针,而瓦片/导出/统计正在同时读它。
//
// ⚠️ 这条用例**必须带 -race 跑才有意义**:
//
// go test -race -count=1 -run TestFogIndexConcurrentResetAndRead ./internal/web/
//
// 不带 -race 时它只能证明「并发下不 panic、拿到的不是 nil」。
func TestFogIndexConcurrentResetAndRead(t *testing.T) {
f := newLoginFixture(t)
// 收尾:把索引恢复成「从库里加载」的状态,别把清空后的空索引留给后面的用例
t.Cleanup(func() {
if ix := fog.NewIndex(); ix.LoadAll(f.srv.db) == nil {
fogIndexV.Store(ix)
}
})
// 先触发一次初始加载,让后面的并发都落在「读已发布指针」这条路径上
if _, err := f.srv.fogIndex(); err != nil {
t.Fatalf("首次加载迷雾索引失败: %v", err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
ix, err := f.srv.fogIndex()
if err != nil || ix == nil {
t.Errorf("并发读取拿到了空索引: ix=%v err=%v", ix, err)
return
}
ix.Stats() // 真实读者会做的事(内部有自己的锁)
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
fogResetIndex() // 与 adminFogClear 同一个调用
}
}()
wg.Wait()
}
+177
View File
@@ -0,0 +1,177 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
// S8 回归:GPX 轨迹必须按属主圈定。
//
// gpx_tracks 带 user_id(为将来多用户留位),但两个入口都没用它:
// - `GET /api/gpx` 是**公开**接口,却把整张表端出去;
// - `POST /admin/gpx/delete` 的 id 来自客户端,只按 id 删就能删别人的。
//
// 单用户站点上影响不大,但这是「越权」而不是「影响小」——而且两处都只差一个 user_id 条件。
func TestGpxOwnershipScoping(t *testing.T) {
f := newMovementFixture(t)
ctx, pg := f.ctx, f.pg
var owner int64
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets WHERE id=$1`, f.dsID).Scan(&owner); err != nil {
t.Fatalf("读数据集属主失败: %v", err)
}
// 外来账号:只为了拥有一条不属于本站主的轨迹
foreign := fmt.Sprintf("e2e-gpx-other-%d", time.Now().UnixNano())
var foreignID int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,'x','gpx-other') RETURNING id`, foreign).Scan(&foreignID); err != nil {
t.Fatalf("建外来账号失败: %v", err)
}
mkTrack := func(uid int64, name string) int64 {
t.Helper()
var id int64
if err := pg.QueryRowContext(ctx, `INSERT INTO gpx_tracks
(user_id, name, point_count, length_km, points)
VALUES ($1,$2,1,1.0,'[[40.0,116.0]]'::jsonb) RETURNING id`, uid, name).Scan(&id); err != nil {
t.Fatalf("插入轨迹 %s 失败: %v", name, err)
}
return id
}
strayTrack := mkTrack(foreignID, "FOREIGN-"+foreign)
ownTrack := mkTrack(owner, "OWNER-"+foreign)
t.Cleanup(func() {
pg.ExecContext(ctx, `DELETE FROM gpx_tracks WHERE id IN ($1,$2)`, strayTrack, ownTrack)
pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, foreignID)
})
// 1) 公开接口不许端出别人的轨迹。
// 判据不依赖「哪份数据集恰好是活跃的」:只要求返回的每条都属于活跃属主,
// 而外来那条必须不在其中——后者才是安全不变量。
var activeOwner int64
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&activeOwner); err != nil {
t.Fatalf("读活跃数据集属主失败: %v", err)
}
rec := httptest.NewRecorder()
f.srv.apiGpx(rec, httptest.NewRequest("GET", "/api/gpx", nil))
if rec.Code != 200 {
t.Fatalf("apiGpx 返回 %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "FOREIGN-") {
t.Errorf("公开接口把别的账号的轨迹也端出来了:%s", truncate(body, 300))
}
if !strings.Contains(body, "OWNER-") {
t.Fatalf("活跃数据集属主(user_id=%d)自己的轨迹没返回,用例失去意义:%s",
activeOwner, truncate(body, 300))
}
// 2) 删除别人账号的轨迹必须被拒,而且不能真删掉。
victim := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/gpx/delete",
strings.NewReader(url.Values{"id": {fmt.Sprint(strayTrack)}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminGpxDelete(victim, req.WithContext(f.ctx))
if msg := flashMsg(t, victim); !strings.Contains(msg, "不存在") {
t.Errorf("删别人的轨迹应当回「不存在」,实际 %q", msg)
}
var still int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, strayTrack).Scan(&still); err != nil {
t.Fatal(err)
}
if still != 1 {
t.Errorf("别人的轨迹被删掉了(id=%d)——越权删除", strayTrack)
}
// 3) 删自己的要能删掉(别为堵越权把正常路径也堵死了)。
okRec := httptest.NewRecorder()
req = httptest.NewRequest("POST", "/admin/gpx/delete",
strings.NewReader(url.Values{"id": {fmt.Sprint(ownTrack)}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminGpxDelete(okRec, req.WithContext(f.ctx))
if msg := flashMsg(t, okRec); !strings.Contains(msg, "已删除") {
t.Errorf("删自己的轨迹应当成功,实际 %q", msg)
}
var gone int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, ownTrack).Scan(&gone); err != nil {
t.Fatal(err)
}
if gone != 0 {
t.Errorf("自己的轨迹没被删掉(id=%d)", ownTrack)
}
}
// truncate 只用于把失败信息压短,超长时截断。
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// sqlText 取出函数体里所有反引号包的 SQL 片段。
//
// 守卫必须只看 SQL:函数里的**中文注释**也会出现「user_id」这个词,
// 直接 strings.Contains(body, "user_id") 会被注释顶包——变异掉真条件照样绿。
func sqlText(body string) string {
var b strings.Builder
for i := 0; ; {
a := strings.IndexByte(body[i:], '`')
if a < 0 {
break
}
a += i
z := strings.IndexByte(body[a+1:], '`')
if z < 0 {
break
}
z += a + 1
b.WriteString(body[a+1 : z])
b.WriteByte('\n')
i = z + 1
}
return b.String()
}
// S8 静态守卫:两个入口的 SQL 都必须带 user_id 条件。
// 行为用例要导一份备份包才能跑(约 6 秒);这条永远不 skip,防止「改回去」时静默回归。
func TestGpxQueriesCarryUserID(t *testing.T) {
for _, c := range []struct {
file string
sig string
want []string
}{
{"handlers_public.go", "func (s *Server) apiGpx(", []string{"user_id"}},
{"handlers_admin.go", "func (s *Server) adminGpxDelete(", []string{"user_id"}},
} {
data, err := os.ReadFile(pkgFile(t, c.file))
if err != nil {
t.Fatalf("读 %s 失败: %v", c.file, err)
}
body := sliceFunc(t, string(data), c.sig)
q := sqlText(body)
if strings.TrimSpace(q) == "" {
t.Fatalf("%s 的 %s 里没找到 SQL 字符串,守卫的切片判据需要更新", c.file, c.sig)
}
for _, w := range c.want {
if !strings.Contains(q, w) {
t.Errorf("%s 的 %s 的 SQL 里没有 %q:轨迹没按属主圈定", c.file, c.sig, w)
}
}
}
// 删不到时的反馈也要有断言:不然「删别人的」会回一句「已删除」。
data, err := os.ReadFile(pkgFile(t, "handlers_admin.go"))
if err != nil {
t.Fatal(err)
}
del := sliceFunc(t, string(data), "func (s *Server) adminGpxDelete(")
if !strings.Contains(del, "RowsAffected") {
t.Error("adminGpxDelete 没看受影响行数:删不到也会回「已删除」")
}
}
+5
View File
@@ -88,6 +88,11 @@ func (g *gzipWriter) Flush() {
}
}
// Unwrap 让 http.NewResponseController 能穿过这层包装拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠它逐层解包)。少了这个方法,
// 被包装的请求调 SetWriteDeadline 只会拿到 ErrNotSupported。
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
func (g *gzipWriter) close() {
if g.gz != nil {
g.gz.Close()
+78 -26
View File
@@ -62,6 +62,15 @@ func (s *Server) checkCaptcha(r *http.Request) bool {
return s.captcha.Verify(c.Value, r.PostFormValue("captcha"))
}
// tooManyTries 把等待时长写成一句人话。向上取整,免得显示「请 0 秒后再试」。
func tooManyTries(wait time.Duration) string {
sec := int(wait/time.Second) + 1
if sec < 60 {
return fmt.Sprintf("登录尝试过于频繁,请 %d 秒后再试", sec)
}
return fmt.Sprintf("登录尝试过于频繁,请 %d 分钟后再试", (sec+59)/60)
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
sets, err := s.sets.Load(r.Context())
if err != nil {
@@ -99,20 +108,33 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
// 爆破短锁:验证码之后还有一道,连错到阈值就按用户名指数退避
if wait := s.loginGuard.RetryAfter(username); wait > 0 {
sets, _ := s.sets.Load(ctx)
s.renderStatus(w, http.StatusTooManyRequests, "admin/login", LoginData{
Settings: sets, Err: tooManyTries(wait), Next: next,
})
return
}
u, err := s.auth.Authenticate(ctx, username, password)
if err != nil {
sets, _ := s.sets.Load(ctx)
code := http.StatusOK
if !errors.Is(err, auth.ErrInvalidCredentials) {
code = http.StatusInternalServerError
}
msg := "用户名或密码错误"
if code == http.StatusInternalServerError {
msg = "登录失败:" + err.Error()
if !errors.Is(err, auth.ErrInvalidCredentials) {
// 登录页是公开的,内部错误只进日志,不回显给门外的人
log.Printf("登录失败 %s: %v", username, err)
code = http.StatusInternalServerError
msg = "登录失败,请稍后重试"
} else if wait := s.loginGuard.Fail(username); wait > 0 {
code = http.StatusTooManyRequests
msg = tooManyTries(wait)
}
s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
return
}
s.loginGuard.Succeed(username)
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled {
sets, _ := s.sets.Load(ctx)
@@ -121,9 +143,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
})
return
}
s.login(w, u.ID, u.Hash)
s.login(w, r, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
// 同样先过站内路径判据(挡控制字符/引号),再限定必须落在 /admin 下
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -202,6 +225,15 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
}
stored := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name)))
// 落盘之后每条失败路径都要把半成品删掉:否则反复上传坏包会在 uploads 里堆垃圾。
// 只有「新导入成功」那一支会 keepStored = true —— 那份上传件要留给导出当基线源
// (findSourceArchive 就是按 datasets.sha256 在 uploads 里找它)。
keepStored := false
defer func() {
if !keepStored {
os.Remove(stored)
}
}()
out, err := os.Create(stored)
if err != nil {
s.serverError(w, r, err)
@@ -265,6 +297,7 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
// 否则 rond 原生的标志位(ZPINSTYLE_/ZRADIUS/ZVISIT.ZRAW…)会被洗成 NULL,越导越失真。
// 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。
if !st.Identical {
keepStored = true
if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil {
log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err)
}
@@ -866,19 +899,15 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
var msg string
switch action {
case "delete":
for _, id := range ids {
if err := s.notes.Delete(r.Context(), u.ID, ds.ID, int(id)); err != nil {
s.serverError(w, r, err)
return
}
if err := s.notes.DeleteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
s.serverError(w, r, err)
return
}
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
case "clear":
for _, id := range ids {
if err := s.notes.SetNote(r.Context(), u.ID, ds.ID, int(id), ""); err != nil {
s.serverError(w, r, err)
return
}
if err := s.notes.SetNoteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
s.serverError(w, r, err)
return
}
msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids))
case "verdict":
@@ -886,11 +915,9 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
for _, id := range ids {
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, int(id), verdict); err != nil {
s.serverError(w, r, err)
return
}
if err := s.votes.SaveVerdictBulk(r.Context(), u.ID, ds.ID, ids, verdict); err != nil {
s.serverError(w, r, err)
return
}
if verdict == 0 {
msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids))
@@ -941,7 +968,7 @@ func (s *Server) adminPlaceNote(w http.ResponseWriter, r *http.Request) {
}
// 备注管理页的行内表单带 next,保存后回到原处(保留筛选);
// 没有 next 时按 Referer 判断,都没有就回后台首页。
if next := r.PostFormValue("next"); strings.HasPrefix(next, "/") {
if next := safeInternalPath(r.PostFormValue("next")); next != "" {
sep := "?"
if strings.Contains(next, "?") {
sep = "&"
@@ -1000,13 +1027,31 @@ func (s *Server) login2FA(w http.ResponseWriter, r *http.Request) {
fail("两步验证状态异常,请重新登录")
return
}
// 两步验证也要限速:中间令牌有 5 分钟有效期,不限速的话 6 位码在窗口内
// 足够被打穿。键带前缀,与「口令那一环」分开计数。
guardKey := "2fa:" + u.Username
if wait := s.loginGuard.RetryAfter(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
if !auth.VerifyTOTP(secret, code) {
if wait := s.loginGuard.Fail(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
fail("验证码不正确")
return
}
s.login(w, u.ID, u.Hash)
// 同一个码在有效窗口内只用一次,否则被偷看的码可以再换一个会话。
// 这一条不计入爆破计数:用户手抖重复提交也会撞上,不该因此被越锁越久。
if !s.totpReplay.Consume(u.ID, code) {
fail("这个验证码刚用过,请等下一个")
return
}
s.loginGuard.Succeed(guardKey)
s.login(w, r, u.ID, u.Hash)
target := "/admin"
if strings.HasPrefix(next, "/admin") {
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -1174,15 +1219,22 @@ func (s *Server) adminGpxUpload(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) adminGpxDelete(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil {
s.adminFlash(w, r, "err", "轨迹编号无效")
return
}
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1`, id); err != nil {
// 带上 user_id:这个 id 是客户端传来的,只按 id 删就能删掉别人的轨迹。
res, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1 AND user_id=$2`, id, u.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
s.adminFlash(w, r, "err", "轨迹不存在")
return
}
s.adminFlash(w, r, "ok", "轨迹已删除")
}
+261 -150
View File
@@ -14,6 +14,7 @@ import (
"time"
"rond-with-you/internal/geo"
"rond-with-you/internal/rond"
)
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
@@ -184,7 +185,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
`+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`,
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon,
COALESCE((SELECT string_agg(x.n || ' ' || x.c, ' · ' ORDER BY x.c DESC, x.n) FROM (
SELECT COALESCE(a.name,'(无)') AS n, count(*) AS c FROM visits v
@@ -631,8 +632,10 @@ func (s *Server) editOptions(ctx context.Context, query string, args ...any) ([]
// 就会复用同一个 src_pk,于是新行继承旧留档的 rond 私有字段(ZRADIUS/ZRAW 等)。
// skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。
// 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。
func (s *Server) pruneEntityRaw(ctx context.Context, datasetID int64) {
if _, err := s.db.ExecContext(ctx, `DELETE FROM entity_raw r
// 收 ex(*sql.DB 或事务)是为了让调用方把它和「删实体」放进同一个事务——
// 分两次提交的话,中间失败会留下「实体没了、留档还在」的错位。
func (s *Server) pruneEntityRaw(ctx context.Context, ex execer, datasetID int64) {
if _, err := ex.ExecContext(ctx, `DELETE FROM entity_raw r
WHERE r.dataset_id=$1 AND NOT r.skipped AND (
(r.entity='ZLOCATION' AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=r.dataset_id AND p.src_pk=r.src_pk))
OR (r.entity='ZVISIT' AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk))
@@ -695,23 +698,38 @@ func (s *Server) editActivityDelete(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": "请选择要删除的活动"})
return
}
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
// 先确认活动存在再动手,整组写进一个事务:清到访标记与删活动分开提交的话,
// 中间失败会留下「到访的活动被摘掉了、活动还在」的半截状态。
notFound := false
if err := s.withTx(ctx, func(ex execer) error {
var exists bool
if err := ex.QueryRowContext(ctx, `SELECT true FROM activities WHERE id=$1 AND dataset_id=$2`,
id, ds.ID).Scan(&exists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
res, err := s.db.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
if notFound {
s.json(w, map[string]any{"error": "活动不存在"})
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"ok": true})
}
@@ -807,7 +825,10 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
return errors.New("活动不存在")
}
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走
isHome, isWork := s.activityFlags(ctx, ex, to)
isHome, isWork, err := s.activityFlags(ctx, ex, to)
if err != nil {
return err
}
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
if err != nil {
@@ -820,8 +841,8 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": err.Error()})
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
s.pruneEntityRaw(ctx, s.db, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"ok": true, "visits": moved})
}
@@ -919,7 +940,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
redirectEdit(w, r, "ok", "已为所选地点新增到访")
return
}
@@ -975,7 +996,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
if hasVisit {
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc))
} else {
@@ -1077,7 +1098,7 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
}
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
if actID != nil {
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
}
msg := "地点已更新"
@@ -1228,27 +1249,31 @@ func (s *Server) editPlaceDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "地点标识无效")
return
}
// 该地点名下到访的 src_pk 集合(删地点会级联删掉这些到访)
// 三条写语句必须同一个事务:删到一半失败会留下「地点还在、它的行程没了」,
// 或者更糟——地点没了但行程还指着已经不存在的到访(rond 里的悬空引用就是这么来的)。
const visitSrcs = `SELECT src_pk FROM visits WHERE dataset_id=$1
AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
if err := s.withTx(ctx, func(ex execer) error {
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
return err
}
// 天气本身有独立价值,只解除关联,不连坐删除
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
// 天气本身有独立价值,只解除关联,不连坐删除
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除")
}
@@ -1289,7 +1314,7 @@ func (s *Server) editActivityCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"id": id, "name": name})
}
@@ -1316,38 +1341,55 @@ func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) {
}
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
var oldPlace int64
var curArrival time.Time
var curDeparture sql.NullTime
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
notFound := false
// 改到访、夹回引用它的行程、重写标签、刷聚合必须同一个事务:夹行程那步失败会留下
// 「到访时间已越界、行程还挂在旧窗口上」的坏数据,导出到 rond 里就是非法区间。
if err := s.withTx(ctx, func(ex execer) error {
var curArrival time.Time
var curDeparture sql.NullTime
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
arrival = keepStoredTime(arrival, &curArrival)
if departure != nil && curDeparture.Valid {
kept := keepStoredTime(*departure, &curDeparture.Time)
departure = &kept
}
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
r.PostFormValue("bookmarked") == "on",
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
return err
}
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
if err := s.clampVisitMovements(ctx, ex, ds.ID, src); err != nil {
return err
}
if err := s.saveVisitTags(ctx, ex, ds.ID, src, r.PostForm["tags"]); err != nil {
return err
}
if oldPlace > 0 {
if err := s.refreshPlaceAgg(ctx, ex, oldPlace); err != nil {
return err
}
}
return s.refreshDatasetAgg(ctx, ex, ds.ID)
}); err != nil {
s.serverError(w, r, err)
return
}
if notFound {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
arrival = keepStoredTime(arrival, &curArrival)
if departure != nil && curDeparture.Valid {
kept := keepStoredTime(*departure, &curDeparture.Time)
departure = &kept
}
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
r.PostFormValue("bookmarked") == "on",
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
s.serverError(w, r, err)
return
}
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
if err := s.clampVisitMovements(ctx, s.db, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"])
if oldPlace > 0 {
s.refreshPlaceAgg(ctx, s.db, oldPlace)
}
s.refreshDatasetAgg(ctx, ds.ID)
redirectEdit(w, r, "ok", "到访已更新")
}
@@ -1369,23 +1411,25 @@ func (s *Server) adminVisitBookmark(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "到访标识无效")
return
}
res, err := s.db.ExecContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src)
if err != nil {
// 一条语句搞定「翻转并回读」:以前是 UPDATE 之后再单独 SELECT,那次回读一旦失败
// 就落进 else 报「已取消收藏」——而它有一半是错的(状态根本不知道)。
// 顺带:翻转与回读在同一条语句里,中间也不再有人插进来的窗口。
var on bool
if err := s.db.QueryRowContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
WHERE dataset_id=$1 AND src_pk=$2
RETURNING bookmarked`, ds.ID, src).Scan(&on); err != nil {
if errors.Is(err, sql.ErrNoRows) {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
redirectEdit(w, r, "err", "到访记录不存在")
return
msg := "已取消收藏"
if on {
msg = "已标记为收藏"
}
var on bool
if err := s.db.QueryRowContext(ctx, `SELECT bookmarked FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, src).Scan(&on); err == nil && on {
redirectEdit(w, r, "ok", "已标记为收藏")
return
}
redirectEdit(w, r, "ok", "已取消收藏")
redirectEdit(w, r, "ok", msg)
}
func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -1404,28 +1448,42 @@ func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
return
}
var placeID int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
notFound := false
// 删行程 / 解天气关联 / 删到访必须同一个事务:中间失败会留下指着不存在到访的悬空行程,
// 导出到 rond 里就是 8 组悬空引用那种坏数据。
if err := s.withTx(ctx, func(ex execer) error {
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
notFound = true
return nil
}
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
return err
}
if _, err := ex.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
return err
}
s.pruneEntityRaw(ctx, ex, ds.ID)
if placeID > 0 {
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
return err
}
}
return s.refreshDatasetAgg(ctx, ex, ds.ID)
}); err != nil {
s.serverError(w, r, err)
return
}
if notFound {
redirectEdit(w, r, "err", "到访记录不存在")
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
s.serverError(w, r, err)
return
}
s.pruneEntityRaw(ctx, ds.ID)
if placeID > 0 {
s.refreshPlaceAgg(ctx, s.db, placeID)
}
s.refreshDatasetAgg(ctx, ds.ID)
redirectEdit(w, r, "ok", "到访已删除")
}
@@ -1813,40 +1871,54 @@ func (s *Server) editMovementUpdate(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效")
return
}
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, pk)
if !ok {
redirectEdit(w, r, "err", msg)
return
}
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID,
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
if msg != "" {
redirectEdit(w, r, "err", msg)
return
}
var trSrc, trName, trColor, trIcon any
if tr.Src > 0 {
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
}
res, err := s.db.ExecContext(ctx, `UPDATE movements SET
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, pk, trSrc, trName, trColor, trIcon,
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
src.Start, src.End, src.DurationMin, src.DistanceKm)
if err != nil {
var tr movementTransport
var failMsg string
done := false
// 校验(读起点/终点到访)与两处写入必须同一事务,否则并发改动会让派生列与两端锚点对不上。
if err := s.withTx(ctx, func(ex execer) error {
src, ok, msg := s.validateMovementForm(ctx, ex, ds.ID, r, pk)
if !ok {
failMsg = msg
return nil
}
var tmsg string
tr, tmsg = s.resolveMovementTransport(ctx, ex, ds.ID,
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
if tmsg != "" {
failMsg = tmsg
return nil
}
var trSrc, trName, trColor, trIcon any
if tr.Src > 0 {
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
}
res, err := ex.ExecContext(ctx, `UPDATE movements SET
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
WHERE dataset_id=$1 AND src_pk=$2`,
ds.ID, pk, trSrc, trName, trColor, trIcon,
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
src.Start, src.End, src.DurationMin, src.DistanceKm)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
failMsg = "行程不存在"
return nil
}
if err := syncTransport(ctx, ex, ds.ID, tr); err != nil {
return err
}
done = true
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
redirectEdit(w, r, "err", "行程不存在")
return
}
if err := syncTransport(ctx, s.db, ds.ID, tr); err != nil {
s.serverError(w, r, err)
if !done {
redirectEdit(w, r, "err", failMsg)
return
}
redirectEdit(w, r, "ok", "行程已更新")
@@ -1870,16 +1942,28 @@ func (s *Server) editMovementDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效")
return
}
res, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
if err != nil {
notFound := false
// 删行程与清留档必须同一事务:删除成功但清留档失败会留下「号已空、留档还在」的酸数据,
// 下一次新建复用该 src_pk 时会继承这条的 rond 私有字段。
if err := s.withTx(ctx, func(ex execer) error {
res, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
notFound = true
return nil
}
s.pruneEntityRaw(ctx, ex, ds.ID)
return nil
}); err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
if notFound {
redirectEdit(w, r, "err", "行程不存在")
return
}
s.pruneEntityRaw(ctx, ds.ID)
redirectEdit(w, r, "ok", "行程已删除")
}
@@ -2138,43 +2222,55 @@ func (s *Server) insertVisit(ctx context.Context, ex execer, datasetID, userID,
bookmarked, remark, emoji); err != nil {
return 0, err
}
s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags)
s.refreshPlaceAgg(ctx, ex, placeID)
if err := s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags); err != nil {
return 0, err
}
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
return 0, err
}
return nextSrc, nil
}
// saveVisitTags 重写到访的标签关联。
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) {
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) error {
var visitID int64
if err := ex.QueryRowContext(ctx, `SELECT id FROM visits WHERE dataset_id=$1 AND src_pk=$2`, datasetID, visitSrc).Scan(&visitID); err != nil {
return
return fmt.Errorf("查待标标签的到访(src_pk=%d)失败: %w", visitSrc, err)
}
if _, err := ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID); err != nil {
return fmt.Errorf("清到访标签失败: %w", err)
}
ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID)
for _, t := range tagIDs {
id, err := strconv.ParseInt(t, 10, 64)
if err != nil || id <= 0 {
continue
}
ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID)
if _, err := ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID); err != nil {
return fmt.Errorf("写到访标签失败: %w", err)
}
}
return nil
}
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) {
ex.ExecContext(ctx, `UPDATE places p SET
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE places p SET
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
first_visit_at = v.f, last_visit_at = v.l
FROM (SELECT $1::bigint AS pid) x
LEFT JOIN (SELECT place_id, count(*) c, COALESCE(sum(LEAST(COALESCE(duration_min,0), 10080)),0) d, min(arrival) f, max(arrival) l
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid
WHERE p.id = x.pid`, placeID)
WHERE p.id = x.pid`, placeID); err != nil {
return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err)
}
return nil
}
// refreshDatasetAgg 重算数据集的到访数 / 地点数 / 时间跨度(页脚「数据截至」与后台 flash 用)。
// 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。
func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
s.db.ExecContext(ctx, `UPDATE datasets d SET
func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error {
if _, err := ex.ExecContext(ctx, `UPDATE datasets d SET
visit_count = COALESCE(v.c,0), first_visit_at = v.f, last_visit_at = v.l,
place_count = COALESCE(p.c,0)
FROM (SELECT $1::bigint AS did) x
@@ -2182,7 +2278,20 @@ func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
FROM visits WHERE dataset_id=$1 GROUP BY dataset_id) v ON v.dataset_id = x.did
LEFT JOIN (SELECT dataset_id, count(*) c
FROM places WHERE dataset_id=$1 GROUP BY dataset_id) p ON p.dataset_id = x.did
WHERE d.id = x.did`, datasetID)
WHERE d.id = x.did`, datasetID); err != nil {
return fmt.Errorf("重算数据集(id=%d)聚合失败: %w", datasetID, err)
}
return nil
}
// logAggFailure 专给「写入已经提交」的那几个刷新点用。
//
// 这时不能把请求判成失败:数据其实已经落库了,报错会让用户以为没保存、再点一次就多建一条——
// 比计数滞后严重得多。但也绝不能静默,否则计数会一路漂下去且日志里查不到。
func logAggFailure(what string, err error) {
if err != nil {
log.Printf("刷新%s失败(数据已写入,计数可能滞后): %v", what, err)
}
}
func parseFloat(v string) (float64, bool) {
@@ -2313,10 +2422,12 @@ func (s *Server) apiCoords(w http.ResponseWriter, r *http.Request) {
// 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标
// (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。
// 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。
const suspectWGS = `(r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'
AND abs((r.raw->>'ZRAWLATITUDE')::float8 - COALESCE(p.lat,0)) < 1e-9
AND abs((r.raw->>'ZRAWLONGITUDE')::float8 - COALESCE(p.lon,0)) < 1e-9
//
// 两边的取值都走 RawFloat8:留档值不是数字时给 NULL,比较结果也是 NULL = 不命中,
// 不会像以前那样因为 `::float8` 把整页 500 掉(旧版那条正则 '^-?[0-9.]+$' 连
// "1.2.3" 都放行,等于没护栏)。
var suspectWGS = `abs(` + rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`) + ` - COALESCE(p.lat,0)) < 1e-9
AND abs(` + rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`) + ` - COALESCE(p.lon,0)) < 1e-9
AND p.lat BETWEEN 3.86 AND 53.55 AND p.lon BETWEEN 73.66 AND 135.05`
// 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移),
+107 -9
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -82,7 +84,7 @@ func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
w.Write([]byte(b.String()))
}
@@ -120,10 +122,74 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
downloadDisposition("places-"+time.Now().Format("20060102")+".gpx"))
w.Write([]byte(b.String()))
}
// downloadDisposition 拼 Content-Disposition。
//
// 导出包的名字里带着 `datasets.original_name`,那是**上传时的文件名**——含引号、斜杠、
// 控制字符都正常,甚至可能整段 `"; filename*=…` 之类的内容。原样塞进 `filename="…"`
// 就能提前闭合引号、往这个头里塞参数(net/http 只把 CR/LF 换成空格,管不了引号)。
// 所以给两个值:
// - `filename=` 只留 ASCII 安全字符,老浏览器兜底;
// - `filename*=`(RFC 5987)按 UTF-8 百分号编码带真实名字,现代浏览器优先取它,
// 中文名不会像以前那样被当成 Latin-1 乱码。
func downloadDisposition(name string) string {
if strings.TrimSpace(name) == "" {
name = "download"
}
ext := filepath.Ext(name)
stem := asciiSafe(strings.TrimSuffix(name, ext))
// 纯中文名的词干映射完就是一串下划线,给个能读的兜底名;但不能把扩展名一起丢掉,
// 否则用户下到手里是个没法双击打开的「download」。
if !hasAlnum(stem) {
stem = "download"
}
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", stem+asciiSafe(ext), rfc5987Escape(name))
}
// asciiSafe 把不能安全写进 `filename="…"` 的字符换成下划线:
// 引号能提前闭合这个参数,斜杠/反斜杠会在落盘时被当成路径,`;` `=` 能伪造后续参数。
func asciiSafe(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '_'
}
switch r {
case '"', '\\', '/', ';', ':', '=', '*', '?', '<', '>', '|':
return '_'
}
return r
}, s)
}
func hasAlnum(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
})
}
// rfc5987Escape 按 RFC 5987 的 attr-char 编码:ASCII 字母数字与 `. _ - ~` 原样,
// 其余**逐字节**百分号编码(UTF-8 的每个字节各编一次,这正是规范要求的)。
// 故意只放行这个窄集合:`;` `=` 这类在 attr-char 之外,漏掉就能截断参数。
func rfc5987Escape(s string) string {
const hexDigits = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
@@ -150,7 +216,8 @@ func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, e
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
var size int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
return "", err
}
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
@@ -163,19 +230,41 @@ func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string
}
return fi.ModTime().After(fj.ModTime())
})
if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) {
got, _, err := ingest.File(f)
return got, err
}); f != "" {
return f, nil
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
}
// pickArchiveBySha 从候选文件里挑出摘要匹配的那一份(没有则返回空串)。
//
// 先按字节数筛一道再算摘要:uploads 里可能堆着几十份几百 MB 的备份,每份都得完整读一遍
// 才比得出 sha256,而 sha256 相同就一定字节数相同——大小对不上的根本不必读。
// 没有 sha 可比时(老库或退化数据)不做筛选,保持「取最新的那份」的旧行为。
//
// hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,
// 带不带这道筛选完全一样,区分不出来。
func pickArchiveBySha(files []string, sha string, size int64, hash func(string) (string, error)) string {
for _, f := range files {
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
fi, err := os.Stat(f)
if err != nil || fi.IsDir() {
continue
}
got, _, err := ingest.File(f)
if sha != "" && size > 0 && fi.Size() != size {
continue
}
got, err := hash(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f, nil
return f
}
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
return ""
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
@@ -204,6 +293,15 @@ func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
w.Write(buf.Bytes())
downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
}
w.WriteHeader(http.StatusOK)
if _, err := buf.WriteTo(w); err != nil {
log.Printf("导出:下载写出中断: %v", err)
}
}
+3
View File
@@ -89,6 +89,9 @@ func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
// 必须先卡请求体:ParseMultipartForm 只限制「内存里留多少」,超出的部分会
// 无上限地落到临时文件,光靠后面 LimitReader 拦不住磁盘被写满。
r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
return
+1 -1
View File
@@ -233,7 +233,7 @@ func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
imported++
}
s.refreshDatasetAgg(ctx, ds.ID)
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
if withImage > 0 {
msg += fmt.Sprintf(",配了 %d 张图", withImage)
+25 -7
View File
@@ -97,8 +97,9 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
p.FilterBase = r.URL.Path
p.Presets = buildPresets(p.From, p.To)
// 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用),
// BackQS 是把自己挂给别人的(列表页生成详情链接用)。
// BackQS 是把自己挂给别人的(列表页生成详情链接用),SelfPath 是它的未编码原文。
p.Back = backFromRequest(r)
p.SelfPath = selfPath(r)
p.BackQS = backQS(r)
return p, nil
}
@@ -715,10 +716,6 @@ func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
}
}
}
if d.Weekday, err = s.q.Weekday(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
for _, m := range d.Monthly {
if m.Count > d.MaxMonthly {
d.MaxMonthly = m.Count
@@ -1101,10 +1098,31 @@ type GPXTrack struct {
// apiGpx 返回已导入的 GPX 轨迹。库里存的是 WGS-84(GPX 标准),
// 底图是 GCJ-02 时按点转成火星坐标,保证和足迹点对齐。
//
// 轨迹按**当前生效数据集的属主**圈定,不是把整张表端出去:gpx_tracks 带 user_id,
// 这是公开接口、不带登录态,不按属主过滤就等于把别人的轨迹一起露给访客。
// 没有生效数据集时返回空集——和页面其余部分「没数据就没有」的口径一致。
func (s *Server) apiGpx(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
empty := func() {
s.json(w, struct {
Tracks []GPXTrack `json:"tracks"`
}{Tracks: []GPXTrack{}})
}
var owner int64
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM datasets
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
empty()
return
}
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
crs := r.URL.Query().Get("crs")
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, points, length_km, started_at
FROM gpx_tracks ORDER BY started_at DESC NULLS LAST, id DESC`)
rows, err := s.db.QueryContext(ctx, `SELECT id, name, points, length_km, started_at
FROM gpx_tracks WHERE user_id=$1 ORDER BY started_at DESC NULLS LAST, id DESC`, owner)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
+63 -20
View File
@@ -94,17 +94,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
power(sin(radians((%s - p.lat) / 2)), 2) +
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
func topicAreaCond(t *Topic, args *[]any) string {
if !t.HasArea() {
return ""
}
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
n := len(*args)
return fmt.Sprintf(" AND %s <= $%d",
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
}
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
args := []any{datasetID, t.StartAt, t.EndAt}
area := ""
if t.HasArea() {
args = append(args, t.CenterLat, t.CenterLon, t.RadiusKm)
n := len(args)
area = fmt.Sprintf(" AND %s <= $%d",
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
}
area := topicAreaCond(t, &args)
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
// 不能把这份(已被追加过的)args 整个传进去。
@@ -168,18 +174,54 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
return points, totalVisits, days, len(cities), nil
}
// fillTopicStats 给专题填上内容统计(列表页展示用)。
func (s *Server) fillTopicStats(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) {
points, visits, days, cities, err := s.topicContent(ctx, t, datasetID, regions, exclude)
// fillTopicStatsBatch 一次算完所有专题的内容统计。
//
// 列表页原本每个专题跑 2 条查询(点位 + 天数),专题一多就是 2N 次往返;而列表
// 只需要汇总数字、不需要点位。这里用 UNION ALL 把 N 个专题拼成一条查询:每个子查询
// 自带一套占位符(从 base 往后编),args 顺序必须与拼接顺序一致。口径与详情页
// (topicPage → topicContent)完全一致 —— 同一专题在列表页和详情页必须数字相同。
func (s *Server) fillTopicStatsBatch(ctx context.Context, list []Topic, datasetID int64,
regions []string, exclude bool) error {
if len(list) == 0 {
return nil
}
var b strings.Builder
var args []any
for i := range list {
t := &list[i]
if i > 0 {
b.WriteString(" UNION ALL ")
}
base := len(args)
args = append(args, datasetID, t.StartAt, t.EndAt)
area := topicAreaCond(t, &args)
region := regionSQL("p", regions, exclude, &args)
fmt.Fprintf(&b, `SELECT %d AS tid, count(DISTINCT p.id), count(*),
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date),
count(DISTINCT NULLIF(p.city,'')),
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0)
FROM places p JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d%s%s`,
i, base+1, base+2, base+3, area, region)
}
rows, err := s.db.QueryContext(ctx, b.String(), args...)
if err != nil {
// 以前这里直接 return:专题列表会静默显示成「0 个地点」,看不出是出错了。
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
return
return err
}
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
for _, p := range points {
t.DwellMin += p.Dwell
defer rows.Close()
for rows.Next() {
var i, places, visits, days, cities int
var dwell int64
if err := rows.Scan(&i, &places, &visits, &days, &cities, &dwell); err != nil {
return err
}
if i < 0 || i >= len(list) {
return fmt.Errorf("专题统计返回了越界序号 %d", i)
}
list[i].PlaceCount, list[i].VisitCount, list[i].DayCount, list[i].CityCount = places, visits, days, cities
list[i].DwellMin = dwell
}
return rows.Err()
}
// parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59,
@@ -249,8 +291,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
for i := range list {
s.fillTopicStats(ctx, &list[i], ds.ID, nil, false) // 后台管理页:站长看全部
// 后台管理页:站长看全部
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
log.Printf("专题统计失败: %v", err)
}
d.Topics = list
// 编辑模式:?edit=<id>
@@ -372,8 +415,8 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
return
}
regions, exclude := visitorRegions(p)
for i := range list {
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude)
if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil {
log.Printf("专题统计失败: %v", err)
}
d.Topics = list
}
+46 -10
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
@@ -17,18 +18,53 @@ import (
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
const voterCookie = "rond_voter"
// clientIP 取访客 IP,优先用反代透传的 X-Forwarded-For 第一段。
// clientIP 取访客 IP,匿名投票身份用它叠一层「同一来源」的识别。
//
// X-Forwarded-For 客户端自己也能发,所以这里有两个讲究:
// 1. 取**最后一段**而不是第一段。nginx 的 `$proxy_add_x_forwarded_for` 是把客户端发来的值
// 原样接在**前面**、再把真实对端补在后面,所以第一段恰恰是最好伪造的那个;最后一段是
// 反代自己写的,客户端改不动。
// 2. 只有最后一段确实能解析成 IP 才采用,否则整个头作废、退回 RemoteAddr——
// 免得一句垃圾字符串被当成身份。
//
// 遗留 1:若本站直接暴露在公网(前面没有反代),客户端仍能自造这个头。要彻底堵上得在最外层
// 覆盖或剥掉它(`proxy_set_header X-Forwarded-For $remote_addr`)。
//
// 遗留 2:反代前面再叠一层 CDN(而反代没配 `real_ip`)时,最后一段会变成 CDN 自己的 IP,
// 于是所有访客被叠成同一个来源。cookie 仍各不相同,只是这层「同源」识别退化成全员共用,
// 不会把谁认成谁。真有多层代理,判据要改成「按已知跳数从右往左取」,不能写死最后一段。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
if ip, ok := lastForwardedIP(r.Header.Values("X-Forwarded-For")); ok {
return ip
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return hostOnly(r.RemoteAddr)
}
// lastForwardedIP 取 X-Forwarded-For 的最后一段并校验成 IP。多行头按逗号拼起来看,
// 与实际代理的拼装方式一致;IPv4-mapped IPv6 归一成 IPv4,同一客户端不该因为写法不同而换身份。
func lastForwardedIP(header []string) (string, bool) {
raw := strings.Join(header, ",")
if i := strings.LastIndexByte(raw, ','); i >= 0 {
raw = raw[i+1:]
}
last := strings.TrimSpace(raw)
if addr, err := netip.ParseAddr(last); err == nil {
return addr.Unmap().String(), true
}
if host, _, err := net.SplitHostPort(last); err == nil {
if addr, err := netip.ParseAddr(host); err == nil {
return addr.Unmap().String(), true
}
}
return "", false
}
// hostOnly 去掉 RemoteAddr 里的端口;没有端口就原样返回。
func hostOnly(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return r.RemoteAddr
return remoteAddr
}
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
@@ -238,8 +274,8 @@ func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
next := r.PostFormValue("next")
if !strings.HasPrefix(next, "/") {
next := safeInternalPath(r.PostFormValue("next"))
if next == "" {
next = "/admin"
}
http.Redirect(w, r, next, http.StatusSeeOther)
+170
View File
@@ -0,0 +1,170 @@
package web
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
)
// loginFixture 是一个只有账号、没有业务数据的测试库,专测登录这条路径。
//
// ROND_TEST_CONF=conf/app-test.ini go test ./internal/web -run Login -v
//
// 不需要 ROND_SRC:登录用不到备份包,省掉那次几十兆的导入。
type loginFixture struct {
pg *sql.DB
srv *Server
uid int64
user string
pass string
}
func newLoginFixture(t *testing.T) *loginFixture {
t.Helper()
confPath := os.Getenv("ROND_TEST_CONF")
if confPath == "" {
t.Skip("设置 ROND_TEST_CONF 后运行登录校验")
}
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
user := fmt.Sprintf("e2e-login-%d", time.Now().UnixNano())
pass := "e2e-correct-horse"
hash, err := auth.HashPassword(pass)
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
t.Cleanup(func() { pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &loginFixture{pg: pg, srv: srv, uid: uid, user: user, pass: pass}
}
// attempt 走一次真实的登录提交:每次配一张新验证码(验证码是一次性的,
// 不复用才能保证测到的是「口令这一环」,而不是被验证码拦下)。
func (f *loginFixture) attempt(t *testing.T, username, password string) *httptest.ResponseRecorder {
t.Helper()
id, _, err := f.srv.captcha.Issue()
if err != nil {
t.Fatalf("签发验证码失败: %v", err)
}
code, ok := f.srv.captcha.Answer(id)
if !ok {
t.Fatal("刚签发的验证码取不到答案")
}
form := url.Values{"username": {username}, "password": {password}, "captcha": {code}}
r := httptest.NewRequest("POST", "/admin/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: captchaCookie, Value: id})
w := httptest.NewRecorder()
f.srv.loginSubmit(w, r)
return w
}
// TestLoginSubmitLocksAfterRepeatedFailures 证明登录失败会按用户名上锁:
// 连错到阈值后,**即使口令是对的**也进不去,直到冷却结束。
//
// 这条链路以前完全没有限速——验证码是唯一的阻力,而验证码本身可以无限重取。
func TestLoginSubmitLocksAfterRepeatedFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
// 阈值内的失败仍是普通的「用户名或密码错误」(200 重新渲染表单)
for i := 1; i < 5; i++ {
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusOK {
t.Fatalf("第 %d 次失败的状态码应为 200,实际 %d", i, w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "用户名或密码错误") {
t.Fatalf("第 %d 次失败应提示口令错误,实际: %s", i, firstLine(body))
}
}
t.Logf("前 4 次失败:普通提示、未上锁")
// 第 5 次失败触发锁定
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusTooManyRequests {
t.Fatalf("触发阈值后应返回 429,实际 %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "过于频繁") {
t.Errorf("应提示尝试过于频繁,实际: %s", firstLine(body))
}
// 锁定期间**正确口令**也进不去——这正是限速的意义
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("锁定期间即使口令正确也应 429,实际 %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "" {
t.Errorf("锁定期间不该签发会话(不该重定向),实际 Location=%s", loc)
}
// 冷却结束后正常登录
f.srv.loginGuard.Reset()
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303 跳转,实际 %d (%s)", w.Code, firstLine(w.Body.String()))
}
if loc := w.Header().Get("Location"); loc != "/admin" {
t.Errorf("应跳到 /admin,实际 %s", loc)
}
if c := w.Result().Cookies(); len(c) == 0 {
t.Error("登录成功没有下发会话 cookie")
}
}
// TestLoginSubmitSuccessClearsFailures 登录成功要把失败计数清零,
// 否则「偶发手滑几次 + 一次成功」会一直攒着,迟早把站长自己挡在门外。
func TestLoginSubmitSuccessClearsFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
for i := 0; i < 4; i++ {
f.attempt(t, f.user, "wrong-password")
}
if d := f.srv.loginGuard.RetryAfter(f.user); d != 0 {
t.Fatalf("4 次失败不该上锁,实际还要等 %v", d)
}
if w := f.attempt(t, f.user, f.pass); w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303,实际 %d", w.Code)
}
// 成功后计数清零:再连错 4 次仍然不该锁
for i := 0; i < 4; i++ {
if w := f.attempt(t, f.user, "wrong-password"); w.Code != http.StatusOK {
t.Fatalf("成功清零后第 %d 次失败不该被锁,实际 %d", i+1, w.Code)
}
}
}
+46
View File
@@ -2,6 +2,8 @@ package web
import (
"html/template"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -58,6 +60,50 @@ func TestMapLink(t *testing.T) {
}
}
// EditPlaceLink 是「在数据编辑页打开这个地点」,它把返回地址挂在 ?back= 上。
//
// 这里只允许编码**一次**。退回当前页时以前用的是 BackQS(已经 QueryEscape 过),
// 再转一次就成了 %252F…,服务端解一次拿到 %2F… 不以 / 开头,被 backFromRequest
// 当站外地址丢掉 —— 直接打开 /places/1 再点「编辑」,编辑页的返回按钮就凭空消失。
func TestEditPlaceLinkBackIsEncodedOnce(t *testing.T) {
const self = "/places/1?city=河池市&sort=dwell"
cases := []struct {
name string
p Page
want string // back 参数解码一次后的原值
}{
{"列表页给的 back", Page{
Back: template.URL("/places?city=河池市"),
SelfPath: self,
BackQS: template.URL(url.QueryEscape(self)),
}, "/places?city=河池市"},
{"back 为空时退回当前页", Page{
SelfPath: self,
BackQS: template.URL(url.QueryEscape(self)),
}, self},
}
for _, c := range cases {
got := string(c.p.EditPlaceLink(2141))
i := strings.Index(got, "&back=")
if i < 0 {
t.Fatalf("%s:链接里没有 back 参数:%s", c.name, got)
}
encoded := got[i+len("&back="):]
raw, err := url.QueryUnescape(encoded)
if err != nil {
t.Fatalf("%s:back 值解不出来:%v", c.name, err)
}
if raw != c.want {
t.Errorf("%s:back 解码一次 = %q,期望 %q(链接 %s)", c.name, raw, c.want, got)
}
// 服务端真的会收这一份:解出来的必须还能通过站内校验,否则返回按钮是空的
r := httptest.NewRequest("GET", "/admin/edit?open=2141&back="+encoded, nil)
if bk := backFromRequest(r); string(bk) != c.want {
t.Errorf("%s:服务端解出 back=%q,期望 %q —— 返回按钮会消失", c.name, bk, c.want)
}
}
}
// 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串,
// 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上,
// 按钮退化成光秃秃的「返回」。
+19 -2
View File
@@ -2,7 +2,9 @@ package web
import (
"context"
"fmt"
"log"
"runtime/debug"
"sync"
"time"
)
@@ -21,6 +23,21 @@ type pageJob struct {
run func(context.Context) error
}
// runJob 跑一个任务,并把 panic 收敛成错误。
//
// 这层兜底不能省:任务跑在子 goroutine 里,而 HTTP 中间件的 recover 只覆盖请求
// goroutine(server.go 的 s.recover),子 goroutine 里逃出去的 panic 会直接终止
// **整个进程**——表现是「站点进程消失」,而不是这一页返回 500。
func (s *Server) runJob(ctx context.Context, j pageJob) (err error) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("页面查询 %s 崩溃: %v\n%s", j.name, rec, debug.Stack())
err = fmt.Errorf("查询 %s 内部错误", j.name)
}
}()
return j.run(ctx)
}
// runJobs 并发执行互不依赖的查询,返回第一个(按传入顺序)非 nil 错误。
//
// 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms),
@@ -29,7 +46,7 @@ type pageJob struct {
func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
if len(jobs) <= 1 {
for _, j := range jobs {
if err := j.run(ctx); err != nil {
if err := s.runJob(ctx, j); err != nil {
return err
}
}
@@ -50,7 +67,7 @@ func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
}
defer func() { <-sem }()
start := time.Now()
errs[i] = jobs[i].run(ctx)
errs[i] = s.runJob(ctx, jobs[i])
if d := time.Since(start); d > slowJobLog && s.cfg.Debug {
log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond))
}
+30
View File
@@ -3,6 +3,7 @@ package web
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -12,6 +13,35 @@ import (
func testServer() *Server { return &Server{cfg: &config.Config{}} }
// TestRunJobsRecoversPanic 钉住「任务 panic 不拖垮进程」。
// runJobs 的任务跑在子 goroutine 里,而 HTTP 层的 recover 只覆盖请求 goroutine,
// 没有这层兜底时,任何一次 nil 解引用都会让**整个进程**退出(不是这一页报错)。
func TestRunJobsRecoversPanic(t *testing.T) {
s := testServer()
err := s.runJobs(context.Background(),
pageJob{"ok", func(context.Context) error { return nil }},
pageJob{"boom", func(context.Context) error { panic("查询炸了") }},
pageJob{"after", func(context.Context) error { return nil }},
)
if err == nil {
t.Fatal("任务 panic 时应返回错误,而不是让调用方以为这页查成功了")
}
if !strings.Contains(err.Error(), "boom") {
t.Errorf("错误里应带上出错任务的名字,实际 %v", err)
}
}
// TestRunJobsRecoversPanicInline 单任务走的是同步分支,也必须兜住 panic。
func TestRunJobsRecoversPanicInline(t *testing.T) {
s := testServer()
err := s.runJobs(context.Background(), pageJob{"boom", func(context.Context) error {
panic("查询炸了")
}})
if err == nil {
t.Fatal("单任务 panic 时应返回错误")
}
}
func TestRunJobsRunsConcurrently(t *testing.T) {
s := testServer()
const n = 8
+60 -5
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -202,23 +203,77 @@ func TestPlaceUpdateBatchActivity(t *testing.T) {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
ph := make([]string, len(pks))
ph := make([]string, 0, len(pks))
args := make([]any, 0, len(pks)+1)
for i, v := range pks {
ph[i] = "?"
// ⚠️ 参数顺序必须与 SQL 里占位符的出现顺序一致:`FILTER (WHERE ZACTIVITY_=?)` 在
// SELECT 列表里,它的 `?` 是整个语句的第一个,所以 actSrc 要排在所有 pks 之前。
// 曾经写成先追加 pks、最后追加 actSrc,于是这个 `?` 绑到了 pks[0] 上,
// 同时 IN 列表被顶掉一位、变成 pks[1:] ∪ {actSrc}:条数偶尔少 1(正是历史 flaky 的来源),
// 而 converted 只有碰巧 pks[0] == actSrc 时才等于 246。
args = append(args, actSrc)
for _, v := range pks {
ph = append(ph, "?")
args = append(args, v)
}
args = append(args, actSrc)
var inPackage, converted int
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
strings.Join(ph, ",") + `)`
if strings.Count(q, "?") != len(args) {
t.Fatalf("占位符 %d 个、参数 %d 个,绑定位错位了", strings.Count(q, "?"), len(args))
}
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
t.Fatalf("读导出包失败: %v", err)
}
if inPackage != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访", inPackage, len(pks))
// 两种成因的修法完全不同,所以失败时直接把证据打出来:
// ① pks 自身有重复 —— IN 列表里有 246 个值、只命中 245 个不同的行
// ② pks 唯一但包里真缺行 —— 导出/覆盖那条路把某一行弄丢了
missing, dup, total := diffVisitPKs(t, edb, pks)
t.Fatalf("导出包里只有 %d/%d 条到访(包内 ZVISIT 共 %d 行);缺 %v,pks 内重复 %v",
inPackage, len(pks), total, missing, dup)
}
if converted != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
}
}
// diffVisitPKs 说清「包里的到访为什么比预期少」:返回包里缺的编号、pks 里重复的编号、
// 以及包内 ZVISIT 的总行数。总行数能立刻区分「少写了一行」和「两行撞成一个 Z_PK」。
func diffVisitPKs(t *testing.T, edb *sql.DB, pks []int) (missing, dup []int, total int) {
t.Helper()
have := map[int]bool{}
rows, err := edb.Query(`SELECT Z_PK FROM ZVISIT`)
if err != nil {
t.Fatalf("读导出包的到访编号失败: %v", err)
}
for rows.Next() {
var pk int
if err := rows.Scan(&pk); err != nil {
rows.Close()
t.Fatalf("扫导出包到访编号失败: %v", err)
}
have[pk] = true
total++
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("读导出包到访编号中断: %v", err)
}
rows.Close()
want := map[int]int{}
for _, v := range pks {
want[v]++
}
for v, c := range want {
if c > 1 {
dup = append(dup, v)
}
if !have[v] {
missing = append(missing, v)
}
}
sort.Ints(missing)
sort.Ints(dup)
return missing, dup, total
}
+46
View File
@@ -438,3 +438,49 @@ func abs(v float64) float64 {
}
return v
}
// TestEditPageSurvivesGarbageRawCoord 钉住「留档里的坐标不是数字时,编辑页照样打得开」。
//
// entity_raw 是原样存下来的 JSON,值不一定就是数字。以前那两列直接 `::float8`,
// 一条脏数据就能让整个数据编辑页 500:查询里还有 CASE / 正则等别的条件,表现是
// 「每次打开都 500、别的页面都正常」,很难一眼看出是哪一行哪一列的事。
func TestEditPageSurvivesGarbageRawCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var orig []byte
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, r.raw FROM places p
JOIN entity_raw r ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
WHERE p.dataset_id=$1 ORDER BY p.src_pk LIMIT 1`, f.dsID).Scan(&pk, &orig); err != nil {
t.Fatalf("找不到带留档的地点: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE entity_raw SET raw=$1::jsonb
WHERE dataset_id=$2 AND entity='ZLOCATION' AND src_pk=$3`, string(orig), f.dsID, pk)
})
get := func() *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.editPage(rec, req)
return rec
}
if rec := get(); rec.Code != 200 {
t.Fatalf("基线就渲染失败:%d", rec.Code)
}
// 坐标位置塞一个「像数字但不是数字」的字符串:旧那条正则 '^-?[0-9.]+$' 会放行它
if _, err := f.pg.ExecContext(f.ctx,
`UPDATE entity_raw SET raw = jsonb_set(raw, '{ZLATITUDE}', to_jsonb('1.2.3'::text))
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, pk); err != nil {
t.Fatalf("写入脏留档失败: %v", err)
}
rec := get()
if rec.Code != 200 {
t.Fatalf("留档坐标不是数字时编辑页返回 %d,应照常 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "</html>") {
t.Error("页面被截断(没有收尾的 </html>)")
}
}
+39
View File
@@ -168,6 +168,45 @@ func TestPlaceImageUploadDelete(t *testing.T) {
}
}
// TestPlaceImageUploadRejectsOversizedBody 上传接口必须先卡住请求体再解析 multipart。
//
// ParseMultipartForm 的 maxMemory 只管「内存里留多少」,超出的部分会无上限地落到
// 临时文件;不先套 MaxBytesReader,一个大请求就能把磁盘写满。
//
// 判据用响应文案而不是「文件大小」:只有 ParseMultipartForm 失败那一条分支会回
// 「可能太大」。把 MaxBytesReader 那行删掉,请求会一路解析成功、最后被 6MB 的单张
// 上限拦成「图片超过 6MB」,这条用例就会红。
func TestPlaceImageUploadRejectsOversizedBody(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
setMediaBackend(t, f, "local")
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
// 超出门限(imageMaxBytes + 1MB)一大截
raw := webpBytes(imageMaxBytes + 3<<20)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)},
map[string][]byte{"file": raw})
if !strings.Contains(rec.Body.String(), "可能太大") {
t.Errorf("超大请求体没在解析阶段被挡下: %s", rec.Body.String())
}
// 没落库(解析阶段就失败,后面取 src_pk / 存图那几步都到不了)
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("被拒的上传落了库: %d 行", n)
}
}
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
// 并且图片挂在正确的 src_pk 上。
func TestPhotoImportWithImage(t *testing.T) {
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"os"
"path/filepath"
"strings"
"testing"
)
// safeInternalPath 是 ?back=、后台表单 next、投票后跳转三处共用的判据。
// 挡不住「//evil.com」就是开放重定向(协议相对地址会被当跨站地址)。
func TestSafeInternalPath(t *testing.T) {
valid := map[string]string{
"/": "/",
"/places/1": "/places/1",
"/admin/notes?q=x&src=1": "/admin/notes?q=x&src=1",
"/places/1#top": "/places/1#top",
" /admin ": "/admin", // 首尾空白先 trim
"/places/%E4%B8%AD%E6%96%87": "/places/%E4%B8%AD%E6%96%87",
}
for in, want := range valid {
if got := safeInternalPath(in); got != want {
t.Errorf("safeInternalPath(%q) = %q,期望 %q", in, got, want)
}
}
invalid := []string{
"",
" ",
"//evil.com", // 协议相对:浏览器当 https://evil.com
"//evil.com/admin", // 同上,还伪装成 /admin 开头
"/\\evil.com", // 反斜杠:浏览器会归一成 //
"\\\\evil.com", // 反斜杠开头
"https://evil.com", // 绝对地址
"javascript:alert(1)", // 伪协议
"/a\"b", // 能跳出属性
"/a'b", //
"/a<b", //
"/a>b", //
"/a\\b", //
"/a\nb", // 控制字符(曾能污染 Location 头)
"/a\rb", //
"/a\x00b", // NUL:浏览器会悄悄吃掉
"/a\x7fb", // DEL
"relative/path", // 非站内绝对路径
"admin", //
"/admin\nLocation: //e.c", // 头注入尝试
}
for _, in := range invalid {
if got := safeInternalPath(in); got != "" {
t.Errorf("safeInternalPath(%q) = %q,应当拒绝", in, got)
}
}
}
// 钉住缺陷形态本身:站内路径判定只能走 safeInternalPath,
// 不许再出现 `strings.HasPrefix(next, "/")` 这种只判前缀、放行 //evil.com 的写法。
func TestNoWeakRedirectPrefixCheck(t *testing.T) {
pattern := `HasPrefix(next, "/")`
// 从仓库根扫,而不是 ".":cwd 会被夹具改掉,"." 时而指包目录、时而指仓库根,
// 覆盖面随之悄悄变化(包目录下扫不到 handlers_admin.go 之类)。
root := moduleRoot(t)
scanned := 0
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if p != root && (info.Name() == "static" || info.Name() == "templates" || info.Name() == ".git") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
scanned++
b, err := os.ReadFile(p)
if err != nil {
return err
}
if strings.Contains(string(b), pattern) {
t.Errorf("%s 里出现了弱判据 %s:它放行 //evil.com,请改用 safeInternalPath", p, pattern)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if scanned < 10 {
t.Fatalf("只扫到 %d 个源文件,静态守卫形同虚设", scanned)
}
}
+76
View File
@@ -0,0 +1,76 @@
package web
import (
"context"
"testing"
"time"
"rond-with-you/internal/stats"
)
// D6 对账:同一套区域筛选下,轨迹图层的谓词(照 apiTrack 的写法,用 regionSQL 拼
// pf / pt 两段 AND)与里程统计用的 movementsWhere 必须选出同一批位移。
//
// 曾经一个是「两端都要命中」、一个是「任一端命中」:白名单下同一段里程在图层里
// 画不出来、却算进了总里程(黑名单下两种写法恰好等价,所以这个洞只在白名单露出来)。
// 判据用真实的两份实现做 A/B,而不是各写一遍 SQL 互相比对——那样只是把漂移抄了两遍。
func TestTrackAndMileageAgreeOnRegionFilter(t *testing.T) {
f := newLoginFixture(t)
pg, ctx := f.pg, context.Background()
// 独立数据集、且 is_active 保持默认 false:统计夹具是「WHERE is_active LIMIT 1」,
// 置 true 会把它选走,串到别人的用例上(本轮在 ingest 那边刚踩过一次)。
var dsID int64
if err := pg.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status)
VALUES ($1,'d6-region-consistency','','done') RETURNING id`, f.uid).Scan(&dsID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.Exec(`DELETE FROM datasets WHERE id=$1`, dsID) })
const inRegion, outRegion = "测试省甲", "测试省乙"
place := func(src int, prov string) int64 {
t.Helper()
var id int64
if err := pg.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, province, city, lat, lon)
VALUES ($1,$2,$3,$4,$5,24.0,109.0) RETURNING id`,
dsID, src, "p", prov, prov).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
in1, out, in2 := place(1, inRegion), place(2, outRegion), place(3, inRegion)
move := func(src int, from, to int64, km float64) {
t.Helper()
if _, err := pg.ExecContext(ctx, `INSERT INTO movements
(dataset_id, user_id, src_pk, from_place_id, to_place_id, distance_km, started_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, dsID, f.uid, src, from, to, km, time.Now()); err != nil {
t.Fatal(err)
}
}
move(1, in1, out, 10) // 一端在区域外:白名单下两边都不该算
move(2, in1, in2, 20) // 两端都在区域内
args := []any{dsID}
regSQL := regionSQL("pf", []string{inRegion}, false, &args) +
regionSQL("pt", []string{inRegion}, false, &args)
var trackKm float64
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(sum(m.distance_km),0) FROM movements m
JOIN places pf ON pf.id = m.from_place_id AND pf.lat IS NOT NULL
JOIN places pt ON pt.id = m.to_place_id AND pt.lat IS NOT NULL
WHERE m.dataset_id = $1`+regSQL, args...).Scan(&trackKm); err != nil {
t.Fatal(err)
}
ov, err := (&stats.DB{DB: pg}).Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{inRegion}})
if err != nil {
t.Fatal(err)
}
if trackKm != 20 {
t.Fatalf("前提不成立:图层侧手写查询应得 20km,实际 %.2f", trackKm)
}
if ov.DistanceKm != trackKm {
t.Errorf("轨迹图层与里程统计口径不一致:图层 %.2f km,统计 %.2f km", trackKm, ov.DistanceKm)
}
}
+81 -21
View File
@@ -90,6 +90,11 @@ type Server struct {
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
loginGuard *auth.Guard
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
totpReplay *auth.CodeReplay
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
@@ -106,21 +111,23 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
media: media.New(cfg),
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
loginGuard: auth.NewGuard(),
totpReplay: auth.NewCodeReplay(),
media: media.New(cfg),
}
s.routes()
return s, nil
@@ -142,7 +149,7 @@ func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.mux))))
return s.recover(gzipHandler(s.logRequests(s.withUser(s.csrfOriginGuard(s.mux)))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
@@ -314,15 +321,42 @@ func (s *Server) logRequests(next http.Handler) http.Handler {
})
}
// headerSent 记录响应首部是否已经提交,供 recover 判断「还能不能写错误页」。
// 首部一旦提交,http.Error 只能再 WriteHeader 一次(Go 记一条 superfluous
// response.WriteHeader)并把错误体追加到半个页面后面,比不写更糟。
type headerSent struct {
http.ResponseWriter
sent bool
}
func (h *headerSent) WriteHeader(code int) {
h.sent = true
h.ResponseWriter.WriteHeader(code)
}
func (h *headerSent) Write(b []byte) (int, error) {
h.sent = true
return h.ResponseWriter.Write(b)
}
// Unwrap 让 http.NewResponseController 能穿过这层拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠逐层解包)。
func (h *headerSent) Unwrap() http.ResponseWriter { return h.ResponseWriter }
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hw := &headerSent{ResponseWriter: w}
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
if hw.sent {
// 已经发出去了,再写错误页只会污染响应;真正的补救是断开连接。
return
}
http.Error(hw, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
next.ServeHTTP(hw, r)
})
}
@@ -371,19 +405,39 @@ func userFrom(ctx context.Context) *auth.User {
return u
}
func (s *Server) login(w http.ResponseWriter, userID int64, hash string) {
// cookieSecure 判断这次响应能不能给会话 cookie 打 Secure。
//
// 不能无条件打:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上,
// 打了 Secure 浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是不是
// 走的 https」判断:直连看 r.TLS,在反向代理后面看 X-Forwarded-Proto(只取首段)。
func cookieSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
func (s *Server) login(w http.ResponseWriter, r *http.Request, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.SetCookie(w, &http.Cookie{
Name: s.cookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(r),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -444,9 +498,15 @@ func (s *Server) json(w http.ResponseWriter, v any) {
}
}
// serverError 统一处理内部错误。详情只回给已登录的站长:公开访客页把 err.Error()
// 原样回显,等于把 SQL 错误里的表结构、列名、以及磁盘路径直接送到门外。
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)
msg := "服务器内部错误,请稍后重试"
if userFrom(r.Context()) != nil {
msg = "服务器内部错误: " + err.Error()
}
http.Error(w, msg, http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
)
// TestServerErrorHidesDetailFromVisitor 断言 500 响应体不带内部错误详情——
// 公开访客页把 SQL 错误、磁盘路径原样回显,等于免费送出表结构与目录布局。
// 站长自己看得到详情(排查要用),别人只能看到一句人话。
func TestServerErrorHidesDetailFromVisitor(t *testing.T) {
s := &Server{}
const secret = `pq: relation "zzz_table" does not exist (SQLSTATE 42P01)`
anon := httptest.NewRecorder()
s.serverError(anon, httptest.NewRequest("GET", "/", nil), errors.New(secret))
if anon.Code != http.StatusInternalServerError {
t.Fatalf("状态码应为 500,实际 %d", anon.Code)
}
if body := anon.Body.String(); strings.Contains(body, "42P01") || strings.Contains(body, "zzz_table") {
t.Errorf("访客页泄漏了内部错误详情: %s", body)
}
owner := httptest.NewRecorder()
s.serverError(owner, httptest.NewRequest("GET", "/", nil).WithContext(ctxWithUser(&auth.User{ID: 1})), errors.New(secret))
if body := owner.Body.String(); !strings.Contains(body, "42P01") {
t.Errorf("站长应能看到错误详情,实际: %s", body)
}
}
// 半路 panic(首部已提交):不能再写错误页。旧写法无条件 http.Error,
// 会二次 WriteHeader(Go 记一条 superfluous response.WriteHeader)并把错误体
// 追加到半个页面后面 —— 用户看到的是「半页正文 + 一句服务器内部错误」。
func TestRecoverLeavesPartialResponseAlone(t *testing.T) {
s := &Server{}
h := s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<p>已经写出去的半页</p>")
panic("boom")
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != http.StatusOK {
t.Errorf("首部已提交就不该再改状态码,实际 %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "已经写出去的半页") {
t.Errorf("原有的半页被覆盖了: %q", body)
}
if strings.Contains(body, "服务器内部错误") {
t.Errorf("错误体被追加到了已提交的响应后面: %q", body)
}
}
// 还没写出任何东西就 panic:必须照旧给 500 + 一句人话。
func TestRecoverWritesErrorWhenNothingSent(t *testing.T) {
s := &Server{}
h := s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("boom") }))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != http.StatusInternalServerError {
t.Errorf("尚未写出时应回 500,实际 %d", w.Code)
}
if !strings.Contains(w.Body.String(), "服务器内部错误") {
t.Errorf("缺少错误提示: %q", w.Body.String())
}
}
// 包装层必须实现 Unwrap():否则 http.NewResponseController 在它这里拿不到
// SetWriteDeadline / Flush / Hijack,导出那条路由的 10 分钟写死线会静默失效。
func TestRecoverWrapperUnwraps(t *testing.T) {
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
s := &Server{}
var got error
s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(time.Minute))
})).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got != nil {
t.Fatalf("ResponseController 没能穿过 recover 的包装: %v", got)
}
if !rec.got {
t.Error("SetWriteDeadline 没有落到真正的 ResponseWriter 上")
}
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// S4 回归:https 下会话 cookie 必须带 Secure;http 下不能带 ——
// 无条件打 Secure 会让本机 http 调试和本仓库的 CDP 验证流程直接登不进去。
func TestSessionCookieSecureFlag(t *testing.T) {
f := newLoginFixture(t)
login := func(https bool, xfp string) *http.Cookie {
t.Helper()
f.srv.loginGuard.Reset()
id, _, err := f.srv.captcha.Issue()
if err != nil {
t.Fatalf("签发验证码失败: %v", err)
}
code, ok := f.srv.captcha.Answer(id)
if !ok {
t.Fatal("刚签发的验证码取不到答案")
}
form := url.Values{"username": {f.user}, "password": {f.pass}, "captcha": {code}}
r := httptest.NewRequest("POST", "/admin/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if xfp != "" {
r.Header.Set("X-Forwarded-Proto", xfp)
}
if https {
r.TLS = &tls.ConnectionState{}
}
r.AddCookie(&http.Cookie{Name: captchaCookie, Value: id})
w := httptest.NewRecorder()
f.srv.loginSubmit(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("登录应 303,实际 %d:%s", w.Code, firstLine(w.Body.String()))
}
for _, c := range w.Result().Cookies() {
if c.Name == f.srv.cookie {
return c
}
}
t.Fatal("登录成功却没有下发会话 cookie")
return nil
}
cases := []struct {
name string
https bool
xfp string
want bool
}{
{"直连 http", false, "", false},
{"直连 https", true, "", true},
{"反代 https", false, "https", true},
{"反代多段取首段", false, "https, http", true},
{"反代 http", false, "http", false},
}
for _, c := range cases {
got := login(c.https, c.xfp)
if got.Secure != c.want {
t.Errorf("%s: Secure=%v,期望 %v", c.name, got.Secure, c.want)
}
// 其余属性不能被顺手改掉
if !got.HttpOnly || got.Path != "/" || got.SameSite != http.SameSiteLaxMode {
t.Errorf("%s: cookie 其它属性有变: HttpOnly=%v Path=%q SameSite=%v",
c.name, got.HttpOnly, got.Path, got.SameSite)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package web
import (
"os"
"strings"
"testing"
)
// 统计页的 Weekday 只该查一次。
//
// 原实现是「在 runJobs 里注册了 weekday 任务」之外,又在渲染前同步查了一遍
// (`d.Weekday, err = s.q.Weekday(ctx, f)`),等于同一个查询发了两次——纯浪费。
// 这条守卫盯住「不许再出现第二次」。
func TestStatsHandlerQueriesWeekdayOnce(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "handlers_public.go"))
if err != nil {
t.Fatalf("读取 handlers_public.go 失败: %v", err)
}
if n := strings.Count(string(b), "s.q.Weekday("); n != 1 {
t.Errorf("handlers_public.go 里 s.q.Weekday( 应恰好出现 1 次,实际 %d 次"+
"(>1 说明又重复查了;0 说明写法改了、这条守卫需要同步更新)", n)
}
}
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// moduleRoot 返回仓库根目录(含 go.mod 的那层)。
//
// 不依赖当前工作目录:测试进程的 cwd 会被 newMovementFixture → chdirModuleRoot 改成仓库根,
// 而用例执行顺序并不保证——于是「相对路径」到底相对谁,取决于这条用例有没有跟夹具用例一起跑。
// 读源码的静态守卫一旦踩上这个,单跑是绿的、混跑就报「找不到文件」,或者更糟:
// filepath.Walk(".") 扫的子树悄悄从 internal/web 变成整个仓库,覆盖面变了却没人知道。
// 一律从本测试文件的真实位置往上找 go.mod。
func moduleRoot(t *testing.T) string {
t.Helper()
_, this, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("拿不到本测试文件的路径")
}
dir := filepath.Dir(this)
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到 go.mod,无法定位仓库根目录")
}
dir = parent
}
}
// pkgFile 返回本包(internal/web)内某个源文件的绝对路径。
func pkgFile(t *testing.T, name string) string {
t.Helper()
return filepath.Join(moduleRoot(t), "internal", "web", name)
}
+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)
}
}
+117
View File
@@ -0,0 +1,117 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
)
// enableTOTP 给 fixture 账号开上两步验证,返回明文秘钥;收尾时关掉并把计数器还原。
func enableTOTP(t *testing.T, f *loginFixture) string {
t.Helper()
ctx := context.Background()
secret, err := auth.GenerateTOTPSecret()
if err != nil {
t.Fatalf("生成秘钥失败: %v", err)
}
if err := f.srv.auth.SetTOTPSecret(ctx, f.uid, secret); err != nil {
t.Fatalf("写秘钥失败: %v", err)
}
if err := f.srv.auth.EnableTOTP(ctx, f.uid); err != nil {
t.Fatalf("开启两步验证失败: %v", err)
}
f.srv.loginGuard.Reset()
t.Cleanup(func() {
f.srv.auth.DisableTOTP(ctx, f.uid)
f.srv.loginGuard.Reset()
})
return secret
}
// pending2FA 签发一张当前账号的中间令牌(等价于口令已经输对了)。
func pending2FA(t *testing.T, f *loginFixture) string {
t.Helper()
u, err := f.srv.auth.UserByID(context.Background(), f.uid)
if err != nil {
t.Fatalf("取账号失败: %v", err)
}
return f.srv.auth.SignPending2FA(u.ID, u.Hash)
}
// attempt2FA 走一次真实的验证码提交。
func attempt2FA(t *testing.T, f *loginFixture, pending, code string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"pending": {pending}, "code": {code}}
r := httptest.NewRequest("POST", "/admin/login/twofa", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
f.srv.login2FA(w, r)
return w
}
// S7 回归:验证码这一环必须限速。
//
// 中间令牌有 5 分钟有效期,而口令那一环的短锁在「口令正确」时就被清掉了,
// 所以攻击者只要知道口令,就能拿着一张令牌在 5 分钟里无限试 6 位码。
func TestLogin2FARateLimited(t *testing.T) {
f := newLoginFixture(t)
secret := enableTOTP(t, f)
pending := pending2FA(t, f)
// 连错到阈值:阈值内只提示「验证码不正确」(手滑不该被罚),过了阈值才上锁
lockedAt := 0
for i := 1; i <= 8; i++ {
rec := attempt2FA(t, f, pending, "000000")
if strings.Contains(rec.Body.String(), "频繁") {
lockedAt = i
break
}
if !strings.Contains(rec.Body.String(), "验证码不正确") {
t.Fatalf("第 %d 次错码的提示不对: %s", i, firstLine(rec.Body.String()))
}
}
if lockedAt == 0 {
t.Fatal("连错 8 次也没触发短锁")
}
if lockedAt < 2 {
t.Errorf("第一次错码就上锁了,正常手滑会被误伤(lockedAt=%d)", lockedAt)
}
// 上锁之后,即使递上**正确**的码也必须被拦下——否则限速没生效
if code, err := auth.TOTPCode(secret, time.Now()); err == nil {
rec := attempt2FA(t, f, pending, code)
if !strings.Contains(rec.Body.String(), "频繁") {
t.Errorf("锁定期内递正确码没被拦住,仍走到了后续分支: %s", firstLine(rec.Body.String()))
}
} else {
t.Fatalf("算验证码失败: %v", err)
}
}
// S7 回归:同一个验证码不能换到第二个会话。
func TestLogin2FACodeIsSingleUse(t *testing.T) {
f := newLoginFixture(t)
secret := enableTOTP(t, f)
pending := pending2FA(t, f)
code, err := auth.TOTPCode(secret, time.Now())
if err != nil {
t.Fatalf("算验证码失败: %v", err)
}
rec := attempt2FA(t, f, pending, code)
if rec.Code != http.StatusSeeOther {
t.Fatalf("第一次用正确码应登录成功,实际 %d: %s", rec.Code, firstLine(rec.Body.String()))
}
rec = attempt2FA(t, f, pending, code)
if !strings.Contains(rec.Body.String(), "刚用过") {
t.Errorf("同一个码被重放了: %d %s", rec.Code, firstLine(rec.Body.String()))
}
}
+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()`)
})
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"rond-with-you/internal/auth"
)
func uploadsIn(t *testing.T, dir string) map[string]bool {
t.Helper()
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, e := range ents {
out[e.Name()] = true
}
return out
}
// D8 回归:备份包解析失败时,已经写进 uploads 的那份半成品必须删掉。
// 旧写法只在「sha 完全相同」那条路上 os.Remove,其余失败(rond.Open / rond.Parse /
// Import 任一)都把文件留在盘上 —— 反复上传坏包就是一堆垃圾,还占着 findSourceArchive
// 每次都要去算 sha256 的那个目录。
func TestUploadRemovesStoredFileOnFailure(t *testing.T) {
f := newLoginFixture(t)
dir := f.srv.cfg.UploadDir
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
before := uploadsIn(t, dir)
// 名字像备份包、内容是垃圾:必然走到「写盘成功 → 解析失败」这条路径。
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("backup", "zz-broken.rondbackup")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("这不是一个 zip 包")); err != nil {
t.Fatal(err)
}
mw.Close()
r := httptest.NewRequest("POST", "/admin/upload", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r = r.WithContext(ctxWithUser(&auth.User{ID: f.uid}))
w := httptest.NewRecorder()
f.srv.adminUpload(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("应当以 303 回管理页,实际 %d", w.Code)
}
loc, _ := url.QueryUnescape(w.Header().Get("Location"))
// 前提守卫:必须真的是「文件已经落盘读过了、才在解析这一步失败」。
// 若落在更早的分支(读上传内容失败 / 类型不对),上传目录压根没动,
// 下面那条「没有残留」的断言只是空过。
if !strings.Contains(loc, "解析备份失败") {
t.Fatalf("没走到「写盘后解析失败」这条路,测试前提不成立: %q", loc)
}
for name := range uploadsIn(t, dir) {
if !before[name] {
t.Errorf("失败后残留在 uploads 里的半成品: %s", name)
}
}
}
+36 -17
View File
@@ -86,6 +86,10 @@ type Page struct {
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
BackQS template.URL
// SelfPath 是 BackQS 的**未编码**原文。需要在这一层再编码一次的场景(编辑页的
// ?back=)必须用它——拿已编码的 BackQS 再编码会变成 %252F…(见 EditPlaceLink)。
SelfPath string
}
// IsAdmin 报告当前访问者是否已登录后台。
@@ -143,12 +147,12 @@ func (p Page) filterPairs() []string {
return kv
}
// backQS 把当前请求的地址编码成 ?back= 的值。
// selfPath 是当前请求的地址(路径 + 规范化后的查询串),**未编码**。
//
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
func backQS(r *http.Request) template.URL {
func selfPath(r *http.Request) string {
q := r.URL.Query()
q.Del("back")
// ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次
@@ -158,27 +162,39 @@ func backQS(r *http.Request) template.URL {
if e := q.Encode(); e != "" {
u += "?" + e
}
return template.URL(url.QueryEscape(u))
return u
}
// backFromRequest 取出 ?back= 并只放行**站内路径**。
// backQS 是 selfPath 的编码形式,直接当 ?back= 的值用。
// 需要在这一层再编码一次的场景要用 selfPath,别拿它再转一遍(会变成 %252F…)。
func backQS(r *http.Request) template.URL {
return template.URL(url.QueryEscape(selfPath(r)))
}
// safeInternalPath 只放行**站内路径**,不合法时返回空串。
//
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以这里必须挡死
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以必须挡死
// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
func backFromRequest(r *http.Request) template.URL {
b := strings.TrimSpace(r.URL.Query().Get("back"))
if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") {
// 控制字符(含 %00、%0b)也一律不收:浏览器会把 NUL 之类的字符悄悄吃掉,
// 留着就是给「同一串地址在不同环节解析成不同东西」留口子。
//
// ?back=、后台表单的 next、投票后的跳转三处共用这一份判据,别再各写一遍。
func safeInternalPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return ""
}
for i := 0; i < len(b); i++ {
// 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收:
// 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子
if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
for i := 0; i < len(p); i++ {
if c := p[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
return ""
}
}
return template.URL(b)
return p
}
func backFromRequest(r *http.Request) template.URL {
return template.URL(safeInternalPath(r.URL.Query().Get("back")))
}
// BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段):
@@ -231,14 +247,18 @@ func (p Page) PlaceLink(id int64) template.URL {
// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。
// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。
//
// 这里必须用**未编码**的 SelfPath:BackQS 已经 QueryEscape 过一次,
// 再转一次就成了 %252F…,服务端解出来不以 / 开头会被当成站外地址丢掉,
// 于是「直接打开 /places/1 再点编辑」的编辑页上返回按钮凭空消失。
func (p Page) EditPlaceLink(srcPK int) template.URL {
back := p.Back
back := string(p.Back)
if back == "" {
back = p.BackQS
back = p.SelfPath
}
u := "/admin/edit?open=" + strconv.Itoa(srcPK)
if back != "" {
u += "&back=" + url.QueryEscape(string(back))
u += "&back=" + url.QueryEscape(back)
}
return template.URL(u)
}
@@ -1364,7 +1384,6 @@ func templateFuncs() template.FuncMap {
}
return b
},
"safe": func(s string) template.HTML { return template.HTML(s) },
"colorof": colorOf,
"icon": iconOf,
"iconor": iconOr,
+138
View File
@@ -0,0 +1,138 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
// sliceFunc 取出 src 里某个函数的函数体(从签名到下一个顶层 func 之前)。
// 找不到直接 Fatal——否则静态守卫会因为「切片切空了」而假绿。
func sliceFunc(t *testing.T, src, sig string) string {
t.Helper()
i := strings.Index(src, sig)
if i < 0 {
t.Fatalf("源码里找不到 %s,静态守卫需要同步更新", sig)
}
body := src[i:]
if j := strings.Index(body[len(sig):], "\nfunc "); j >= 0 {
body = body[:len(sig)+j]
}
if len(body) < 200 {
t.Fatalf("%s 的函数体只切到 %d 字节,切片判据失效", sig, len(body))
}
return body
}
// D5 回归(静态):收藏切换必须是「一条语句翻转并回读」。
//
// 以前是 UPDATE 之后再单独 SELECT 回读,那次回读一旦失败就落进 else 报「已取消收藏」——
// 状态根本没读出来,这句话有一半是错的。换成 UPDATE ... RETURNING 后没有第二次读,
// 也就没有「读失败只能猜」这个分支。
//
// 为什么只能静态守:要让那条独立回读失败得先有故障注入(本项目还没有这套设施),
// 而正常路径下新旧写法的输出完全一样,行为测试分辨不出来。
func TestVisitBookmarkTogglesInOneStatement(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_edit.go"))
if err != nil {
t.Fatalf("读 handlers_edit.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) adminVisitBookmark(")
if !strings.Contains(body, "RETURNING bookmarked") {
t.Error("收藏切换应当用 UPDATE ... RETURNING 一条语句翻转并回读")
}
if strings.Contains(body, "SELECT bookmarked") {
t.Error("不该再有独立的回读语句:它失败时无法知道状态,只能猜")
}
if strings.Contains(body, "err == nil && on") {
t.Error("不许把「读失败」和「读到 false」合成一个分支——那正是误报「已取消收藏」的写法")
}
if !strings.Contains(body, "s.serverError(w, r, err)") {
t.Error("语句失败必须按错误上报,不能伪装成成功")
}
}
// D5 回归(行为):连点两次星标要真的来回翻,且提示必须与库里的状态一致。
// 「翻过来但提示说反了」这种错,只有把提示和库内实况对起来才看得出来。
func TestVisitBookmarkFlashMatchesRow(t *testing.T) {
f := newMovementFixture(t)
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM visits
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到到访记录: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE visits SET bookmarked=FALSE WHERE dataset_id=$1 AND src_pk=$2`,
f.dsID, src)
})
click := func() (*httptest.ResponseRecorder, bool) {
t.Helper()
form := url.Values{"src_pk": {fmt.Sprint(src)}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
var on bool
if err := f.pg.QueryRowContext(f.ctx, `SELECT bookmarked FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&on); err != nil {
t.Fatalf("读库内收藏状态失败: %v", err)
}
return rec, on
}
rec, on := click()
if !on {
t.Fatalf("点一次应当把收藏打开,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已标记为收藏" {
t.Errorf("提示 %q 与库内状态(已收藏)不一致", msg)
}
rec, on = click()
if on {
t.Fatalf("再点一次应当取消收藏,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已取消收藏" {
t.Errorf("提示 %q 与库内状态(未收藏)不一致", msg)
}
// 不存在的记录要报错,不能假装成功
form := url.Values{"src_pk": {"2147483000"}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec = httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
if msg := flashMsg(t, rec); strings.Contains(msg, "已") {
t.Errorf("对不存在的记录不该回一句成功提示: %q", msg)
}
}
// flashMsg 从重定向的 Location 里取出 flash 文案(redirectEdit 把提示挂在查询串上)。
func flashMsg(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
loc := rec.Header().Get("Location")
if loc == "" {
t.Fatalf("没有重定向,实际状态 %d:%s", rec.Code, firstLine(rec.Body.String()))
}
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("解析 Location 失败: %v", err)
}
msg := u.Query().Get("ok")
if msg == "" {
msg = u.Query().Get("err")
}
if msg == "" {
t.Fatalf("Location 里没有提示文案: %s", loc)
}
return msg
}