【HarmonyOS 7新能力|027】业务能力接入工程封装实战:契约校验、状态编排与分层结构落地
接入一个系统能力很容易跑出演示:调一次接口、弹一个界面、拿到一个结果。但把它变成可预测、可调测、可审核的产品能力是另一回事。入参边界模糊、缺参时直接执行、权限声明与代码行为对不上,演示里的"顺利"在真实使用中会变成误触发、错账和越权风险。这类问题没有一行是出在"接口调不通"上,全部出在工程封装的粗糙上。
本文把业务能力接入拆成场景、契约、编排、能力、治理和交付六层,贯通开发、调测、审核与上架准备。示例业务设定为"支付前的安全确认",其余能力(账号授权、设备联动、资料变更)可以按同一套框架平移。文中代码采用适配层表达,正式方法签名以当前官方SDK为准;清单、类型与目录结构是应用侧工程示例。
一、从单一可验证场景开始
第一版只做一个场景:用户在高风险交易前完成一次安全确认。把场景写窄有三个好处:验收标准可以穷举、失败路径可以枚举、联调对象(后台、风控、客服话术)都收在一个闭环里。场景定义写成一段可评审的文字——触发条件、用户看到什么、用户做什么、成功与失败怎么判定、每条失败路径的出口是什么。写不清楚的场景,后面每一层都会跟着模糊,最后变成"大家都在猜"。
// 场景描述的结构化表达 const scenario = { id: 'pay-confirm-v1', trigger: (order) => order.amountMinor >= 5000000 || order.riskLevel === 'high', ui: { title: '安全确认', fields: ['merchant', 'amount', 'orderId'] }, actions: ['inputSecret', 'confirm', 'cancel'], success: (r) => r.status === 'SUCCEEDED', failure: ['CANCELLED', 'TIMEOUT', 'MISMATCH', 'LOCKED'], };1.1 一个写宽了的反例
第一版评审时有人提议把"订单改址、退款、换绑账号"一起塞进同一个确认流程,理由是"都是确认,界面长得差不多"。做出来之后问题不断:退款要展示退款说明、改址要展示新旧地址对比,界面模板被迫做条件分支;失败码从四个膨胀到十一个;客服话术对不上号。最后还是拆回单场景,各自独立演进。场景边界宁可窄,窄了可以加,宽了拆不动。
二、把请求写成机器可检验契约
契约是六层里最便宜也最值钱的一层。一份请求契约包含字段、类型、单位、必选性、约束和错误码,写完立即变成校验代码,所有入口共用。金额用最小货币单位、订单号限定字符集、时间戳统一毫秒——这些约定散落在代码里迟早打架,收进契约一处管死。
// 请求契约与校验器 const confirmContract = { orderId: { type: 'string', pattern: /^[A-Za-z0-9-]{8,64}$/, required: true }, amountMinor: { type: 'integer', min: 1, required: true }, currency: { type: 'enum', values: ['CNY', 'USD'], required: true }, merchantId: { type: 'string', pattern: /^M\d{6,12}$/, required: true }, issuedAt: { type: 'integer', min: 0, required: true }, }; function validate(contract, input) { const errs = []; for (const [k, rule] of Object.entries(contract)) { const v = input[k]; if (rule.required && (v === undefined || v === null || v === '')) { errs.push('MISSING:' + k); continue; } if (v === undefined) continue; if (rule.type === 'integer' && (!Number.isInteger(v) || (rule.min !== undefined && v < rule.min))) errs.push('TYPE:' + k); if (rule.pattern && !rule.pattern.test(String(v))) errs.push('FORMAT:' + k); if (rule.type === 'enum' && !rule.values.includes(v)) errs.push('ENUM:' + k); } return errs; }校验失败直接短路返回结构化错误,不允许带着脏参数往下走。契约变更走版本号,老版本保留一个过渡期,两端按版本协商,避免"服务端升了客户端没跟"的窗口期互咬。
2.1 契约测试怎么落地
契约不是文档,是测试。把契约表自动生成一批正负用例:缺必选字段、类型错、格式错、枚举越界各来一组,断言校验器返回的错误码逐个对得上。契约改了用例没改,测试就红——这比任何代码评审都可靠。
2.2 契约版本协商的实操约定
版本号放进请求头,服务端按版本路由校验规则。约定三条:同一时间最多存在两个活跃版本;新版本上线旧版本至少保留十四天;废弃版本的第一天开始打迁移提醒日志,按调用方统计谁还没升。这三条让契约演进从"赌博"变成"排期",两端团队都能按自己的节奏走。
三、入参必须有来源和合法性边界
每个入参都要回答三个问题:从哪来(用户输入、服务端下发、设备采集)、能到哪去(哪些层可见)、不合法时谁兜底。来源标记让排查有据可查;可见域限制防止敏感字段被顺手传进日志;兜底策略防止"缺一个字段就卡死整个流程"。
| 字段 | 来源 | 可见域 | 非法兜底 |
|---|---|---|---|
| orderId | 服务端下发 | 全部层 | 拒绝进入编排 |
| amountMinor | 服务端计算 | 编排+界面 | 拒绝并告警 |
| secret | 用户输入 | 仅能力适配层 | 提示重输 |
| deviceFp | 设备采集 | 治理层 | 降级为匿名风控 |
secret 这类字段永远不进日志、不进状态机、不进任何回调参数,只在适配层内存里短暂存在,用完即弃。这条红线写进代码评审清单,谁碰谁改。
3.1 一次字段越权的复盘
早期版本为了排查方便,把 deviceFp 拼进了确认结果的回调参数里,结果日志系统把整个回调对象都打了出来,敏感采集字段进了普通业务日志。复盘后的规矩:回调参数白名单制,新增字段必须先过可见域表;日志层加了一道脱敏过滤器,命中敏感键名直接打码。越权不是黑客才有的问题,工程图省事就是最大的越权来源。
3.2 可见域在代码里怎么落地
可见域不靠口头约定,靠类型收窄:编排层拿到的请求对象是 Pick 过的子集,敏感字段在类型上就不存在,编译器替你把关。适配层内部再建一个独立的小结构体装敏感值,出了作用域即销毁,日志序列化器对这个类型直接输出掩码。三层防线(类型、作用域、序列化)任何一层漏了,后面两层还能兜住。
四、用状态机编排多步流程
安全确认至少有探测、就绪、展示、校验、终态五个阶段,加上取消、超时、锁定三条中断路径。状态机把"现在能发生什么"变成显式规则:任何时刻只允许一条出口链,非法迁移抛错而不是吞掉。
const FLOW = new Map([ ['idle', ['probing']], ['probing', ['ready', 'unsupported']], ['ready', ['presenting']], ['presenting', ['verifying', 'cancelled', 'timeout']], ['verifying', ['succeeded', 'failed', 'timeout']], ]); function step(from, event, trace) { const map = { probing: 'probing', confirm: 'verifying', cancel: 'cancelled', expire: 'timeout' }; const to = map[event]; if (!to || !(FLOW.get(from) || []).includes(to)) { trace.mark('illegal:' + from + '+' + event); throw new Error('ILLEGAL ' + from + ' + ' + event); } return to; }把超时也当作一次正常迁移处理,就不会出现"界面在等、会话已死"的悬空态。每个迁移都带 trace 打点,一次请求从头到尾一条线,排查时按 requestId 一查到底。
4.1 悬空态的三种成因
实测下来悬空态只有三种成因:事件重入(用户狂点、回调重放)、会话失效但界面还活着、超时没有接到状态机上。前两种靠非法迁移防护暴露,第三种靠把超时事件化。三样都做齐之后,这类的线上工单归零。
五、能力适配器隔离平台与业务
平台差异(版本、机型、服务开通状态)只允许出现在适配层。业务层依赖的是四个抽象方法:探测、开会话、展示、释放。换机型、升版本、在模拟器跑回归,业务层一行不改。
export interface SecureConfirmAdapter { probe(): Promise<CapabilityReport>; begin(intent: ConfirmIntent): Promise<Session>; present(session: Session, payload: Summary): Promise<Result>; release(session: Session): Promise<void>; }判断适配层切得好不好,有个土办法:能不能写一个内存版测试桩替换真适配器,把编排层和治理层的单测整体跑通。能,说明边界干净;不能,说明业务层偷摸摸碰了平台细节。
5.1 内存测试桩长什么样
// 内存桩:行为可编排,用于编排层与治理层单测 class MemoryAdapter { constructor(script) { this.script = script; this.calls = []; } async probe() { this.calls.push('probe'); return this.script.probe; } async begin(intent) { this.calls.push(['begin', intent.orderId]); return { id: 's-1' }; } async present(session, payload) { this.calls.push(['present', payload.digest]); return this.script.present; } async release(session) { this.calls.push('release'); } }桩的关键不是返回值,而是 calls 数组:单测断言"调用顺序、调用次数、传参内容",编排层的流程正确性就有了硬校验。桩跑得通的业务层,接真适配器只差环境问题。
六、分层架构约束副作用
六层各管一件事:场景层定义验收、契约层管数据、编排层管流程、能力层碰平台、治理层管日志与降级、交付层管产物。副作用(网络、存储、弹窗)只允许在能力层和治理层出现,其余层保持纯函数——同样的输入永远得到同样的输出,测试不需要起环境。
6.1 一次真实的越层事故
编排层有个同事为了"快一点",直接在流程函数里调了 storage.set 把中间态落盘。单测全绿,集成测试时并发两条流程互相覆盖中间态,偶现订单错乱。修法是把落盘挪进治理层,编排层只发"状态已变更"事件,谁关心谁订阅。副作用越层的代码单测照样绿,所以约束不能靠自觉,要靠评审清单和分层 lint。
七、输出也必须经过契约校验
入参有契约,回传结果同样要有。平台返回码、摘要哈希、时间戳逐项校验,任何一项不过就按失败处理,绝不"看到成功字样就当成功"。
function verifyResult(raw, expectedDigest) { if (raw.status !== 'SUCCEEDED') return 'FAILED:' + raw.status; if (raw.digest !== expectedDigest) return 'MISMATCH'; if (Date.now() - raw.finishedAt > 300000) return 'STALE'; return 'OK'; }摘要哈希在请求发出前算好存档,结果回来比对,两边不一致说明中间发生了不该发生的事——展示被替换、参数被篡改、或者拿错了单子。宁可错杀重走流程,不放过一次不一致。
7.1 结果校验的失败演练
校验代码写完要故意喂坏数据:改一位摘要、把时间戳拨慢十分钟、把状态换成未知枚举。三种坏数据分别命中 MISMATCH、STALE、FAILED 三条出口,一条都不能漏到"当成功处理"。这组演练进了回归之后,校验逻辑再怎么重构都不怕,红了就是改坏了。
八、重试只处理真正的瞬时失败
网络抖动可以重试,摘要不匹配不能重试,口令错误更不能自动重试。把错误分成三类:瞬时(网络、超时)、确定性(参数、格式)、安全(口令、锁定),只给瞬时类发重试名额,其余直接走对应出口。重试要带上限、退避和 trace,防止把一次失败放大成一场风暴。
8.1 重试风暴的教训
上线首日出现过一次服务抖动,客户端的重试没有退避,三秒内把同一个请求打了几百次,把本已恢复的服务又压了下去。从此重试统一走一个带指数退避的调度器,全局并发上限、单请求上限分开设置,并且每次重试都带同一个 requestId,服务端能识别并合并重复请求。
九、沙箱调测覆盖正负样本
调测环境跑两套样本:正常请求走通全链路,证明"能用";构造脏参数、过期摘要、非法迁移走失败路径,证明"坏不了"。正负样本都进回归,每次契约或流程变更后全量重放。
9.1 负样本清单从哪来
不是拍脑袋列的,三个来源:失败路径枚举里每条出口至少一个样本;历史工单里每类真实故障一个样本;评审会上大家现场想一两个"如果这样会怎样"。三处合起来,覆盖度就够用了。负样本的价值在于它记录的是"我们怕什么",比正样本更接近系统的真实风险。
十、安全与隐私审查前置
审查在编码前定规则:口令只在适配层内存出现、摘要哈希落日志不落原文、设备指纹最小化采集、权限声明与实际调用一一对应。审查不是上线前补一张表,而是评审会上对着代码一条条核对,发现偏差当场改。前置的成本是几次评审会议,后置的成本是一次安全事件。
10.1 审查清单十条
- 口令与等价敏感值是否只在适配层内存出现;
- 日志输出是否过敏感键名脱敏过滤器;
- 设备标识采集是否声明了用途并最小化;
- 权限声明与代码里的实际调用是否一一对应;
- 失败路径是否都有出口,无静默吞错;
- 重试是否只覆盖瞬时错误且带上限;
- 回传结果是否逐项过契约校验;
- 状态机是否存在可达但未定义的迁移;
- 回调参数是否白名单制;
- 灰度开关与降级开关是否独立可切。
十条对应十类历史事故,每一条背后都有一次真实返工。清单会随事故库增长,但只做减法不做加法:某条连续两年没人命中,说明它已内化成习惯,可以撤下。
十一、交付材料从事实生成
交付文档里每一句"支持/不支持"都要有对应证据:用例编号、实测环境、数据来源。手写材料最大的风险是和代码脱节——代码改了文档没跟,审核方按旧文档验收到线上问题,板子最后打在工程团队身上。从测试报告自动生成验收结论,把人从对表里解放出来。
// 从回归报告生成验收摘要 function buildAcceptance(report) { return { scenario: report.scenarioId, passed: report.cases.filter(c => c.verdict === 'PASS').length, failed: report.cases.filter(c => c.verdict !== 'PASS').map(c => c.id), env: report.environment, generatedAt: Date.now(), }; }11.1 材料与代码同源的流水线
验收摘要由回归报告自动生成,界面截图由自动化脚本在真机上现拍现传,接口清单从契约表导出——三样东西都以代码为唯一事实源。审核方提出质疑时,回溯链条是"结论 → 报告 → 用例 → 提交记录",每一环都查得到。手工材料省下的半天,往往要用两次返工来还。
十二、用失败注入完成验收
验收不看"功能能跑",看失败注入的全矩阵:服务不可用、会话超限、摘要篡改、连续输错、中途锁屏,每一条都要走到预期出口并留下日志。
| 注入点 | 预期出口 | 必须留痕 |
|---|---|---|
| 能力探测失败 | 降级普通确认 | 降级原因码 |
| 会话创建超时 | 重试1次后降级 | 重试trace |
| 摘要比对失败 | 终止+告警 | 证据快照 |
| 连续3次校验失败 | 锁定+阻断 | 计数记录 |
| 中途锁屏再回 | 会话失效重走 | 失效事件 |
十二项全部通过,才允许进入灰度。灰度期双写日志两周,随时可以回放每一次确认决策;灰度转全量后,降级矩阵和失败注入用例进入常态回归,防止后续迭代把已经堵住的口子又撕开。
这套六层框架的成本集中在契约和状态机两处,换来的是后面每一层都可以独立替换、独立测试。接入第二个、第三个能力时,只有场景层和适配层是新的,其余全部复用——工程封装的意义不在第一次接入有多快,而在第二次、第三次接入时,还敢不敢说"这次和上次一样稳"。