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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
165 lines
6.4 KiB
Go
165 lines
6.4 KiB
Go
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
|
|
}
|