1. 前后端协作
插件化手册
  • FH2 插件开发手册
    • 产品简介
    • 典型场景
    • 发版说明
    • 快速开始
      • 快速开始总览
      • 第一个插件
      • 后端插件
      • 全栈闭环
    • AI辅助开发
      • AI 开发 Skill
      • 安装与复验
      • 第一个工程
      • 真实需求示例:现场问题上报
      • 改造已有工程
      • 调试与交付
    • 开发指南
      • 开发指南
      • 基础概念
        • 基础概念总览
        • 什么是插件开发
        • 平台架构
        • 前端宿主、扩展点与能力
        • 后端运行时、入口与权限
        • 开发流程
        • 安全与隔离
        • 支持范围与问题反馈
      • 开发指南
        • 开发指南总览
        • 前端插件
          • 前端插件深度指南
          • 前端工程结构
          • 插件清单(fh2-plugin.json)
          • 入口与生命周期
          • 界面与状态
          • 多宿主插件
          • 跨宿主通信服务
          • HTTP、WebSocket 与事件
          • 存储、Blob 与定时器
          • 工具链与本地调试页面
        • 后端插件
          • 后端开发深度指南
          • 后端插件的结构
          • 后端源清单 fh2-plugin.json
          • HTTP 接口处理函数
          • 消息订阅与事件处理函数
          • ctx 与能力注入
          • 数据存储与缓存
          • 流程编排
          • 触发方式
          • 连接外部系统
          • 流程编排练习(DAG)
        • 前后端协作
          • 前后端协作总览
          • 前后端共享契约
          • 端到端幂等
          • 事件、通知与状态
          • 统一错误模型
          • 链路标识与诊断日志
        • 完整练习
          • 环境准备
          • 第一个前端插件
          • 第一个后端插件
          • 第一个全栈插件
      • 能力总览
        • 能力地图
        • 能力可用性矩阵
        • 前端能力
          • 平台通用能力
            • 平台通用能力总览
            • 基础运行时:HTTP、事件、WebSocket 与存储
            • 导航、位置与通知
            • 弹窗、音频与剪贴板
            • 地图能力
            • 项目设备能力
            • WPMZ 与航线预览
            • 受控对象上传
          • 项目宿主
            • 项目宿主总览
            • 项目上下文
            • 项目侧边栏入口
            • 项目插件路由
            • 项目工作区
          • 消息宿主
            • 消息宿主总览
            • 消息告警弹层
            • 消息导航与通知
          • 计划库宿主
            • 计划库宿主总览
            • 计划库表单上下文
            • 计划库表单扩展
            • 校验、提交与 `biz_ext`
          • 驾驶舱
            • 驾驶舱插件能力总览
            • 驾驶舱扩展点
            • 上下文与遥测
            • 相机、检测与 AI
            • 场景几何与直播指针
            • 飞控、云台与任务
            • 驾驶舱平台事件
        • 后端能力
          • 后端能力地图
          • 后端能力总览
          • 配置、受管出网与集成连接
          • 事件与流程
          • 飞行任务与违停巡逻
          • 数据存储
          • 媒体与前端通知
          • 资源租约
          • 作业、目标轨迹与视频处理
      • 调试与测试
        • 调试与测试总览
        • 快速入门排错
        • 本地开发
        • Web IDE:打开工程与保存改动
        • 工作流编辑器:节点、接线与交付
        • 知识与 MCP:先判断需要哪一层事实
        • 前端本地调试
        • 后端运行时与流程调试
        • 模拟数据与测试数据
        • 集成测试与 E2E
        • 日志与链路追踪
        • 常见问题排查
      • 完整示例
        • 示例与案例总览
        • 项目侧边栏与工作区
        • 消息告警卡片
        • 计划表单任务清单
        • 驾驶舱动作与左侧面板
        • 最小后端插件
        • 最小全栈插件
        • 多宿主插件实战
        • 事故快处
        • 交通抓拍
        • 违停巡逻完整案例
      • 构建发布与安全
        • 构建、发布与安全
        • 质量门禁
        • 行业包构建与打包
        • 能力版本发布
        • 安装、升级与回滚
        • 安全基线
      • API参考
        • API 参考总览
        • 前端
          • 前端 API 参考
          • 前端插件清单结构
          • 前端 SDK API
          • 前端扩展点目录
          • 前端能力目录
          • 前端平台事件目录
          • 跨宿主通信服务 API
          • 工具链命令行参考(`fh2-plugin`)
        • 后端
          • 后端 API 参考
          • 后端插件源清单
          • 后端 HTTP 接口参考
          • 后端事件消费参考
          • 后端脚本上下文(`ctx`)
          • 后端数据存储
          • 流程编排语言
          • 流程触发器参考
          • 后端能力目录
          • 后端 HTTP API
        • 共享契约
          • 共享契约参考
          • 类型与契约目录
          • 错误模型
          • 版本兼容性
      • 附录
        • 错误码
        • 术语表
        • 常见问题
  1. 前后端协作

