一张“碰一下就打开商品详情”的业务卡片,看起来只有一次页面跳转,但接收方其实不知道这次启动是不是重复通知、旧消息重放,或者入口参数被换成了另一件商品。尤其在收银、核销、预约之类的场景里,把接收到的卡片号直接当作可信交易指令,代价通常比多跳一次确认页更大。

本篇把范围缩小到已经由业务入口交给应用的参数:借助 Ability Kit 的 Want,设计一个只负责接收卡片、检查时效、抑制进程内重复请求的页面门禁。示例项目为 TapReceipt,页面为 TapLandingPage,演示任务 TR-072,商品卡片 SHOP-318,请求标识 tap-72c。这里不实现 NFC 发现、系统“精准碰一碰”触发,也不宣称完成相关资质接入;系统触碰能力是否开放、支持哪些设备,必须另查对应正式产品文档。

一、先问清楚:接收数据与授权消费不是一回事

近场交互容易给人一种“物理距离足够近,所以数据就可靠”的错觉。实际工程里,底层近场触发、唤起业务页面、传送可解释的卡片参数、服务端核验和最终操作确认,是不同边界。Want 只是一种 Ability 之间携带意图及参数的机制;可以帮助我们演示应用接收处理,但它自己不会为卡片签名、验证商户身份或确认资金操作。

这次的业务协议只允许到达 WAIT_CONFIRM:展示商品标题与来源,等用户明确点击“查看并确认”。即使拿到看似合法的卡片,也不得自动扣款、核销或者跳到隐藏操作页。过期请求返回 EXPIRED,相同 requestId 的重复触发返回 DUPLICATE_IGNORED。这里的 “返回”是应用的业务判断,并非官方 NFC 回调状态码。

预设演示夹具有三次输入:第一条年龄 42 秒,在有效窗口内,判为 WAIT_CONFIRM;第二条重复 tap-72c,判为 DUPLICATE_IGNORED;第三条年龄 131 秒,判为 EXPIRED。有效期暂定 90 秒只是 Demo 的产品规则,既不是 HarmonyOS 对碰一碰的时间要求,也不应写死为任何安全协议的通用答案。

二、让进入 Ability 的参数先过类型检查

Want.parameters 里是跨边界传来的数据,不能因为代码写了 as string 就认定它一定是字符串。实际发送方可能缺少字段、传了数值版编号,或把时间戳填成了设备当前时间。先用可独立测试的解析器,把传入对象转换为我们自己的小型协议。

第一段 ArkTS 代码放在 entry/src/main/ets/common/TapInbox.ets。它只认可固定的业务场景类型 product_card、指定字段形状以及合理的标识长度,不触发页面导航。这样失败请求根本无法进入下一步业务判断。

import { Want } from '@kit.AbilityKit';

export interface TapCard {
  requestId: string;
  cardId: string;
  issuedAtMs: number;
  kind: string;
}

export function parseTapCard(want: Want): TapCard | undefined {
  const p = want.parameters;
  const requestId = p?.['requestId'];
  const cardId = p?.['cardId'];
  const issuedAtMs = p?.['issuedAtMs'];
  const kind = p?.['kind'];
  if (typeof requestId !== 'string' || requestId.length > 64 ||
      typeof cardId !== 'string' || cardId.length > 64 ||
      typeof issuedAtMs !== 'number' || !Number.isFinite(issuedAtMs) ||
      kind !== 'product_card') return undefined;
  if (!requestId || !cardId) return undefined;
  return { requestId, cardId, issuedAtMs, kind };
}

不建议把用户昵称、完整手机号或者交易密钥放进这类跳转参数。即便调试需要追踪,也只在日志中记录脱敏请求标识和判定状态。issuedAtMs 是业务侧约定的毫秒时间戳,但客户端上报的时间不能当作安全信任根;真正的有效期需由服务端签名票据和受信时间共同验证。示例解析只保障字段形状,并不能防止伪造。

三、时效与重复判定的先后顺序

最容易漏掉的不是“判断集合中有没有这个 ID”,而是有效期和重复集合的关系。若先把一条已过期的请求塞进 Set,再次送入时只看到“重复”,日志就失去区分过期与重放的能力。演示门禁因此按顺序判断:参数完整性、时间差、当前进程中的重复、最后才创建待确认事项。

下面的 TapInbox 与前一个解析器放在同一文件中。它没有调用隐含的 NFC SDK,也没有依赖网络,适合作为接口联调之前的纯业务夹具。状态字符串完全由业务定义,用来驱动页面文案。

export type TapStatus = 'INVALID' | 'EXPIRED' |
  'DUPLICATE_IGNORED' | 'WAIT_CONFIRM';

export interface TapDecision {
  status: TapStatus;
  cardId: string;
}

