Compare commits

...
9 Commits
Author SHA1 Message Date
tamakyi 10d23e03cf 合并:重复地点不再反复判「备份新增」,到访的活动改按身份比
- DiffPlaces 的 byCoord/byName 改为多值索引 + take() 逐条认领未用项:真机一个坐标下会有
  多条同名同坐标的地点(实测「柳州站」3 条),单槽位时同一坐标最多只配上 2 条,第 3 条
  永远判「备份新增」⇒ 每次合并都再插一条重复地点、差异永远归不了零(实测 368→438→439)。
  修完:地点新增 70→67、合并后再比对归零、再点合并直接「没有要写入的改动」。
- 到访的「活动」由按名称比改为按身份(ZUID_)比,两边都缺 UUID 才退回名称:16 个活动改名
  曾把 355 条到访全变成同因冲突,且逐条选「用实机」只写回 activity_id(映射回同一个活动)
  改不掉名字。loadStoredVisits 为此多 JOIN 一次 entity_raw 取 ZACTIVITY.ZUID_。
- 回归用例 4 条(重复地点一一配上、活动改名不算冲突、换活动仍要报、缺 UUID 退回名称)+
  「合并后地点差异归零」断言 3 处;两处改动都做过变异验证。
- 实测(09-28 备份并进 09-14 数据集):到访冲突 253→6,合并两次地点数 438→439 变为 435→435。
2026-09-28 19:31:21 +08:00
tamakyi f2ce6646c4 报告:补 P2 批次(覆盖率口径与关键页冒烟)记录 2026-09-24 22:48:32 +08:00
tamakyi 256620ee3d 验收:覆盖率改用 -coverpkg 口径,新增关键页冒烟 2026-09-24 22:48:31 +08:00
tamakyi 05c6e970db 测试:补 rond 合并冲突与 gpx 解析用例,删两处死代码 2026-09-24 22:48:22 +08:00
tamakyi c001e64ee0 报告:补稳定性基线与 P0/P1 收尾记录
- 稳定性基线(覆盖率 ≈51%、不设环境变量时 95 PASS / 86 SKIP 的假绿、无 CI)
- scripts/verify.sh 的能力与实测闸门
- 全新库冒烟的变异验证(迁移块顺序错了只有新库会炸)
- web 整包 -race:161 PASS / 0 DATA RACE
- 收尾验收:244 用例 / 0 FAIL / 0 SKIP
2026-09-24 20:55:52 +08:00
tamakyi ff594121da 验收脚本:SKIP/FAIL 也把子测试数进去
只数顶层 `^--- SKIP` 会把子测试里的跳过漏掉 —— 那正是「看起来全绿、其实没跑」的形态。
换成 `^[[:space:]]*--- SKIP`,失败明细同理。
2026-09-24 20:47:23 +08:00
tamakyi d2e4392a88 验收脚本、工具整理与排查报告
- scripts/verify.sh:把「带齐六个环境变量、且必须 0 skip」这条纪律固化下来
  * DSN 自己从 conf/app-test.ini 取,不把口令写进脚本
  * 库名不含 test 就拒跑(指到维护库会多出「users 不存在」+ 一堆假 skip)
  * 数 SKIP,非 0 直接失败 —— 不带环境变量时 go test 依然回 ok,最容易被当成通过
  * 支持 --fast / --race / --cover / --pkg / --run
- tools/:删掉 6 个一次性探针(git 历史里可翻)和误入库的 __pycache__,
  只留在用的 4 个工具并补 README;.gitignore 补 __pycache__ / *.pyc
- audit-2026-09-21.md:全量排查报告(含 11 批修复记录与收尾审查)
2026-09-24 20:38:18 +08:00
tamakyi d5f6dfa381 审计修复:前端(模板 / CSS / JS 与无障碍)
- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本);
  preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队);
  异步回包一律发序号作废,取消后回来的包不再画
- photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度
- 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉
- 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛)
- 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表
- 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt
- report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack

本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、
坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
2026-09-24 20:37:57 +08:00
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00
105 changed files with 8236 additions and 1409 deletions
+2
View File
@@ -23,3 +23,5 @@
# 开发期的临时探针/抓取产物(不属于运行时) # 开发期的临时探针/抓取产物(不属于运行时)
/tmpfog/ /tmpfog/
__pycache__/
*.pyc
+1182
View File
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -169,7 +169,7 @@ func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
return &u, nil return &u, nil
} }
// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。 // 会话令牌 = uid|expiry|hmac(uid|expiry|口令摘要),口令变更即自动失效。
func (s *Store) SignToken(userID int64, hash string) string { func (s *Store) SignToken(userID int64, hash string) string {
exp := time.Now().AddDate(0, 0, s.Days).Unix() exp := time.Now().AddDate(0, 0, s.Days).Unix()
payload := fmt.Sprintf("%d|%d", userID, exp) 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 { func (s *Store) mac(payload, hash string) string {
m := hmac.New(sha256.New, s.Secret) m := hmac.New(sha256.New, s.Secret)
m.Write([]byte(payload)) m.Write([]byte(payload))
// 混入口令摘要,改密后旧 cookie 立即作废 // 绑完整口令摘要:摘要里带随机 salt,改密后必然不同,旧 cookie 当场作废。
m.Write([]byte("|" + hash[:min(len(hash), 16)])) // 别只取摘要前几位——PBKDF2 编码的前缀是固定格式,等于绑了个常量。
m.Write([]byte("\x00" + hash))
return b64(m.Sum(nil)) return b64(m.Sum(nil))
} }
func min(a, b int) int {
if a < b {
return a
}
return b
}
+24
View File
@@ -12,6 +12,30 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
// TestSessionTokenBindsPasswordHash 钉住「改密后旧会话立即作废」这条契约。
// 令牌的 MAC 必须真的绑上口令摘要:曾经绑的是摘要的前 16 个字符,而 PBKDF2
// 编码形如 `pbkdf2$sha256$210000$<salt>$<dk>`,前 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 覆盖改名的正向路径、口令校验与占用冲突。 // TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。
// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来, // 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来,
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。 // 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
+47
View File
@@ -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)
}
}
+128
View File
@@ -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
}
+147
View File
@@ -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)
}
}
+39
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -98,6 +99,44 @@ func urlEscape(s string) string {
return b.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 返回用户的两步验证状态。 // TOTPState 返回用户的两步验证状态。
+164
View File
@@ -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
}
+35 -5
View File
@@ -65,9 +65,19 @@ var popcount = func() [256]byte {
return t 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 尾缀。 // tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
func tileIDFromName(name string) (int, bool) { 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 return 0, false
} }
digits := make([]byte, 0, len(name)) digits := make([]byte, 0, len(name))
@@ -82,7 +92,7 @@ func tileIDFromName(name string) (int, bool) {
if err != nil { if err != nil {
return 0, false return 0, false
} }
s := strconv.Itoa(id) s := tileDigits(id)
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] { if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
return 0, false return 0, false
} }
@@ -97,7 +107,7 @@ func tileIDFromName(name string) (int, bool) {
// tileName 把瓦片编号编码回文件名。 // tileName 把瓦片编号编码回文件名。
func tileName(id int) string { func tileName(id int) string {
s := strconv.Itoa(id) s := tileDigits(id)
sum := fmt.Sprintf("%x", md5.Sum([]byte(s))) sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
var b strings.Builder var b strings.Builder
b.WriteString(sum[:4]) b.WriteString(sum[:4])
@@ -110,6 +120,15 @@ func tileName(id int) string {
return b.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),返回去重后的块与格子总数。 // Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。 // 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
func Decode(data []byte) ([]Block, int, error) { func Decode(data []byte) ([]Block, int, error) {
@@ -131,11 +150,14 @@ func Decode(data []byte) ([]Block, int, error) {
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err) 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() rc.Close()
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err) 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) zdata, err := zlibDecompress(raw)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err) return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
@@ -196,7 +218,15 @@ func zlibDecompress(raw []byte) ([]byte, error) {
return nil, err return nil, err
} }
defer zr.Close() 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/*/ 由块重建),可用于导出与再导入。 // Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
+78
View File
@@ -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)
}
}
+48
View File
@@ -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)
}
}
-20
View File
@@ -125,26 +125,6 @@ func (t Track) LengthKm() float64 {
return sum return sum
} }
// BBox 返回 [minLon, minLat, maxLon, maxLat]。
func (t Track) BBox() [4]float64 {
b := [4]float64{180, 90, -180, -90}
for _, p := range t.Points {
if p.Lon < b[0] {
b[0] = p.Lon
}
if p.Lat < b[1] {
b[1] = p.Lat
}
if p.Lon > b[2] {
b[2] = p.Lon
}
if p.Lat > b[3] {
b[3] = p.Lat
}
}
return b
}
func haversine(a, b Point) float64 { func haversine(a, b Point) float64 {
const r = 6371.0 const r = 6371.0
dLat := (b.Lat - a.Lat) * math.Pi / 180 dLat := (b.Lat - a.Lat) * math.Pi / 180
+166
View File
@@ -0,0 +1,166 @@
package gpx
import (
"math"
"strings"
"testing"
"time"
)
const trkGPX = `<?xml version="1.0"?>
<gpx version="1.1" creator="test">
<metadata><name>元数据名</name></metadata>
<trk>
<name>轨迹名</name>
<trkseg>
<trkpt lat="30.100000" lon="120.200000"><ele>12.5</ele><time>2026-08-13T07:00:00Z</time></trkpt>
<trkpt lat="30.101000" lon="120.200000"><ele>13.5</ele><time>2026-08-13T07:00:30Z</time></trkpt>
</trkseg>
</trk>
</gpx>`
func TestParseTrk(t *testing.T) {
ts, err := Parse([]byte(trkGPX))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(ts) != 1 {
t.Fatalf("应得 1 条轨迹,实际 %d", len(ts))
}
tr := ts[0]
if tr.Name != "轨迹名" {
t.Errorf("trk 有 name 时用它,实际 %q", tr.Name)
}
if len(tr.Points) != 2 {
t.Fatalf("应得 2 个点,实际 %d", len(tr.Points))
}
if tr.Points[0].Ele != 12.5 {
t.Errorf("trkpt 的高程该带出来,实际 %v", tr.Points[0].Ele)
}
want := time.Date(2026, 8, 13, 7, 0, 0, 0, time.UTC)
if !tr.Points[0].T.Equal(want) {
t.Errorf("时间解析错: %v", tr.Points[0].T)
}
}
// trk 没写 name 时退回 metadata>name——否则整条轨迹会没名字。
func TestParseTrackNameFallsBackToMetadata(t *testing.T) {
src := strings.Replace(trkGPX, "<name>轨迹名</name>", "", 1)
ts, err := Parse([]byte(src))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if ts[0].Name != "元数据名" {
t.Errorf("应退回 metadata 名字,实际 %q", ts[0].Name)
}
}
// 没有 trk 时退回 rte/rtept。
func TestParseFallsBackToRoute(t *testing.T) {
src := `<?xml version="1.0"?>
<gpx version="1.1"><metadata><name>路线</name></metadata>
<rte><name>回家</name>
<rtept lat="30.100000" lon="120.200000"><ele>9</ele></rtept>
<rtept lat="30.102000" lon="120.200000"><ele>11</ele></rtept>
</rte></gpx>`
ts, err := Parse([]byte(src))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(ts) != 1 || ts[0].Name != "回家" || len(ts[0].Points) != 2 {
t.Fatalf("route 应解析成一条轨迹: %+v", ts)
}
// 与 trkpt 分支不一致:rtept 的高程没被带出来。当前 Point.Ele 全仓无人消费,
// 所以不影响功能;这条断言是把现状钉住,将来若决定统一,改这里会立刻提醒。
if ts[0].Points[0].Ele != 0 {
t.Errorf("rte 分支当前不带高程,实际 %v(如已统一请更新本用例)", ts[0].Points[0].Ele)
}
}
// (0,0) 是 GPX 里常见的空点,必须丢掉,否则「几内亚湾」会被算进里程。
func TestParseSkipsNullIsland(t *testing.T) {
src := `<?xml version="1.0"?>
<gpx version="1.1"><trk><trkseg>
<trkpt lat="0" lon="0"/>
<trkpt lat="30.100000" lon="120.200000"/>
<trkpt lat="30.101000" lon="120.200000"/>
</trkseg></trk></gpx>`
ts, err := Parse([]byte(src))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(ts[0].Points) != 2 {
t.Fatalf("(0,0) 该被丢掉,实际剩 %d 个点: %+v", len(ts[0].Points), ts[0].Points)
}
}
// 只有一个点算不出里程,整条丢掉;全丢光就报错,别让调用方以为成功。
func TestParseDropsSinglePointTrack(t *testing.T) {
src := `<?xml version="1.0"?>
<gpx version="1.1"><trk><trkseg>
<trkpt lat="30.100000" lon="120.200000"/>
</trkseg></trk></gpx>`
if ts, err := Parse([]byte(src)); err == nil {
t.Fatalf("单点轨迹应当报错,实际返回 %+v", ts)
}
}
func TestParseErrors(t *testing.T) {
if _, err := Parse([]byte("这不是 XML")); err == nil ||
!strings.Contains(err.Error(), "GPX 解析失败") {
t.Errorf("非法 XML 要报「GPX 解析失败」,实际 %v", err)
}
empty := `<?xml version="1.0"?><gpx version="1.1"><metadata><name>x</name></metadata></gpx>`
_, err := Parse([]byte(empty))
if err == nil || !strings.Contains(err.Error(), "没有找到轨迹点") {
t.Errorf("没有轨迹点要明确报出来,实际 %v", err)
}
}
// 时间格式不对时不该整个失败,只是那个点没有时间。
func TestParseToleratesBadTime(t *testing.T) {
src := `<?xml version="1.0"?>
<gpx version="1.1"><trk><trkseg>
<trkpt lat="30.100000" lon="120.200000"><time>2026/08/13 07:00</time></trkpt>
<trkpt lat="30.101000" lon="120.200000"/>
</trkseg></trk></gpx>`
ts, err := Parse([]byte(src))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if !ts[0].Time.IsZero() {
t.Errorf("时间解析不出来时应为零值,实际 %v", ts[0].Time)
}
}
// 对跖点附近 h 会因浮点误差略大于 1(实测最多到 1+2^-52)。geo.Haversine 用
// math.Min(1, …) 夹了一道,gpx 这份没有——但那个夹子在这里其实够不着:sqrt(1+2^-52)
// 会回舍成正好 1.0,asin(1.0)=π/2,出不了 NaN。这条用例把「对跖点不出 NaN」钉住,
// 将来若有人换公式或去掉开方,会立刻红。
func TestLengthKmAntipodalIsFinite(t *testing.T) {
for _, c := range [][4]float64{
{45, 0, -45, 180},
{0, 0, 0, 180},
{30.5, 10.25, -30.5, -169.75},
} {
tr := Track{Points: []Point{{Lat: c[0], Lon: c[1]}, {Lat: c[2], Lon: c[3]}}}
if got := tr.LengthKm(); math.IsNaN(got) {
t.Errorf("对跖点 %v 算出了 NaN", c)
}
}
}
func TestLengthKm(t *testing.T) {
// 纬度差 1 度 ≈ 6371*π/180 = 111.19 km
tr := Track{Points: []Point{{Lat: 30, Lon: 120}, {Lat: 31, Lon: 120}}}
got := tr.LengthKm()
if math.Abs(got-111.195) > 0.01 {
t.Errorf("1 度纬度该约 111.19 km,实际 %.4f", got)
}
if n := (Track{Points: []Point{{Lat: 30, Lon: 120}}}).LengthKm(); n != 0 {
t.Errorf("单点里程该为 0,实际 %v", n)
}
if n := (Track{}).LengthKm(); n != 0 {
t.Errorf("空轨迹里程该为 0,实际 %v", n)
}
}
+7 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"rond-with-you/internal/geo" "rond-with-you/internal/geo"
"rond-with-you/internal/rond"
) )
// querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。 // querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。
@@ -30,15 +31,15 @@ type querier interface {
// ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。 // ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。
func BackfillCoordSource(ctx context.Context, q querier, datasetID int64) (int, error) { 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, 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, `+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`,
(r.raw->>'ZRAWLATITUDE')::float8, (r.raw->>'ZRAWLONGITUDE')::float8 `+rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`)+`
FROM places p JOIN entity_raw r 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 ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
WHERE p.dataset_id=$1 WHERE p.dataset_id=$1
AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$' AND (r.raw->>'ZLATITUDE') ~ '`+rond.RawFloatPattern+`'
AND (r.raw->>'ZLONGITUDE') ~ '^-?[0-9.]+$' AND (r.raw->>'ZLONGITUDE') ~ '`+rond.RawFloatPattern+`'
AND (r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$' AND (r.raw->>'ZRAWLATITUDE') ~ '`+rond.RawFloatPattern+`'
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'`, datasetID) AND (r.raw->>'ZRAWLONGITUDE') ~ '`+rond.RawFloatPattern+`'`, datasetID)
if err != nil { if err != nil {
return 0, fmt.Errorf("读留档行: %w", err) return 0, fmt.Errorf("读留档行: %w", err)
} }
+108
View File
@@ -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)
}
}
+34
View File
@@ -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) 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 删除某地点的整条备注与别名。 // Delete 删除某地点的整条备注与别名。
func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error { 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 _, 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 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 回填的那一块要留着)。 // Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。
func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error { func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error {
alias = strings.TrimSpace(alias) alias = strings.TrimSpace(alias)
+62 -16
View File
@@ -27,6 +27,7 @@ type StoredVisit struct {
PlaceKey string // 地点对齐键,空 = 该到访没有地点 PlaceKey string // 地点对齐键,空 = 该到访没有地点
PlaceName string // 仅用于展示 PlaceName string // 仅用于展示
ActivityName string ActivityName string
ActivityUUID string // 该到访所挂活动的对齐键,空 = 站点新建的活动
} }
// StoredPlace 是库里的一个地点。 // StoredPlace 是库里的一个地点。
@@ -110,6 +111,29 @@ func PlaceCoordKey(lat, lon float64) string {
func coordStr(v float64) string { return fmt.Sprintf("%.6f", v) } 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 对齐两边的到访。 // DiffVisits 按 UUID 对齐两边的到访。
// //
// 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算 // 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算
@@ -147,7 +171,7 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff {
classify := func(i int, v Visit) { classify := func(i int, v Visit) {
used[i] = true used[i] = true
sv := stored[i] sv := stored[i]
if fields := visitFieldChanges(sv, v, placeKey, placeName, actName); len(fields) > 0 { if fields := visitFieldChanges(sv, v, placeKey, placeName, actName, b.ActivityUUID); len(fields) > 0 {
d.Changed = append(d.Changed, VisitConflict{Backup: v, DB: sv, Fields: fields}) d.Changed = append(d.Changed, VisitConflict{Backup: v, DB: sv, Fields: fields})
} else { } else {
d.Same++ d.Same++
@@ -192,7 +216,7 @@ func timeKey(t time.Time, place string) string {
return fmt.Sprintf("%d|%s", t.Unix(), place) return fmt.Sprintf("%d|%s", t.Unix(), place)
} }
func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map[int]string) []FieldChange { func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName, actUUID map[int]string) []FieldChange {
var out []FieldChange var out []FieldChange
add := func(label, from, to string) { add := func(label, from, to string) {
if from != to { if from != to {
@@ -205,7 +229,7 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map
if !sameTime(sv.Departure, v.Departure) { if !sameTime(sv.Departure, v.Departure) {
add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure)) add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure))
} }
// 地点按坐标判、显示名称;活动按名称判。都不能比 src_pk——两边编号空间不同。 // 地点按坐标、活动按身份比对,都不能比 src_pk——两边编号空间不同。
bp, bn := "", "" bp, bn := "", ""
if v.LocationSrcPK != nil { if v.LocationSrcPK != nil {
bp, bn = placeKey[*v.LocationSrcPK], placeName[*v.LocationSrcPK] bp, bn = placeKey[*v.LocationSrcPK], placeName[*v.LocationSrcPK]
@@ -214,11 +238,21 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map
if sv.PlaceKey != bp { if sv.PlaceKey != bp {
out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn}) out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn})
} }
bl := "" // 活动按**身份**判,不按名称:站内与真机对同一个活动改名属于活动层的差异,
// 挂在它名下的每条到访都跟着报一遍就会把真差异淹掉(实测 16 个活动改名 → 355 条假冲突),
// 而且逐条选「用实机」也改不掉活动名,冲突永远收敛不了。
// 两边都有 UUID 才比 UUID;缺 UUID 的老数据退回按名称比。
bl, bu := "", ""
if v.ActivitySrcPK != nil { if v.ActivitySrcPK != nil {
bl = actName[*v.ActivitySrcPK] bl, bu = actName[*v.ActivitySrcPK], actUUID[*v.ActivitySrcPK]
}
if sv.ActivityUUID != "" && bu != "" {
if sv.ActivityUUID != bu {
add("活动", sv.ActivityName, bl)
}
} else {
add("活动", sv.ActivityName, bl)
} }
add("活动", sv.ActivityName, bl)
add("备注", sv.Remark, v.Remark) add("备注", sv.Remark, v.Remark)
add("表情", sv.Emoji, v.Emoji) add("表情", sv.Emoji, v.Emoji)
add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked)) add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked))
@@ -235,21 +269,35 @@ func placeLabel(name string, lat, lon float64) string {
// DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍 // DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍
// (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。 // (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。
//
// 两侧都用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条,
// 备份里这类分组共 10 组),单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成
// 「备份新增」——于是每次合并都再插一条重复地点、差异永远归不了零。
func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
byCoord := make(map[string]StoredPlace, len(stored)) byCoord := make(map[string][]StoredPlace, len(stored))
byName := map[string]StoredPlace{} byName := make(map[string][]StoredPlace, len(stored))
for _, sp := range stored { for _, sp := range stored {
if sp.Lat != 0 || sp.Lon != 0 { if sp.Lat != 0 || sp.Lon != 0 {
byCoord[PlaceCoordKey(sp.Lat, sp.Lon)] = sp k := PlaceCoordKey(sp.Lat, sp.Lon)
byCoord[k] = append(byCoord[k], sp)
} }
if _, dup := byName[sp.Name]; !dup && sp.Name != "" { if sp.Name != "" {
byName[sp.Name] = sp byName[sp.Name] = append(byName[sp.Name], sp)
} }
} }
used := map[int]bool{} used := map[int]bool{}
// take 认领第一条还没被用掉的候选,认领即置 used——同一个站内地点不能被两个备份地点配走。
take := func(list []StoredPlace) (StoredPlace, bool) {
for _, sp := range list {
if !used[sp.SrcPK] {
used[sp.SrcPK] = true
return sp, true
}
}
return StoredPlace{}, false
}
var d PlaceDiff var d PlaceDiff
classify := func(sp StoredPlace, l Location) { classify := func(sp StoredPlace, l Location) {
used[sp.SrcPK] = true
if fields := placeFieldChanges(sp, l); len(fields) > 0 { if fields := placeFieldChanges(sp, l); len(fields) > 0 {
d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields}) d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields})
} else { } else {
@@ -257,11 +305,11 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
} }
} }
for _, l := range b.Locations { for _, l := range b.Locations {
if sp, ok := byCoord[PlaceCoordKey(l.Lat, l.Lon)]; ok && !used[sp.SrcPK] { if sp, ok := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok {
classify(sp, l) classify(sp, l)
continue continue
} }
if sp, ok := byName[l.Name]; ok && !used[sp.SrcPK] { if sp, ok := take(byName[l.Name]); ok {
classify(sp, l) classify(sp, l)
continue continue
} }
@@ -375,8 +423,6 @@ type Counts struct {
Added, Removed, Changed, Same int Added, Removed, Changed, Same int
} }
func (c Counts) Total() int { return c.Added + c.Removed + c.Changed + c.Same }
func VisitCounts(d VisitDiff) Counts { func VisitCounts(d VisitDiff) Counts {
return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same} return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same}
} }
+98
View File
@@ -291,3 +291,101 @@ func TestDiffPlacesMatchesByNameWhenCoordMoved(t *testing.T) {
t.Fatalf("应报出坐标差异: %+v", d.Changed) t.Fatalf("应报出坐标差异: %+v", d.Changed)
} }
} }
// 真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」同一个坐标 3 条,备份里这类分组共 10 组)。
// 用单槽位 map 时,同一坐标最多只能配上一条,第 3 条永远判「备份新增」→ 每次合并都再插一条
// 重复地点、差异永远归不了零。
func TestDiffPlacesAlignsDuplicateNameCoordGroups(t *testing.T) {
loc := func(pk int) Location {
return Location{SrcPK: pk, Name: "柳州站", Lat: 24.307787, Lon: 109.388309}
}
sp := func(pk int) StoredPlace {
return StoredPlace{SrcPK: pk, Key: PlaceCoordKey(24.307787, 109.388309),
Name: "柳州站", Lat: 24.307787, Lon: 109.388309}
}
b := &Backup{Locations: []Location{loc(41), loc(42), loc(43)}}
d := DiffPlaces([]StoredPlace{sp(11), sp(12), sp(13)}, b)
if len(d.Added) != 0 || len(d.Removed) != 0 || len(d.Changed) != 0 {
t.Fatalf("两边各 3 条同名同坐标应一一配上: +%d -%d ~%d", len(d.Added), len(d.Removed), len(d.Changed))
}
if d.Same != 3 {
t.Fatalf("应配上 3 条,实际 %d", d.Same)
}
// 反过来:站内真的少两条时,仍要老老实实报出「备份新增 2」——
// 多值配对不能变成「只要有同名同坐标就一律不算新增」。
d = DiffPlaces([]StoredPlace{sp(11)}, b)
if len(d.Added) != 2 || len(d.Removed) != 0 || d.Same != 1 {
t.Fatalf("站内只有 1 条时应报新增 2: +%d -%d same=%d", len(d.Added), len(d.Removed), d.Same)
}
}
// 活动改了名(UUID 还在):那是活动层的差异,不该在挂着它的每条到访上重复报一遍。
// 实测真机 16 个活动改名会把 355 条到访全变成假冲突,而且逐条选「用实机」也改不掉活动名。
func TestDiffVisitsIgnoresActivityRename(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC},
}
stored := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
ActivityName: "餐厅", ActivityUUID: uuidC,
}}
d := DiffVisits(stored, b)
if len(d.Changed) != 0 {
t.Fatalf("同一个活动改名不该算到访冲突: %+v", d.Changed[0].Fields)
}
if d.Same != 1 {
t.Fatalf("应算作一致,实际 same=%d", d.Same)
}
}
// 换成了另一个活动才是真差异。
func TestDiffVisitsReportsActivityChange(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}, {SrcPK: 8, Name: "散步"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC, 8: uuidB},
}
stored := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
ActivityName: "散步", ActivityUUID: uuidB,
}}
d := DiffVisits(stored, b)
if len(d.Changed) != 1 {
t.Fatalf("换了活动该报冲突,实际 %d 条", len(d.Changed))
}
f := d.Changed[0].Fields
if len(f) != 1 || f[0].Label != "活动" || f[0].From != "散步" || f[0].To != "吃饭" {
t.Fatalf("活动差异不对: %+v", f)
}
}
// 站点自建的活动没有 UUID,退不回身份就按名称比(老数据要保持原样)。
func TestDiffVisitsActivityWithoutUUIDFallsBackToName(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC},
}
// 名称一致 → 不报
same := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "吃饭",
}}
if d := DiffVisits(same, b); len(d.Changed) != 0 {
t.Fatalf("名称相同时不该报冲突: %+v", d.Changed[0].Fields)
}
// 名称不同 → 报
diff := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "散步",
}}
d := DiffVisits(diff, b)
if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "活动" {
t.Fatalf("没有 UUID 时应退回按名称比: %+v", d.Changed)
}
}
+203
View File
@@ -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)
}
}
+98
View File
@@ -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
}
+85
View File
@@ -0,0 +1,85 @@
package rond
import "testing"
// 地点/活动冲突此前一条用例都没有:只造过「新增」,所以 placePick / activityPick
// 与 UpdPlaces / UpdActs 分支始终没被执行过。差异页线上确实会提交这两类选择。
func TestBuildMergePlanPlaceClash(t *testing.T) {
keyA := PlaceCoordKey(30.123456, 120.654321)
keyB := PlaceCoordKey(31.000001, 121.000002)
keyC := PlaceCoordKey(32.000003, 122.000004)
pd := PlaceDiff{Changed: []PlaceConflict{
{Backup: Location{SrcPK: 11, Name: "备份名A"}, DB: StoredPlace{SrcPK: 1, Key: keyA, Name: "站内名A"}},
{Backup: Location{SrcPK: 12, Name: "备份名B"}, DB: StoredPlace{SrcPK: 2, Key: keyB, Name: "站内名B"}},
{Backup: Location{SrcPK: 13, Name: "备份名C"}, DB: StoredPlace{SrcPK: 3, Key: keyC, Name: "站内名C"}},
}}
p := BuildMergePlan(VisitDiff{}, pd, ActivityDiff{}, Choices{
PlaceClash: map[string]string{keyA: PickBackup, keyB: PickDB},
})
if len(p.UpdPlaces) != 1 {
t.Fatalf("只有选了「用实机」的地点该更新: %+v", p.UpdPlaces)
}
// 键必须是库里的坐标键:备份侧那条没有 Key 字段,拿它当键会让处理函数找不到行
if p.UpdPlaces[0].CoordKey != keyA || p.UpdPlaces[0].L.Name != "备份名A" {
t.Fatalf("应当用库里的坐标键 + 备份的值: %+v", p.UpdPlaces[0])
}
if len(p.UpdVisits)+len(p.AddVisits)+len(p.UpdActs)+len(p.AddActs) != 0 {
t.Fatal("没勾新增就不该带出到访/活动改动")
}
}
func TestBuildMergePlanActivityClash(t *testing.T) {
ad := ActivityDiff{Changed: []ActivityConflict{
{Backup: Activity{SrcPK: 21, Name: "散步"}, DB: StoredActivity{SrcPK: 3, Name: "走路", UUID: uuidC}},
{Backup: Activity{SrcPK: 22, Name: "跑步"}, DB: StoredActivity{SrcPK: 4, Name: "跑步"}},
{Backup: Activity{SrcPK: 23, Name: "游泳"}, DB: StoredActivity{SrcPK: 5, Name: "游泳", UUID: uuidA}},
}}
p := BuildMergePlan(VisitDiff{}, PlaceDiff{}, ad, Choices{
ActClash: map[string]string{uuidC: PickBackup, "n:跑步": PickSkip},
})
if len(p.UpdActs) != 1 {
t.Fatalf("只有选了「用实机」的活动该更新: %+v", p.UpdActs)
}
// 改名也能对上:键取 UUID 而不是名称;没 UUID 的才退回 "n:"+名称
if p.UpdActs[0].Key != uuidC || p.UpdActs[0].A.Name != "散步" {
t.Fatalf("有 UUID 的活动应按 UUID 对上: %+v", p.UpdActs[0])
}
}
// 删除是逐条勾选的事:没勾的「仅站内有」绝不顺手删掉,勾了的才进计划。
func TestBuildMergePlanDeletesOnlyWhenChecked(t *testing.T) {
vd := VisitDiff{Removed: []StoredVisit{{SrcPK: 51}, {SrcPK: 52}}}
pd := PlaceDiff{Removed: []StoredPlace{{SrcPK: 31}, {SrcPK: 32}}}
ad := ActivityDiff{Removed: []StoredActivity{{SrcPK: 41}, {SrcPK: 42}}}
p := BuildMergePlan(vd, pd, ad, Choices{
DelVisits: map[int]bool{52: true},
DelPlaces: map[int]bool{31: true},
DelActs: map[int]bool{42: true},
})
if len(p.DelVisits) != 1 || p.DelVisits[0].SrcPK != 52 {
t.Fatalf("只该删勾了的那条到访: %+v", p.DelVisits)
}
if len(p.DelPlaces) != 1 || p.DelPlaces[0].SrcPK != 31 {
t.Fatalf("只该删勾了的那个地点: %+v", p.DelPlaces)
}
if len(p.DelActs) != 1 || p.DelActs[0].SrcPK != 42 {
t.Fatalf("只该删勾了的那个活动: %+v", p.DelActs)
}
}
func TestAppendMissingDedupesBySrcPK(t *testing.T) {
places := appendMissingPlaces(
[]Location{{SrcPK: 1, Name: "甲"}},
[]Location{{SrcPK: 1, Name: "甲"}, {SrcPK: 2, Name: "乙"}},
)
if len(places) != 2 || places[1].SrcPK != 2 {
t.Fatalf("同 src_pk 不该重复加入: %+v", places)
}
acts := appendMissingActs(
[]Activity{{SrcPK: 7, Name: "通勤"}},
[]Activity{{SrcPK: 7, Name: "通勤"}, {SrcPK: 8, Name: "散步"}},
)
if len(acts) != 2 || acts[1].SrcPK != 8 {
t.Fatalf("同 src_pk 不该重复加入: %+v", acts)
}
}
+124 -28
View File
@@ -24,19 +24,44 @@ import (
// Core Data 时间戳基准:2001-01-01 UTC // Core Data 时间戳基准:2001-01-01 UTC
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.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 { 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 return nil
} }
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的 // 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒, // 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
// 往返导出后时间戳就对不上了。 // 往返导出后时间戳就对不上了。
sec := math.Floor(v.Float64)
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6)) us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local() t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
return &t 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 { type Activity struct {
SrcPK int SrcPK int
Name string Name string
@@ -165,6 +190,23 @@ type Backup struct {
ActivityUUID map[int]string 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 连接。 // Open 解压备份包到 dir,返回可用的 SQLite 连接。
// 调用方负责 Close 与删除 dir。 // 调用方负责 Close 与删除 dir。
func Open(archivePath, dir string) (*sql.DB, error) { func Open(archivePath, dir string) (*sql.DB, error) {
@@ -177,6 +219,28 @@ func Open(archivePath, dir string) (*sql.DB, error) {
} }
defer zr.Close() 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 var dbPath string
for _, f := range zr.File { for _, f := range zr.File {
base := filepath.Base(f.Name) base := filepath.Base(f.Name)
@@ -191,38 +255,39 @@ func Open(archivePath, dir string) (*sql.DB, error) {
dst := filepath.Join(dir, base) dst := filepath.Join(dir, base)
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
return nil, err return "", err
} }
out, err := os.Create(dst) out, err := os.Create(dst)
if err != nil { if err != nil {
rc.Close() rc.Close()
return nil, err return "", err
} }
_, cerr := io.Copy(out, rc) cerr := copyLimited(out, rc, limit)
rc.Close() rc.Close()
out.Close() out.Close()
if cerr != nil { if cerr != nil {
return nil, cerr os.Remove(dst)
return "", fmt.Errorf("解压 %s: %w", base, cerr)
} }
if strings.HasSuffix(low, ".sqlite") { if strings.HasSuffix(low, ".sqlite") {
dbPath = dst dbPath = dst
} }
} }
if dbPath == "" { 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 { if err != nil {
return nil, err return fmt.Errorf("%s: %w", what, err)
} }
db.SetMaxOpenConns(1) return nil
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
}
return db, nil
} }
// Parse 读取 Core Data 库,转换为领域模型。 // Parse 读取 Core Data 库,转换为领域模型。
@@ -244,7 +309,9 @@ func Parse(db *sql.DB) (*Backup, error) {
} }
b.TagGroups[pk] = name b.TagGroups[pk] = name
} }
rows.Close() if err = drainRows(rows, "读取标签分组"); err != nil {
return nil, err
}
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP) // 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
tagGroup := map[int]int{} tagGroup := map[int]int{}
@@ -260,7 +327,9 @@ func Parse(db *sql.DB) (*Backup, error) {
} }
tagGroup[pk] = g 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`) trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
if err != nil { if err != nil {
@@ -275,7 +344,9 @@ func Parse(db *sql.DB) (*Backup, error) {
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]] t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
b.Tags = append(b.Tags, t) 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_,''), 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 a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
b.Activities = append(b.Activities, a) 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`) 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) 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_,''), lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
@@ -327,9 +402,14 @@ func Parse(db *sql.DB) (*Backup, error) {
lrows.Close() lrows.Close()
return nil, err return nil, err
} }
if !validCoord(l.Lat, l.Lon) {
l.Lat, l.Lon = 0, 0
}
b.Locations = append(b.Locations, l) 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_, 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) visitIdx[v.SrcPK] = len(b.Visits)
b.Visits = append(b.Visits, v) 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_`) 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) 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`) 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) m.End = appleTime(en)
b.Movements = append(b.Movements, m) 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), 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) 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 { if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
b.RawVisitNum = 0 b.RawVisitNum = 0
@@ -645,7 +733,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
m.Entities[name] = EntityInfo{Ent: ent, Max: max} 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`) 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 { if err != nil {
@@ -659,7 +749,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
} }
m.DDL[name] = ddl m.DDL[name] = ddl
} }
drows.Close() if err = drainRows(drows, "读取建表 DDL"); err != nil {
return nil, err
}
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。 // 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
visitEnt, okV := m.Entities["Visit"] visitEnt, okV := m.Entities["Visit"]
@@ -692,7 +784,11 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
tcol = cname tcol = cname
} }
} }
cerr := crows.Err()
crows.Close() crows.Close()
if cerr != nil {
continue
}
if vcol != "" && tcol != "" { if vcol != "" && tcol != "" {
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol} m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
break break
+66
View File
@@ -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
}
+169
View File
@@ -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))
}
+100 -26
View File
@@ -36,10 +36,23 @@ type Filter struct {
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。 // RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
Regions []string Regions []string
RegionsExclude bool RegionsExclude bool
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。 // Verdict 非 0 时只保留站长标记为该结论的地点:**连同它们名下的到访与位移一起**
// (1 推荐 / -1 踩雷)。它不是「只筛地点列表」的镜头——判据下推到 visitsWhere /
// movementsWhere,页面上所有数字跟着一起变,否则会出现「点没了、里程还在」这种
// 自相矛盾的视图。
Verdict int 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 { type where struct {
conds []string conds []string
args []any 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 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) 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 return w
} }
@@ -157,8 +179,11 @@ func (f Filter) visitsWhere(off int) *where {
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该 // 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。 // 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
func (f Filter) hasVisitConds() bool { func (f Filter) hasVisitConds() bool {
// Verdict 也算:movements/weather 只在「有这类条件」时才会包一层 EXISTS 到访,
// 漏掉它就别指望位移会跟着筛选走。
return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork || 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 的过滤条件。 // weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
@@ -187,6 +212,34 @@ func (f Filter) weatherWhere() *where {
return w 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)。 // movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。
// //
// 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places // 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places
@@ -215,20 +268,45 @@ func (f Filter) movementsWhere(off int) *where {
AND (` + strings.Join(flags, " OR ") + `))`) AND (` + strings.Join(flags, " OR ") + `))`)
} }
if len(f.Regions) > 0 { 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 { for _, r := range f.Regions {
prov = append(prov, w.ph(r)) phs = append(phs, w.ph(r))
city = append(city, w.ph(r))
dist = append(dist, w.ph(r))
} }
expr := `EXISTS (SELECT 1 FROM places pmv endpoint := func(col string) string {
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id) var conds []string
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` + for _, ph := range phs {
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))` // 必须 COALESCE:district 常为 NULL,NULL = 'x' 得 NULL,
if f.RegionsExclude { // 黑名单的 NOT (...) 也跟着变 NULL,整行被误删(regionSQL 同样处理)。
expr = "NOT " + expr 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 return w
} }
@@ -654,17 +732,20 @@ type Place struct {
Down int 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), 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,''), 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, 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 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 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 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),''), 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.is_home), 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.is_work), 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, p.src_pk,
EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key = EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key =
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))` 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...) 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() + `)` 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) q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
rows, err := d.QueryContext(ctx, q, args...) rows, err := d.QueryContext(ctx, q, args...)
+72
View File
@@ -250,3 +250,75 @@ func TestWeatherStatsRespectsFilter(t *testing.T) {
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal) 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)
}
}
+355
View File
@@ -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)
}
}
+26
View File
@@ -87,6 +87,32 @@ func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK,
return err 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 把某个数据集里的地点编号换算成坐标键——访客票也是挂在坐标上的, // placeKeyOf 把某个数据集里的地点编号换算成坐标键——访客票也是挂在坐标上的,
// 换数据集之后票仍然跟着同一个地点。 // 换数据集之后票仍然跟着同一个地点。
func (s *Store) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) { func (s *Store) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) {
+108
View File
@@ -0,0 +1,108 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
)
// adminNotesBulk 必须走批量语句。
//
// 旧实现是 `for _, id := range ids` 里逐条 Delete/SetNote/SaveVerdict:勾 50 个地点就是
// 50(清空是 100)次数据库往返,在 1C1G 的机器上体感很明显。这条守卫钉住「不许退回逐条循环」。
func TestAdminNotesBulkUsesBulkStatements(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "handlers_admin.go"))
if err != nil {
t.Fatalf("读取 handlers_admin.go 失败: %v", err)
}
fn := funcBody(string(b), "func (s *Server) adminNotesBulk(")
if fn == "" {
t.Fatal("在 handlers_admin.go 里找不到 adminNotesBulk(写法变了,这条守卫就失效了)")
}
for _, banned := range []string{"s.notes.Delete(", "s.notes.SetNote(", "s.votes.SaveVerdict("} {
if strings.Contains(fn, banned) {
t.Errorf("adminNotesBulk 不该再逐条调用 %s —— 应改用对应的 *Bulk 方法", banned)
}
}
for _, want := range []string{"s.notes.DeleteBulk(", "s.notes.SetNoteBulk(", "s.votes.SaveVerdictBulk("} {
if !strings.Contains(fn, want) {
t.Errorf("adminNotesBulk 未改用批量方法 %s", want)
}
}
}
// 批量勾选必须真的作用到**每一个**被勾的地点(不是只改第一条)。
func TestAdminNotesBulkAppliesToEveryCheckedPlace(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
pks := []int64{680001, 680002, 680003}
for i, pk := range pks {
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'批量点',$3,$4)`, f.dsID, pk,
30.0+float64(i)*0.01, 120.0+float64(i)*0.01); err != nil {
t.Fatalf("建地点失败: %v", err)
}
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM place_notes WHERE user_id=$1`, uid)
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid)
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[])`,
f.dsID, pks)
})
post := func(vals url.Values) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/notes/bulk", strings.NewReader(vals.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminNotesBulk(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("批量操作应跳回页面: code=%d body=%s", rec.Code, rec.Body.String())
}
}
bulk := func(action string, extra ...string) url.Values {
v := url.Values{"action": {action}}
for i := 0; i+1 < len(extra); i += 2 {
v.Set(extra[i], extra[i+1])
}
for _, pk := range pks {
v.Add("src_pk", strconv.FormatInt(pk, 10))
}
return v
}
// 先把三个地点都写上别名 + 备注
for _, pk := range pks {
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, int(pk), "别名", "备注"); err != nil {
t.Fatalf("预置备注失败: %v", err)
}
}
post(bulk("verdict", "verdict", "1"))
if n := f.scalar(t, `SELECT count(*) FROM place_votes WHERE user_id=$1 AND verdict=1`, uid); n != len(pks) {
t.Fatalf("三个地点都该设上结论,实际 %d", n)
}
post(bulk("clear"))
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1 AND note <> ''`, uid); n != 0 {
t.Fatalf("清空后不该还有手写备注,剩 %d 条", n)
}
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1 AND alias='别名'`, uid); n != len(pks) {
t.Fatalf("清空备注要保留别名,实际剩 %d 条", n)
}
post(bulk("verdict", "verdict", "0"))
if n := f.scalar(t, `SELECT count(*) FROM place_votes WHERE user_id=$1`, uid); n != 0 {
t.Fatalf("取消结论后不该还有记录,剩 %d 条", n)
}
post(bulk("delete"))
if n := f.scalar(t, `SELECT count(*) FROM place_notes WHERE user_id=$1`, uid); n != 0 {
t.Fatalf("删除后不该还有备注,剩 %d 条", n)
}
}
+81
View File
@@ -0,0 +1,81 @@
package web
import (
"context"
"os"
"regexp"
"strings"
"testing"
)
// TestAggHelpersPropagateErrors 是 D1 的行为守卫:四个「重算聚合 / 写标签」的辅助函数
// 必须把数据库错误返回给调用方。
//
// 它们原先的签名是 `(isHome, isWork bool)` / 无返回值,Scan 的错误被直接丢掉——
// 于是「查询炸了」和「查到了 false/false」在调用方看来一模一样:到访会被写得没有归属活动,
// 计数会静默地停在旧值上。用一个已取消的 context 就能逼出这一层错误。
func TestAggHelpersPropagateErrors(t *testing.T) {
f := newMovementFixture(t)
// 已取消的 context:任何一次 Query/Exec 都会立刻以 context.Canceled 返回。
ctx, cancel := context.WithCancel(f.ctx)
cancel()
if err := f.srv.refreshDatasetAgg(ctx, f.pg, f.dsID); err == nil {
t.Error("refreshDatasetAgg 吞掉了错误:ctx 已取消却返回 nil")
}
if err := f.srv.refreshPlaceAgg(ctx, f.pg, 1); err == nil {
t.Error("refreshPlaceAgg 吞掉了错误:ctx 已取消却返回 nil")
}
if err := f.srv.saveVisitTags(ctx, f.pg, f.dsID, 1, nil); err == nil {
t.Error("saveVisitTags 吞掉了错误:ctx 已取消却返回 nil")
}
if _, _, err := f.srv.activityFlags(ctx, f.pg, 1); err == nil {
t.Error("activityFlags 吞掉了错误:ctx 已取消却返回 nil")
}
}
// TestNoAggCallSiteSwallowsError 是 D1 的静态守卫:这三个辅助函数「返回 error」只有在
// 每个调用点都接住它时才有意义。裸调用(语句形式、丢掉返回值)会随第一个新加的调用点回归,
// 而且编译器不会拦——这里按行扫描源码,把裸调用钉死。
func TestNoAggCallSiteSwallowsError(t *testing.T) {
// 只匹配「整行就是一次调用」的裸语句,合法写法前面一定还有 `if err := `、`return `、
// `logAggFailure(` 等前缀,因此不会被误伤。
bare := regexp.MustCompile(`^\s*s\.(refreshDatasetAgg|refreshPlaceAgg|saveVisitTags)\(`)
for _, name := range []string{
"handlers_edit.go",
"handlers_photos.go",
"fillup.go",
} {
path := pkgFile(t, name)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
for i, line := range strings.Split(string(data), "\n") {
if bare.MatchString(line) {
t.Errorf("%s:%d 裸调用吞掉了 error:%s", name, i+1, strings.TrimSpace(line))
}
}
}
}
// TestAggHelpersHaveErrorReturn 钉住签名本身:如果谁把它们改回无返回值,
// 上面两条用例会失去意义(编译期就不会再有 error 可断言)。
func TestAggHelpersHaveErrorReturn(t *testing.T) {
data, err := os.ReadFile(pkgFile(t, "handlers_edit.go"))
if err != nil {
t.Fatalf("读 handlers_edit.go 失败: %v", err)
}
src := string(data)
for _, sig := range []string{
"func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) error",
"func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error",
"func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error",
} {
if !strings.Contains(src, sig) {
t.Errorf("签名被改掉了(不再返回 error):%s", sig)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// mkFile 造一个指定字节数的候选文件。
func mkFile(t *testing.T, dir, name string, n int) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), n), 0o644); err != nil {
t.Fatalf("造 %s 失败: %v", name, err)
}
return p
}
// P5 回归:按摘要找回备份包时,必须先按字节数筛一遍再算摘要。
//
// 只看「最终返回哪个文件」新旧写法完全一样——真正的差别是**算了几次 sha256**,
// 而 uploads 里每份备份动辄几百 MB,多算一次就是几百 MB 的读盘。所以这里注入一个
// 会计数的 hash,把「有没有白算」直接钉住。
func TestPickArchiveSkipsWrongSizedFilesWithoutHashing(t *testing.T) {
dir := t.TempDir()
// 两个诱饵都比正主新(会排在候选前面),大小都和正主不同
d1 := mkFile(t, dir, "decoy-newest.rondbackup", 10)
d2 := mkFile(t, dir, "decoy-mid.rondbackup", 20)
right := mkFile(t, dir, "right.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{d1, d2, right}, "sha-right", 30, func(f string) (string, error) {
hashed++
// 按文件名给摘要:这样「选中谁」和「算了几次」互相独立——
// 去掉预筛时选中的仍然是 right,但白读了两个诱饵(hashed=3)。
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != right {
t.Errorf("应当选中 %s,实际 %s", filepath.Base(right), filepath.Base(got))
}
if hashed != 1 {
t.Errorf("只该对大小相符的那份算摘要,实际算了 %d 次(白读了 %d 份整包)", hashed, hashed-1)
}
}
// 反向约束:筛选只许按大小筛,不许把大小恰好相符的候选也一起跳过。
// 三份大小相同、只有最后一份摘要对得上 ⇒ 必须老老实实算三次。
func TestPickArchiveHashesEverySameSizedCandidate(t *testing.T) {
dir := t.TempDir()
a := mkFile(t, dir, "a.rondbackup", 30)
b := mkFile(t, dir, "b.rondbackup", 30)
c := mkFile(t, dir, "c.rondbackup", 30)
hashed := 0
got := pickArchiveBySha([]string{a, b, c}, "sha-c", 30, func(f string) (string, error) {
hashed++
return "sha-" + strings.TrimSuffix(filepath.Base(f), ".rondbackup"), nil
})
if got != c {
t.Errorf("应当选中 c.rondbackup,实际 %s", filepath.Base(got))
}
if hashed != 3 {
t.Errorf("大小都相符时不能跳过任何一份,实际只算了 %d 次", hashed)
}
}
// 退化路径:库里没记 sha(老数据)时不按大小筛,保持「取最新的那份」的旧行为——
// 否则会因为记不到 size 而突然一份都选不中,导出直接失败。
func TestPickArchiveKeepsNewestWhenNoSha(t *testing.T) {
dir := t.TempDir()
newest := mkFile(t, dir, "newest.rondbackup", 10)
older := mkFile(t, dir, "older.rondbackup", 999)
hashed := 0
got := pickArchiveBySha([]string{newest, older}, "", 0, func(f string) (string, error) {
hashed++
return "whatever", nil
})
if got != newest {
t.Errorf("没有 sha 时应当取第一份可读的,实际 %s", filepath.Base(got))
}
if hashed != 1 {
t.Errorf("没有 sha 时不该多算,实际算了 %d 次", hashed)
}
}
// 目录与读不到的文件要跳过,不能因为一份坏候选就整段失败。
func TestPickArchiveSkipsDirsAndUnreadable(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
t.Fatal(err)
}
missing := filepath.Join(dir, "does-not-exist.rondbackup")
ok := mkFile(t, dir, "ok.rondbackup", 5)
got := pickArchiveBySha([]string{missing, filepath.Join(dir, "subdir"), ok}, "sha-ok", 5, func(f string) (string, error) {
if f == ok {
return "sha-ok", nil
}
t.Errorf("不该对 %s 算摘要", f)
return "nope", nil
})
if got != ok {
t.Errorf("应当跳过缺失与目录后选中 ok.rondbackup,实际 %q", got)
}
// 找不到时返回空串(调用方据此报「请重新上传」)
if s := pickArchiveBySha([]string{ok}, "sha-none", 5, func(string) (string, error) { return "other", nil }); s != "" {
t.Errorf("没有命中时应返回空串,实际 %q", s)
}
}
// findSourceArchive 必须把 size_bytes 一起查出来——只查 sha 的话上面那道筛选就永远不生效,
// 而且编译器不会拦(筛挂掉、越筛越慢,行为测试看不出来)。
func TestFindSourceArchiveConsultsSizeBytes(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_export.go"))
if err != nil {
t.Fatalf("读 handlers_export.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) findSourceArchive(")
if !strings.Contains(body, "size_bytes") {
t.Error("findSourceArchive 没有读取 size_bytes:按大小预筛会永远拿不到目标大小")
}
if !strings.Contains(body, "pickArchiveBySha(") {
t.Error("findSourceArchive 没有走 pickArchiveBySha:按大小预筛没有生效")
}
}
+8 -3
View File
@@ -239,17 +239,21 @@ func (s *Server) countOther(ctx context.Context, datasetID int64) (movements, we
// loadStoredVisits 读出该数据集的全部到访,并把原始行留档里的 UUID 解出来当对齐键。 // loadStoredVisits 读出该数据集的全部到访,并把原始行留档里的 UUID 解出来当对齐键。
// 没有留档的行就是站点新建的(真机不会给它 UUID),对齐键留空。 // 没有留档的行就是站点新建的(真机不会给它 UUID),对齐键留空。
// 所挂活动的 UUID 也一样:到访层面按活动身份比对,才不会因活动改名而全线报冲突。
func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.StoredVisit, error) { func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.StoredVisit, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT v.src_pk, v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''), SELECT v.src_pk, v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''),
v.bookmarked, v.is_user_added, COALESCE(p.src_pk,0), v.bookmarked, v.is_user_added, COALESCE(p.src_pk,0),
COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(a.name,''), COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(a.name,''),
COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}','') COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}',''),
COALESCE(aer.raw #>> '{ZUID_,$blob}','')
FROM visits v FROM visits v
LEFT JOIN places p ON p.id = v.place_id LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id LEFT JOIN activities a ON a.id = v.activity_id
LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id
AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
LEFT JOIN entity_raw aer ON aer.dataset_id = v.dataset_id
AND aer.entity = 'ZACTIVITY' AND aer.src_pk = a.src_pk
WHERE v.dataset_id = $1`, datasetID) WHERE v.dataset_id = $1`, datasetID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -263,9 +267,9 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.
var placePK int var placePK int
var placeName string var placeName string
var lat, lon float64 var lat, lon float64
var b64 string var b64, actB64 string
if err := rows.Scan(&sv.SrcPK, &arr, &dep, &sv.Remark, &sv.Emoji, &sv.Bookmarked, if err := rows.Scan(&sv.SrcPK, &arr, &dep, &sv.Remark, &sv.Emoji, &sv.Bookmarked,
&sv.UserAdded, &placePK, &placeName, &lat, &lon, &sv.ActivityName, &b64); err != nil { &sv.UserAdded, &placePK, &placeName, &lat, &lon, &sv.ActivityName, &b64, &actB64); err != nil {
return nil, err return nil, err
} }
sv.Arrival = &arr sv.Arrival = &arr
@@ -278,6 +282,7 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.
sv.PlaceKey = rond.PlaceCoordKey(lat, lon) sv.PlaceKey = rond.PlaceCoordKey(lat, lon)
} }
sv.UUID = uuidFromBase64(b64) sv.UUID = uuidFromBase64(b64)
sv.ActivityUUID = uuidFromBase64(actB64)
out = append(out, sv) out = append(out, sv)
} }
return out, rows.Err() return out, rows.Err()
+63 -6
View File
@@ -62,16 +62,22 @@ func previewBackupToken(t *testing.T, f *movementFixture, path string) (string,
} }
// visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。 // visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。
// 不能全页 Contains 数字——地点行也有同样的 data-label,会张冠李戴。
func visitCounts(t *testing.T, page string) (added, gone, clash int) { func visitCounts(t *testing.T, page string) (added, gone, clash int) {
t.Helper() t.Helper()
row := regexp.MustCompile(`(?s)<td>到访</td>(.*?)</tr>`).FindStringSubmatch(page) return overviewCounts(t, page, "到访")
}
// overviewCounts 取概览表里任意一行(到访 / 地点 / 活动类型)的三个计数。
// 不能全页 Contains 数字——三行有同样的 data-label,会张冠李戴。
func overviewCounts(t *testing.T, page, label string) (added, gone, clash int) {
t.Helper()
row := regexp.MustCompile(`(?s)<td>` + label + `</td>(.*?)</tr>`).FindStringSubmatch(page)
if row == nil { if row == nil {
t.Fatal("概览表里找不到「到访」行") t.Fatalf("概览表里找不到「%s」行", label)
} }
nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1) nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1)
if len(nums) != 3 { if len(nums) != 3 {
t.Fatalf("到访行应有三个计数,实际 %d", len(nums)) t.Fatalf("「%s」行应有三个计数,实际 %d", label, len(nums))
} }
for _, n := range nums { for _, n := range nums {
v, _ := strconv.Atoi(n[2]) v, _ := strconv.Atoi(n[2])
@@ -87,19 +93,70 @@ func visitCounts(t *testing.T, page string) (added, gone, clash int) {
return 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 对得上、字段也没变,到访应当一条不差。
// //
// 这条也顺带钉住「按 UUID 而不是编号对齐」:库内那些行的编号与备份里完全不同 // ⚠️ 注意这条用例**没有**验证「按 UUID 对齐」:夹具导入的就是 f.src 本身,
// (rond 重排过),只按编号比会让 800 多条全变冲突。 // 库里的 src_pk 与备份的 Z_PK 逐条相同(实测 823/823),它是靠编号直接命中的。
// 真正钉住 UUID 对齐的是 TestBackupDiffAlignsByUUIDNotSrcPK / TestBackupMergeRenumberedBackup。
// //
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址), // 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。 // 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
//
// 但**「备份新增」必须是 0**:导入就是把备份的地点一条一条写进去的(含同名同坐标的重复项),
// 比对再认不出它们就会每次都判「多出来一条」⇒ 合并时反复插重复地点。真机备份里
// 「柳州站」在同一个坐标上有 3 条,正是这个形状。
func TestBackupDiffIdentical(t *testing.T) { func TestBackupDiffIdentical(t *testing.T) {
f := newMovementFixture(t) f := newMovementFixture(t)
page := previewBackup(t, f, f.src) page := previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 { if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("同一份备份的到访不该有差异: 新增 %d 仅站内有 %d 冲突 %d", a, g, c) t.Fatalf("同一份备份的到访不该有差异: 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
} }
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("刚导入就到比对,地点不该有新增或仅站内有: 新增 %d 仅站内有 %d"+
"(同名同坐标的重复地点没被一一配上)", a, g)
}
}
// 编号全被平移后(= 数据来自另一份重排过 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)
}
} }
// 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。 // 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。
+4 -2
View File
@@ -731,8 +731,10 @@ func writeRawRow(ctx context.Context, ex execer, dsID int64, entity string, pk i
} }
blob, err := json.Marshal(raw) blob, err := json.Marshal(raw)
if err != nil { if err != nil {
log.Printf("留档 %s#%d 序列化失败: %v", entity, pk, err) // 这里以前只 log 后 return nil:调用方会以为留档成功,合并照常提交,
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) _, err = ex.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
VALUES ($1,$2,$3,$4,false) VALUES ($1,$2,$3,$4,false)
+93 -1
View File
@@ -54,6 +54,10 @@ func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID); // - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
// - 位移/天气的外键指向库里的新编号(写错就成孤儿); // - 位移/天气的外键指向库里的新编号(写错就成孤儿);
// - 合并是幂等的:再传同一份备份,差异归零。 // - 合并是幂等的:再传同一份备份,差异归零。
//
// 注意:这里库内编号与备份逐条相同(夹具导入的就是 f.src 本身,实测 823/823),
// 所以走的是「编号直接命中」的快路径。编号重排过的真实场景见
// TestBackupMergeRenumberedBackupRestoresOnlyMissing。
func TestBackupMergeRestoresMissingVisits(t *testing.T) { func TestBackupMergeRestoresMissingVisits(t *testing.T) {
f := newMovementFixture(t) f := newMovementFixture(t)
@@ -120,11 +124,99 @@ func TestBackupMergeRestoresMissingVisits(t *testing.T) {
t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans) t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans)
} }
// 幂等:再比一次应当完全一致 // 幂等:再比一次应当完全一致(地点也一样——同名同坐标的重复地点必须一一配上,
// 否则每次合并都会再插一条重复地点)
page = previewBackup(t, f, f.src) page = previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 { if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c) t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c)
} }
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后再比对,地点应当归零,实际 新增 %d / 仅站内有 %d"+
"(重复地点会被反复当成「备份新增」→ 每合并一次多一条)", a, g)
}
}
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 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)
}
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后地点差异也该归零,实际 新增 %d 仅站内有 %d", a, g)
}
} }
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。 // 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
+65
View File
@@ -0,0 +1,65 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// S6 回归:X-Forwarded-For 只有**最后一段**可信。
//
// 反代(nginx 的 `$proxy_add_x_forwarded_for`)是把客户端发来的值原样接在前面、
// 再把真实对端补在后面。所以取第一段等于「谁想冒充谁自己填」——访客随手改个请求头
// 就能换一个投票身份。取最后一段才是反代自己写、客户端改不动的那一个。
func TestClientIPTrustsOnlyProxyWrittenSegment(t *testing.T) {
cases := []struct {
name string
remote string
xff []string
want string
}{
{"没有 XFF 时用 RemoteAddr", "203.0.113.7:51234", nil, "203.0.113.7"},
{"RemoteAddr 不带端口", "203.0.113.7", nil, "203.0.113.7"},
{"单段 XFF(反代覆盖式写入)", "10.0.0.1:80", []string{"203.0.113.7"}, "203.0.113.7"},
{"多段 XFF 取最后一段", "10.0.0.1:80", []string{"198.51.100.9, 203.0.113.7"}, "203.0.113.7"},
{"最后一段两边有空格", "10.0.0.1:80", []string{"198.51.100.9 , 203.0.113.7 "}, "203.0.113.7"},
{"多行 XFF 拼接后取最后", "10.0.0.1:80", []string{"198.51.100.9", "203.0.113.7"}, "203.0.113.7"},
{"最后一段带端口", "10.0.0.1:80", []string{"198.51.100.9, 203.0.113.7:51234"}, "203.0.113.7"},
{"IPv6", "10.0.0.1:80", []string{"198.51.100.9, 2001:db8::1"}, "2001:db8::1"},
{"IPv4-mapped IPv6 归一", "10.0.0.1:80", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
// 最后一段不是 IP ⇒ 整个头作废,别把垃圾当身份
{"最后一段是垃圾", "203.0.113.7:51234", []string{"198.51.100.9, not-an-ip"}, "203.0.113.7"},
{"XFF 只有垃圾", "203.0.113.7:51234", []string{"whatever"}, "203.0.113.7"},
{"XFF 为空串", "203.0.113.7:51234", []string{""}, "203.0.113.7"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/vote", nil)
r.RemoteAddr = c.remote
for _, v := range c.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != c.want {
t.Errorf("clientIP = %q,期望 %q", got, c.want)
}
})
}
}
// 真正要守的性质:只改伪造的前缀,不该改变访客身份。
// 这条比上面任何单点断言都硬——它直接对应「换个请求头就换一票」这个攻击。
func TestClientIPIgnoresForgedPrefix(t *testing.T) {
mk := func(forged string) *http.Request {
r := httptest.NewRequest("POST", "/api/vote", nil)
r.RemoteAddr = "10.0.0.1:80"
r.Header.Set("X-Forwarded-For", forged+", 203.0.113.7")
return r
}
base := clientIP(mk(""))
for _, forged := range []string{"1.2.3.4", "9.9.9.9", "::1", "198.51.100.9"} {
got := clientIP(mk(forged))
if got != base {
t.Errorf("伪造前缀 %q 让身份变成了 %q(基线 %q)——换个头就能换一票", forged, got, base)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"log"
"net/http"
"net/url"
"strings"
)
// csrfOriginGuard 挡「跨站发起的写请求」。
//
// 会话 cookie 是 SameSite=Lax,跨站 POST 本来就带不上 cookie;这层是纵深防御,
// 顺手把「将来有人把 SameSite 放宽」和「同站不同源」这两条路一起堵掉。
//
// 判据只认浏览器一定会带的两个头,不需要给每个表单塞 token,因此模板一行都不用改:
// - 有 Origin:必须是本站;`Origin: null`(沙箱 iframe / file://)一律拒。
// - 没有 Origin 但有 Referer:必须是本站。
// - 两个都没有:放行 —— 跨站 POST 浏览器必带 Origin(只有 Referrer-Policy 把它
// 一起抹掉的情况,而那时 Lax 已经把 cookie 拦在外面了),所以这里只可能是
// curl / 脚本这类非浏览器客户端。
//
// 注意:反代必须把**原始 Host** 传过来(nginx 的 `proxy_set_header Host $host`),
// 并且改了 Host 时要带 X-Forwarded-Host;否则本站自己的表单也会被拒。
func (s *Server) csrfOriginGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
next.ServeHTTP(w, r)
return
}
if !sameOriginRequest(r) {
log.Printf("拒绝跨站写请求 %s %s:Origin=%q Referer=%q Host=%q",
r.Method, r.URL.Path, r.Header.Get("Origin"), r.Header.Get("Referer"), r.Host)
http.Error(w, "跨站请求已被拒绝", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// sameOriginRequest 判断写请求是不是本站自己发起的。
func sameOriginRequest(r *http.Request) bool {
if o := r.Header.Get("Origin"); o != "" {
return matchesRequestHost(r, o)
}
if ref := r.Header.Get("Referer"); ref != "" {
return matchesRequestHost(r, ref)
}
return true
}
// matchesRequestHost 比较 raw(Origin 或 Referer)的 host 与本请求的 host。
// X-Forwarded-Host 也在候选里:反代改写 Host 时靠它兜底。
func matchesRequestHost(r *http.Request, raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false // 含 "null"、相对地址等无法定位来源的形态
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
if i := strings.IndexByte(xfh, ','); i >= 0 {
xfh = xfh[:i]
}
return strings.EqualFold(u.Host, strings.TrimSpace(xfh))
}
return false
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// sameOriginRequest 是 CSRF 纵深防御的核心判据。会话 cookie 已经是 SameSite=Lax,
// 这层是防「将来有人把 SameSite 放宽」以及同站不同源。
func TestSameOriginRequest(t *testing.T) {
cases := []struct {
name string
host string
origin string
ref string
xfh string
want bool
}{
{name: "无任何来源头(curl/脚本)", host: "site.example", want: true},
{name: "本站 Origin", host: "site.example", origin: "http://site.example", want: true},
{name: "本站 Origin(带端口)", host: "site.example:8080", origin: "http://site.example:8080", want: true},
{name: "本站 Origin(https)", host: "site.example", origin: "https://site.example", want: true},
{name: "跨站 Origin", host: "site.example", origin: "http://evil.com", want: false},
{name: "跨站 Origin 同后缀", host: "site.example", origin: "http://site.example.evil.com", want: false},
{name: "Origin 为 null(沙箱 iframe)", host: "site.example", origin: "null", want: false},
{name: "Origin 不可解析", host: "site.example", origin: "://", want: false},
{name: "跨站 Referer", host: "site.example", ref: "http://evil.com/admin", want: false},
{name: "本站 Referer", host: "site.example", ref: "http://site.example/admin/notes", want: true},
{name: "Origin 优先于 Referer", host: "site.example", origin: "http://evil.com", ref: "http://site.example/x", want: false},
{name: "反代改写 Host,靠 XFH 兜底", host: "10.0.0.5:8080", origin: "https://site.example",
xfh: "site.example", want: true},
{name: "反代 XFH 多段取首段", host: "10.0.0.5:8080", origin: "https://site.example",
xfh: "site.example, proxy.internal", want: true},
{name: "反代 XFH 也不匹配", host: "10.0.0.5:8080", origin: "https://evil.com",
xfh: "site.example", want: false},
}
for _, c := range cases {
r := httptest.NewRequest("POST", "/admin/logout", nil)
r.Host = c.host
if c.origin != "" {
r.Header.Set("Origin", c.origin)
}
if c.ref != "" {
r.Header.Set("Referer", c.ref)
}
if c.xfh != "" {
r.Header.Set("X-Forwarded-Host", c.xfh)
}
if got := sameOriginRequest(r); got != c.want {
t.Errorf("%s: sameOriginRequest = %v,期望 %v", c.name, got, c.want)
}
}
}
// 走完整中间件链验证接线:写请求跨站被 403,读请求不受影响。
func TestHandlerRejectsCrossOriginWrite(t *testing.T) {
f := newLoginFixture(t)
h := f.srv.Handler()
post := func(method, path, origin, ref string) int {
t.Helper()
r := httptest.NewRequest(method, path, nil)
if origin != "" {
r.Header.Set("Origin", origin)
}
if ref != "" {
r.Header.Set("Referer", ref)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
if code := post("POST", "/admin/logout", "http://evil.com", ""); code != http.StatusForbidden {
t.Errorf("跨站 POST 应 403,实际 %d", code)
}
if code := post("POST", "/admin/logout", "null", ""); code != http.StatusForbidden {
t.Errorf("Origin: null 的 POST 应 403,实际 %d", code)
}
if code := post("POST", "/admin/logout", "", "http://evil.com/x"); code != http.StatusForbidden {
t.Errorf("跨站 Referer 的 POST 应 403,实际 %d", code)
}
// 本站来源与「无来源头」都要放行到业务逻辑(不是 403 即可)
for _, c := range []struct{ name, origin, ref string }{
{"本站 Origin", "http://example.com", ""},
{"无来源头", "", ""},
} {
if code := post("POST", "/admin/logout", c.origin, c.ref); code == http.StatusForbidden {
t.Errorf("%s 的 POST 不该被拦,实际 403", c.name)
}
}
// GET 是读,带跨站 Origin 也不能拦(否则外链图片/预取就废了)
if code := post("GET", "/healthz", "http://evil.com", ""); code == http.StatusForbidden {
t.Errorf("GET 不该被 CSRF 守卫拦,实际 403")
}
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// S9 回归:下载文件名不能从引号里「逃出去」。
//
// 导出包名里带着 `datasets.original_name`,也就是上传时的文件名。它原样拼进
// `filename="…"` 时,一个 `"` 就能提前闭合引号、把后面的内容变成新的头参数
// (`x"; filename="evil.exe`)。net/http 只把 CR/LF 换成空格,引号它不管。
func TestDownloadDispositionCannotEscapeTheHeader(t *testing.T) {
// 结构:attachment; filename="<纯 ASCII 安全串>"; filename*=UTF-8''<百分号编码>
// 集合里必须带上 `-`:attr-char 允许它,而日期段 `-export-20260921` 全是它。
shape := regexp.MustCompile(`^attachment; filename="([^"]*)"; filename\*=UTF-8''([-A-Za-z0-9._~%]*)$`)
cases := []struct {
in string
want string // 期望 filename* 解回来的原名;空表示与 in 相同
}{
{`普通名字-export-20260921.rondbackup`, ""},
{`a"b.rondbackup`, ""},
{`a"; filename="evil.exe`, ""},
{"换\n行\r.rondbackup", ""},
{`..%2f..%2fetc%2fpasswd`, ""},
{`C:\Windows\system32\x.rondbackup`, ""},
{`a;b=c:d*e?f<g>h|i.rondbackup`, ""},
{`日本語のバックアップ.rondbackup`, ""},
{"", "download"},
{" ", "download"},
}
for _, c := range cases {
got := downloadDisposition(c.in)
if strings.ContainsAny(got, "\r\n") {
t.Errorf("%q → 头部里出现了裸的换行: %q", c.in, got)
}
if n := strings.Count(got, `"`); n != 2 {
t.Errorf("%q → 引号有 %d 个(必须恰好一对,多了就能逃逸): %q", c.in, n, got)
}
m := shape.FindStringSubmatch(got)
if m == nil {
t.Errorf("%q → 头部形状不对: %q", c.in, got)
continue
}
if strings.ContainsAny(m[1], `"\\/:;:=*?<>|`) {
t.Errorf("%q → filename= 里还有危险字符: %q", c.in, m[1])
}
// 非 ASCII 名字才需要 filename* 兜底;这里统一验「能解回原名」
back, err := url.PathUnescape(m[2])
if err != nil {
t.Fatalf("%q → filename*= 不是合法的百分号编码: %v", c.in, err)
}
want := c.want
if want == "" {
want = c.in
}
if back != want {
t.Errorf("%q → filename*= 解回来是 %q,期望 %q", c.in, back, want)
}
}
}
// 纯中文名会把 filename= 整串换成下划线,此时必须保留扩展名,
// 否则用户下到手里是个没法双击打开的「download」。
func TestDownloadDispositionKeepsExtensionOnNonASCII(t *testing.T) {
got := downloadDisposition("足迹备份.rondbackup")
if !strings.Contains(got, `.rondbackup"`) {
t.Errorf("非 ASCII 名的 ASCII 兜底丢了扩展名: %q", got)
}
if !strings.HasPrefix(got, `attachment; filename="download`) {
t.Errorf("非 ASCII 名应当退到可读的兜底名: %q", got)
}
}
// S9 静态守卫:所有 Content-Disposition 都必须走 downloadDisposition,
// 不许再手拼 f-string——手拼就是这次的成因,而且它只在特定文件名下出问题。
func TestAllContentDispositionGoThroughHelper(t *testing.T) {
dir := pkgFile(t, "")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读包目录失败: %v", err)
}
scanned, found := 0, 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
scanned++
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("读 %s 失败: %v", e.Name(), err)
}
src := string(data)
for i := 0; ; {
j := strings.Index(src[i:], `"Content-Disposition"`)
if j < 0 {
break
}
j += i
found++
// 取一小段窗口看这次 Set 是不是用的助手
win := src[j:]
if len(win) > 200 {
win = win[:200]
}
if !strings.Contains(win, "downloadDisposition(") {
line := 1 + strings.Count(src[:j], "\n")
t.Errorf("%s:%d 的 Content-Disposition 没有走 downloadDisposition:%s",
e.Name(), line, strings.TrimSpace(strings.SplitN(win, "\n", 2)[0]))
}
i = j + 1
}
}
if scanned < 5 {
t.Fatalf("只扫了 %d 个源文件,守卫形同虚设", scanned)
}
if found < 3 {
t.Fatalf("只找到 %d 处 Content-Disposition(期望至少 3 处),守卫判据需要更新", found)
}
}
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"context"
"math"
"testing"
)
// D2 回归:留档序列化失败必须把错误上抛,让整个合并事务回滚。
// 以前是只 log 后 return nil —— 调用方以为写成功了,合并照常提交,
// 这一行的原始列就永久缺失(下次跨备份比对认不出 UUID,导出也还原不回去)。
func TestWriteRawRowFailsLoudlyOnMarshalError(t *testing.T) {
f := newLoginFixture(t)
ctx := context.Background()
var ds int64
if err := f.pg.QueryRowContext(ctx, `SELECT id FROM datasets ORDER BY id LIMIT 1`).Scan(&ds); err != nil {
t.Skipf("库里没有数据集:%v", err)
}
const pk = 987654321
t.Cleanup(func() {
ctx := context.Background()
for _, e := range []string{"ZVISIT", "ZLOCATION"} {
f.pg.ExecContext(ctx, `DELETE FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$3`, ds, e, pk)
}
})
// NaN 是 json.Marshal 明确拒绝的值,拿来触发真实的序列化失败
err := writeRawRow(ctx, f.pg, ds, "ZVISIT", pk, map[string]any{"ZBAD": math.NaN()})
if err == nil {
t.Fatal("序列化失败必须返回错误;返回 nil 等于让留档静默丢失")
}
var n int
if err := f.pg.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, ds, pk).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("失败时不应留下 entity_raw 行,实际 %d 行", n)
}
// 正常值必须写得进去(否则上面的断言可能只是「这张表根本写不动」)
if err := writeRawRow(ctx, f.pg, ds, "ZLOCATION", pk, map[string]any{"ZNAME": "回归测试"}); err != nil {
t.Fatalf("正常留档不应失败: %v", err)
}
var got string
if err := f.pg.QueryRowContext(ctx, `SELECT raw->>'ZNAME' FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, ds, pk).Scan(&got); err != nil {
t.Fatalf("读取刚写入的留档失败: %v", err)
}
if got != "回归测试" {
t.Errorf("留档内容对不上:%q", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
// deadlineRecorder 冒充 http.response:只有实现了 SetWriteDeadline,
// 才看得出 http.NewResponseController 有没有被中间件挡住。
type deadlineRecorder struct {
*httptest.ResponseRecorder
deadline time.Time
got bool
}
func (d *deadlineRecorder) SetWriteDeadline(t time.Time) error {
d.deadline, d.got = t, true
return nil
}
// P4 回归:gzipWriter 必须实现 Unwrap()。少了它,被 gzip 中间件包住的请求调
// http.NewResponseController(...).SetWriteDeadline/Flush 只会拿到 ErrNotSupported。
func TestGzipWriterUnwrapsToUnderlyingWriter(t *testing.T) {
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
gw := &gzipWriter{ResponseWriter: rec}
if err := http.NewResponseController(gw).SetWriteDeadline(time.Now().Add(time.Minute)); err != nil {
t.Fatalf("ResponseController 没能穿过 gzipWriter: %v", err)
}
if !rec.got {
t.Error("SetWriteDeadline 没有落到真正的 ResponseWriter 上")
}
}
// P4 回归:导出是「一次性大响应」,得带上 Content-Length(下载进度条、
// 也顺带锁住「不再依赖隐式 200」),并把写死线放宽到全站 120s 之外。
func TestExportSetsContentLengthAndExtendsWriteDeadline(t *testing.T) {
f := newMovementFixture(t)
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
req := httptest.NewRequest("GET", "/admin/export/rondbackup", nil).WithContext(f.ctx)
f.srv.exportRondbackup(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("导出返回 %d:%s", rec.Code, firstLine(rec.Body.String()))
}
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length=%q,实际写出 %d 字节", cl, rec.Body.Len())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type 不对: %q", ct)
}
if !rec.got {
t.Error("导出没有延长写死线(慢链路下会被全站 120s 截断)")
}
}
+24 -3
View File
@@ -31,11 +31,29 @@ type fillupHint struct {
var ( var (
fillupMu sync.Mutex fillupMu sync.Mutex
fillupCache = map[int64]fillupHint{} fillupCache = map[int64]fillupHint{}
fillupOrder []int64 // 键的插入顺序,超限时据此淘汰最旧的一批
) )
// fillupCacheMax 是内存提示缓存的条目上限。
//
// 个人站点的地点数是有限的,这里兜的是「站点长期运行、数据集换了一轮又一轮」时的无界增长:
// 光写不清的全局 map 会一路涨到把 1C1G 的机器吃干净。超限就丢掉最旧的四分之一,
// 让缓存自己收敛——比整表清空温和,不会一次性把所有高德建议都丢掉。
const fillupCacheMax = 2048
func fillupCachePut(placeID int64, h fillupHint) { func fillupCachePut(placeID int64, h fillupHint) {
fillupMu.Lock() fillupMu.Lock()
defer fillupMu.Unlock() 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 fillupCache[placeID] = h
} }
@@ -381,7 +399,10 @@ func (s *Server) adminFillupApply(w http.ResponseWriter, r *http.Request) {
actByName[name] = actID actByName[name] = actID
newActs++ 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 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 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)`, 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() return out, rows.Err()
} }
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool) { func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool, err error) {
ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork) err = ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
return return
} }
+51
View File
@@ -0,0 +1,51 @@
package web
import (
"strconv"
"testing"
)
// fillupCache 是全局的、只写不清的内存缓存:长期运行必须有上限,
// 否则站点换了几轮数据集之后 map 会一路涨下去,把 1C1G 的机器吃干净。
func TestFillupCacheIsBounded(t *testing.T) {
fillupMu.Lock()
savedCache, savedOrder := fillupCache, fillupOrder
fillupCache, fillupOrder = map[int64]fillupHint{}, nil
fillupMu.Unlock()
t.Cleanup(func() {
fillupMu.Lock()
fillupCache, fillupOrder = savedCache, savedOrder
fillupMu.Unlock()
})
total := fillupCacheMax + fillupCacheMax/2
for i := 0; i < total; i++ {
fillupCachePut(int64(i), fillupHint{Act: strconv.Itoa(i)})
}
fillupMu.Lock()
size, order := len(fillupCache), len(fillupOrder)
fillupMu.Unlock()
if size > fillupCacheMax {
t.Fatalf("缓存条目应被限制在 %d 以内,实际 %d(又变成无界增长了)", fillupCacheMax, size)
}
if order != size {
t.Fatalf("淘汰后 order 应与 cache 等长:order=%d cache=%d", order, size)
}
if _, ok := fillupHintOf(int64(total - 1)); !ok {
t.Error("最后写入的条目不该被淘汰")
}
if _, ok := fillupHintOf(0); ok {
t.Error("最旧的条目应当已被淘汰")
}
// 覆盖写同一个键不该让 order 重复记账(否则 order 会比 cache 长,淘汰时错删)。
before := order
fillupCachePut(int64(total-1), fillupHint{Act: "again"})
fillupMu.Lock()
after := len(fillupOrder)
fillupMu.Unlock()
if after != before {
t.Errorf("重复写同一个键不该增加 order 长度:%d -> %d", before, after)
}
}
+17 -8
View File
@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"rond-with-you/internal/fog" "rond-with-you/internal/fog"
@@ -26,22 +27,27 @@ const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB
// 迷雾是站点级数据(与公开页读数据不区分归属的约定一致), // 迷雾是站点级数据(与公开页读数据不区分归属的约定一致),
// 内存里只持一份索引,首次访问时从库里加载。 // 内存里只持一份索引,首次访问时从库里加载。
//
// 指针用 atomic 而不是普通变量:清空迷雾(fogResetIndex)会在请求 goroutine 里换掉它,
// 而瓦片/导出/统计同时在读 —— 直接赋值就是一个真的 data race。
var ( var (
fogOnce sync.Once fogOnce sync.Once
fogIndexV *fog.Index fogIndexV atomic.Pointer[fog.Index]
) )
func (s *Server) fogIndex() (*fog.Index, error) { func (s *Server) fogIndex() (*fog.Index, error) {
fogOnce.Do(func() { fogOnce.Do(func() {
fogIndexV = fog.NewIndex() ix := fog.NewIndex()
if err := fogIndexV.LoadAll(s.db); err != nil { // 加载完才发布:中途的实例不能被别的 goroutine 看到(这也是Once的意义)
fogIndexV = nil 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 nil, fmt.Errorf("迷雾数据加载失败")
} }
return fogIndexV, nil return ix, nil
} }
// handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是 // handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是
@@ -441,8 +447,11 @@ func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []str
// fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用), // fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用),
// 下次访问时按空库重建。 // 下次访问时按空库重建。
//
// 被清掉的那个索引对象可能还有请求正拿着用(Import/View/All 都走它自己的锁),
// 这里只换指针、不动旧对象,那些请求会安全地跑完。
func fogResetIndex() { func fogResetIndex() {
fogIndexV = fog.NewIndex() fogIndexV.Store(fog.NewIndex())
} }
// adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。 // adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。
@@ -483,7 +492,7 @@ func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) {
_, cells := ix.Stats() _, cells := ix.Stats()
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", 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.Header().Set("X-Fog-Cells", strconv.Itoa(cells))
w.Write(out) w.Write(out)
} }
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"sync"
"testing"
"rond-with-you/internal/fog"
)
// S1 回归:清空迷雾时换索引指针,而瓦片/导出/统计正在同时读它。
//
// ⚠️ 这条用例**必须带 -race 跑才有意义**:
//
// go test -race -count=1 -run TestFogIndexConcurrentResetAndRead ./internal/web/
//
// 不带 -race 时它只能证明「并发下不 panic、拿到的不是 nil」。
func TestFogIndexConcurrentResetAndRead(t *testing.T) {
f := newLoginFixture(t)
// 收尾:把索引恢复成「从库里加载」的状态,别把清空后的空索引留给后面的用例
t.Cleanup(func() {
if ix := fog.NewIndex(); ix.LoadAll(f.srv.db) == nil {
fogIndexV.Store(ix)
}
})
// 先触发一次初始加载,让后面的并发都落在「读已发布指针」这条路径上
if _, err := f.srv.fogIndex(); err != nil {
t.Fatalf("首次加载迷雾索引失败: %v", err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
ix, err := f.srv.fogIndex()
if err != nil || ix == nil {
t.Errorf("并发读取拿到了空索引: ix=%v err=%v", ix, err)
return
}
ix.Stats() // 真实读者会做的事(内部有自己的锁)
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
fogResetIndex() // 与 adminFogClear 同一个调用
}
}()
wg.Wait()
}
+200
View File
@@ -0,0 +1,200 @@
package web
import (
"os"
"strings"
"testing"
)
// ---------- F5:地图脚本里的 localStorage 访问必须走兜底 ----------
//
// 隐私模式 / 禁用 Cookie 的浏览器里 localStorage 读写都会抛异常,裸调用会把整段脚本打断
// (地图白屏、图层不出来,且控制台之外看不出来)。统一走 lsGet/lsSet。
func TestMapLocalStorageGoesThroughGuard(t *testing.T) {
b, err := staticFS.ReadFile("static/js/rond-map.js")
if err != nil {
t.Fatalf("读取 rond-map.js 失败: %v", err)
}
src := string(b)
if !strings.Contains(src, "function lsGet(k)") || !strings.Contains(src, "function lsSet(k, v)") {
t.Fatal("rond-map.js 里找不到 lsGet/lsSet 兜底(写法变了,这条守卫失效)")
}
if n := strings.Count(src, "window.localStorage."); n != 2 {
t.Errorf("裸 localStorage 访问应只剩 lsGet/lsSet 里那 2 处,实际 %d 处"+
"(其余一律要走 lsGet/lsSet,否则隐私模式下整段脚本会抛异常)", n)
}
}
// ---------- F6:配置未就绪时的第二次 preview 不能丢点 ----------
func TestMapPreviewQueuesPendingPoints(t *testing.T) {
b, err := staticFS.ReadFile("static/js/rond-map.js")
if err != nil {
t.Fatalf("读取 rond-map.js 失败: %v", err)
}
src := string(b)
body := funcBody(src, "preview: function (opts)")
if body == "" {
t.Fatal("在 rond-map.js 里找不到 preview(写法变了,这条守卫失效)")
}
queue := strings.Index(body, "previewQueue[opts.id] = points;")
live := strings.Index(body, "if (live) {")
if queue < 0 || live < 0 || queue > live {
t.Error("preview:必须在 `if (live)` 之前就把本次的点记进 previewQueue," +
"否则配置未就绪时第二次 preview 的点会被静默丢掉")
}
if !strings.Contains(body, "if (previewQueue[opts.id]) points = previewQueue[opts.id];") {
t.Error("preview:配置回调里没把 previewQueue 排队的点取回来画")
}
if strings.Contains(src, "previewPending") {
t.Error("rond-map.js 里不该再有 previewPending(已由 previewQueue 取代)")
}
}
// ---------- F8:EXIF 全 0 时间不能变成 1899-11-30 ----------
func TestExifRejectsOutOfRangeDate(t *testing.T) {
b, err := staticFS.ReadFile("static/js/photo-exif.js")
if err != nil {
t.Fatalf("读取 photo-exif.js 失败: %v", err)
}
src := string(b)
guard := strings.Index(src, "Y >= 1900")
mk := strings.Index(src, "res.shot = new Date(Y, Mo - 1, D, H, Mi, S);")
flag := strings.Index(src, "res.hasTime = true;")
if guard < 0 || mk < 0 || flag < 0 || guard > mk || guard > flag {
t.Error("photo-exif.js:拍摄时间必须先过范围校验再赋给 res.shot / hasTime" +
"(否则「0000:00:00」会被 Date 归一成 1899-11-30,被当成到达时间写进库)")
}
}
// ---------- F9:findExifItemID 读 count 前必须卡住宽度 ----------
func TestExifItemIDChecksBoundsBeforeRead(t *testing.T) {
b, err := staticFS.ReadFile("static/js/photo-exif.js")
if err != nil {
t.Fatalf("读取 photo-exif.js 失败: %v", err)
}
body := funcBody(string(b), "function findExifItemID(")
if body == "" {
t.Fatal("在 photo-exif.js 里找不到 findExifItemID(写法变了,这条守卫失效)")
}
bounds := strings.Index(body, "p + 2 > u8.length")
read := strings.Index(body, "dv.getUint16(p)")
if bounds < 0 || read < 0 || bounds > read {
t.Error("findExifItemID:读 count 之前必须按 ver 卡住宽度(p+2 / p+4)——" +
"末尾恰好相等(start+4 == length)时旧守卫会放行,越界会抛 RangeError")
}
}
// ---------- F16:数据编辑页每个筛选表单都要带上全部筛选条件 ----------
//
// 三个表单各自只放自己那几个控件,其余条件靠隐藏域带着;漏一个,用户一提交
// 就会把别的列表的筛选悄悄清掉(原来「到访」表单只带 vq+back)。
func TestEditSearchFormsCarryAllFilters(t *testing.T) {
b, err := tmplFS.ReadFile("templates/admin/edit.html")
if err != nil {
t.Fatalf("读取 edit.html 失败: %v", err)
}
src := string(b)
const open = `<form method="get" action="/admin/edit"`
params := []string{`name="q"`, `name="src"`, `name="sus"`, `name="vq"`,
`name="mt"`, `name="mp"`, `name="mfrom"`, `name="mto"`}
n := 0
rest := src
for {
i := strings.Index(rest, open)
if i < 0 {
break
}
n++
rest = rest[i:]
j := strings.Index(rest, "</form>")
if j < 0 {
t.Fatalf("第 %d 个筛选表单没有收尾", n)
}
body := rest[:j]
for _, p := range params {
if !strings.Contains(body, p) {
t.Errorf("第 %d 个筛选表单少了 %s:一提交就会把别的列表的筛选清掉", n, p)
}
}
rest = rest[j:]
}
if n < 3 {
t.Fatalf("只找到 %d 个 /admin/edit 筛选表单,预期至少 3 个(写法变了,这条守卫失效)", n)
}
}
// ---------- F17:`safe` 这个现成的转义出口不该留着 ----------
func TestNoSafeEscapeFunc(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "view.go"))
if err != nil {
t.Fatalf("读取 view.go 失败: %v", err)
}
if strings.Contains(string(b), `"safe":`) {
t.Error("view.go 里不该再有 `safe` 模板函数(模板一处都没用,却是个现成的 HTML 注入出口)")
}
}
// ---------- F18:纯图标按钮要有 aria-label,图片要有 alt ----------
func TestPlaceImageControlsAreLabelled(t *testing.T) {
b, err := tmplFS.ReadFile("templates/place.html")
if err != nil {
t.Fatalf("读取 place.html 失败: %v", err)
}
src := string(b)
if !strings.Contains(src, `aria-label="删除这张图片"`) {
t.Error("place.html 模板渲染的删除按钮(×)缺 aria-label")
}
if !strings.Contains(src, `setAttribute('aria-label', '删除这张图片')`) {
t.Error("place.html 用 JS 新建的删除按钮(×)缺 aria-label")
}
if strings.Contains(src, `img.alt = ''`) {
t.Error("place.html 新建缩略图时 alt 不能留空")
}
}
// ---------- F14:窄屏导航链接的内边距别再写死一处(会被后面的触控块盖掉) ----------
func TestNavLinksNarrowRuleHasNoDeadPadding(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
if err != nil {
t.Fatalf("读取 app.css 失败: %v", err)
}
src := string(b)
if !strings.Contains(src, ".nav-links a { font-size: 13px; }") {
t.Fatal("找不到窄屏的 `.nav-links a { font-size: 13px; }`(写法变了,这条守卫失效)")
}
if strings.Contains(src, ".nav-links a { padding:") {
t.Error("窄屏 .nav-links a 又写了 padding:同特异性的内边距由后面「窄屏触控」那块给," +
"写在这里会被盖掉(死代码)")
}
}
// ---------- F10:补全页的 sessionStorage 读写都要包在 try 里 ----------
//
// 隐私模式下 sessionStorage 本身就抛,脏数据又会让 JSON.parse 抛;任何一处漏了 try,
// 整段脚本就从那一行断掉——下拉候选、草稿恢复全部哑掉,而且控制台之外看不出来。
func TestFillupSessionStorageIsGuarded(t *testing.T) {
b, err := tmplFS.ReadFile("templates/admin/fillup.html")
if err != nil {
t.Fatalf("读取 fillup.html 失败: %v", err)
}
src := string(b)
needles := []string{"JSON.parse(sessionStorage", "sessionStorage.setItem"}
total := 0
for _, n := range needles {
rest := src
for {
i := strings.Index(rest, n)
if i < 0 {
break
}
total++
if open := strings.LastIndex(rest[:i], "try {"); open < 0 || i-open > 160 {
t.Errorf("fillup.html:`%s` 前面没有紧挨着的 try {(隐私模式 / 脏数据会中断整段脚本)", n)
}
rest = rest[i+len(n):]
}
}
if total == 0 {
t.Fatal("fillup.html 里找不到 sessionStorage 读写(写法变了,这条守卫失效)")
}
}
+177
View File
@@ -0,0 +1,177 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
// S8 回归:GPX 轨迹必须按属主圈定。
//
// gpx_tracks 带 user_id(为将来多用户留位),但两个入口都没用它:
// - `GET /api/gpx` 是**公开**接口,却把整张表端出去;
// - `POST /admin/gpx/delete` 的 id 来自客户端,只按 id 删就能删别人的。
//
// 单用户站点上影响不大,但这是「越权」而不是「影响小」——而且两处都只差一个 user_id 条件。
func TestGpxOwnershipScoping(t *testing.T) {
f := newMovementFixture(t)
ctx, pg := f.ctx, f.pg
var owner int64
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets WHERE id=$1`, f.dsID).Scan(&owner); err != nil {
t.Fatalf("读数据集属主失败: %v", err)
}
// 外来账号:只为了拥有一条不属于本站主的轨迹
foreign := fmt.Sprintf("e2e-gpx-other-%d", time.Now().UnixNano())
var foreignID int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,'x','gpx-other') RETURNING id`, foreign).Scan(&foreignID); err != nil {
t.Fatalf("建外来账号失败: %v", err)
}
mkTrack := func(uid int64, name string) int64 {
t.Helper()
var id int64
if err := pg.QueryRowContext(ctx, `INSERT INTO gpx_tracks
(user_id, name, point_count, length_km, points)
VALUES ($1,$2,1,1.0,'[[40.0,116.0]]'::jsonb) RETURNING id`, uid, name).Scan(&id); err != nil {
t.Fatalf("插入轨迹 %s 失败: %v", name, err)
}
return id
}
strayTrack := mkTrack(foreignID, "FOREIGN-"+foreign)
ownTrack := mkTrack(owner, "OWNER-"+foreign)
t.Cleanup(func() {
pg.ExecContext(ctx, `DELETE FROM gpx_tracks WHERE id IN ($1,$2)`, strayTrack, ownTrack)
pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, foreignID)
})
// 1) 公开接口不许端出别人的轨迹。
// 判据不依赖「哪份数据集恰好是活跃的」:只要求返回的每条都属于活跃属主,
// 而外来那条必须不在其中——后者才是安全不变量。
var activeOwner int64
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&activeOwner); err != nil {
t.Fatalf("读活跃数据集属主失败: %v", err)
}
rec := httptest.NewRecorder()
f.srv.apiGpx(rec, httptest.NewRequest("GET", "/api/gpx", nil))
if rec.Code != 200 {
t.Fatalf("apiGpx 返回 %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "FOREIGN-") {
t.Errorf("公开接口把别的账号的轨迹也端出来了:%s", truncate(body, 300))
}
if !strings.Contains(body, "OWNER-") {
t.Fatalf("活跃数据集属主(user_id=%d)自己的轨迹没返回,用例失去意义:%s",
activeOwner, truncate(body, 300))
}
// 2) 删除别人账号的轨迹必须被拒,而且不能真删掉。
victim := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/gpx/delete",
strings.NewReader(url.Values{"id": {fmt.Sprint(strayTrack)}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminGpxDelete(victim, req.WithContext(f.ctx))
if msg := flashMsg(t, victim); !strings.Contains(msg, "不存在") {
t.Errorf("删别人的轨迹应当回「不存在」,实际 %q", msg)
}
var still int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, strayTrack).Scan(&still); err != nil {
t.Fatal(err)
}
if still != 1 {
t.Errorf("别人的轨迹被删掉了(id=%d)——越权删除", strayTrack)
}
// 3) 删自己的要能删掉(别为堵越权把正常路径也堵死了)。
okRec := httptest.NewRecorder()
req = httptest.NewRequest("POST", "/admin/gpx/delete",
strings.NewReader(url.Values{"id": {fmt.Sprint(ownTrack)}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminGpxDelete(okRec, req.WithContext(f.ctx))
if msg := flashMsg(t, okRec); !strings.Contains(msg, "已删除") {
t.Errorf("删自己的轨迹应当成功,实际 %q", msg)
}
var gone int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, ownTrack).Scan(&gone); err != nil {
t.Fatal(err)
}
if gone != 0 {
t.Errorf("自己的轨迹没被删掉(id=%d)", ownTrack)
}
}
// truncate 只用于把失败信息压短,超长时截断。
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// sqlText 取出函数体里所有反引号包的 SQL 片段。
//
// 守卫必须只看 SQL:函数里的**中文注释**也会出现「user_id」这个词,
// 直接 strings.Contains(body, "user_id") 会被注释顶包——变异掉真条件照样绿。
func sqlText(body string) string {
var b strings.Builder
for i := 0; ; {
a := strings.IndexByte(body[i:], '`')
if a < 0 {
break
}
a += i
z := strings.IndexByte(body[a+1:], '`')
if z < 0 {
break
}
z += a + 1
b.WriteString(body[a+1 : z])
b.WriteByte('\n')
i = z + 1
}
return b.String()
}
// S8 静态守卫:两个入口的 SQL 都必须带 user_id 条件。
// 行为用例要导一份备份包才能跑(约 6 秒);这条永远不 skip,防止「改回去」时静默回归。
func TestGpxQueriesCarryUserID(t *testing.T) {
for _, c := range []struct {
file string
sig string
want []string
}{
{"handlers_public.go", "func (s *Server) apiGpx(", []string{"user_id"}},
{"handlers_admin.go", "func (s *Server) adminGpxDelete(", []string{"user_id"}},
} {
data, err := os.ReadFile(pkgFile(t, c.file))
if err != nil {
t.Fatalf("读 %s 失败: %v", c.file, err)
}
body := sliceFunc(t, string(data), c.sig)
q := sqlText(body)
if strings.TrimSpace(q) == "" {
t.Fatalf("%s 的 %s 里没找到 SQL 字符串,守卫的切片判据需要更新", c.file, c.sig)
}
for _, w := range c.want {
if !strings.Contains(q, w) {
t.Errorf("%s 的 %s 的 SQL 里没有 %q:轨迹没按属主圈定", c.file, c.sig, w)
}
}
}
// 删不到时的反馈也要有断言:不然「删别人的」会回一句「已删除」。
data, err := os.ReadFile(pkgFile(t, "handlers_admin.go"))
if err != nil {
t.Fatal(err)
}
del := sliceFunc(t, string(data), "func (s *Server) adminGpxDelete(")
if !strings.Contains(del, "RowsAffected") {
t.Error("adminGpxDelete 没看受影响行数:删不到也会回「已删除」")
}
}
+5
View File
@@ -88,6 +88,11 @@ func (g *gzipWriter) Flush() {
} }
} }
// Unwrap 让 http.NewResponseController 能穿过这层包装拿到真正的 ResponseWriter
// (SetWriteDeadline / Flush / Hijack 都靠它逐层解包)。少了这个方法,
// 被包装的请求调 SetWriteDeadline 只会拿到 ErrNotSupported。
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
func (g *gzipWriter) close() { func (g *gzipWriter) close() {
if g.gz != nil { if g.gz != nil {
g.gz.Close() g.gz.Close()
+78 -26
View File
@@ -62,6 +62,15 @@ func (s *Server) checkCaptcha(r *http.Request) bool {
return s.captcha.Verify(c.Value, r.PostFormValue("captcha")) 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) { func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
sets, err := s.sets.Load(r.Context()) sets, err := s.sets.Load(r.Context())
if err != nil { if err != nil {
@@ -99,20 +108,33 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
return 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) u, err := s.auth.Authenticate(ctx, username, password)
if err != nil { if err != nil {
sets, _ := s.sets.Load(ctx) sets, _ := s.sets.Load(ctx)
code := http.StatusOK code := http.StatusOK
if !errors.Is(err, auth.ErrInvalidCredentials) {
code = http.StatusInternalServerError
}
msg := "用户名或密码错误" msg := "用户名或密码错误"
if code == http.StatusInternalServerError { if !errors.Is(err, auth.ErrInvalidCredentials) {
msg = "登录失败:" + err.Error() // 登录页是公开的,内部错误只进日志,不回显给门外的人
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}) s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
return return
} }
s.loginGuard.Succeed(username)
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页 // 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled { if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled {
sets, _ := s.sets.Load(ctx) sets, _ := s.sets.Load(ctx)
@@ -121,9 +143,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
s.login(w, u.ID, u.Hash) s.login(w, r, u.ID, u.Hash)
target := "/admin" target := "/admin"
if strings.HasPrefix(next, "/admin") { // 同样先过站内路径判据(挡控制字符/引号),再限定必须落在 /admin 下
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next target = next
} }
http.Redirect(w, r, target, http.StatusSeeOther) 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))) 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) out, err := os.Create(stored)
if err != nil { if err != nil {
s.serverError(w, r, err) 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,越导越失真。 // 否则 rond 原生的标志位(ZPINSTYLE_/ZRADIUS/ZVISIT.ZRAW…)会被洗成 NULL,越导越失真。
// 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。 // 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。
if !st.Identical { if !st.Identical {
keepStored = true
if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil { if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil {
log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err) log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err)
} }
@@ -866,19 +899,15 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
var msg string var msg string
switch action { switch action {
case "delete": case "delete":
for _, id := range ids { if err := s.notes.DeleteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
if err := s.notes.Delete(r.Context(), u.ID, ds.ID, int(id)); err != nil { s.serverError(w, r, err)
s.serverError(w, r, err) return
return
}
} }
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids)) msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
case "clear": case "clear":
for _, id := range ids { if err := s.notes.SetNoteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
if err := s.notes.SetNote(r.Context(), u.ID, ds.ID, int(id), ""); err != nil { s.serverError(w, r, err)
s.serverError(w, r, err) return
return
}
} }
msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids)) msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids))
case "verdict": case "verdict":
@@ -886,11 +915,9 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid { if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0 verdict = 0
} }
for _, id := range ids { if err := s.votes.SaveVerdictBulk(r.Context(), u.ID, ds.ID, ids, verdict); err != nil {
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, int(id), verdict); err != nil { s.serverError(w, r, err)
s.serverError(w, r, err) return
return
}
} }
if verdict == 0 { if verdict == 0 {
msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids)) msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids))
@@ -941,7 +968,7 @@ func (s *Server) adminPlaceNote(w http.ResponseWriter, r *http.Request) {
} }
// 备注管理页的行内表单带 next,保存后回到原处(保留筛选); // 备注管理页的行内表单带 next,保存后回到原处(保留筛选);
// 没有 next 时按 Referer 判断,都没有就回后台首页。 // 没有 next 时按 Referer 判断,都没有就回后台首页。
if next := r.PostFormValue("next"); strings.HasPrefix(next, "/") { if next := safeInternalPath(r.PostFormValue("next")); next != "" {
sep := "?" sep := "?"
if strings.Contains(next, "?") { if strings.Contains(next, "?") {
sep = "&" sep = "&"
@@ -1000,13 +1027,31 @@ func (s *Server) login2FA(w http.ResponseWriter, r *http.Request) {
fail("两步验证状态异常,请重新登录") fail("两步验证状态异常,请重新登录")
return 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 !auth.VerifyTOTP(secret, code) {
if wait := s.loginGuard.Fail(guardKey); wait > 0 {
fail(tooManyTries(wait))
return
}
fail("验证码不正确") fail("验证码不正确")
return 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" target := "/admin"
if strings.HasPrefix(next, "/admin") { if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
target = next target = next
} }
http.Redirect(w, r, target, http.StatusSeeOther) 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) { func (s *Server) adminGpxDelete(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
if err != nil { if err != nil {
s.adminFlash(w, r, "err", "轨迹编号无效") s.adminFlash(w, r, "err", "轨迹编号无效")
return 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) s.serverError(w, r, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 {
s.adminFlash(w, r, "err", "轨迹不存在")
return
}
s.adminFlash(w, r, "ok", "轨迹已删除") s.adminFlash(w, r, "ok", "轨迹已删除")
} }
+261 -150
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"rond-with-you/internal/geo" "rond-with-you/internal/geo"
"rond-with-you/internal/rond"
) )
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ---------- // ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 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.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END, 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(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 ( 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 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 等)。 // 就会复用同一个 src_pk,于是新行继承旧留档的 rond 私有字段(ZRADIUS/ZRAW 等)。
// skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。 // skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。
// 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。 // 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。
func (s *Server) pruneEntityRaw(ctx context.Context, datasetID int64) { // 收 ex(*sql.DB 或事务)是为了让调用方把它和「删实体」放进同一个事务——
if _, err := s.db.ExecContext(ctx, `DELETE FROM entity_raw r // 分两次提交的话,中间失败会留下「实体没了、留档还在」的错位。
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 ( 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)) (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)) 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": "请选择要删除的活动"}) s.json(w, map[string]any{"error": "请选择要删除的活动"})
return 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) s.serverError(w, r, err)
return return
} }
res, err := s.db.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID) if notFound {
if err != nil {
s.serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
s.json(w, map[string]any{"error": "活动不存在"}) s.json(w, map[string]any{"error": "活动不存在"})
return return
} }
s.pruneEntityRaw(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.refreshDatasetAgg(ctx, ds.ID)
s.json(w, map[string]any{"ok": true}) 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("活动不存在") return errors.New("活动不存在")
} }
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走 // 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 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) WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
if err != nil { 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()}) s.json(w, map[string]any{"error": err.Error()})
return return
} }
s.pruneEntityRaw(ctx, ds.ID) s.pruneEntityRaw(ctx, s.db, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"ok": true, "visits": moved}) 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) s.serverError(w, r, err)
return return
} }
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
redirectEdit(w, r, "ok", "已为所选地点新增到访") redirectEdit(w, r, "ok", "已为所选地点新增到访")
return return
} }
@@ -975,7 +996,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
} }
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
if hasVisit { if hasVisit {
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc)) redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc))
} else { } else {
@@ -1077,7 +1098,7 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
} }
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次 // 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
if actID != nil { if actID != nil {
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
} }
msg := "地点已更新" msg := "地点已更新"
@@ -1228,27 +1249,31 @@ func (s *Server) editPlaceDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "地点标识无效") redirectEdit(w, r, "err", "地点标识无效")
return return
} }
// 该地点名下到访的 src_pk 集合(删地点会级联删掉这些到访) // 三条写语句必须同一个事务:删到一半失败会留下「地点还在、它的行程没了」,
// 或者更糟——地点没了但行程还指着已经不存在的到访(rond 里的悬空引用就是这么来的)。
const visitSrcs = `SELECT src_pk FROM visits WHERE dataset_id=$1 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)` AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访 if err := s.withTx(ctx, func(ex execer) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 // 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil { 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) s.serverError(w, r, err)
return return
} }
// 天气本身有独立价值,只解除关联,不连坐删除 logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
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)
redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除") redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除")
} }
@@ -1289,7 +1314,7 @@ func (s *Server) editActivityCreate(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
} }
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
s.json(w, map[string]any{"id": id, "name": name}) 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")) actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
var oldPlace int64 var oldPlace int64
var curArrival time.Time notFound := false
var curDeparture sql.NullTime // 改到访、夹回引用它的行程、重写标签、刷聚合必须同一个事务:夹行程那步失败会留下
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits // 「到访时间已越界、行程还挂在旧窗口上」的坏数据,导出到 rond 里就是非法区间。
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil { 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", "到访记录不存在") redirectEdit(w, r, "err", "到访记录不存在")
return 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", "到访已更新") redirectEdit(w, r, "ok", "到访已更新")
} }
@@ -1369,23 +1411,25 @@ func (s *Server) adminVisitBookmark(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "到访标识无效") redirectEdit(w, r, "err", "到访标识无效")
return return
} }
res, err := s.db.ExecContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked // 一条语句搞定「翻转并回读」:以前是 UPDATE 之后再单独 SELECT,那次回读一旦失败
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src) // 就落进 else 报「已取消收藏」——而它有一半是错的(状态根本不知道)。
if err != nil { // 顺带:翻转与回读在同一条语句里,中间也不再有人插进来的窗口。
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) s.serverError(w, r, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { msg := "已取消收藏"
redirectEdit(w, r, "err", "到访记录不存在") if on {
return msg = "已标记为收藏"
} }
var on bool redirectEdit(w, r, "ok", msg)
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", "已取消收藏")
} }
func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -1404,28 +1448,42 @@ func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
var placeID int64 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", "到访记录不存在") redirectEdit(w, r, "err", "到访记录不存在")
return 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", "到访已删除") redirectEdit(w, r, "ok", "到访已删除")
} }
@@ -1813,40 +1871,54 @@ func (s *Server) editMovementUpdate(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效") redirectEdit(w, r, "err", "行程标识无效")
return return
} }
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, pk) var tr movementTransport
if !ok { var failMsg string
redirectEdit(w, r, "err", msg) done := false
return // 校验(读起点/终点到访)与两处写入必须同一事务,否则并发改动会让派生列与两端锚点对不上。
} if err := s.withTx(ctx, func(ex execer) error {
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID, src, ok, msg := s.validateMovementForm(ctx, ex, ds.ID, r, pk)
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"), if !ok {
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon")) failMsg = msg
if msg != "" { return nil
redirectEdit(w, r, "err", msg) }
return var tmsg string
} tr, tmsg = s.resolveMovementTransport(ctx, ex, ds.ID,
var trSrc, trName, trColor, trIcon any r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
if tr.Src > 0 { r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon) if tmsg != "" {
} failMsg = tmsg
res, err := s.db.ExecContext(ctx, `UPDATE movements SET return nil
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, var trSrc, trName, trColor, trIcon any
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14 if tr.Src > 0 {
WHERE dataset_id=$1 AND src_pk=$2`, trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
ds.ID, pk, trSrc, trName, trColor, trIcon, }
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace), res, err := ex.ExecContext(ctx, `UPDATE movements SET
src.Start, src.End, src.DurationMin, src.DistanceKm) transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
if err != nil { 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) s.serverError(w, r, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if !done {
redirectEdit(w, r, "err", "行程不存在") redirectEdit(w, r, "err", failMsg)
return
}
if err := syncTransport(ctx, s.db, ds.ID, tr); err != nil {
s.serverError(w, r, err)
return return
} }
redirectEdit(w, r, "ok", "行程已更新") redirectEdit(w, r, "ok", "行程已更新")
@@ -1870,16 +1942,28 @@ func (s *Server) editMovementDelete(w http.ResponseWriter, r *http.Request) {
redirectEdit(w, r, "err", "行程标识无效") redirectEdit(w, r, "err", "行程标识无效")
return return
} }
res, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk) notFound := false
if err != nil { // 删行程与清留档必须同一事务:删除成功但清留档失败会留下「号已空、留档还在」的酸数据,
// 下一次新建复用该 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) s.serverError(w, r, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if notFound {
redirectEdit(w, r, "err", "行程不存在") redirectEdit(w, r, "err", "行程不存在")
return return
} }
s.pruneEntityRaw(ctx, ds.ID)
redirectEdit(w, r, "ok", "行程已删除") redirectEdit(w, r, "ok", "行程已删除")
} }
@@ -2138,43 +2222,55 @@ func (s *Server) insertVisit(ctx context.Context, ex execer, datasetID, userID,
bookmarked, remark, emoji); err != nil { bookmarked, remark, emoji); err != nil {
return 0, err return 0, err
} }
s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags) if err := s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags); err != nil {
s.refreshPlaceAgg(ctx, ex, placeID) return 0, err
}
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
return 0, err
}
return nextSrc, nil return nextSrc, nil
} }
// saveVisitTags 重写到访的标签关联。 // 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 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 { 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 { for _, t := range tagIDs {
id, err := strconv.ParseInt(t, 10, 64) id, err := strconv.ParseInt(t, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
continue continue
} }
ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id) 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) 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 重算某地点的到访聚合(次数 / 停留 / 首末时间)。 // refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) { func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
ex.ExecContext(ctx, `UPDATE places p SET if _, err := ex.ExecContext(ctx, `UPDATE places p SET
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0), visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
first_visit_at = v.f, last_visit_at = v.l first_visit_at = v.f, last_visit_at = v.l
FROM (SELECT $1::bigint AS pid) x 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 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 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 用)。 // refreshDatasetAgg 重算数据集的到访数 / 地点数 / 时间跨度(页脚「数据截至」与后台 flash 用)。
// 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。 // 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。
func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) { func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error {
s.db.ExecContext(ctx, `UPDATE datasets d SET 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, visit_count = COALESCE(v.c,0), first_visit_at = v.f, last_visit_at = v.l,
place_count = COALESCE(p.c,0) place_count = COALESCE(p.c,0)
FROM (SELECT $1::bigint AS did) x 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 FROM visits WHERE dataset_id=$1 GROUP BY dataset_id) v ON v.dataset_id = x.did
LEFT JOIN (SELECT dataset_id, count(*) c LEFT JOIN (SELECT dataset_id, count(*) c
FROM places WHERE dataset_id=$1 GROUP BY dataset_id) p ON p.dataset_id = x.did 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) { func parseFloat(v string) (float64, bool) {
@@ -2313,10 +2422,12 @@ func (s *Server) apiCoords(w http.ResponseWriter, r *http.Request) {
// 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标 // 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标
// (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。 // (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。
// 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。 // 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。
const suspectWGS = `(r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$' //
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$' // 两边的取值都走 RawFloat8:留档值不是数字时给 NULL,比较结果也是 NULL = 不命中,
AND abs((r.raw->>'ZRAWLATITUDE')::float8 - COALESCE(p.lat,0)) < 1e-9 // 不会像以前那样因为 `::float8` 把整页 500 掉(旧版那条正则 '^-?[0-9.]+$' 连
AND abs((r.raw->>'ZRAWLONGITUDE')::float8 - COALESCE(p.lon,0)) < 1e-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` AND p.lat BETWEEN 3.86 AND 53.55 AND p.lon BETWEEN 73.66 AND 135.05`
// 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移), // 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移),
+107 -9
View File
@@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -82,7 +84,7 @@ func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
} }
b.WriteString(`]}`) b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8") 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())) w.Write([]byte(b.String()))
} }
@@ -120,10 +122,74 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
b.WriteString("</gpx>\n") b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8") w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition", 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())) 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 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本, // baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史, // 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。 // 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
@@ -150,7 +216,8 @@ func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, e
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。 // findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) { func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string 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 return "", err
} }
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*")) 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()) 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 { 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 continue
} }
got, _, err := ingest.File(f) if sha != "" && size > 0 && fi.Size() != size {
continue
}
got, err := hash(f)
if err != nil { if err != nil {
continue continue
} }
if sha == "" || got == sha { if sha == "" || got == sha {
return f, nil return f
} }
} }
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup") return ""
} }
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。 // 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-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102"))) downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
w.Write(buf.Bytes()) w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
}
w.WriteHeader(http.StatusOK)
if _, err := buf.WriteTo(w); err != nil {
log.Printf("导出:下载写出中断: %v", err)
}
} }
+3
View File
@@ -89,6 +89,9 @@ func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"}) s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return return
} }
// 必须先卡请求体:ParseMultipartForm 只限制「内存里留多少」,超出的部分会
// 无上限地落到临时文件,光靠后面 LimitReader 拦不住磁盘被写满。
r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)
if err := r.ParseMultipartForm(imageUploadMem); err != nil { if err := r.ParseMultipartForm(imageUploadMem); err != nil {
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"}) s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
return return
+1 -1
View File
@@ -233,7 +233,7 @@ func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
imported++ imported++
} }
s.refreshDatasetAgg(ctx, ds.ID) logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported) msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
if withImage > 0 { if withImage > 0 {
msg += fmt.Sprintf(",配了 %d 张图", withImage) msg += fmt.Sprintf(",配了 %d 张图", withImage)
+25 -7
View File
@@ -97,8 +97,9 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
p.FilterBase = r.URL.Path p.FilterBase = r.URL.Path
p.Presets = buildPresets(p.From, p.To) p.Presets = buildPresets(p.From, p.To)
// 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用), // 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用),
// BackQS 是把自己挂给别人的(列表页生成详情链接用)。 // BackQS 是把自己挂给别人的(列表页生成详情链接用),SelfPath 是它的未编码原文。
p.Back = backFromRequest(r) p.Back = backFromRequest(r)
p.SelfPath = selfPath(r)
p.BackQS = backQS(r) p.BackQS = backQS(r)
return p, nil 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 { for _, m := range d.Monthly {
if m.Count > d.MaxMonthly { if m.Count > d.MaxMonthly {
d.MaxMonthly = m.Count d.MaxMonthly = m.Count
@@ -1101,10 +1098,31 @@ type GPXTrack struct {
// apiGpx 返回已导入的 GPX 轨迹。库里存的是 WGS-84(GPX 标准), // apiGpx 返回已导入的 GPX 轨迹。库里存的是 WGS-84(GPX 标准),
// 底图是 GCJ-02 时按点转成火星坐标,保证和足迹点对齐。 // 底图是 GCJ-02 时按点转成火星坐标,保证和足迹点对齐。
//
// 轨迹按**当前生效数据集的属主**圈定,不是把整张表端出去:gpx_tracks 带 user_id,
// 这是公开接口、不带登录态,不按属主过滤就等于把别人的轨迹一起露给访客。
// 没有生效数据集时返回空集——和页面其余部分「没数据就没有」的口径一致。
func (s *Server) apiGpx(w http.ResponseWriter, r *http.Request) { 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") crs := r.URL.Query().Get("crs")
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, points, length_km, started_at rows, err := s.db.QueryContext(ctx, `SELECT id, name, points, length_km, started_at
FROM gpx_tracks ORDER BY started_at DESC NULLS LAST, id DESC`) FROM gpx_tracks WHERE user_id=$1 ORDER BY started_at DESC NULLS LAST, id DESC`, owner)
if err != nil { if err != nil {
s.json(w, map[string]string{"error": err.Error()}) s.json(w, map[string]string{"error": err.Error()})
return return
+63 -20
View File
@@ -94,17 +94,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
power(sin(radians((%s - p.lat) / 2)), 2) + power(sin(radians((%s - p.lat) / 2)), 2) +
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 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 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。 // topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64, func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
regions []string, exclude bool) ([]MapPoint, int, int, int, error) { regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
args := []any{datasetID, t.StartAt, t.EndAt} args := []any{datasetID, t.StartAt, t.EndAt}
area := "" area := topicAreaCond(t, &args)
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 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上 // 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件, // 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
// 不能把这份(已被追加过的)args 整个传进去。 // 不能把这份(已被追加过的)args 整个传进去。
@@ -168,18 +174,54 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
return points, totalVisits, days, len(cities), nil return points, totalVisits, days, len(cities), nil
} }
// fillTopicStats 给专题填上内容统计(列表页展示用)。 // fillTopicStatsBatch 一次算完所有专题的内容统计。
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) // 列表页原本每个专题跑 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 { if err != nil {
// 以前这里直接 return:专题列表会静默显示成「0 个地点」,看不出是出错了。 return err
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
return
} }
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities defer rows.Close()
for _, p := range points { for rows.Next() {
t.DwellMin += p.Dwell 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, // 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) s.serverError(w, r, err)
return 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 d.Topics = list
// 编辑模式:?edit=<id> // 编辑模式:?edit=<id>
@@ -372,8 +415,8 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
return return
} }
regions, exclude := visitorRegions(p) regions, exclude := visitorRegions(p)
for i := range list { if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil {
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude) log.Printf("专题统计失败: %v", err)
} }
d.Topics = list d.Topics = list
} }
+46 -10
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"net" "net"
"net/http" "net/http"
"net/netip"
"strconv" "strconv"
"strings" "strings"
@@ -17,18 +18,53 @@ import (
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。 // voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
const voterCookie = "rond_voter" 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 { func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if ip, ok := lastForwardedIP(r.Header.Values("X-Forwarded-For")); ok {
if i := strings.IndexByte(xff, ','); i >= 0 { return ip
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
} }
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 host
} }
return r.RemoteAddr return remoteAddr
} }
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。 // voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
@@ -238,8 +274,8 @@ func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
} }
next := r.PostFormValue("next") next := safeInternalPath(r.PostFormValue("next"))
if !strings.HasPrefix(next, "/") { if next == "" {
next = "/admin" next = "/admin"
} }
http.Redirect(w, r, next, http.StatusSeeOther) http.Redirect(w, r, next, http.StatusSeeOther)
+170
View File
@@ -0,0 +1,170 @@
package web
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
)
// loginFixture 是一个只有账号、没有业务数据的测试库,专测登录这条路径。
//
// ROND_TEST_CONF=conf/app-test.ini go test ./internal/web -run Login -v
//
// 不需要 ROND_SRC:登录用不到备份包,省掉那次几十兆的导入。
type loginFixture struct {
pg *sql.DB
srv *Server
uid int64
user string
pass string
}
func newLoginFixture(t *testing.T) *loginFixture {
t.Helper()
confPath := os.Getenv("ROND_TEST_CONF")
if confPath == "" {
t.Skip("设置 ROND_TEST_CONF 后运行登录校验")
}
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
user := fmt.Sprintf("e2e-login-%d", time.Now().UnixNano())
pass := "e2e-correct-horse"
hash, err := auth.HashPassword(pass)
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
t.Cleanup(func() { pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &loginFixture{pg: pg, srv: srv, uid: uid, user: user, pass: pass}
}
// attempt 走一次真实的登录提交:每次配一张新验证码(验证码是一次性的,
// 不复用才能保证测到的是「口令这一环」,而不是被验证码拦下)。
func (f *loginFixture) attempt(t *testing.T, username, password string) *httptest.ResponseRecorder {
t.Helper()
id, _, err := f.srv.captcha.Issue()
if err != nil {
t.Fatalf("签发验证码失败: %v", err)
}
code, ok := f.srv.captcha.Answer(id)
if !ok {
t.Fatal("刚签发的验证码取不到答案")
}
form := url.Values{"username": {username}, "password": {password}, "captcha": {code}}
r := httptest.NewRequest("POST", "/admin/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: captchaCookie, Value: id})
w := httptest.NewRecorder()
f.srv.loginSubmit(w, r)
return w
}
// TestLoginSubmitLocksAfterRepeatedFailures 证明登录失败会按用户名上锁:
// 连错到阈值后,**即使口令是对的**也进不去,直到冷却结束。
//
// 这条链路以前完全没有限速——验证码是唯一的阻力,而验证码本身可以无限重取。
func TestLoginSubmitLocksAfterRepeatedFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
// 阈值内的失败仍是普通的「用户名或密码错误」(200 重新渲染表单)
for i := 1; i < 5; i++ {
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusOK {
t.Fatalf("第 %d 次失败的状态码应为 200,实际 %d", i, w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "用户名或密码错误") {
t.Fatalf("第 %d 次失败应提示口令错误,实际: %s", i, firstLine(body))
}
}
t.Logf("前 4 次失败:普通提示、未上锁")
// 第 5 次失败触发锁定
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusTooManyRequests {
t.Fatalf("触发阈值后应返回 429,实际 %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "过于频繁") {
t.Errorf("应提示尝试过于频繁,实际: %s", firstLine(body))
}
// 锁定期间**正确口令**也进不去——这正是限速的意义
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("锁定期间即使口令正确也应 429,实际 %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "" {
t.Errorf("锁定期间不该签发会话(不该重定向),实际 Location=%s", loc)
}
// 冷却结束后正常登录
f.srv.loginGuard.Reset()
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303 跳转,实际 %d (%s)", w.Code, firstLine(w.Body.String()))
}
if loc := w.Header().Get("Location"); loc != "/admin" {
t.Errorf("应跳到 /admin,实际 %s", loc)
}
if c := w.Result().Cookies(); len(c) == 0 {
t.Error("登录成功没有下发会话 cookie")
}
}
// TestLoginSubmitSuccessClearsFailures 登录成功要把失败计数清零,
// 否则「偶发手滑几次 + 一次成功」会一直攒着,迟早把站长自己挡在门外。
func TestLoginSubmitSuccessClearsFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
for i := 0; i < 4; i++ {
f.attempt(t, f.user, "wrong-password")
}
if d := f.srv.loginGuard.RetryAfter(f.user); d != 0 {
t.Fatalf("4 次失败不该上锁,实际还要等 %v", d)
}
if w := f.attempt(t, f.user, f.pass); w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303,实际 %d", w.Code)
}
// 成功后计数清零:再连错 4 次仍然不该锁
for i := 0; i < 4; i++ {
if w := f.attempt(t, f.user, "wrong-password"); w.Code != http.StatusOK {
t.Fatalf("成功清零后第 %d 次失败不该被锁,实际 %d", i+1, w.Code)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package web
import (
"io/fs"
"strings"
"testing"
)
// funcBody 从 marker 起按大括号配平取出一段函数体(含 marker)。
// 这几段代码里的字符串字面量都不含大括号,直接数括号是安全的。
func funcBody(src, marker string) string {
i := strings.Index(src, marker)
if i < 0 {
return ""
}
j := strings.Index(src[i:], "{")
if j < 0 {
return ""
}
depth := 0
for k := i + j; k < len(src); k++ {
switch src[k] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[i : k+1]
}
}
}
return ""
}
// TestMapLayersDropStaleResponses 守住「开关关着图层还在画」这个竞态。
//
// 勾选 → 请求发出 → 用户立刻取消 → 请求回包。回包若直接 addTo(map),图层就画上去了。
// 修法是给每次调用发一个序号,回包时对不上就作废;关键是**序号必须在 `if (!on) return;`
// 之前就递增**,否则取消时压根不会作废在途的那次请求。
//
// 判据放在源码文本上:这是「两个异步分支的相对顺序」,用 CDP 造这个时序既慢又脆。
func TestMapLayersDropStaleResponses(t *testing.T) {
js, err := staticFS.ReadFile("static/js/rond-map.js")
if err != nil {
t.Fatalf("读取 rond-map.js 失败: %v", err)
}
src := string(js)
for _, c := range []struct{ setter, seq, draw string }{
{"window.rondMap.setGpx = function (on)", "gpxSeq", "addTo(map)"},
{"window.rondMap.setHeat = function (on)", "heatSeq", "addTo(map)"},
{"window.rondMap.setCityAgg = function (on)", "citySeq", "addTo(map)"},
} {
body := funcBody(src, c.setter)
if body == "" {
t.Fatalf("在 rond-map.js 里找不到 %s 的函数体(写法变了,这条守卫就失效了)", c.setter)
}
inc := strings.Index(body, "++"+c.seq)
off := strings.Index(body, "if (!on) return;")
guard := strings.Index(body, "seq !== "+c.seq)
draw := strings.Index(body, c.draw)
if inc < 0 || off < 0 || inc > off {
t.Errorf("%s:必须在 `if (!on) return;` 之前先递增序号(否则取消时作废不了在途回包)", c.setter)
}
if guard < 0 || draw < 0 || guard > draw {
t.Errorf("%s:回包在 %s 之前没有做 `seq !== %s` 作废检查", c.setter, c.draw, c.seq)
}
}
// 行程轨迹开关在模板里(map.html),症状一样:取消勾选后旧回包把轨迹画回来。
html, err := fs.ReadFile(tmplFS, "templates/map.html")
if err != nil {
t.Fatalf("读取 map.html 失败: %v", err)
}
body := funcBody(string(html), "function refreshTrack()")
if body == "" {
t.Fatal("在 map.html 里找不到 refreshTrack(写法变了,这条守卫就失效了)")
}
inc := strings.Index(body, "++trackSeq")
off := strings.Index(body, "if (!trackOn)")
guard := strings.Index(body, "seq !== trackSeq")
draw := strings.Index(body, "setTrack(d.segs")
if inc < 0 || off < 0 || inc > off {
t.Error("refreshTrack:必须在 `if (!trackOn)` 之前先递增序号")
}
if guard < 0 || draw < 0 || guard > draw {
t.Error("refreshTrack:回包在 setTrack(d.segs…) 之前没有做 `seq !== trackSeq` 作废检查")
}
}
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"strings"
"testing"
)
// mapHTMLSinks 是「可编辑字段被裸拼进 HTML」的具体写法。这些值来自地点名、交通方式名、
// 城市名、GPX 轨迹名——站长自己可编辑,但访客页也会一起渲染。
//
// Leaflet 的 bindTooltip / bindPopup 把字符串参数按 **HTML** 解析,innerHTML 同理,
// 所以少一层 esc() 就是一次注入。同文件里的 popup() 早就用 esc() 了,
// 这四处漏掉纯属不一致(2026-09-21 审计 P1-4)。
var mapHTMLSinks = []string{
"bindTooltip(p.n", // 地点名
"bindTooltip((s.m", // 交通方式名(轨迹 tooltip)
"bindTooltip(t.name", // GPX 轨迹名
"'<b>' + c.city", // 城市名(城市气泡)
"+ m.name +", // 交通方式名(图例)
"+ (m.color ||", // 交通方式的颜色值,进了 style 属性
}
// TestMapTooltipsEscapeEditableFields 守住地图 JS 的 HTML 出口:
// 1. 已知的裸拼接写法一个都不许出现;
// 2. 每一个 bindTooltip / bindPopup 的调用行要么走 esc(),要么传的是 function(自己建 DOM)。
//
// 判据放在源码文本上而不是浏览器里,是因为这四处的注入后果很轻(站长可编辑的数据偏「自伤」),
// 但渲染错乱是实打实的——用 CDP 起浏览器只为了证明一句 esc() 太贵。
func TestMapTooltipsEscapeEditableFields(t *testing.T) {
raw, err := staticFS.ReadFile("static/js/rond-map.js")
if err != nil {
t.Fatalf("读取 rond-map.js 失败: %v", err)
}
src := string(raw)
for _, bad := range mapHTMLSinks {
if strings.Contains(src, bad) {
t.Errorf("rond-map.js 里出现未转义的 HTML 拼接 %q:该字段可编辑,须过 esc()", bad)
}
}
checked := 0
for i, line := range strings.Split(src, "\n") {
if !strings.Contains(line, ".bindTooltip(") && !strings.Contains(line, ".bindPopup(") {
continue
}
checked++
if !strings.Contains(line, "esc(") && !strings.Contains(line, "function") {
t.Errorf("rond-map.js:%d 的 tooltip/popup 参数既没过 esc() 也不是 function,"+
"Leaflet 会按 HTML 渲染:%s", i+1, strings.TrimSpace(line))
}
}
if checked == 0 {
t.Fatal("一个 bindTooltip/bindPopup 都没扫到:写法变了,这条守卫就失效了")
}
t.Logf("扫了 %d 处 tooltip/popup", checked)
}
+46
View File
@@ -2,6 +2,8 @@ package web
import ( import (
"html/template" "html/template"
"net/http/httptest"
"net/url"
"strings" "strings"
"testing" "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 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串, // 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串,
// 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上, // 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上,
// 按钮退化成光秃秃的「返回」。 // 按钮退化成光秃秃的「返回」。
+19 -2
View File
@@ -2,7 +2,9 @@ package web
import ( import (
"context" "context"
"fmt"
"log" "log"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@@ -21,6 +23,21 @@ type pageJob struct {
run func(context.Context) error 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 错误。 // runJobs 并发执行互不依赖的查询,返回第一个(按传入顺序)非 nil 错误。
// //
// 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms), // 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms),
@@ -29,7 +46,7 @@ type pageJob struct {
func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error { func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
if len(jobs) <= 1 { if len(jobs) <= 1 {
for _, j := range jobs { for _, j := range jobs {
if err := j.run(ctx); err != nil { if err := s.runJob(ctx, j); err != nil {
return err return err
} }
} }
@@ -50,7 +67,7 @@ func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
} }
defer func() { <-sem }() defer func() { <-sem }()
start := time.Now() 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 { if d := time.Since(start); d > slowJobLog && s.cfg.Debug {
log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond)) log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond))
} }
+30
View File
@@ -3,6 +3,7 @@ package web
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -12,6 +13,35 @@ import (
func testServer() *Server { return &Server{cfg: &config.Config{}} } 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) { func TestRunJobsRunsConcurrently(t *testing.T) {
s := testServer() s := testServer()
const n = 8 const n = 8
+60 -5
View File
@@ -7,6 +7,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
@@ -202,23 +203,77 @@ func TestPlaceUpdateBatchActivity(t *testing.T) {
t.Fatalf("打开导出包失败: %v", err) t.Fatalf("打开导出包失败: %v", err)
} }
defer edb.Close() defer edb.Close()
ph := make([]string, len(pks)) ph := make([]string, 0, len(pks))
args := make([]any, 0, len(pks)+1) args := make([]any, 0, len(pks)+1)
for i, v := range pks { // ⚠️ 参数顺序必须与 SQL 里占位符的出现顺序一致:`FILTER (WHERE ZACTIVITY_=?)` 在
ph[i] = "?" // 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, v)
} }
args = append(args, actSrc)
var inPackage, converted int var inPackage, converted int
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` + q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
strings.Join(ph, ",") + `)` 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 { if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
t.Fatalf("读导出包失败: %v", err) t.Fatalf("读导出包失败: %v", err)
} }
if inPackage != len(pks) { 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) { if converted != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID) t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
} }
} }
// diffVisitPKs 说清「包里的到访为什么比预期少」:返回包里缺的编号、pks 里重复的编号、
// 以及包内 ZVISIT 的总行数。总行数能立刻区分「少写了一行」和「两行撞成一个 Z_PK」。
func diffVisitPKs(t *testing.T, edb *sql.DB, pks []int) (missing, dup []int, total int) {
t.Helper()
have := map[int]bool{}
rows, err := edb.Query(`SELECT Z_PK FROM ZVISIT`)
if err != nil {
t.Fatalf("读导出包的到访编号失败: %v", err)
}
for rows.Next() {
var pk int
if err := rows.Scan(&pk); err != nil {
rows.Close()
t.Fatalf("扫导出包到访编号失败: %v", err)
}
have[pk] = true
total++
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("读导出包到访编号中断: %v", err)
}
rows.Close()
want := map[int]int{}
for _, v := range pks {
want[v]++
}
for v, c := range want {
if c > 1 {
dup = append(dup, v)
}
if !have[v] {
missing = append(missing, v)
}
}
sort.Ints(missing)
sort.Ints(dup)
return missing, dup, total
}
+46
View File
@@ -438,3 +438,49 @@ func abs(v float64) float64 {
} }
return v return v
} }
// TestEditPageSurvivesGarbageRawCoord 钉住「留档里的坐标不是数字时,编辑页照样打得开」。
//
// entity_raw 是原样存下来的 JSON,值不一定就是数字。以前那两列直接 `::float8`,
// 一条脏数据就能让整个数据编辑页 500:查询里还有 CASE / 正则等别的条件,表现是
// 「每次打开都 500、别的页面都正常」,很难一眼看出是哪一行哪一列的事。
func TestEditPageSurvivesGarbageRawCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var orig []byte
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, r.raw FROM places p
JOIN entity_raw r ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
WHERE p.dataset_id=$1 ORDER BY p.src_pk LIMIT 1`, f.dsID).Scan(&pk, &orig); err != nil {
t.Fatalf("找不到带留档的地点: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE entity_raw SET raw=$1::jsonb
WHERE dataset_id=$2 AND entity='ZLOCATION' AND src_pk=$3`, string(orig), f.dsID, pk)
})
get := func() *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.editPage(rec, req)
return rec
}
if rec := get(); rec.Code != 200 {
t.Fatalf("基线就渲染失败:%d", rec.Code)
}
// 坐标位置塞一个「像数字但不是数字」的字符串:旧那条正则 '^-?[0-9.]+$' 会放行它
if _, err := f.pg.ExecContext(f.ctx,
`UPDATE entity_raw SET raw = jsonb_set(raw, '{ZLATITUDE}', to_jsonb('1.2.3'::text))
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, pk); err != nil {
t.Fatalf("写入脏留档失败: %v", err)
}
rec := get()
if rec.Code != 200 {
t.Fatalf("留档坐标不是数字时编辑页返回 %d,应照常 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "</html>") {
t.Error("页面被截断(没有收尾的 </html>)")
}
}
+39
View File
@@ -168,6 +168,45 @@ func TestPlaceImageUploadDelete(t *testing.T) {
} }
} }
// TestPlaceImageUploadRejectsOversizedBody 上传接口必须先卡住请求体再解析 multipart。
//
// ParseMultipartForm 的 maxMemory 只管「内存里留多少」,超出的部分会无上限地落到
// 临时文件;不先套 MaxBytesReader,一个大请求就能把磁盘写满。
//
// 判据用响应文案而不是「文件大小」:只有 ParseMultipartForm 失败那一条分支会回
// 「可能太大」。把 MaxBytesReader 那行删掉,请求会一路解析成功、最后被 6MB 的单张
// 上限拦成「图片超过 6MB」,这条用例就会红。
func TestPlaceImageUploadRejectsOversizedBody(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
setMediaBackend(t, f, "local")
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
// 超出门限(imageMaxBytes + 1MB)一大截
raw := webpBytes(imageMaxBytes + 3<<20)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)},
map[string][]byte{"file": raw})
if !strings.Contains(rec.Body.String(), "可能太大") {
t.Errorf("超大请求体没在解析阶段被挡下: %s", rec.Body.String())
}
// 没落库(解析阶段就失败,后面取 src_pk / 存图那几步都到不了)
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("被拒的上传落了库: %d 行", n)
}
}
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好, // TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
// 并且图片挂在正确的 src_pk 上。 // 并且图片挂在正确的 src_pk 上。
func TestPhotoImportWithImage(t *testing.T) { func TestPhotoImportWithImage(t *testing.T) {
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"os"
"path/filepath"
"strings"
"testing"
)
// safeInternalPath 是 ?back=、后台表单 next、投票后跳转三处共用的判据。
// 挡不住「//evil.com」就是开放重定向(协议相对地址会被当跨站地址)。
func TestSafeInternalPath(t *testing.T) {
valid := map[string]string{
"/": "/",
"/places/1": "/places/1",
"/admin/notes?q=x&src=1": "/admin/notes?q=x&src=1",
"/places/1#top": "/places/1#top",
" /admin ": "/admin", // 首尾空白先 trim
"/places/%E4%B8%AD%E6%96%87": "/places/%E4%B8%AD%E6%96%87",
}
for in, want := range valid {
if got := safeInternalPath(in); got != want {
t.Errorf("safeInternalPath(%q) = %q,期望 %q", in, got, want)
}
}
invalid := []string{
"",
" ",
"//evil.com", // 协议相对:浏览器当 https://evil.com
"//evil.com/admin", // 同上,还伪装成 /admin 开头
"/\\evil.com", // 反斜杠:浏览器会归一成 //
"\\\\evil.com", // 反斜杠开头
"https://evil.com", // 绝对地址
"javascript:alert(1)", // 伪协议
"/a\"b", // 能跳出属性
"/a'b", //
"/a<b", //
"/a>b", //
"/a\\b", //
"/a\nb", // 控制字符(曾能污染 Location 头)
"/a\rb", //
"/a\x00b", // NUL:浏览器会悄悄吃掉
"/a\x7fb", // DEL
"relative/path", // 非站内绝对路径
"admin", //
"/admin\nLocation: //e.c", // 头注入尝试
}
for _, in := range invalid {
if got := safeInternalPath(in); got != "" {
t.Errorf("safeInternalPath(%q) = %q,应当拒绝", in, got)
}
}
}
// 钉住缺陷形态本身:站内路径判定只能走 safeInternalPath,
// 不许再出现 `strings.HasPrefix(next, "/")` 这种只判前缀、放行 //evil.com 的写法。
func TestNoWeakRedirectPrefixCheck(t *testing.T) {
pattern := `HasPrefix(next, "/")`
// 从仓库根扫,而不是 ".":cwd 会被夹具改掉,"." 时而指包目录、时而指仓库根,
// 覆盖面随之悄悄变化(包目录下扫不到 handlers_admin.go 之类)。
root := moduleRoot(t)
scanned := 0
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if p != root && (info.Name() == "static" || info.Name() == "templates" || info.Name() == ".git") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
scanned++
b, err := os.ReadFile(p)
if err != nil {
return err
}
if strings.Contains(string(b), pattern) {
t.Errorf("%s 里出现了弱判据 %s:它放行 //evil.com,请改用 safeInternalPath", p, pattern)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if scanned < 10 {
t.Fatalf("只扫到 %d 个源文件,静态守卫形同虚设", scanned)
}
}
+76
View File
@@ -0,0 +1,76 @@
package web
import (
"context"
"testing"
"time"
"rond-with-you/internal/stats"
)
// D6 对账:同一套区域筛选下,轨迹图层的谓词(照 apiTrack 的写法,用 regionSQL 拼
// pf / pt 两段 AND)与里程统计用的 movementsWhere 必须选出同一批位移。
//
// 曾经一个是「两端都要命中」、一个是「任一端命中」:白名单下同一段里程在图层里
// 画不出来、却算进了总里程(黑名单下两种写法恰好等价,所以这个洞只在白名单露出来)。
// 判据用真实的两份实现做 A/B,而不是各写一遍 SQL 互相比对——那样只是把漂移抄了两遍。
func TestTrackAndMileageAgreeOnRegionFilter(t *testing.T) {
f := newLoginFixture(t)
pg, ctx := f.pg, context.Background()
// 独立数据集、且 is_active 保持默认 false:统计夹具是「WHERE is_active LIMIT 1」,
// 置 true 会把它选走,串到别人的用例上(本轮在 ingest 那边刚踩过一次)。
var dsID int64
if err := pg.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status)
VALUES ($1,'d6-region-consistency','','done') RETURNING id`, f.uid).Scan(&dsID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.Exec(`DELETE FROM datasets WHERE id=$1`, dsID) })
const inRegion, outRegion = "测试省甲", "测试省乙"
place := func(src int, prov string) int64 {
t.Helper()
var id int64
if err := pg.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, province, city, lat, lon)
VALUES ($1,$2,$3,$4,$5,24.0,109.0) RETURNING id`,
dsID, src, "p", prov, prov).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
in1, out, in2 := place(1, inRegion), place(2, outRegion), place(3, inRegion)
move := func(src int, from, to int64, km float64) {
t.Helper()
if _, err := pg.ExecContext(ctx, `INSERT INTO movements
(dataset_id, user_id, src_pk, from_place_id, to_place_id, distance_km, started_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, dsID, f.uid, src, from, to, km, time.Now()); err != nil {
t.Fatal(err)
}
}
move(1, in1, out, 10) // 一端在区域外:白名单下两边都不该算
move(2, in1, in2, 20) // 两端都在区域内
args := []any{dsID}
regSQL := regionSQL("pf", []string{inRegion}, false, &args) +
regionSQL("pt", []string{inRegion}, false, &args)
var trackKm float64
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(sum(m.distance_km),0) FROM movements m
JOIN places pf ON pf.id = m.from_place_id AND pf.lat IS NOT NULL
JOIN places pt ON pt.id = m.to_place_id AND pt.lat IS NOT NULL
WHERE m.dataset_id = $1`+regSQL, args...).Scan(&trackKm); err != nil {
t.Fatal(err)
}
ov, err := (&stats.DB{DB: pg}).Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{inRegion}})
if err != nil {
t.Fatal(err)
}
if trackKm != 20 {
t.Fatalf("前提不成立:图层侧手写查询应得 20km,实际 %.2f", trackKm)
}
if ov.DistanceKm != trackKm {
t.Errorf("轨迹图层与里程统计口径不一致:图层 %.2f km,统计 %.2f km", trackKm, ov.DistanceKm)
}
}
+81 -21
View File
@@ -90,6 +90,11 @@ type Server struct {
masks maskCache masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期) // captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store captcha *captcha.Store
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
loginGuard *auth.Guard
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
totpReplay *auth.CodeReplay
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址 // media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store 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} pages[key] = pageSet{t: t, entry: def.entry}
} }
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
db: db, db: db,
q: stats.DB{DB: db}, q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays}, auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db}, sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db}, notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db}, votes: &votes.Store{DB: db},
pages: pages, pages: pages,
mux: http.NewServeMux(), mux: http.NewServeMux(),
cookie: "rond_session", cookie: "rond_session",
tiles: buildTileSet(cfg), tiles: buildTileSet(cfg),
tileCache: newTileCache(1024), tileCache: newTileCache(1024),
fogTiles: newTileCache(512), fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute), captcha: captcha.NewStore(10 * time.Minute),
media: media.New(cfg), loginGuard: auth.NewGuard(),
totpReplay: auth.NewCodeReplay(),
media: media.New(cfg),
} }
s.routes() s.routes()
return s, nil return s, nil
@@ -142,7 +149,7 @@ func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
} }
func (s *Server) Handler() 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 给带了当前版本号的静态资源加长期强缓存。 // 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 { func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hw := &headerSent{ResponseWriter: w}
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec) 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 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{ http.SetCookie(w, &http.Cookie{
Name: s.cookie, Name: s.cookie,
Value: s.auth.SignToken(userID, hash), Value: s.auth.SignToken(userID, hash),
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400, MaxAge: s.cfg.SessionDays * 86400,
}) })
} }
func (s *Server) logout(w http.ResponseWriter, r *http.Request) { 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) 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) { func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err) 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 { func atoiOr(s string, def int) int {
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
)
// TestServerErrorHidesDetailFromVisitor 断言 500 响应体不带内部错误详情——
// 公开访客页把 SQL 错误、磁盘路径原样回显,等于免费送出表结构与目录布局。
// 站长自己看得到详情(排查要用),别人只能看到一句人话。
func TestServerErrorHidesDetailFromVisitor(t *testing.T) {
s := &Server{}
const secret = `pq: relation "zzz_table" does not exist (SQLSTATE 42P01)`
anon := httptest.NewRecorder()
s.serverError(anon, httptest.NewRequest("GET", "/", nil), errors.New(secret))
if anon.Code != http.StatusInternalServerError {
t.Fatalf("状态码应为 500,实际 %d", anon.Code)
}
if body := anon.Body.String(); strings.Contains(body, "42P01") || strings.Contains(body, "zzz_table") {
t.Errorf("访客页泄漏了内部错误详情: %s", body)
}
owner := httptest.NewRecorder()
s.serverError(owner, httptest.NewRequest("GET", "/", nil).WithContext(ctxWithUser(&auth.User{ID: 1})), errors.New(secret))
if body := owner.Body.String(); !strings.Contains(body, "42P01") {
t.Errorf("站长应能看到错误详情,实际: %s", body)
}
}
// 半路 panic(首部已提交):不能再写错误页。旧写法无条件 http.Error,
// 会二次 WriteHeader(Go 记一条 superfluous response.WriteHeader)并把错误体
// 追加到半个页面后面 —— 用户看到的是「半页正文 + 一句服务器内部错误」。
func TestRecoverLeavesPartialResponseAlone(t *testing.T) {
s := &Server{}
h := s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<p>已经写出去的半页</p>")
panic("boom")
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != http.StatusOK {
t.Errorf("首部已提交就不该再改状态码,实际 %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "已经写出去的半页") {
t.Errorf("原有的半页被覆盖了: %q", body)
}
if strings.Contains(body, "服务器内部错误") {
t.Errorf("错误体被追加到了已提交的响应后面: %q", body)
}
}
// 还没写出任何东西就 panic:必须照旧给 500 + 一句人话。
func TestRecoverWritesErrorWhenNothingSent(t *testing.T) {
s := &Server{}
h := s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("boom") }))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
if w.Code != http.StatusInternalServerError {
t.Errorf("尚未写出时应回 500,实际 %d", w.Code)
}
if !strings.Contains(w.Body.String(), "服务器内部错误") {
t.Errorf("缺少错误提示: %q", w.Body.String())
}
}
// 包装层必须实现 Unwrap():否则 http.NewResponseController 在它这里拿不到
// SetWriteDeadline / Flush / Hijack,导出那条路由的 10 分钟写死线会静默失效。
func TestRecoverWrapperUnwraps(t *testing.T) {
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
s := &Server{}
var got error
s.recover(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(time.Minute))
})).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got != nil {
t.Fatalf("ResponseController 没能穿过 recover 的包装: %v", got)
}
if !rec.got {
t.Error("SetWriteDeadline 没有落到真正的 ResponseWriter 上")
}
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// S4 回归:https 下会话 cookie 必须带 Secure;http 下不能带 ——
// 无条件打 Secure 会让本机 http 调试和本仓库的 CDP 验证流程直接登不进去。
func TestSessionCookieSecureFlag(t *testing.T) {
f := newLoginFixture(t)
login := func(https bool, xfp string) *http.Cookie {
t.Helper()
f.srv.loginGuard.Reset()
id, _, err := f.srv.captcha.Issue()
if err != nil {
t.Fatalf("签发验证码失败: %v", err)
}
code, ok := f.srv.captcha.Answer(id)
if !ok {
t.Fatal("刚签发的验证码取不到答案")
}
form := url.Values{"username": {f.user}, "password": {f.pass}, "captcha": {code}}
r := httptest.NewRequest("POST", "/admin/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if xfp != "" {
r.Header.Set("X-Forwarded-Proto", xfp)
}
if https {
r.TLS = &tls.ConnectionState{}
}
r.AddCookie(&http.Cookie{Name: captchaCookie, Value: id})
w := httptest.NewRecorder()
f.srv.loginSubmit(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("登录应 303,实际 %d:%s", w.Code, firstLine(w.Body.String()))
}
for _, c := range w.Result().Cookies() {
if c.Name == f.srv.cookie {
return c
}
}
t.Fatal("登录成功却没有下发会话 cookie")
return nil
}
cases := []struct {
name string
https bool
xfp string
want bool
}{
{"直连 http", false, "", false},
{"直连 https", true, "", true},
{"反代 https", false, "https", true},
{"反代多段取首段", false, "https, http", true},
{"反代 http", false, "http", false},
}
for _, c := range cases {
got := login(c.https, c.xfp)
if got.Secure != c.want {
t.Errorf("%s: Secure=%v,期望 %v", c.name, got.Secure, c.want)
}
// 其余属性不能被顺手改掉
if !got.HttpOnly || got.Path != "/" || got.SameSite != http.SameSiteLaxMode {
t.Errorf("%s: cookie 其它属性有变: HttpOnly=%v Path=%q SameSite=%v",
c.name, got.HttpOnly, got.Path, got.SameSite)
}
}
}
+13 -20
View File
@@ -27,6 +27,12 @@
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */ /* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
--nav-h: 54px; --nav-h: 54px;
/* 地图页浮层的层级:Leaflet 自己的 z-index 封顶在 1000(.leaflet-top/.leaflet-bottom,
见 vendor/leaflet/leaflet.css),而 #map 那一层没有建立层叠上下文 ——
站点的浮层排不到 1000 之上,就会被左上角的缩放按钮、右下的比例尺压住。
分两档是为了保住原有次序:加载状态条要压在所有浮层之上。 */
--z-map-overlay: 1100;
--z-map-status: 1150;
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */ /* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
--safe-t: env(safe-area-inset-top, 0px); --safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px); --safe-r: env(safe-area-inset-right, 0px);
@@ -76,7 +82,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* ---------- 布局 ---------- */ /* ---------- 布局 ---------- */
.wrap { max-width: 1120px; margin: 0 auto; .wrap { max-width: 1120px; margin: 0 auto;
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); } padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
.wrap-wide { max-width: 100%; padding: 0; }
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 21px; } .page-head h1 { font-size: 21px; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; } .page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
@@ -124,7 +129,8 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
} }
.nav-links a { padding: 5px 7px; font-size: 13px; } /* 内边距由下面「窄屏触控」那块给(同特异性、写在后面才生效);这里只留字号。 */
.nav-links a { font-size: 13px; }
.nav-right .who { display: none; } .nav-right .who { display: none; }
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; } .nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
} }
@@ -159,7 +165,6 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。 网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
flex 会让最后一行自己撑满,任何个数都不会有空格。 */ flex 会让最后一行自己撑满,任何个数都不会有空格。 */
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); } .kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; } .kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */ /* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; } .kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
@@ -282,7 +287,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); box-sha
.btn.danger { color: #b42318; border-color: #f0cecb; } .btn.danger { color: #b42318; border-color: #f0cecb; }
.btn.danger:hover { background: var(--err-bg); } .btn.danger:hover { background: var(--err-bg); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
/* ---------- 表格 ---------- */ /* ---------- 表格 ---------- */
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; } table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -317,7 +321,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l); inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l);
} }
.map-panel { .map-panel {
position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px); position: absolute; top: 12px; left: 12px; z-index: var(--z-map-overlay); width: 286px; max-height: calc(100% - 24px);
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px); overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
} }
@@ -354,16 +358,12 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.adv-fold > h3 { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; } .adv-fold > h3 { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
.map-legend { .map-legend {
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */ /* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong); position: absolute; bottom: 26px; right: 12px; z-index: var(--z-map-overlay); background: var(--glass-strong);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow); max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
} }
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); } .map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; } .map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.maplink b { font-weight: 500; }
.maplink span { color: var(--text-3); font-size: 12px; display: block; }
.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); }
/* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签—— /* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签——
不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */ 不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; } .fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
@@ -380,7 +380,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
/* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */ /* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */
.to-top { .to-top {
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900; position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: var(--z-map-status);
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line); width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1; background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
cursor: pointer; box-shadow: var(--shadow); cursor: pointer; box-shadow: var(--shadow);
@@ -427,7 +427,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */ 和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */
.timeline-bar { .timeline-bar {
position: absolute; left: 50%; transform: translateX(-50%); position: absolute; left: 50%; transform: translateX(-50%);
bottom: 12px; z-index: 650; bottom: 12px; z-index: var(--z-map-overlay);
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
max-width: min(560px, calc(100% - 24px)); max-width: min(560px, calc(100% - 24px));
padding: 7px 12px; border-radius: 10px; padding: 7px 12px; border-radius: 10px;
@@ -689,7 +689,7 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; } footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.timeline-day { margin-bottom: 20px; } .timeline-day { margin-bottom: 20px; }
.timeline-day .dh { .timeline-day .dh {
position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px; position: sticky; top: var(--nav-h); background: var(--bg); z-index: 5; padding: 6px 0 7px;
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline; font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
} }
.timeline-day .dh .muted { font-size: 12px; } .timeline-day .dh .muted { font-size: 12px; }
@@ -857,11 +857,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.geo-name { display: block; font-size: 13.5px; } .geo-name { display: block; font-size: 13.5px; }
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; } .geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
/* 「同时创建到访」折叠区 */
.opt-visit { margin: 0 0 12px; }
.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; }
.opt-visit .row { margin-bottom: 8px; }
/* ---------- 专题 ---------- */ /* ---------- 专题 ---------- */
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器, /* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */ 单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
@@ -871,8 +866,6 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); } .topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
.topic-head { display: flex; align-items: center; gap: 8px; } .topic-head { display: flex; align-items: center; gap: 8px; }
.topic-head h2 { margin: 0; } .topic-head h2 { margin: 0; }
.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */ /* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
.map-fallback { .map-fallback {
+11 -2
View File
@@ -113,8 +113,14 @@
if (sub[0x9003]) { if (sub[0x9003]) {
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim()); var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
if (m) { if (m) {
res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); // 全 0 的「0000:00:00 00:00:00」会被 Date 归一成 1899-11-30,然后被当成拍摄时间
res.hasTime = true; // 写进到访时间;字段超范围一律当「没有时间」,别把垃圾时间带进库。
var Y = +m[1], Mo = +m[2], D = +m[3], H = +m[4], Mi = +m[5], S = +m[6];
if (Y >= 1900 && Y <= 2200 && Mo >= 1 && Mo <= 12 && D >= 1 && D <= 31 &&
H <= 23 && Mi <= 59 && S <= 59) {
res.shot = new Date(Y, Mo - 1, D, H, Mi, S);
res.hasTime = true;
}
} }
} }
} }
@@ -171,6 +177,9 @@
if (start + 4 > u8.length) return 0; if (start + 4 > u8.length) return 0;
var ver = u8[start]; var ver = u8[start];
var p = start + 4; var p = start + 4;
// 末尾恰好相等时(start+4 == length)上面那条守卫会放行,紧接着的 getUint16/32
// 就越界抛 RangeError(用户看到一句英文的 DataView 报错)。读之前把宽度也卡住。
if (ver === 0 ? p + 2 > u8.length : p + 4 > u8.length) return 0;
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p); var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
p += ver === 0 ? 2 : 4; p += ver === 0 ? 2 : 4;
for (var i = 0; i < count; i++) { for (var i = 0; i < count; i++) {
+6 -1
View File
@@ -96,7 +96,12 @@
function post(url, form) { function post(url, form) {
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' }) return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (r) { return r.json(); }); .then(function (r) {
// 服务端出错时回的是 HTML,直接 r.json() 会抛一句英文 SyntaxError;
// 先判状态码,调用方才能拿到人话错误。
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
} }
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}> // upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
+45 -25
View File
@@ -26,8 +26,8 @@
modes.forEach(function (m) { modes.forEach(function (m) {
var row = L.DomUtil.create('div'); var row = L.DomUtil.create('div');
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' + row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' + 'background:' + esc(m.color || '#30b0c7') + ';margin-right:6px"></span>' +
m.name + ' <span style="color:#8a9199">' + m.count + '</span>'; esc(m.name) + ' <span style="color:#8a9199">' + esc(m.count) + '</span>';
box.appendChild(row); box.appendChild(row);
}); });
L.DomEvent.disableClickPropagation(box); L.DomEvent.disableClickPropagation(box);
@@ -89,7 +89,7 @@
L.DomEvent.on(slider, 'input', function () { L.DomEvent.on(slider, 'input', function () {
var v = parseInt(slider.value, 10) / 100; var v = parseInt(slider.value, 10) / 100;
layer.setOpacity(v); layer.setOpacity(v);
localStorage.setItem('rond.fogOpacity', String(v)); lsSet('rond.fogOpacity', String(v));
}); });
return box; return box;
} }
@@ -155,6 +155,7 @@
}; };
var maps = {}; var maps = {};
var previewQueue = {}; // id -> 最近一次 preview 请求的点集(配置就绪后补画)
var cfgCache = null; var cfgCache = null;
function register(id, map) { function register(id, map) {
@@ -211,20 +212,27 @@
*/ */
var BASE_PICK = 'rond.base'; var BASE_PICK = 'rond.base';
/* localStorage 在隐私模式 / 禁用 Cookie 的浏览器里**读写都会抛异常**,
裸调用会把整段脚本打断(地图白屏、图层不出来)。统一走这两个兜底,失败当没有。 */
function lsGet(k) {
try { return window.localStorage.getItem(k); } catch (e) { return null; }
}
function lsSet(k, v) {
try { window.localStorage.setItem(k, v); } catch (e) { /* 隐私模式,忽略 */ }
}
function pickBase(cfg) { function pickBase(cfg) {
var byKey = {}; var byKey = {};
cfg.base.forEach(function (b) { byKey[b.key] = b; }); cfg.base.forEach(function (b) { byKey[b.key] = b; });
try { var saved = lsGet(BASE_PICK);
var saved = window.localStorage.getItem(BASE_PICK); if (saved && byKey[saved]) return byKey[saved];
if (saved && byKey[saved]) return byKey[saved];
} catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ }
if (cfg.default && byKey[cfg.default]) return byKey[cfg.default]; if (cfg.default && byKey[cfg.default]) return byKey[cfg.default];
return cfg.base[0]; return cfg.base[0];
} }
function rememberBase(key) { function rememberBase(key) {
if (!key) return; if (!key) return;
try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ } lsSet(BASE_PICK, key);
} }
function esc(s) { function esc(s) {
@@ -379,7 +387,7 @@
// maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行 // maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行
m.bindPopup(function () { return popup(p, vote); }, m.bindPopup(function () { return popup(p, vote); },
{ closeButton: true, maxWidth: 360, minWidth: 240 }); { closeButton: true, maxWidth: 360, minWidth: 240 });
m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] }); m.bindTooltip(esc(p.n || '未命名'), { direction: 'top', offset: [0, -4] });
m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」 m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」
return m; return m;
} }
@@ -543,12 +551,18 @@
// 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」: // 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」:
// 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。 // 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。
// 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。 // 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。
// 记下这一轮要画的点:配置就绪后以「最新一次请求」为准。
// 原来只在 _state 已建好时才处理;配置还没回来时 _state 里还没有条目,
// 第二次 preview 的点会被静默丢掉——地图上少一批点,且完全不报错。
previewQueue[opts.id] = points;
var live = maps[opts.id]; var live = maps[opts.id];
if (live) { if (live) {
live.invalidateSize(); live.invalidateSize();
var prev = window.rondMap._state[opts.id]; var prev = window.rondMap._state[opts.id];
if (prev && prev.setPoints) prev.setPoints(points); if (prev && prev.setPoints) {
else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画 delete previewQueue[opts.id];
prev.setPoints(points);
}
return; return;
} }
var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false }); var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false });
@@ -557,6 +571,9 @@
if (vote) loadVotes(); if (vote) loadVotes();
loadConfig().then(function (cfg) { loadConfig().then(function (cfg) {
var def = pickBase(cfg); var def = pickBase(cfg);
// 配置期间又来了一次 preview 的话,以那一次的点为准
if (previewQueue[opts.id]) points = previewQueue[opts.id];
delete previewQueue[opts.id];
tileLayer(def).addTo(map); tileLayer(def).addTo(map);
var byId = {}; var byId = {};
var group = markers(points, def.crs, false, vote, byId); var group = markers(points, def.crs, false, vote, byId);
@@ -587,12 +604,6 @@
applyFit(); applyFit();
}; };
window.rondMap._state[opts.id] = st; window.rondMap._state[opts.id] = st;
// 配置就绪前有别的调用挂上来的点,现在补画一次
if (st.previewPending) {
var queued = st.previewPending;
st.previewPending = null;
st.setPoints(queued);
}
}); });
}, },
@@ -701,7 +712,7 @@
var overlayDefs = {}; var overlayDefs = {};
if (fogLayer) { if (fogLayer) {
overlayDefs['世界迷雾'] = fogLayer; overlayDefs['世界迷雾'] = fogLayer;
var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity')); var savedFog = parseFloat(lsGet('rond.fogOpacity'));
if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog); if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog);
var fogCtl = addFogOpacityControl(map, fogLayer, corner); var fogCtl = addFogOpacityControl(map, fogLayer, corner);
// 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了; // 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了;
@@ -710,9 +721,9 @@
var on = map.hasLayer(fogLayer); var on = map.hasLayer(fogLayer);
var box = fogCtl.getContainer(); var box = fogCtl.getContainer();
if (box) box.style.display = on ? '' : 'none'; if (box) box.style.display = on ? '' : 'none';
localStorage.setItem('rond.fogOff', on ? '0' : '1'); lsSet('rond.fogOff', on ? '0' : '1');
}; };
if (localStorage.getItem('rond.fogOff') === '1') syncFog(); if (lsGet('rond.fogOff') === '1') syncFog();
else fogLayer.addTo(map); else fogLayer.addTo(map);
map.on('overlayadd overlayremove', function (e) { map.on('overlayadd overlayremove', function (e) {
if (e.name === '世界迷雾') syncFog(); if (e.name === '世界迷雾') syncFog();
@@ -764,12 +775,15 @@
// 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影 // 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影
var trackLayer = null, trackSegs = null, heatLayer = null; var trackLayer = null, trackSegs = null, heatLayer = null;
var cityLayer = null, gpxLayer = null, modeLegend = null; var cityLayer = null, gpxLayer = null, modeLegend = null;
// 图层开关与异步回包的竞态:勾选后立刻取消时请求还在飞,回包若直接 addTo(map),
// 开关明明关着图层却画了上去(切底图重取也走同一条路)。递增序号作废过期回包。
var gpxSeq = 0, heatSeq = 0, citySeq = 0;
function buildTrack(segs, crs) { function buildTrack(segs, crs) {
var g = L.layerGroup(); var g = L.layerGroup();
segs.forEach(function (s) { segs.forEach(function (s) {
var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs); var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs);
L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 }) L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 })
.bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true }) .bindTooltip(esc(s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(g); .addTo(g);
}); });
return g; return g;
@@ -802,9 +816,11 @@
// 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑 // 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑
window.rondMap.setCityAgg = function (on) { window.rondMap.setCityAgg = function (on) {
var seq = ++citySeq;
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; } if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; }
if (!on) return; if (!on) return;
fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) { fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) {
if (seq !== citySeq) return; // 期间又切过开关/底图,这次回包作废
var cities = d.cities || []; var cities = d.cities || [];
if (!cities.length) return; if (!cities.length) return;
var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; })); var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; }));
@@ -815,10 +831,10 @@
L.circleMarker(pos, { L.circleMarker(pos, {
radius: r, color: '#2f7d6e', weight: 1.5, radius: r, color: '#2f7d6e', weight: 1.5,
fillColor: '#2f7d6e', fillOpacity: 0.18 fillColor: '#2f7d6e', fillOpacity: 0.18
}).bindPopup('<b>' + c.city + '</b>' + }).bindPopup('<b>' + esc(c.city) + '</b>' +
(c.province ? ' · ' + c.province : '') + (c.province ? ' · ' + esc(c.province) : '') +
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' + '<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer); (c.first ? '<br>首次到达 ' + esc(c.first) : '')).addTo(cityLayer);
}); });
cityLayer.addTo(map); cityLayer.addTo(map);
}); });
@@ -826,24 +842,28 @@
// GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好 // GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好
window.rondMap.setGpx = function (on) { window.rondMap.setGpx = function (on) {
var seq = ++gpxSeq;
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; } if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; }
if (!on) return; if (!on) return;
ensureGpx(st.crs, function (tracks) { ensureGpx(st.crs, function (tracks) {
if (seq !== gpxSeq) return; // 期间又切过开关/底图,这次回包作废
gpxLayer = L.layerGroup(); gpxLayer = L.layerGroup();
tracks.forEach(function (t) { tracks.forEach(function (t) {
if (!t.points || t.points.length < 2) return; if (!t.points || t.points.length < 2) return;
L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 }) L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 })
.bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true }) .bindTooltip(esc(t.name) + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(gpxLayer); .addTo(gpxLayer);
}); });
gpxLayer.addTo(map); gpxLayer.addTo(map);
}); });
}; };
window.rondMap.setHeat = function (on) { window.rondMap.setHeat = function (on) {
var seq = ++heatSeq;
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
if (!on) return; if (!on) return;
// heat 库只在第一次开热力图时才下载,平时不进首屏 // heat 库只在第一次开热力图时才下载,平时不进首屏
ensureHeat(function () { ensureHeat(function () {
if (seq !== heatSeq) return; // 期间又切过开关/底图,这次回包作废
if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map); if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map);
}); });
}; };
+23
View File
@@ -0,0 +1,23 @@
package web
import (
"os"
"strings"
"testing"
)
// 统计页的 Weekday 只该查一次。
//
// 原实现是「在 runJobs 里注册了 weekday 任务」之外,又在渲染前同步查了一遍
// (`d.Weekday, err = s.q.Weekday(ctx, f)`),等于同一个查询发了两次——纯浪费。
// 这条守卫盯住「不许再出现第二次」。
func TestStatsHandlerQueriesWeekdayOnce(t *testing.T) {
b, err := os.ReadFile(pkgFile(t, "handlers_public.go"))
if err != nil {
t.Fatalf("读取 handlers_public.go 失败: %v", err)
}
if n := strings.Count(string(b), "s.q.Weekday("); n != 1 {
t.Errorf("handlers_public.go 里 s.q.Weekday( 应恰好出现 1 次,实际 %d 次"+
"(>1 说明又重复查了;0 说明写法改了、这条守卫需要同步更新)", n)
}
}
+2
View File
@@ -40,6 +40,8 @@ func TestStackedTablesHaveLabels(t *testing.T) {
{"/admin/topics", f.srv.topicsAdminPage, true}, {"/admin/topics", f.srv.topicsAdminPage, true},
// 数据编辑页的三张表默认折叠在卡片里(探针量到的是 display:none),最容易漏标 // 数据编辑页的三张表默认折叠在卡片里(探针量到的是 display:none),最容易漏标
{"/admin/edit", f.srv.editPage, true}, {"/admin/edit", f.srv.editPage, true},
// 遮罩表原来既没 tb-stack 也没 .tw,两条守卫都看不见(数据库里没有遮罩时这页也不渲染表)
{"/admin/fog/mask", f.srv.fogMaskPage, true},
} }
if topicID > 0 { if topicID > 0 {
targets = append(targets, target{"/topics/" + strconv.FormatInt(topicID, 10), f.srv.topicPage, false}) targets = append(targets, target{"/topics/" + strconv.FormatInt(topicID, 10), f.srv.topicPage, false})
+135
View File
@@ -0,0 +1,135 @@
package web
import (
"io/fs"
"path/filepath"
"regexp"
"strings"
"testing"
)
// tbStackExempt 是明确不参与窄屏堆叠的表格类名,必须写清理由。
var tbStackExempt = map[string]string{
"photo-table": "照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路",
}
// TestAllTBTablesAreStackable 静态扫全部模板:只要用了 `tb` 这个宽表基类,就必须带 `tb-stack`。
//
// 这是对 wide_table_layout_test 的补充——那条是**渲染后**按 DOM 栈判的,两种表能躲过去:
// ① 运行期的数据为空(比如库里没有遮罩区),那张表压根没渲染;
// ② 模板忘了写 .tw 包裹,判据里的「在 .tw 里」就不成立。
// /admin/fog/mask 的遮罩表两条都占了,2026-09-21 审计才发现。
func TestAllTBTablesAreStackable(t *testing.T) {
tableRe := regexp.MustCompile(`<table\b[^>]*class="([^"]*)"`)
scanned, tables := 0, 0
err := fs.WalkDir(tmplFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
return err
}
raw, err := fs.ReadFile(tmplFS, path)
if err != nil {
return err
}
scanned++
for _, m := range tableRe.FindAllStringSubmatch(string(raw), -1) {
classes := strings.Fields(m[1])
if !containsStr(classes, "tb") {
continue
}
tables++
if containsStr(classes, "tb-stack") {
continue
}
exempt := false
for _, c := range classes {
if _, ok := tbStackExempt[c]; ok {
exempt = true
}
}
if exempt {
continue
}
t.Errorf("%s:用了 tb 却没带 tb-stack(class=%q)——窄屏没有任何堆叠规则可命中,只能横滑",
path, m[1])
}
return nil
})
if err != nil {
t.Fatalf("遍历模板失败: %v", err)
}
if scanned == 0 || tables == 0 {
t.Fatalf("没扫到模板/表格(模板目录或类名写法变了?):模板 %d 个、tb 表 %d 张", scanned, tables)
}
t.Logf("扫了 %d 个模板里的 %d 张 tb 表", scanned, tables)
}
// TestTemplatesHaveBalancedRows 静态钉住模板里 `<tr>` 与 `</tr>` 数量相等。
//
// 浏览器会自己把漏掉的 `</tr>` 补上,页面看着完全正常——但 tb_stack_test 的行列校验
// 是按 `<tr>…</tr>` 配对的,一个没闭合的 `<tr>` 会让**那一行整行不被检查**
// (2026-09-21 审计在 report.html 的「最南」行上发现,之前一直静默跳过)。
//
// 先剥掉 <script> 块:照片表的行是脚本拼字符串生成的,那边的 `<tr>` 不算数。
func TestTemplatesHaveBalancedRows(t *testing.T) {
checked := 0
err := fs.WalkDir(tmplFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
return err
}
raw, err := fs.ReadFile(tmplFS, path)
if err != nil {
return err
}
body := stripScriptBlocks(string(raw))
open := len(regexp.MustCompile(`<tr\b`).FindAllString(body, -1))
close := strings.Count(body, "</tr>")
if open == 0 {
return nil
}
checked++
if open != close {
t.Errorf("%s:<tr> 有 %d 个、</tr> 只有 %d 个 —— 缺口那一行会被 tb_stack_test 静默跳过",
path, open, close)
}
return nil
})
if err != nil {
t.Fatalf("遍历模板失败: %v", err)
}
if checked == 0 {
t.Fatal("一个含表格行的模板都没扫到:模板目录变了?")
}
t.Logf("检查了 %d 个含表格行的模板", checked)
}
// stripScriptBlocks 去掉所有 <script>…</script> 的内容(含标签本身)。
func stripScriptBlocks(s string) string {
var b strings.Builder
for {
i := strings.Index(s, "<script")
if i < 0 {
b.WriteString(s)
return b.String()
}
b.WriteString(s[:i])
j := strings.Index(s[i:], ">")
if j < 0 {
return b.String()
}
end := strings.Index(s[i+j:], "</script>")
if end < 0 {
return b.String()
}
s = s[i+j+end+len("</script>"):]
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
+18
View File
@@ -205,6 +205,13 @@
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px"> <form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
{{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}} {{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}}
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}} {{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{/* 三个表单各自只带自己那几个条件,其余条件必须原样带上:
否则搜一下就把别的列表的筛选悄悄清掉了。 */}}
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
<label class="f" style="flex:1 1 200px"><span>搜索</span><input type="text" name="q" value="{{.Query}}" placeholder="名称 / 城市 / 区县"></label> <label class="f" style="flex:1 1 200px"><span>搜索</span><input type="text" name="q" value="{{.Query}}" placeholder="名称 / 城市 / 区县"></label>
<label class="f" style="flex:0 1 170px"><span>坐标来源</span> <label class="f" style="flex:0 1 170px"><span>坐标来源</span>
<select name="src"> <select name="src">
@@ -343,6 +350,15 @@
<h2>到访记录</h2> <h2>到访记录</h2>
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px"> <form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}} {{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{/* 只搜到访不能把别的列表的筛选清掉(原来只带 vq+back,一搜 q/src/sus
和行程的 mt/mp/mfrom/mto 全丢了)。 */}}
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
<label class="f"><span>搜索</span><input type="text" name="vq" value="{{.VisitQuery}}" placeholder="地点 / 城市 / 备注 / 活动 / 日期 / #编号"></label> <label class="f"><span>搜索</span><input type="text" name="vq" value="{{.VisitQuery}}" placeholder="地点 / 城市 / 备注 / 活动 / 日期 / #编号"></label>
<div class="act"><button class="btn" type="submit">搜索</button><a class="btn" href="{{.EditLink "vq"}}">重置</a></div> <div class="act"><button class="btn" type="submit">搜索</button><a class="btn" href="{{.EditLink "vq"}}">重置</a></div>
</form> </form>
@@ -424,6 +440,8 @@
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}} {{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}} {{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}} {{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
<label class="f" style="flex:0 1 150px"><span>交通方式</span> <label class="f" style="flex:0 1 150px"><span>交通方式</span>
<select name="mt"> <select name="mt">
<option value="">全部</option> <option value="">全部</option>
+5 -3
View File
@@ -40,14 +40,15 @@
<div class="card"> <div class="card">
<h2>已有遮罩区({{len .Masks}})</h2> <h2>已有遮罩区({{len .Masks}})</h2>
{{if .Masks}} {{if .Masks}}
<table class="tb"> <div class="tw">
<table class="tb tb-stack">
<thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead> <thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Masks}} {{range .Masks}}
<tr> <tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td> <td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td> <td class="mono" data-label="圆心">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
<td>{{printf "%.0f" .RadiusM}} 米</td> <td data-label="半径">{{printf "%.0f" .RadiusM}} 米</td>
<td> <td>
<button class="btn sm" type="button" data-edit <button class="btn sm" type="button" data-edit
data-id="{{.ID}}" data-name="{{.Name}}" data-id="{{.ID}}" data-name="{{.Name}}"
@@ -62,6 +63,7 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div> <div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div>
{{end}} {{end}}
+15 -16
View File
@@ -520,7 +520,11 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coord: coordSel(), points: coords }) body: JSON.stringify({ coord: coordSel(), points: coords })
}).then(function (r) { return r.json(); }).then(function (d) { }).then(function (r) {
// 500 时回的是 HTML,直接 r.json() 会抛 SyntaxError(英文),先判状态码
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function (d) {
if (d.error) throw new Error(d.error); if (d.error) throw new Error(d.error);
return d.items || []; return d.items || [];
}); });
@@ -671,29 +675,24 @@
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ---- // ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
var modal = document.getElementById('point-modal'); var modal = document.getElementById('point-modal');
var modalInited = false;
function openPointMap(lat, lon, name, addr) { function openPointMap(lat, lon, name, addr) {
document.getElementById('point-modal-title').textContent = name || '点位'; document.getElementById('point-modal-title').textContent = name || '点位';
document.getElementById('point-modal-meta').textContent = document.getElementById('point-modal-meta').textContent =
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6); (addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
modal.style.display = 'flex'; modal.style.display = 'flex';
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }]; var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
if (!modalInited) { if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; } // 统一交给 preview:它自己区分「首次建图」与「复用换点」,配置还没回来时也会把点排队补画。
window.rondMap.preview({ id: 'point-modal-map', points: pts }); // 原来这里同步置 modalInited=true,第二次打开时地图实例其实还没建好(_state 里没有条目),
modalInited = true; // 于是走 else 分支拿不到 st,该画的点被静默丢掉。
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级 window.rondMap.preview({ id: 'point-modal-map', points: pts });
var pin = function () { // 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
var st = window.rondMap._state['point-modal-map']; var pin = function () {
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
};
setTimeout(pin, 400);
setTimeout(pin, 1200);
} else {
var st = window.rondMap._state['point-modal-map']; var st = window.rondMap._state['point-modal-map'];
if (st && st.setPoints) st.setPoints(pts);
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); } if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
} };
setTimeout(pin, 400);
setTimeout(pin, 1200);
} }
function closePointMap() { modal.style.display = 'none'; } function closePointMap() { modal.style.display = 'none'; }
document.getElementById('point-modal-close').addEventListener('click', closePointMap); document.getElementById('point-modal-close').addEventListener('click', closePointMap);
+3 -1
View File
@@ -212,7 +212,9 @@
{{if ne .Nav "map"}} {{if ne .Nav "map"}}
<footer class="ft"> <footer class="ft">
{{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}} {{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}}
{{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{end}} {{/* 到访被删光的数据集 last_visit_at 是 NULL,而 d 收的是 time.Time —— 少判这一层就会
nil 解引用、页脚之后整段不渲染(客户端还拿到 200)。 */}}
{{if .Dataset}}{{if .Dataset.Last}} · 数据截至 {{d .Dataset.Last}}{{end}}{{end}}
</footer> </footer>
{{end}} {{end}}
{{if .HasMap}} {{if .HasMap}}
+9 -1
View File
@@ -384,13 +384,21 @@
}); });
var trackOn = false; var trackOn = false;
// 与图层开关同理:勾选后马上取消时请求还在飞,回包会把轨迹画回来;
// 序号顺带解决「连点筛选 / 切底图」时旧回包盖掉新回包。
var trackSeq = 0;
function refreshTrack() { function refreshTrack() {
var seq = ++trackSeq;
if (!trackOn) { window.rondMap.setTrack(null, null); return; } if (!trackOn) { window.rondMap.setTrack(null, null); return; }
var q = qs(); var q = qs();
loadJSON('/api/track' + (q ? '?' + q : '')) loadJSON('/api/track' + (q ? '?' + q : ''))
.then(function (d) { window.rondMap.setTrack(d.segs || [], d.modes || []); }) .then(function (d) {
if (seq !== trackSeq) return;
window.rondMap.setTrack(d.segs || [], d.modes || []);
})
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态 // 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
.catch(function (err) { .catch(function (err) {
if (seq !== trackSeq) return;
trackOn = false; trackOn = false;
document.getElementById('track-toggle').checked = false; document.getElementById('track-toggle').checked = false;
window.rondMap.setTrack(null, null); window.rondMap.setTrack(null, null);
+7 -5
View File
@@ -70,12 +70,12 @@
当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b> 当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b>
,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。 ,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。
</div> </div>
<div id="imgs-admin" data-src="{{.Place.SrcPK}}"> <div id="imgs-admin" data-src="{{.Place.SrcPK}}" data-name="{{.Place.Name}}">
<div class="imgs-grid" id="imgs-grid"> <div class="imgs-grid" id="imgs-grid">
{{range .Images}} {{range .Images}}
<figure class="imgs-cell" data-id="{{.ID}}"> <figure class="imgs-cell" data-id="{{.ID}}">
<img src="{{.URL}}" alt="" loading="lazy"> <img src="{{.URL}}" alt="{{if $.Place.Name}}{{$.Place.Name}} 的图片{{else}}地点图片{{end}}" loading="lazy">
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张">×</button> <button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张" aria-label="删除这张图片">×</button>
</figure> </figure>
{{end}} {{end}}
</div> </div>
@@ -228,7 +228,7 @@
<button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button> <button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button>
<button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button> <button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button>
<div class="imgs-stage" id="imgs-stage"> <div class="imgs-stage" id="imgs-stage">
<img id="imgs-img" alt=""> <img id="imgs-img" alt="{{if .Place.Name}}{{.Place.Name}} 的图片{{else}}地点图片{{end}}">
</div> </div>
</div> </div>
@@ -386,9 +386,11 @@
fig.className = 'imgs-cell'; fig.className = 'imgs-cell';
fig.setAttribute('data-id', id); fig.setAttribute('data-id', id);
var img = document.createElement('img'); var img = document.createElement('img');
img.src = url; img.loading = 'lazy'; img.alt = ''; img.src = url; img.loading = 'lazy';
img.alt = (admin && admin.dataset.name ? admin.dataset.name + ' 的图片' : '地点图片');
var del = document.createElement('button'); var del = document.createElement('button');
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张'; del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
del.setAttribute('aria-label', '删除这张图片');
del.setAttribute('data-id', id); del.setAttribute('data-id', id);
fig.appendChild(img); fig.appendChild(del); fig.appendChild(img); fig.appendChild(del);
return fig; return fig;
+1 -1
View File
@@ -68,7 +68,7 @@
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead> <thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
<tbody> <tbody>
<tr><td>最北</td><td data-label="地点">{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num" data-label="纬度">{{f1 .North.Lat}}°N</td></tr> <tr><td>最北</td><td data-label="地点">{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num" data-label="纬度">{{f1 .North.Lat}}°N</td></tr>
{{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td>{{end}} {{if .South}}<tr><td>最南</td><td data-label="地点">{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num" data-label="纬度">{{f1 .South.Lat}}°N</td></tr>{{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
+48
View File
@@ -1,9 +1,12 @@
package web package web
import ( import (
"bytes"
"html/template" "html/template"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
) )
// TestPageTemplatesParse 把每个页面模板都解析一遍。 // TestPageTemplatesParse 把每个页面模板都解析一遍。
@@ -17,3 +20,48 @@ func TestPageTemplatesParse(t *testing.T) {
} }
} }
} }
// TestLayoutFooterToleratesMissingLastVisit 钉住页脚在「数据集一条到访都没有」时也渲染得完。
//
// last_visit_at 可以为 NULL(refreshDatasetAgg 用 LEFT JOIN 求 max,零到访就写 NULL),
// 而 d 的形参是 time.Time、实参是 *time.Time:非 nil 时模板会自动解一层指针,**nil 时报
// "dereference of nil pointer" 并中断渲染**。render() 只把错误记进日志,客户端拿到的是
// HTTP 200 加一个「页脚之后整段不存在」的页面——滚动恢复、地图脚本引入全丢,
// 服务端一点动静都没有。所以这里断言「有收尾的 </html>」,而不只是断言没报错。
func TestLayoutFooterToleratesMissingLastVisit(t *testing.T) {
base, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(tmplFS, "templates/layout.html")
if err != nil {
t.Fatalf("解析 layout.html: %v", err)
}
tpl, err := base.Parse(`{{define "content"}}正文{{end}}`)
if err != nil {
t.Fatalf("补 content 块: %v", err)
}
last := time.Date(2026, 9, 21, 12, 0, 0, 0, time.Local)
cases := []struct {
name string
ds *Dataset
want string
}{
{"没有数据集", nil, ""},
{"数据集里一条到访都没有", &Dataset{Name: "空数据集"}, ""},
{"正常", &Dataset{Name: "有数据", Last: &last}, "数据截至 2026-09-21"},
}
for _, c := range cases {
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "layout", Page{Nav: "home", Title: "测", Dataset: c.ds}); err != nil {
t.Fatalf("%s: 渲染失败: %v", c.name, err)
}
got := buf.String()
if !strings.Contains(got, "</html>") {
t.Errorf("%s: 页面被截断(没有收尾的 </html>)", c.name)
}
if c.want == "" {
if strings.Contains(got, "数据截至") {
t.Errorf("%s: 没有可信的截止时间时不该渲染「数据截至」", c.name)
}
} else if !strings.Contains(got, c.want) {
t.Errorf("%s: 页脚应含 %q", c.name, c.want)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// moduleRoot 返回仓库根目录(含 go.mod 的那层)。
//
// 不依赖当前工作目录:测试进程的 cwd 会被 newMovementFixture → chdirModuleRoot 改成仓库根,
// 而用例执行顺序并不保证——于是「相对路径」到底相对谁,取决于这条用例有没有跟夹具用例一起跑。
// 读源码的静态守卫一旦踩上这个,单跑是绿的、混跑就报「找不到文件」,或者更糟:
// filepath.Walk(".") 扫的子树悄悄从 internal/web 变成整个仓库,覆盖面变了却没人知道。
// 一律从本测试文件的真实位置往上找 go.mod。
func moduleRoot(t *testing.T) string {
t.Helper()
_, this, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("拿不到本测试文件的路径")
}
dir := filepath.Dir(this)
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到 go.mod,无法定位仓库根目录")
}
dir = parent
}
}
// pkgFile 返回本包(internal/web)内某个源文件的绝对路径。
func pkgFile(t *testing.T, name string) string {
t.Helper()
return filepath.Join(moduleRoot(t), "internal", "web", name)
}
+107
View File
@@ -0,0 +1,107 @@
package web
import (
"context"
"database/sql"
"testing"
)
// 列表页(fillTopicStatsBatch:一条 UNION ALL)与详情页(topicContent:逐专题查询)
// 对同一专题必须给出同样的统计 —— 列表上的数字点进去要对得上。
// 顺带钉住批量版的占位符编号:错一个就是 pgx「expected N arguments, got M」。
func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
f := newLoginFixture(t)
ctx := context.Background()
var ds int64
if err := f.srv.db.QueryRowContext(ctx, `SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&ds); err != nil {
t.Skipf("没有生效数据集:%v", err)
}
var minAt, maxAt sql.NullTime
if err := f.srv.db.QueryRowContext(ctx,
`SELECT min(arrival), max(arrival) FROM visits WHERE dataset_id=$1`, ds).Scan(&minAt, &maxAt); err != nil {
t.Fatal(err)
}
if !minAt.Valid || !maxAt.Valid {
t.Skip("生效数据集里没有到访记录")
}
var lat, lon float64
if err := f.srv.db.QueryRowContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
WHERE dataset_id=$1 ORDER BY visit_count DESC LIMIT 1`, ds).Scan(&lat, &lon); err != nil {
t.Fatal(err)
}
var city string
_ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1
AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city)
mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2)
base := []Topic{
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
{ID: 2, Title: "前半段", StartAt: minAt.Time, EndAt: mid},
{ID: 3, Title: "后半段", StartAt: mid, EndAt: maxAt.Time},
{ID: 4, Title: "按区域圈定", StartAt: minAt.Time, EndAt: maxAt.Time,
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
CenterLat: 1, CenterLon: 1, RadiusKm: 1},
}
type round struct {
name string
regions []string
exclude bool
wantNonZero bool
}
rounds := []round{{name: "无区域过滤", wantNonZero: true}}
if city != "" {
rounds = append(rounds,
round{name: "白名单:" + city, regions: []string{city}, wantNonZero: true},
round{name: "黑名单:" + city, regions: []string{city}, exclude: true},
)
}
for _, rd := range rounds {
// 详情页路径:逐专题查询
want := append([]Topic(nil), base...)
for i := range want {
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
if err != nil {
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
}
want[i].PlaceCount, want[i].VisitCount = len(points), visits
want[i].DayCount, want[i].CityCount = days, cities
for _, p := range points {
want[i].DwellMin += p.Dwell
}
}
// 列表页路径:一条 UNION ALL
got := append([]Topic(nil), base...)
if err := f.srv.fillTopicStatsBatch(ctx, got, ds, rd.regions, rd.exclude); err != nil {
t.Fatalf("[%s] fillTopicStatsBatch: %v", rd.name, err)
}
nonZero := 0
for i := range got {
if got[i].PlaceCount != want[i].PlaceCount || got[i].VisitCount != want[i].VisitCount ||
got[i].DayCount != want[i].DayCount || got[i].CityCount != want[i].CityCount ||
got[i].DwellMin != want[i].DwellMin {
t.Errorf("[%s] 专题 #%d 批量与详情页不一致:\n 批量 地点/到访/天数/城市/停留 = %d/%d/%d/%d/%d\n 详情页同项 = %d/%d/%d/%d/%d",
rd.name, got[i].ID, got[i].PlaceCount, got[i].VisitCount, got[i].DayCount,
got[i].CityCount, got[i].DwellMin,
want[i].PlaceCount, want[i].VisitCount, want[i].DayCount, want[i].CityCount, want[i].DwellMin)
}
if got[i].PlaceCount > 0 {
nonZero++
}
// 半径 1km 圈在 (1,1) 是海面,必须一个地点都圈不到(证明区域条件真的生效)
if got[i].ID == 5 && (got[i].PlaceCount != 0 || got[i].VisitCount != 0 || got[i].DwellMin != 0) {
t.Errorf("[%s] 半径 1km 圈在 (1,1) 的专题不应有内容,实得 %d 地点 %d 到访",
rd.name, got[i].PlaceCount, got[i].VisitCount)
}
}
if rd.wantNonZero && nonZero == 0 {
t.Fatalf("[%s] 所有专题统计都是 0,用例形同虚设", rd.name)
}
t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero)
}
}
+117
View File
@@ -0,0 +1,117 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
)
// enableTOTP 给 fixture 账号开上两步验证,返回明文秘钥;收尾时关掉并把计数器还原。
func enableTOTP(t *testing.T, f *loginFixture) string {
t.Helper()
ctx := context.Background()
secret, err := auth.GenerateTOTPSecret()
if err != nil {
t.Fatalf("生成秘钥失败: %v", err)
}
if err := f.srv.auth.SetTOTPSecret(ctx, f.uid, secret); err != nil {
t.Fatalf("写秘钥失败: %v", err)
}
if err := f.srv.auth.EnableTOTP(ctx, f.uid); err != nil {
t.Fatalf("开启两步验证失败: %v", err)
}
f.srv.loginGuard.Reset()
t.Cleanup(func() {
f.srv.auth.DisableTOTP(ctx, f.uid)
f.srv.loginGuard.Reset()
})
return secret
}
// pending2FA 签发一张当前账号的中间令牌(等价于口令已经输对了)。
func pending2FA(t *testing.T, f *loginFixture) string {
t.Helper()
u, err := f.srv.auth.UserByID(context.Background(), f.uid)
if err != nil {
t.Fatalf("取账号失败: %v", err)
}
return f.srv.auth.SignPending2FA(u.ID, u.Hash)
}
// attempt2FA 走一次真实的验证码提交。
func attempt2FA(t *testing.T, f *loginFixture, pending, code string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"pending": {pending}, "code": {code}}
r := httptest.NewRequest("POST", "/admin/login/twofa", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
f.srv.login2FA(w, r)
return w
}
// S7 回归:验证码这一环必须限速。
//
// 中间令牌有 5 分钟有效期,而口令那一环的短锁在「口令正确」时就被清掉了,
// 所以攻击者只要知道口令,就能拿着一张令牌在 5 分钟里无限试 6 位码。
func TestLogin2FARateLimited(t *testing.T) {
f := newLoginFixture(t)
secret := enableTOTP(t, f)
pending := pending2FA(t, f)
// 连错到阈值:阈值内只提示「验证码不正确」(手滑不该被罚),过了阈值才上锁
lockedAt := 0
for i := 1; i <= 8; i++ {
rec := attempt2FA(t, f, pending, "000000")
if strings.Contains(rec.Body.String(), "频繁") {
lockedAt = i
break
}
if !strings.Contains(rec.Body.String(), "验证码不正确") {
t.Fatalf("第 %d 次错码的提示不对: %s", i, firstLine(rec.Body.String()))
}
}
if lockedAt == 0 {
t.Fatal("连错 8 次也没触发短锁")
}
if lockedAt < 2 {
t.Errorf("第一次错码就上锁了,正常手滑会被误伤(lockedAt=%d)", lockedAt)
}
// 上锁之后,即使递上**正确**的码也必须被拦下——否则限速没生效
if code, err := auth.TOTPCode(secret, time.Now()); err == nil {
rec := attempt2FA(t, f, pending, code)
if !strings.Contains(rec.Body.String(), "频繁") {
t.Errorf("锁定期内递正确码没被拦住,仍走到了后续分支: %s", firstLine(rec.Body.String()))
}
} else {
t.Fatalf("算验证码失败: %v", err)
}
}
// S7 回归:同一个验证码不能换到第二个会话。
func TestLogin2FACodeIsSingleUse(t *testing.T) {
f := newLoginFixture(t)
secret := enableTOTP(t, f)
pending := pending2FA(t, f)
code, err := auth.TOTPCode(secret, time.Now())
if err != nil {
t.Fatalf("算验证码失败: %v", err)
}
rec := attempt2FA(t, f, pending, code)
if rec.Code != http.StatusSeeOther {
t.Fatalf("第一次用正确码应登录成功,实际 %d: %s", rec.Code, firstLine(rec.Body.String()))
}
rec = attempt2FA(t, f, pending, code)
if !strings.Contains(rec.Body.String(), "刚用过") {
t.Errorf("同一个码被重放了: %d %s", rec.Code, firstLine(rec.Body.String()))
}
}
+104
View File
@@ -0,0 +1,104 @@
package web
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestEditVisitUpdateAtomicOnClampFailure 证明「改到访」整组写入是一个事务:
// 其中夹行程那步失败时,前面已经写进去的到访时间必须一并回滚。
//
// 手法是给 movements 挂一个必然报错的 BEFORE UPDATE 触发器——夹行程(clampVisitMovements)
// 就是靠 UPDATE movements 落地的,触发器一炸它就返回错误。
// 回滚生效 ⇒ 到访时间保持原值;回滚失效 ⇒ 到访时间已被改掉(旧代码各写各的、没有事务,
// 到访那条 UPDATE 早就提交了)。
func TestEditVisitUpdateAtomicOnClampFailure(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 挑一条被行程引用的到访,连同它那条行程的开始时间
var visitSrc, movePK int
var mvSt, mvEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT m.from_visit_src, m.src_pk, m.started_at, m.ended_at
FROM movements m WHERE m.dataset_id=$1 AND m.from_visit_src IS NOT NULL
AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
ORDER BY m.src_pk LIMIT 1`, dsID).Scan(&visitSrc, &movePK, &mvSt, &mvEn); err != nil {
t.Fatalf("挑不出「被行程引用的到访」: %v", err)
}
var origArrival time.Time
var origDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&origArrival, &origDeparture); err != nil {
t.Fatalf("读取到访失败: %v", err)
}
// 把离开时间推到行程开始之后,夹行程就必然要动手改这条行程
newArrival := mvSt.Add(-2 * time.Hour).Truncate(time.Minute)
newDeparture := mvSt.Add(time.Hour).Truncate(time.Minute)
installMovementUpdateFailTrigger(t, ctx, pg)
form := url.Values{
"src_pk": {fmt.Sprint(visitSrc)},
"arrival": {newArrival.Local().Format("2006-01-02T15:04")},
"departure": {newDeparture.Local().Format("2006-01-02T15:04")},
}
r := httptest.NewRequest("POST", "/admin/edit/visit/update", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
f.srv.editVisitUpdate(w, r.WithContext(f.ctx))
if w.Code != http.StatusInternalServerError {
t.Fatalf("夹行程失败应报 500,实际 code=%d body=%s", w.Code, w.Body.String())
}
var gotArrival time.Time
var gotDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&gotArrival, &gotDeparture); err != nil {
t.Fatalf("回读到访失败: %v", err)
}
same := gotArrival.Equal(origArrival) && gotDeparture.Valid == origDeparture.Valid &&
(!gotDeparture.Valid || gotDeparture.Time.Equal(origDeparture.Time))
if !same {
t.Errorf("夹行程失败后到访 #%d 的时间被改掉了:%v/%v → %v/%v(整组写入应当回滚)",
visitSrc, origArrival, origDeparture, gotArrival, gotDeparture)
}
// 行程那一端也不能被改
var gotSt, gotEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, movePK).Scan(&gotSt, &gotEn); err != nil {
t.Fatalf("回读行程失败: %v", err)
}
if !gotSt.Equal(mvSt) || !gotEn.Equal(mvEn) {
t.Errorf("行程 #%d 的时间被改掉了:%v/%v → %v/%v", movePK, mvSt, mvEn, gotSt, gotEn)
}
t.Logf("夹行程失败后到访 #%d(%v ~ %v)与行程 #%d 均保持原值 ✓", visitSrc, origArrival, gotDeparture, movePK)
}
// installMovementUpdateFailTrigger 给 movements 挂一个必然报错的 BEFORE UPDATE 触发器。
// 名字带 e2e_ 前缀以免撞上别的对象;用完即弃(t.Cleanup 里 drop,且在连接关闭之前跑)。
func installMovementUpdateFailTrigger(t *testing.T, ctx context.Context, pg *sql.DB) {
t.Helper()
if _, err := pg.ExecContext(ctx, `CREATE OR REPLACE FUNCTION e2e_fail_movement_update() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'e2e: 事务原子性用例故意失败'; END $$`); err != nil {
t.Fatalf("建触发器函数失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`); err != nil {
t.Fatalf("清理旧触发器失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `CREATE TRIGGER e2e_fail_movement_update
BEFORE UPDATE ON movements FOR EACH ROW EXECUTE FUNCTION e2e_fail_movement_update()`); err != nil {
t.Fatalf("建触发器失败: %v", err)
}
t.Cleanup(func() {
pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`)
pg.ExecContext(ctx, `DROP FUNCTION IF EXISTS e2e_fail_movement_update()`)
})
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"rond-with-you/internal/auth"
)
func uploadsIn(t *testing.T, dir string) map[string]bool {
t.Helper()
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, e := range ents {
out[e.Name()] = true
}
return out
}
// D8 回归:备份包解析失败时,已经写进 uploads 的那份半成品必须删掉。
// 旧写法只在「sha 完全相同」那条路上 os.Remove,其余失败(rond.Open / rond.Parse /
// Import 任一)都把文件留在盘上 —— 反复上传坏包就是一堆垃圾,还占着 findSourceArchive
// 每次都要去算 sha256 的那个目录。
func TestUploadRemovesStoredFileOnFailure(t *testing.T) {
f := newLoginFixture(t)
dir := f.srv.cfg.UploadDir
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
before := uploadsIn(t, dir)
// 名字像备份包、内容是垃圾:必然走到「写盘成功 → 解析失败」这条路径。
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("backup", "zz-broken.rondbackup")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("这不是一个 zip 包")); err != nil {
t.Fatal(err)
}
mw.Close()
r := httptest.NewRequest("POST", "/admin/upload", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r = r.WithContext(ctxWithUser(&auth.User{ID: f.uid}))
w := httptest.NewRecorder()
f.srv.adminUpload(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("应当以 303 回管理页,实际 %d", w.Code)
}
loc, _ := url.QueryUnescape(w.Header().Get("Location"))
// 前提守卫:必须真的是「文件已经落盘读过了、才在解析这一步失败」。
// 若落在更早的分支(读上传内容失败 / 类型不对),上传目录压根没动,
// 下面那条「没有残留」的断言只是空过。
if !strings.Contains(loc, "解析备份失败") {
t.Fatalf("没走到「写盘后解析失败」这条路,测试前提不成立: %q", loc)
}
for name := range uploadsIn(t, dir) {
if !before[name] {
t.Errorf("失败后残留在 uploads 里的半成品: %s", name)
}
}
}
+36 -17
View File
@@ -86,6 +86,10 @@ type Page struct {
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值, // BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。 // 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
BackQS template.URL BackQS template.URL
// SelfPath 是 BackQS 的**未编码**原文。需要在这一层再编码一次的场景(编辑页的
// ?back=)必须用它——拿已编码的 BackQS 再编码会变成 %252F…(见 EditPlaceLink)。
SelfPath string
} }
// IsAdmin 报告当前访问者是否已登录后台。 // IsAdmin 报告当前访问者是否已登录后台。
@@ -143,12 +147,12 @@ func (p Page) filterPairs() []string {
return kv return kv
} }
// backQS 把当前请求的地址编码成 ?back= 的值。 // selfPath 是当前请求的地址(路径 + 规范化后的查询串),**未编码**。
// //
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页), // 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。 // 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。 // 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
func backQS(r *http.Request) template.URL { func selfPath(r *http.Request) string {
q := r.URL.Query() q := r.URL.Query()
q.Del("back") q.Del("back")
// ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次 // ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次
@@ -158,27 +162,39 @@ func backQS(r *http.Request) template.URL {
if e := q.Encode(); e != "" { if e := q.Encode(); e != "" {
u += "?" + 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」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 —— // 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。 // 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
func backFromRequest(r *http.Request) template.URL { // 控制字符(含 %00、%0b)也一律不收:浏览器会把 NUL 之类的字符悄悄吃掉,
b := strings.TrimSpace(r.URL.Query().Get("back")) // 留着就是给「同一串地址在不同环节解析成不同东西」留口子。
if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") { //
// ?back=、后台表单的 next、投票后的跳转三处共用这一份判据,别再各写一遍。
func safeInternalPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return "" return ""
} }
for i := 0; i < len(b); i++ { for i := 0; i < len(p); i++ {
// 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收: if c := p[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
// 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子
if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
return "" 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 字段): // BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段):
@@ -231,14 +247,18 @@ func (p Page) PlaceLink(id int64) template.URL {
// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。 // EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。
// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。 // 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。
//
// 这里必须用**未编码**的 SelfPath:BackQS 已经 QueryEscape 过一次,
// 再转一次就成了 %252F…,服务端解出来不以 / 开头会被当成站外地址丢掉,
// 于是「直接打开 /places/1 再点编辑」的编辑页上返回按钮凭空消失。
func (p Page) EditPlaceLink(srcPK int) template.URL { func (p Page) EditPlaceLink(srcPK int) template.URL {
back := p.Back back := string(p.Back)
if back == "" { if back == "" {
back = p.BackQS back = p.SelfPath
} }
u := "/admin/edit?open=" + strconv.Itoa(srcPK) u := "/admin/edit?open=" + strconv.Itoa(srcPK)
if back != "" { if back != "" {
u += "&back=" + url.QueryEscape(string(back)) u += "&back=" + url.QueryEscape(back)
} }
return template.URL(u) return template.URL(u)
} }
@@ -1364,7 +1384,6 @@ func templateFuncs() template.FuncMap {
} }
return b return b
}, },
"safe": func(s string) template.HTML { return template.HTML(s) },
"colorof": colorOf, "colorof": colorOf,
"icon": iconOf, "icon": iconOf,
"iconor": iconOr, "iconor": iconOr,
+138
View File
@@ -0,0 +1,138 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
// sliceFunc 取出 src 里某个函数的函数体(从签名到下一个顶层 func 之前)。
// 找不到直接 Fatal——否则静态守卫会因为「切片切空了」而假绿。
func sliceFunc(t *testing.T, src, sig string) string {
t.Helper()
i := strings.Index(src, sig)
if i < 0 {
t.Fatalf("源码里找不到 %s,静态守卫需要同步更新", sig)
}
body := src[i:]
if j := strings.Index(body[len(sig):], "\nfunc "); j >= 0 {
body = body[:len(sig)+j]
}
if len(body) < 200 {
t.Fatalf("%s 的函数体只切到 %d 字节,切片判据失效", sig, len(body))
}
return body
}
// D5 回归(静态):收藏切换必须是「一条语句翻转并回读」。
//
// 以前是 UPDATE 之后再单独 SELECT 回读,那次回读一旦失败就落进 else 报「已取消收藏」——
// 状态根本没读出来,这句话有一半是错的。换成 UPDATE ... RETURNING 后没有第二次读,
// 也就没有「读失败只能猜」这个分支。
//
// 为什么只能静态守:要让那条独立回读失败得先有故障注入(本项目还没有这套设施),
// 而正常路径下新旧写法的输出完全一样,行为测试分辨不出来。
func TestVisitBookmarkTogglesInOneStatement(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "handlers_edit.go"))
if err != nil {
t.Fatalf("读 handlers_edit.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) adminVisitBookmark(")
if !strings.Contains(body, "RETURNING bookmarked") {
t.Error("收藏切换应当用 UPDATE ... RETURNING 一条语句翻转并回读")
}
if strings.Contains(body, "SELECT bookmarked") {
t.Error("不该再有独立的回读语句:它失败时无法知道状态,只能猜")
}
if strings.Contains(body, "err == nil && on") {
t.Error("不许把「读失败」和「读到 false」合成一个分支——那正是误报「已取消收藏」的写法")
}
if !strings.Contains(body, "s.serverError(w, r, err)") {
t.Error("语句失败必须按错误上报,不能伪装成成功")
}
}
// D5 回归(行为):连点两次星标要真的来回翻,且提示必须与库里的状态一致。
// 「翻过来但提示说反了」这种错,只有把提示和库内实况对起来才看得出来。
func TestVisitBookmarkFlashMatchesRow(t *testing.T) {
f := newMovementFixture(t)
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM visits
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到到访记录: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE visits SET bookmarked=FALSE WHERE dataset_id=$1 AND src_pk=$2`,
f.dsID, src)
})
click := func() (*httptest.ResponseRecorder, bool) {
t.Helper()
form := url.Values{"src_pk": {fmt.Sprint(src)}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
var on bool
if err := f.pg.QueryRowContext(f.ctx, `SELECT bookmarked FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&on); err != nil {
t.Fatalf("读库内收藏状态失败: %v", err)
}
return rec, on
}
rec, on := click()
if !on {
t.Fatalf("点一次应当把收藏打开,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已标记为收藏" {
t.Errorf("提示 %q 与库内状态(已收藏)不一致", msg)
}
rec, on = click()
if on {
t.Fatalf("再点一次应当取消收藏,库里仍是 %v", on)
}
if msg := flashMsg(t, rec); msg != "已取消收藏" {
t.Errorf("提示 %q 与库内状态(未收藏)不一致", msg)
}
// 不存在的记录要报错,不能假装成功
form := url.Values{"src_pk": {"2147483000"}}
r := httptest.NewRequest("POST", "/admin/edit/visit/bookmark", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(f.ctx)
rec = httptest.NewRecorder()
f.srv.adminVisitBookmark(rec, r)
if msg := flashMsg(t, rec); strings.Contains(msg, "已") {
t.Errorf("对不存在的记录不该回一句成功提示: %q", msg)
}
}
// flashMsg 从重定向的 Location 里取出 flash 文案(redirectEdit 把提示挂在查询串上)。
func flashMsg(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
loc := rec.Header().Get("Location")
if loc == "" {
t.Fatalf("没有重定向,实际状态 %d:%s", rec.Code, firstLine(rec.Body.String()))
}
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("解析 Location 失败: %v", err)
}
msg := u.Query().Get("ok")
if msg == "" {
msg = u.Query().Get("err")
}
if msg == "" {
t.Fatalf("Location 里没有提示文案: %s", loc)
}
return msg
}
+11 -2
View File
@@ -202,8 +202,17 @@ func checkWideTablesOutsideGrid(t *testing.T, path, body string) int {
} }
if !strings.Contains(tag, "tb-stack") { if !strings.Contains(tag, "tb-stack") {
// 照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路,暂不纳入堆叠。 // 照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路,暂不纳入堆叠。
if inTW && !strings.Contains(tag, "photo-table") { if strings.Contains(tag, "photo-table") {
t.Errorf("%s:.tw 里的表格没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑)", path) continue
}
// 判据从「在 .tw 里」扩成「只要用了 tb 这个宽表基类就得堆叠」:
// 漏了 .tw 包裹的宽表(/admin/fog/mask 的遮罩表就是这样)以前两条守卫都看不见。
usesTB := false
if m := classRe.FindStringSubmatch(tag); m != nil {
usesTB = strings.Contains(" "+m[1]+" ", " tb ")
}
if inTW || usesTB {
t.Errorf("%s:宽表没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑):%s", path, tag)
} }
continue continue
} }
+14 -2
View File
@@ -47,15 +47,27 @@ func main() {
if adminUser == "" { if adminUser == "" {
adminUser = "admin" adminUser = "admin"
} }
// 不再写死出厂口令:公网部署漏配 ROND_ADMIN_PASSWORD 时,admin/rond-admin
// 等于后台没上锁。改成生成随机口令并在启动日志里打印一次。
// 取自环境变量的那份**不打日志**,免得口令被日志系统长期留存。
adminPass := os.Getenv("ROND_ADMIN_PASSWORD") adminPass := os.Getenv("ROND_ADMIN_PASSWORD")
generatedPass := false
if adminPass == "" { 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} store := &auth.Store{DB: database, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays}
if created, err := store.EnsureDefaultUser(context.Background(), adminUser, adminPass); err != nil { if created, err := store.EnsureDefaultUser(context.Background(), adminUser, adminPass); err != nil {
log.Fatalf("初始化管理员失败: %v", err) log.Fatalf("初始化管理员失败: %v", err)
} else if created { } 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) srv, err := web.New(cfg, database)
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""关键页冒烟:确认页面真的渲染出来了,而不只是「模板测试通过」。
Go 侧的模板守卫只能证明模板里有那段文字,证明不了浏览器打开后能看到它——
白屏、JS 抛错、关键区块高度为 0,模板测试全都是绿的。这里按三种口径各走一遍:
1. 站长(浏览器):每页断言 HTTP 200、落点路径未跳走、关键区块在、零 JS 报错
2. 访客(纯 HTTP):公开页 200;后台页必须 303 跳登录(绝不能 200)
3. 访客(浏览器):抽两页验零 JS 报错(只在访客侧复现的 bug 就藏在这里)
依赖 rond-web-verify 技能里的 site_cdp.py(CDP 直连本机 Chrome)。
用法:
python scripts/smoke.py
python scripts/smoke.py --only /map --shot-dir storage/.verify/smoke
python scripts/smoke.py --base http://localhost:8081
环境变量:ROND_CDP / ROND_BASE / ROND_PY
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_CDP = os.path.join(os.path.expanduser("~"), ".workbuddy", "skills",
"rond-web-verify", "scripts", "site_cdp.py")
# kind: public=谁都能看 guard=受「公开页面」总开关控制 admin=仅站长
# 关键区块选的是**页面内容区**,不是布局骨架:nav.nav 在 layout.html 里,
# 内容块整个没渲染出来它照样在,等于没断言。地图页取地图容器,其余取首个 .card。
# 用 .card 而不是 div.card:专题列表的卡片是 <a class="card topic-card">,
# 写 div.card 会一个都选不中,而 null 的尺寸也是 0,看着像「元素被压扁了」。
PAGES = [
("/", "public", "#home-map", "首页 + 足迹地图"),
("/map", "guard", "#map", "全屏地图 + 筛选面板"),
("/places", "guard", ".card", "地点列表"),
("/timeline", "guard", ".card", "时间线"),
("/stats", "guard", ".card", "统计"),
("/report", "guard", ".card", "年报"),
("/topics", "public", ".card", "专题列表"),
("{place}", "public", "#place-map", "地点详情"),
("{topic}", "public", "#map", "专题详情"),
("/admin", "admin", ".card", "后台首页"),
("/admin/edit", "admin", "#taxonomy", "数据编辑"),
("/admin/photos", "admin", ".card", "照片导入"),
("/admin/notes", "admin", ".card", "备注"),
("/admin/fillup", "admin", ".card", "待补全"),
("/admin/topics", "admin", ".card", "专题管理"),
("/admin/settings", "admin", ".card", "设置"),
]
# 选择器里可能带引号,走 --eval-file 而不是 --eval:Git Bash 会把 \" 吃掉,
# 脚本只丢一个 exit 23、连错误都看不到。
PROBE = """(function () {
var nav = (performance.getEntriesByType('navigation') || [])[0] || {};
var el = document.querySelector(%(sel)s);
var box = el ? el.getBoundingClientRect() : null;
var errs = window.__errs || [];
return JSON.stringify({
st: nav.responseStatus || 0,
path: location.pathname,
found: !!el,
w: box ? Math.round(box.width) : 0,
h: box ? Math.round(box.height) : 0,
body: (document.body.innerText || '').length,
errs: errs.length,
e0: errs.length ? String(errs[0]) : ''
});
})()
"""
def http(path, base, cookie=None, timeout=20):
"""不跟随跳转,拿原始状态码——后台守卫的 303 正是要断言的东西。"""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **kw):
return None
req = urllib.request.Request(base.rstrip("/") + path)
if cookie:
req.add_header("Cookie", cookie)
opener = urllib.request.build_opener(NoRedirect)
try:
with opener.open(req, timeout=timeout) as r:
return r.status, ""
except urllib.error.HTTPError as e:
return e.code, (e.headers.get("Location") or "")
except Exception as e:
return 0, str(e)
def session_cookie(cdp, root, user="admin"):
"""复用 site_cdp.py 自签会话的能力(它自己读 conf/app.ini 与 users 表)。"""
code = (
"import sys; sys.path.insert(0, %r)\n"
"import site_cdp as S\n"
"cfg = S.read_config(%r)\n"
"print(S.session_token(cfg, S.find_psql(None), %r)[1])\n" % (
os.path.dirname(cdp), root, user)
)
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
cwd=root, timeout=90)
tok = (out.stdout or "").strip().splitlines()
if not tok:
raise SystemExit("没能自签会话 cookie:\n" + (out.stderr or out.stdout or "")[-500:])
return "rond_session=" + tok[-1]
def discover(base):
"""地点 id 从公开的 /api/points 取;专题 id 从 /topics 页面里抠。"""
place = topic = None
try:
with urllib.request.urlopen(base.rstrip("/") + "/api/points", timeout=20) as r:
pts = json.loads(r.read().decode("utf-8")).get("points") or []
if pts:
place = str(pts[0]["id"])
except Exception:
pass
try:
with urllib.request.urlopen(base.rstrip("/") + "/topics", timeout=20) as r:
html = r.read().decode("utf-8", "replace")
m = re.search(r"/topics/(\d+)", html)
if m:
topic = m.group(1)
except Exception:
pass
return place, topic
def probe(cdp, python, base, path, width, height, visitor, sel, shot, wait):
expr = os.path.join(ROOT, "storage", ".verify", "smoke", "_probe.js")
os.makedirs(os.path.dirname(expr), exist_ok=True)
with open(expr, "w", encoding="utf-8") as f:
f.write(PROBE % {"sel": json.dumps(sel)})
cmd = [python, cdp, "--root", ROOT, "--base", base, "--path", path,
"--width", str(width), "--height", str(height), "--wait", str(wait),
"--eval-file", expr]
if visitor:
cmd.append("--visitor")
if shot:
cmd += ["--shot", shot]
p = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT, timeout=180)
data = None
jserrs = "?"
for line in (p.stdout or "").splitlines():
line = line.strip()
if line.startswith('"') and data is None:
try:
data = json.loads(json.loads(line))
except Exception:
pass
if line.startswith("# JS 报错:"):
jserrs = line.split(":", 1)[1].strip()
if data is None:
return None, jserrs, ((p.stderr or p.stdout or "")[-300:])
return data, jserrs, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=os.environ.get("ROND_BASE", "http://localhost:8080"))
ap.add_argument("--cdp", default=os.environ.get("ROND_CDP", DEFAULT_CDP))
ap.add_argument("--python", default=os.environ.get("ROND_PY", sys.executable))
ap.add_argument("--width", type=int, default=1440)
ap.add_argument("--height", type=int, default=900)
ap.add_argument("--wait", type=float, default=3.0)
ap.add_argument("--only", help="只跑匹配这个子串的页面")
ap.add_argument("--shot-dir", help="每页存一张整屏截图(站长口径)")
ap.add_argument("--min-body", type=int, default=100,
help="正文字符数下限,只用来兜「整页空白」;内容稀疏的页面(如专题列表)本来就少")
args = ap.parse_args()
if not os.path.isfile(args.cdp):
raise SystemExit(
"找不到 site_cdp.py:%s\n"
"设 ROND_CDP 指过去,或先装 rond-web-verify 技能。" % args.cdp)
base = args.base.rstrip("/")
st, _ = http("/healthz", base)
if st != 200:
raise SystemExit(
"站点没在 %s 上响应(/healthz => %s)。先起服务:\n"
" go build -o bin/rond.exe .\n"
" 然后用 Bash 工具的后台任务跑 ./bin/rond.exe" % (base, st or "连不上"))
place, topic = discover(base)
subs = {"{place}": "/places/" + place if place else None,
"{topic}": "/topics/" + topic if topic else None}
cookie = session_cookie(args.cdp, ROOT)
# 公开页面总开关:以访客能否看 /places 为准,同类的 guard 页必须一致
pub_on = http("/places", base)[0] == 200
pages = []
for raw, kind, sel, desc in PAGES:
path = subs.get(raw, raw)
if path is None:
pages.append((raw, kind, sel, desc, None))
else:
pages.append((raw, kind, sel, desc, path))
print("== %s 公开页面开关=%s 地点=%s 专题=%s" % (
base, "开" if pub_on else "关", place or "?", topic or "?"))
fails, warns, skipped = [], [], []
for raw, kind, sel, desc, path in pages:
if path is None:
skipped.append("%s(发现不到 id)" % raw)
print("%-18s 跳过 %s" % (raw, desc))
continue
if args.only and args.only not in path:
continue
# 站长:浏览器,全量断言
shot = None
if args.shot_dir:
os.makedirs(args.shot_dir, exist_ok=True)
shot = os.path.join(args.shot_dir, path.strip("/").replace("/", "_") or "home")
shot += ".png"
data, jserrs, err = probe(args.cdp, args.python, base, path, args.width,
args.height, False, sel, shot, args.wait)
tag = "%-18s" % path
if data is None:
fails.append("%s 站长:跑失败 %s" % (path, err))
print("%s ✗ 跑失败 %s" % (tag, err))
continue
# 「没选中」和「选中了但宽高为 0」是两回事,别都印成 =0×0
box = "%s=%d×%d" % (sel, data["w"], data["h"]) if data["found"] else "%s 不存在" % sel
bits = "st=%s path=%s %s body=%d" % (data["st"], data["path"], box, data["body"])
problems = []
if data["st"] != 200:
problems.append("HTTP %s" % data["st"])
if data["path"] != path:
problems.append("跳到了 %s" % data["path"])
if not data["found"]:
problems.append("缺关键区块 %s" % sel)
if data["body"] < args.min_body:
problems.append("正文只有 %d 字" % data["body"])
if jserrs not in ("[]", "?"):
problems.append("JS 报错 %s" % jserrs)
if data["errs"]:
problems.append("window.onerror: %s" % data["e0"])
if data["found"] and (data["w"] == 0 or data["h"] == 0):
warns.append("%s 站长:%s 尺寸为 0" % (path, sel))
if problems:
fails.append("%s 站长:%s" % (path, ";".join(problems)))
print("%s ✗ %s [%s]" % (tag, ";".join(problems), bits))
else:
print("%s ✓ %s" % (tag, bits))
# 访客:公开页 200、guard 页跟随总开关、后台页 303 跳登录
vst, loc = http(path, base)
if kind == "admin":
if vst != 303 or "/admin/login" not in loc:
fails.append("%s 访客:后台页竟是 %s(应 303 跳登录)" % (path, vst))
print("%s ↳ 访客 ✗ %s %s" % (" " * 18, vst, loc))
else:
print("%s ↳ 访客 ✓ 303 → 登录" % (" " * 18))
else:
want = 200 if kind == "public" or pub_on else 303
if vst != want:
fails.append("%s 访客:%s(期望 %s)" % (path, vst, want))
print("%s ↳ 访客 ✗ %s" % (" " * 18, vst))
else:
print("%s ↳ 访客 ✓ %s" % (" " * 18, vst))
# 访客浏览器抽检:双身份下 DOM 不一样,只在访客侧挂的 JS 报错只有这里能抓到
for path in ["/", subs["{place}"]]:
if not path or (args.only and args.only not in path):
continue
sel = "#home-map" if path == "/" else "#place-map"
data, jserrs, err = probe(args.cdp, args.python, base, path, args.width,
args.height, True, sel, None, args.wait)
if data is None:
fails.append("%s 访客浏览器:跑失败 %s" % (path, err))
print("%-18s 访客浏览器 ✗ 跑失败 %s" % (path, err))
continue
ok = data["st"] == 200 and jserrs in ("[]", "?") and not data["errs"]
if not ok:
fails.append("%s 访客浏览器:st=%s JS报错=%s" % (path, data["st"], jserrs))
print("%-18s 访客浏览器 %s st=%s body=%d errs=%s" % (
path, "✓" if ok else "✗", data["st"], data["body"], data["errs"]))
print("-" * 78)
for w in warns:
print("⚠ %s" % w)
for s in skipped:
print("· 跳过 %s" % s)
if fails:
print("冒烟失败 %d 项:" % len(fails))
for f in fails:
print(" ✗ %s" % f)
return 1
print("冒烟通过(%d 页)" % (len(pages) - len(skipped)))
return 0
if __name__ == "__main__":
sys.exit(main())
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# 一键验收:把「必须带六个环境变量、且必须 0 skip」这条纪律固化下来。
#
# ./scripts/verify.sh # 全量验收(默认,约 8 分钟)
# ./scripts/verify.sh --fast # 只做 gofmt / vet / build(约 20 秒)
# ./scripts/verify.sh --race # 全量 + -race(更慢,需 mingw 在 PATH)
# ./scripts/verify.sh --cover # 全量 + 覆盖率(跨包口径,见下)
# ./scripts/verify.sh --smoke # 只跑关键页冒烟(需站点已在 --base 上运行)
# ./scripts/verify.sh --all # 全量 + 冒烟
# ./scripts/verify.sh --pkg ./internal/web/ --run 'TestMapPreview' # 定点跑
#
# 退出码:0 = 全绿且 0 skip;非 0 = 有失败或有跳过。
set -u -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ABS="$ROOT"
command -v pwd >/dev/null && ABS="$(pwd -W 2>/dev/null || pwd)"
CONF="${ROND_TEST_CONF:-conf/app-test.ini}"
ROND_SRC="${ROND_SRC:-$ABS/data/20260914_151649.rondbackup}"
FOG_SRC="${FOG_SRC:-$ABS/data/Snapshot-20260914T154839+0800.fwss}"
OUT="$ABS/storage/.verify"
CLEAN_OUT="$OUT/acceptance-clean.rondbackup"
LOG="$OUT/verify.log"
COVOUT="$OUT/cover.out"
FAST=0; RACE=0; COVER=0; SMOKE=0; SMOKE_ONLY=0; PKGS="./..."; RUN=""
while [ $# -gt 0 ]; do
case "$1" in
--fast) FAST=1 ;;
--race) RACE=1 ;;
--cover) COVER=1 ;;
--smoke) SMOKE=1; SMOKE_ONLY=1 ;;
--all) SMOKE=1 ;;
--pkg) PKGS="${2:?--pkg 后面要给包路径}"; shift ;;
--run) RUN="${2:?--run 后面要给正则}"; shift ;;
*) echo "未知参数:$1" >&2; exit 2 ;;
esac
shift
done
fail() { echo "✗ $*" >&2; exit 1; }
ok() { echo "✓ $*"; }
echo "== 仓库:$ROOT"
# 冒烟单独跑:它要一个真在跑的站点(浏览器口径),跟单元测试是两回事
run_smoke() {
local py="${ROND_PY:-python}"
command -v "$py" >/dev/null 2>&1 || py="${ROND_PY:-python3}"
command -v "$py" >/dev/null 2>&1 || fail "找不到 python(可用 ROND_PY 指定)"
echo "== 冒烟:关键页渲染(站长浏览器 + 访客 HTTP/浏览器)"
# 路径必须给 Windows 形式:这里传的是 Git Bash 的 $ROOT(/c/...),
# Windows 版 python 会把它拼成 C:\c\... 然后报 No such file
"$py" "$ABS/scripts/smoke.py" --base "${ROND_BASE:-http://localhost:8080}" \
--shot-dir "$OUT/smoke" || fail "关键页冒烟"
ok "关键页冒烟"
}
if [ "$FAST" = 1 ]; then
unformatted="$(gofmt -l internal/ main.go 2>&1)"
[ -n "$unformatted" ] && fail "gofmt 不干净(注意 CRLF 会假警):$(echo "$unformatted" | tr '\n' ' ')"
ok "gofmt"
go vet ./... || fail "go vet"
ok "go vet"
go build ./... || fail "go build"
ok "go build"
exit 0
fi
if [ "$SMOKE_ONLY" = 1 ]; then
run_smoke
exit 0
fi
# --- 六个环境变量 ---------------------------------------------------------------
[ -f "$CONF" ] || fail "找不到 $CONF"
[ -f "$ROND_SRC" ] || fail "找不到备份包 ROND_SRC=$ROND_SRC"
[ -f "$FOG_SRC" ] || fail "找不到迷雾快照 FOG_SRC=$FOG_SRC"
if [ -n "${ROND_DSN:-}" ]; then
DSN="$ROND_DSN"
else
DSN="$(grep -m1 -iE '^[[:space:]]*dsn[[:space:]]*=' "$CONF" | sed 's/^[^=]*=[[:space:]]*//' | tr -d '\r\n')"
fi
[ -n "$DSN" ] || fail "没能从 $CONF 取到 dsn(也不在环境里),别把口令写进脚本"
# 指到维护库 postgres 会多出「users 不存在」+ 一堆 stats 假 skip,直接拦住
DBNAME="$(printf '%s' "$DSN" | sed -E 's#^[^/]*//[^/]*/([^/?]+).*#\1#')"
case "$DBNAME" in
test|*test*) : ;;
*) fail "ROND_DSN 指向的库名是 '$DBNAME',不含 test —— 验收必须用应用测试库(rond_fog_test)" ;;
esac
export ROND_TEST_CONF="$CONF" ROND_SRC FOG_SRC ROND_DSN="$DSN"
export ROND_CLEAN_OUT="$CLEAN_OUT" ROND_EXPORT="$CLEAN_OUT"
mkdir -p "$OUT"
echo "== 库:$DBNAME 备份:$(basename "$ROND_SRC") / $(basename "$FOG_SRC")"
GOFLAGS_EXTRA=""
[ "$RACE" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -race"
# 必须 -coverpkg:-cover 只统计「测试文件所在包」自己的执行,凡是主要由别的包驱动的
# 代码(rond 的导出被 internal/web 驱动、geo/gpx/notes/votes 被 web/stats 驱动)
# 一律假报 0%,看着像没测,其实是量错了。
[ "$COVER" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -coverpkg=./internal/... -coverprofile=$COVOUT"
if [ "$RACE" = 1 ] && command -v gcc >/dev/null 2>&1; then :; else
for d in "$HOME/scoop/apps/mingw/current/bin"; do
[ -d "$d" ] && export PATH="$d:$PATH"
done
fi
if [ "$RACE" = 1 ] && ! command -v gcc >/dev/null 2>&1; then
fail "-race 需要 C 编译器:scoop install mingw,或把 mingw 的 bin 加进 PATH"
fi
# --- 第一段:先产出一份「纯净往返包」,第二段要读它 ------------------------------
if [ -z "$RUN" ] && [ "$PKGS" = "./..." ]; then
echo "== 第一段:导出纯净往返包"
if ! go test -count=1 -run '^TestMovementCleanExport$' -v ./internal/web/ 2>&1 | tee "$OUT/stage1.log" | grep -E '^(--- (PASS|FAIL|SKIP)|ok|FAIL)'; then
fail "第一段导出失败,见 $OUT/stage1.log"
fi
grep -q '^--- PASS' "$OUT/stage1.log" || fail "TestMovementCleanExport 没跑起来(被 skip 了?)"
ok "纯净往返包已生成:$(basename "$CLEAN_OUT")"
fi
# --- 第二段:全量(或定点)------------------------------------------------------
RUNARG=""
[ -n "$RUN" ] && RUNARG="-run $RUN"
echo "== 第二段:go test -count=1 -v $GOFLAGS_EXTRA $RUNARG $PKGS"
# shellcheck disable=SC2086
go test -count=1 -v $GOFLAGS_EXTRA -timeout 2400s $RUNARG $PKGS >"$LOG" 2>&1
RC=$?
PASS=$(grep -cE '^[[:space:]]*--- PASS' "$LOG" || true)
FAILN=$(grep -cE '^[[:space:]]*--- FAIL' "$LOG" || true)
SKIP=$(grep -cE '^[[:space:]]*--- SKIP' "$LOG" || true)
BADPKG=$(grep -c '^FAIL' "$LOG" || true)
echo "== 结果:PASS=$PASS FAIL=$FAILN SKIP=$SKIP 非ok包=$BADPKG"
echo "== 日志:$LOG"
if [ "$SKIP" != 0 ]; then
echo "-- 以下用例被跳过(=没跑。门控测试缺环境变量时 go test 依然回 ok,别当通过看)--"
grep -n -B1 -E '^[[:space:]]*--- SKIP' "$LOG" | head -40
fi
if [ "$FAILN" != 0 ] || [ "$BADPKG" != 0 ]; then
echo "-- 失败明细(前 40 行)--"
grep -n -B2 -E '^[[:space:]]*--- FAIL' "$LOG" | head -40
fi
if [ "$RC" != 0 ] || [ "$FAILN" != 0 ] || [ "$BADPKG" != 0 ]; then
fail "有失败"
fi
if [ "$SKIP" != 0 ]; then
fail "有 $SKIP 个用例被跳过 ⇒ 这次不算验收过"
fi
ok "全绿且 0 skip(PASS=$PASS)"
if [ "$COVER" = 1 ] && [ -s "$COVOUT" ]; then
echo "== 覆盖率(按包,跨包执行已计入)"
# 必须按 (文件,行段) 取 max 而不是求和:-coverpkg 下每个测试二进制都会给同一段代码
# 写一条记录,求和等于把语句数乘上二进制份数,每包都会看着只有 1/N。
awk 'NR>1 { key=$1; n=$(NF-1); c=$NF+0
if (!(key in seen)) seen[key]=c; else if (c>seen[key]) seen[key]=c
stmt[key]=n; split(key,a,":"); pkg=a[1]; sub(/\/[^/]+$/,"",pkg); pk[key]=pkg }
END { for (k in stmt) { p=pk[k]; tot[p]+=stmt[k]; if (seen[k]>0) cov[p]+=stmt[k] }
for (p in tot) printf " %-36s %5.1f%% %d/%d\n", p, 100*cov[p]/tot[p], cov[p], tot[p] }' \
"$COVOUT" | sort -k2 -n
echo -n " == 项目合计 "
go tool cover -func="$COVOUT" | tail -1
echo " 明细:go tool cover -html=$COVOUT"
fi
if [ "$SMOKE" = 1 ]; then
run_smoke
fi
+52
View File
@@ -0,0 +1,52 @@
# tools
留在这里的都是**还在用**的工具;一次性探针用完就删(历史里能翻到)。
| 工具 | 用途 |
|---|---|
| `verify_export_structure.py` | 结构级对比:导出的 `.rondbackup` / `.fwss` 与真机原版比表、列、行数、悬空引用。导出改动后必跑。 |
| `verify_rondbackup.py` | 逐字段对比两个 `.rondbackup`(含 `Z_METADATA` 等字节哈希),比上面那个更细。 |
| `photo_coord_audit.py` | 照片导入的坐标体检与批量换算(GCJ-02 / WGS-84)。 |
| `audit_db.sql` | 库内一致性审计:`psql "<dsn>" -f tools/audit_db.sql`。 |
## 验收
`../scripts/verify.sh`(封了六个环境变量,会数 SKIP,拒绝假绿):
```bash
./scripts/verify.sh # 全量,0 skip 才算过
./scripts/verify.sh --fast # gofmt + vet + build
./scripts/verify.sh --race --pkg ./internal/web/
./scripts/verify.sh --cover # 覆盖率,-coverpkg 口径
./scripts/verify.sh --smoke # 关键页冒烟(要站点在 8080 上跑着)
./scripts/verify.sh --all # 全量 + 冒烟
```
### `--cover` 为什么要 `-coverpkg`
`go test -cover` 只统计「测试文件所在的那个包」自己的执行。本项目大量代码是被**别的包**
驱动的——`internal/rond` 的导出流程由 `internal/web` 的用例跑,`geo`/`gpx`/`notes`/`votes`
由 `web`/`stats` 跑——按 `-cover` 量这些包会一律报 **0%**,看着像没测,其实全都在跑。
`--cover` 用 `-coverpkg=./internal/...` 把跨包执行算进来。实测差别:
| 包 | `-cover`(假) | `-coverpkg`(真) |
|---|---|---|
| `internal/rond` | 38.8% | 79.1%(由 `internal/web` 的用例贡献) |
### 关键页冒烟:`../scripts/smoke.py`
Go 侧模板守卫只能证明「模板里有那段文字」,证明不了浏览器打开后看得到——白屏、JS 抛错、
内容区块被压成 0 高,模板测试全都是绿的。冒烟按三种口径各走一遍:
1. **站长(浏览器)**:每页断言 HTTP 200、落点路径没跳走、内容区块在且尺寸非 0、零 JS 报错
2. **访客(纯 HTTP)**:公开页 200;后台页必须 303 跳登录(绝不能 200)
3. **访客(浏览器)抽检**:`/` 与地点详情,只在访客侧复现的 bug 藏在这里
```bash
python scripts/smoke.py --only /map # 单页调试
python scripts/smoke.py --shot-dir storage/.verify/smoke # 顺带存整屏截图
```
依赖 `rond-web-verify` 技能里的 `site_cdp.py`(CDP 直连本机 Chrome);用 `ROND_CDP`
指过去,默认取 `~/.workbuddy/skills/rond-web-verify/scripts/site_cdp.py`。
站点得先在 `ROND_BASE`(默认 `http://localhost:8080`)上跑着,否则直接报错退出。
Binary file not shown.
-89
View File
@@ -1,89 +0,0 @@
// 一次性探针:验证 ZLOCATION.ZPLACEID / ZVISIT.ZEVENTIDENTIFIER 是否跨备份稳定。
package main
import (
"fmt"
"os"
"rond-with-you/internal/rond"
)
type snap struct {
placeID map[string]int
eventID map[string]int
nulls int
total int
evTotal int
}
func load(p string) *snap {
dir, err := os.MkdirTemp("", "colprobe")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
db, err := rond.Open(p, dir)
if err != nil {
panic(err)
}
defer db.Close()
s := &snap{placeID: map[string]int{}, eventID: map[string]int{}}
rows, err := db.Query(`SELECT Z_PK, COALESCE(ZPLACEID,'') FROM ZLOCATION`)
if err != nil {
panic(err)
}
for rows.Next() {
var pk int
var id string
rows.Scan(&pk, &id)
s.total++
if id == "" {
s.nulls++
} else {
s.placeID[id] = pk
}
}
rows.Close()
rows, err = db.Query(`SELECT COALESCE(ZEVENTIDENTIFIER,'') FROM ZVISIT`)
if err != nil {
panic(err)
}
for rows.Next() {
var id string
rows.Scan(&id)
if id != "" {
s.eventID[id] = 1
s.evTotal++
}
}
rows.Close()
fmt.Printf("%s: ZLOCATION %d 个(ZPLACEID 空 %d),ZEVENTIDENTIFIER 非空 %d\n",
p, s.total, s.nulls, s.evTotal)
return s
}
func main() {
a := load(os.Args[1])
b := load(os.Args[2])
inter, samePK := 0, 0
for k, pa := range a.placeID {
if pb, ok := b.placeID[k]; ok {
inter++
if pa == pb {
samePK++
}
}
}
fmt.Printf("\nZPLACEID 交集 %d(A 独有 %d,B 独有 %d),其中 Z_PK 相同 %d\n",
inter, len(a.placeID)-inter, len(b.placeID)-inter, samePK)
ei := 0
for k := range a.eventID {
if _, ok := b.eventID[k]; ok {
ei++
}
}
fmt.Printf("ZEVENTIDENTIFIER 交集 %d(A %d / B %d)\n", ei, len(a.eventID), len(b.eventID))
}

Some files were not shown because too many files have changed in this diff Show More