HarmonyOS 精准分享投递回执与幂等去重(连载04)【共创稿事节】

一次素材投递最容易被误判的时刻,往往不是发送请求之前,而是发送端一直没有收到结果之后。手机向平板中的设计画布投递现场照片,接收端可能已经把图片插入 A 座一层平面图,但回执恰好在网络切换时丢失。用户再点一次“重新发送”,如果新请求直接追加图片,就会在同一个落点出现两份相同的素材。

本系列前面建立了目标窗口、素材批次和文档落点三项基础信息。现在需要补足最后一道一致性机制:同一业务请求只产生一次写入效果,接收端保存真实处理结果,发送端通过回执或状态查询恢复界面。HarmonyOS 7 的精准分享负责使目标窗口和触点进入跨设备交互链路;本文的回执协议、去重表和投递状态机属于应用业务层实现,可以与实际接入的跨设备通道组合。

跨设备素材投递回执、重复请求拦截与结果恢复

一、不要把网络成功当成画布写入成功

从发送端看,发出素材后有三个重要事实:请求是否到达接收端、接收端是否通过目标校验、文档是否真正写入成功。网络请求成功返回只能说明当前调用链取得了响应,不能替代业务提交结果。回执中的状态必须和接收端的写入事务有确定关系。

业务状态含义手机界面的处理
created已建立请求,尚未开始投递允许用户确认素材与目标
sending请求正在传递禁用重复提交按钮
accepted接收端已登记请求保持等待,不展示最终成功
committed接收端已完成文档写入展示落点及可查看的文档链接
rejected目标校验失败或业务拒绝呈现明确原因,允许重新选目标
unknown网络结果不明确使用原请求标识查询状态

accepted 与 committed 必须拆开。假如接收端先回复“收到”,随后保存图纸失败,发送端若过早展示成功,用户会以为照片已经进入画布。反过来,网络调用超时也不意味着接收端失败,因此 unknown 不是可以随意发起全新请求的状态。

一条请求要保留三个不可替换的标识:requestId 表示这次业务操作,assetIds 表示要投递的素材,documentId + documentRevision 表示投递目标。为了防止同一 requestId 被错误复用,还需要关联稳定的载荷摘要。摘要的生成要使用双方约定的字段顺序、编码格式和摘要算法,而不是依赖普通对象键枚举顺序。

二、定义可核对的请求与最终回执

前几篇的 TargetEnvelope 包含目标窗口和触点;坐标映射之后,接收端可以保存文档坐标。业务协议对字段使用更严格的类型,发送端和接收端只以 requestId 关联结果。

// entry/src/main/ets/common/ReceiptModel.ets
export type ReceiptState = 'accepted' | 'committed' | 'rejected';
export type ViewState = 'created' | 'sending' | 'accepted' |
  'committed' | 'rejected' | 'unknown';

export interface DocumentPoint {
  x: number;
  y: number;
}

export interface DeliveryPayload {
  requestId: string;
  payloadDigest: string;
  windowId: string;
  documentId: string;
  documentRevision: number;
  assetIds: string[];
  point: DocumentPoint;
  createdAtMs: number;
}

export interface DeliveryReceipt {
  requestId: string;
  payloadDigest: string;
  state: ReceiptState;
  documentId: string;
  insertionIds: string[];
  updatedAtMs: number;
  reason: string;
}

export interface DeliveryView {
  state: ViewState;
  message: string;
  receipt: DeliveryReceipt | null;
}

export function sameReceipt(request: DeliveryPayload,
                            receipt: DeliveryReceipt): boolean {
  return receipt.requestId === request.requestId &&
    receipt.payloadDigest === request.payloadDigest &&
    receipt.documentId === request.documentId;
}

insertionIds 是接收端实际写入后的文档对象标识。如果一批素材包含三张图片,成功回执应能列出对应的三个插入对象;日后用户从消息记录打开图纸,不需要再依赖临时的图片 URI。拒绝状态则携带原因,比如目标窗口关闭、文档修订号变动、素材读取失败或接收区不可编辑。

为了防止错绑,还应比较当前登录用户、目标文档的写入权限与关联会话。上述 sameReceipt 只实现传输内容的一致性核对,授权仍由接收端的业务服务完成。不能因为请求标识正确就跳过权限验证。

三、接收端用去重登记保护写入

去重记录应当建立在接收端,而不是仅在发送端保存一个 isSending 布尔值。页面可以重建、用户可以在多台设备重复提交,甚至不同网络通道可能同时送达同一请求。真正保证“一次”的位置,是保存文档对象的业务事务。