export class TapInbox {
  private seen: Set<string> = new Set<string>();
  receive(want: Want, nowMs: number): TapDecision {
    const card = parseTapCard(want);
    if (!card) return { status: 'INVALID', cardId: '' };
    const ageMs = nowMs - card.issuedAtMs;
    if (ageMs < 0 || ageMs > 90000) {
      return { status: 'EXPIRED', cardId: card.cardId };
    }
    if (this.seen.has(card.requestId)) {
      return { status: 'DUPLICATE_IGNORED', cardId: card.cardId };
    }
    this.seen.add(card.requestId);
    return { status: 'WAIT_CONFIRM', cardId: card.cardId };
  }
}

有两个限制必须写在代码旁边。其一,Set 只存在于当前进程:进程重启会丢失,不能称为跨设备防重。其二,requestId 完全由输入方提供:攻击者换一个新 ID 就能绕过本地集合,服务端幂等键、签名校验及业务授权不可省。示例故意不把它夸大为“防伪支付协议”。

如果业务会收到大量请求,集合还需要过期清理和容量上限,否则长期运行会不断积累历史 ID。若要求离线核验,则必须与设备持久化、撤销列表和时间可信度一起设计;这里只提供可读的最小业务门禁。

四、Ability 负责路由,不替业务做可信证明

华为官方开发文档展示了 UIAbility.onCreate() 与 onNewWant() 处理 Want 参数的模式,也说明应用可以把解析结果写入 AppStorage 供页面读取。对本场景而言,冷启动和已存在实例接到新启动意图,是两条都要考虑的入口路径,不能只写一个按钮处理方法就假定外部请求都会进入。

第三段示例是接线层,放在 EntryAbility.ets。业务门禁保持为 Ability 实例持有的对象;有效卡片会更新待确认商品,拒绝的请求只更新最近拦截原因,避免一条重复消息把已展示的合法卡片从屏幕上抹掉。示例不填写外部 skills 配置,也不虚构“精准碰一碰”的系统动作字符串。

import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { TapInbox } from '../common/TapInbox';

export default class EntryAbility extends UIAbility {
  private gate: TapInbox = new TapInbox();

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleIncoming(want);
  }
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleIncoming(want);
  }
  private handleIncoming(want: Want): void {
    const decision = this.gate.receive(want, Date.now());
    if (decision.status === 'WAIT_CONFIRM') {
      AppStorage.setOrCreate('tapStatus', decision.status);
      AppStorage.setOrCreate('tapCardId', decision.cardId);
    } else {
      AppStorage.setOrCreate('tapLastReject', decision.status);
    }
    hilog.info(0x0000, 'TapReceipt',
      `TR-072 status=${decision.status}`);
  }
}

页面 TapLandingPage 通过 @StorageLink 订阅 tapStatus、tapCardId 和 tapLastReject 后,可分别显示当前待确认卡片与最近一次拦截。示例状态 WAIT_CONFIRM 不表示已经购买或核销;“确认”按钮下一步应先调用服务端检查卡片有效性、用户登录态及真正授权范围。对没有业务签名的来源,前台只显示受限的说明页,不应展示敏感操作入口。

为了让演示容易复核,TR-072 只是一条内部测试任务标签,不应被当成系统任务 ID。截图中的 SHOP-318 是虚构商品卡片编号,tap-72c 是演示请求标识。真实现场如果使用同名字段,也应先约定字段版本、字符范围、最大长度和异常兼容策略。

五、从演示日志反推三个可测断言

先把系统触碰入口替换为可控的手工注入,用固定时钟执行三次纯函数检查。期待的顺序是 TR-072 WAIT_CONFIRM card=SHOP-318、TR-072 DUPLICATE_IGNORED 和 TR-072 EXPIRED。这些是期望日志样例而非已抓取的 HiLog。建议验收时同时检查 AppStorage 中待确认卡片是否仍为 SHOP-318,拒绝理由是否更新,页面是否未触发支付或订单状态变更。

若线上出现相同卡片重复弹出,先确认是否真的重复调用了 onNewWant(),再检查应用是不是创建了多个 Ability 实例。只看 UI 重绘次数无法推断入口意图次数。可以把路由事件、业务门禁判定与后端业务请求分别打点,但记录中不要暴露原始票据或身份信息。

六、不能越过的工程边界

华为公开的碰一碰资料包含不同平台与场景;某些 OneHop 资料明确涉及 Android 或特定设备组合,不能拿旧版能力说明推断任何 HarmonyOS 7 设备都具备相同触发 SDK。因此本篇只演示 Ability 接收端业务协议:应用启动意图究竟从 NFC、系统分享、App Linking 还是本地测试产生,需要产品团队结合正式开放文档单独接入,并执行相应设备及资质核验。

本地有效期与重复集合更多用于改善交互稳定性,不足以承担安全身份认证。真实系统还要验证来源签名、票据的一次性使用、时间同步、撤销和敏感操作二次确认。界面可显示“卡片已接收,等待确认”,却不应显示“交易成功”。把这种谨慎写到代码和文案里,比给按钮取一个更炫的名字更重要。

参考一手资料: 官方 UIAbility 接收 Want 的开发示例、AppStorage 与 @StorageLink 的开发说明、OneHop 碰一碰能力介绍(需注意所列支持平台)。本文三张图片均为设计演示,不能作为 NFC 真实接通或运行结果的证据。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