From b4c2a8452f5db7774fb3f4780dd9b2f22053c911 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 24 Sep 2026 20:37:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88=E5=AE=89=E5=85=A8=20/=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=80=E8=87=B4=E6=80=A7=20/=20=E6=80=A7=E8=83=BD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。 --- internal/auth/auth.go | 14 +- internal/auth/auth_test.go | 24 ++ internal/auth/code_replay_test.go | 47 +++ internal/auth/guard.go | 128 ++++++++ internal/auth/guard_test.go | 147 +++++++++ internal/auth/totp.go | 39 +++ internal/db/fresh_db_test.go | 164 ++++++++++ internal/fog/fog.go | 40 ++- internal/fog/limit_test.go | 78 +++++ internal/fog/tilename_test.go | 48 +++ internal/ingest/coord.go | 13 +- internal/ingest/dirty_import_test.go | 108 +++++++ internal/notes/notes.go | 34 ++ internal/rond/diff.go | 23 ++ internal/rond/dirty_test.go | 203 ++++++++++++ internal/rond/limit_test.go | 98 ++++++ internal/rond/parse.go | 152 +++++++-- internal/rond/parse_rows_test.go | 66 ++++ internal/stats/placecols_test.go | 169 ++++++++++ internal/stats/stats.go | 126 ++++++-- internal/stats/stats_test.go | 72 +++++ internal/stats/verdict_scope_test.go | 355 ++++++++++++++++++++ internal/votes/votes.go | 26 ++ internal/web/admin_notes_bulk_test.go | 108 +++++++ internal/web/agg_error_test.go | 81 +++++ internal/web/archive_pick_test.go | 129 ++++++++ internal/web/backup_diff_test.go | 47 ++- internal/web/backup_merge.go | 6 +- internal/web/backup_merge_test.go | 84 +++++ internal/web/client_ip_test.go | 65 ++++ internal/web/csrf.go | 70 ++++ internal/web/csrf_test.go | 97 ++++++ internal/web/download_name_test.go | 127 ++++++++ internal/web/entity_raw_test.go | 54 ++++ internal/web/export_download_test.go | 59 ++++ internal/web/fillup.go | 27 +- internal/web/fillup_cache_test.go | 51 +++ internal/web/fog.go | 25 +- internal/web/fog_race_test.go | 55 ++++ internal/web/gpx_owner_test.go | 177 ++++++++++ internal/web/gzip.go | 5 + internal/web/handlers_admin.go | 104 ++++-- internal/web/handlers_edit.go | 411 +++++++++++++++--------- internal/web/handlers_export.go | 116 ++++++- internal/web/handlers_images.go | 3 + internal/web/handlers_photos.go | 2 +- internal/web/handlers_public.go | 32 +- internal/web/handlers_topics.go | 83 +++-- internal/web/handlers_vote.go | 56 +++- internal/web/login_guard_test.go | 170 ++++++++++ internal/web/pagelink_test.go | 46 +++ internal/web/parallel.go | 21 +- internal/web/parallel_test.go | 30 ++ internal/web/place_activity_test.go | 65 +++- internal/web/place_coord_test.go | 46 +++ internal/web/place_image_test.go | 39 +++ internal/web/redirect_target_test.go | 93 ++++++ internal/web/region_consistency_test.go | 76 +++++ internal/web/server.go | 102 ++++-- internal/web/server_error_test.go | 93 ++++++ internal/web/session_cookie_test.go | 75 +++++ internal/web/stats_weekday_once_test.go | 23 ++ internal/web/testpaths_test.go | 40 +++ internal/web/topic_stats_test.go | 107 ++++++ internal/web/twofa_guard_test.go | 117 +++++++ internal/web/tx_atomicity_test.go | 104 ++++++ internal/web/upload_cleanup_test.go | 75 +++++ internal/web/view.go | 53 ++- internal/web/visit_bookmark_test.go | 138 ++++++++ main.go | 16 +- 70 files changed, 5417 insertions(+), 360 deletions(-) create mode 100644 internal/auth/code_replay_test.go create mode 100644 internal/auth/guard.go create mode 100644 internal/auth/guard_test.go create mode 100644 internal/db/fresh_db_test.go create mode 100644 internal/fog/limit_test.go create mode 100644 internal/fog/tilename_test.go create mode 100644 internal/ingest/dirty_import_test.go create mode 100644 internal/rond/dirty_test.go create mode 100644 internal/rond/limit_test.go create mode 100644 internal/rond/parse_rows_test.go create mode 100644 internal/stats/placecols_test.go create mode 100644 internal/stats/verdict_scope_test.go create mode 100644 internal/web/admin_notes_bulk_test.go create mode 100644 internal/web/agg_error_test.go create mode 100644 internal/web/archive_pick_test.go create mode 100644 internal/web/client_ip_test.go create mode 100644 internal/web/csrf.go create mode 100644 internal/web/csrf_test.go create mode 100644 internal/web/download_name_test.go create mode 100644 internal/web/entity_raw_test.go create mode 100644 internal/web/export_download_test.go create mode 100644 internal/web/fillup_cache_test.go create mode 100644 internal/web/fog_race_test.go create mode 100644 internal/web/gpx_owner_test.go create mode 100644 internal/web/login_guard_test.go create mode 100644 internal/web/redirect_target_test.go create mode 100644 internal/web/region_consistency_test.go create mode 100644 internal/web/server_error_test.go create mode 100644 internal/web/session_cookie_test.go create mode 100644 internal/web/stats_weekday_once_test.go create mode 100644 internal/web/testpaths_test.go create mode 100644 internal/web/topic_stats_test.go create mode 100644 internal/web/twofa_guard_test.go create mode 100644 internal/web/tx_atomicity_test.go create mode 100644 internal/web/upload_cleanup_test.go create mode 100644 internal/web/visit_bookmark_test.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8cc792d..02a6216 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -169,7 +169,7 @@ func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) { return &u, nil } -// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。 +// 会话令牌 = uid|expiry|hmac(uid|expiry|口令摘要),口令变更即自动失效。 func (s *Store) SignToken(userID int64, hash string) string { exp := time.Now().AddDate(0, 0, s.Days).Unix() payload := fmt.Sprintf("%d|%d", userID, exp) @@ -195,14 +195,8 @@ func (s *Store) ParseToken(token, hash string) (int64, bool) { func (s *Store) mac(payload, hash string) string { m := hmac.New(sha256.New, s.Secret) m.Write([]byte(payload)) - // 混入口令摘要,改密后旧 cookie 立即作废 - m.Write([]byte("|" + hash[:min(len(hash), 16)])) + // 绑完整口令摘要:摘要里带随机 salt,改密后必然不同,旧 cookie 当场作废。 + // 别只取摘要前几位——PBKDF2 编码的前缀是固定格式,等于绑了个常量。 + m.Write([]byte("\x00" + hash)) return b64(m.Sum(nil)) } - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 9525c83..68ec81c 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -12,6 +12,30 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) +// TestSessionTokenBindsPasswordHash 钉住「改密后旧会话立即作废」这条契约。 +// 令牌的 MAC 必须真的绑上口令摘要:曾经绑的是摘要的前 16 个字符,而 PBKDF2 +// 编码形如 `pbkdf2$sha256$210000$$`,前 16 字节恒为 `pbkdf2$sha256$21` +// ——等于绑了个常量,改密后别的设备上的旧 cookie 照样能用。 +// 不碰数据库:SignToken/ParseToken 只用到 Secret 与 Days。 +func TestSessionTokenBindsPasswordHash(t *testing.T) { + st := &Store{Secret: []byte("test-secret"), Days: 7} + before, err := HashPassword("old-password-123456") + if err != nil { + t.Fatal(err) + } + after, err := HashPassword("new-password-123456") + if err != nil { + t.Fatal(err) + } + tok := st.SignToken(1, before) + if _, ok := st.ParseToken(tok, before); !ok { + t.Fatal("用签名时的口令摘要应能验签") + } + if _, ok := st.ParseToken(tok, after); ok { + t.Error("改密后旧会话仍验签通过:令牌 MAC 没有真的绑上口令摘要") + } +} + // TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。 // 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来, // 未设置时跳过(与项目里其它按环境变量门控的测试一致)。 diff --git a/internal/auth/code_replay_test.go b/internal/auth/code_replay_test.go new file mode 100644 index 0000000..e9edc96 --- /dev/null +++ b/internal/auth/code_replay_test.go @@ -0,0 +1,47 @@ +package auth + +import ( + "testing" + "time" +) + +// TestCodeReplayRejectsReuseInWindow 同一个码在窗口内只能用一次。 +func TestCodeReplayRejectsReuseInWindow(t *testing.T) { + r := NewCodeReplay() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + r.now = func() time.Time { return base } + + if !r.Consume(7, "123456") { + t.Fatal("首次使用应当通过") + } + if r.Consume(7, "123456") { + t.Error("同一个码被重放了") + } + // 换个用户号的同一个数字码不受影响 + if !r.Consume(8, "123456") { + t.Error("不同用户的同一个码不该互相影响") + } +} + +// TestCodeReplayExpiresAfterWindow 条目只活过一个接受窗口(±1 步 = 90 秒), +// 过期后同一个码可以再被收下——否则条目会越攒越多,且数字复用会误伤。 +func TestCodeReplayExpiresAfterWindow(t *testing.T) { + r := NewCodeReplay() + now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + r.now = func() time.Time { return now } + + if !r.Consume(7, "123456") { + t.Fatal("首次使用应当通过") + } + now = now.Add(replayTTL - time.Second) + if r.Consume(7, "123456") { + t.Error("窗口内不该放行") + } + now = now.Add(2 * time.Second) // 越过 replayTTL + if !r.Consume(7, "123456") { + t.Error("过期后应当放行") + } + if n := len(r.used); n != 1 { + t.Errorf("过期条目没被清掉,还剩 %d 条", n) + } +} diff --git a/internal/auth/guard.go b/internal/auth/guard.go new file mode 100644 index 0000000..40196f1 --- /dev/null +++ b/internal/auth/guard.go @@ -0,0 +1,128 @@ +// Package auth 的登录失败短锁。 +package auth + +import ( + "crypto/rand" + "math/big" + "sync" + "time" +) + +// Guard 拦登录爆破:按用户名累计失败次数,到阈值后按指数退避短暂锁住。 +// +// 键只用用户名、**不带 IP**——clientIP 优先信 X-Forwarded-For 的第一段, +// 而那是客户端可伪造的,拿它当键就等于「每换一个请求头换一把新锁」。 +// 本站只有一个账号(启动时创建,没有注册入口),用户名是稳定的, +// 所以它才是可靠的爆破计数键。 +// +// 状态在内存里:站点是单进程部署,重启即清空,代价可接受。 +type Guard struct { + mu sync.Mutex + items map[string]entry + now func() time.Time // 测试注入用 +} + +type entry struct { + fails int + until time.Time // 锁到什么时候 + seen time.Time +} + +const ( + // guardFreeTries 是阈值:前几次失败不锁,正常手滑不该被罚。 + guardFreeTries = 5 + // guardBaseLock 是刚过阈值时的锁定时长,之后每次失败翻倍。 + guardBaseLock = 2 * time.Second + guardMaxLock = 15 * time.Minute + // guardMaxKeys 与 guardIdleTTL 只管内存:有人拿随机用户名刷接口时, + // 记录不能无限长下去。 + guardMaxKeys = 1024 + guardIdleTTL = time.Hour +) + +// NewGuard 建一个空白的失败计数器。 +func NewGuard() *Guard { + return &Guard{items: map[string]entry{}, now: time.Now} +} + +// RetryAfter 返回该键还要等多久才能再试;0 表示可以试。 +func (g *Guard) RetryAfter(key string) time.Duration { + g.mu.Lock() + defer g.mu.Unlock() + return g.retryAfterLocked(key) +} + +func (g *Guard) retryAfterLocked(key string) time.Duration { + e, ok := g.items[key] + if !ok { + return 0 + } + if d := e.until.Sub(g.now()); d > 0 { + return d + } + return 0 +} + +// Fail 记一次失败,返回这次之后需要等待的时长(0 表示还没到阈值)。 +func (g *Guard) Fail(key string) time.Duration { + g.mu.Lock() + defer g.mu.Unlock() + g.pruneLocked() + e := g.items[key] + e.fails++ + e.seen = g.now() + if e.fails >= guardFreeTries { + // 第 5 次失败锁 2 秒,第 6 次 4 秒……封顶 15 分钟 + lock := guardBaseLock << uint(e.fails-guardFreeTries) + if lock > guardMaxLock || lock <= 0 { + lock = guardMaxLock + } + e.until = g.now().Add(lock) + } + g.items[key] = e + return g.retryAfterLocked(key) +} + +// Succeed 登录成功就清掉计数:口令是对的,之前的失败不再累积到下次。 +func (g *Guard) Succeed(key string) { + g.mu.Lock() + delete(g.items, key) + g.mu.Unlock() +} + +// Reset 清空全部计数(测试与本地排查用)。 +func (g *Guard) Reset() { + g.mu.Lock() + g.items = map[string]entry{} + g.mu.Unlock() +} + +func (g *Guard) pruneLocked() { + now := g.now() + for k, v := range g.items { + if now.Sub(v.seen) > guardIdleTTL { + delete(g.items, k) + } + } + for k := range g.items { + if len(g.items) < guardMaxKeys { + break + } + delete(g.items, k) + } +} + +// RandomPassword 生成 n 位随机口令,字符集去掉了容易看错的 0/O/1/I/L。 +// 用于「没配 ROND_ADMIN_PASSWORD 时别再写死出厂口令」。 +func RandomPassword(n int) (string, error) { + const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" + var b = make([]byte, n) + for i := range b { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", err + } + b[i] = alphabet[idx.Int64()] + } + return string(b), nil +} diff --git a/internal/auth/guard_test.go b/internal/auth/guard_test.go new file mode 100644 index 0000000..2b1867c --- /dev/null +++ b/internal/auth/guard_test.go @@ -0,0 +1,147 @@ +package auth + +import ( + "strings" + "testing" + "time" +) + +// TestGuardLocksAfterThreshold 前几次手滑不该被罚,到阈值才开始锁。 +func TestGuardLocksAfterThreshold(t *testing.T) { + g := NewGuard() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + g.now = func() time.Time { return base } + + for i := 1; i < guardFreeTries; i++ { + if wait := g.Fail("admin"); wait != 0 { + t.Fatalf("第 %d 次失败就被锁了 %v,阈值应当是 %d", i, wait, guardFreeTries) + } + if d := g.RetryAfter("admin"); d != 0 { + t.Fatalf("第 %d 次失败后不该有等待时间,实际 %v", i, d) + } + } + wait := g.Fail("admin") + if wait <= 0 { + t.Fatalf("第 %d 次失败应当上锁", guardFreeTries) + } + if wait > guardBaseLock { + t.Errorf("首次锁定时长 %v 超过了基准 %v", wait, guardBaseLock) + } + if d := g.RetryAfter("admin"); d != wait { + t.Errorf("RetryAfter=%v 与 Fail 返回的 %v 不一致", d, wait) + } + t.Logf("第 %d 次失败锁 %v", guardFreeTries, wait) +} + +// TestGuardBackoffGrowsAndCaps 继续失败要退避得更久,但封顶。 +func TestGuardBackoffGrowsAndCaps(t *testing.T) { + g := NewGuard() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + g.now = func() time.Time { return base } + + var prev time.Duration + for i := 0; i < guardFreeTries+12; i++ { + wait := g.Fail("admin") + if wait < prev { + t.Fatalf("第 %d 次失败的等待时间 %v 比上一次 %v 还短", i+1, wait, prev) + } + if wait > guardMaxLock { + t.Fatalf("第 %d 次失败锁了 %v,超过上限 %v", i+1, wait, guardMaxLock) + } + prev = wait + } + if prev != guardMaxLock { + t.Errorf("连续失败后应当封顶在 %v,实际 %v", guardMaxLock, prev) + } +} + +// TestGuardReleasesAfterWait 时间走够之后要放行,否则就是永久锁死自己。 +func TestGuardReleasesAfterWait(t *testing.T) { + g := NewGuard() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + now := base + g.now = func() time.Time { return now } + + var wait time.Duration + for i := 0; i < guardFreeTries; i++ { + wait = g.Fail("admin") + } + if wait <= 0 { + t.Fatal("到阈值却没上锁") + } + + now = base.Add(wait - time.Millisecond) + if d := g.RetryAfter("admin"); d <= 0 { + t.Errorf("还差 1ms 到期,应当仍在锁内,实际 %v", d) + } + now = base.Add(wait) + if d := g.RetryAfter("admin"); d != 0 { + t.Errorf("到期后应当放行,实际还要等 %v", d) + } +} + +// TestGuardSucceedClears 登录成功要把计数清零,别让几天前的失败把下次正常登录挡住。 +func TestGuardSucceedClears(t *testing.T) { + g := NewGuard() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + g.now = func() time.Time { return base } + + for i := 0; i < guardFreeTries; i++ { + g.Fail("admin") + } + if g.RetryAfter("admin") <= 0 { + t.Fatal("应当处于锁定状态") + } + g.Succeed("admin") + if d := g.RetryAfter("admin"); d != 0 { + t.Errorf("成功后不该还有等待时间,实际 %v", d) + } + // 清零之后又要重新攒满阈值才会锁 + for i := 1; i < guardFreeTries; i++ { + if wait := g.Fail("admin"); wait != 0 { + t.Errorf("清零后第 %d 次失败不该上锁,实际 %v", i, wait) + } + } +} + +// TestGuardSeparatesKeys 不同用户名各算各的。 +func TestGuardSeparatesKeys(t *testing.T) { + g := NewGuard() + base := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + g.now = func() time.Time { return base } + + for i := 0; i < guardFreeTries; i++ { + g.Fail("admin") + } + if g.RetryAfter("admin") <= 0 { + t.Fatal("admin 应当被锁") + } + if d := g.RetryAfter("someone-else"); d != 0 { + t.Errorf("另一个用户名不该受影响,实际 %v", d) + } +} + +// TestRandomPassword 确认初始口令有足够熵、字符集不含易混字符。 +func TestRandomPassword(t *testing.T) { + const n = 16 + a, err := RandomPassword(n) + if err != nil { + t.Fatal(err) + } + b, err := RandomPassword(n) + if err != nil { + t.Fatal(err) + } + if len(a) != n || len(b) != n { + t.Fatalf("长度不对: %d / %d(应为 %d)", len(a), len(b), n) + } + if a == b { + t.Error("两次生成的随机口令相同") + } + if strings.ContainsAny(a, "0O1Il") { + t.Errorf("口令含易混字符: %s", a) + } + if _, err := RandomPassword(0); err != nil { + t.Errorf("长度 0 不该报错: %v", err) + } +} diff --git a/internal/auth/totp.go b/internal/auth/totp.go index ddbd613..d71e07f 100644 --- a/internal/auth/totp.go +++ b/internal/auth/totp.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" ) @@ -98,6 +99,44 @@ func urlEscape(s string) string { return b.String() } +// ---------- 验证码重放 ---------- + +// replayTTL 要盖过 VerifyTOTP 的整个接受窗口(前后各一步 = 90 秒), +// 否则同一步长的码会在条目过期后又被收下。 +const replayTTL = 3 * totpStep + +// CodeReplay 记住最近用过的验证码,拦住「同一个码在窗口内被用第二次」。 +// VerifyTOTP 只判断「这个码此刻对不对」,不含一次性语义;被偷看到或被日志 +// 截获的码,在它过期前都能再换一个会话。 +type CodeReplay struct { + mu sync.Mutex + used map[string]time.Time + now func() time.Time +} + +func NewCodeReplay() *CodeReplay { + return &CodeReplay{used: map[string]time.Time{}, now: time.Now} +} + +// Consume 在验证码**已经校验通过**之后登记一次使用;已被用过则返回 false。 +// 键里带用户号:不同账号的同一个数字码互不影响。 +func (r *CodeReplay) Consume(userID int64, code string) bool { + now := r.now() + key := fmt.Sprintf("%d|%s", userID, code) + r.mu.Lock() + defer r.mu.Unlock() + for k, t := range r.used { + if now.Sub(t) > replayTTL { + delete(r.used, k) + } + } + if _, ok := r.used[key]; ok { + return false + } + r.used[key] = now + return true +} + // ---------- 用户秘钥存取 ---------- // TOTPState 返回用户的两步验证状态。 diff --git a/internal/db/fresh_db_test.go b/internal/db/fresh_db_test.go new file mode 100644 index 0000000..cb7a53f --- /dev/null +++ b/internal/db/fresh_db_test.go @@ -0,0 +1,164 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "testing" + "time" + + "rond-with-you/internal/stats" +) + +// TestFreshDatabaseServesAppQueries 在一个全新库里跑完整迁移,再用应用侧的读查询真打一遍。 +// +// 为什么非要在「全新库」上测:schema.sql 里有一段 DO 块(附属数据改挂坐标键)依赖 +// place_images / place_visitor_votes / places / datasets 已经建好。迁移块一旦排到依赖表 +// 之前,**老库毫无症状**(表早就存在),只有新部署起不来 —— 这类改动在本机开发时根本看不见。 +// 顺带也守住「表/列/索引/主键」在全新库上的最终形态,而不是只看「建了 10 张表就算过」。 +func TestFreshDatabaseServesAppQueries(t *testing.T) { + dsn := os.Getenv("ROND_DSN") + if dsn == "" { + t.Skip("设置 ROND_DSN 后运行全新库冒烟") + } + name := fmt.Sprintf("rond_test_fresh_%d", time.Now().UnixNano()) + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("解析 ROND_DSN: %v", err) + } + u.Path = "/" + name + + d, err := Open(u.String()) + if err != nil { + t.Fatalf("全新库应自动建出来: %v", err) + } + t.Cleanup(func() { + d.Close() // 必须放在 dropDatabase 之前:DROP DATABASE 要求没有连接挂着 + dropDatabase(t, dsn, name) + }) + + ctx := context.Background() + if err := Migrate(ctx, d); err != nil { + t.Fatalf("全新库初始化表结构失败(迁移块顺序/依赖问题会让新库起不来): %v", err) + } + if err := Migrate(ctx, d); err != nil { + t.Fatalf("重复 Migrate 不该报错(部署会重复跑): %v", err) + } + + tables := []string{ + "users", "datasets", "activities", "tags", "places", "visits", "visit_tags", + "movements", "weather", "settings", "category_labels", "fog_blocks", "fog_masks", + "fog_raw_files", "gpx_tracks", "topics", "place_notes", "place_votes", + "place_visitor_votes", "place_images", "entity_raw", + } + for _, tbl := range tables { + if !hasTable(t, d, tbl) { + t.Errorf("全新库里缺表 %s", tbl) + } + } + + // 这些列全靠迁移块补出来:列不在 = 块没跑到 / 被插到了依赖表前面 + cols := map[string][]string{ + "places": {"coord_source", "coord_sys", "src_lat", "src_lon"}, + "place_notes": {"place_key", "rond_note", "dataset_id"}, + "place_votes": {"place_key", "dataset_id"}, + "place_visitor_votes": {"place_key"}, + "place_images": {"place_key", "storage", "object_key"}, + "datasets": {"content_hash", "coredata_meta"}, + "users": {"totp_secret", "totp_enabled"}, + "entity_raw": {"skipped"}, + } + for tbl, want := range cols { + for _, col := range want { + if !hasColumn(t, d, tbl, col) { + t.Errorf("全新库里 %s.%s 没有建出来", tbl, col) + } + } + } + + // 附属数据的主键必须已经搬到坐标键上:停在老主键就是「半迁移」状态 + for tbl, want := range map[string]string{ + "place_notes": "user_id,place_key", "place_votes": "user_id,place_key", + "place_visitor_votes": "place_key,voter_key", + } { + var got string + err := d.QueryRow(`SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum)) + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + WHERE c.conrelid = $1::regclass AND c.contype = 'p'`, tbl).Scan(&got) + if err != nil { + t.Fatalf("读 %s 主键失败: %v", tbl, err) + } + if got != want { + t.Errorf("%s 的主键是 (%s),期望 (%s)", tbl, got, want) + } + } + + // DO 块之后才建的索引:块顺序错了它就会在缺表时报错 + for _, idx := range []string{"idx_place_images_key", "idx_pvv_key", "idx_places_geo", "idx_visits_place"} { + if !hasIndex(t, d, idx) { + t.Errorf("全新库里缺索引 %s", idx) + } + } + + // 应用侧读查询真打一遍空库:任何查询引用了没建出来的列,这里就会报错 + sd := stats.DB{DB: d} + f := stats.Filter{DatasetID: 1} + reads := map[string]func() error{ + "Overview": func() error { _, err := sd.Overview(ctx, f); return err }, + "Places": func() error { _, _, err := sd.Places(ctx, f, "visits", 20, 0); return err }, + "MapPlaces": func() error { _, err := sd.MapPlaces(ctx, f, 20); return err }, + "Visits": func() error { _, _, err := sd.Visits(ctx, f, 20, 0, "desc"); return err }, + "RecentVisits": func() error { _, err := sd.RecentVisits(ctx, f, 5); return err }, + "CityStats": func() error { _, err := sd.CityStats(ctx, f, 10); return err }, + "ProvinceStats": func() error { _, err := sd.ProvinceStats(ctx, f); return err }, + "ActivityStats": func() error { _, err := sd.ActivityStats(ctx, f); return err }, + "ActivityCounts": func() error { _, err := sd.ActivityCounts(ctx, f); return err }, + "TransportStats": func() error { _, err := sd.TransportStats(ctx, f); return err }, + "TagStats": func() error { _, err := sd.TagStats(ctx, f); return err }, + "Monthly": func() error { _, err := sd.Monthly(ctx, f); return err }, + "Calendar": func() error { _, err := sd.Calendar(ctx, f); return err }, + "Hourly": func() error { _, err := sd.Hourly(ctx, f); return err }, + "Weekday": func() error { _, err := sd.Weekday(ctx, f); return err }, + "Cities": func() error { _, err := sd.Cities(ctx, 1, nil, false); return err }, + "Tags": func() error { _, err := sd.Tags(ctx, 1); return err }, + "Activities": func() error { _, err := sd.Activities(ctx, 1); return err }, + } + for label, run := range reads { + if err := run(); err != nil { + t.Errorf("全新库上跑 %s 失败(多半是某个列/索引没建出来): %v", label, err) + } + } +} + +func hasTable(t *testing.T, d *sql.DB, tbl string) bool { + t.Helper() + var n int + if err := d.QueryRow(`SELECT count(*) FROM information_schema.tables + WHERE table_schema='public' AND table_name=$1`, tbl).Scan(&n); err != nil { + t.Fatalf("查表 %s: %v", tbl, err) + } + return n > 0 +} + +func hasColumn(t *testing.T, d *sql.DB, tbl, col string) bool { + t.Helper() + var n int + if err := d.QueryRow(`SELECT count(*) FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 AND column_name=$2`, tbl, col).Scan(&n); err != nil { + t.Fatalf("查列 %s.%s: %v", tbl, col, err) + } + return n > 0 +} + +func hasIndex(t *testing.T, d *sql.DB, idx string) bool { + t.Helper() + var n int + if err := d.QueryRow(`SELECT count(*) FROM pg_indexes + WHERE schemaname='public' AND indexname=$1`, idx).Scan(&n); err != nil { + t.Fatalf("查索引 %s: %v", idx, err) + } + return n > 0 +} diff --git a/internal/fog/fog.go b/internal/fog/fog.go index 71f7cea..2a06db7 100644 --- a/internal/fog/fog.go +++ b/internal/fog/fog.go @@ -65,9 +65,19 @@ var popcount = func() [256]byte { return t }() +// tileDigits 返回瓦片编号的规范十进制串,不足两位补前导 0。 +// 文件名格式固定以 2 位 M2 尾缀结尾(见 tileName),单数字编号不补齐就无法往返。 +func tileDigits(id int) string { + s := strconv.Itoa(id) + if len(s) < 2 { + return "0" + s + } + return s +} + // tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。 func tileIDFromName(name string) (int, bool) { - if len(name) < 7 || len(name[4:]) < 3 { + if len(name) < 8 || len(name[4:]) < 4 { return 0, false } digits := make([]byte, 0, len(name)) @@ -82,7 +92,7 @@ func tileIDFromName(name string) (int, bool) { if err != nil { return 0, false } - s := strconv.Itoa(id) + s := tileDigits(id) if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] { return 0, false } @@ -97,7 +107,7 @@ func tileIDFromName(name string) (int, bool) { // tileName 把瓦片编号编码回文件名。 func tileName(id int) string { - s := strconv.Itoa(id) + s := tileDigits(id) sum := fmt.Sprintf("%x", md5.Sum([]byte(s))) var b strings.Builder b.WriteString(sum[:4]) @@ -110,6 +120,15 @@ func tileName(id int) string { return b.String() } +// maxTileRawBytes / maxTileZBytes 是单个迷雾瓦片的「压缩后 / 解压后」上限。 +// 实测瓦片压缩后约 250KB;解压后结构上最多 headerSize + 65535*blockSize ≈ 33MB +// (头部的每个索引是 uint16),给到 64MB 已足够宽松。不设上限的话, +// 一个几 KB 的 zip bomb 就能把 1C1G 的机器打爆。 +const ( + maxTileRawBytes = 16 << 20 + maxTileZBytes = 64 << 20 +) + // Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。 // 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。 func Decode(data []byte) ([]Block, int, error) { @@ -131,11 +150,14 @@ func Decode(data []byte) ([]Block, int, error) { if err != nil { return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err) } - raw, err := io.ReadAll(rc) + raw, err := io.ReadAll(io.LimitReader(rc, maxTileRawBytes+1)) rc.Close() if err != nil { return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err) } + if len(raw) > maxTileRawBytes { + return nil, 0, fmt.Errorf("瓦片 %s 压缩后超过 %d 字节,疑似异常快照", f.Name, maxTileRawBytes) + } zdata, err := zlibDecompress(raw) if err != nil { return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err) @@ -196,7 +218,15 @@ func zlibDecompress(raw []byte) ([]byte, error) { return nil, err } defer zr.Close() - return io.ReadAll(zr) + out, err := io.ReadAll(io.LimitReader(zr, maxTileZBytes+1)) + if err != nil { + return nil, err + } + // zlib 流本身可以解出任意长度,必须在读的时候掐住 + if len(out) > maxTileZBytes { + return nil, fmt.Errorf("解压后超过 %d 字节,疑似 zip bomb", maxTileZBytes) + } + return out, nil } // Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。 diff --git a/internal/fog/limit_test.go b/internal/fog/limit_test.go new file mode 100644 index 0000000..292186f --- /dev/null +++ b/internal/fog/limit_test.go @@ -0,0 +1,78 @@ +package fog + +import ( + "archive/zip" + "bytes" + "compress/zlib" + "strings" + "testing" +) + +// TestZlibDecompressRejectsBomb 守住「zlib 流能解出任意长度」这个口子。 +// 几十 MB 的全零数据压出来只有几十 KB,解压时不设上限就是一个标准 zip bomb—— +// 上传接口只有站长能用,但 1C1G 的机器被打爆一次就够受。 +func TestZlibDecompressRejectsBomb(t *testing.T) { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + zeros := make([]byte, 1<<20) + written := 0 + for written <= maxTileZBytes { + if _, err := zw.Write(zeros); err != nil { + t.Fatal(err) + } + written += len(zeros) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + t.Logf("构造出 %d 字节的 zlib 流,解压后 %d 字节", buf.Len(), written) + + if _, err := zlibDecompress(buf.Bytes()); err == nil { + t.Errorf("解压出 %d 字节(上限 %d)的流应当被拒绝", written, maxTileZBytes) + } else { + t.Logf("已拒绝: %v", err) + } +} + +// TestZlibDecompressNormalStream 反向确认:正常大小的流仍能原样解出, +// 免得上面那条把限额做得太紧、把真快照也一起挡了。 +func TestZlibDecompressNormalStream(t *testing.T) { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + want := strings.Repeat("rond", 4096) + if _, err := zw.Write([]byte(want)); err != nil { + t.Fatal(err) + } + zw.Close() + + got, err := zlibDecompress(buf.Bytes()) + if err != nil { + t.Fatalf("正常流不该被拒: %v", err) + } + if string(got) != want { + t.Errorf("解压结果不一致: %d 字节 → %d 字节", len(want), len(got)) + } +} + +// TestDecodeRejectsOversizedTileEntry 确认 Decode 这一层也限住了「压缩后」的体积: +// 条目本身就超限时直接报错,而不是先整个读进内存再判断。 +func TestDecodeRejectsOversizedTileEntry(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("Model/*/" + tileName(42)) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(strings.Repeat("x", maxTileRawBytes+1))); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + if _, _, err := Decode(buf.Bytes()); err == nil { + t.Errorf("压缩后超过 %d 字节的瓦片应当被拒绝", maxTileRawBytes) + } else { + t.Logf("已拒绝: %v", err) + } +} diff --git a/internal/fog/tilename_test.go b/internal/fog/tilename_test.go new file mode 100644 index 0000000..d0f3c8e --- /dev/null +++ b/internal/fog/tilename_test.go @@ -0,0 +1,48 @@ +package fog + +import ( + "crypto/md5" + "fmt" + "testing" +) + +// 瓦片编号 0..9 是合法的(tx=0, ty=0 那块就是 0),编码/解码都必须能往返。 +func TestTileNameRoundtripAllIDs(t *testing.T) { + for _, id := range []int{0, 1, 5, 9, 10, 11, 99, 100, 511, 512, 262143, 1 << 20} { + name := tileName(id) + got, ok := tileIDFromName(name) + if !ok || got != id { + t.Errorf("id=%d → name=%q → (%d,%v),往返不一致", id, name, got, ok) + } + } +} + +// 4 位 md5("5") 前缀 + mask1 掩码 + 2 位 M2 尾缀 = 7 字节:旧的长度守卫会放行、 +// md5 校验也通过,随后在 s[len(s)-2+i] 处越界 panic(上传构造过的 .fwss 即可触发)。 +func TestTileIDFromNameRejectsCraftedShortName(t *testing.T) { + name := fmt.Sprintf("%x", md5.Sum([]byte("5")))[:4] + + string(mask1[5]) + string(mask2[5]) + string(mask2[5]) + if len(name) != 7 { + t.Fatalf("构造名长度应为 7,实为 %d (%q)", len(name), name) + } + if id, ok := tileIDFromName(name); ok { + t.Fatalf("构造名 %q 不应被接受(解出 id=%d)", name, id) + } +} + +// 网格第一列(tx=0)的瓦片编号就是 0..9,Encode 不能 panic,且能原样解回。 +func TestEncodeHandlesSingleDigitTile(t *testing.T) { + bm := make([]byte, bitmapSize) + bm[0] = 0x80 + data, err := Encode([]Block{{GX: 0, GY: 0, Bitmap: bm}}, nil) + if err != nil { + t.Fatalf("Encode: %v", err) + } + blocks, cells, err := Decode(data) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if len(blocks) != 1 || blocks[0].GX != 0 || blocks[0].GY != 0 || cells != 1 { + t.Fatalf("往返得到 %+v (cells=%d),期望 1 块 (0,0)、1 格", blocks, cells) + } +} diff --git a/internal/ingest/coord.go b/internal/ingest/coord.go index 707336b..b98dbe2 100644 --- a/internal/ingest/coord.go +++ b/internal/ingest/coord.go @@ -6,6 +6,7 @@ import ( "fmt" "rond-with-you/internal/geo" + "rond-with-you/internal/rond" ) // querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。 @@ -30,15 +31,15 @@ type querier interface { // ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。 func BackfillCoordSource(ctx context.Context, q querier, datasetID int64) (int, error) { rows, err := q.QueryContext(ctx, `SELECT p.src_pk, COALESCE(p.coord_source,''), p.src_lat, p.src_lon, - (r.raw->>'ZLATITUDE')::float8, (r.raw->>'ZLONGITUDE')::float8, - (r.raw->>'ZRAWLATITUDE')::float8, (r.raw->>'ZRAWLONGITUDE')::float8 + `+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`, + `+rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`)+` 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 - AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$' - AND (r.raw->>'ZLONGITUDE') ~ '^-?[0-9.]+$' - AND (r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$' - AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'`, datasetID) + AND (r.raw->>'ZLATITUDE') ~ '`+rond.RawFloatPattern+`' + AND (r.raw->>'ZLONGITUDE') ~ '`+rond.RawFloatPattern+`' + AND (r.raw->>'ZRAWLATITUDE') ~ '`+rond.RawFloatPattern+`' + AND (r.raw->>'ZRAWLONGITUDE') ~ '`+rond.RawFloatPattern+`'`, datasetID) if err != nil { return 0, fmt.Errorf("读留档行: %w", err) } diff --git a/internal/ingest/dirty_import_test.go b/internal/ingest/dirty_import_test.go new file mode 100644 index 0000000..b2342f4 --- /dev/null +++ b/internal/ingest/dirty_import_test.go @@ -0,0 +1,108 @@ +package ingest + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "rond-with-you/internal/db" + "rond-with-you/internal/rond" +) + +// ingestTestDSN 把 ROND_DSN 的库名换成专用库。Import 会把该用户的 is_active 切到自己那份, +// 而 stats 的夹具是「WHERE is_active LIMIT 1」找数据集(不带 user 过滤)——共用库时, +// 本用例运行期间那份临时数据集会被别的包选中,直接串味。换个库谁都不影响谁(库不存在 db.Open 会建)。 +func ingestTestDSN(base string) string { + u, err := url.Parse(base) + if err != nil || u.Path == "" { + return base + } + u.Path = strings.TrimSuffix(u.Path, "/") + "_ingest" + return u.String() +} + +// 脏时间(parse 阶段被 appleTime 判成 nil 的到访)导入后必须: +// - 不进站点模型(visit_count 不含它); +// - 按「跳过」留档,导出时原样补回(否则一趟导入导出就少一条); +// - 绝不污染 datasets.first_visit_at —— 旧实现会把它回绕成 2001,时间轴直接从 2001 起算。 +func TestImportKeepsDirtyTimeOutOfTimeline(t *testing.T) { + base := os.Getenv("ROND_DSN") + if base == "" { + t.Skip("未设置 ROND_DSN") + } + ctx := context.Background() + d, err := db.Open(ingestTestDSN(base)) + if err != nil { + t.Fatal(err) + } + // 先注册 close、后注册删用户:Cleanup 是后进先出,删用户时连接还没关。 + t.Cleanup(func() { d.Close() }) + if err := db.Migrate(ctx, d); err != nil { + t.Fatal(err) + } + + var userID int64 + uname := fmt.Sprintf("__dirty_%d", time.Now().UnixNano()) + if err := d.QueryRowContext(ctx, + `INSERT INTO users (username, password_hash) VALUES ($1,'x') RETURNING id`, uname).Scan(&userID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Exec(`DELETE FROM users WHERE id=$1`, userID) }) + + good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC) + b := &rond.Backup{ + VisitUUID: map[int]string{1: strings.Repeat("a", 32)}, + ActivityUUID: map[int]string{}, + Locations: []rond.Location{{SrcPK: 1, Name: "家", Lat: 24.1, Lon: 109.1}}, + Visits: []rond.Visit{ + {SrcPK: 1, Arrival: &good, LocationSrcPK: intp(1)}, + {SrcPK: 2, Arrival: nil, LocationSrcPK: intp(1)}, // 脏时间被 appleTime 判成 nil + }, + RawRows: map[string]map[int]map[string]any{ + "ZVISIT": {2: {"Z_PK": int64(2), "ZARRIVALDATE_": 1e30}}, + }, + } + + datasetID, st, err := Import(ctx, d, userID, "dirty.rondbackup", strings.Repeat("0", 64), 1, b) + if err != nil { + t.Fatal(err) + } + + if st.Visits != 1 { + t.Errorf("只该导入 1 条到访(脏时间那条跳过),实际 %d", st.Visits) + } + + var stored int + if err := d.QueryRowContext(ctx, + `SELECT count(*) FROM visits WHERE dataset_id=$1`, datasetID).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != 1 { + t.Errorf("库里到访应为 1 条,实际 %d", stored) + } + + var archived int + if err := d.QueryRowContext(ctx, + `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND skipped`, datasetID).Scan(&archived); err != nil { + t.Fatal(err) + } + if archived != 1 { + t.Errorf("脏时间的到访应当留档为 skipped,实际 %d 条", archived) + } + + var first, last sql.NullTime + if err := d.QueryRowContext(ctx, + `SELECT first_visit_at, last_visit_at FROM datasets WHERE id=$1`, datasetID).Scan(&first, &last); err != nil { + t.Fatal(err) + } + if !first.Valid || !first.Time.Equal(good) { + t.Errorf("first_visit_at 应等于合法到访的时间 %s,实际 %v", good.Format(time.RFC3339), first) + } +} diff --git a/internal/notes/notes.go b/internal/notes/notes.go index 403c056..b2c0446 100644 --- a/internal/notes/notes.go +++ b/internal/notes/notes.go @@ -69,6 +69,29 @@ func (s *Store) SetNote(ctx context.Context, userID, datasetID int64, srcPK int, return s.Save(ctx, userID, datasetID, srcPK, alias, content) } +// SetNoteBulk 批量「清空备注、保留别名」,语义与逐条 SetNote(..., "") 一致,但只发两条语句 +// (逐个来是 2N 次往返,勾几十个地点在 1C1G 的机器上是能感觉到的): +// 先删掉「别名与 rond 备注都为空」的行,再把其余行的手写 note 清空。 +func (s *Store) SetNoteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error { + if len(srcPKs) == 0 { + return nil + } + if _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p + WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[]) + AND n.place_key = `+placeKeyExpr("p")+` + AND btrim(COALESCE(n.alias,''))='' AND n.rond_note=''`, + userID, datasetID, srcPKs); err != nil { + return err + } + // 顺带把 dataset_id/src_pk 刷成当前数据集的值,与逐条 Save 的 upsert 行为对齐 + _, err := s.DB.ExecContext(ctx, `UPDATE place_notes n + SET note='', dataset_id=p.dataset_id, src_pk=p.src_pk, updated_at=now() + FROM places p + WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[]) + AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs) + return err +} + // Delete 删除某地点的整条备注与别名。 func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p @@ -77,6 +100,17 @@ func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) return err } +// DeleteBulk 一次删掉多个地点的整条备注与别名(后台批量勾选时用,一条语句代替 N 次往返)。 +func (s *Store) DeleteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error { + if len(srcPKs) == 0 { + return nil + } + _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p + WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[]) + AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs) + return err +} + // Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。 func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error { alias = strings.TrimSpace(alias) diff --git a/internal/rond/diff.go b/internal/rond/diff.go index b3096d5..43c3e8f 100644 --- a/internal/rond/diff.go +++ b/internal/rond/diff.go @@ -110,6 +110,29 @@ func PlaceCoordKey(lat, lon float64) string { func coordStr(v float64) string { return fmt.Sprintf("%.6f", v) } +// RawFloat8 返回「把 entity_raw 里的 JSON 值安全地转成 float8」的 SQL 表达式, +// 值不是合法的数字就给 NULL,而不是抛错。 +// +// 留档是原样存下来的 JSON,值不一定是数字:空串、字符串形式的 "1.2.3"、 +// "NaN" 都可能出现。直接 `::float8` 会抛 invalid input syntax,代价是**编辑页 +// 整页 500** 或**整次导入失败 / 启动自愈失败**,而且只在特定那一行数据上复现。 +// +// 两个实现细节都有原因,别简化: +// - 写成 CASE 而不是「正则 AND cast」:PG 不保证 AND/OR 各操作数的求值顺序, +// 正则没挡住时照样会炸;CASE 只求值命中的那个分支。 +// - 正则把**指数限在两位**,于是最大幅度约 1e109,本身就进不了 float8 的溢出区 +// (实测 `'1e999'` 这类值在 CASE 里一样会抛 out of range)。这样不依赖 +// PG 版本——`pg_input_is_valid` 要 PG 16+,本项目的库版本没有写死。 +// 坐标用不上 1e109 以上的写法,也不会小到 1e-100 以下。 +// +// RawFloatPattern 是同一个正则(不带引号),给「只想判合不合法、不取数值」的地方用: +// 光一个 `~` 判断不会抛错,所以那种地方可以直接串它。 +const RawFloatPattern = `^[+-]?([0-9]{1,10}(\.[0-9]*)?|\.[0-9]{1,10})([eE][+-]?[0-9]{1,2})?$` + +func RawFloat8(expr string) string { + return `CASE WHEN ` + expr + ` ~ '` + RawFloatPattern + `' THEN (` + expr + `)::float8 END` +} + // DiffVisits 按 UUID 对齐两边的到访。 // // 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算 diff --git a/internal/rond/dirty_test.go b/internal/rond/dirty_test.go new file mode 100644 index 0000000..ca83888 --- /dev/null +++ b/internal/rond/dirty_test.go @@ -0,0 +1,203 @@ +package rond + +import ( + "database/sql" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func nullF(v float64) sql.NullFloat64 { return sql.NullFloat64{Float64: v, Valid: true} } + +// 脏值必须判成「装不下」(nil)。旧实现直接 int64(sec),超范围是回绕不是饱和: +// 实测 NaN/±Inf/1e30 恰好回绕到基准点(2001-01-01)、1e12 落到 2123 年。 +func TestAppleTimeRejectsDirtyValues(t *testing.T) { + dirty := map[string]float64{ + "NaN": math.NaN(), + "+Inf": math.Inf(1), + "-Inf": math.Inf(-1), + "1e30": 1e30, + "-1e30": -1e30, + "1e12": 1e12, // 会落到 2123 年 + "-1e12": -1e12, + "早于窗口": appleMinSec - 86400, + "晚于窗口": appleMaxSec + 86400, + } + for name, v := range dirty { + if tm := appleTime(nullF(v)); tm != nil { + t.Errorf("%s 应当被拒(nil),实际得到 %s", name, tm.Format(time.RFC3339Nano)) + } + } + if appleTime(sql.NullFloat64{}) != nil { + t.Error("NULL 应当返回 nil") + } +} + +// 合法值要原样保留,尤其是微秒精度——整数/小数分开算就是为了它。 +func TestAppleTimeKeepsLegitValues(t *testing.T) { + for _, want := range []time.Time{ + time.Date(1995, 7, 1, 12, 30, 45, 123456000, time.UTC), + time.Date(2018, 3, 9, 21, 4, 5, 987654000, time.UTC), + time.Date(2026, 9, 21, 10, 19, 9, 1000, time.UTC), + } { + got := appleTime(nullF(want.Sub(appleEpoch).Seconds())) + if got == nil { + t.Fatalf("%s 被误拒", want.Format(time.RFC3339Nano)) + } + if !got.Equal(want) { + t.Errorf("时间失真: 期望 %s 得到 %s", want.Format(time.RFC3339Nano), got.Format(time.RFC3339Nano)) + } + } + for _, edge := range []float64{appleMinSec, appleMaxSec} { + if appleTime(nullF(edge)) == nil { + t.Errorf("窗口边界 %.0f 不该被拒", edge) + } + } +} + +func TestValidCoord(t *testing.T) { + for _, c := range [][2]float64{ + {math.NaN(), 0}, {0, math.NaN()}, {math.Inf(1), 0}, {0, math.Inf(-1)}, + {91, 0}, {-91, 0}, {0, 181}, {0, -181}, {999, 999}, + } { + if validCoord(c[0], c[1]) { + t.Errorf("无效坐标 (%v,%v) 被判成可用", c[0], c[1]) + } + } + for _, c := range [][2]float64{{0, 0}, {24.1, 109.1}, {90, 180}, {-90, -180}, {39.9042, 116.4074}} { + if !validCoord(c[0], c[1]) { + t.Errorf("合法坐标 (%v,%v) 被误拒", c[0], c[1]) + } + } +} + +// 函数再对,没接上去也白搭:读 ZLOCATION 的循环必须过一遍 validCoord。 +func TestParseSanitizesLocationCoords(t *testing.T) { + _, self, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("拿不到测试文件路径") + } + src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go")) + if err != nil { + t.Fatal(err) + } + text := string(src) + i := strings.Index(text, "FROM ZLOCATION") + if i < 0 { + t.Fatal("parse.go 里找不到 ZLOCATION 查询") + } + j := strings.Index(text[i:], "b.Locations = append") + if j < 0 { + t.Fatal("找不到了 ZLOCATION 循环的收尾") + } + if !strings.Contains(text[i:i+j], "validCoord(") { + t.Error("读 ZLOCATION 的循环没有调用 validCoord 归一化坐标") + } +} + +// 端到端:脏备份走一遍真实的 Parse,不许把伪时间/越界坐标带进领域模型。 +func TestParseRejectsDirtyTimeAndCoords(t *testing.T) { + good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC) + goodSec := good.Sub(appleEpoch).Seconds() + + path := filepath.Join(t.TempDir(), "Backup.sqlite") + sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatal(err) + } + defer sdb.Close() + for _, stmt := range []string{ + `CREATE TABLE ZTAGGROUP (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT)`, + `CREATE TABLE ZTAG (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZGROUP INTEGER)`, + `CREATE TABLE ZACTIVITY (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT, + ZISHOME INTEGER, ZISWORK INTEGER, ZISEXCLUDED INTEGER, ZISARCHIVED INTEGER, ZUID_ BLOB)`, + `CREATE TABLE ZTRANSPORT (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT)`, + `CREATE TABLE ZLOCATION (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCATEGORY_ TEXT, + ZLATITUDE REAL, ZLONGITUDE REAL, ZISOCOUNTRYCODE TEXT, ZADMINISTRATIVEAREA TEXT, + ZLOCALITY TEXT, ZSUBLOCALITY TEXT, ZTHOROUGHFARE TEXT, ZSUBTHOROUGHFARE TEXT, ZTIMEZONE TEXT)`, + `CREATE TABLE ZVISIT (Z_PK INTEGER PRIMARY KEY, ZLOCATION INTEGER, ZACTIVITY_ INTEGER, + ZARRIVALDATE_ REAL, ZDEPARTUREDATE_ REAL, ZBOOKMARKED INTEGER, ZUSERADDED INTEGER, + ZREMARK_ TEXT, ZEMOJINAME_ TEXT, ZWEATHERSYMBOL_ TEXT, ZIDENTIFIER_ BLOB)`, + `CREATE TABLE Z_11VISITS_ (Z_18VISITS_ INTEGER, Z_11TAGS_5 INTEGER)`, + `CREATE TABLE ZMOVEMENT (Z_PK INTEGER PRIMARY KEY, ZTRANSPORT_ INTEGER, ZVISITFROM_ INTEGER, + ZVISITTO_ INTEGER, ZSTART_ REAL, ZEND_ REAL)`, + `CREATE TABLE ZHOURLYWEATHER (Z_PK INTEGER PRIMARY KEY, ZVISIT INTEGER, ZDATE_ REAL, + ZISDAYLIGHT INTEGER, ZTEMPERATURE_ REAL, ZAPPARENTTEMPERATURE_ REAL, ZHUMIDITY_ REAL, + ZPRECIPITATIONAMOUNT_ REAL, ZPRECIPITATIONCHANCE_ REAL, ZWINDSPEED_ REAL, ZVISIBILITY_ REAL, + ZUVINDEXVALUE_ INTEGER, ZCONDITION_ TEXT, ZSYMBOLNAME_ TEXT, ZUVINDEXCATEGORY_ TEXT, + ZWINDCOMPASSDIRECTION_ TEXT)`, + } { + if _, err := sdb.Exec(stmt); err != nil { + t.Fatalf("建表失败: %v", err) + } + } + + exec := func(q string, args ...any) { + t.Helper() + if _, err := sdb.Exec(q, args...); err != nil { + t.Fatalf("插入失败: %v", err) + } + } + exec(`INSERT INTO ZACTIVITY (Z_PK, ZNAME_, ZCOLOR_, ZICON_, ZISHOME, ZISWORK, ZISEXCLUDED, ZISARCHIVED) + VALUES (1,'通勤','#fff','🚇',0,0,0,0)`) + // 1=正常;2=坐标越界;3=坐标量级离谱(模拟 NaN 入库前的形态) + exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (1,'正常',24.1,109.1)`) + exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (2,'越界',999,-999)`) + exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (3,'离谱',1e30,0)`) + // 1=正常时间;2=ZARRIVALDATE_ 落到 2123 年;3=量级离谱(旧实现回绕成 2001) + exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_) + VALUES (1,1,1,?,?)`, goodSec, goodSec+3600) + exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (2,2,1,1e12)`) + exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (3,3,1,-1e30)`) + // 位移:时间坏了保留「有行、无时间」,不能写成伪时间 + exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_) + VALUES (1,1,1,1,?,?)`, goodSec, goodSec+600) + exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_) + VALUES (2,1,1,1,1e12,1e12)`) + + b, err := Parse(sdb) + if err != nil { + t.Fatal(err) + } + + byLoc := map[int]Location{} + for _, l := range b.Locations { + byLoc[l.SrcPK] = l + } + if got := byLoc[1]; got.Lat != 24.1 || got.Lon != 109.1 { + t.Errorf("正常坐标被改动了: (%v,%v)", got.Lat, got.Lon) + } + if got := byLoc[2]; got.Lat != 0 || got.Lon != 0 { + t.Errorf("越界坐标应归零,实际 (%v,%v)", got.Lat, got.Lon) + } + if got := byLoc[3]; got.Lat != 0 || got.Lon != 0 { + t.Errorf("量级离谱的坐标应归零,实际 (%v,%v)", got.Lat, got.Lon) + } + + byVisit := map[int]Visit{} + for _, v := range b.Visits { + byVisit[v.SrcPK] = v + } + if v := byVisit[1]; v.Arrival == nil || !v.Arrival.Equal(good) { + t.Errorf("正常到访时间丢了: %v", v.Arrival) + } + for _, pk := range []int{2, 3} { + if v := byVisit[pk]; v.Arrival != nil { + t.Errorf("到访 #%d 的脏时间应当被拒,实际 %s", pk, v.Arrival.Format(time.RFC3339)) + } + } + + if len(b.Movements) != 2 { + t.Fatalf("位移条数不对: %d", len(b.Movements)) + } + if m := b.Movements[0]; m.Start == nil || !m.Start.Equal(good) { + t.Errorf("正常位移时间丢了: %v", m.Start) + } + if m := b.Movements[1]; m.Start != nil || m.End != nil { + t.Errorf("脏位移时间应当为空而不是伪时间: start=%v end=%v", m.Start, m.End) + } +} diff --git a/internal/rond/limit_test.go b/internal/rond/limit_test.go new file mode 100644 index 0000000..a6db995 --- /dev/null +++ b/internal/rond/limit_test.go @@ -0,0 +1,98 @@ +package rond + +import ( + "archive/zip" + "bytes" + "os" + "strings" + "testing" +) + +// TestCopyLimitedRejectsOverrun 守住备份包解压的体积上限。 +// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明——必须在读的时候掐住。 +func TestCopyLimitedRejectsOverrun(t *testing.T) { + var buf bytes.Buffer + if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 99); err == nil { + t.Error("超过上限应当报错") + } + + // 恰好等于上限要放过,内容也不能被截断 + buf.Reset() + if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 100); err != nil { + t.Errorf("恰好等于上限不该报错: %v", err) + } + if buf.Len() != 100 { + t.Errorf("正常内容被截断了: %d 字节(应为 100)", buf.Len()) + } + + buf.Reset() + if err := copyLimited(&buf, strings.NewReader("短"), 100); err != nil { + t.Errorf("正常内容不该报错: %v", err) + } + if buf.String() != "短" { + t.Errorf("内容被改动了: %q", buf.String()) + } +} + +// TestUnpackArchiveEnforcesLimit 确认上限真的接在 Open 的解压路径上, +// 而不只是 copyLimited 孤零零地正确。上限抽成参数就是为了能在这里用小值验一遍。 +func TestUnpackArchiveEnforcesLimit(t *testing.T) { + // 真上限(2 GiB)没法用一个小包走一遍,退一步把它圈在「挡得住 zip bomb、 + // 又不至于误伤真备份」的区间里——顺手拦住把常量改成 1<<62 这类手滑。 + if maxDBBytes < 1<<30 || maxDBBytes > 8<<30 { + t.Errorf("maxDBBytes=%d 超出合理区间 [1GiB, 8GiB]", maxDBBytes) + } + + const payload = 4096 + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, name := range []string{"Backup.sqlite", "Backup.sqlite-wal"} { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(bytes.Repeat([]byte("s"), payload)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + // 上限低于条目体积:必须报错,而且不留半个文件在盘上 + if _, err := unpackArchive(zr, dir, payload-1); err == nil { + t.Error("条目超过上限时应当报错") + } else { + t.Logf("已拒绝: %v", err) + } + if left, _ := filepathGlob(dir); len(left) != 0 { + t.Errorf("被拒绝的条目留下了残留文件: %v", left) + } + + // 放宽到够用:正常解出主库路径 + dbPath, err := unpackArchive(zr, dir, payload) + if err != nil { + t.Fatalf("上限够用时不该报错: %v", err) + } + if !strings.HasSuffix(dbPath, "Backup.sqlite") { + t.Errorf("返回的主库路径不对: %s", dbPath) + } +} + +// filepathGlob 列出 dir 下的文件(测试辅助,避免为上一条再引一个包)。 +func filepathGlob(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out, nil +} diff --git a/internal/rond/parse.go b/internal/rond/parse.go index e51488d..f28ce76 100644 --- a/internal/rond/parse.go +++ b/internal/rond/parse.go @@ -24,19 +24,44 @@ import ( // Core Data 时间戳基准:2001-01-01 UTC var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) +// 允许的时间窗口 [1970, 2100)。脏备份里会出现 NaN / ±Inf / 量级离谱的值, +// 而 int64 转换对超范围值是回绕而非饱和——实测 1e30 恰好回绕成 0(=基准点 2001)、 +// 1e12 变成 2123 年,都是「看着正常」的伪时间,再被当成 first/last_visit_at +// 污染整条时间轴。必须先卡输入秒数:回绕后的结果可能又落回合法区间,只查结果挡不住。 +var ( + appleMinSec = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds() + appleMaxSec = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds() +) + func appleTime(v sql.NullFloat64) *time.Time { - if !v.Valid { + if !v.Valid || math.IsNaN(v.Float64) || math.IsInf(v.Float64, 0) { + return nil + } + sec := math.Floor(v.Float64) + if sec < appleMinSec || sec > appleMaxSec { return nil } // 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的 // 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒, // 往返导出后时间戳就对不上了。 - sec := math.Floor(v.Float64) us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6)) t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local() return &t } +// validCoord 判定坐标是否可用。SQLite 的 REAL 列可能塞进 NaN,备份也可能带出 +// ±90 / ±180 之外的值;这类值会让地图与迷雾索引的网格算出 NaN 或越界格。 +// 无效就按「无坐标」处理——与管线里 COALESCE(NULL,0) 的既有约定一致。 +func validCoord(lat, lon float64) bool { + if math.IsNaN(lat) || math.IsInf(lat, 0) || lat < -90 || lat > 90 { + return false + } + if math.IsNaN(lon) || math.IsInf(lon, 0) || lon < -180 || lon > 180 { + return false + } + return true +} + type Activity struct { SrcPK int Name string @@ -165,6 +190,23 @@ type Backup struct { ActivityUUID map[int]string } +// maxDBBytes 是备份包内单个 SQLite 文件解压后的上限。真机的库是几十 MB 量级, +// 2 GiB 足够宽松,但足以挡住 zip bomb——压缩比上千时几 MB 的包能解出 TB 级。 +const maxDBBytes = 2 << 30 + +// copyLimited 把 r 拷进 w,超过 limit 就报错。 +// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明,必须在读的时候掐住。 +func copyLimited(w io.Writer, r io.Reader, limit int64) error { + n, err := io.Copy(w, io.LimitReader(r, limit+1)) + if err != nil { + return err + } + if n > limit { + return fmt.Errorf("解压后超过 %d 字节,疑似异常备份包", limit) + } + return nil +} + // Open 解压备份包到 dir,返回可用的 SQLite 连接。 // 调用方负责 Close 与删除 dir。 func Open(archivePath, dir string) (*sql.DB, error) { @@ -177,6 +219,28 @@ func Open(archivePath, dir string) (*sql.DB, error) { } defer zr.Close() + dbPath, err := unpackArchive(&zr.Reader, dir, maxDBBytes) + if err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + // 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态 + if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + db.Close() + return nil, fmt.Errorf("合并 WAL 失败: %w", err) + } + return db, nil +} + +// unpackArchive 把包里的 Core Data 主库及其 WAL / SHM 解压到 dir,返回主库路径。 +// limit 是单个条目解压后的字节上限;抽成参数是为了让上限本身可测 +// (真上限 2 GiB,测试里没法造出一个 2 GiB 的 zip bomb)。 +func unpackArchive(zr *zip.Reader, dir string, limit int64) (string, error) { var dbPath string for _, f := range zr.File { base := filepath.Base(f.Name) @@ -191,38 +255,39 @@ func Open(archivePath, dir string) (*sql.DB, error) { dst := filepath.Join(dir, base) rc, err := f.Open() if err != nil { - return nil, err + return "", err } out, err := os.Create(dst) if err != nil { rc.Close() - return nil, err + return "", err } - _, cerr := io.Copy(out, rc) + cerr := copyLimited(out, rc, limit) rc.Close() out.Close() if cerr != nil { - return nil, cerr + os.Remove(dst) + return "", fmt.Errorf("解压 %s: %w", base, cerr) } if strings.HasSuffix(low, ".sqlite") { dbPath = dst } } if dbPath == "" { - return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件") + return "", fmt.Errorf("备份包内未找到 .sqlite 数据库文件") } + return dbPath, nil +} - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)") +// drainRows 收尾 rows 循环。只 Close 不查 Err:驱动或连接中途出错时 Next() 只会 +// 返回 false、从不报错,半张表就被当成完整的交出去——数据静默变少,日志一片空白。 +func drainRows(rows *sql.Rows, what string) error { + err := rows.Err() + rows.Close() if err != nil { - return nil, err + return fmt.Errorf("%s: %w", what, err) } - db.SetMaxOpenConns(1) - // 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态 - if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { - db.Close() - return nil, fmt.Errorf("合并 WAL 失败: %w", err) - } - return db, nil + return nil } // Parse 读取 Core Data 库,转换为领域模型。 @@ -244,7 +309,9 @@ func Parse(db *sql.DB) (*Backup, error) { } b.TagGroups[pk] = name } - rows.Close() + if err = drainRows(rows, "读取标签分组"); err != nil { + return nil, err + } // 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP) tagGroup := map[int]int{} @@ -260,7 +327,9 @@ func Parse(db *sql.DB) (*Backup, error) { } tagGroup[pk] = g } - grows.Close() + if err = drainRows(grows, "读取标签分组关系"); err != nil { + return nil, err + } trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`) if err != nil { @@ -275,7 +344,9 @@ func Parse(db *sql.DB) (*Backup, error) { t.GroupName = b.TagGroups[tagGroup[t.SrcPK]] b.Tags = append(b.Tags, t) } - trows.Close() + if err = drainRows(trows, "读取标签"); err != nil { + return nil, err + } // 类型 / 活动 arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''), @@ -293,7 +364,9 @@ func Parse(db *sql.DB) (*Backup, error) { a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1 b.Activities = append(b.Activities, a) } - arows.Close() + if err = drainRows(arows, "读取活动类型"); err != nil { + return nil, err + } // 交通方式 trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`) @@ -308,7 +381,9 @@ func Parse(db *sql.DB) (*Backup, error) { } b.Transports = append(b.Transports, t) } - trrows.Close() + if err = drainRows(trrows, "读取交通方式"); err != nil { + return nil, err + } // 地点 lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''), @@ -327,9 +402,14 @@ func Parse(db *sql.DB) (*Backup, error) { lrows.Close() return nil, err } + if !validCoord(l.Lat, l.Lon) { + l.Lat, l.Lon = 0, 0 + } b.Locations = append(b.Locations, l) } - lrows.Close() + if err = drainRows(lrows, "读取地点"); err != nil { + return nil, err + } // 访问记录 vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_, @@ -363,7 +443,9 @@ func Parse(db *sql.DB) (*Backup, error) { visitIdx[v.SrcPK] = len(b.Visits) b.Visits = append(b.Visits, v) } - vrows.Close() + if err = drainRows(vrows, "读取访问记录"); err != nil { + return nil, err + } // 访问 → 标签 vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`) @@ -380,7 +462,9 @@ func Parse(db *sql.DB) (*Backup, error) { b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid) } } - vtrows.Close() + if err = drainRows(vtrows, "读取访问标签"); err != nil { + return nil, err + } // 位移 mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`) @@ -411,7 +495,9 @@ func Parse(db *sql.DB) (*Backup, error) { m.End = appleTime(en) b.Movements = append(b.Movements, m) } - mrows.Close() + if err = drainRows(mrows, "读取位移记录"); err != nil { + return nil, err + } // 小时级天气 wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0), @@ -453,7 +539,9 @@ func Parse(db *sql.DB) (*Backup, error) { } b.Weather = append(b.Weather, w) } - wrows.Close() + if err = drainRows(wrows, "读取天气记录"); err != nil { + return nil, err + } if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil { b.RawVisitNum = 0 @@ -645,7 +733,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) { m.Entities[name] = EntityInfo{Ent: ent, Max: max} } } - rows.Close() + if err = drainRows(rows, "读取 Z_PRIMARYKEY"); err != nil { + return nil, err + } drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`) if err != nil { @@ -659,7 +749,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) { } m.DDL[name] = ddl } - drows.Close() + if err = drainRows(drows, "读取建表 DDL"); err != nil { + return nil, err + } // 访问↔标签关联表:表名形如 Z_VISITS_,两列分别指向 Visit / Tag 实体。 visitEnt, okV := m.Entities["Visit"] @@ -692,7 +784,11 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) { tcol = cname } } + cerr := crows.Err() crows.Close() + if cerr != nil { + continue + } if vcol != "" && tcol != "" { m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol} break diff --git a/internal/rond/parse_rows_test.go b/internal/rond/parse_rows_test.go new file mode 100644 index 0000000..65fdccf --- /dev/null +++ b/internal/rond/parse_rows_test.go @@ -0,0 +1,66 @@ +package rond + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// D3 回归:parse.go 里每个 rows 循环的收尾都必须查 rows.Err()。 +// +// 只 Close 不查 Err 是「不报错也不算错」的那一类:驱动或连接中途出错时 +// Next() 只返回 false、从不报错,读到一半的表会被当成完整的交出去—— +// 表现成导入后到访/位移莫名变少,日志里一个字都没有。 +// +// 两种合格写法:循环后 `if err = drainRows(x, "…")`,或自己 `x.Err()`。 +func TestParseDrainsEveryRowsLoop(t *testing.T) { + _, self, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("定位测试文件失败") + } + src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go")) + if err != nil { + t.Fatalf("读 parse.go 失败: %v", err) + } + text := string(src) + + lits := regexp.MustCompile(`for (\w+)\.Next\(\)`).FindAllStringSubmatch(text, -1) + seen := map[string]bool{} + for _, m := range lits { + name := m[1] + if seen[name] { + continue + } + seen[name] = true + if !strings.Contains(text, name+".Err()") && !strings.Contains(text, "drainRows("+name+",") { + t.Errorf("parse.go 里 %s 的循环收尾没查 %s.Err()(只 Close 会把读一半的表当完整的交出去)", name, name) + } + } + if len(seen) < 10 { + t.Fatalf("只认出 %d 个 rows 循环变量(期望 ≥10),判据可能被改写架空,请同步更新本用例", len(seen)) + } + + // 收尾助手自己必须真的读 Err,否则上面那条判据能被一个空壳 drainRows 骗过去 + body := between(text, "func drainRows(", "\nfunc ") + if body == "" { + t.Fatal("找不到 drainRows 的实现") + } + if !strings.Contains(body, "Err()") || !strings.Contains(body, "Close()") { + t.Errorf("drainRows 必须既查 Err() 又 Close(),实际:\n%s", body) + } +} + +func between(s, start, end string) string { + i := strings.Index(s, start) + if i < 0 { + return "" + } + rest := s[i+len(start):] + if j := strings.Index(rest, end); j >= 0 { + return rest[:j] + } + return rest +} diff --git a/internal/stats/placecols_test.go b/internal/stats/placecols_test.go new file mode 100644 index 0000000..3c7391e --- /dev/null +++ b/internal/stats/placecols_test.go @@ -0,0 +1,169 @@ +package stats_test + +import ( + "context" + "regexp" + "strings" + "testing" + + "rond-with-you/internal/stats" +) + +// placeCols 里每个相关子查询都必须带 `<别名>.dataset_id = p.dataset_id`: +// idx_visits_place 是 (dataset_id, place_id),缺了前导列,每轮循环都要为库里 +// 每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669,耗时 53ms → 31ms)。 +// 谓词本身冗余,删掉不会报错、也不会算错,只会悄悄变慢 —— 所以只能这么钉。 +func TestPlaceColsCarriesDatasetIDInCorrelatedSubqueries(t *testing.T) { + // 去掉全部空白再找,免得因为换行/缩进/等号两边空格的差异误报。 + compact := strings.NewReplacer(" ", "", "\n", "", "\t", "", "\r", "").Replace(stats.PlaceCols) + // 必须按子查询分段判断:v2 出现三次,全文 Contains 会被另外两处顶包。 + hits := regexp.MustCompile(`FROMvisits(v\d+)`).FindAllStringSubmatchIndex(compact, -1) + if len(hits) != 5 { + t.Fatalf("placeCols 里的 visits 子查询数量变成 %d(期望 5),请同步更新本用例", len(hits)) + } + for i, m := range hits { + alias := compact[m[2]:m[3]] + end := len(compact) + if i+1 < len(hits) { + end = hits[i+1][0] + } + if seg := compact[m[1]:end]; !strings.Contains(seg, alias+".dataset_id=p.dataset_id") { + t.Errorf("placeCols 中 %s 的子查询缺少 %s.dataset_id=p.dataset_id(会导致索引前导列缺失)", alias, alias) + } + } +} + +// placeCols 里 top 活动三轴(name/color/icon)是三个**互相独立**的众数, +// 不能合并成一个 GROUP BY:合并后同一活动名下的颜色/图标会跟着最多的那一行走, +// 而独立众数允许「名字取 A、颜色取 B」。这里用 Go 侧独立数一遍钉住语义。 +// SQL 的并列取法是未定义的,所以只在「三轴都存在唯一严格最大值」的地点上断言。 +func TestPlaceColsTopActivityMatchesIndependentCount(t *testing.T) { + db, dsID := setup(t) + ctx := context.Background() + d := stats.DB{DB: db} + + type counts map[string]int + type placeAgg struct { + name, color, icon counts + home, work bool + } + aggs := map[int64]*placeAgg{} + + var placeIDs []int64 + rows, err := db.QueryContext(ctx, `SELECT id FROM places WHERE dataset_id=$1`, dsID) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + placeIDs = append(placeIDs, id) + aggs[id] = &placeAgg{name: counts{}, color: counts{}, icon: counts{}} + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + + // 有活动的到访:top 三轴的独立计数。 + rows, err = db.QueryContext(ctx, `SELECT v.place_id, a.name, a.color, a.icon + FROM visits v JOIN activities a ON a.id=v.activity_id WHERE v.dataset_id=$1`, dsID) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var pid int64 + var name, color, icon string + if err := rows.Scan(&pid, &name, &color, &icon); err != nil { + t.Fatal(err) + } + a := aggs[pid] + if a == nil { + continue + } + a.name[name]++ + a.color[color]++ + a.icon[icon]++ + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + + // is_home / is_work 来自 visits 本身,不 join activities。 + for _, tc := range []struct { + q string + mark func(*placeAgg) + }{ + {`SELECT place_id FROM visits WHERE dataset_id=$1 AND is_home`, func(a *placeAgg) { a.home = true }}, + {`SELECT place_id FROM visits WHERE dataset_id=$1 AND is_work`, func(a *placeAgg) { a.work = true }}, + } { + rows, err := db.QueryContext(ctx, tc.q, dsID) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var pid int64 + if err := rows.Scan(&pid); err != nil { + t.Fatal(err) + } + if a := aggs[pid]; a != nil { + tc.mark(a) + } + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + } + + got, err := d.MapPlaces(ctx, stats.Filter{DatasetID: dsID}, 4000) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Skip("MapPlaces 返回空,无法比对") + } + + strict := func(c counts) (string, bool) { // 唯一严格众数 + best, n, tie := "", 0, false + for v, k := range c { + switch { + case k > n: + best, n, tie = v, k, false + case k == n: + tie = true + } + } + return best, best != "" && !tie + } + + checked := 0 + for _, p := range got { + a := aggs[p.ID] + if a == nil { + t.Fatalf("MapPlaces 返回了不属于本数据集的地点 %d", p.ID) + } + if p.IsHome != a.home || p.IsWork != a.work { + t.Errorf("地点 %d: is_home=%v is_work=%v,独立统计为 %v/%v", + p.ID, p.IsHome, p.IsWork, a.home, a.work) + } + name, nOK := strict(a.name) + color, cOK := strict(a.color) + icon, iOK := strict(a.icon) + if !nOK || !cOK || !iOK { + continue // SQL 的并列取法未定义,跳过 + } + checked++ + if p.TopActivity != name || p.TopColor != color || p.TopIcon != icon { + t.Errorf("地点 %d: top 三轴 = %q/%q/%q,独立数出 %q/%q/%q", + p.ID, p.TopActivity, p.TopColor, p.TopIcon, name, color, icon) + } + } + if checked == 0 { + t.Skip("本数据集没有「三轴唯一众数」的地点,比不了") + } + t.Logf("已比对 %d/%d 个地点(其余有并列,跳过)", checked, len(got)) +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index e5c9a7c..77aecd7 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -36,10 +36,23 @@ type Filter struct { // RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。 Regions []string RegionsExclude bool - // Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。 + // Verdict 非 0 时只保留站长标记为该结论的地点:**连同它们名下的到访与位移一起** + // (1 推荐 / -1 踩雷)。它不是「只筛地点列表」的镜头——判据下推到 visitsWhere / + // movementsWhere,页面上所有数字跟着一起变,否则会出现「点没了、里程还在」这种 + // 自相矛盾的视图。 Verdict int } +// placeKeyExpr 把 places 某个别名下的坐标换算成 place_key。 +// **必须与 Go 侧 rond.PlaceCoordKey 逐位一致**(notes / votes / handlers_images / stats +// 各有一份,改一处搜全仓): +// +// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000') +func placeKeyExpr(alias string) string { + return fmt.Sprintf( + "to_char(%[1]s.lat,'FM999990.000000')||'|'||to_char(%[1]s.lon,'FM999990.000000')", alias) +} + type where struct { conds []string args []any @@ -148,6 +161,15 @@ func (f Filter) visitsWhere(off int) *where { w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw) } + if f.Verdict != 0 { + // 结论挂坐标键(换数据集也跟得住),所以判据是「这次到访所属地点的键」。 + // + // 放在 visitsWhere 而不是 Places 里,是为了**全域一致**:地图点、城市/省份统计、 + // 类型计数、概览,以及靠 movementVisitConds 派生的里程 / 出行数,全都从这一份 + // 条件派生出来,不会各认各的。 + w.cond(`EXISTS (SELECT 1 FROM place_votes pv JOIN places pvp ON pvp.id = v.place_id + WHERE pv.place_key = `+placeKeyExpr("pvp")+` AND pv.verdict = ?)`, f.Verdict) + } return w } @@ -157,8 +179,11 @@ func (f Filter) visitsWhere(off int) *where { // 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该 // 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。 func (f Filter) hasVisitConds() bool { + // Verdict 也算:movements/weather 只在「有这类条件」时才会包一层 EXISTS 到访, + // 漏掉它就别指望位移会跟着筛选走。 return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork || - f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" + f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" || + f.Verdict != 0 } // weatherWhere 构造作用于 weather 表别名 w 的过滤条件。 @@ -187,6 +212,34 @@ func (f Filter) weatherWhere() *where { return w } +// movementVisitConds 是「必须落到某次到访上才能判定」的那几档条件(活动 / 最短停留 / +// 城市 / 省份 / 标签 / 关键词),专门给位移用——movements 自己只有两端到访, +// 没有城市、活动这些字段。 +// +// 这里**复用 visitsWhere 拼出来的表达式**,而不是再抄一遍:抄一遍迟早跟 visitsWhere +// 漂移,漏掉一个维度就是「到访数变了、里程还是全量」(区域那档已经犯过一次)。 +// 清掉三类条件,剩下的正是可以按到访判的: +// - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径; +// - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere; +// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」。 +func (f Filter) movementVisitConds(off int) *where { + rest := f + rest.From, rest.To = nil, nil + rest.Regions = nil + rest.ExcludeHome, rest.ExcludeWork = false, false + return rest.visitsWhere(off) +} + +// hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。 +// 从 hasVisitConds 派生,免得两处再各写一份清单;减掉的正是 movementVisitConds +// 里被清掉的那两类(家工作、区域),它们由 movementsWhere 自己判。 +func (f Filter) hasMovementVisitConds() bool { + rest := f + rest.ExcludeHome, rest.ExcludeWork = false, false + rest.Regions = nil + return rest.hasVisitConds() +} + // movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。 // // 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places @@ -215,20 +268,45 @@ func (f Filter) movementsWhere(off int) *where { AND (` + strings.Join(flags, " OR ") + `))`) } if len(f.Regions) > 0 { - var prov, city, dist []string + // 每一段都要**两端分别**过同一套判定,与地图轨迹图层保持一致:apiTrack 就是 + // 用 regionSQL 拼出 pf / pt 两段 AND 起来的。只按「任一端命中」的话,白名单下 + // 同一段里程在图层里画不出来、却算进了总里程(黑名单下两种写法恰好等价, + // 所以这个洞只在白名单时露出来)。 + phs := make([]string, 0, len(f.Regions)) for _, r := range f.Regions { - prov = append(prov, w.ph(r)) - city = append(city, w.ph(r)) - dist = append(dist, w.ph(r)) + phs = append(phs, w.ph(r)) } - expr := `EXISTS (SELECT 1 FROM places pmv - WHERE pmv.id IN (movements.from_place_id, movements.to_place_id) - AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` + - strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))` - if f.RegionsExclude { - expr = "NOT " + expr + endpoint := func(col string) string { + var conds []string + for _, ph := range phs { + // 必须 COALESCE:district 常为 NULL,NULL = 'x' 得 NULL, + // 黑名单的 NOT (...) 也跟着变 NULL,整行被误删(regionSQL 同样处理)。 + conds = append(conds, "COALESCE(pmv.province,'') = "+ph, + "COALESCE(pmv.city,'') = "+ph, "COALESCE(pmv.district,'') = "+ph) + } + expr := "EXISTS (SELECT 1 FROM places pmv WHERE pmv.id = movements." + col + + " AND (" + strings.Join(conds, " OR ") + "))" + if f.RegionsExclude { + return "NOT " + expr + } + return expr } - w.conds = append(w.conds, expr) + w.conds = append(w.conds, endpoint("from_place_id"), endpoint("to_place_id")) + } + // 活动 / 城市 / 省份 / 标签 / 最短停留 / 关键词这几档只长在到访上,位移得靠两端 + // 的到访带过来,否则筛完之后「到访数变了、里程和出行数还是全量」。 + // 判定用「**任一端**命中即算」——和上面区域那条同一个理由:位移横跨两个地点, + // 改成「两端都得命中」会让城际段在每张筛选视图里都消失,而「任一端」才和到访列表 + // 对得上(一次通勤的起点和终点,在两边各自的视图里都该看得到)。 + // 两端都关联不到到访的位移(真机有 31/791 这种)在带这类筛选时不参与: + // 它们没法证明自己落在筛选范围内。 + if f.hasMovementVisitConds() { + vc := f.movementVisitConds(len(w.args)) + w.conds = append(w.conds, `EXISTS ( + SELECT 1 FROM visits v WHERE v.dataset_id = movements.dataset_id + AND (v.src_pk = movements.from_visit_src OR v.src_pk = movements.to_visit_src) + AND `+vc.expr()+`)`) + w.args = append(w.args, vc.args...) } return w } @@ -654,17 +732,20 @@ type Place struct { Down int } +// 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id) +// 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。 +// 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。 const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''), p.visit_count, p.dwell_minutes, p.first_visit_at, p.last_visit_at, COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id - WHERE v2.place_id=p.id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''), + WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''), COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id - WHERE v2.place_id=p.id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''), + WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''), COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id - WHERE v2.place_id=p.id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''), - EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home), - EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work), + WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''), + EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home), + EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work), p.src_pk, EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key = to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))` @@ -707,15 +788,8 @@ func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset i } args := append([]any{f.DatasetID}, inner.args...) + // verdict 条件已经在 visitsWhere 里,这里不用再补一份(补了就是同一个 EXISTS 跑两遍)。 base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)` - if f.Verdict != 0 { - // 结论挂坐标键(换数据集也要跟得住),按本数据集的地点圈定范围; - // 占位符接在内层条件之后。 - args = append(args, f.Verdict) - base += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM place_votes pv WHERE pv.place_key = - to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000') - AND pv.verdict = $%d)`, len(args)) - } q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset) rows, err := d.QueryContext(ctx, q, args...) diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 708baf3..b56234b 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -250,3 +250,75 @@ func TestWeatherStatsRespectsFilter(t *testing.T) { t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal) } } + +// TestVisitSideFiltersApplyToMovements 钉住「按活动 / 城市 / 标签 / 关键词筛选时, +// 里程与出行次数也要跟着变」。 +// +// movements 自己只有两端到访,没有活动、城市这些字段。曾经 movementsWhere 只认 +// dataset / 时间 / 家工作 / 区域,于是筛完之后「到访数变了、里程还是全量」—— +// 区域那档犯过一次(见上一个测试),这几档是同一个洞没堵完。 +func TestVisitSideFiltersApplyToMovements(t *testing.T) { + db, dsID := setup(t) + d := stats.DB{DB: db} + ctx := context.Background() + + // 挑一个真有到访的活动,且它只覆盖一部分到访(否则筛选前后一样,测不出东西) + var actID int64 + if err := db.QueryRow(`SELECT activity_id FROM visits + WHERE dataset_id=$1 AND activity_id IS NOT NULL + GROUP BY activity_id ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&actID); err != nil { + t.Skipf("没有带活动的到访:%v", err) + } + all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID}) + if err != nil { + t.Fatal(err) + } + one, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, ActivityIDs: []int64{actID}}) + if err != nil { + t.Fatal(err) + } + if one.Visits >= all.Visits { + t.Skipf("活动 %d 覆盖了全部到访,换个数据集再测", actID) + } + + // 手写 SQL:两端**任一端**的到访属于该活动的位移 + var dist float64 + var n int + if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0), count(*) FROM movements m + WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v + WHERE v.dataset_id=m.dataset_id AND v.activity_id=$2 + AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`, + dsID, actID).Scan(&dist, &n); err != nil { + t.Fatal(err) + } + if diff := one.DistanceKm - dist; diff > 0.01 || diff < -0.01 { + t.Errorf("按活动筛选后里程 %.4f,手写 SQL %.4f(筛选没作用到里程上?)", one.DistanceKm, dist) + } + if one.Movements != n { + t.Errorf("按活动筛选后出行次数 %d,手写 SQL %d", one.Movements, n) + } + + // 同一条路径也覆盖城市:movementVisitConds 里所有维度共用一个 EXISTS + var city string + if err := db.QueryRow(`SELECT p.city FROM visits v JOIN places p ON p.id=v.place_id + WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>'' + GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil { + t.Skipf("没有带城市的到访:%v", err) + } + byCity, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, City: city}) + if err != nil { + t.Fatal(err) + } + var cityDist float64 + if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0) FROM movements m + WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v + WHERE v.dataset_id=m.dataset_id + AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2) + AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`, + dsID, city).Scan(&cityDist); err != nil { + t.Fatal(err) + } + if diff := byCity.DistanceKm - cityDist; diff > 0.01 || diff < -0.01 { + t.Errorf("按城市「%s」筛选后里程 %.4f,手写 SQL %.4f", city, byCity.DistanceKm, cityDist) + } +} diff --git a/internal/stats/verdict_scope_test.go b/internal/stats/verdict_scope_test.go new file mode 100644 index 0000000..5d144e2 --- /dev/null +++ b/internal/stats/verdict_scope_test.go @@ -0,0 +1,355 @@ +package stats_test + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "rond-with-you/internal/stats" +) + +// verdictFixture 是一套**自造**的数据集,坐标故意取在远端(89.9 / 100.x), +// 与任何真实备份、任何测试夹具都不会重合。 +// +// 为什么不复用 setup(t) 的活跃数据集:`place_key` 是**坐标键**,而所有夹具导入的都是 +// 同一份备份、坐标完全相同。往活跃数据集上写 place_votes 会顺着坐标键漏进并行跑的 +// 其它包(internal/web 有用例要读某个地点的 verdict),把别人的断言弄脏。 +// 这里只建自己的数据集,并且**不碰 is_active**——改它会让并行跑的统计用例选到空库。 +type verdictFixture struct { + db *sql.DB + ds int64 + uid int64 + p1, p2, p3 int64 // p1/p2 打「推荐」,p3 不打 +} + +func newVerdictFixture(t *testing.T) *verdictFixture { + t.Helper() + dsn := os.Getenv("ROND_DSN") + if dsn == "" { + t.Skip("未设置 ROND_DSN") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + ctx := context.Background() + + tag := fmt.Sprint(time.Now().UnixNano()) + var uid int64 + if err := db.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name) + VALUES ($1,'x','verdict-scope') RETURNING id`, "verdict-scope-"+tag).Scan(&uid); err != nil { + t.Fatalf("建临时账号失败: %v", err) + } + var ds int64 + sha := fmt.Sprintf("%064x", time.Now().UnixNano()) + if err := db.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status) + VALUES ($1,'verdict-scope',$2,'done') RETURNING id`, uid, sha).Scan(&ds); err != nil { + t.Fatalf("建临时数据集失败: %v", err) + } + t.Cleanup(func() { + db.ExecContext(ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid) + // 数据集级联带走 places / visits / movements / weather / activities + db.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, ds) + db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) + }) + + // 到访的到达时间固定写死,避免「今天」漂移导致天数/月份断言不稳 + at := func(iso string) time.Time { + v, err := time.Parse(time.RFC3339, iso) + if err != nil { + t.Fatal(err) + } + return v + } + mkPlace := func(pk int, lat, lon float64, city, prov string) int64 { + t.Helper() + var id int64 + if err := db.QueryRowContext(ctx, `INSERT INTO places + (dataset_id, src_pk, name, lat, lon, city, province) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + ds, pk, fmt.Sprintf("P%d", pk), lat, lon, city, prov).Scan(&id); err != nil { + t.Fatalf("建地点失败: %v", err) + } + return id + } + f := &verdictFixture{db: db, ds: ds, uid: uid} + f.p1 = mkPlace(1, 89.900001, 100.100001, "CityA", "ProvA") + f.p2 = mkPlace(2, 89.900002, 100.100002, "CityB", "ProvA") + f.p3 = mkPlace(3, 89.900003, 100.100003, "CityC", "ProvB") + + var actID int64 + if err := db.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name) + VALUES ($1,1,'家') RETURNING id`, ds).Scan(&actID); err != nil { + t.Fatalf("建活动失败: %v", err) + } + mkVisit := func(pk int, placeID int64, iso string, dur int) { + t.Helper() + if _, err := db.ExecContext(ctx, `INSERT INTO visits + (dataset_id, src_pk, place_id, activity_id, arrival, duration_min) + VALUES ($1,$2,$3,$4,$5,$6)`, ds, pk, placeID, actID, at(iso), dur); err != nil { + t.Fatalf("建到访失败: %v", err) + } + } + mkVisit(101, f.p1, "2026-01-05T10:00:00+08:00", 60) + mkVisit(102, f.p1, "2026-01-06T10:00:00+08:00", 30) + mkVisit(103, f.p2, "2026-02-10T10:00:00+08:00", 20) + mkVisit(104, f.p3, "2026-02-11T10:00:00+08:00", 10) + + mkMove := func(pk, from, to int, placeID int64, km float64, iso string) { + t.Helper() + if _, err := db.ExecContext(ctx, `INSERT INTO movements + (dataset_id, src_pk, from_visit_src, to_visit_src, from_place_id, to_place_id, + started_at, ended_at, distance_km) + VALUES ($1,$2,$3,$4,$5,$5,$6,$6,$7)`, ds, pk, from, to, placeID, at(iso), km); err != nil { + t.Fatalf("建位移失败: %v", err) + } + } + // m1 跨 p1→p2(两端都在 S 内)· m2 跨 p3→p1(任一端在 S 内就该算)· m3 两端都在 p3(该被排除) + mkMove(201, 101, 103, f.p1, 5.0, "2026-01-07T10:00:00+08:00") + mkMove(202, 104, 101, f.p1, 7.0, "2026-02-12T10:00:00+08:00") + mkMove(203, 104, 104, f.p3, 11.0, "2026-02-13T10:00:00+08:00") + + mkWeather := func(pk, visitSrc int, iso, cond string) { + t.Helper() + if _, err := db.ExecContext(ctx, `INSERT INTO weather + (dataset_id, src_pk, visit_src, at, condition) VALUES ($1,$2,$3,$4,$5)`, + ds, pk, visitSrc, at(iso), cond); err != nil { + t.Fatalf("建天气失败: %v", err) + } + } + mkWeather(301, 101, "2026-01-05T10:00:00+08:00", "Clear") + mkWeather(302, 104, "2026-02-11T10:00:00+08:00", "Cloudy") + + // 只给 p1 / p2 打「推荐」。键用 Go 侧的 %.6f —— 顺便验证它与 SQL 的 + // to_char(...,'FM999990.000000') 逐位一致(不一致这里就查不到,断言会红)。 + mark := func(placeID int64, lat, lon float64, verdict int) { + t.Helper() + key := fmt.Sprintf("%.6f|%.6f", lat, lon) + if _, err := db.ExecContext(ctx, `INSERT INTO place_votes + (user_id, place_key, dataset_id, src_pk, verdict) VALUES ($1,$2,$3,1,$4)`, + uid, key, ds, verdict); err != nil { + t.Fatalf("写 verdict(键 %s)失败: %v", key, err) + } + } + mark(f.p1, 89.900001, 100.100001, 1) + mark(f.p2, 89.900002, 100.100002, 1) + return f +} + +func (f *verdictFixture) filter(v int) stats.Filter { + return stats.Filter{DatasetID: f.ds, Verdict: v} +} + +func sumBuckets(bs []stats.Bucket) int { + n := 0 + for _, b := range bs { + n += b.Count + } + return n +} + +// D7 回归:verdict 必须作用到**每一个**统计口径上。 +// +// 用户已定口径「全域一致」——筛选条上看得见的这枚条件,页面上所有数字都得跟着变, +// 否则就是「点没了、里程还在」这种自相矛盾的视图(P1-3 是同一类问题的另一个面)。 +// +// 判据用**不可能存在的结论值 99**:没有任何地点会被它匹配,于是**每一个**统计都必须退回 +// 空集。任何一处漏了下推(地图点、城市/省份、类型计数、概览、里程/出行、天气、时间分布) +// 都会在这一条上暴露成一个非零数——比逐个统计写断言更彻底。 +func TestVerdictScopesEveryStatistic(t *testing.T) { + f := newVerdictFixture(t) + ctx := context.Background() + d := stats.DB{DB: f.db} + + // 先确认夹具本身有数据(否则「为空」是废话) + all, err := d.Overview(ctx, f.filter(0)) + if err != nil { + t.Fatalf("Overview(无筛选) 失败: %v", err) + } + if all.Visits != 4 || all.Places != 3 || all.Movements != 3 { + t.Fatalf("夹具数据不对:visits=%d places=%d movements=%d,期望 4/3/3", all.Visits, all.Places, all.Movements) + } + + // ① 不可能存在的结论 ⇒ 全域都该是空的 + none := f.filter(99) + ov, err := d.Overview(ctx, none) + if err != nil { + t.Fatal(err) + } + if ov != (stats.Overview{}) { + t.Errorf("Verdict=99 时 Overview 应当全零,实际 %+v", ov) + } + places, total, err := d.Places(ctx, none, "visits", 100, 0) + if err != nil { + t.Fatal(err) + } + if len(places) != 0 || total != 0 { + t.Errorf("Verdict=99 时地点列表应当为空,实际 %d 条(total=%d)", len(places), total) + } + mapped, err := d.MapPlaces(ctx, none, 100) + if err != nil { + t.Fatal(err) + } + if len(mapped) != 0 { + t.Errorf("Verdict=99 时地图点应当为空,实际 %d 个", len(mapped)) + } + if rows, n, err := d.Visits(ctx, none, 100, 0, "desc"); err != nil || len(rows) != 0 || n != 0 { + t.Errorf("Verdict=99 时到访列表应当为空,实际 %d 条(total=%d, err=%v)", len(rows), n, err) + } + if as, err := d.ActivityStats(ctx, none); err != nil || len(as) != 0 { + t.Errorf("Verdict=99 时类型分布应当为空,实际 %d 组(err=%v)", len(as), err) + } + if ac, err := d.ActivityCounts(ctx, none); err != nil || len(ac) != 0 { + t.Errorf("Verdict=99 时类型计数应当为空,实际 %d 项(err=%v)", len(ac), err) + } + if cs, err := d.CityStats(ctx, none, 10); err != nil || len(cs) != 0 { + t.Errorf("Verdict=99 时城市分布应当为空,实际 %d 组(err=%v)", len(cs), err) + } + if ps, err := d.ProvinceStats(ctx, none); err != nil || len(ps) != 0 { + t.Errorf("Verdict=99 时省份分布应当为空,实际 %d 组(err=%v)", len(ps), err) + } + if ws, err := d.WeatherStats(ctx, none); err != nil || len(ws) != 0 { + t.Errorf("Verdict=99 时天气汇总应当为空,实际 %d 组(err=%v)", len(ws), err) + } + if ms, err := d.Monthly(ctx, none); err != nil || sumBuckets(ms) != 0 { + t.Errorf("Verdict=99 时月度分布应当为空,实际 %d 次(err=%v)", sumBuckets(ms), err) + } + if cal, err := d.Calendar(ctx, none); err != nil || len(cal) != 0 { + t.Errorf("Verdict=99 时日历应当为空,实际 %d 天(err=%v)", len(cal), err) + } + if bs, err := d.Hourly(ctx, none); err != nil || sumBuckets(bs) != 0 { + t.Errorf("Verdict=99 时小时分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err) + } + if bs, err := d.Weekday(ctx, none); err != nil || sumBuckets(bs) != 0 { + t.Errorf("Verdict=99 时星期分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err) + } + if ts, err := d.TransportStats(ctx, none); err != nil || len(ts) != 0 { + t.Errorf("Verdict=99 时出行方式应当为空,实际 %d 组(err=%v)", len(ts), err) + } +} + +// D7 正向:只给 p1/p2 打「推荐」时,每个口径都必须**恰好**收窄到它们名下的数据。 +// 上一条证明「不会漏下推」,这一条证明「下推之后选对了」。 +func TestVerdictNarrowsEveryStatisticToMarkedPlaces(t *testing.T) { + f := newVerdictFixture(t) + ctx := context.Background() + d := stats.DB{DB: f.db} + on := f.filter(1) + + // S = {p1, p2}:3 条到访(60+30+20 分钟)、2 个地点、2 个城市、1 个省、3 个不同日期 + ov, err := d.Overview(ctx, on) + if err != nil { + t.Fatal(err) + } + if ov.Visits != 3 || ov.Places != 2 || ov.Cities != 2 || ov.Provinces != 1 || ov.Days != 3 { + t.Errorf("Overview 收窄不对:visits=%d places=%d cities=%d provinces=%d days=%d,期望 3/2/2/1/3", + ov.Visits, ov.Places, ov.Cities, ov.Provinces, ov.Days) + } + if ov.DwellMin != 110 { + t.Errorf("停留 %d 分钟,期望 110", ov.DwellMin) + } + // 位移按「任一端在 S 内」:m1(p1→p2) 与 m2(p3→p1) 算,两端都在 p3 的 m3 不算 + if ov.Movements != 2 || ov.DistanceKm < 11.99 || ov.DistanceKm > 12.01 { + t.Errorf("位移收窄不对:movements=%d distance=%.2f,期望 2 / 12.00", ov.Movements, ov.DistanceKm) + } + + places, total, err := d.Places(ctx, on, "visits", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 2 || len(places) != 2 { + t.Errorf("地点列表应当只剩 2 个,实际 %d 条(total=%d)", len(places), total) + } + got := map[int64]bool{} + for _, p := range places { + got[p.ID] = true + } + if !got[f.p1] || !got[f.p2] || got[f.p3] { + t.Errorf("地点列表选错:%v(应当只有 p1/p2)", got) + } + + mapped, err := d.MapPlaces(ctx, on, 100) + if err != nil { + t.Fatal(err) + } + if len(mapped) != 2 { + t.Errorf("地图点应当只剩 2 个,实际 %d 个(这一处以前完全没认 verdict)", len(mapped)) + } + + // 每个「按到访计数」的口径都必须与 Overview.Visits 对得上 + if ac, err := d.ActivityCounts(ctx, on); err != nil { + t.Fatal(err) + } else { + n := 0 + for _, c := range ac { + n += c + } + if n != ov.Visits { + t.Errorf("类型计数合计 %d,与到访数 %d 不符", n, ov.Visits) + } + } + if as, err := d.ActivityStats(ctx, on); err != nil { + t.Fatal(err) + } else { + n := 0 + for _, a := range as { + n += a.Count + } + if n != ov.Visits { + t.Errorf("类型分布合计 %d,与到访数 %d 不符", n, ov.Visits) + } + } + if cs, err := d.CityStats(ctx, on, 10); err != nil { + t.Fatal(err) + } else { + n := 0 + for _, c := range cs { + n += c.Count + } + if n != ov.Visits || len(cs) != 2 { + t.Errorf("城市分布应当 2 组共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(cs), n) + } + } + if ps, err := d.ProvinceStats(ctx, on); err != nil { + t.Fatal(err) + } else { + n := 0 + for _, p := range ps { + n += p.Count + } + if n != ov.Visits || len(ps) != 1 || ps[0].Province != "ProvA" { + t.Errorf("省份分布应当只有 ProvA 共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(ps), n) + } + } + if ms, err := d.Monthly(ctx, on); err != nil || sumBuckets(ms) != ov.Visits { + t.Errorf("月度分布合计 %d,与到访数 %d 不符(err=%v)", sumBuckets(ms), ov.Visits, err) + } + if bs, err := d.Hourly(ctx, on); err != nil || sumBuckets(bs) != ov.Visits { + t.Errorf("小时分布合计不对(err=%v)", err) + } + if bs, err := d.Weekday(ctx, on); err != nil || sumBuckets(bs) != ov.Visits { + t.Errorf("星期分布合计不对(err=%v)", err) + } + // 天气挂在到访上:只有 p1 那条该留下 + if ws, err := d.WeatherStats(ctx, on); err != nil { + t.Fatal(err) + } else { + n := 0 + for _, w := range ws { + n += w.Count + } + if n != 1 { + t.Errorf("天气应当只剩 1 条(挂在 p1 上那条),实际 %d 条", n) + } + } + // 到访列表也要跟着收窄 + if rows, n, err := d.Visits(ctx, on, 100, 0, "desc"); err != nil || len(rows) != 3 || n != 3 { + t.Errorf("到访列表应当只剩 3 条,实际 %d 条(total=%d, err=%v)", len(rows), n, err) + } +} diff --git a/internal/votes/votes.go b/internal/votes/votes.go index 29c0966..6f3205e 100644 --- a/internal/votes/votes.go +++ b/internal/votes/votes.go @@ -87,6 +87,32 @@ func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK, return err } +// SaveVerdictBulk 一次给多个地点设置(verdict=1/-1)或取消(verdict=0)站长结论, +// 语义同逐条 SaveVerdict,但一条语句代替 N 次往返。 +func (s *Store) SaveVerdictBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64, verdict int) error { + if len(srcPKs) == 0 { + return nil + } + if verdict == 0 { + _, err := s.DB.ExecContext(ctx, `DELETE FROM place_votes v USING places p + WHERE v.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[]) + AND v.place_key = `+placeKeyExpr, userID, datasetID, srcPKs) + return err + } + // 先按坐标键算出每条地点该挂在哪,找不到该地点就什么也不写 + _, err := s.DB.ExecContext(ctx, `INSERT INTO place_votes + (user_id, place_key, dataset_id, src_pk, verdict, updated_at) + SELECT $1, `+placeKeyExpr+`, $2, p.src_pk, $4, now() + FROM places p + WHERE p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[]) + AND p.lat IS NOT NULL AND p.lon IS NOT NULL + ON CONFLICT (user_id, place_key) DO UPDATE SET + verdict=EXCLUDED.verdict, dataset_id=EXCLUDED.dataset_id, + src_pk=EXCLUDED.src_pk, updated_at=now()`, + userID, datasetID, srcPKs, verdict) + return err +} + // placeKeyOf 把某个数据集里的地点编号换算成坐标键——访客票也是挂在坐标上的, // 换数据集之后票仍然跟着同一个地点。 func (s *Store) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) { diff --git a/internal/web/admin_notes_bulk_test.go b/internal/web/admin_notes_bulk_test.go new file mode 100644 index 0000000..03cdcc0 --- /dev/null +++ b/internal/web/admin_notes_bulk_test.go @@ -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) + } +} diff --git a/internal/web/agg_error_test.go b/internal/web/agg_error_test.go new file mode 100644 index 0000000..3b4c131 --- /dev/null +++ b/internal/web/agg_error_test.go @@ -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) + } + } +} diff --git a/internal/web/archive_pick_test.go b/internal/web/archive_pick_test.go new file mode 100644 index 0000000..3187b86 --- /dev/null +++ b/internal/web/archive_pick_test.go @@ -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:按大小预筛没有生效") + } +} diff --git a/internal/web/backup_diff_test.go b/internal/web/backup_diff_test.go index b0fece0..383da25 100644 --- a/internal/web/backup_diff_test.go +++ b/internal/web/backup_diff_test.go @@ -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 与备份里完全不同 diff --git a/internal/web/backup_merge.go b/internal/web/backup_merge.go index c7b0cf6..b15a866 100644 --- a/internal/web/backup_merge.go +++ b/internal/web/backup_merge.go @@ -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) diff --git a/internal/web/backup_merge_test.go b/internal/web/backup_merge_test.go index 58031f7..d30e9e1 100644 --- a/internal/web/backup_merge_test.go +++ b/internal/web/backup_merge_test.go @@ -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) diff --git a/internal/web/client_ip_test.go b/internal/web/client_ip_test.go new file mode 100644 index 0000000..59cb7a0 --- /dev/null +++ b/internal/web/client_ip_test.go @@ -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) + } + } +} diff --git a/internal/web/csrf.go b/internal/web/csrf.go new file mode 100644 index 0000000..0fa390d --- /dev/null +++ b/internal/web/csrf.go @@ -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 +} diff --git a/internal/web/csrf_test.go b/internal/web/csrf_test.go new file mode 100644 index 0000000..5780ad6 --- /dev/null +++ b/internal/web/csrf_test.go @@ -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") + } +} diff --git a/internal/web/download_name_test.go b/internal/web/download_name_test.go new file mode 100644 index 0000000..f18038a --- /dev/null +++ b/internal/web/download_name_test.go @@ -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?fh|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) + } +} diff --git a/internal/web/entity_raw_test.go b/internal/web/entity_raw_test.go new file mode 100644 index 0000000..9655076 --- /dev/null +++ b/internal/web/entity_raw_test.go @@ -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) + } +} diff --git a/internal/web/export_download_test.go b/internal/web/export_download_test.go new file mode 100644 index 0000000..56c66c6 --- /dev/null +++ b/internal/web/export_download_test.go @@ -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 截断)") + } +} diff --git a/internal/web/fillup.go b/internal/web/fillup.go index 531231f..26f370b 100644 --- a/internal/web/fillup.go +++ b/internal/web/fillup.go @@ -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 } diff --git a/internal/web/fillup_cache_test.go b/internal/web/fillup_cache_test.go new file mode 100644 index 0000000..adc21c2 --- /dev/null +++ b/internal/web/fillup_cache_test.go @@ -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) + } +} diff --git a/internal/web/fog.go b/internal/web/fog.go index 690c8d7..bc92068 100644 --- a/internal/web/fog.go +++ b/internal/web/fog.go @@ -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) } diff --git a/internal/web/fog_race_test.go b/internal/web/fog_race_test.go new file mode 100644 index 0000000..3ba9337 --- /dev/null +++ b/internal/web/fog_race_test.go @@ -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() +} diff --git a/internal/web/gpx_owner_test.go b/internal/web/gpx_owner_test.go new file mode 100644 index 0000000..0fdcdf9 --- /dev/null +++ b/internal/web/gpx_owner_test.go @@ -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 没看受影响行数:删不到也会回「已删除」") + } +} diff --git a/internal/web/gzip.go b/internal/web/gzip.go index cb5eb45..23e2868 100644 --- a/internal/web/gzip.go +++ b/internal/web/gzip.go @@ -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() diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 9a7530a..1b8a766 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -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", "轨迹已删除") } diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index 6557b69..e06f1f6 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -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(判断坐标系是否没偏移), diff --git a/internal/web/handlers_export.go b/internal/web/handlers_export.go index 6dda385..8377957 100644 --- a/internal/web/handlers_export.go +++ b/internal/web/handlers_export.go @@ -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("\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) + } } diff --git a/internal/web/handlers_images.go b/internal/web/handlers_images.go index 79ced8a..cbaba97 100644 --- a/internal/web/handlers_images.go +++ b/internal/web/handlers_images.go @@ -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 diff --git a/internal/web/handlers_photos.go b/internal/web/handlers_photos.go index a0a09d1..de7e537 100644 --- a/internal/web/handlers_photos.go +++ b/internal/web/handlers_photos.go @@ -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) diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index cbf33f7..66aa251 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -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 diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index e097e59..c878d17 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -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= @@ -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 } diff --git a/internal/web/handlers_vote.go b/internal/web/handlers_vote.go index 26827c8..11cbf44 100644 --- a/internal/web/handlers_vote.go +++ b/internal/web/handlers_vote.go @@ -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) diff --git a/internal/web/login_guard_test.go b/internal/web/login_guard_test.go new file mode 100644 index 0000000..75f7b46 --- /dev/null +++ b/internal/web/login_guard_test.go @@ -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) + } + } +} diff --git a/internal/web/pagelink_test.go b/internal/web/pagelink_test.go index e60ae4b..97ab2b2 100644 --- a/internal/web/pagelink_test.go +++ b/internal/web/pagelink_test.go @@ -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` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上, // 按钮退化成光秃秃的「返回」。 diff --git a/internal/web/parallel.go b/internal/web/parallel.go index 975bf81..993c3c6 100644 --- a/internal/web/parallel.go +++ b/internal/web/parallel.go @@ -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)) } diff --git a/internal/web/parallel_test.go b/internal/web/parallel_test.go index 606e7cb..dc7bf3f 100644 --- a/internal/web/parallel_test.go +++ b/internal/web/parallel_test.go @@ -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 diff --git a/internal/web/place_activity_test.go b/internal/web/place_activity_test.go index e45ddb2..cb26243 100644 --- a/internal/web/place_activity_test.go +++ b/internal/web/place_activity_test.go @@ -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 +} diff --git a/internal/web/place_coord_test.go b/internal/web/place_coord_test.go index 46cf24c..666567e 100644 --- a/internal/web/place_coord_test.go +++ b/internal/web/place_coord_test.go @@ -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(), "") { + t.Error("页面被截断(没有收尾的 )") + } +} diff --git a/internal/web/place_image_test.go b/internal/web/place_image_test.go index a701a07..5e9e32c 100644 --- a/internal/web/place_image_test.go +++ b/internal/web/place_image_test.go @@ -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) { diff --git a/internal/web/redirect_target_test.go b/internal/web/redirect_target_test.go new file mode 100644 index 0000000..02eeed6 --- /dev/null +++ b/internal/web/redirect_target_test.go @@ -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", // + "/ab", // + "/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) + } +} diff --git a/internal/web/region_consistency_test.go b/internal/web/region_consistency_test.go new file mode 100644 index 0000000..828c036 --- /dev/null +++ b/internal/web/region_consistency_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index bd17084..575ae28 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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: ¬es.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: ¬es.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 { diff --git a/internal/web/server_error_test.go b/internal/web/server_error_test.go new file mode 100644 index 0000000..5d2e27a --- /dev/null +++ b/internal/web/server_error_test.go @@ -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, "