下面先用 ArkTS 类说明登记与返回回执的逻辑。AssetWriter 代表设计画布已经存在的持久化写入服务;生产实现应将接收登记、目标校验和素材写入封装进同一个受事务约束的服务端操作。

// entry/src/main/ets/common/DeliveryInbox.ets
import { DeliveryPayload, DeliveryReceipt } from './ReceiptModel';

export interface AssetWriter {
  insertOnce(request: DeliveryPayload): Promise<string[]>;
}

export class DeliveryInbox {
  private receipts: Map<string, DeliveryReceipt> =
    new Map<string, DeliveryReceipt>();
  private inflight: Set<string> = new Set<string>();

  constructor(private writer: AssetWriter) {}

  async receive(request: DeliveryPayload): Promise<DeliveryReceipt> {
    const known: DeliveryReceipt | undefined =
      this.receipts.get(request.requestId);
    if (known) {
      if (known.payloadDigest !== request.payloadDigest) {
        throw new Error('同一请求标识对应不同素材数据');
      }
      return known;
    }
    if (this.inflight.has(request.requestId)) {
      throw new Error('该请求正在提交,请稍后查询结果');
    }
    this.inflight.add(request.requestId);
    const pending: DeliveryReceipt = {
      requestId: request.requestId,
      payloadDigest: request.payloadDigest,
      state: 'accepted', documentId: request.documentId,
      insertionIds: [], updatedAtMs: Date.now(),
      reason: '等待画布写入'
    };
    this.receipts.set(request.requestId, pending);
    try {
      const inserted: string[] = await this.writer.insertOnce(request);
      const done: DeliveryReceipt = { ...pending,
        state: 'committed', insertionIds: inserted,
        updatedAtMs: Date.now(), reason: '写入完成' };
      this.receipts.set(request.requestId, done);
      return done;
    } finally {
      this.inflight.delete(request.requestId);
    }
  }

  read(requestId: string): DeliveryReceipt | undefined {
    return this.receipts.get(requestId);
  }
}

这段类适合解释接收协议的生命周期,但内存中的 Map 与 Set 只能覆盖单实例运行期间的重复请求。多进程、服务重启和事务并发下,要靠持久数据约束实现最终去重。尤其当 writer.insertOnce() 已落库、回执更新却遇到异常时,持久层必须仍能按原请求标识查到插入结果,否则单靠本地状态无法消除重复写入。

一个典型的业务数据库结构如下,数据库约束与文档写入必须处于同一个提交边界:

-- 接收服务的关系型数据结构
CREATE TABLE delivery_inbox (
  request_id VARCHAR(80) PRIMARY KEY,
  payload_digest VARCHAR(128) NOT NULL,
  document_id VARCHAR(128) NOT NULL,
  state VARCHAR(16) NOT NULL,
  receipt_json TEXT,
  updated_at TIMESTAMP NOT NULL
);

CREATE TABLE canvas_insertion (
  insertion_id VARCHAR(80) PRIMARY KEY,
  request_id VARCHAR(80) NOT NULL,
  asset_id VARCHAR(80) NOT NULL,
  document_id VARCHAR(128) NOT NULL,
  pos_x DECIMAL(16, 5) NOT NULL,
  pos_y DECIMAL(16, 5) NOT NULL,
  UNIQUE (request_id, asset_id)
);

接收逻辑先尝试按 request_id 登记请求;遇到唯一键冲突时读取已有摘要和回执,相同摘要返回同一结果,不同摘要拒绝。插入三张素材的操作、canvas_insertion 唯一约束以及回执状态更新必须在事务内完成。事务提交完成之后才能向发送端返回 committed。如果并发请求在第一笔事务提交前进入,数据库的唯一键锁与事务隔离负责串行化相同请求。

白色主题 DevEco Studio 中的回执去重业务逻辑和状态界面

四、超时后先查询原请求,再决定是否重发

在手机侧设计一个 DeliveryTransport 接口,将通信实现与业务控制器分开。它定义 post 和 query 两种操作:前者携带完整请求,后者只携带请求标识。它们是接收服务的业务端点,不应与 HarmonyOS SDK 方法混淆。

// entry/src/main/ets/common/DeliveryController.ets
import { DeliveryPayload, DeliveryReceipt, DeliveryView,
  sameReceipt } from './ReceiptModel';

export interface DeliveryTransport {
  post(request: DeliveryPayload): Promise<DeliveryReceipt>;
  query(requestId: string): Promise<DeliveryReceipt | null>;
}

export class DeliveryController {
  private busy: boolean = false;
  private request: DeliveryPayload | null = null;

