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

audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
tamakyi
2026-09-24 20:37:16 +08:00
parent 78865fc736
commit b4c2a8452f
70 changed files with 5417 additions and 360 deletions
+23
View File
@@ -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 配对会让同一条既算
+203
View File
@@ -0,0 +1,203 @@
package rond
import (
"database/sql"
"math"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func nullF(v float64) sql.NullFloat64 { return sql.NullFloat64{Float64: v, Valid: true} }
// 脏值必须判成「装不下」(nil)。旧实现直接 int64(sec),超范围是回绕不是饱和:
// 实测 NaN/±Inf/1e30 恰好回绕到基准点(2001-01-01)、1e12 落到 2123 年。
func TestAppleTimeRejectsDirtyValues(t *testing.T) {
dirty := map[string]float64{
"NaN": math.NaN(),
"+Inf": math.Inf(1),
"-Inf": math.Inf(-1),
"1e30": 1e30,
"-1e30": -1e30,
"1e12": 1e12, // 会落到 2123 年
"-1e12": -1e12,
"早于窗口": appleMinSec - 86400,
"晚于窗口": appleMaxSec + 86400,
}
for name, v := range dirty {
if tm := appleTime(nullF(v)); tm != nil {
t.Errorf("%s 应当被拒(nil),实际得到 %s", name, tm.Format(time.RFC3339Nano))
}
}
if appleTime(sql.NullFloat64{}) != nil {
t.Error("NULL 应当返回 nil")
}
}
// 合法值要原样保留,尤其是微秒精度——整数/小数分开算就是为了它。
func TestAppleTimeKeepsLegitValues(t *testing.T) {
for _, want := range []time.Time{
time.Date(1995, 7, 1, 12, 30, 45, 123456000, time.UTC),
time.Date(2018, 3, 9, 21, 4, 5, 987654000, time.UTC),
time.Date(2026, 9, 21, 10, 19, 9, 1000, time.UTC),
} {
got := appleTime(nullF(want.Sub(appleEpoch).Seconds()))
if got == nil {
t.Fatalf("%s 被误拒", want.Format(time.RFC3339Nano))
}
if !got.Equal(want) {
t.Errorf("时间失真: 期望 %s 得到 %s", want.Format(time.RFC3339Nano), got.Format(time.RFC3339Nano))
}
}
for _, edge := range []float64{appleMinSec, appleMaxSec} {
if appleTime(nullF(edge)) == nil {
t.Errorf("窗口边界 %.0f 不该被拒", edge)
}
}
}
func TestValidCoord(t *testing.T) {
for _, c := range [][2]float64{
{math.NaN(), 0}, {0, math.NaN()}, {math.Inf(1), 0}, {0, math.Inf(-1)},
{91, 0}, {-91, 0}, {0, 181}, {0, -181}, {999, 999},
} {
if validCoord(c[0], c[1]) {
t.Errorf("无效坐标 (%v,%v) 被判成可用", c[0], c[1])
}
}
for _, c := range [][2]float64{{0, 0}, {24.1, 109.1}, {90, 180}, {-90, -180}, {39.9042, 116.4074}} {
if !validCoord(c[0], c[1]) {
t.Errorf("合法坐标 (%v,%v) 被误拒", c[0], c[1])
}
}
}
// 函数再对,没接上去也白搭:读 ZLOCATION 的循环必须过一遍 validCoord。
func TestParseSanitizesLocationCoords(t *testing.T) {
_, self, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("拿不到测试文件路径")
}
src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go"))
if err != nil {
t.Fatal(err)
}
text := string(src)
i := strings.Index(text, "FROM ZLOCATION")
if i < 0 {
t.Fatal("parse.go 里找不到 ZLOCATION 查询")
}
j := strings.Index(text[i:], "b.Locations = append")
if j < 0 {
t.Fatal("找不到了 ZLOCATION 循环的收尾")
}
if !strings.Contains(text[i:i+j], "validCoord(") {
t.Error("读 ZLOCATION 的循环没有调用 validCoord 归一化坐标")
}
}
// 端到端:脏备份走一遍真实的 Parse,不许把伪时间/越界坐标带进领域模型。
func TestParseRejectsDirtyTimeAndCoords(t *testing.T) {
good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC)
goodSec := good.Sub(appleEpoch).Seconds()
path := filepath.Join(t.TempDir(), "Backup.sqlite")
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatal(err)
}
defer sdb.Close()
for _, stmt := range []string{
`CREATE TABLE ZTAGGROUP (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT)`,
`CREATE TABLE ZTAG (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZGROUP INTEGER)`,
`CREATE TABLE ZACTIVITY (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT,
ZISHOME INTEGER, ZISWORK INTEGER, ZISEXCLUDED INTEGER, ZISARCHIVED INTEGER, ZUID_ BLOB)`,
`CREATE TABLE ZTRANSPORT (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT)`,
`CREATE TABLE ZLOCATION (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCATEGORY_ TEXT,
ZLATITUDE REAL, ZLONGITUDE REAL, ZISOCOUNTRYCODE TEXT, ZADMINISTRATIVEAREA TEXT,
ZLOCALITY TEXT, ZSUBLOCALITY TEXT, ZTHOROUGHFARE TEXT, ZSUBTHOROUGHFARE TEXT, ZTIMEZONE TEXT)`,
`CREATE TABLE ZVISIT (Z_PK INTEGER PRIMARY KEY, ZLOCATION INTEGER, ZACTIVITY_ INTEGER,
ZARRIVALDATE_ REAL, ZDEPARTUREDATE_ REAL, ZBOOKMARKED INTEGER, ZUSERADDED INTEGER,
ZREMARK_ TEXT, ZEMOJINAME_ TEXT, ZWEATHERSYMBOL_ TEXT, ZIDENTIFIER_ BLOB)`,
`CREATE TABLE Z_11VISITS_ (Z_18VISITS_ INTEGER, Z_11TAGS_5 INTEGER)`,
`CREATE TABLE ZMOVEMENT (Z_PK INTEGER PRIMARY KEY, ZTRANSPORT_ INTEGER, ZVISITFROM_ INTEGER,
ZVISITTO_ INTEGER, ZSTART_ REAL, ZEND_ REAL)`,
`CREATE TABLE ZHOURLYWEATHER (Z_PK INTEGER PRIMARY KEY, ZVISIT INTEGER, ZDATE_ REAL,
ZISDAYLIGHT INTEGER, ZTEMPERATURE_ REAL, ZAPPARENTTEMPERATURE_ REAL, ZHUMIDITY_ REAL,
ZPRECIPITATIONAMOUNT_ REAL, ZPRECIPITATIONCHANCE_ REAL, ZWINDSPEED_ REAL, ZVISIBILITY_ REAL,
ZUVINDEXVALUE_ INTEGER, ZCONDITION_ TEXT, ZSYMBOLNAME_ TEXT, ZUVINDEXCATEGORY_ TEXT,
ZWINDCOMPASSDIRECTION_ TEXT)`,
} {
if _, err := sdb.Exec(stmt); err != nil {
t.Fatalf("建表失败: %v", err)
}
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := sdb.Exec(q, args...); err != nil {
t.Fatalf("插入失败: %v", err)
}
}
exec(`INSERT INTO ZACTIVITY (Z_PK, ZNAME_, ZCOLOR_, ZICON_, ZISHOME, ZISWORK, ZISEXCLUDED, ZISARCHIVED)
VALUES (1,'通勤','#fff','🚇',0,0,0,0)`)
// 1=正常;2=坐标越界;3=坐标量级离谱(模拟 NaN 入库前的形态)
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (1,'正常',24.1,109.1)`)
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (2,'越界',999,-999)`)
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (3,'离谱',1e30,0)`)
// 1=正常时间;2=ZARRIVALDATE_ 落到 2123 年;3=量级离谱(旧实现回绕成 2001)
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_)
VALUES (1,1,1,?,?)`, goodSec, goodSec+3600)
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (2,2,1,1e12)`)
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (3,3,1,-1e30)`)
// 位移:时间坏了保留「有行、无时间」,不能写成伪时间
exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_)
VALUES (1,1,1,1,?,?)`, goodSec, goodSec+600)
exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_)
VALUES (2,1,1,1,1e12,1e12)`)
b, err := Parse(sdb)
if err != nil {
t.Fatal(err)
}
byLoc := map[int]Location{}
for _, l := range b.Locations {
byLoc[l.SrcPK] = l
}
if got := byLoc[1]; got.Lat != 24.1 || got.Lon != 109.1 {
t.Errorf("正常坐标被改动了: (%v,%v)", got.Lat, got.Lon)
}
if got := byLoc[2]; got.Lat != 0 || got.Lon != 0 {
t.Errorf("越界坐标应归零,实际 (%v,%v)", got.Lat, got.Lon)
}
if got := byLoc[3]; got.Lat != 0 || got.Lon != 0 {
t.Errorf("量级离谱的坐标应归零,实际 (%v,%v)", got.Lat, got.Lon)
}
byVisit := map[int]Visit{}
for _, v := range b.Visits {
byVisit[v.SrcPK] = v
}
if v := byVisit[1]; v.Arrival == nil || !v.Arrival.Equal(good) {
t.Errorf("正常到访时间丢了: %v", v.Arrival)
}
for _, pk := range []int{2, 3} {
if v := byVisit[pk]; v.Arrival != nil {
t.Errorf("到访 #%d 的脏时间应当被拒,实际 %s", pk, v.Arrival.Format(time.RFC3339))
}
}
if len(b.Movements) != 2 {
t.Fatalf("位移条数不对: %d", len(b.Movements))
}
if m := b.Movements[0]; m.Start == nil || !m.Start.Equal(good) {
t.Errorf("正常位移时间丢了: %v", m.Start)
}
if m := b.Movements[1]; m.Start != nil || m.End != nil {
t.Errorf("脏位移时间应当为空而不是伪时间: start=%v end=%v", m.Start, m.End)
}
}
+98
View File
@@ -0,0 +1,98 @@
package rond
import (
"archive/zip"
"bytes"
"os"
"strings"
"testing"
)
// TestCopyLimitedRejectsOverrun 守住备份包解压的体积上限。
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明——必须在读的时候掐住。
func TestCopyLimitedRejectsOverrun(t *testing.T) {
var buf bytes.Buffer
if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 99); err == nil {
t.Error("超过上限应当报错")
}
// 恰好等于上限要放过,内容也不能被截断
buf.Reset()
if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 100); err != nil {
t.Errorf("恰好等于上限不该报错: %v", err)
}
if buf.Len() != 100 {
t.Errorf("正常内容被截断了: %d 字节(应为 100)", buf.Len())
}
buf.Reset()
if err := copyLimited(&buf, strings.NewReader("短"), 100); err != nil {
t.Errorf("正常内容不该报错: %v", err)
}
if buf.String() != "短" {
t.Errorf("内容被改动了: %q", buf.String())
}
}
// TestUnpackArchiveEnforcesLimit 确认上限真的接在 Open 的解压路径上,
// 而不只是 copyLimited 孤零零地正确。上限抽成参数就是为了能在这里用小值验一遍。
func TestUnpackArchiveEnforcesLimit(t *testing.T) {
// 真上限(2 GiB)没法用一个小包走一遍,退一步把它圈在「挡得住 zip bomb、
// 又不至于误伤真备份」的区间里——顺手拦住把常量改成 1<<62 这类手滑。
if maxDBBytes < 1<<30 || maxDBBytes > 8<<30 {
t.Errorf("maxDBBytes=%d 超出合理区间 [1GiB, 8GiB]", maxDBBytes)
}
const payload = 4096
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, name := range []string{"Backup.sqlite", "Backup.sqlite-wal"} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(bytes.Repeat([]byte("s"), payload)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
// 上限低于条目体积:必须报错,而且不留半个文件在盘上
if _, err := unpackArchive(zr, dir, payload-1); err == nil {
t.Error("条目超过上限时应当报错")
} else {
t.Logf("已拒绝: %v", err)
}
if left, _ := filepathGlob(dir); len(left) != 0 {
t.Errorf("被拒绝的条目留下了残留文件: %v", left)
}
// 放宽到够用:正常解出主库路径
dbPath, err := unpackArchive(zr, dir, payload)
if err != nil {
t.Fatalf("上限够用时不该报错: %v", err)
}
if !strings.HasSuffix(dbPath, "Backup.sqlite") {
t.Errorf("返回的主库路径不对: %s", dbPath)
}
}
// filepathGlob 列出 dir 下的文件(测试辅助,避免为上一条再引一个包)。
func filepathGlob(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
out = append(out, e.Name())
}
return out, nil
}
+124 -28
View File
@@ -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
+66
View File
@@ -0,0 +1,66 @@
package rond
import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
)
// D3 回归:parse.go 里每个 rows 循环的收尾都必须查 rows.Err()。
//
// 只 Close 不查 Err 是「不报错也不算错」的那一类:驱动或连接中途出错时
// Next() 只返回 false、从不报错,读到一半的表会被当成完整的交出去——
// 表现成导入后到访/位移莫名变少,日志里一个字都没有。
//
// 两种合格写法:循环后 `if err = drainRows(x, "…")`,或自己 `x.Err()`。
func TestParseDrainsEveryRowsLoop(t *testing.T) {
_, self, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("定位测试文件失败")
}
src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go"))
if err != nil {
t.Fatalf("读 parse.go 失败: %v", err)
}
text := string(src)
lits := regexp.MustCompile(`for (\w+)\.Next\(\)`).FindAllStringSubmatch(text, -1)
seen := map[string]bool{}
for _, m := range lits {
name := m[1]
if seen[name] {
continue
}
seen[name] = true
if !strings.Contains(text, name+".Err()") && !strings.Contains(text, "drainRows("+name+",") {
t.Errorf("parse.go 里 %s 的循环收尾没查 %s.Err()(只 Close 会把读一半的表当完整的交出去)", name, name)
}
}
if len(seen) < 10 {
t.Fatalf("只认出 %d 个 rows 循环变量(期望 ≥10),判据可能被改写架空,请同步更新本用例", len(seen))
}
// 收尾助手自己必须真的读 Err,否则上面那条判据能被一个空壳 drainRows 骗过去
body := between(text, "func drainRows(", "\nfunc ")
if body == "" {
t.Fatal("找不到 drainRows 的实现")
}
if !strings.Contains(body, "Err()") || !strings.Contains(body, "Close()") {
t.Errorf("drainRows 必须既查 Err() 又 Close(),实际:\n%s", body)
}
}
func between(s, start, end string) string {
i := strings.Index(s, start)
if i < 0 {
return ""
}
rest := s[i+len(start):]
if j := strings.Index(rest, end); j >= 0 {
return rest[:j]
}
return rest
}