已经写出去的半页

") + 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 上") + } +} diff --git a/internal/web/session_cookie_test.go b/internal/web/session_cookie_test.go new file mode 100644 index 0000000..359d07f --- /dev/null +++ b/internal/web/session_cookie_test.go @@ -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) + } + } +} diff --git a/internal/web/stats_weekday_once_test.go b/internal/web/stats_weekday_once_test.go new file mode 100644 index 0000000..e6d117b --- /dev/null +++ b/internal/web/stats_weekday_once_test.go @@ -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) + } +} diff --git a/internal/web/testpaths_test.go b/internal/web/testpaths_test.go new file mode 100644 index 0000000..54415f2 --- /dev/null +++ b/internal/web/testpaths_test.go @@ -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) +} diff --git a/internal/web/topic_stats_test.go b/internal/web/topic_stats_test.go new file mode 100644 index 0000000..c2b8619 --- /dev/null +++ b/internal/web/topic_stats_test.go @@ -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) + } +} diff --git a/internal/web/twofa_guard_test.go b/internal/web/twofa_guard_test.go new file mode 100644 index 0000000..d65081a --- /dev/null +++ b/internal/web/twofa_guard_test.go @@ -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())) + } +} diff --git a/internal/web/tx_atomicity_test.go b/internal/web/tx_atomicity_test.go new file mode 100644 index 0000000..94d9da4 --- /dev/null +++ b/internal/web/tx_atomicity_test.go @@ -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()`) + }) +} diff --git a/internal/web/upload_cleanup_test.go b/internal/web/upload_cleanup_test.go new file mode 100644 index 0000000..f2852ec --- /dev/null +++ b/internal/web/upload_cleanup_test.go @@ -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) + } + } +} diff --git a/internal/web/view.go b/internal/web/view.go index 9f64dc9..463c4da 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -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, diff --git a/internal/web/visit_bookmark_test.go b/internal/web/visit_bookmark_test.go new file mode 100644 index 0000000..f64c0e1 --- /dev/null +++ b/internal/web/visit_bookmark_test.go @@ -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 +} diff --git a/main.go b/main.go index 1981c02..50a35bb 100644 --- a/main.go +++ b/main.go @@ -47,15 +47,27 @@ func main() { if adminUser == "" { adminUser = "admin" } + // 不再写死出厂口令:公网部署漏配 ROND_ADMIN_PASSWORD 时,admin/rond-admin + // 等于后台没上锁。改成生成随机口令并在启动日志里打印一次。 + // 取自环境变量的那份**不打日志**,免得口令被日志系统长期留存。 adminPass := os.Getenv("ROND_ADMIN_PASSWORD") + generatedPass := false if adminPass == "" { - adminPass = "rond-admin" + var err error + if adminPass, err = auth.RandomPassword(16); err != nil { + log.Fatalf("生成初始口令失败: %v", err) + } + generatedPass = true } store := &auth.Store{DB: database, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays} if created, err := store.EnsureDefaultUser(context.Background(), adminUser, adminPass); err != nil { log.Fatalf("初始化管理员失败: %v", err) } else if created { - log.Printf("已创建初始管理员:%s / %s(请登录后立即修改密码)", adminUser, adminPass) + if generatedPass { + log.Printf("已创建初始管理员:%s / %s(随机生成,只打印这一次;登录后请立即修改)", adminUser, adminPass) + } else { + log.Printf("已创建初始管理员:%s(口令取自 ROND_ADMIN_PASSWORD)", adminUser) + } } srv, err := web.New(cfg, database)