审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
+4
-10
@@ -169,7 +169,7 @@ func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。
|
||||
// 会话令牌 = uid|expiry|hmac(uid|expiry|口令摘要),口令变更即自动失效。
|
||||
func (s *Store) SignToken(userID int64, hash string) string {
|
||||
exp := time.Now().AddDate(0, 0, s.Days).Unix()
|
||||
payload := fmt.Sprintf("%d|%d", userID, exp)
|
||||
@@ -195,14 +195,8 @@ func (s *Store) ParseToken(token, hash string) (int64, bool) {
|
||||
func (s *Store) mac(payload, hash string) string {
|
||||
m := hmac.New(sha256.New, s.Secret)
|
||||
m.Write([]byte(payload))
|
||||
// 混入口令摘要,改密后旧 cookie 立即作废
|
||||
m.Write([]byte("|" + hash[:min(len(hash), 16)]))
|
||||
// 绑完整口令摘要:摘要里带随机 salt,改密后必然不同,旧 cookie 当场作废。
|
||||
// 别只取摘要前几位——PBKDF2 编码的前缀是固定格式,等于绑了个常量。
|
||||
m.Write([]byte("\x00" + hash))
|
||||
return b64(m.Sum(nil))
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -12,6 +12,30 @@ import (
|
||||
_ "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 覆盖改名的正向路径、口令校验与占用冲突。
|
||||
// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来,
|
||||
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -98,6 +99,44 @@ func urlEscape(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------- 验证码重放 ----------
|
||||
|
||||
// replayTTL 要盖过 VerifyTOTP 的整个接受窗口(前后各一步 = 90 秒),
|
||||
// 否则同一步长的码会在条目过期后又被收下。
|
||||
const replayTTL = 3 * totpStep
|
||||
|
||||
// CodeReplay 记住最近用过的验证码,拦住「同一个码在窗口内被用第二次」。
|
||||
// VerifyTOTP 只判断「这个码此刻对不对」,不含一次性语义;被偷看到或被日志
|
||||
// 截获的码,在它过期前都能再换一个会话。
|
||||
type CodeReplay struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCodeReplay() *CodeReplay {
|
||||
return &CodeReplay{used: map[string]time.Time{}, now: time.Now}
|
||||
}
|
||||
|
||||
// Consume 在验证码**已经校验通过**之后登记一次使用;已被用过则返回 false。
|
||||
// 键里带用户号:不同账号的同一个数字码互不影响。
|
||||
func (r *CodeReplay) Consume(userID int64, code string) bool {
|
||||
now := r.now()
|
||||
key := fmt.Sprintf("%d|%s", userID, code)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for k, t := range r.used {
|
||||
if now.Sub(t) > replayTTL {
|
||||
delete(r.used, k)
|
||||
}
|
||||
}
|
||||
if _, ok := r.used[key]; ok {
|
||||
return false
|
||||
}
|
||||
r.used[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- 用户秘钥存取 ----------
|
||||
|
||||
// TOTPState 返回用户的两步验证状态。
|
||||
|
||||
@@ -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
@@ -65,9 +65,19 @@ var popcount = func() [256]byte {
|
||||
return t
|
||||
}()
|
||||
|
||||
// tileDigits 返回瓦片编号的规范十进制串,不足两位补前导 0。
|
||||
// 文件名格式固定以 2 位 M2 尾缀结尾(见 tileName),单数字编号不补齐就无法往返。
|
||||
func tileDigits(id int) string {
|
||||
s := strconv.Itoa(id)
|
||||
if len(s) < 2 {
|
||||
return "0" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
|
||||
func tileIDFromName(name string) (int, bool) {
|
||||
if len(name) < 7 || len(name[4:]) < 3 {
|
||||
if len(name) < 8 || len(name[4:]) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
digits := make([]byte, 0, len(name))
|
||||
@@ -82,7 +92,7 @@ func tileIDFromName(name string) (int, bool) {
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
s := strconv.Itoa(id)
|
||||
s := tileDigits(id)
|
||||
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
|
||||
return 0, false
|
||||
}
|
||||
@@ -97,7 +107,7 @@ func tileIDFromName(name string) (int, bool) {
|
||||
|
||||
// tileName 把瓦片编号编码回文件名。
|
||||
func tileName(id int) string {
|
||||
s := strconv.Itoa(id)
|
||||
s := tileDigits(id)
|
||||
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
|
||||
var b strings.Builder
|
||||
b.WriteString(sum[:4])
|
||||
@@ -110,6 +120,15 @@ func tileName(id int) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// maxTileRawBytes / maxTileZBytes 是单个迷雾瓦片的「压缩后 / 解压后」上限。
|
||||
// 实测瓦片压缩后约 250KB;解压后结构上最多 headerSize + 65535*blockSize ≈ 33MB
|
||||
// (头部的每个索引是 uint16),给到 64MB 已足够宽松。不设上限的话,
|
||||
// 一个几 KB 的 zip bomb 就能把 1C1G 的机器打爆。
|
||||
const (
|
||||
maxTileRawBytes = 16 << 20
|
||||
maxTileZBytes = 64 << 20
|
||||
)
|
||||
|
||||
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
|
||||
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
|
||||
func Decode(data []byte) ([]Block, int, error) {
|
||||
@@ -131,11 +150,14 @@ func Decode(data []byte) ([]Block, int, error) {
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
raw, err := io.ReadAll(rc)
|
||||
raw, err := io.ReadAll(io.LimitReader(rc, maxTileRawBytes+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
if len(raw) > maxTileRawBytes {
|
||||
return nil, 0, fmt.Errorf("瓦片 %s 压缩后超过 %d 字节,疑似异常快照", f.Name, maxTileRawBytes)
|
||||
}
|
||||
zdata, err := zlibDecompress(raw)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
|
||||
@@ -196,7 +218,15 @@ func zlibDecompress(raw []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
out, err := io.ReadAll(io.LimitReader(zr, maxTileZBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// zlib 流本身可以解出任意长度,必须在读的时候掐住
|
||||
if len(out) > maxTileZBytes {
|
||||
return nil, fmt.Errorf("解压后超过 %d 字节,疑似 zip bomb", maxTileZBytes)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
// querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。
|
||||
@@ -30,15 +31,15 @@ type querier interface {
|
||||
// ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。
|
||||
func BackfillCoordSource(ctx context.Context, q querier, datasetID int64) (int, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT p.src_pk, COALESCE(p.coord_source,''), p.src_lat, p.src_lon,
|
||||
(r.raw->>'ZLATITUDE')::float8, (r.raw->>'ZLONGITUDE')::float8,
|
||||
(r.raw->>'ZRAWLATITUDE')::float8, (r.raw->>'ZRAWLONGITUDE')::float8
|
||||
`+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`,
|
||||
`+rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`)+`
|
||||
FROM places p JOIN entity_raw r
|
||||
ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
|
||||
WHERE p.dataset_id=$1
|
||||
AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZLONGITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'`, datasetID)
|
||||
AND (r.raw->>'ZLATITUDE') ~ '`+rond.RawFloatPattern+`'
|
||||
AND (r.raw->>'ZLONGITUDE') ~ '`+rond.RawFloatPattern+`'
|
||||
AND (r.raw->>'ZRAWLATITUDE') ~ '`+rond.RawFloatPattern+`'
|
||||
AND (r.raw->>'ZRAWLONGITUDE') ~ '`+rond.RawFloatPattern+`'`, datasetID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读留档行: %w", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,29 @@ func (s *Store) SetNote(ctx context.Context, userID, datasetID int64, srcPK int,
|
||||
return s.Save(ctx, userID, datasetID, srcPK, alias, content)
|
||||
}
|
||||
|
||||
// SetNoteBulk 批量「清空备注、保留别名」,语义与逐条 SetNote(..., "") 一致,但只发两条语句
|
||||
// (逐个来是 2N 次往返,勾几十个地点在 1C1G 的机器上是能感觉到的):
|
||||
// 先删掉「别名与 rond 备注都为空」的行,再把其余行的手写 note 清空。
|
||||
func (s *Store) SetNoteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error {
|
||||
if len(srcPKs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
|
||||
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
|
||||
AND n.place_key = `+placeKeyExpr("p")+`
|
||||
AND btrim(COALESCE(n.alias,''))='' AND n.rond_note=''`,
|
||||
userID, datasetID, srcPKs); err != nil {
|
||||
return err
|
||||
}
|
||||
// 顺带把 dataset_id/src_pk 刷成当前数据集的值,与逐条 Save 的 upsert 行为对齐
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE place_notes n
|
||||
SET note='', dataset_id=p.dataset_id, src_pk=p.src_pk, updated_at=now()
|
||||
FROM places p
|
||||
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
|
||||
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除某地点的整条备注与别名。
|
||||
func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
|
||||
@@ -77,6 +100,17 @@ func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBulk 一次删掉多个地点的整条备注与别名(后台批量勾选时用,一条语句代替 N 次往返)。
|
||||
func (s *Store) DeleteBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64) error {
|
||||
if len(srcPKs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_notes n USING places p
|
||||
WHERE n.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
|
||||
AND n.place_key = `+placeKeyExpr("p"), userID, datasetID, srcPKs)
|
||||
return err
|
||||
}
|
||||
|
||||
// Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。
|
||||
func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error {
|
||||
alias = strings.TrimSpace(alias)
|
||||
|
||||
@@ -110,6 +110,29 @@ func PlaceCoordKey(lat, lon float64) string {
|
||||
|
||||
func coordStr(v float64) string { return fmt.Sprintf("%.6f", v) }
|
||||
|
||||
// RawFloat8 返回「把 entity_raw 里的 JSON 值安全地转成 float8」的 SQL 表达式,
|
||||
// 值不是合法的数字就给 NULL,而不是抛错。
|
||||
//
|
||||
// 留档是原样存下来的 JSON,值不一定是数字:空串、字符串形式的 "1.2.3"、
|
||||
// "NaN" 都可能出现。直接 `::float8` 会抛 invalid input syntax,代价是**编辑页
|
||||
// 整页 500** 或**整次导入失败 / 启动自愈失败**,而且只在特定那一行数据上复现。
|
||||
//
|
||||
// 两个实现细节都有原因,别简化:
|
||||
// - 写成 CASE 而不是「正则 AND cast」:PG 不保证 AND/OR 各操作数的求值顺序,
|
||||
// 正则没挡住时照样会炸;CASE 只求值命中的那个分支。
|
||||
// - 正则把**指数限在两位**,于是最大幅度约 1e109,本身就进不了 float8 的溢出区
|
||||
// (实测 `'1e999'` 这类值在 CASE 里一样会抛 out of range)。这样不依赖
|
||||
// PG 版本——`pg_input_is_valid` 要 PG 16+,本项目的库版本没有写死。
|
||||
// 坐标用不上 1e109 以上的写法,也不会小到 1e-100 以下。
|
||||
//
|
||||
// RawFloatPattern 是同一个正则(不带引号),给「只想判合不合法、不取数值」的地方用:
|
||||
// 光一个 `~` 判断不会抛错,所以那种地方可以直接串它。
|
||||
const RawFloatPattern = `^[+-]?([0-9]{1,10}(\.[0-9]*)?|\.[0-9]{1,10})([eE][+-]?[0-9]{1,2})?$`
|
||||
|
||||
func RawFloat8(expr string) string {
|
||||
return `CASE WHEN ` + expr + ` ~ '` + RawFloatPattern + `' THEN (` + expr + `)::float8 END`
|
||||
}
|
||||
|
||||
// DiffVisits 按 UUID 对齐两边的到访。
|
||||
//
|
||||
// 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+124
-28
@@ -24,19 +24,44 @@ import (
|
||||
// Core Data 时间戳基准:2001-01-01 UTC
|
||||
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// 允许的时间窗口 [1970, 2100)。脏备份里会出现 NaN / ±Inf / 量级离谱的值,
|
||||
// 而 int64 转换对超范围值是回绕而非饱和——实测 1e30 恰好回绕成 0(=基准点 2001)、
|
||||
// 1e12 变成 2123 年,都是「看着正常」的伪时间,再被当成 first/last_visit_at
|
||||
// 污染整条时间轴。必须先卡输入秒数:回绕后的结果可能又落回合法区间,只查结果挡不住。
|
||||
var (
|
||||
appleMinSec = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
|
||||
appleMaxSec = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
|
||||
)
|
||||
|
||||
func appleTime(v sql.NullFloat64) *time.Time {
|
||||
if !v.Valid {
|
||||
if !v.Valid || math.IsNaN(v.Float64) || math.IsInf(v.Float64, 0) {
|
||||
return nil
|
||||
}
|
||||
sec := math.Floor(v.Float64)
|
||||
if sec < appleMinSec || sec > appleMaxSec {
|
||||
return nil
|
||||
}
|
||||
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
|
||||
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
|
||||
// 往返导出后时间戳就对不上了。
|
||||
sec := math.Floor(v.Float64)
|
||||
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
|
||||
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
|
||||
return &t
|
||||
}
|
||||
|
||||
// validCoord 判定坐标是否可用。SQLite 的 REAL 列可能塞进 NaN,备份也可能带出
|
||||
// ±90 / ±180 之外的值;这类值会让地图与迷雾索引的网格算出 NaN 或越界格。
|
||||
// 无效就按「无坐标」处理——与管线里 COALESCE(NULL,0) 的既有约定一致。
|
||||
func validCoord(lat, lon float64) bool {
|
||||
if math.IsNaN(lat) || math.IsInf(lat, 0) || lat < -90 || lat > 90 {
|
||||
return false
|
||||
}
|
||||
if math.IsNaN(lon) || math.IsInf(lon, 0) || lon < -180 || lon > 180 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
@@ -165,6 +190,23 @@ type Backup struct {
|
||||
ActivityUUID map[int]string
|
||||
}
|
||||
|
||||
// maxDBBytes 是备份包内单个 SQLite 文件解压后的上限。真机的库是几十 MB 量级,
|
||||
// 2 GiB 足够宽松,但足以挡住 zip bomb——压缩比上千时几 MB 的包能解出 TB 级。
|
||||
const maxDBBytes = 2 << 30
|
||||
|
||||
// copyLimited 把 r 拷进 w,超过 limit 就报错。
|
||||
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明,必须在读的时候掐住。
|
||||
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
|
||||
n, err := io.Copy(w, io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > limit {
|
||||
return fmt.Errorf("解压后超过 %d 字节,疑似异常备份包", limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
|
||||
// 调用方负责 Close 与删除 dir。
|
||||
func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
@@ -177,6 +219,28 @@ func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
dbPath, err := unpackArchive(&zr.Reader, dir, maxDBBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// unpackArchive 把包里的 Core Data 主库及其 WAL / SHM 解压到 dir,返回主库路径。
|
||||
// limit 是单个条目解压后的字节上限;抽成参数是为了让上限本身可测
|
||||
// (真上限 2 GiB,测试里没法造出一个 2 GiB 的 zip bomb)。
|
||||
func unpackArchive(zr *zip.Reader, dir string, limit int64) (string, error) {
|
||||
var dbPath string
|
||||
for _, f := range zr.File {
|
||||
base := filepath.Base(f.Name)
|
||||
@@ -191,38 +255,39 @@ func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
dst := filepath.Join(dir, base)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
_, cerr := io.Copy(out, rc)
|
||||
cerr := copyLimited(out, rc, limit)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
os.Remove(dst)
|
||||
return "", fmt.Errorf("解压 %s: %w", base, cerr)
|
||||
}
|
||||
if strings.HasSuffix(low, ".sqlite") {
|
||||
dbPath = dst
|
||||
}
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
|
||||
return "", fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
|
||||
}
|
||||
return dbPath, nil
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
|
||||
// drainRows 收尾 rows 循环。只 Close 不查 Err:驱动或连接中途出错时 Next() 只会
|
||||
// 返回 false、从不报错,半张表就被当成完整的交出去——数据静默变少,日志一片空白。
|
||||
func drainRows(rows *sql.Rows, what string) error {
|
||||
err := rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse 读取 Core Data 库,转换为领域模型。
|
||||
@@ -244,7 +309,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.TagGroups[pk] = name
|
||||
}
|
||||
rows.Close()
|
||||
if err = drainRows(rows, "读取标签分组"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
|
||||
tagGroup := map[int]int{}
|
||||
@@ -260,7 +327,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
tagGroup[pk] = g
|
||||
}
|
||||
grows.Close()
|
||||
if err = drainRows(grows, "读取标签分组关系"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
|
||||
if err != nil {
|
||||
@@ -275,7 +344,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
|
||||
b.Tags = append(b.Tags, t)
|
||||
}
|
||||
trows.Close()
|
||||
if err = drainRows(trows, "读取标签"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 类型 / 活动
|
||||
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
|
||||
@@ -293,7 +364,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
|
||||
b.Activities = append(b.Activities, a)
|
||||
}
|
||||
arows.Close()
|
||||
if err = drainRows(arows, "读取活动类型"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 交通方式
|
||||
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
|
||||
@@ -308,7 +381,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.Transports = append(b.Transports, t)
|
||||
}
|
||||
trrows.Close()
|
||||
if err = drainRows(trrows, "读取交通方式"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 地点
|
||||
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
|
||||
@@ -327,9 +402,14 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
lrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !validCoord(l.Lat, l.Lon) {
|
||||
l.Lat, l.Lon = 0, 0
|
||||
}
|
||||
b.Locations = append(b.Locations, l)
|
||||
}
|
||||
lrows.Close()
|
||||
if err = drainRows(lrows, "读取地点"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问记录
|
||||
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
|
||||
@@ -363,7 +443,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
visitIdx[v.SrcPK] = len(b.Visits)
|
||||
b.Visits = append(b.Visits, v)
|
||||
}
|
||||
vrows.Close()
|
||||
if err = drainRows(vrows, "读取访问记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问 → 标签
|
||||
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
|
||||
@@ -380,7 +462,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
|
||||
}
|
||||
}
|
||||
vtrows.Close()
|
||||
if err = drainRows(vtrows, "读取访问标签"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 位移
|
||||
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
|
||||
@@ -411,7 +495,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
m.End = appleTime(en)
|
||||
b.Movements = append(b.Movements, m)
|
||||
}
|
||||
mrows.Close()
|
||||
if err = drainRows(mrows, "读取位移记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 小时级天气
|
||||
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
|
||||
@@ -453,7 +539,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.Weather = append(b.Weather, w)
|
||||
}
|
||||
wrows.Close()
|
||||
if err = drainRows(wrows, "读取天气记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
|
||||
b.RawVisitNum = 0
|
||||
@@ -645,7 +733,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err = drainRows(rows, "读取 Z_PRIMARYKEY"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
|
||||
if err != nil {
|
||||
@@ -659,7 +749,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
}
|
||||
m.DDL[name] = ddl
|
||||
}
|
||||
drows.Close()
|
||||
if err = drainRows(drows, "读取建表 DDL"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
|
||||
visitEnt, okV := m.Entities["Visit"]
|
||||
@@ -692,7 +784,11 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
tcol = cname
|
||||
}
|
||||
}
|
||||
cerr := crows.Err()
|
||||
crows.Close()
|
||||
if cerr != nil {
|
||||
continue
|
||||
}
|
||||
if vcol != "" && tcol != "" {
|
||||
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
|
||||
break
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -36,10 +36,23 @@ type Filter struct {
|
||||
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
|
||||
Regions []string
|
||||
RegionsExclude bool
|
||||
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。
|
||||
// Verdict 非 0 时只保留站长标记为该结论的地点:**连同它们名下的到访与位移一起**
|
||||
// (1 推荐 / -1 踩雷)。它不是「只筛地点列表」的镜头——判据下推到 visitsWhere /
|
||||
// movementsWhere,页面上所有数字跟着一起变,否则会出现「点没了、里程还在」这种
|
||||
// 自相矛盾的视图。
|
||||
Verdict int
|
||||
}
|
||||
|
||||
// placeKeyExpr 把 places 某个别名下的坐标换算成 place_key。
|
||||
// **必须与 Go 侧 rond.PlaceCoordKey 逐位一致**(notes / votes / handlers_images / stats
|
||||
// 各有一份,改一处搜全仓):
|
||||
//
|
||||
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
|
||||
func placeKeyExpr(alias string) string {
|
||||
return fmt.Sprintf(
|
||||
"to_char(%[1]s.lat,'FM999990.000000')||'|'||to_char(%[1]s.lon,'FM999990.000000')", alias)
|
||||
}
|
||||
|
||||
type where struct {
|
||||
conds []string
|
||||
args []any
|
||||
@@ -148,6 +161,15 @@ func (f Filter) visitsWhere(off int) *where {
|
||||
w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id
|
||||
AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw)
|
||||
}
|
||||
if f.Verdict != 0 {
|
||||
// 结论挂坐标键(换数据集也跟得住),所以判据是「这次到访所属地点的键」。
|
||||
//
|
||||
// 放在 visitsWhere 而不是 Places 里,是为了**全域一致**:地图点、城市/省份统计、
|
||||
// 类型计数、概览,以及靠 movementVisitConds 派生的里程 / 出行数,全都从这一份
|
||||
// 条件派生出来,不会各认各的。
|
||||
w.cond(`EXISTS (SELECT 1 FROM place_votes pv JOIN places pvp ON pvp.id = v.place_id
|
||||
WHERE pv.place_key = `+placeKeyExpr("pvp")+` AND pv.verdict = ?)`, f.Verdict)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -157,8 +179,11 @@ func (f Filter) visitsWhere(off int) *where {
|
||||
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
|
||||
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
|
||||
func (f Filter) hasVisitConds() bool {
|
||||
// Verdict 也算:movements/weather 只在「有这类条件」时才会包一层 EXISTS 到访,
|
||||
// 漏掉它就别指望位移会跟着筛选走。
|
||||
return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork ||
|
||||
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != ""
|
||||
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != "" ||
|
||||
f.Verdict != 0
|
||||
}
|
||||
|
||||
// weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
|
||||
@@ -187,6 +212,34 @@ func (f Filter) weatherWhere() *where {
|
||||
return w
|
||||
}
|
||||
|
||||
// movementVisitConds 是「必须落到某次到访上才能判定」的那几档条件(活动 / 最短停留 /
|
||||
// 城市 / 省份 / 标签 / 关键词),专门给位移用——movements 自己只有两端到访,
|
||||
// 没有城市、活动这些字段。
|
||||
//
|
||||
// 这里**复用 visitsWhere 拼出来的表达式**,而不是再抄一遍:抄一遍迟早跟 visitsWhere
|
||||
// 漂移,漏掉一个维度就是「到访数变了、里程还是全量」(区域那档已经犯过一次)。
|
||||
// 清掉三类条件,剩下的正是可以按到访判的:
|
||||
// - 时间:位移按 movements.started_at 判,再按到访的 arrival 判一次就是另一套口径;
|
||||
// - 区域:位移要按**两端 places** 判(任一端命中即算),见 movementsWhere;
|
||||
// - 家 / 工作:那边是「两端都不是」的 NOT EXISTS,塞进这里的 EXISTS 会松成「任一端不是」。
|
||||
func (f Filter) movementVisitConds(off int) *where {
|
||||
rest := f
|
||||
rest.From, rest.To = nil, nil
|
||||
rest.Regions = nil
|
||||
rest.ExcludeHome, rest.ExcludeWork = false, false
|
||||
return rest.visitsWhere(off)
|
||||
}
|
||||
|
||||
// hasMovementVisitConds 表示「带上了只有到访才判得出的筛选条件」。
|
||||
// 从 hasVisitConds 派生,免得两处再各写一份清单;减掉的正是 movementVisitConds
|
||||
// 里被清掉的那两类(家工作、区域),它们由 movementsWhere 自己判。
|
||||
func (f Filter) hasMovementVisitConds() bool {
|
||||
rest := f
|
||||
rest.ExcludeHome, rest.ExcludeWork = false, false
|
||||
rest.Regions = nil
|
||||
return rest.hasVisitConds()
|
||||
}
|
||||
|
||||
// movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。
|
||||
//
|
||||
// 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places
|
||||
@@ -215,20 +268,45 @@ func (f Filter) movementsWhere(off int) *where {
|
||||
AND (` + strings.Join(flags, " OR ") + `))`)
|
||||
}
|
||||
if len(f.Regions) > 0 {
|
||||
var prov, city, dist []string
|
||||
// 每一段都要**两端分别**过同一套判定,与地图轨迹图层保持一致:apiTrack 就是
|
||||
// 用 regionSQL 拼出 pf / pt 两段 AND 起来的。只按「任一端命中」的话,白名单下
|
||||
// 同一段里程在图层里画不出来、却算进了总里程(黑名单下两种写法恰好等价,
|
||||
// 所以这个洞只在白名单时露出来)。
|
||||
phs := make([]string, 0, len(f.Regions))
|
||||
for _, r := range f.Regions {
|
||||
prov = append(prov, w.ph(r))
|
||||
city = append(city, w.ph(r))
|
||||
dist = append(dist, w.ph(r))
|
||||
phs = append(phs, w.ph(r))
|
||||
}
|
||||
expr := `EXISTS (SELECT 1 FROM places pmv
|
||||
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id)
|
||||
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` +
|
||||
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))`
|
||||
if f.RegionsExclude {
|
||||
expr = "NOT " + expr
|
||||
endpoint := func(col string) string {
|
||||
var conds []string
|
||||
for _, ph := range phs {
|
||||
// 必须 COALESCE:district 常为 NULL,NULL = 'x' 得 NULL,
|
||||
// 黑名单的 NOT (...) 也跟着变 NULL,整行被误删(regionSQL 同样处理)。
|
||||
conds = append(conds, "COALESCE(pmv.province,'') = "+ph,
|
||||
"COALESCE(pmv.city,'') = "+ph, "COALESCE(pmv.district,'') = "+ph)
|
||||
}
|
||||
expr := "EXISTS (SELECT 1 FROM places pmv WHERE pmv.id = movements." + col +
|
||||
" AND (" + strings.Join(conds, " OR ") + "))"
|
||||
if f.RegionsExclude {
|
||||
return "NOT " + expr
|
||||
}
|
||||
return expr
|
||||
}
|
||||
w.conds = append(w.conds, expr)
|
||||
w.conds = append(w.conds, endpoint("from_place_id"), endpoint("to_place_id"))
|
||||
}
|
||||
// 活动 / 城市 / 省份 / 标签 / 最短停留 / 关键词这几档只长在到访上,位移得靠两端
|
||||
// 的到访带过来,否则筛完之后「到访数变了、里程和出行数还是全量」。
|
||||
// 判定用「**任一端**命中即算」——和上面区域那条同一个理由:位移横跨两个地点,
|
||||
// 改成「两端都得命中」会让城际段在每张筛选视图里都消失,而「任一端」才和到访列表
|
||||
// 对得上(一次通勤的起点和终点,在两边各自的视图里都该看得到)。
|
||||
// 两端都关联不到到访的位移(真机有 31/791 这种)在带这类筛选时不参与:
|
||||
// 它们没法证明自己落在筛选范围内。
|
||||
if f.hasMovementVisitConds() {
|
||||
vc := f.movementVisitConds(len(w.args))
|
||||
w.conds = append(w.conds, `EXISTS (
|
||||
SELECT 1 FROM visits v WHERE v.dataset_id = movements.dataset_id
|
||||
AND (v.src_pk = movements.from_visit_src OR v.src_pk = movements.to_visit_src)
|
||||
AND `+vc.expr()+`)`)
|
||||
w.args = append(w.args, vc.args...)
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -654,17 +732,20 @@ type Place struct {
|
||||
Down int
|
||||
}
|
||||
|
||||
// 每个相关子查询都带上 dataset_id = p.dataset_id:它是 idx_visits_place(dataset_id, place_id)
|
||||
// 的前导列,缺了它每次循环都要为库里每个 dataset 各下一次索引(实测 Buffer 命中 23737 → 5669)。
|
||||
// 谓词本身冗余(到访与所属地点的 dataset 必然相同),纯粹是给计划器的。
|
||||
const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
|
||||
COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''),
|
||||
p.visit_count, p.dwell_minutes, p.first_visit_at, p.last_visit_at,
|
||||
COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
|
||||
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
|
||||
COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
|
||||
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
|
||||
COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
|
||||
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home),
|
||||
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work),
|
||||
WHERE v2.place_id=p.id AND v2.dataset_id=p.dataset_id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
|
||||
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.dataset_id=p.dataset_id AND v3.is_home),
|
||||
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.dataset_id=p.dataset_id AND v4.is_work),
|
||||
p.src_pk,
|
||||
EXISTS (SELECT 1 FROM place_images pi WHERE pi.place_key =
|
||||
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000'))`
|
||||
@@ -707,15 +788,8 @@ func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset i
|
||||
}
|
||||
|
||||
args := append([]any{f.DatasetID}, inner.args...)
|
||||
// verdict 条件已经在 visitsWhere 里,这里不用再补一份(补了就是同一个 EXISTS 跑两遍)。
|
||||
base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)`
|
||||
if f.Verdict != 0 {
|
||||
// 结论挂坐标键(换数据集也要跟得住),按本数据集的地点圈定范围;
|
||||
// 占位符接在内层条件之后。
|
||||
args = append(args, f.Verdict)
|
||||
base += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM place_votes pv WHERE pv.place_key =
|
||||
to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')
|
||||
AND pv.verdict = $%d)`, len(args))
|
||||
}
|
||||
q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
|
||||
|
||||
rows, err := d.QueryContext(ctx, q, args...)
|
||||
|
||||
@@ -250,3 +250,75 @@ func TestWeatherStatsRespectsFilter(t *testing.T) {
|
||||
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisitSideFiltersApplyToMovements 钉住「按活动 / 城市 / 标签 / 关键词筛选时,
|
||||
// 里程与出行次数也要跟着变」。
|
||||
//
|
||||
// movements 自己只有两端到访,没有活动、城市这些字段。曾经 movementsWhere 只认
|
||||
// dataset / 时间 / 家工作 / 区域,于是筛完之后「到访数变了、里程还是全量」——
|
||||
// 区域那档犯过一次(见上一个测试),这几档是同一个洞没堵完。
|
||||
func TestVisitSideFiltersApplyToMovements(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
// 挑一个真有到访的活动,且它只覆盖一部分到访(否则筛选前后一样,测不出东西)
|
||||
var actID int64
|
||||
if err := db.QueryRow(`SELECT activity_id FROM visits
|
||||
WHERE dataset_id=$1 AND activity_id IS NOT NULL
|
||||
GROUP BY activity_id ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&actID); err != nil {
|
||||
t.Skipf("没有带活动的到访:%v", err)
|
||||
}
|
||||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
one, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, ActivityIDs: []int64{actID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Visits >= all.Visits {
|
||||
t.Skipf("活动 %d 覆盖了全部到访,换个数据集再测", actID)
|
||||
}
|
||||
|
||||
// 手写 SQL:两端**任一端**的到访属于该活动的位移
|
||||
var dist float64
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0), count(*) FROM movements m
|
||||
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||||
WHERE v.dataset_id=m.dataset_id AND v.activity_id=$2
|
||||
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
|
||||
dsID, actID).Scan(&dist, &n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := one.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
|
||||
t.Errorf("按活动筛选后里程 %.4f,手写 SQL %.4f(筛选没作用到里程上?)", one.DistanceKm, dist)
|
||||
}
|
||||
if one.Movements != n {
|
||||
t.Errorf("按活动筛选后出行次数 %d,手写 SQL %d", one.Movements, n)
|
||||
}
|
||||
|
||||
// 同一条路径也覆盖城市:movementVisitConds 里所有维度共用一个 EXISTS
|
||||
var city string
|
||||
if err := db.QueryRow(`SELECT p.city FROM visits v JOIN places p ON p.id=v.place_id
|
||||
WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
|
||||
t.Skipf("没有带城市的到访:%v", err)
|
||||
}
|
||||
byCity, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, City: city})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cityDist float64
|
||||
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0) FROM movements m
|
||||
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||||
WHERE v.dataset_id=m.dataset_id
|
||||
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2)
|
||||
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
|
||||
dsID, city).Scan(&cityDist); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := byCity.DistanceKm - cityDist; diff > 0.01 || diff < -0.01 {
|
||||
t.Errorf("按城市「%s」筛选后里程 %.4f,手写 SQL %.4f", city, byCity.DistanceKm, cityDist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,32 @@ func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK,
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveVerdictBulk 一次给多个地点设置(verdict=1/-1)或取消(verdict=0)站长结论,
|
||||
// 语义同逐条 SaveVerdict,但一条语句代替 N 次往返。
|
||||
func (s *Store) SaveVerdictBulk(ctx context.Context, userID, datasetID int64, srcPKs []int64, verdict int) error {
|
||||
if len(srcPKs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if verdict == 0 {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM place_votes v USING places p
|
||||
WHERE v.user_id=$1 AND p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
|
||||
AND v.place_key = `+placeKeyExpr, userID, datasetID, srcPKs)
|
||||
return err
|
||||
}
|
||||
// 先按坐标键算出每条地点该挂在哪,找不到该地点就什么也不写
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_votes
|
||||
(user_id, place_key, dataset_id, src_pk, verdict, updated_at)
|
||||
SELECT $1, `+placeKeyExpr+`, $2, p.src_pk, $4, now()
|
||||
FROM places p
|
||||
WHERE p.dataset_id=$2 AND p.src_pk = ANY($3::bigint[])
|
||||
AND p.lat IS NOT NULL AND p.lon IS NOT NULL
|
||||
ON CONFLICT (user_id, place_key) DO UPDATE SET
|
||||
verdict=EXCLUDED.verdict, dataset_id=EXCLUDED.dataset_id,
|
||||
src_pk=EXCLUDED.src_pk, updated_at=now()`,
|
||||
userID, datasetID, srcPKs, verdict)
|
||||
return err
|
||||
}
|
||||
|
||||
// placeKeyOf 把某个数据集里的地点编号换算成坐标键——访客票也是挂在坐标上的,
|
||||
// 换数据集之后票仍然跟着同一个地点。
|
||||
func (s *Store) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:按大小预筛没有生效")
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,37 @@ func visitCounts(t *testing.T, page string) (added, gone, clash int) {
|
||||
return
|
||||
}
|
||||
|
||||
// shiftSrcPKs 把库里的编号整体平移,模拟「这份数据其实来自另一份编号完全不同的备份」。
|
||||
//
|
||||
// 这不是臆造的场景:rond 每次导出/同步都会重排 Z_PK,所以「把本地和后来新增的备份合并」
|
||||
// 时,两边编号一定对不上,唯一稳定的键只有到访 `ZIDENTIFIER_` / 活动 `ZUID_`。
|
||||
// 夹具导入的就是 ROND_SRC 本身,编号与备份逐条相同(实测 823/823),
|
||||
// 所以不平移的话,比对会走「编号直接命中的快路径」,UUID 对齐这条真正在用的路径反而测不到。
|
||||
//
|
||||
// 平移量取得足够大(远超库里最大编号),单条 UPDATE 就不会在语句中途撞上唯一约束;
|
||||
// 同时把指向到访编号的外键(movements.from/to_visit_src、weather.visit_src)和留档一起挪。
|
||||
func (f *movementFixture) shiftSrcPKs(t *testing.T, delta int) {
|
||||
t.Helper()
|
||||
for _, q := range []string{
|
||||
`UPDATE visits SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
|
||||
`UPDATE places SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
|
||||
`UPDATE activities SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
|
||||
`UPDATE movements SET src_pk = src_pk + $2,
|
||||
from_visit_src = from_visit_src + $2, to_visit_src = to_visit_src + $2 WHERE dataset_id=$1`,
|
||||
`UPDATE weather SET src_pk = src_pk + $2, visit_src = visit_src + $2 WHERE dataset_id=$1`,
|
||||
`UPDATE entity_raw SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
|
||||
} {
|
||||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, delta); err != nil {
|
||||
t.Fatalf("平移编号失败: %v\n%s", err, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份备份再传一次:UUID 对得上、字段也没变,到访应当一条不差。
|
||||
//
|
||||
// 这条也顺带钉住「按 UUID 而不是编号对齐」:库内那些行的编号与备份里完全不同
|
||||
// (rond 重排过),只按编号比会让 800 多条全变冲突。
|
||||
// ⚠️ 注意这条用例**没有**验证「按 UUID 对齐」:夹具导入的就是 f.src 本身,
|
||||
// 库里的 src_pk 与备份的 Z_PK 逐条相同(实测 823/823),它是靠编号直接命中的。
|
||||
// 真正钉住 UUID 对齐的是 TestBackupDiffAlignsByUUIDNotSrcPK / TestBackupMergeRenumberedBackup。
|
||||
//
|
||||
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
|
||||
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
|
||||
@@ -102,6 +129,22 @@ func TestBackupDiffIdentical(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 编号全被平移后(= 数据来自另一份重排过 Z_PK 的备份),同一份内容再比一次仍须零差异。
|
||||
//
|
||||
// 这是「本地 + 后来新增的备份」合并的真正前提:rond 每次导出都重排编号,
|
||||
// 若比对退回按编号对齐,这里会报成「几百条备份新增 + 几百条仅站内有」,
|
||||
// 勾一下「并进来」就会把整份足迹**复制一遍**。
|
||||
func TestBackupDiffAlignsByUUIDNotSrcPK(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
f.shiftSrcPKs(t, 500000)
|
||||
|
||||
page := previewBackup(t, f, f.src)
|
||||
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
||||
t.Fatalf("编号平移后内容其实没变,差异应为 0;实际 新增 %d 仅站内有 %d 冲突 %d"+
|
||||
" ⇒ 比对是靠编号对齐的,跨备份合并会把足迹复制一遍", a, g, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。
|
||||
//
|
||||
// 这里的关键是「按 UUID 对齐」:库里那些行的 Z_PK 与备份里完全不同
|
||||
|
||||
@@ -731,8 +731,10 @@ func writeRawRow(ctx context.Context, ex execer, dsID int64, entity string, pk i
|
||||
}
|
||||
blob, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
log.Printf("留档 %s#%d 序列化失败: %v", entity, pk, err)
|
||||
return nil
|
||||
// 这里以前只 log 后 return nil:调用方会以为留档成功,合并照常提交,
|
||||
// 于是这行的原始列永久缺失(下次比对认不出 UUID、导出也还原不回去)。
|
||||
// 必须上抛,让整个合并事务回滚。
|
||||
return fmt.Errorf("留档 %s#%d 序列化失败: %w", entity, pk, err)
|
||||
}
|
||||
_, err = ex.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
|
||||
VALUES ($1,$2,$3,$4,false)
|
||||
|
||||
@@ -54,6 +54,10 @@ func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
|
||||
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
|
||||
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
|
||||
// - 合并是幂等的:再传同一份备份,差异归零。
|
||||
//
|
||||
// 注意:这里库内编号与备份逐条相同(夹具导入的就是 f.src 本身,实测 823/823),
|
||||
// 所以走的是「编号直接命中」的快路径。编号重排过的真实场景见
|
||||
// TestBackupMergeRenumberedBackupRestoresOnlyMissing。
|
||||
func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
@@ -127,6 +131,86 @@ func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过,
|
||||
// 与库里的编号对不上,而备份里还多了几条新记录。
|
||||
//
|
||||
// 这条要钉住的是最贵的一种错:如果比对退回按编号对齐,那 800 多条老记录会被判成
|
||||
// 「备份新增 + 仅站内有」,用户勾一下「并进来」就把整份足迹**复制一遍**(数量翻倍、
|
||||
// 地图上每个点都重影)。必须在平移编号的前提下,差异仍然只有真正缺的那 2 条。
|
||||
func TestBackupMergeRenumberedBackupRestoresOnlyMissing(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
visitCount := func() int {
|
||||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||||
}
|
||||
all := visitCount()
|
||||
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
|
||||
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
|
||||
if all == 0 || movCount == 0 {
|
||||
t.Skip("这份备份没有足够的到访/位移可测")
|
||||
}
|
||||
|
||||
// ① 库里的编号整体平移:等价于「这批数据其实来自另一份编号完全不同的备份」
|
||||
f.shiftSrcPKs(t, 500000)
|
||||
|
||||
// ② 再按**站点删到访的方式**删掉最早两条:备份里还有它们,所以差异应当正好是 2 条。
|
||||
// 和平移前的用例一样,必须连带清位移、清天气归属,否则留下的是测试手法造成的孤儿。
|
||||
var pk1, pk2 int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
|
||||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
|
||||
f.dsID).Scan(&pk1, &pk2); err != nil {
|
||||
t.Fatalf("取待删到访失败: %v", err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
|
||||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
|
||||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
|
||||
} {
|
||||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
|
||||
t.Fatalf("预置删除失败: %v", err)
|
||||
}
|
||||
}
|
||||
if got := visitCount(); got != all-2 {
|
||||
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
|
||||
}
|
||||
|
||||
token, page := previewBackupToken(t, f, f.src)
|
||||
if a, g, c := visitCounts(t, page); a != 2 || g != 0 || c != 0 {
|
||||
t.Fatalf("编号重排过、只缺 2 条 ⇒ 差异应当正好 2 条新增;实际 新增 %d 仅站内有 %d 冲突 %d"+
|
||||
"(判成 800+ 就是把整份足迹当新记录,合并会复制一遍)", a, g, c)
|
||||
}
|
||||
|
||||
mergeBackup(t, f, token, "add_new=1")
|
||||
|
||||
if got := visitCount(); got != all {
|
||||
t.Fatalf("合并后到访数应回到 %d 且不重不漏,实际 %d", all, got)
|
||||
}
|
||||
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
|
||||
t.Errorf("位移应恢复到 %d,实际 %d(恢复到新编号时外键映射错了)", movCount, got)
|
||||
}
|
||||
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
|
||||
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
|
||||
}
|
||||
// 补进来的那两条必须真的挂回了某个经历,不能因为编号变了就成孤儿
|
||||
if got := f.scalar(t, `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
|
||||
AND m.from_visit_src IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src)`, f.dsID); got != 0 {
|
||||
t.Errorf("恢复后出现了 %d 条起点悬空的位移", got)
|
||||
}
|
||||
// 地点是按**坐标**对齐的(不是按编号),所以编号平移不该让补回来的到访挂空 ——
|
||||
// place_id 可空,映射错了不会报错,只会变成地图上没地点的孤点到访。
|
||||
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id IS NULL`, f.dsID); got != 0 {
|
||||
t.Errorf("恢复后有 %d 条到访没挂上地点(按编号映射就会这样)", got)
|
||||
}
|
||||
|
||||
// 幂等:再比一次必须归零(否则每合并一次就多一批)
|
||||
page = previewBackup(t, f, f.src)
|
||||
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
||||
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
|
||||
func TestBackupMergeConflictChoice(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -31,11 +31,29 @@ type fillupHint struct {
|
||||
var (
|
||||
fillupMu sync.Mutex
|
||||
fillupCache = map[int64]fillupHint{}
|
||||
fillupOrder []int64 // 键的插入顺序,超限时据此淘汰最旧的一批
|
||||
)
|
||||
|
||||
// fillupCacheMax 是内存提示缓存的条目上限。
|
||||
//
|
||||
// 个人站点的地点数是有限的,这里兜的是「站点长期运行、数据集换了一轮又一轮」时的无界增长:
|
||||
// 光写不清的全局 map 会一路涨到把 1C1G 的机器吃干净。超限就丢掉最旧的四分之一,
|
||||
// 让缓存自己收敛——比整表清空温和,不会一次性把所有高德建议都丢掉。
|
||||
const fillupCacheMax = 2048
|
||||
|
||||
func fillupCachePut(placeID int64, h fillupHint) {
|
||||
fillupMu.Lock()
|
||||
defer fillupMu.Unlock()
|
||||
if _, ok := fillupCache[placeID]; !ok {
|
||||
if len(fillupOrder) >= fillupCacheMax {
|
||||
n := fillupCacheMax / 4
|
||||
for _, k := range fillupOrder[:n] {
|
||||
delete(fillupCache, k)
|
||||
}
|
||||
fillupOrder = append(fillupOrder[:0], fillupOrder[n:]...)
|
||||
}
|
||||
fillupOrder = append(fillupOrder, placeID)
|
||||
}
|
||||
fillupCache[placeID] = h
|
||||
}
|
||||
|
||||
@@ -381,7 +399,10 @@ func (s *Server) adminFillupApply(w http.ResponseWriter, r *http.Request) {
|
||||
actByName[name] = actID
|
||||
newActs++
|
||||
}
|
||||
isHome, isWork := s.activityFlags(ctx, ex, actID)
|
||||
isHome, isWork, err := s.activityFlags(ctx, ex, actID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := ex.ExecContext(ctx, `UPDATE visits v SET activity_id=$1, is_home=$2, is_work=$3
|
||||
WHERE v.dataset_id=$4 AND v.activity_id IS NULL
|
||||
AND v.place_id = (SELECT p.id FROM places p WHERE p.dataset_id=$4 AND p.id=$5)`,
|
||||
@@ -451,8 +472,8 @@ func (s *Server) fillupTargets(ctx context.Context, ex execer, datasetID int64)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool) {
|
||||
ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
|
||||
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool, err error) {
|
||||
err = ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/fog"
|
||||
@@ -26,22 +27,27 @@ const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB
|
||||
|
||||
// 迷雾是站点级数据(与公开页读数据不区分归属的约定一致),
|
||||
// 内存里只持一份索引,首次访问时从库里加载。
|
||||
//
|
||||
// 指针用 atomic 而不是普通变量:清空迷雾(fogResetIndex)会在请求 goroutine 里换掉它,
|
||||
// 而瓦片/导出/统计同时在读 —— 直接赋值就是一个真的 data race。
|
||||
var (
|
||||
fogOnce sync.Once
|
||||
fogIndexV *fog.Index
|
||||
fogIndexV atomic.Pointer[fog.Index]
|
||||
)
|
||||
|
||||
func (s *Server) fogIndex() (*fog.Index, error) {
|
||||
fogOnce.Do(func() {
|
||||
fogIndexV = fog.NewIndex()
|
||||
if err := fogIndexV.LoadAll(s.db); err != nil {
|
||||
fogIndexV = nil
|
||||
ix := fog.NewIndex()
|
||||
// 加载完才发布:中途的实例不能被别的 goroutine 看到(这也是Once的意义)
|
||||
if err := ix.LoadAll(s.db); err == nil {
|
||||
fogIndexV.Store(ix)
|
||||
}
|
||||
})
|
||||
if fogIndexV == nil {
|
||||
ix := fogIndexV.Load()
|
||||
if ix == nil {
|
||||
return nil, fmt.Errorf("迷雾数据加载失败")
|
||||
}
|
||||
return fogIndexV, nil
|
||||
return ix, nil
|
||||
}
|
||||
|
||||
// handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是
|
||||
@@ -441,8 +447,11 @@ func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []str
|
||||
|
||||
// fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用),
|
||||
// 下次访问时按空库重建。
|
||||
//
|
||||
// 被清掉的那个索引对象可能还有请求正拿着用(Import/View/All 都走它自己的锁),
|
||||
// 这里只换指针、不动旧对象,那些请求会安全地跑完。
|
||||
func fogResetIndex() {
|
||||
fogIndexV = fog.NewIndex()
|
||||
fogIndexV.Store(fog.NewIndex())
|
||||
}
|
||||
|
||||
// adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。
|
||||
@@ -483,7 +492,7 @@ func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) {
|
||||
_, cells := ix.Stats()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="fog-export-%s.fwss"`, time.Now().Format("20060102-150405")))
|
||||
downloadDisposition("fog-export-"+time.Now().Format("20060102-150405")+".fwss"))
|
||||
w.Header().Set("X-Fog-Cells", strconv.Itoa(cells))
|
||||
w.Write(out)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 没看受影响行数:删不到也会回「已删除」")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ func (g *gzipWriter) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap 让 http.NewResponseController 能穿过这层包装拿到真正的 ResponseWriter
|
||||
// (SetWriteDeadline / Flush / Hijack 都靠它逐层解包)。少了这个方法,
|
||||
// 被包装的请求调 SetWriteDeadline 只会拿到 ErrNotSupported。
|
||||
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
|
||||
|
||||
func (g *gzipWriter) close() {
|
||||
if g.gz != nil {
|
||||
g.gz.Close()
|
||||
|
||||
@@ -62,6 +62,15 @@ func (s *Server) checkCaptcha(r *http.Request) bool {
|
||||
return s.captcha.Verify(c.Value, r.PostFormValue("captcha"))
|
||||
}
|
||||
|
||||
// tooManyTries 把等待时长写成一句人话。向上取整,免得显示「请 0 秒后再试」。
|
||||
func tooManyTries(wait time.Duration) string {
|
||||
sec := int(wait/time.Second) + 1
|
||||
if sec < 60 {
|
||||
return fmt.Sprintf("登录尝试过于频繁,请 %d 秒后再试", sec)
|
||||
}
|
||||
return fmt.Sprintf("登录尝试过于频繁,请 %d 分钟后再试", (sec+59)/60)
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
sets, err := s.sets.Load(r.Context())
|
||||
if err != nil {
|
||||
@@ -99,20 +108,33 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 爆破短锁:验证码之后还有一道,连错到阈值就按用户名指数退避
|
||||
if wait := s.loginGuard.RetryAfter(username); wait > 0 {
|
||||
sets, _ := s.sets.Load(ctx)
|
||||
s.renderStatus(w, http.StatusTooManyRequests, "admin/login", LoginData{
|
||||
Settings: sets, Err: tooManyTries(wait), Next: next,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.auth.Authenticate(ctx, username, password)
|
||||
if err != nil {
|
||||
sets, _ := s.sets.Load(ctx)
|
||||
code := http.StatusOK
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
code = http.StatusInternalServerError
|
||||
}
|
||||
msg := "用户名或密码错误"
|
||||
if code == http.StatusInternalServerError {
|
||||
msg = "登录失败:" + err.Error()
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
// 登录页是公开的,内部错误只进日志,不回显给门外的人
|
||||
log.Printf("登录失败 %s: %v", username, err)
|
||||
code = http.StatusInternalServerError
|
||||
msg = "登录失败,请稍后重试"
|
||||
} else if wait := s.loginGuard.Fail(username); wait > 0 {
|
||||
code = http.StatusTooManyRequests
|
||||
msg = tooManyTries(wait)
|
||||
}
|
||||
s.renderStatus(w, code, "admin/login", LoginData{Settings: sets, Err: msg, Next: next})
|
||||
return
|
||||
}
|
||||
s.loginGuard.Succeed(username)
|
||||
// 已开启两步验证:口令过关只算第一步,签发短时中间令牌进入验证码页
|
||||
if _, enabled, _ := s.auth.TOTPState(ctx, u.ID); enabled {
|
||||
sets, _ := s.sets.Load(ctx)
|
||||
@@ -121,9 +143,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
s.login(w, u.ID, u.Hash)
|
||||
s.login(w, r, u.ID, u.Hash)
|
||||
target := "/admin"
|
||||
if strings.HasPrefix(next, "/admin") {
|
||||
// 同样先过站内路径判据(挡控制字符/引号),再限定必须落在 /admin 下
|
||||
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
|
||||
target = next
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
@@ -202,6 +225,15 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
stored := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name)))
|
||||
// 落盘之后每条失败路径都要把半成品删掉:否则反复上传坏包会在 uploads 里堆垃圾。
|
||||
// 只有「新导入成功」那一支会 keepStored = true —— 那份上传件要留给导出当基线源
|
||||
// (findSourceArchive 就是按 datasets.sha256 在 uploads 里找它)。
|
||||
keepStored := false
|
||||
defer func() {
|
||||
if !keepStored {
|
||||
os.Remove(stored)
|
||||
}
|
||||
}()
|
||||
out, err := os.Create(stored)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
@@ -265,6 +297,7 @@ func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// 否则 rond 原生的标志位(ZPINSTYLE_/ZRADIUS/ZVISIT.ZRAW…)会被洗成 NULL,越导越失真。
|
||||
// 老数据集缺基线的情况,交给 ensureBaseline 按当初上传的原始包重建。
|
||||
if !st.Identical {
|
||||
keepStored = true
|
||||
if err := rond.WriteBaseline(stored, s.baselinePath(datasetID)); err != nil {
|
||||
log.Printf("写入基线库失败(导出 rondbackup 时需重新上传原始备份): %v", err)
|
||||
}
|
||||
@@ -866,19 +899,15 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var msg string
|
||||
switch action {
|
||||
case "delete":
|
||||
for _, id := range ids {
|
||||
if err := s.notes.Delete(r.Context(), u.ID, ds.ID, int(id)); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.notes.DeleteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
msg = fmt.Sprintf("已删除 %d 个地点的备注与别名", len(ids))
|
||||
case "clear":
|
||||
for _, id := range ids {
|
||||
if err := s.notes.SetNote(r.Context(), u.ID, ds.ID, int(id), ""); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.notes.SetNoteBulk(r.Context(), u.ID, ds.ID, ids); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
msg = fmt.Sprintf("已清空 %d 个地点的备注(保留别名)", len(ids))
|
||||
case "verdict":
|
||||
@@ -886,11 +915,9 @@ func (s *Server) adminNotesBulk(w http.ResponseWriter, r *http.Request) {
|
||||
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
|
||||
verdict = 0
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, int(id), verdict); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.votes.SaveVerdictBulk(r.Context(), u.ID, ds.ID, ids, verdict); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if verdict == 0 {
|
||||
msg = fmt.Sprintf("已取消 %d 个地点的结论", len(ids))
|
||||
@@ -941,7 +968,7 @@ func (s *Server) adminPlaceNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// 备注管理页的行内表单带 next,保存后回到原处(保留筛选);
|
||||
// 没有 next 时按 Referer 判断,都没有就回后台首页。
|
||||
if next := r.PostFormValue("next"); strings.HasPrefix(next, "/") {
|
||||
if next := safeInternalPath(r.PostFormValue("next")); next != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(next, "?") {
|
||||
sep = "&"
|
||||
@@ -1000,13 +1027,31 @@ func (s *Server) login2FA(w http.ResponseWriter, r *http.Request) {
|
||||
fail("两步验证状态异常,请重新登录")
|
||||
return
|
||||
}
|
||||
// 两步验证也要限速:中间令牌有 5 分钟有效期,不限速的话 6 位码在窗口内
|
||||
// 足够被打穿。键带前缀,与「口令那一环」分开计数。
|
||||
guardKey := "2fa:" + u.Username
|
||||
if wait := s.loginGuard.RetryAfter(guardKey); wait > 0 {
|
||||
fail(tooManyTries(wait))
|
||||
return
|
||||
}
|
||||
if !auth.VerifyTOTP(secret, code) {
|
||||
if wait := s.loginGuard.Fail(guardKey); wait > 0 {
|
||||
fail(tooManyTries(wait))
|
||||
return
|
||||
}
|
||||
fail("验证码不正确")
|
||||
return
|
||||
}
|
||||
s.login(w, u.ID, u.Hash)
|
||||
// 同一个码在有效窗口内只用一次,否则被偷看的码可以再换一个会话。
|
||||
// 这一条不计入爆破计数:用户手抖重复提交也会撞上,不该因此被越锁越久。
|
||||
if !s.totpReplay.Consume(u.ID, code) {
|
||||
fail("这个验证码刚用过,请等下一个")
|
||||
return
|
||||
}
|
||||
s.loginGuard.Succeed(guardKey)
|
||||
s.login(w, r, u.ID, u.Hash)
|
||||
target := "/admin"
|
||||
if strings.HasPrefix(next, "/admin") {
|
||||
if next = safeInternalPath(next); strings.HasPrefix(next, "/admin") {
|
||||
target = next
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
@@ -1174,15 +1219,22 @@ func (s *Server) adminGpxUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) adminGpxDelete(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r.Context())
|
||||
id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
s.adminFlash(w, r, "err", "轨迹编号无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1`, id); err != nil {
|
||||
// 带上 user_id:这个 id 是客户端传来的,只按 id 删就能删掉别人的轨迹。
|
||||
res, err := s.db.ExecContext(r.Context(), `DELETE FROM gpx_tracks WHERE id=$1 AND user_id=$2`, id, u.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
s.adminFlash(w, r, "err", "轨迹不存在")
|
||||
return
|
||||
}
|
||||
s.adminFlash(w, r, "ok", "轨迹已删除")
|
||||
}
|
||||
|
||||
|
||||
+261
-150
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
|
||||
@@ -184,7 +185,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
||||
COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
|
||||
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
|
||||
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
|
||||
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
|
||||
`+rond.RawFloat8(`r.raw->>'ZLATITUDE'`)+`, `+rond.RawFloat8(`r.raw->>'ZLONGITUDE'`)+`,
|
||||
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon,
|
||||
COALESCE((SELECT string_agg(x.n || ' ' || x.c, ' · ' ORDER BY x.c DESC, x.n) FROM (
|
||||
SELECT COALESCE(a.name,'(无)') AS n, count(*) AS c FROM visits v
|
||||
@@ -631,8 +632,10 @@ func (s *Server) editOptions(ctx context.Context, query string, args ...any) ([]
|
||||
// 就会复用同一个 src_pk,于是新行继承旧留档的 rond 私有字段(ZRADIUS/ZRAW 等)。
|
||||
// skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。
|
||||
// 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。
|
||||
func (s *Server) pruneEntityRaw(ctx context.Context, datasetID int64) {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM entity_raw r
|
||||
// 收 ex(*sql.DB 或事务)是为了让调用方把它和「删实体」放进同一个事务——
|
||||
// 分两次提交的话,中间失败会留下「实体没了、留档还在」的错位。
|
||||
func (s *Server) pruneEntityRaw(ctx context.Context, ex execer, datasetID int64) {
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM entity_raw r
|
||||
WHERE r.dataset_id=$1 AND NOT r.skipped AND (
|
||||
(r.entity='ZLOCATION' AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=r.dataset_id AND p.src_pk=r.src_pk))
|
||||
OR (r.entity='ZVISIT' AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk))
|
||||
@@ -695,23 +698,38 @@ func (s *Server) editActivityDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.json(w, map[string]any{"error": "请选择要删除的活动"})
|
||||
return
|
||||
}
|
||||
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
|
||||
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
|
||||
// 先确认活动存在再动手,整组写进一个事务:清到访标记与删活动分开提交的话,
|
||||
// 中间失败会留下「到访的活动被摘掉了、活动还在」的半截状态。
|
||||
notFound := false
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
var exists bool
|
||||
if err := ex.QueryRowContext(ctx, `SELECT true FROM activities WHERE id=$1 AND dataset_id=$2`,
|
||||
id, ds.ID).Scan(&exists); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
|
||||
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ex, ds.ID)
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if notFound {
|
||||
s.json(w, map[string]any{"error": "活动不存在"})
|
||||
return
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ds.ID)
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
s.json(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -807,7 +825,10 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
|
||||
return errors.New("活动不存在")
|
||||
}
|
||||
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走
|
||||
isHome, isWork := s.activityFlags(ctx, ex, to)
|
||||
isHome, isWork, err := s.activityFlags(ctx, ex, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
|
||||
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
|
||||
if err != nil {
|
||||
@@ -820,8 +841,8 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
|
||||
s.json(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ds.ID)
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
s.pruneEntityRaw(ctx, s.db, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
s.json(w, map[string]any{"ok": true, "visits": moved})
|
||||
}
|
||||
|
||||
@@ -919,7 +940,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
redirectEdit(w, r, "ok", "已为所选地点新增到访")
|
||||
return
|
||||
}
|
||||
@@ -975,7 +996,7 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
if hasVisit {
|
||||
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc))
|
||||
} else {
|
||||
@@ -1077,7 +1098,7 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
|
||||
if actID != nil {
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
}
|
||||
|
||||
msg := "地点已更新"
|
||||
@@ -1228,27 +1249,31 @@ func (s *Server) editPlaceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
redirectEdit(w, r, "err", "地点标识无效")
|
||||
return
|
||||
}
|
||||
// 该地点名下到访的 src_pk 集合(删地点会级联删掉这些到访)
|
||||
// 三条写语句必须同一个事务:删到一半失败会留下「地点还在、它的行程没了」,
|
||||
// 或者更糟——地点没了但行程还指着已经不存在的到访(rond 里的悬空引用就是这么来的)。
|
||||
const visitSrcs = `SELECT src_pk FROM visits WHERE dataset_id=$1
|
||||
AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`
|
||||
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||||
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||||
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
// 天气本身有独立价值,只解除关联,不连坐删除
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
|
||||
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ex, ds.ID)
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// 天气本身有独立价值,只解除关联,不连坐删除
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
|
||||
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ds.ID)
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除")
|
||||
}
|
||||
|
||||
@@ -1289,7 +1314,7 @@ func (s *Server) editActivityCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
s.json(w, map[string]any{"id": id, "name": name})
|
||||
}
|
||||
|
||||
@@ -1316,38 +1341,55 @@ func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
|
||||
var oldPlace int64
|
||||
var curArrival time.Time
|
||||
var curDeparture sql.NullTime
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
|
||||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
|
||||
notFound := false
|
||||
// 改到访、夹回引用它的行程、重写标签、刷聚合必须同一个事务:夹行程那步失败会留下
|
||||
// 「到访时间已越界、行程还挂在旧窗口上」的坏数据,导出到 rond 里就是非法区间。
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
var curArrival time.Time
|
||||
var curDeparture sql.NullTime
|
||||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
|
||||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
|
||||
arrival = keepStoredTime(arrival, &curArrival)
|
||||
if departure != nil && curDeparture.Valid {
|
||||
kept := keepStoredTime(*departure, &curDeparture.Time)
|
||||
departure = &kept
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
|
||||
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
|
||||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
|
||||
r.PostFormValue("bookmarked") == "on",
|
||||
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
|
||||
return err
|
||||
}
|
||||
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
|
||||
if err := s.clampVisitMovements(ctx, ex, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.saveVisitTags(ctx, ex, ds.ID, src, r.PostForm["tags"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldPlace > 0 {
|
||||
if err := s.refreshPlaceAgg(ctx, ex, oldPlace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.refreshDatasetAgg(ctx, ex, ds.ID)
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if notFound {
|
||||
redirectEdit(w, r, "err", "到访记录不存在")
|
||||
return
|
||||
}
|
||||
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
|
||||
arrival = keepStoredTime(arrival, &curArrival)
|
||||
if departure != nil && curDeparture.Valid {
|
||||
kept := keepStoredTime(*departure, &curDeparture.Time)
|
||||
departure = &kept
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
|
||||
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
|
||||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
|
||||
r.PostFormValue("bookmarked") == "on",
|
||||
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
|
||||
if err := s.clampVisitMovements(ctx, s.db, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"])
|
||||
if oldPlace > 0 {
|
||||
s.refreshPlaceAgg(ctx, s.db, oldPlace)
|
||||
}
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
redirectEdit(w, r, "ok", "到访已更新")
|
||||
}
|
||||
|
||||
@@ -1369,23 +1411,25 @@ func (s *Server) adminVisitBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
redirectEdit(w, r, "err", "到访标识无效")
|
||||
return
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
|
||||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src)
|
||||
if err != nil {
|
||||
// 一条语句搞定「翻转并回读」:以前是 UPDATE 之后再单独 SELECT,那次回读一旦失败
|
||||
// 就落进 else 报「已取消收藏」——而它有一半是错的(状态根本不知道)。
|
||||
// 顺带:翻转与回读在同一条语句里,中间也不再有人插进来的窗口。
|
||||
var on bool
|
||||
if err := s.db.QueryRowContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
|
||||
WHERE dataset_id=$1 AND src_pk=$2
|
||||
RETURNING bookmarked`, ds.ID, src).Scan(&on); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
redirectEdit(w, r, "err", "到访记录不存在")
|
||||
return
|
||||
}
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
redirectEdit(w, r, "err", "到访记录不存在")
|
||||
return
|
||||
msg := "已取消收藏"
|
||||
if on {
|
||||
msg = "已标记为收藏"
|
||||
}
|
||||
var on bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT bookmarked FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
ds.ID, src).Scan(&on); err == nil && on {
|
||||
redirectEdit(w, r, "ok", "已标记为收藏")
|
||||
return
|
||||
}
|
||||
redirectEdit(w, r, "ok", "已取消收藏")
|
||||
redirectEdit(w, r, "ok", msg)
|
||||
}
|
||||
func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -1404,28 +1448,42 @@ func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var placeID int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
|
||||
notFound := false
|
||||
// 删行程 / 解天气关联 / 删到访必须同一个事务:中间失败会留下指着不存在到访的悬空行程,
|
||||
// 导出到 rond 里就是 8 组悬空引用那种坏数据。
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||||
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ex, ds.ID)
|
||||
if placeID > 0 {
|
||||
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.refreshDatasetAgg(ctx, ex, ds.ID)
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if notFound {
|
||||
redirectEdit(w, r, "err", "到访记录不存在")
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||||
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ds.ID)
|
||||
if placeID > 0 {
|
||||
s.refreshPlaceAgg(ctx, s.db, placeID)
|
||||
}
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
redirectEdit(w, r, "ok", "到访已删除")
|
||||
}
|
||||
|
||||
@@ -1813,40 +1871,54 @@ func (s *Server) editMovementUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
redirectEdit(w, r, "err", "行程标识无效")
|
||||
return
|
||||
}
|
||||
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, pk)
|
||||
if !ok {
|
||||
redirectEdit(w, r, "err", msg)
|
||||
return
|
||||
}
|
||||
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID,
|
||||
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
|
||||
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
|
||||
if msg != "" {
|
||||
redirectEdit(w, r, "err", msg)
|
||||
return
|
||||
}
|
||||
var trSrc, trName, trColor, trIcon any
|
||||
if tr.Src > 0 {
|
||||
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE movements SET
|
||||
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
|
||||
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
|
||||
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
|
||||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
ds.ID, pk, trSrc, trName, trColor, trIcon,
|
||||
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
|
||||
src.Start, src.End, src.DurationMin, src.DistanceKm)
|
||||
if err != nil {
|
||||
var tr movementTransport
|
||||
var failMsg string
|
||||
done := false
|
||||
// 校验(读起点/终点到访)与两处写入必须同一事务,否则并发改动会让派生列与两端锚点对不上。
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
src, ok, msg := s.validateMovementForm(ctx, ex, ds.ID, r, pk)
|
||||
if !ok {
|
||||
failMsg = msg
|
||||
return nil
|
||||
}
|
||||
var tmsg string
|
||||
tr, tmsg = s.resolveMovementTransport(ctx, ex, ds.ID,
|
||||
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
|
||||
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
|
||||
if tmsg != "" {
|
||||
failMsg = tmsg
|
||||
return nil
|
||||
}
|
||||
var trSrc, trName, trColor, trIcon any
|
||||
if tr.Src > 0 {
|
||||
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
|
||||
}
|
||||
res, err := ex.ExecContext(ctx, `UPDATE movements SET
|
||||
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
|
||||
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
|
||||
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
|
||||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
ds.ID, pk, trSrc, trName, trColor, trIcon,
|
||||
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
|
||||
src.Start, src.End, src.DurationMin, src.DistanceKm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
failMsg = "行程不存在"
|
||||
return nil
|
||||
}
|
||||
if err := syncTransport(ctx, ex, ds.ID, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
done = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
redirectEdit(w, r, "err", "行程不存在")
|
||||
return
|
||||
}
|
||||
if err := syncTransport(ctx, s.db, ds.ID, tr); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
if !done {
|
||||
redirectEdit(w, r, "err", failMsg)
|
||||
return
|
||||
}
|
||||
redirectEdit(w, r, "ok", "行程已更新")
|
||||
@@ -1870,16 +1942,28 @@ func (s *Server) editMovementDelete(w http.ResponseWriter, r *http.Request) {
|
||||
redirectEdit(w, r, "err", "行程标识无效")
|
||||
return
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
|
||||
if err != nil {
|
||||
notFound := false
|
||||
// 删行程与清留档必须同一事务:删除成功但清留档失败会留下「号已空、留档还在」的酸数据,
|
||||
// 下一次新建复用该 src_pk 时会继承这条的 rond 私有字段。
|
||||
if err := s.withTx(ctx, func(ex execer) error {
|
||||
res, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
notFound = true
|
||||
return nil
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ex, ds.ID)
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if notFound {
|
||||
redirectEdit(w, r, "err", "行程不存在")
|
||||
return
|
||||
}
|
||||
s.pruneEntityRaw(ctx, ds.ID)
|
||||
redirectEdit(w, r, "ok", "行程已删除")
|
||||
}
|
||||
|
||||
@@ -2138,43 +2222,55 @@ func (s *Server) insertVisit(ctx context.Context, ex execer, datasetID, userID,
|
||||
bookmarked, remark, emoji); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags)
|
||||
s.refreshPlaceAgg(ctx, ex, placeID)
|
||||
if err := s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.refreshPlaceAgg(ctx, ex, placeID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return nextSrc, nil
|
||||
}
|
||||
|
||||
// saveVisitTags 重写到访的标签关联。
|
||||
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) {
|
||||
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) error {
|
||||
var visitID int64
|
||||
if err := ex.QueryRowContext(ctx, `SELECT id FROM visits WHERE dataset_id=$1 AND src_pk=$2`, datasetID, visitSrc).Scan(&visitID); err != nil {
|
||||
return
|
||||
return fmt.Errorf("查待标标签的到访(src_pk=%d)失败: %w", visitSrc, err)
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID); err != nil {
|
||||
return fmt.Errorf("清到访标签失败: %w", err)
|
||||
}
|
||||
ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID)
|
||||
for _, t := range tagIDs {
|
||||
id, err := strconv.ParseInt(t, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
|
||||
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID)
|
||||
if _, err := ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
|
||||
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID); err != nil {
|
||||
return fmt.Errorf("写到访标签失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
|
||||
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) {
|
||||
ex.ExecContext(ctx, `UPDATE places p SET
|
||||
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) error {
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE places p SET
|
||||
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
|
||||
first_visit_at = v.f, last_visit_at = v.l
|
||||
FROM (SELECT $1::bigint AS pid) x
|
||||
LEFT JOIN (SELECT place_id, count(*) c, COALESCE(sum(LEAST(COALESCE(duration_min,0), 10080)),0) d, min(arrival) f, max(arrival) l
|
||||
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid
|
||||
WHERE p.id = x.pid`, placeID)
|
||||
WHERE p.id = x.pid`, placeID); err != nil {
|
||||
return fmt.Errorf("重算地点(id=%d)聚合失败: %w", placeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshDatasetAgg 重算数据集的到访数 / 地点数 / 时间跨度(页脚「数据截至」与后台 flash 用)。
|
||||
// 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。
|
||||
func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
|
||||
s.db.ExecContext(ctx, `UPDATE datasets d SET
|
||||
func (s *Server) refreshDatasetAgg(ctx context.Context, ex execer, datasetID int64) error {
|
||||
if _, err := ex.ExecContext(ctx, `UPDATE datasets d SET
|
||||
visit_count = COALESCE(v.c,0), first_visit_at = v.f, last_visit_at = v.l,
|
||||
place_count = COALESCE(p.c,0)
|
||||
FROM (SELECT $1::bigint AS did) x
|
||||
@@ -2182,7 +2278,20 @@ func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
|
||||
FROM visits WHERE dataset_id=$1 GROUP BY dataset_id) v ON v.dataset_id = x.did
|
||||
LEFT JOIN (SELECT dataset_id, count(*) c
|
||||
FROM places WHERE dataset_id=$1 GROUP BY dataset_id) p ON p.dataset_id = x.did
|
||||
WHERE d.id = x.did`, datasetID)
|
||||
WHERE d.id = x.did`, datasetID); err != nil {
|
||||
return fmt.Errorf("重算数据集(id=%d)聚合失败: %w", datasetID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logAggFailure 专给「写入已经提交」的那几个刷新点用。
|
||||
//
|
||||
// 这时不能把请求判成失败:数据其实已经落库了,报错会让用户以为没保存、再点一次就多建一条——
|
||||
// 比计数滞后严重得多。但也绝不能静默,否则计数会一路漂下去且日志里查不到。
|
||||
func logAggFailure(what string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("刷新%s失败(数据已写入,计数可能滞后): %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFloat(v string) (float64, bool) {
|
||||
@@ -2313,10 +2422,12 @@ func (s *Server) apiCoords(w http.ResponseWriter, r *http.Request) {
|
||||
// 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标
|
||||
// (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。
|
||||
// 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。
|
||||
const suspectWGS = `(r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'
|
||||
AND abs((r.raw->>'ZRAWLATITUDE')::float8 - COALESCE(p.lat,0)) < 1e-9
|
||||
AND abs((r.raw->>'ZRAWLONGITUDE')::float8 - COALESCE(p.lon,0)) < 1e-9
|
||||
//
|
||||
// 两边的取值都走 RawFloat8:留档值不是数字时给 NULL,比较结果也是 NULL = 不命中,
|
||||
// 不会像以前那样因为 `::float8` 把整页 500 掉(旧版那条正则 '^-?[0-9.]+$' 连
|
||||
// "1.2.3" 都放行,等于没护栏)。
|
||||
var suspectWGS = `abs(` + rond.RawFloat8(`r.raw->>'ZRAWLATITUDE'`) + ` - COALESCE(p.lat,0)) < 1e-9
|
||||
AND abs(` + rond.RawFloat8(`r.raw->>'ZRAWLONGITUDE'`) + ` - COALESCE(p.lon,0)) < 1e-9
|
||||
AND p.lat BETWEEN 3.86 AND 53.55 AND p.lon BETWEEN 73.66 AND 135.05`
|
||||
|
||||
// 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移),
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -82,7 +84,7 @@ func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
|
||||
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
@@ -120,10 +122,74 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
|
||||
b.WriteString("</gpx>\n")
|
||||
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
|
||||
downloadDisposition("places-"+time.Now().Format("20060102")+".gpx"))
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
// downloadDisposition 拼 Content-Disposition。
|
||||
//
|
||||
// 导出包的名字里带着 `datasets.original_name`,那是**上传时的文件名**——含引号、斜杠、
|
||||
// 控制字符都正常,甚至可能整段 `"; filename*=…` 之类的内容。原样塞进 `filename="…"`
|
||||
// 就能提前闭合引号、往这个头里塞参数(net/http 只把 CR/LF 换成空格,管不了引号)。
|
||||
// 所以给两个值:
|
||||
// - `filename=` 只留 ASCII 安全字符,老浏览器兜底;
|
||||
// - `filename*=`(RFC 5987)按 UTF-8 百分号编码带真实名字,现代浏览器优先取它,
|
||||
// 中文名不会像以前那样被当成 Latin-1 乱码。
|
||||
func downloadDisposition(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "download"
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := asciiSafe(strings.TrimSuffix(name, ext))
|
||||
// 纯中文名的词干映射完就是一串下划线,给个能读的兜底名;但不能把扩展名一起丢掉,
|
||||
// 否则用户下到手里是个没法双击打开的「download」。
|
||||
if !hasAlnum(stem) {
|
||||
stem = "download"
|
||||
}
|
||||
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", stem+asciiSafe(ext), rfc5987Escape(name))
|
||||
}
|
||||
|
||||
// asciiSafe 把不能安全写进 `filename="…"` 的字符换成下划线:
|
||||
// 引号能提前闭合这个参数,斜杠/反斜杠会在落盘时被当成路径,`;` `=` 能伪造后续参数。
|
||||
func asciiSafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return '_'
|
||||
}
|
||||
switch r {
|
||||
case '"', '\\', '/', ';', ':', '=', '*', '?', '<', '>', '|':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
func hasAlnum(s string) bool {
|
||||
return strings.ContainsFunc(s, func(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
})
|
||||
}
|
||||
|
||||
// rfc5987Escape 按 RFC 5987 的 attr-char 编码:ASCII 字母数字与 `. _ - ~` 原样,
|
||||
// 其余**逐字节**百分号编码(UTF-8 的每个字节各编一次,这正是规范要求的)。
|
||||
// 故意只放行这个窄集合:`;` `=` 这类在 attr-char 之外,漏掉就能截断参数。
|
||||
func rfc5987Escape(s string) string {
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '_' || c == '-' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hexDigits[c>>4])
|
||||
b.WriteByte(hexDigits[c&0x0f])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
|
||||
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
|
||||
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
|
||||
@@ -150,7 +216,8 @@ func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, e
|
||||
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
|
||||
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
|
||||
var sha string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
|
||||
var size int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
|
||||
@@ -163,19 +230,41 @@ func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string
|
||||
}
|
||||
return fi.ModTime().After(fj.ModTime())
|
||||
})
|
||||
if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) {
|
||||
got, _, err := ingest.File(f)
|
||||
return got, err
|
||||
}); f != "" {
|
||||
return f, nil
|
||||
}
|
||||
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
|
||||
}
|
||||
|
||||
// pickArchiveBySha 从候选文件里挑出摘要匹配的那一份(没有则返回空串)。
|
||||
//
|
||||
// 先按字节数筛一道再算摘要:uploads 里可能堆着几十份几百 MB 的备份,每份都得完整读一遍
|
||||
// 才比得出 sha256,而 sha256 相同就一定字节数相同——大小对不上的根本不必读。
|
||||
// 没有 sha 可比时(老库或退化数据)不做筛选,保持「取最新的那份」的旧行为。
|
||||
//
|
||||
// hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,
|
||||
// 带不带这道筛选完全一样,区分不出来。
|
||||
func pickArchiveBySha(files []string, sha string, size int64, hash func(string) (string, error)) string {
|
||||
for _, f := range files {
|
||||
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
|
||||
fi, err := os.Stat(f)
|
||||
if err != nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
got, _, err := ingest.File(f)
|
||||
if sha != "" && size > 0 && fi.Size() != size {
|
||||
continue
|
||||
}
|
||||
got, err := hash(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if sha == "" || got == sha {
|
||||
return f, nil
|
||||
return f
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
|
||||
return ""
|
||||
}
|
||||
|
||||
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
|
||||
@@ -204,6 +293,15 @@ func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
|
||||
w.Write(buf.Bytes())
|
||||
downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
|
||||
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
|
||||
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := buf.WriteTo(w); err != nil {
|
||||
log.Printf("导出:下载写出中断: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
|
||||
return
|
||||
}
|
||||
// 必须先卡请求体:ParseMultipartForm 只限制「内存里留多少」,超出的部分会
|
||||
// 无上限地落到临时文件,光靠后面 LimitReader 拦不住磁盘被写满。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
|
||||
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
|
||||
return
|
||||
|
||||
@@ -233,7 +233,7 @@ func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
|
||||
imported++
|
||||
}
|
||||
|
||||
s.refreshDatasetAgg(ctx, ds.ID)
|
||||
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, ds.ID))
|
||||
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
|
||||
if withImage > 0 {
|
||||
msg += fmt.Sprintf(",配了 %d 张图", withImage)
|
||||
|
||||
@@ -97,8 +97,9 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
|
||||
p.FilterBase = r.URL.Path
|
||||
p.Presets = buildPresets(p.From, p.To)
|
||||
// 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用),
|
||||
// BackQS 是把自己挂给别人的(列表页生成详情链接用)。
|
||||
// BackQS 是把自己挂给别人的(列表页生成详情链接用),SelfPath 是它的未编码原文。
|
||||
p.Back = backFromRequest(r)
|
||||
p.SelfPath = selfPath(r)
|
||||
p.BackQS = backQS(r)
|
||||
return p, nil
|
||||
}
|
||||
@@ -715,10 +716,6 @@ func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Weekday, err = s.q.Weekday(ctx, f); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, m := range d.Monthly {
|
||||
if m.Count > d.MaxMonthly {
|
||||
d.MaxMonthly = m.Count
|
||||
@@ -1101,10 +1098,31 @@ type GPXTrack struct {
|
||||
|
||||
// apiGpx 返回已导入的 GPX 轨迹。库里存的是 WGS-84(GPX 标准),
|
||||
// 底图是 GCJ-02 时按点转成火星坐标,保证和足迹点对齐。
|
||||
//
|
||||
// 轨迹按**当前生效数据集的属主**圈定,不是把整张表端出去:gpx_tracks 带 user_id,
|
||||
// 这是公开接口、不带登录态,不按属主过滤就等于把别人的轨迹一起露给访客。
|
||||
// 没有生效数据集时返回空集——和页面其余部分「没数据就没有」的口径一致。
|
||||
func (s *Server) apiGpx(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
empty := func() {
|
||||
s.json(w, struct {
|
||||
Tracks []GPXTrack `json:"tracks"`
|
||||
}{Tracks: []GPXTrack{}})
|
||||
}
|
||||
var owner int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM datasets
|
||||
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&owner)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
empty()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.json(w, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
crs := r.URL.Query().Get("crs")
|
||||
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, points, length_km, started_at
|
||||
FROM gpx_tracks ORDER BY started_at DESC NULLS LAST, id DESC`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, points, length_km, started_at
|
||||
FROM gpx_tracks WHERE user_id=$1 ORDER BY started_at DESC NULLS LAST, id DESC`, owner)
|
||||
if err != nil {
|
||||
s.json(w, map[string]string{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -94,17 +94,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
|
||||
power(sin(radians((%s - p.lat) / 2)), 2) +
|
||||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||||
|
||||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
||||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
||||
func topicAreaCond(t *Topic, args *[]any) string {
|
||||
if !t.HasArea() {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
n := len(*args)
|
||||
return fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
}
|
||||
|
||||
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||||
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
|
||||
regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
|
||||
args := []any{datasetID, t.StartAt, t.EndAt}
|
||||
area := ""
|
||||
if t.HasArea() {
|
||||
args = append(args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
n := len(args)
|
||||
area = fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
}
|
||||
area := topicAreaCond(t, &args)
|
||||
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
|
||||
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
|
||||
// 不能把这份(已被追加过的)args 整个传进去。
|
||||
@@ -168,18 +174,54 @@ func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
|
||||
return points, totalVisits, days, len(cities), nil
|
||||
}
|
||||
|
||||
// fillTopicStats 给专题填上内容统计(列表页展示用)。
|
||||
func (s *Server) fillTopicStats(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) {
|
||||
points, visits, days, cities, err := s.topicContent(ctx, t, datasetID, regions, exclude)
|
||||
// fillTopicStatsBatch 一次算完所有专题的内容统计。
|
||||
//
|
||||
// 列表页原本每个专题跑 2 条查询(点位 + 天数),专题一多就是 2N 次往返;而列表
|
||||
// 只需要汇总数字、不需要点位。这里用 UNION ALL 把 N 个专题拼成一条查询:每个子查询
|
||||
// 自带一套占位符(从 base 往后编),args 顺序必须与拼接顺序一致。口径与详情页
|
||||
// (topicPage → topicContent)完全一致 —— 同一专题在列表页和详情页必须数字相同。
|
||||
func (s *Server) fillTopicStatsBatch(ctx context.Context, list []Topic, datasetID int64,
|
||||
regions []string, exclude bool) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
var b strings.Builder
|
||||
var args []any
|
||||
for i := range list {
|
||||
t := &list[i]
|
||||
if i > 0 {
|
||||
b.WriteString(" UNION ALL ")
|
||||
}
|
||||
base := len(args)
|
||||
args = append(args, datasetID, t.StartAt, t.EndAt)
|
||||
area := topicAreaCond(t, &args)
|
||||
region := regionSQL("p", regions, exclude, &args)
|
||||
fmt.Fprintf(&b, `SELECT %d AS tid, count(DISTINCT p.id), count(*),
|
||||
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date),
|
||||
count(DISTINCT NULLIF(p.city,'')),
|
||||
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0)
|
||||
FROM places p JOIN visits v ON v.place_id = p.id
|
||||
WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d%s%s`,
|
||||
i, base+1, base+2, base+3, area, region)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, b.String(), args...)
|
||||
if err != nil {
|
||||
// 以前这里直接 return:专题列表会静默显示成「0 个地点」,看不出是出错了。
|
||||
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
|
||||
for _, p := range points {
|
||||
t.DwellMin += p.Dwell
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var i, places, visits, days, cities int
|
||||
var dwell int64
|
||||
if err := rows.Scan(&i, &places, &visits, &days, &cities, &dwell); err != nil {
|
||||
return err
|
||||
}
|
||||
if i < 0 || i >= len(list) {
|
||||
return fmt.Errorf("专题统计返回了越界序号 %d", i)
|
||||
}
|
||||
list[i].PlaceCount, list[i].VisitCount, list[i].DayCount, list[i].CityCount = places, visits, days, cities
|
||||
list[i].DwellMin = dwell
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59,
|
||||
@@ -249,8 +291,9 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for i := range list {
|
||||
s.fillTopicStats(ctx, &list[i], ds.ID, nil, false) // 后台管理页:站长看全部
|
||||
// 后台管理页:站长看全部
|
||||
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
d.Topics = list
|
||||
// 编辑模式:?edit=<id>
|
||||
@@ -372,8 +415,8 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
regions, exclude := visitorRegions(p)
|
||||
for i := range list {
|
||||
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude)
|
||||
if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil {
|
||||
log.Printf("专题统计失败: %v", err)
|
||||
}
|
||||
d.Topics = list
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -17,18 +18,53 @@ import (
|
||||
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
|
||||
const voterCookie = "rond_voter"
|
||||
|
||||
// clientIP 取访客 IP,优先用反代透传的 X-Forwarded-For 第一段。
|
||||
// clientIP 取访客 IP,匿名投票身份用它叠一层「同一来源」的识别。
|
||||
//
|
||||
// X-Forwarded-For 客户端自己也能发,所以这里有两个讲究:
|
||||
// 1. 取**最后一段**而不是第一段。nginx 的 `$proxy_add_x_forwarded_for` 是把客户端发来的值
|
||||
// 原样接在**前面**、再把真实对端补在后面,所以第一段恰恰是最好伪造的那个;最后一段是
|
||||
// 反代自己写的,客户端改不动。
|
||||
// 2. 只有最后一段确实能解析成 IP 才采用,否则整个头作废、退回 RemoteAddr——
|
||||
// 免得一句垃圾字符串被当成身份。
|
||||
//
|
||||
// 遗留 1:若本站直接暴露在公网(前面没有反代),客户端仍能自造这个头。要彻底堵上得在最外层
|
||||
// 覆盖或剥掉它(`proxy_set_header X-Forwarded-For $remote_addr`)。
|
||||
//
|
||||
// 遗留 2:反代前面再叠一层 CDN(而反代没配 `real_ip`)时,最后一段会变成 CDN 自己的 IP,
|
||||
// 于是所有访客被叠成同一个来源。cookie 仍各不相同,只是这层「同源」识别退化成全员共用,
|
||||
// 不会把谁认成谁。真有多层代理,判据要改成「按已知跳数从右往左取」,不能写死最后一段。
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
if ip, ok := lastForwardedIP(r.Header.Values("X-Forwarded-For")); ok {
|
||||
return ip
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return hostOnly(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// lastForwardedIP 取 X-Forwarded-For 的最后一段并校验成 IP。多行头按逗号拼起来看,
|
||||
// 与实际代理的拼装方式一致;IPv4-mapped IPv6 归一成 IPv4,同一客户端不该因为写法不同而换身份。
|
||||
func lastForwardedIP(header []string) (string, bool) {
|
||||
raw := strings.Join(header, ",")
|
||||
if i := strings.LastIndexByte(raw, ','); i >= 0 {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
last := strings.TrimSpace(raw)
|
||||
if addr, err := netip.ParseAddr(last); err == nil {
|
||||
return addr.Unmap().String(), true
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(last); err == nil {
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.Unmap().String(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// hostOnly 去掉 RemoteAddr 里的端口;没有端口就原样返回。
|
||||
func hostOnly(remoteAddr string) string {
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
|
||||
@@ -238,8 +274,8 @@ func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
next := r.PostFormValue("next")
|
||||
if !strings.HasPrefix(next, "/") {
|
||||
next := safeInternalPath(r.PostFormValue("next"))
|
||||
if next == "" {
|
||||
next = "/admin"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -58,6 +60,50 @@ func TestMapLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// EditPlaceLink 是「在数据编辑页打开这个地点」,它把返回地址挂在 ?back= 上。
|
||||
//
|
||||
// 这里只允许编码**一次**。退回当前页时以前用的是 BackQS(已经 QueryEscape 过),
|
||||
// 再转一次就成了 %252F…,服务端解一次拿到 %2F… 不以 / 开头,被 backFromRequest
|
||||
// 当站外地址丢掉 —— 直接打开 /places/1 再点「编辑」,编辑页的返回按钮就凭空消失。
|
||||
func TestEditPlaceLinkBackIsEncodedOnce(t *testing.T) {
|
||||
const self = "/places/1?city=河池市&sort=dwell"
|
||||
cases := []struct {
|
||||
name string
|
||||
p Page
|
||||
want string // back 参数解码一次后的原值
|
||||
}{
|
||||
{"列表页给的 back", Page{
|
||||
Back: template.URL("/places?city=河池市"),
|
||||
SelfPath: self,
|
||||
BackQS: template.URL(url.QueryEscape(self)),
|
||||
}, "/places?city=河池市"},
|
||||
{"back 为空时退回当前页", Page{
|
||||
SelfPath: self,
|
||||
BackQS: template.URL(url.QueryEscape(self)),
|
||||
}, self},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := string(c.p.EditPlaceLink(2141))
|
||||
i := strings.Index(got, "&back=")
|
||||
if i < 0 {
|
||||
t.Fatalf("%s:链接里没有 back 参数:%s", c.name, got)
|
||||
}
|
||||
encoded := got[i+len("&back="):]
|
||||
raw, err := url.QueryUnescape(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("%s:back 值解不出来:%v", c.name, err)
|
||||
}
|
||||
if raw != c.want {
|
||||
t.Errorf("%s:back 解码一次 = %q,期望 %q(链接 %s)", c.name, raw, c.want, got)
|
||||
}
|
||||
// 服务端真的会收这一份:解出来的必须还能通过站内校验,否则返回按钮是空的
|
||||
r := httptest.NewRequest("GET", "/admin/edit?open=2141&back="+encoded, nil)
|
||||
if bk := backFromRequest(r); string(bk) != c.want {
|
||||
t.Errorf("%s:服务端解出 back=%q,期望 %q —— 返回按钮会消失", c.name, bk, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串,
|
||||
// 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上,
|
||||
// 按钮退化成光秃秃的「返回」。
|
||||
|
||||
@@ -2,7 +2,9 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -21,6 +23,21 @@ type pageJob struct {
|
||||
run func(context.Context) error
|
||||
}
|
||||
|
||||
// runJob 跑一个任务,并把 panic 收敛成错误。
|
||||
//
|
||||
// 这层兜底不能省:任务跑在子 goroutine 里,而 HTTP 中间件的 recover 只覆盖请求
|
||||
// goroutine(server.go 的 s.recover),子 goroutine 里逃出去的 panic 会直接终止
|
||||
// **整个进程**——表现是「站点进程消失」,而不是这一页返回 500。
|
||||
func (s *Server) runJob(ctx context.Context, j pageJob) (err error) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("页面查询 %s 崩溃: %v\n%s", j.name, rec, debug.Stack())
|
||||
err = fmt.Errorf("查询 %s 内部错误", j.name)
|
||||
}
|
||||
}()
|
||||
return j.run(ctx)
|
||||
}
|
||||
|
||||
// runJobs 并发执行互不依赖的查询,返回第一个(按传入顺序)非 nil 错误。
|
||||
//
|
||||
// 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms),
|
||||
@@ -29,7 +46,7 @@ type pageJob struct {
|
||||
func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
|
||||
if len(jobs) <= 1 {
|
||||
for _, j := range jobs {
|
||||
if err := j.run(ctx); err != nil {
|
||||
if err := s.runJob(ctx, j); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -50,7 +67,7 @@ func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
start := time.Now()
|
||||
errs[i] = jobs[i].run(ctx)
|
||||
errs[i] = s.runJob(ctx, jobs[i])
|
||||
if d := time.Since(start); d > slowJobLog && s.cfg.Debug {
|
||||
log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,6 +13,35 @@ import (
|
||||
|
||||
func testServer() *Server { return &Server{cfg: &config.Config{}} }
|
||||
|
||||
// TestRunJobsRecoversPanic 钉住「任务 panic 不拖垮进程」。
|
||||
// runJobs 的任务跑在子 goroutine 里,而 HTTP 层的 recover 只覆盖请求 goroutine,
|
||||
// 没有这层兜底时,任何一次 nil 解引用都会让**整个进程**退出(不是这一页报错)。
|
||||
func TestRunJobsRecoversPanic(t *testing.T) {
|
||||
s := testServer()
|
||||
err := s.runJobs(context.Background(),
|
||||
pageJob{"ok", func(context.Context) error { return nil }},
|
||||
pageJob{"boom", func(context.Context) error { panic("查询炸了") }},
|
||||
pageJob{"after", func(context.Context) error { return nil }},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("任务 panic 时应返回错误,而不是让调用方以为这页查成功了")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("错误里应带上出错任务的名字,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunJobsRecoversPanicInline 单任务走的是同步分支,也必须兜住 panic。
|
||||
func TestRunJobsRecoversPanicInline(t *testing.T) {
|
||||
s := testServer()
|
||||
err := s.runJobs(context.Background(), pageJob{"boom", func(context.Context) error {
|
||||
panic("查询炸了")
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("单任务 panic 时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobsRunsConcurrently(t *testing.T) {
|
||||
s := testServer()
|
||||
const n = 8
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -202,23 +203,77 @@ func TestPlaceUpdateBatchActivity(t *testing.T) {
|
||||
t.Fatalf("打开导出包失败: %v", err)
|
||||
}
|
||||
defer edb.Close()
|
||||
ph := make([]string, len(pks))
|
||||
ph := make([]string, 0, len(pks))
|
||||
args := make([]any, 0, len(pks)+1)
|
||||
for i, v := range pks {
|
||||
ph[i] = "?"
|
||||
// ⚠️ 参数顺序必须与 SQL 里占位符的出现顺序一致:`FILTER (WHERE ZACTIVITY_=?)` 在
|
||||
// SELECT 列表里,它的 `?` 是整个语句的第一个,所以 actSrc 要排在所有 pks 之前。
|
||||
// 曾经写成先追加 pks、最后追加 actSrc,于是这个 `?` 绑到了 pks[0] 上,
|
||||
// 同时 IN 列表被顶掉一位、变成 pks[1:] ∪ {actSrc}:条数偶尔少 1(正是历史 flaky 的来源),
|
||||
// 而 converted 只有碰巧 pks[0] == actSrc 时才等于 246。
|
||||
args = append(args, actSrc)
|
||||
for _, v := range pks {
|
||||
ph = append(ph, "?")
|
||||
args = append(args, v)
|
||||
}
|
||||
args = append(args, actSrc)
|
||||
var inPackage, converted int
|
||||
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
|
||||
strings.Join(ph, ",") + `)`
|
||||
if strings.Count(q, "?") != len(args) {
|
||||
t.Fatalf("占位符 %d 个、参数 %d 个,绑定位错位了", strings.Count(q, "?"), len(args))
|
||||
}
|
||||
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
|
||||
t.Fatalf("读导出包失败: %v", err)
|
||||
}
|
||||
if inPackage != len(pks) {
|
||||
t.Fatalf("导出包里只有 %d/%d 条到访", inPackage, len(pks))
|
||||
// 两种成因的修法完全不同,所以失败时直接把证据打出来:
|
||||
// ① pks 自身有重复 —— IN 列表里有 246 个值、只命中 245 个不同的行
|
||||
// ② pks 唯一但包里真缺行 —— 导出/覆盖那条路把某一行弄丢了
|
||||
missing, dup, total := diffVisitPKs(t, edb, pks)
|
||||
t.Fatalf("导出包里只有 %d/%d 条到访(包内 ZVISIT 共 %d 行);缺 %v,pks 内重复 %v",
|
||||
inPackage, len(pks), total, missing, dup)
|
||||
}
|
||||
if converted != len(pks) {
|
||||
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
|
||||
}
|
||||
}
|
||||
|
||||
// diffVisitPKs 说清「包里的到访为什么比预期少」:返回包里缺的编号、pks 里重复的编号、
|
||||
// 以及包内 ZVISIT 的总行数。总行数能立刻区分「少写了一行」和「两行撞成一个 Z_PK」。
|
||||
func diffVisitPKs(t *testing.T, edb *sql.DB, pks []int) (missing, dup []int, total int) {
|
||||
t.Helper()
|
||||
have := map[int]bool{}
|
||||
rows, err := edb.Query(`SELECT Z_PK FROM ZVISIT`)
|
||||
if err != nil {
|
||||
t.Fatalf("读导出包的到访编号失败: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var pk int
|
||||
if err := rows.Scan(&pk); err != nil {
|
||||
rows.Close()
|
||||
t.Fatalf("扫导出包到访编号失败: %v", err)
|
||||
}
|
||||
have[pk] = true
|
||||
total++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
t.Fatalf("读导出包到访编号中断: %v", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
want := map[int]int{}
|
||||
for _, v := range pks {
|
||||
want[v]++
|
||||
}
|
||||
for v, c := range want {
|
||||
if c > 1 {
|
||||
dup = append(dup, v)
|
||||
}
|
||||
if !have[v] {
|
||||
missing = append(missing, v)
|
||||
}
|
||||
}
|
||||
sort.Ints(missing)
|
||||
sort.Ints(dup)
|
||||
return missing, dup, total
|
||||
}
|
||||
|
||||
@@ -438,3 +438,49 @@ func abs(v float64) float64 {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// TestEditPageSurvivesGarbageRawCoord 钉住「留档里的坐标不是数字时,编辑页照样打得开」。
|
||||
//
|
||||
// entity_raw 是原样存下来的 JSON,值不一定就是数字。以前那两列直接 `::float8`,
|
||||
// 一条脏数据就能让整个数据编辑页 500:查询里还有 CASE / 正则等别的条件,表现是
|
||||
// 「每次打开都 500、别的页面都正常」,很难一眼看出是哪一行哪一列的事。
|
||||
func TestEditPageSurvivesGarbageRawCoord(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
|
||||
var pk int
|
||||
var orig []byte
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, r.raw FROM places p
|
||||
JOIN entity_raw r ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
|
||||
WHERE p.dataset_id=$1 ORDER BY p.src_pk LIMIT 1`, f.dsID).Scan(&pk, &orig); err != nil {
|
||||
t.Fatalf("找不到带留档的地点: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `UPDATE entity_raw SET raw=$1::jsonb
|
||||
WHERE dataset_id=$2 AND entity='ZLOCATION' AND src_pk=$3`, string(orig), f.dsID, pk)
|
||||
})
|
||||
|
||||
get := func() *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
f.srv.editPage(rec, req)
|
||||
return rec
|
||||
}
|
||||
if rec := get(); rec.Code != 200 {
|
||||
t.Fatalf("基线就渲染失败:%d", rec.Code)
|
||||
}
|
||||
|
||||
// 坐标位置塞一个「像数字但不是数字」的字符串:旧那条正则 '^-?[0-9.]+$' 会放行它
|
||||
if _, err := f.pg.ExecContext(f.ctx,
|
||||
`UPDATE entity_raw SET raw = jsonb_set(raw, '{ZLATITUDE}', to_jsonb('1.2.3'::text))
|
||||
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, pk); err != nil {
|
||||
t.Fatalf("写入脏留档失败: %v", err)
|
||||
}
|
||||
rec := get()
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("留档坐标不是数字时编辑页返回 %d,应照常 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "</html>") {
|
||||
t.Error("页面被截断(没有收尾的 </html>)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,45 @@ func TestPlaceImageUploadDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceImageUploadRejectsOversizedBody 上传接口必须先卡住请求体再解析 multipart。
|
||||
//
|
||||
// ParseMultipartForm 的 maxMemory 只管「内存里留多少」,超出的部分会无上限地落到
|
||||
// 临时文件;不先套 MaxBytesReader,一个大请求就能把磁盘写满。
|
||||
//
|
||||
// 判据用响应文案而不是「文件大小」:只有 ParseMultipartForm 失败那一条分支会回
|
||||
// 「可能太大」。把 MaxBytesReader 那行删掉,请求会一路解析成功、最后被 6MB 的单张
|
||||
// 上限拦成「图片超过 6MB」,这条用例就会红。
|
||||
func TestPlaceImageUploadRejectsOversizedBody(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
f.cfg.DataDir = t.TempDir()
|
||||
setMediaBackend(t, f, "local")
|
||||
|
||||
var src int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
|
||||
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
|
||||
t.Fatalf("找不到地点: %v", err)
|
||||
}
|
||||
|
||||
// 超出门限(imageMaxBytes + 1MB)一大截
|
||||
raw := webpBytes(imageMaxBytes + 3<<20)
|
||||
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
|
||||
map[string]string{"src_pk": fmt.Sprint(src)},
|
||||
map[string][]byte{"file": raw})
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "可能太大") {
|
||||
t.Errorf("超大请求体没在解析阶段被挡下: %s", rec.Body.String())
|
||||
}
|
||||
// 没落库(解析阶段就失败,后面取 src_pk / 存图那几步都到不了)
|
||||
var n int
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images
|
||||
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, src).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("被拒的上传落了库: %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
|
||||
// 并且图片挂在正确的 src_pk 上。
|
||||
func TestPhotoImportWithImage(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -90,6 +90,11 @@ type Server struct {
|
||||
masks maskCache
|
||||
// captcha 存登录验证码的答案(内存、一次性、带过期)
|
||||
captcha *captcha.Store
|
||||
// loginGuard 是登录失败的短锁(按用户名指数退避),验证码之外的又一道闸。
|
||||
// 两步验证那一步也用同一个实例,但键带 "2fa:" 前缀,两边计数互不干扰。
|
||||
loginGuard *auth.Guard
|
||||
// totpReplay 记住最近用过的验证码:同一个码在 ±1 步窗口内只能换一次会话
|
||||
totpReplay *auth.CodeReplay
|
||||
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
|
||||
media *media.Store
|
||||
}
|
||||
@@ -106,21 +111,23 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
|
||||
pages[key] = pageSet{t: t, entry: def.entry}
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
q: stats.DB{DB: db},
|
||||
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
|
||||
sets: &setting.Store{DB: db},
|
||||
notes: ¬es.Store{DB: db},
|
||||
votes: &votes.Store{DB: db},
|
||||
pages: pages,
|
||||
mux: http.NewServeMux(),
|
||||
cookie: "rond_session",
|
||||
tiles: buildTileSet(cfg),
|
||||
tileCache: newTileCache(1024),
|
||||
fogTiles: newTileCache(512),
|
||||
captcha: captcha.NewStore(10 * time.Minute),
|
||||
media: media.New(cfg),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
q: stats.DB{DB: db},
|
||||
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
|
||||
sets: &setting.Store{DB: db},
|
||||
notes: ¬es.Store{DB: db},
|
||||
votes: &votes.Store{DB: db},
|
||||
pages: pages,
|
||||
mux: http.NewServeMux(),
|
||||
cookie: "rond_session",
|
||||
tiles: buildTileSet(cfg),
|
||||
tileCache: newTileCache(1024),
|
||||
fogTiles: newTileCache(512),
|
||||
captcha: captcha.NewStore(10 * time.Minute),
|
||||
loginGuard: auth.NewGuard(),
|
||||
totpReplay: auth.NewCodeReplay(),
|
||||
media: media.New(cfg),
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
@@ -142,7 +149,7 @@ func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recover(gzipHandler(s.logRequests(s.withUser(s.mux))))
|
||||
return s.recover(gzipHandler(s.logRequests(s.withUser(s.csrfOriginGuard(s.mux)))))
|
||||
}
|
||||
|
||||
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
|
||||
@@ -314,15 +321,42 @@ func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// headerSent 记录响应首部是否已经提交,供 recover 判断「还能不能写错误页」。
|
||||
// 首部一旦提交,http.Error 只能再 WriteHeader 一次(Go 记一条 superfluous
|
||||
// response.WriteHeader)并把错误体追加到半个页面后面,比不写更糟。
|
||||
type headerSent struct {
|
||||
http.ResponseWriter
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (h *headerSent) WriteHeader(code int) {
|
||||
h.sent = true
|
||||
h.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (h *headerSent) Write(b []byte) (int, error) {
|
||||
h.sent = true
|
||||
return h.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Unwrap 让 http.NewResponseController 能穿过这层拿到真正的 ResponseWriter
|
||||
// (SetWriteDeadline / Flush / Hijack 都靠逐层解包)。
|
||||
func (h *headerSent) Unwrap() http.ResponseWriter { return h.ResponseWriter }
|
||||
|
||||
func (s *Server) recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hw := &headerSent{ResponseWriter: w}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
|
||||
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
|
||||
if hw.sent {
|
||||
// 已经发出去了,再写错误页只会污染响应;真正的补救是断开连接。
|
||||
return
|
||||
}
|
||||
http.Error(hw, "服务器内部错误", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
next.ServeHTTP(hw, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,19 +405,39 @@ func userFrom(ctx context.Context) *auth.User {
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, userID int64, hash string) {
|
||||
// cookieSecure 判断这次响应能不能给会话 cookie 打 Secure。
|
||||
//
|
||||
// 不能无条件打:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上,
|
||||
// 打了 Secure 浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是不是
|
||||
// 走的 https」判断:直连看 r.TLS,在反向代理后面看 X-Forwarded-Proto(只取首段)。
|
||||
func cookieSecure(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||
proto = proto[:i]
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(proto), "https")
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, userID int64, hash string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cookie,
|
||||
Value: s.auth.SignToken(userID, hash),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: cookieSecure(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: s.cfg.SessionDays * 86400,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cookie, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: cookieSecure(r),
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -444,9 +498,15 @@ func (s *Server) json(w http.ResponseWriter, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// serverError 统一处理内部错误。详情只回给已登录的站长:公开访客页把 err.Error()
|
||||
// 原样回显,等于把 SQL 错误里的表结构、列名、以及磁盘路径直接送到门外。
|
||||
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
|
||||
http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)
|
||||
msg := "服务器内部错误,请稍后重试"
|
||||
if userFrom(r.Context()) != nil {
|
||||
msg = "服务器内部错误: " + err.Error()
|
||||
}
|
||||
http.Error(w, msg, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func atoiOr(s string, def int) int {
|
||||
|
||||
@@ -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 上")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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()`)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -86,6 +86,10 @@ type Page struct {
|
||||
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
|
||||
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
|
||||
BackQS template.URL
|
||||
|
||||
// SelfPath 是 BackQS 的**未编码**原文。需要在这一层再编码一次的场景(编辑页的
|
||||
// ?back=)必须用它——拿已编码的 BackQS 再编码会变成 %252F…(见 EditPlaceLink)。
|
||||
SelfPath string
|
||||
}
|
||||
|
||||
// IsAdmin 报告当前访问者是否已登录后台。
|
||||
@@ -143,12 +147,12 @@ func (p Page) filterPairs() []string {
|
||||
return kv
|
||||
}
|
||||
|
||||
// backQS 把当前请求的地址编码成 ?back= 的值。
|
||||
// selfPath 是当前请求的地址(路径 + 规范化后的查询串),**未编码**。
|
||||
//
|
||||
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
|
||||
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
|
||||
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
|
||||
func backQS(r *http.Request) template.URL {
|
||||
func selfPath(r *http.Request) string {
|
||||
q := r.URL.Query()
|
||||
q.Del("back")
|
||||
// ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次
|
||||
@@ -158,27 +162,39 @@ func backQS(r *http.Request) template.URL {
|
||||
if e := q.Encode(); e != "" {
|
||||
u += "?" + e
|
||||
}
|
||||
return template.URL(url.QueryEscape(u))
|
||||
return u
|
||||
}
|
||||
|
||||
// backFromRequest 取出 ?back= 并只放行**站内路径**。
|
||||
// backQS 是 selfPath 的编码形式,直接当 ?back= 的值用。
|
||||
// 需要在这一层再编码一次的场景要用 selfPath,别拿它再转一遍(会变成 %252F…)。
|
||||
func backQS(r *http.Request) template.URL {
|
||||
return template.URL(url.QueryEscape(selfPath(r)))
|
||||
}
|
||||
|
||||
// safeInternalPath 只放行**站内路径**,不合法时返回空串。
|
||||
//
|
||||
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以这里必须挡死
|
||||
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以必须挡死
|
||||
// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
|
||||
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
|
||||
func backFromRequest(r *http.Request) template.URL {
|
||||
b := strings.TrimSpace(r.URL.Query().Get("back"))
|
||||
if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") {
|
||||
// 控制字符(含 %00、%0b)也一律不收:浏览器会把 NUL 之类的字符悄悄吃掉,
|
||||
// 留着就是给「同一串地址在不同环节解析成不同东西」留口子。
|
||||
//
|
||||
// ?back=、后台表单的 next、投票后的跳转三处共用这一份判据,别再各写一遍。
|
||||
func safeInternalPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(b); i++ {
|
||||
// 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收:
|
||||
// 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子
|
||||
if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
|
||||
for i := 0; i < len(p); i++ {
|
||||
if c := p[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return template.URL(b)
|
||||
return p
|
||||
}
|
||||
|
||||
func backFromRequest(r *http.Request) template.URL {
|
||||
return template.URL(safeInternalPath(r.URL.Query().Get("back")))
|
||||
}
|
||||
|
||||
// BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段):
|
||||
@@ -231,14 +247,18 @@ func (p Page) PlaceLink(id int64) template.URL {
|
||||
|
||||
// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。
|
||||
// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。
|
||||
//
|
||||
// 这里必须用**未编码**的 SelfPath:BackQS 已经 QueryEscape 过一次,
|
||||
// 再转一次就成了 %252F…,服务端解出来不以 / 开头会被当成站外地址丢掉,
|
||||
// 于是「直接打开 /places/1 再点编辑」的编辑页上返回按钮凭空消失。
|
||||
func (p Page) EditPlaceLink(srcPK int) template.URL {
|
||||
back := p.Back
|
||||
back := string(p.Back)
|
||||
if back == "" {
|
||||
back = p.BackQS
|
||||
back = p.SelfPath
|
||||
}
|
||||
u := "/admin/edit?open=" + strconv.Itoa(srcPK)
|
||||
if back != "" {
|
||||
u += "&back=" + url.QueryEscape(string(back))
|
||||
u += "&back=" + url.QueryEscape(back)
|
||||
}
|
||||
return template.URL(u)
|
||||
}
|
||||
@@ -1364,7 +1384,6 @@ func templateFuncs() template.FuncMap {
|
||||
}
|
||||
return b
|
||||
},
|
||||
"safe": func(s string) template.HTML { return template.HTML(s) },
|
||||
"colorof": colorOf,
|
||||
"icon": iconOf,
|
||||
"iconor": iconOr,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user