【寻迹校园 HarmonyOS NEXT 实战 14】RelationalStore 增量迁移:为旧失物记录安全补上 event_date 字段
【寻迹校园 HarmonyOS NEXT 实战 14】RelationalStore 增量迁移:为旧失物记录安全补上 event_date 字段
这是“寻迹校园 HarmonyOS NEXT 实战”系列第 14 篇。本文结合
ReportRepository.ets的真实实现,说明为什么升级表结构要先读取PRAGMA table_info,只在缺列时执行ALTER TABLE,并为旧记录提供可解释的日期回填策略。

上图为本文原创生成的迁移概念图,不是数据库截图。旧表没有 event_date 时才新增该列;已经升级过的数据库直接跳过,保证应用第二次、第三次启动仍然安全。
一、为什么失物记录需要 event_date
早期记录只有 time_label 和 created_at:
time_label适合展示“今天 08:00”“昨天 15:20”;created_at表示记录创建时间;- 真正的物品丢失或拾得日期可能早于发布时间。
匹配逻辑如果只看创建时间,会把“今天补发昨天丢失的物品”误认为今天事件。为了稳定计算候选日期差,模型新增绝对日期 event_date,格式为 YYYY-MM-DD。
新安装可以直接按新表创建;真正困难的是已经存在旧数据库的用户。不能删除旧表重建,也不能假设 CREATE TABLE IF NOT EXISTS 会给旧表自动补列。
二、CREATE TABLE IF NOT EXISTS 不会改造旧结构
项目的新建表 SQL 已包含 event_date TEXT NOT NULL DEFAULT ''。
但当 item_report 已经存在时,CREATE TABLE IF NOT EXISTS 只保证表存在,不会把新定义与旧结构比较后自动添加字段。
因此初始化需要两步:
- 执行
CREATE TABLE IF NOT EXISTS,覆盖首次安装; - 对旧表逐列检查,缺失时执行增量迁移。
把这两类场景分开,才能同时支持新装和升级。
三、先用 PRAGMA table_info 读取真实列结构
ensureColumn() 不信任代码中的期望结构,而是查询设备上当前数据库:
const resultSet: relationalStore.ResultSet =
await store.querySql(`PRAGMA table_info(${TABLE_REPORT})`);
let exists: boolean = false;
try {
while (resultSet.goToNextRow()) {
if (resultSet.getString(resultSet.getColumnIndex('name')) === column) {
exists = true;
break;
}
}
} finally {
resultSet.close();
}
ResultSet 必须关闭。即使找到目标列后提前 break,finally 仍然执行,避免资源泄漏。
这种方式把“是否迁移”的判断建立在真实 Schema 上,而不是进程内布尔值、版本号猜测或异常文本。
四、只有缺列时才执行 ALTER TABLE
检查完成后逻辑很明确:
if (!exists) {
await store.executeSql(
`ALTER TABLE ${TABLE_REPORT} ADD COLUMN ${column} ${definition}`
);
}
首次从旧版本升级:列不存在,执行一次 ALTER TABLE。以后启动:列已存在,直接跳过。
这就是幂等迁移的核心:同一个初始化流程执行多次,最终结构保持一致,不会因为重复运行产生错误。
五、为什么不能“直接 ALTER,报错就 catch”
另一种常见写法是每次启动都执行 ALTER TABLE ADD COLUMN,如果出现 duplicate column 就捕获忽略。项目中的 ClaimRepository 目前仍能看到这种历史写法:无条件添加 source_report_id,再用 try/catch 吞掉已有列错误。
它能让流程继续,却有明显维护风险:
- 每次启动都触发一个可预期异常;
catch可能同时吞掉权限、SQL 拼写、存储损坏等真正问题;- 日志会反复出现噪声,掩盖首条真实错误;
- 代码无法明确证明“列已存在”还是“迁移失败被忽略”;
- 不利于后续统计迁移成功率。
本文只记录这个真实风险,不宣称已经修复 ClaimRepository。更稳妥的后续动作是复用 ensureColumn,让异常只代表真正异常。