  constructor(private transport: DeliveryTransport) {}

  setRequest(request: DeliveryPayload): void {
    if (this.busy) throw new Error('当前请求尚在处理中');
    this.request = request;
  }

  async submit(): Promise<DeliveryView> {
    const request = this.request;
    if (!request || this.busy) {
      return { state: 'unknown', message: '请核对请求状态', receipt: null };
    }
    this.busy = true;
    try {
      const receipt: DeliveryReceipt = await this.transport.post(request);
      return this.resolve(request, receipt);
    } catch (_error) {
      try {
        const found: DeliveryReceipt | null =
          await this.transport.query(request.requestId);
        if (found) return this.resolve(request, found);
      } catch (_queryError) {
        // 网络仍不可用,保留原请求标识
      }
      return { state: 'unknown',
        message: '结果待确认,请查询原请求后再处理', receipt: null };
    } finally {
      this.busy = false;
    }
  }

  private resolve(request: DeliveryPayload,
                  receipt: DeliveryReceipt): DeliveryView {
    if (!sameReceipt(request, receipt)) {
      return { state: 'unknown',
        message: '回执身份不一致,保持待核对状态', receipt: null };
    }
    const text: string = receipt.state === 'committed' ? '素材已经写入画布' :
      receipt.state === 'accepted' ? '接收端已登记,等待写入结果' :
      receipt.reason;
    return { state: receipt.state, message: text, receipt: receipt };
  }
}

submit() 内不创建新请求标识。允许重试时仍把原 DeliveryPayload 送出,而服务端通过 requestId + payloadDigest 识别重复提交。通常可以对连接错误设置退避间隔,但上限、网络状态与用户取消需要单独控制;不能无限重试,更不应在目标文档已经切换后自动生成新落点。

如果旧请求处于 accepted,页面可以间隔查询而不立即再次写入;只有接收端明确返回可重试结论或者业务通道支持同请求标识安全重复投递时,才执行下一次 post。一旦查到 committed,即使先前的发送调用已经抛出超时异常,也应该复用原回执更新界面。

五、通过 Network Kit 连接应用接收服务

接收服务使用 HTTPS 提供接口时,可以使用 HarmonyOS 的 @kit.NetworkKit HTTP 能力。这里保留简单的 JSON 通信,业务地址由应用配置提供。PUT /deliveries 按请求标识进行幂等处理,GET /deliveries/{requestId} 查询已保存状态。GET 查询失败与“记录不存在”要分开处理,不能把网络不可用等同于服务端确认无记录。

// entry/src/main/ets/net/HttpDeliveryTransport.ets
import { http } from '@kit.NetworkKit';
import { DeliveryPayload, DeliveryReceipt } from '../common/ReceiptModel';
import { DeliveryTransport } from '../common/DeliveryController';

export class HttpDeliveryTransport implements DeliveryTransport {
  constructor(private baseUrl: string) {}

  private async json(url: string, method: http.RequestMethod,
                     body?: string): Promise<http.HttpResponse> {
    const channel: http.HttpRequest = http.createHttp();
    try {
      return await channel.request(url, {
        method: method,
        header: { 'Content-Type': 'application/json' },
        extraData: body,
        connectTimeout: 8000,
        readTimeout: 10000,
        expectDataType: http.HttpDataType.STRING
      });
    } finally {
      channel.destroy();
    }
  }

  async post(payload: DeliveryPayload): Promise<DeliveryReceipt> {
    const response = await this.json(this.baseUrl + '/deliveries',
      http.RequestMethod.PUT, JSON.stringify(payload));
    if (response.responseCode < 200 || response.responseCode >= 300) {
      throw new Error(`投递服务错误:${response.responseCode}`);
    }
    return JSON.parse(response.result as string) as DeliveryReceipt;
  }

  async query(requestId: string): Promise<DeliveryReceipt | null> {
    const url = this.baseUrl + '/deliveries/' + encodeURIComponent(requestId);
    const response = await this.json(url, http.RequestMethod.GET);
    if (response.responseCode === 404) return null;
    if (response.responseCode !== 200) {
      throw new Error(`状态查询错误:${response.responseCode}`);
    }
    return JSON.parse(response.result as string) as DeliveryReceipt;
  }
}

网络通信需要在应用 module.json5 中配置对应的 ohos.permission.INTERNET,接收服务还应验证身份、权限、请求摘要及资产归属。上述接口的路径属于应用自建服务契约,实际项目中应与服务端接口文档保持一致。channel.destroy() 放在 finally,确保异常分支也能归还网络资源。响应数据在进入界面前仍要通过 sameReceipt 校验,不能直接相信字符串解析成功就表示业务完成。

