# 钉钉互动卡片一卡到底(排队卡 → 进度卡)与模板合并技术方案 > 评审状态:待评审。本文档只描述方案,不包含实施。 > 前置约定:**不考虑历史数据与存量兼容**。数据库列、后台字段、环境变量一律以最终形态为准,不做迁移回填、不留双轨、不保留回落链。 ## 1. 需求背景 ### 1.1 现象 钉钉通道在会话繁忙时会话收到新消息,会**发出两张互不相干的卡片**: 1. 排队卡(模板 `queue_card_template_id`):只投放一次,内容固定为「排队中」,带「立即发送」/「取消排队」按钮,**此后永不更新**; 2. 进度卡(模板 `progress_card_template_id`):等到这条排队消息真正开始执行时,才由 `createProgressCard` **新建一张**。 结果:用户在会话忙时连发两条消息,会话里会同时躺着「排队中」旧卡 + 「正在处理」新卡 + 最终「处理完成」卡。旧排队卡永远停在排队态,成为残留信息,用户无法从会话流里看出一条消息的完整生命周期。 ### 1.2 与飞书通道的差距 飞书通道早已是**一卡到底**:排队卡拿到 `card_message_id` 后写进队列行,队列被消费时 `createProgressCard` 看到 `context.progressCardMessageId` 非空,**不新建卡片,直接 `message.patch` 那张排队卡**(`backend-ts/src/feishu-cards.ts:189-195`、`backend-ts/src/feishu/agent-inbound-handler.ts:154`)。CHANGELOG 0.0.61 明确记录「卡片一卡到底…全生命周期不刷屏」。 钉钉没有做到,纯粹是因为**接线缺失**,不是因为平台不支持。 ### 1.3 目标 - **第一步(核心)**:钉钉排队卡在被消费时**就地升级**为进度卡,此后沿用进度卡生命周期(执行中 → 完成/失败/取消)。全程一条消息,不新增卡片、不残留旧卡。 - **第二步(模板合并)**:把排队卡与进度卡合并为**单一模板 + 单一配置字段**,模板、`dingtalk_bot` 列、环境变量、admin 表单全部收敛为一个。 ### 1.4 明确不做 | 不做项 | 说明 | |--------|------| | 绑定卡(OAuth 引导卡)合并 | 它是未绑定用户的一次性引导,3 分钟过期、不参与任务生命周期(`runtime.ts` `sendBindingCard`),并入只会把状态机搞复杂 | | 微信通道同类改造 | 微信通道维持现状,本期不动 | | 桌面/Web 端卡片 | 与 IM 通道无关 | | 存量排队卡回刷 / 数据迁移 | 上线前遗留的旧排队卡实例不回刷;`dingtalk_bot` 两个模板 ID 列直接删旧留新,不回填旧值 | | 前端五端其余改动 | 本期只动 `backend-ts` + `admin`(表单字段)+ `skills/mao-cli`(模板与文档) | ## 2. 关键前置调研结论 ### 2.1 钉钉 `updateCard` 是增量更新,这是方案成立的基础 `PUT /v1.0/card/instances` 带 `cardUpdateOptions.updateCardDataByKey: true` 时为**增量更新**:只覆盖传入的 `cardParamMap` key,未传的变量保持原值;`false`(默认)为全量覆盖[^1^][^2^]。现有 `backend-ts/src/dingtalk/card-client.ts:70-88` 已固定传 `true`。 这意味着:**用排队卡的 outTrackId 调 `updateCard`,只传进度卡的变量,就能把排队卡改成进度卡的样子**,不需要新建实例。第一步零平台配置即可落地。 ### 2.2 按钮可见性只能走 boolean 变量(本次已修复的约束) 钉钉卡片平台只把「boolean 变量 + `isTrue`/`isFalse`」渲染成可用的 `@data{...}` 条件;字符串 `isEqual` 比较导入后会被抹成空串(`@triple{'' , 'visible', 'gone'}` 恒假)。这是「取消/重试按钮不显示」的真实根因,已在提交 `032b3525` 修复(`canCancel` / `canRetry` 两个 boolean 变量)。**第二步合并模板时必须沿用该约束**,新增按钮同样只能用 boolean 变量。 ### 2.3 回调路由天然支持一卡到底 `backend-ts/src/dingtalk/card-action.service.ts:37-48` 的回调分流: ```ts if (event.actionId === 'run' || event.actionId === 'cancel') { const row = await this.options.queuePort.findByOutTrackId(event.outTrackId); if (row != null) return this.decideQueue(...); // 排队卡 } if (event.actionId === 'retry' || event.actionId === 'cancel') { const progress = await this.options.findProgress(event.outTrackId); if (progress != null) return this.decideProgress(...); // 进度卡 } ``` - 排队卡 actionId:`cancel`、`run`(插队) - 进度卡 actionId:`cancel`、`retry` - `cancel` 共用,靠 `dingtalk_inbound_queue.out_track_id` 与 `dingtalk_progress_card.out_track_id` 谁先命中来分流 **结论:一卡到底后这张卡同时是两个表的潜在命中对象,actionId 不冲突,路由逻辑不用改。** 关键约束是:卡片升级为进度卡时,必须把 outTrackId 写进 `dingtalk_progress_card`,否则取消/重试回调第二段查不到它。 ### 2.4 载体已就位,两步都不需要新表 `dingtalk_inbound_queue.out_track_id`(V120 已建)就是排队卡实例 ID,`updateCard` 接收的正是它。第二步只是**删一列、改一列**,不需要新表。 ## 3. 现状代码索引 | 模块 | 位置 | 与本方案的关联 | |------|------|----------------| | 卡片参数拼装 | `backend-ts/src/dingtalk/card-body.ts` | `progressCardParams` / `queueCardParams` 变量集;第二步需统一 | | 投放与更新 | `backend-ts/src/dingtalk/card-client.ts` | `createAndDeliverCard` / `updateCard`(增量更新) | | 卡片生命周期 | `backend-ts/src/dingtalk/runtime.ts` | `createProgress`(:208)、`createQueueCard`(:305)、`templateOf`(:151)、`cardProgress`(:190)、`recoverProgress`(:513) | | 回调路由 | `backend-ts/src/dingtalk/card-action.service.ts:37-48` | 排队/进度分流,`run`/`cancel`/`retry` 三个 actionId | | 入站编排 | `backend-ts/src/dingtalk/agent-inbound-handler.ts` | `enqueueMessage`(:411) 调 `createQueueCard`;`executeQueued`(:505) 消费队列;`contextFromQueue`(:699) 重构上下文;`dropQueued`(:609) | | 队列仓储/服务 | `backend-ts/src/dingtalk/inbound-queue.{repository,service}.ts` | `outTrackId` 读写、CAS 认领、启动恢复 | | 进度卡映射 | `backend-ts/src/dingtalk/progress-card.repository.ts` | `upsert` / `findBySessionId` / `findByOutTrackId` / `deleteBySessionId` | | 模板 JSON / 自检 / 说明 | `skills/mao-cli/template/` | 第二步合并对象;`check-template.js` 需扩展 | | 飞书参照实现 | `backend-ts/src/feishu-cards.ts:189-195` | 一卡到底的既有范本 | ### 3.1 模板 ID 配置的现有引用点(第二步全部要改) | 层 | 位置 | 内容 | |----|------|------| | 数据库列 | `backend-ts/db/migration/V120__dingtalk_bot_channel.sql:10-11` | `progress_card_template_id`、`queue_card_template_id` | | 后端类型 | `backend-ts/src/dingtalk/types.ts:13-14` | `DingtalkBot` 两个可选字段 | | 后端仓储 | `backend-ts/src/dingtalk/bot.repository.ts:28-29,47-48` | create/update 两处各写两个字段 | | 后端路由 | `backend-ts/src/dingtalk/admin.routes.ts:27-28,76-77,106-107` | 请求体两个字段 + create/update 赋值 | | 后端配置 | `backend-ts/src/config/app-config.ts:13-14,222-223,582-583` | `DingtalkRuntimeConfig` 两个字段 + 两个 env | | 装配 | `backend-ts/src/app-feishu-handler.ts:519-520` | 透传两个字段给 runtime | | 模板选择 | `backend-ts/src/dingtalk/runtime.ts:151` | `templateOf(bot, kind)` 按 kind 读对应字段 | | 管理后台 | `admin/src/views/dingtalk-bot/DingtalkBotListView.vue:105-110,148-149,169,243,252,267-268` | 两个表单输入框 + form 字段 + 提交体 | | 文档 | `skills/mao-cli/reference/config.md:167-168`、`dingtalk-bot.md:53`、`admin.md:258` | 两个 env、两个 API 参数、后台说明 | ## 4. 第一步:排队卡就地升级为进度卡 ### 4.1 设计思路 照搬飞书已验证的形态:把排队卡的 outTrackId 通过入站上下文传给 `createProgressCard`,命中时跳过 `createAndDeliverCard`、改用 `updateCard` 首次刷新,并补写进度卡映射。 飞书用 `context.progressCardMessageId` 承载,钉钉照此新增同义字段 `progressCardOutTrackId`,保持两端命名风格一致。 ### 4.2 数据流 ``` 消息 D 到达(会话 BUSY) → enqueueMessage ├─ INSERT dingtalk_inbound_queue (status=QUEUED) ├─ createQueueCard → 投放排队卡,拿 outTrackId └─ setOutTrackId(queueId, outTrackId) ← 已有 A 任务结束 → drainNext → claimNext 命中 D 行(status: QUEUED→RUNNING) → executeQueued(sessionId, row) ├─ contextFromQueue(payload, row) → context.progressCardOutTrackId = row.outTrackId ← 新增透传 └─ runExecution(...) └─ createProgressCard(context, sessionId) ├─ outTrackId = context.progressCardOutTrackId ├─ 非空 → updateCard(outTrackId, progressCardParams({status:'running', ...})) │ + progressRepo.upsert({sessionId, botId, outTrackId, ...}) ← 新增映射 │ + 返回 cardProgress(cred, outTrackId, ...) ← 沿用现有句柄 └─ 为空 → 走原 createAndDeliverCard 新建路径(不变) ``` 此后该卡的 running/completed/failed/cancelled 全部由 `cardProgress` 的 `updateCard` 驱动,与现有进度卡完全同一条链路。 ### 4.3 详细改动点 #### 4.3.1 `types.ts`:上下文新增字段 ```ts export interface DingtalkInboundContext extends DingtalkNormalizedMessage { accountId: string; maoUserId?: number; groupContext?: string; senderLabel?: string; /** 排队卡实例 ID:非空表示本轮执行应就地升级该卡为进度卡,不新建卡片(飞书同义字段 progressCardMessageId)。 */ progressCardOutTrackId?: string | null; } ``` #### 4.3.2 `agent-inbound-handler.ts`:透传 `contextFromQueue(payload, row)`(:699)补一行: ```ts progressCardOutTrackId: row.outTrackId, ``` `enqueueMessage`(:411)当前顺序是「先投卡、后 setOutTrackId」。注意 `createQueueCard` 的调用点在 `setOutTrackId` **之前**,因此升级用的 outTrackId 只能从**队列行**读(`row.outTrackId`),不能指望投卡返回值——`executeQueued` 拿到的 row 是后续 `claimNext` 的产物,其 `outTrackId` 已由 `setOutTrackId` 回填。**这条链路已通,不需要改 enqueueMessage。** `createProgressCard` 的 options 签名(:95)不变(`(context, sessionId)`),因为 outTrackId 走 context 而非新增参数——与飞书保持一致,减少调用点改动。 #### 4.3.3 `runtime.ts`:`createProgress` 增加升级分支 ```ts const createProgress = async (context, sessionId) => { const botId = Number(context.accountId); const cred = await credentialsOf(botId); if (cred == null) return null; const sender = context.senderUserid ?? ''; const startedAt = Date.now(); const params = progressCardParams({ status: 'running', round: 0, detail: '任务已接收,正在准备执行。', sessionUrl: await sessionDetailUrl(sessionId), sessionId, senderUserid: sender, }); // 一卡到底:排队卡就地升级为进度卡(飞书同款)。队列行带了 outTrackId 就走 updateCard, // 不再 createAndDeliverCard 新建实例,避免旧排队卡残留「排队中」。 const existing = context.progressCardOutTrackId?.trim(); if (existing != null && existing !== '') { const token = await tokens.getAccessToken(cred.bot.clientId, cred.secret); await updateCard(fetchImpl, token, existing, params) .catch((error) => console.warn(`钉钉排队卡升级进度卡失败, sessionId=${sessionId}`, error)); // 映射必须写:取消/重试回调靠 findProgress(outTrackId) 定位这张卡。 await progressRepo.upsert({ sessionId, botId, outTrackId: existing, chatType: context.chatType, conversationId: context.conversationId, senderUserid: sender, }).catch((error) => console.warn(`钉钉进度卡映射写入失败, sessionId=${sessionId}`, error)); return cardProgress(cred, existing, sessionId, sender, startedAt); } const templateId = templateOf(cred.bot); // 第二步后签名简化 if (templateId == null) return textProgress(botId, context, sessionId); const outTrackId = await createAndDeliverCard(...); // 原路径不动 await progressRepo.upsert(...).catch(...); return cardProgress(cred, outTrackId, sessionId, sender, startedAt); }; ``` **关于升级失败的处理**:`updateCard` 失败时不应让任务失败——这里只 warn 并继续返回 `cardProgress`,后续每轮 `updateCard` 仍会尝试。但**映射无论如何都要写**:即使首刷失败,卡片实例仍在,写映射能让取消/重试继续可用。若连实例都已不存在,后续 `updateCard` 持续失败会由 `FeishuCardProgressListener` 折算为 `false`,终态自动回落文本回复(`agent-inbound-handler.ts:338-339` 已有该逻辑),用户不会两边都收不到。 #### 4.3.4 升级后 `templateOf` 检查的语义变化 原逻辑「`templateOf(cred.bot,'progress') == null` → 退化为文本进度」。升级分支**不再检查模板 ID**:卡片已经存在,更新它不需要模板 ID。这反而是个附带好处——**只配了模板 ID 的 bot,现在也能一卡到底**(原先这种配置下排队消息会另建文本/新卡)。`recoverProgress`(:513)与 `noteReplyFailure`(:357)仍保留模板 ID 检查,语义不变。 ### 4.4 边界与竞态 | 场景 | 行为 | |------|------| | 用户点「立即发送」(`run`)插队 | `decideQueue` 把卡刷成「已插队」后 `interruptAndDrain`;drain 认领该行 → `executeQueued` → 升级分支把同一张卡刷成「任务已接收,正在准备执行。」**符合预期**:插队后紧接着就是执行。与飞书同(飞书此处靠回调 card 展示插队态,随后被进度卡覆盖) | | 用户点「取消排队」(`cancel`) | `decideQueue` 刷成「已取消」,队列行被 `cancel()` 删除(`inbound-queue.service.ts` `cancel` → `deleteById`)。**卡片不会被升级**(行已不存在,drain 认领不到),残留一张终态「已取消」卡——正确 | | 队列行被 `dropQueued` 丢弃(`---` 换会话) | 同上,卡片停留在最后一次状态,不再更新。可接受:会话指针已切走 | | 升级分支 `updateCard` 抛错 | 只 warn,任务继续;后续轮次继续尝试更新 | | 双实例同时消费同一队列行 | 行级 CAS(`claimNextQueued`)保证单实例消费,升级只发生一次 | | 升级后 `afterDelivery` 删映射 | 按 sessionId 删 `dingtalk_progress_card`,一卡到底后语义更干净:卡片终态已由最后一次 `updateCard` 写入,删映射只是让回调不再命中 | ### 4.5 第一步的固有遗留(第二步的必要性) 第一步**不改任何模板**:排队卡仍是排队模板、进度卡仍是进度模板。升级只是「用进度卡变量刷新排队卡实例」——由于 `updateCardDataByKey=true` 是增量更新,排队卡模板里**只声明了自己用到的变量**,进度卡传进来的 `body`/`canCancel` 等 key 在排队卡模板中不存在,会被平台忽略,卡片主体保持排队卡排版。 **这意味着升级后的卡片排版仍是「排队卡排版」,只是文字变成了进度卡文字。** 第一步先拿到「一条消息、无残留」的行为收益,第二步再拿到排版收益。 此判断需上线后实证:增量更新遇到「模板中未声明的 key」的确切行为(忽略 vs 报错)尚未验证。若平台报错,第一步需改为「模板也加变量但隐藏」,或直接把第二步的合并模板提前。见 §7 风险 R1。 ## 5. 第二步:模板与配置字段一并合并 ### 5.1 目标形态 一个模板覆盖全部状态,`dingtalk_bot` 只保留一个模板 ID 列。 | 状态 | `status` 值 | 可见按钮 | boolean 变量 | |------|-------------|----------|--------------| | 排队中 | `queued` | 立即发送、取消排队 | `canRun=true` | | 已插队 | `started` | 无 | 全 false | | 已取消(排队) | `cancelled` | 无 | 全 false | | 执行中 | `running` | 取消任务 | `canCancel=true` | | 完成 | `completed` | 无 | 全 false | | 失败 | `failed` | 重试 | `canRetry=true` | | 取消(任务) | `cancelled` | 无 | 全 false | `cancelled` 在排队取消与任务取消两种语义下复用:都是「这条消息不再执行」,终态文案由 `title`/`body` 区分即可,不需要两个值。 ### 5.2 变量契约(并集) | 变量 | 类型 | 说明 | |------|------|------| | `title` | string | 卡片标题(当前模板不渲染,仅语义占位) | | `body` | markdown | 单一 markdown 变量,承载排队提示 + 状态行 + 正文 + 工具列表 | | `status` | string | 七值语义占位,**不渲染成文本** | | `canRun` | boolean | 排队态显示「立即发送」+「取消排队」 | | `canCancel` | boolean | 执行中显示「取消任务」 | | `canRetry` | boolean | 失败显示「重试」 | | `senderUserid` | string | 原发送者(回调鉴权) | | `queueId` | string | 队列行 ID(排队态需要) | | `sessionId` | string | 会话 ID | | `tag` | string | 卡片标签(当前未传,保留占位) | `sessionUrl` 当前后端传了但模板未使用(进度卡模板只绑了 `body`),合并时若不加「会话详情」按钮就不声明。 ### 5.3 关键约束:所有 boolean 必须补齐 钉钉对未传变量的表现未实证。稳妥做法:**两个 `*CardParams` 都把三个 boolean 显式下发,无关态置 `false`**。这样任何一次更新都不会留下 undefined。 - `progressCardParams` 增补 `canRun: 'false'`(进度卡永远不该显示插队按钮) - `queueCardParams` 增补 `canCancel: 'false'`, `canRetry: 'false'`,并新增 `canRun` - 同时 `queueCardParams` 需要能发 `body`(替代 `preview`)才能让合并模板正确渲染——这是模板侧的前提,见 5.4 ### 5.4 `preview` 与 `body` 合一 排队卡现在用 `preview`(80 字截断)单独一个变量。合并后统一用 `body`:排队态由 `queueCardParams` 自己拼一段 markdown(`**状态:排队中**` + 队列位次 + 消息摘要),运行/终态由 `progressCardParams` 拼。`preview` 变量删除。 **影响面**:`card-action.service.ts` 的 `decideQueue` 回调用 `queueCardParams({status, preview, queueId, senderUserid})`,签名随之调整;`previewOf(payload)` 辅助函数保留(从队列 payload 提取原文)。 ### 5.5 actionId 与成功判定条件 | 按钮 | actionId | actionType | 可见性 | 成功判定条件 | |------|----------|------------|--------|--------------| | 立即发送 | `run` | `request` | `isTrue(canRun)` | `status == 'started'` | | 取消排队 | `cancel` | `request` | `isTrue(canRun)` | `status == 'cancelled'` | | 取消任务 | `cancel` | `request` | `isTrue(canCancel)` | `status == 'cancelled'` | | 重试 | `retry` | `request` | `isTrue(canRetry)` | `status == 'running'` | 两个 `cancel` 是**同一个 actionId 出现在两个按钮上**,靠可见性互斥(`canRun` 与 `canCancel` 不会同时为 true)。回调侧 `decide` 已按 outTrackId 分流,能正确定位到队列行或进度卡映射。**这一点必须实测**:平台是否允许同模板内两个按钮共用 actionId(不同 id/不同 visible)——若不允许,需要把排队取消改名 `cancelQueue`,并相应改 `decide`。见 §7 风险 R2。 沿用已修复的硬约束:actionId 必须字面量、actionType 必须 `request`、按钮 `status` 不能 `disabled`、可见性只能 boolean + `isTrue`、`successCondition` 不能为空、ButtonBlock 要带区块级 `visible`。详见 `skills/mao-cli/template/README.md`。 ### 5.6 配置收敛(一刀切,不做兼容) 按「不考虑历史数据与兼容」的约定,两个模板 ID 全部收敛为一个, naming 用 `cardTemplateId`(不再带 progress/queue 语义)。 #### 数据库 新增 `backend-ts/db/migration/V192__dingtalk_bot_single_card_template.sql`(当前最大版本 V191): ```sql ALTER TABLE `dingtalk_bot` ADD COLUMN `card_template_id` VARCHAR(128) NULL COMMENT '钉钉互动卡片模板ID(排队/进度/终态共用同一模板)' AFTER `model_id`; ALTER TABLE `dingtalk_bot` DROP COLUMN `queue_card_template_id`; ALTER TABLE `dingtalk_bot` DROP COLUMN `progress_card_template_id`; ``` 不回填 `card_template_id`:新列为 NULL,上线后由用户在后台重新填一次。迁移只做结构变更,不搬运旧列的值。 #### 环境变量 `DINGTALK_PROGRESS_CARD_TEMPLATE_ID` 与 `DINGTALK_QUEUE_CARD_TEMPLATE_ID` 合并为 `DINGTALK_CARD_TEMPLATE_ID`。`app-config.ts` 的 `DingtalkRuntimeConfig` 只留 `cardTemplateId`,删掉两处 env 读取。`/opt/mao/backend-ts/.env` 当前未配这两个变量,无需同步清理。 #### 模板选择 ```ts const templateOf = (bot: DingtalkBot): string | null => { const value = (bot.cardTemplateId != null && bot.cardTemplateId !== '' ? bot.cardTemplateId : deps.config.cardTemplateId).trim(); return value === '' ? null : value; }; ``` 去掉 `kind` 参数。调用点:`createProgress`(新建路径)、`createQueueCard`、`recoverProgress`、`noteReplyFailure`。 #### 管理后台 `DingtalkBotListView.vue`:两个输入框合并为一个「卡片模板」,form 字段 `cardTemplateId`,提交体同步。 #### 模板文件 `skills/mao-cli/template/dingtalk-progress-card.json` 重命名为 `dingtalk-card.json`,并就地改造为合并模板。改名需同步:`check-template.js` 的 `file` 常量、`README.md` 的文档内引用。旧文件名不再保留。 ### 5.7 自检脚本扩展 `skills/mao-cli/template/check-template.js` 的 `ACTION_STATUS` / `ACTION_VISIBLE` 目前硬编码 `cancel`/`retry` 两个按钮。改为按 `(actionId, visibleVar)` 组合校验: ```js const ACTION_STATUS = { cancel: 'cancelled', retry: 'running', run: 'started' }; const ACTION_VISIBLE = { cancel: ['canCancel', 'canRun'], retry: ['canRetry'], run: ['canRun'] }; ``` (`cancel` 有两个可见性来源,任一命中即通过,且必须同时校验 boolean 声明。)同时把 `REQUIRED_VARS` 扩到并集,并新增检查:`preview` 变量不得再出现在 variableList(已与 `body` 合一)。 ## 6. 两步的依赖关系与顺序 ``` 第一步(接线 + 配置收敛前的纯行为改动,零模板改动) ├─ 收益:一条消息、无残留卡、取消/重试全链路可用 └─ 遗留:升级后的卡片排版仍是排队卡排版 第二步(模板合并 + 配置字段一刀切) ├─ 前置:第一步已上线且稳定(升级路径已被验证) ├─ 收益:排版正确、单一模板、单一配置字段 └─ 依赖:平台行为实证(§7 R1/R2) ``` **第一步可独立上线并交付价值**,第二步是其排版与配置层面的完善。两步都做,第一步先行。 > 注意:第二步的迁移会删列,因此**第二步不能回退到第一步的代码状态**(代码已不读旧列)。部署顺序必须是「先发代码 + 迁移,再在后台填新模板 ID」;若迁移执行后未及时填 ID,卡片退化为文本进度(`templateOf` 返回 null),任务本身不受影响。 ## 7. 风险与未实证项 | # | 风险 | 影响 | 缓解 | |---|------|------|------| | R1 | 增量更新(`updateCardDataByKey=true`)遇到「本次传入但模板未声明的 key」时,平台行为未知(忽略 / 报错 / 其他) | 决定第一步能否零模板改动上线 | 第一步上线前先用现有排队卡模板 + 进度卡变量实测一次 `updateCard`;若报错,则第一步同时给排队卡模板补上 `body`/`canCancel`/`canRetry` 变量(排版仍不变,仅让 key 有声明),或直接把第二步的合并模板提前 | | R2 | 同一模板内两个按钮共用 actionId `cancel`(靠 visible 互斥)是否被平台允许 | 决定 5.5 的设计能否成立 | 导入前先在卡片平台试;若不允许,排队取消改名 `cancelQueue`,同步改 `decide` 的 actionId 判断与 `decideQueue` | | R3 | 未传变量在 boolean `isTrue` 下的表现 | 可能导致按钮意外显示/隐藏 | 5.3 的两个 `*CardParams` 都补齐全部 boolean;上线前用 dist 端到端验证四种状态的 `canXxx` 取值(与本次 `canCancel`/`canRetry` 的验证方式相同) | | R4 | 升级首刷 `updateCard` 失败(卡片实例已过期/被删) | 卡片停在旧内容 | 已按 4.3.3 处理:warn + 继续 + 写映射;终态回落文本回复的既有逻辑兜底 | | R5 | 模板导入发布是人工步骤,容易漏「发布」 | 以为改了没生效 | 沿用 README 已有告警(只保存不发布 → `param.templateUnpublished`);自检脚本 + 部署后在钉钉侧实测 | | R6 | 第二步删列后若未填新模板 ID,卡片退化为文本 | 用户看不到卡片 | 部署清单把「后台填模板 ID」列为上线前置步骤;`templateOf` 返回 null 时已有文本进度降级路径,任务不失败 | ## 8. 测试计划 单元测试(Vitest,`backend-ts/src/dingtalk/`): 1. `card-body.spec.ts`: - 第二步新增 `queueCardParams` 的 `body` 拼装(状态行 + 队列位次 + 摘要)与 `canRun/canCancel/canRetry` 三值组合; - `progressCardParams` 的 `canRun` 恒为 `false`; - 固化四态映射(running → canCancel、failed → canRetry、completed/cancelled → 全 false)——沿用 `032b3525` 已加的回归用例。 2. `agent-inbound-handler.spec.ts`:`contextFromQueue` 把 `row.outTrackId` 透传为 `context.progressCardOutTrackId`。 3. `card-action.service.spec.ts`:保持现有 `run`/`cancel`/`retry` 分流用例;第二步若 R2 触发改名,补 `cancelQueue` 用例。 4. `runtime` 侧若可注入 fake fetch,补:升级分支走 `updateCard` 而非 `createAndDeliverCard`,且 `progressRepo.upsert` 被调用。 模板自检: - `cd skills/mao-cli/template && node check-template.js` 全绿; - 反向验证:把可见性改回 `isEqual(status,...)` 或删掉 boolean 声明,确认自检报 FAIL。 全量回归: - `cd backend-ts && npm test > /tmp/x.log 2>&1; echo exit=$?`,再 `grep -E 'Test Files|Tests ' /tmp/x.log`; - `npm run build`。 端到端(部署后,钉钉侧人工): - [ ] 会话忙时连发两条 → 各收一张排队卡;A 完成后 B **在同一张卡上**变为「正在处理」→ 终态,会话中无残留「排队中」卡 - [ ] 排队卡点「取消排队」→ 卡变「已取消」,当前任务不受影响 - [ ] 排队卡点「立即发送」→ 当前任务卡显示「已被下一条指令中断」,目标消息跳队首执行且**同一张卡**变进度态 - [ ] 执行中点「取消任务」→ 同一张卡变「任务已取消」,后续排队消息继续 - [ ] 失败态「重试」→ 同一张卡变「正在重试」 - [ ] `restart-backend` 后:`dingtalk_inbound_queue` 中 QUEUED 行被接力消费,卡片按钮仍可用 - [ ] 第二步:合并模板导入发布后重跑以上全流程,并确认只填一个模板 ID 的 bot 行为正常 ## 9. 落地清单 ### 第一步 - [ ] `backend-ts/src/dingtalk/types.ts`:`DingtalkInboundContext` 增 `progressCardOutTrackId` - [ ] `backend-ts/src/dingtalk/agent-inbound-handler.ts`:`contextFromQueue` 透传 - [ ] `backend-ts/src/dingtalk/runtime.ts`:`createProgress` 增加升级分支 - [ ] `backend-ts/src/dingtalk/agent-inbound-handler.spec.ts`:透传用例 - [ ] `CHANGELOG.md` 新增版本小节(`### 后端`) - [ ] `skills/mao-cli/reference/dingtalk-bot.md`:补充一卡到底说明 - [ ] 全量测试 + build + 部署 + 钉钉侧实测 ### 第二步 - [ ] `backend-ts/db/migration/V192__dingtalk_bot_single_card_template.sql`:新增 `card_template_id`,删两个旧列 - [ ] `backend-ts/src/dingtalk/card-body.ts`:`queueCardParams` 改发 `body` + 三 boolean;`progressCardParams` 补 `canRun: 'false'` - [ ] `backend-ts/src/dingtalk/card-action.service.ts`:`decideQueue` 适配新 `queueCardParams` 签名 - [ ] `backend-ts/src/dingtalk/types.ts`:`DingtalkBot` 两个字段 → `cardTemplateId` - [ ] `backend-ts/src/dingtalk/bot.repository.ts`:create/update 改单字段 - [ ] `backend-ts/src/dingtalk/admin.routes.ts`:请求体与赋值改单字段 - [ ] `backend-ts/src/config/app-config.ts`:`DingtalkRuntimeConfig.cardTemplateId` + `DINGTALK_CARD_TEMPLATE_ID` - [ ] `backend-ts/src/app-feishu-handler.ts`:装配透传改单字段 - [ ] `backend-ts/src/dingtalk/runtime.ts`:`templateOf(bot)` 去掉 `kind` - [ ] `admin/src/views/dingtalk-bot/DingtalkBotListView.vue`:两个输入框合并为一个 - [ ] `skills/mao-cli/template/dingtalk-card.json`(由 `dingtalk-progress-card.json` 重命名):三按钮 + 三 boolean + `body` 合一(改 `editorData` 必须用函数形式 replace,避免 `$$key` 被吞) - [ ] `skills/mao-cli/template/check-template.js`:`file` 常量指向新文件名;`ACTION_STATUS`/`ACTION_VISIBLE`/`REQUIRED_VARS` 扩展 - [ ] `skills/mao-cli/template/README.md`:更新契约表与踩坑记录 - [ ] `backend-ts/src/dingtalk/card-body.spec.ts`:新契约回归 - [ ] 自检 + 反向验证 + 全量测试 + build + `admin` 侧 `npm run build` - [ ] **人工**:卡片平台导入 → 保存 → **发布** → 后台填模板 ID - [ ] `CHANGELOG.md` + `skills/mao-cli/reference/{dingtalk-bot,config,admin}.md` 同步 - [ ] 部署 + 钉钉侧全流程实测 ## 10. 参考资料 - 飞书一卡到底范本:`backend-ts/src/feishu-cards.ts:189-195`(`existingMessageId` PATCH 分支) - 飞书入站队列设计:`docs/plan/2026-08-28-feishu-inbound-queue-technical-design.md`(决策 5「一卡到底」) - 钉钉卡片更新语义(增量/全量):钉钉开放平台「卡片更新」[^1^][^2^] - 本次已修复的按钮可见性约束:`skills/mao-cli/template/README.md`