上图展示推荐链路与风险分支:读取列结构,缺列才新增,旧值再回填;无条件 ALTER 则会在二次启动进入重复列错误。
六、DEFAULT 空字符串解决的是结构兼容,不是业务语义
新增列使用 TEXT NOT NULL DEFAULT ''。
这样旧行在加列后有合法默认值,不会立即违反非空约束。但空字符串不是真实事件日期,只表示“旧数据尚无明确值”。
因此读取记录时,项目还要处理业务回填:若 storedEventDate 非空就使用;否则执行 this.formatDate(createdAt)。
这是一种可解释的兼容策略:旧数据没有事件日期时,以创建日期近似。它不是还原历史真相,产品与文档都不应把推导值说成用户原始填写值。
七、读取时回填与一次性数据更新如何选择
项目当前在 readReport() 中动态回填。优点是迁移简单,不需要批量更新旧行;缺点是数据库里的旧值继续为空,每次读取都要走兼容分支。
另一种方案是 Schema 迁移后执行一次数据更新,把 created_at 转成日期写入 event_date。这种方式查询更统一,但需要:
- 明确时间戳时区;
- 在事务中批量更新;
- 记录迁移版本与完成状态;
- 处理部分失败与重试;
- 说明推导值来源。
当前数据量和单机场景下,读取时回填是较小改动。未来需要按日期建立索引或做复杂查询时,再升级为一次性持久化迁移更合适。
八、formatDate 也有边界条件
formatDate() 使用本地 Date,通过 padStart(2, '0') 补齐月和日,再拼成稳定的 YYYY-MM-DD。
当时间戳小于等于 0 时,当前实现回退到 Date.now()。这能避免生成 1970 年异常展示,但也会隐藏脏数据来源。
生产级迁移应统计非法时间戳、记录受控告警,并决定使用“未知日期”、创建日期还是要求用户重新确认。不能让所有损坏记录静默变成今天。
九、schemaReady 不等于数据库版本
Repository 中的 schemaReady 用于避免同一对象重复检查:
if (!this.schemaReady) {
await this.store.executeSql(CREATE_REPORT_TABLE_SQL);
await this.ensureColumn(this.store, 'event_date', "TEXT NOT NULL DEFAULT ''");
await this.ensureColumn(this.store, 'image_uris', "TEXT NOT NULL DEFAULT ''");
this.schemaReady = true;
}
应用重启后它会恢复为 false,所以它只是进程内优化,不能作为迁移已完成的永久证据。真正的幂等性来自 PRAGMA table_info 与缺列判断。
当迁移数量增加时,应考虑正式 Schema 版本表或 user_version,按版本顺序执行并记录完成状态。每个迁移仍应尽量可重试、可验证。
十、迁移失败不能静默进入不兼容查询
如果新增列失败,但后续 readReport() 仍直接读取 event_date,查询会出现列不存在错误。初始化应把迁移失败作为存储层错误处理:
- 不把
schemaReady设为 true; - 返回稳定错误分类;
- 不执行依赖新列的写入;
- 页面展示可重试状态;
- 受控日志记录迁移步骤和列名,不打印用户数据;
- 下一次启动能够重新尝试。
“继续运行”不是所有场景的正确降级。如果旧 Schema 无法满足当前代码契约,静默回退可能造成更难定位的数据错误。
十一、迁移验收必须覆盖多种起点
至少准备以下数据库状态:
| 起点 | 操作 | 预期结果 |
|---|---|---|
| 无数据库 | 首次启动 | 创建完整新表 |
| 旧表无 event_date | 升级启动 | 新增列,旧行可读取 |
| 新表已有 event_date | 再次启动 | 不执行重复 ALTER |
| 旧行 event_date 为空 | 读取记录 | 使用 createdAt 推导值 |
| 新行有绝对日期 | 读取记录 | 保留原值 |
| createdAt 非法 | 读取旧行 | 按约定显示未知或受控回退 |
| 迁移 SQL 失败 | 初始化 | 不标记完成,用户收到存储错误 |
还应连续启动两次,验证第二次没有 duplicate column;再执行新建、编辑和列表筛选,确认新列不仅存在,而且业务链路正确消费。
十二、备份、回滚与数据安全
ADD COLUMN 通常是向后兼容修改,但交付前仍要准备:
- 升级前保留可恢复测试数据库;
- 对迁移脚本做旧版样本回放;
- 确认旧应用若回滚,是否会忽略新增列;
- 不在失败时删除整库或清空表;
- 记录迁移版本、耗时和失败分类;
- 对真实用户数据避免输出明文日志。
如果新版必须回滚,新增列通常可以保留,让旧代码忽略它;不要为了“恢复旧结构”贸然重建表并搬运数据。结构回滚的风险往往大于保留一个未使用列。
十三、变更影响分析不能只看 Repository
加入 event_date 还会影响:
ItemReport模型构造参数;ReportDraft与草稿表;- 发布页日期选择器和 Service 校验;
- 匹配规则中的日期差;
- 种子数据;
- 编辑旧记录;
- 测试夹具和导入导出;
- 后续 OpenAPI 或云同步 DTO。
字段迁移是端到端契约变更。只改建表 SQL而不检查模型和查询,最容易出现“能编译、启动后才崩”的问题。
十四、当前项目的真实结论
ReportRepository 和 ReportDraftRepository 已采用“查询列结构、缺失才新增”的方式处理 event_date 与 image_uris。代码层可以确认迁移设计具备幂等意图。
ClaimRepository 仍存在无条件添加 source_report_id 再吞异常的实现,这是已识别风险,不在本文中伪装成已修复。真实设备上的旧库升级、连续冷启动和数据样本回放仍需单独留证。
十五、本文小结
RelationalStore 增量迁移不能只修改 CREATE TABLE。安全链路应读取 PRAGMA table_info,确认列缺失后才执行 ALTER TABLE;新增列用默认值保障结构兼容,再以明确策略处理旧行语义。
进程内 schemaReady 只是优化,真正的二次启动安全来自幂等迁移。下一篇将讨论另一个容易误读的设计:同一个 Repository 为什么同时支持 RelationalStore 与内存回退,以及两种数据源各自能证明什么。
系列导航:第 14 篇 / 共 50 篇。上一篇:《按 LOST/FOUND 隔离草稿恢复》;下一篇:《Repository 双数据源策略》。
更多推荐


所有评论(0)