端到端幂等

网络重试、消息重投、流程重放,都会让同一个操作被执行多次。写操作不去重,就会产生重复数据、重复下发任务。这一页讲每条链路靠什么去重,读完你能安全地写出"创建记录、推进状态"这类接口。

为什么会重复执行#

至少有三个来源:
1.
前端网络超时后重试同一个请求。
2.
事件消息是"至少一次"投递,消费方可能收到重复消息。
3.
平台按幂等键重放流程步骤。
所以后端接口不能假设"每个请求只来一次",要用幂等原语保证"来多少次结果都一样"。

每条链路的去重原语#

链路平台提供什么你要做的事
HTTP 接口Idempotency-Key 头 → ctx.idempotencyKey用 ctx.stableId(scope) 或业务唯一键写库
数据新建store.createIfAbsent + 唯一键设计业务唯一键,按 created 区分新建/已存在
状态推进store.update 返回 affectedupdate 带旧状态条件,affected === 1 才算抢占成功
流程启动flow.start(流程名, bizId) → workflowId = 流程名 + "-" + bizId所有入口用同一个稳定 bizId,重复启动返回 already
流程步骤幂等键 workflowID:stepID,脚本里可读 run.idempotencyKey有外部副作用的步骤,把这个键透传给下游
事件消费消息的 event_id 注入 ctx.idempotencyKey副作用全部幂等:唯一键去重、条件更新
一个关键函数:ctx.stableId(scope) 用幂等键 + scope 派生稳定 ID(同一请求多次调用结果相同)。请求没带幂等键时它直接抛契约错——平台不会生成随机值兜底,所以关键写操作一定要让调用方带 Idempotency-Key。

前端:生成并保存业务键#

幂等键要能跨重试保持不变,所以在发起请求之前生成,存在本地状态里:
// 业务键来自业务事实(谁、何时、对什么),不是随机数
const eventKey = `${crossedAt}:${objId}:${regionId}`

// 首次调用
await invoke(ctx, pluginId, 'lane-change.create-event', input, {
  idempotencyKey: `lane-change:${eventKey}`,
})

// 网络失败重试:复用同一个键,不要重新生成
await invoke(ctx, pluginId, 'lane-change.create-event', input, {
  idempotencyKey: `lane-change:${eventKey}`,
})
读操作(查询列表、查详情)不需要幂等键。

后端:用唯一键做原子创建#

创建类接口用 store.createIfAbsent,唯一键冲突时返回已有记录而不是报错:
// handlers/lane-change/create-event.ts
export function handle (input: RecordInput, ctx: HandlerCtx) {
  const saved = store.createIfAbsent('lane_change_event', {
    event_key: input.event_key,
    project_id: input.project_id,
    post_status: 'pending_video'
  }, ['event_key'])            // uniqueBy:按这些字段判重

  if (!saved.ok) {
    throw saved.error
  }
  return {
    event_id: saved.data.record.id,
    created: saved.data.created   // true = 本次新建;false = 已有同 key 记录
  }
}
创建和更新时间由平台维护,不要在 createIfAbsent 的记录参数中写 created_at / updated_at。SQL 物理列名仍为 created_at / updated_at;JS store 返回及筛选/排序使用公开名 createdAt / updatedAt,例如读取 saved.data.record.createdAt,不能直接把物理列名当作脚本字段。
两个要点:
created 是 true 还是 false 都是成功(code=0)。前端按 event_id 继续流程,不要把 created=false 当失败。
数据库侧要有唯一索引兜底,写在插件的 schema.sql 里(生产建表用它,平台不会自动执行):