六、回执驱动 UI,而不是按钮驱动结果

当页面收到 committed 回执,才能展示写入位置、插入对象和“查看画布”按钮。重复请求在接收端被识别后,应该返回与首次处理一致的最终回执,这样用户无须区分回执来自首次提交还是后续查询。

素材写入完成、回执信息及重复请求归并后的手机界面

// entry/src/main/ets/pages/ReceiptPage.ets
import { DeliveryController } from '../common/DeliveryController';
import { HttpDeliveryTransport } from '../net/HttpDeliveryTransport';
import { DeliveryPayload, DeliveryView } from '../common/ReceiptModel';

@Entry
@Component
struct ReceiptPage {
  @State private view: DeliveryView = {
    state: 'created', message: '核对现场照片与接收画布', receipt: null
  };
  @State private waiting: boolean = false;
  private controller: DeliveryController = new DeliveryController(
    new HttpDeliveryTransport('https://service.example.com'));
  private request: DeliveryPayload = {
    requestId: 'delivery-20261102-photo-01',
    payloadDigest: '5b37ea-canonical-payload-digest',
    windowId: 'design-canvas', documentId: 'building-a-floor-1',
    documentRevision: 18, assetIds: ['photo-equipment-room'],
    point: { x: 310.4, y: 191.2 }, createdAtMs: 0
  };

  aboutToAppear(): void {
    // 当前业务请求由上一页传入,示例数据在此集中展示
    this.request = { ...this.request, createdAtMs: Date.now() };
    this.controller.setRequest(this.request);
  }

  private async send(): Promise<void> {
    if (this.waiting) return;
    this.waiting = true;
    try {
      this.view = await this.controller.submit();
    } finally {
      this.waiting = false;
    }
  }

  build() {
    Column({ space: 18 }) {
      Text('素材投递结果').fontSize(26).fontWeight(FontWeight.Bold)
      Text(this.view.message).fontColor('#56657C')
      Text(`请求标识:${this.request.requestId}`).fontSize(13)
      if (this.view.receipt !== null) {
        Text(`接收状态:${this.view.receipt.state}`)
        Text(`目标文档:${this.view.receipt.documentId}`)
        Text(`插入对象:${this.view.receipt.insertionIds.length} 项`)
      }
      Button(this.waiting ? '正在核对' :
        this.view.state === 'unknown' ? '查询并恢复状态' : '确认投递')
        .enabled(!this.waiting && this.view.state !== 'committed')
        .onClick(() => { this.send(); })
    }
    .padding(24)
    .width('100%')
  }
}

这个页面把一次投递对象固定在控制器里。业务应用从前一页取得实际 requestId 和载荷摘要,并将它们持久保存到尚未完成的投递记录;重新打开页面时加载同一请求,而不是以当前时间生成全新请求。用户明确选择另一张图片或另一个文档时,才建立全新的业务操作。

七、围绕真实异常设计回归场景

技术排查可按先后顺序记录三个时间点:接收端登记时刻、文档事务提交时刻、手机取得回执时刻。不要只看发送按钮触发次数,因为两次点击可以对应同一次业务操作;也不要只看网络响应码,因为它与文档事务并非同一个概念。

异常条件接收端结果发送端应呈现的状态
首次提交成功保存素材并返回 committed完成,可进入画布
回执丢失,随后查询已有相同 requestId 记录使用已保存回执恢复完成态
同一请求再次到达返回原有插入对象标识不再次增加画布对象
同一 ID 携带不同摘要拒绝本次请求提示请求标识冲突
目标修订号变动拒绝旧目标写入返回目标重新确认入口
接收端处理时间较长暂存 accepted保持等待并限制重试频率
手机进程退出后返回通过持久请求记录继续查询恢复原业务状态

通过 HiLog 分析问题时,推荐记录 requestId 的脱敏片段、状态、耗时和业务错误码,避免把现场照片、完整访问凭据或用户敏感信息写进日志。测试中要同时检查画布对象数量与回执内容:重复提交十次,最终的 requestId 只应关联同一组插入对象;目标变更后重试,服务端不应越过文档修订号校验。

从目标校验、图片封装、画布坐标映射到投递回执,精准分享的业务链路才形成完整闭环。触点决定“放在哪里”,素材封装决定“放什么”,而最终回执与唯一约束决定“是否确实只放了一次”。对于跨设备编辑而言,最后这一点常常比动效更直接地影响用户对工作台的信任。

参考资料

Logo

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

更多推荐