导出:新建活动没选颜色时补占位色,别再写 ZCOLOR_=NULL(真机拒收整包)

症状:生产导出的包导不回 rond,提示「错误,备份恢复失败,你的当前数据未被更改」。

根因链:站内「地点补全」新建活动时写的是 color=''、icon=''(fillup.go 的 INSERT),而导出侧
ns() 把空串也当空(返回 nil)——图标那侧早就有 📍 兜底(注释还写着「站点自己渲染时缺省用 📍」),
颜色这侧漏了,于是写出 ZACTIVITY.ZCOLOR_ = NULL。真机这一列在任何包里都是 0 NULL/0 空串
(扫了 data/ 与 storage/ 下全部 15 个包,只有出事的那份有 1 个空),Core Data 拿到非可选属性上的
NULL 会判成损坏数据 → 整包拒收。

修复:加 activityPlaceholderColor = "gray"(与 transportPlaceholderColor 同一套写法),
活动颜色为空时兜底。

用例:TestExportNeverNullsColumnsTheDeviceAlwaysFills 补上「没选颜色的活动」场景——它守的通用
不变量是「基线里 0 个 NULL 的列,导出新行也不许 NULL」,但此前只造过交通方式,没覆盖活动。
变异验证:去掉兜底 → 两条断言都红(提示语正好复刻生产现象)。

另新增 tools/audit_export_nulls.py:拿参照包做逐列空值画像 + 全部关系列悬空 + Z_MAX 检查,
是排查「导出被拒收」最快的一步(这次就是它一击命中的)。注意判据必须是「参照里 0 个 NULL」——
ZLOCATION.ZISOCOUNTRYCODE 真机自己就漏过 1 个 NULL,说明它容忍 NULL,不要当违规去修。

全量验收 293 PASS / 0 FAIL / 0 SKIP。
This commit is contained in:
tamakyi
2026-09-29 11:50:06 +08:00
parent 8763b850be
commit f26537b1e9
3 changed files with 209 additions and 4 deletions
+11 -3
View File
@@ -86,11 +86,12 @@ var keepNilCols = map[string][]string{
"ZTRANSPORT": {"ZNAME_", "ZCOLOR_", "ZICON_"},
}
// 新建交通方式没选颜色/图标时的占位值。真机这两列从来不空,写 NULL 是它没见过的形状;
// 颜色取 rond 系统色名,图标取一个必定存在的 SF Symbol。
// 新建行没选颜色/图标时的占位值。真机这几列从来不空(NULL 和空串都没有),写 NULL 是
// 它没见过的形状;颜色取 rond 系统色名,图标取一个必定存在的 SF Symbol。
const (
transportPlaceholderColor = "gray"
transportPlaceholderIcon = "circle.fill"
activityPlaceholderColor = "gray"
)
// movementTypeDefault 给站点新建的位移补 ZTYPE_:真机里这条规律覆盖 757/791 行
@@ -308,12 +309,19 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
if icon == nil {
icon = "📍"
}
// 颜色同样要兜底:ns() 把空串也当空,而「地点补全」新建的活动 color 就是空串
// (见 fillup.go),不兜底这里会写出 ZCOLOR_=NULL。真机这列从来不空——2026-09-29
// 生产导出就是因为这一格被 rond 判成损坏数据,提示「备份恢复失败,你的当前数据未被更改」。
color := ns(a.Color)
if color == nil {
color = activityPlaceholderColor
}
// 活动列表里的「最近活跃时间」:同 ZLOCATION,取该活动最晚一次到访
var last any
if t, ok := actLast[int64(a.ID)]; ok {
last = appleSeconds(t)
}
if err := act.write(a.SrcPK, []any{ns(a.Name), ns(a.Color), icon,
if err := act.write(a.SrcPK, []any{ns(a.Name), color, icon,
bint(a.Home), bint(a.Work), bint(a.Excluded), bint(a.Archived), last,
coreDataUUID(!known["ZACTIVITY"][a.SrcPK], "ZACTIVITY", a.SrcPK)},
raw); err != nil {