状态推进:条件更新#

推进状态时把"旧状态"写进更新条件。别人抢先改过状态,affected 就是 0:
const updated = store.update('lane_change_event', {
  id: { eq: eventId },
  post_status: { eq: 'pending_video' }   // 只允许从 pending_video 推进
}, {
  post_status: 'waiting_artifacts',
  video_file_id: fileId
})
if (!updated.ok) {
  throw updated.error
}
affected === 0 不证明作业已启动:可能是上次"已绑视频、还没启动流程"时进程中断了。需要 data:read 查询目标记录,确认仍为 waiting_artifacts 且 video_file_id 是同一个 fileId 后,继续用稳定流程名、bizId 和 seed 补启;记录不存在、已改绑或已推进状态则返回对象。不能一律提前返回,也不能不检查绑定就无条件启动。完整同绑定检查见事件、通知与状态。

流程启动:确定性的 bizId#

flow.start(流程名, bizId) 用 bizId 拼出流程实例 ID。所有会启动同一业务流程的入口(事件回调、手工补偿……)必须用同一个 bizId,才能命中同一个实例:
const started = flow.start('lane-change-postprocess', `${eventId}:${fileId}`, {
  event_id: eventId,
  video_file_id: fileId
})
// 同一个 bizId 再启动得到 already === true:这段视频的作业已经在跑
bizId 要带上视频维度。同一事件可能先等到一段不含违法时刻的视频,后处理退回等下一段;只用 eventId 会撞上已经结束的流程 ID,新视频就再也起不来了。
本例的两键调用要求目标声明为 "job":{"seed":["event_id","video_file_id"]};与其他页面含 video_object_key 的三键片段示例不是可直接拼装的完整组合,传入键必须与实际 job 声明完全相同。
流程里有对外副作用的步骤(比如推送结果),把平台给的幂等键显式传下去:
{
  "inputs": {
    "script": { "const": "push-result" },
    "eventId": { "from": "trigger.eventId" },
    "idempotencyKey": { "from": "run.idempotencyKey" }
  }
}
一步要发多条记录时,用稳定顺序加序号生成子键,不要用对象遍历顺序、墙钟或随机数:
for (const i = 0; i < input.items.length; i++) {
  pushOne(input.items[i], input.idempotencyKey + ':' + i)
}

没有跨存储的事务#

当前平台没有"改数据库 + 启动流程 + 发通知"在一个事务里完成的能力。典型窗口:
写业务状态成功
  ↓ 进程在这里崩溃
flow.start / notify.publish 还没执行
补救方式是:让重复投递的消息再次执行,用同绑定查询检查补启并复用确定性 bizId,而不是因 affected === 0 提前确认消息。这个检查需要 data:read,只确认可补偿的业务状态,不代替 affected 判断谁抢到了更新。查询与启动仍不是原子操作,不能消除后续并发改绑或取消的窗口。
查询或启动持续失败会耗尽有界重试并丢弃消息(Discard);依赖恢复后可人工重放死信消息补启,没有自动死信恢复保证。死信保存也依赖队列配置;要保证长期最终恢复需另行设计对账或事务发件箱。不要假设"代码紧挨着写"就是原子的。

常见问题#

现象原因与处理
调 ctx.stableId 报契约错误请求没带 Idempotency-Key 头;让调用方补上
重复提交出现两条记录唯一键没设计对,或 schema.sql 里的唯一索引没建
created=false 被当前端失败它是"已存在"的正常结果,按 event_id 继续
重试后数据重复重试时换了幂等键;键必须在首次请求前生成并复用
状态被旧请求改回去update 条件里没带旧状态;补上 status: {eq: ...} 条件

延伸阅读#

数据存储:createIfAbsent / update 的完整签名。
事件消费:至少一次投递与消费处置。
流程编排:flow.start 与流程定义。
统一错误模型:DUPLICATE 等业务错误的处理。
修改于 2026-10-25 13:35:59
上一页
前后端共享契约
下一页
事件、通知与状态
Built with