
功能开关最危险的时候通常不是服务端完全不可用而是它返回了一份“JSON 能解析、业务却不能用”的配置。比如checkoutV2本来应该是布尔值却被后台写成字符串灰度比例从 0.25 变成 25依赖的riskGuard被关掉结算入口却仍然开启。应用如果只做JSON.parse()这些问题都要等页面跑到相应分支才暴露。FeatureSnapshotLab把远端配置看成发布输入而不是运行时随手消费的数据。Node 构建脚本使用 Zod 4 描述版本化模式先把旧结构迁移到 v3再做跨字段检查Hvigor 构建阶段只接纳通过门禁的标准化快照。设备侧读取rawfile/feature_snapshot.json与 Preferences 中的已激活版本对账不能验证的候选永远不覆盖上一版。本次演示任务为CFG-1842-319时间 18:42候选版本2026.10.06-r19模式版本 3。25 项规则完成 17 项进度 68%迁移 4 个字段发现 3 个坏值候选状态QUARANTINED。设备继续使用2026.10.05-r18不会因为新文件存在就抢先切换。一、先从产物反推安装包里只允许出现一种配置这类工具最容易写成一个“检查命令”CI 打印三条红字但后面的打包任务仍然执行原始 JSON 还是进了 HAP。看起来有门禁实际只有提醒。更稳妥的规则是让打包输入只有一个来源resources/rawfile/feature_snapshot.json必须由验证任务生成仓库不直接提交它生成失败时文件不存在后续任务没有可打包的候选。原始响应、迁移日志与错误报告放在构建工作区不进入应用包。Hvigor 以任务为基本工作单元任务之间形成有向无环图。hvigorfile.ts可以注册任务、插件和生命周期 hook。本文不绑定某个内部任务名而是要求“验证配置”成为资源处理或打包任务的显式前置依赖。不同 DevEco Studio/Hvigor 版本的扩展 API 以当前工具链声明为准不能把旧博客中的任务名称原样抄进新工程。Demo 的输入目录是config/incoming/feature_2026.10.06-r19.json输出目录是entry/src/main/resources/rawfile/feature_snapshot.json报告是build/reports/feature-gate/CFG-1842-319.json。只有报告状态为APPROVED时才写正式输出QUARANTINED只写报告和隔离副本。二、模式迁移和业务校验必须分成两步第一段代码解决旧配置结构继续存在的问题。v2 用整数百分比v3 改为 0 到 1 的小数v2 的enabledFeatures数组也迁移成flags对象。迁移只改变表示方式不偷偷替业务补一个“合理值”。import*aszfromzod;constFlagSchemaz.object({enabled:z.boolean(),rollout:z.number().min(0).max(1),requires:z.array(z.string()).default([])});constSnapshotV3z.object({schemaVersion:z.literal(3),version:z.string().regex(/^\d{4}\.\d{2}\.\d{2}-r\d$/),flags:z.record(z.string(),FlagSchema),expiresAt:z.iso.datetime()}).check((ctx){constflagsctx.value.flags;for(const[name,flag]ofObject.entries(flags)){for(constdepofflag.requires){if(!flags[dep]?.enabledflag.enabled){ctx.issues.push({code:custom,path:[flags,name,requires],message:enabled flag requires${dep},input:flag.requires});}}}});functionmigrateToV3(raw:unknown):unknown{constsourcerawasRecordstring,unknown;if(source.schemaVersion!2)returnraw;returnmigrateV2FieldsWithoutGuessing(source);}Zod 4 的safeParse()会把成功数据与错误分支分开不需要靠异常控制普通坏输入.check()适合表达跨字段约束。这里故意没有使用.catch()给坏值兜底因为配置门禁的目标不是“无论如何都产出”而是让错误停在发布前。migrateV2FieldsWithoutGuessing()只处理确定的结构变化整数百分比除以 100数组项映射成显式对象旧字段名重命名。遇到rollout: 25不主动转数字遇到未知依赖也不自动创建开关。自动猜测会让报告变绿却把后台错误永久写进标准快照。需要特别注意 Zod 4 的版本边界。官方迁移说明指出默认值与可选字段的行为存在变化如果项目从 Zod 3 升级不能只看 TypeScript 编译通过还要对“缺字段、显式 undefined、默认值”做快照回归。本文使用的是 Zod 4 公共 API不依赖内部类型。三、失败时不要覆盖输出文件第二段代码是构建脚本的核心。它读取候选、执行迁移、safeParse、整理可复核错误通过时先写.next再替换正式快照失败时删除本轮.next上一份正式快照不动。importfsfromnode:fs;importpathfromnode:path;exportfunctionbuildFeatureSnapshot(input:string,output:string,reportPath:string):void{consttaskIdCFG-1842-319;constrawJSON.parse(fs.readFileSync(input,utf8))asunknown;constmigratedmigrateToV3(raw);constresultSnapshotV3.safeParse(migrated);constnextPath${output}.next;if(!result.success){constissuesresult.error.issues.map((issue)({path:issue.path.join(.),code:issue.code,message:issue.message}));fs.mkdirSync(path.dirname(reportPath),{recursive:true});fs.writeFileSync(reportPath,JSON.stringify({taskId,state:QUARANTINED,progress:68,version:2026.10.06-r19,issues},null,2));if(fs.existsSync(nextPath))fs.unlinkSync(nextPath);thrownewError(FEATURE_SNAPSHOT_REJECTED:${issues.length});}fs.mkdirSync(path.dirname(output),{recursive:true});fs.writeFileSync(nextPath,JSON.stringify(result.data,null,2));fs.renameSync(nextPath,output);}这里的原子性范围是构建工作区内的同文件系统重命名不应扩大解释成所有平台、所有文件系统都绝对原子。真正重要的是失败分支没有写正式输出。CI 还要把脚本非零退出码传给 Hvigor不能在外层捕获后继续打包。错误报告只保存路径、错误码和脱敏信息。远端配置如果包含实验人群、内部 URL 或商业参数不应把完整原文上传到公开构建日志。隔离副本要设置访问范围和保留期限修复完成后按任务号清理。项目结构中tools/validate-flags.ts负责模式迁移与验证config/incoming只存 CI 临时输入resources/rawfile/feature_snapshot.json是唯一进包产物pages/FeatureGatePage.ets负责调试展示model/FeatureActivation.ets保存设备侧激活结果。下面的 DevEco Studio 风格图是按演示数据生成的说明画面不冒充真实构建截图。四、68% 不代表“差不多能发”而是明确不能激活构建页面很容易把进度做成心理暗示。25 项过了 17 项看起来已经大半完成但其中任何一条可能是硬门槛。Demo 将规则分为三类ERROR阻断产物REVIEW要求人工确认INFO只记录变化。进度只表示检查执行比例不表示风险剩余比例。候选2026.10.06-r19的 3 个坏值分别是checkoutV2.rollout使用字符串couponStack.enabled缺失checkoutV2开启但依赖的riskGuard关闭。前两个是结构错误第三个是业务不变量错误。即便迁移脚本成功移动了 4 个字段也不能把候选状态改成通过。03 图展示构建门禁的当前状态任务CFG-1842-319、模式 v3、候选版本、17/25 与 68%、迁移字段 4、坏值 3、状态QUARANTINED。红色标注圈出“3 个坏值”提醒进度与放行是两套信号。五、设备侧只激活经过构建批准的版本第三段代码解决安装包内快照与设备历史状态的交接。应用从 rawfile 读取标准快照解析出版本后先与 Preferences 中的激活版本比较再更新版本标记。示例假定构建门禁已经保证结构有效设备侧仍对 JSON 和必要字段做最小防御检查。import { preferences } from kit.ArkData; import { util } from kit.ArkTS; async function activateBundledSnapshot(context: Context): Promisestring { const bytes await context.resourceManager .getRawFileContent(feature_snapshot.json); const text new util.TextDecoder().decodeToString(bytes); const candidate JSON.parse(text) as Recordstring, Object; const version String(candidate[version] ?? ); const schemaVersion Number(candidate[schemaVersion] ?? 0); if (version.length 0 || schemaVersion ! 3) { throw new Error(BUNDLED_SNAPSHOT_INVALID); } const store await preferences.getPreferences(context, feature_activation); const current await store.get(activeVersion, 2026.10.05-r18) as string; if (current version) return current; await store.put(pendingVersion, version); await store.flush(); await store.put(activeVersion, version); await store.delete(pendingVersion); await store.flush(); return version; }Preferences 的put()修改需要通过flush()或flushSync()同步到持久化文件。示例使用pendingVersion留下两阶段痕迹应用崩溃后可判断激活是否完成。但 Preferences 不是关系型事务如果配置内容本身还需要运行时下载和多表更新应改用具有事务能力的存储而不是把两个flush()宣称为原子事务。为什么演示设备仍显示2026.10.05-r18因为候选 r19 在构建期已经隔离正式feature_snapshot.json没有被覆盖。设备不会看到坏候选更不会执行激活代码。调试页可以读取随测试包附带的脱敏报告但生产包不应携带隔离详情。六、把坏版本留在证据链里而不是留在运行时04 图从结果反推整个流程18:42:11 读取 r1918:42:11 完成 v2→v3 的 4 字段迁移18:42:12 规则进度到 68%随后记录SCHEMA_ISSUES3和QUARANTINED正式输出保持 r18设备激活版本也是 r18。红圈标在OUTPUT_UNCHANGED因为这才是门禁真正产生的结果。诊断时不要只搜异常字符串。需要把taskId、候选版本、模式版本、输入摘要、工具版本、规则集版本、输出摘要和最终状态关联起来。这样后台修复后重新生成 r20才能证明它使用了哪份输入、通过了哪组规则而不是仅凭“这次构建绿了”。同一个候选不应无限重试。CI 可以按内容摘要去重摘要相同且上一次是QUARANTINED直接复用报告并阻断只有输入或规则集变化才重新验证。这能避免错误配置触发每小时构建制造一串内容完全相同的失败记录。七、测试重点放在迁移边界和输出不变性第一组使用完整 v3 配置检查标准化输出稳定同样输入必须生成同样字段顺序与摘要。第二组使用合法 v2 配置验证 4 个字段迁移后语义不变。第三组加入字符串百分比、缺失布尔值和关闭依赖确认得到 3 个结构化错误正式输出哈希保持不变。第四组覆盖 Zod 4 默认值行为字段缺失、显式undefined、空对象分别断言不让升级库版本悄悄改变快照。第五组让写.next成功、重命名前中断下一次任务必须先识别并清理本任务临时文件不能把陈旧.next当新候选。第六组验证设备激活中断只写入pendingVersion后进程退出重启时应该回到上一稳定版本并记录恢复原因。第七组检查 release 包确认只包含批准快照不包含config/incoming、隔离副本和完整错误报告。工具升级也要进入证据链。Zod 4 小版本、Node 版本、Hvigor 版本或规则集变化都可能改变输出。升级后先对历史有效配置和历史坏配置跑回归语料再允许新工具生成发布快照。构建工具不是“开发环境细节”它参与决定安装包内容本身就是供应链的一部分。八、最终目标不是让 JSON 更漂亮而是让错误停得更早远端功能开关常被当成一种灵活能力但灵活不等于可以跳过发布纪律。Zod 负责把输入变成可解释的成功或失败迁移函数负责保留版本语义Hvigor 负责把门禁放进任务依赖设备侧只接手已经批准的快照。CFG-1842-319在 68% 发现 3 个坏值后停下r19 被隔离r18 保持激活。表面看是一次构建失败实际避免的是一份坏配置在设备上变成随机页面行为。好的门禁不一定让发布更快但它能让失败发生在最容易解释、最容易回滚的位置。参考资料Hvigor 构建系统生命周期Zod 4 基础用法Zod 4 迁移说明rawfile 内容读取Preferences 数据存储