Commit 4c8c70a7 by luoqi

feat(callback): 执行结果回调(PAC → 宿主)+ 修 push 密钥存储 + 修凭据页刷新

执行结果回调(opt-in,宿主配 callbackUrl 才投递):
- Host 加 callbackUrl + callbackSecrets(存原文,PAC 签名用);迁移加两列。
- ExecutionService.submit 提交后入 execution-callback 队列(6 次退避重试,死信可见)。
- ExecutionCallbackService:查 execution→plan→patient 拼 payload,HMAC-SHA256("{ts}.{body}")
  签名(与 push 入站同配方),fetch+8s 超时,非 2xx/超时抛错交 BullMQ 重试;jobId=executionId 幂等。
- 凭据页新增「执行结果回执」区:callbackUrl 输入 + Callback Secrets(add/rotate/grace/删),
  suffix 用 sha256 指纹不回显原文。对接文档同步。

顺手修 push 密钥存储 bug:
- create/rotate 原本存 scrypt 哈希,但 HmacVerifier 把它当原文做 HMAC key → 宿主拿明文、
  PAC 拿哈希,两边 key 不一致,push 验签永远过不了。改为存原文(对齐 seed 与 verifier 预期)。
- 本地实测:轮换后用返回的明文签名 → 验签通过;错误签名 → 10106 拒绝。

修凭据页「刷新即整页重载」:
- useHostAdmin.refresh 一进来就切 loading,父组件把整页换成占位、卸载 HostAdminApp,
  连带把"仅显示一次"的明文 secret 弹窗刷没(来不及复制)。改 stale-while-revalidate:
  已有数据不切 loading,旧数据留屏后台重取。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 29f27fe0
Pipeline #3337 failed in 0 seconds
--- ---
title: 执行结果回调 title: 执行结果回调
description: PAC 把客服召回执行结果实时回调给宿主的出站 webhook description: 客服提交召回执行结果后,PAC 回调宿主登记地址的对接说明
icon: BellRing icon: BellRing
--- ---
> **状态:规划中(v2 实时回执),接口待实现。** 字段以最终 OpenAPI 定义为准 > 客服在 PAC 工作台提交召回执行结果后,PAC 主动 POST 回宿主登记的地址,用于同步宿主侧 CRM / 工单
> 客服在 PAC 工作台填写召回执行结果(是否打通、结论、备注等)后,PAC 主动**回执**给宿主,用于同步宿主侧 CRM / 工单。需要此能力的宿主在接入时注册回执地址即可,不需要的可不注册 > 方向与登录换票相反 —— 换票是宿主调 PAC,回执是 **PAC 调宿主**
--- ---
## 方向与流程 ## 接入前提
``` 接入时向 PAC 提供:
客服在 PAC 提交执行结果
→ PAC 状态机更新
→ PAC 主动 POST 宿主回执地址(HMAC 签名)
→ 宿主返回 2xx 确认;非 2xx / 超时 → PAC 退避重试
```
回执方向与登录换票相反:登录是宿主调 PAC,回执是 **PAC 调宿主** - **回执地址**(`callbackUrl`):宿主侧接收 POST 的 HTTPS endpoint
## 接入前提 PAC 分配:
接入时向 PAC 提供: - **回执密钥**:PAC 一次性明文下发(仅本次显示,支持轮换)。用于验签,与 push 密钥相互独立。
- **回执地址**(`callbackUrl`):宿主侧接收 POST 的 URL;
- **验签密钥**:PAC 用它对请求体签名,宿主据此验签(密钥仅双方持有)。 ## 请求
## 请求:`POST {callbackUrl}` `POST {callbackUrl}`
请求头: 请求头:
| 头 | 说明 | | 头 | 说明 |
|---|---| |---|---|
| `X-PAC-Event-Id` | 事件唯一 id(= 请求体 `eventId`),**幂等键** | | `X-PAC-Timestamp` | unix 秒;容忍 ±5 分钟偏差 |
| `X-PAC-Signature` | `HMAC-SHA256(rawBody, 验签密钥)` 的十六进制;宿主用同密钥重算比对 | | `X-PAC-Signature` | `hex(HMAC-SHA256("{X-PAC-Timestamp}.{rawBody}", 回执密钥))` |
| `X-PAC-Event-Id` | 事件唯一 id,**幂等键** |
请求体: 请求体:
```jsonc ```jsonc
{ {
"eventId": "...", // 幂等键,宿主据此去重 "eventId": "...", // = X-PAC-Event-Id
"patientExternalId": "...", // 宿主侧患者 id(关联用) "patientExternalId": "...", // 宿主侧患者 id
"operatorUserId": "...", // 宿主侧客服 id "operatorUserId": "...", // 宿主侧客服 id
"channel": "phone", // phone | wecom | sms | other "channel": "phone", // phone | wecom | sms | other
"outcome": "refused", // 执行结论,见下 "outcome": "refused", // 执行结论,见下
"planStatus": "abandoned", // 回执时该工单状态,见下
"notes": "...", // 通话纪要(可选) "notes": "...", // 通话纪要(可选)
"scheduledNextAt": "2026-07-01T02:00:00Z", // 约定下次回访时间(可选) "scheduledNextAt": "2026-07-01T02:00:00Z", // 约定下次回访时间(可选)
"planStatus": "abandoned", // 回执时该召回工单的状态
"occurredAt": "2026-06-30T08:12:00Z" // 提交时间(ISO 8601) "occurredAt": "2026-06-30T08:12:00Z" // 提交时间(ISO 8601)
} }
``` ```
**`outcome`** — 客服选择的执行结论(常见值): **`outcome`** — 执行结论(取值可能新增,按未知值宽容处理;工单最终状态以 `planStatus` 为准):
| 值 | 含义 | 对 `planStatus` 的影响 | | 值 | 含义 |
|---|---|---| |---|---|
| `success_appointed` | 成功转化为新预约 | `completed` | | `success_appointed` | 转化为新预约 |
| `refused` | 明确拒绝 | `abandoned` | | `refused` | 明确拒绝 |
| `external_treatment` | 已在外院治疗 | `abandoned` | | `external_treatment` | 已在外院治疗 |
| `declined_recent` | 近期不考虑 | 维持(冷静期后再召) | | `declined_recent` | 近期不考虑 |
| `scheduled_next` | 约定下次回访 | 维持 | | `scheduled_next` | 约定下次回访 |
| `no_answer` / `quick_hangup` | 未接通 / 接通即挂 | 维持 | | `no_answer` | 未接通 |
| `quick_hangup` | 接通即挂 |
> 完整取值以 OpenAPI / `ExecutionOutcome` 枚举为准。
**`planStatus`** — `active`(在召回池)/ `assigned`(已分配)/ `completed`(结案)/ `abandoned`(关闭)。 **`planStatus`** — `active`(在召回池)/ `assigned`(已分配)/ `completed`(结案)/ `abandoned`(关闭)。
## 宿主响应与重试 ## 宿主要做的三件事
- 宿主收到后**尽快返回 2xx**(建议先落库再异步处理),即视为投递成功。 1. **验签**:用回执密钥按上表算法重算,与 `X-PAC-Signature` 比对;不匹配返回 4xx 拒绝。
- 非 2xx 或超时 → PAC 按退避策略重试;多次失败入死信并告警。 2. **去重**:按 `X-PAC-Event-Id` 去重 —— 投递为 at-least-once,同一事件可能到达多次。
- 投递语义为 **at-least-once**:宿主**必须按 `eventId` 去重**(同一 `eventId` 可能到达多次)。 3. **回应**:尽快返回 **2xx**(建议先落库再异步处理)即视为投递成功;非 2xx 或超时,PAC 会重试。
- 验签失败的请求应拒绝(返回 4xx)。
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "callback_secrets" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "callback_url" TEXT;
...@@ -105,7 +105,8 @@ model Host { ...@@ -105,7 +105,8 @@ model Host {
/// } /// }
pullConfig Json? @map("pull_config") pullConfig Json? @map("pull_config")
/// 接受该宿主 push 时验签用的 HMAC 共享密钥哈希数组;空数组 = 拒绝该宿主所有 push /// 接受该宿主 push 时验签用的 HMAC 共享密钥;空数组 = 拒绝该宿主所有 push
/// **存原文**(HMAC 对称,PAC 验签需与宿主持同一 key);列名 Hashes 是历史命名,不代表哈希。
/// 数组形式支持**平滑密钥轮换**:验签时遍历任一命中即通过。 /// 数组形式支持**平滑密钥轮换**:验签时遍历任一命中即通过。
/// 约定:索引 0 current,后置为 grace period 内仍接受的旧 key,运维定期清理过期项 /// 约定:索引 0 current,后置为 grace period 内仍接受的旧 key,运维定期清理过期项
pushSecretHashes String[] @map("push_secret_hashes") pushSecretHashes String[] @map("push_secret_hashes")
...@@ -113,6 +114,14 @@ model Host { ...@@ -113,6 +114,14 @@ model Host {
/// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT ) /// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT )
/// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮 /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
actionUrls Json @default("{}") @map("action_urls") actionUrls Json @default("{}") @map("action_urls")
/// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in)
callbackUrl String? @map("callback_url")
/// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希)
/// 数组前插支持平滑轮换:索引 0 = current(签名用它),后置为宿主切换期仍在用的旧 key
/// push 密钥相互独立(不同方向、不同宿主子系统,各管一把)
callbackSecrets String[] @default([]) @map("callback_secrets")
/// 软停用开关,false 时换票直接 401 /// 软停用开关,false 时换票直接 401
active Boolean @default(true) active Boolean @default(true)
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
HostDetailSchema, HostDetailSchema,
HostStatsSchema, HostStatsSchema,
RotatePushSecretResponseSchema, RotatePushSecretResponseSchema,
RotateCallbackSecretResponseSchema,
RotateSecretResponseSchema, RotateSecretResponseSchema,
RemovePushSecretRequestSchema, RemovePushSecretRequestSchema,
RemovePushSecretResponseSchema, RemovePushSecretResponseSchema,
...@@ -22,6 +23,7 @@ export class HostStatsDto extends createZodDto(HostStatsSchema) {} ...@@ -22,6 +23,7 @@ export class HostStatsDto extends createZodDto(HostStatsSchema) {}
export class UpdateHostRequestDto extends createZodDto(UpdateHostRequestSchema) {} export class UpdateHostRequestDto extends createZodDto(UpdateHostRequestSchema) {}
export class RotateSecretResponseDto extends createZodDto(RotateSecretResponseSchema) {} export class RotateSecretResponseDto extends createZodDto(RotateSecretResponseSchema) {}
export class RotatePushSecretResponseDto extends createZodDto(RotatePushSecretResponseSchema) {} export class RotatePushSecretResponseDto extends createZodDto(RotatePushSecretResponseSchema) {}
export class RotateCallbackSecretResponseDto extends createZodDto(RotateCallbackSecretResponseSchema) {}
export class RemovePushSecretRequestDto extends createZodDto(RemovePushSecretRequestSchema) {} export class RemovePushSecretRequestDto extends createZodDto(RemovePushSecretRequestSchema) {}
export class RemovePushSecretResponseDto extends createZodDto(RemovePushSecretResponseSchema) {} export class RemovePushSecretResponseDto extends createZodDto(RemovePushSecretResponseSchema) {}
......
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
HostStatsDto, HostStatsDto,
HostSummaryDto, HostSummaryDto,
RemovePushSecretResponseDto, RemovePushSecretResponseDto,
RotateCallbackSecretResponseDto,
RotatePushSecretResponseDto, RotatePushSecretResponseDto,
RotateSecretResponseDto, RotateSecretResponseDto,
UpdateHostRequestDto, UpdateHostRequestDto,
...@@ -100,4 +101,28 @@ export class HostSelfAdminController { ...@@ -100,4 +101,28 @@ export class HostSelfAdminController {
) { ) {
return this.hosts.removePushSecretBySuffix(scope.hostId, suffix); return this.hosts.removePushSecretBySuffix(scope.hostId, suffix);
} }
// ─── 回执密钥(执行结果回调,方向:PAC → 宿主)───
@Post('callback-secrets')
@ZodResponse({ status: 200, type: RotateCallbackSecretResponseDto })
@ApiOperation({
summary: '添加新 callback secret(数组前插,旧 key 进 grace period)',
description: '返回的 plaintext 仅本次显示,请立即保存。回执地址(callbackUrl)走 PATCH 设置。',
})
addCallbackSecret(@TenantScope() scope: TenantScopeContext) {
return this.hosts.rotateCallbackSecret(scope.hostId);
}
@Delete('callback-secrets/:suffix')
@ZodResponse({ status: 200, type: RemovePushSecretResponseDto })
@ApiOperation({
summary: '按 suffix 删除某条 callback secret(不能删唯一一条)',
})
removeCallbackSecret(
@TenantScope() scope: TenantScopeContext,
@Param('suffix') suffix: string,
) {
return this.hosts.removeCallbackSecretBySuffix(scope.hostId, suffix);
}
} }
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { hashSecret, randomCode } from '@pac/utils'; import { hashSecret, randomCode, sha256 } from '@pac/utils';
import type { Host, Prisma } from '@prisma/client'; import type { Host, Prisma } from '@prisma/client';
import type { import type {
CreateHostRequest, CreateHostRequest,
...@@ -10,6 +10,7 @@ import type { ...@@ -10,6 +10,7 @@ import type {
HostStats, HostStats,
PushSecretEntry, PushSecretEntry,
RotatePushSecretResponse, RotatePushSecretResponse,
RotateCallbackSecretResponse,
RotateSecretResponse, RotateSecretResponse,
RemovePushSecretResponse, RemovePushSecretResponse,
UpdateHostRequest, UpdateHostRequest,
...@@ -71,7 +72,8 @@ export class HostsService { ...@@ -71,7 +72,8 @@ export class HostsService {
appId, appId,
appSecretHash: hashSecret(appSecret), appSecretHash: hashSecret(appSecret),
pullConfig: this.toPrismaJson(req.pullConfig), pullConfig: this.toPrismaJson(req.pullConfig),
pushSecretHashes: pushSecret ? [hashSecret(pushSecret)] : [], // push 密钥存**原文**:HMAC 对称,PAC 验签必须与宿主持同一把 key(不能存哈希,否则永远验不过)。
pushSecretHashes: pushSecret ? [pushSecret] : [],
actionUrls: this.toPrismaJson(req.actionUrls) ?? {}, actionUrls: this.toPrismaJson(req.actionUrls) ?? {},
active: true, active: true,
}, },
...@@ -112,6 +114,8 @@ export class HostsService { ...@@ -112,6 +114,8 @@ export class HostsService {
if (req.pullConfig !== undefined) data.pullConfig = this.toPrismaJson(req.pullConfig); if (req.pullConfig !== undefined) data.pullConfig = this.toPrismaJson(req.pullConfig);
if (req.actionUrls !== undefined) data.actionUrls = this.toPrismaJson(req.actionUrls); if (req.actionUrls !== undefined) data.actionUrls = this.toPrismaJson(req.actionUrls);
if (req.active !== undefined) data.active = req.active; if (req.active !== undefined) data.active = req.active;
// callbackUrl:传 null → 关闭回执;传 url → 启用/改地址
if (req.callbackUrl !== undefined) data.callbackUrl = req.callbackUrl ?? null;
const updated = await this.prisma.host.update({ where: { id }, data }); const updated = await this.prisma.host.update({ where: { id }, data });
return this.toSummary(updated); return this.toSummary(updated);
...@@ -138,7 +142,8 @@ export class HostsService { ...@@ -138,7 +142,8 @@ export class HostsService {
async rotatePushSecret(id: string): Promise<RotatePushSecretResponse> { async rotatePushSecret(id: string): Promise<RotatePushSecretResponse> {
const p = await this.getHostOrThrow(id); const p = await this.getHostOrThrow(id);
const pushSecret = randomCode(32); const pushSecret = randomCode(32);
const newHashes = [hashSecret(pushSecret), ...p.pushSecretHashes]; // 存原文(理由同 create):HMAC 验签需双方持同一 key。
const newHashes = [pushSecret, ...p.pushSecretHashes];
await this.prisma.host.update({ await this.prisma.host.update({
where: { id }, where: { id },
data: { pushSecretHashes: newHashes }, data: { pushSecretHashes: newHashes },
...@@ -147,6 +152,39 @@ export class HostsService { ...@@ -147,6 +152,39 @@ export class HostsService {
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// Rotate callbackSecret(数组前插;PAC 签名用 → 存**原文**,非哈希)
// ─────────────────────────────────────────────────────────
async rotateCallbackSecret(id: string): Promise<RotateCallbackSecretResponse> {
const p = await this.getHostOrThrow(id);
const callbackSecret = randomCode(32);
// 存原文:PAC 出站签名必须持有与宿主同一把 key(HMAC 对称)。
const next = [callbackSecret, ...p.callbackSecrets];
await this.prisma.host.update({
where: { id },
data: { callbackSecrets: next },
});
return { callbackSecret, totalKeys: next.length };
}
async removeCallbackSecretBySuffix(id: string, suffix: string): Promise<RemovePushSecretResponse> {
const p = await this.getHostOrThrow(id);
const matchIdx = p.callbackSecrets.findIndex((s) => fingerprintSuffix(s) === suffix);
if (matchIdx < 0) {
throw new BadRequestException(`callback secret suffix=${suffix} not found in rotation`);
}
if (p.callbackSecrets.length === 1) {
throw new BadRequestException('不能删除唯一的 callback secret;请先添加新 secret 再删旧的');
}
const next = p.callbackSecrets.filter((_, i) => i !== matchIdx);
await this.prisma.host.update({
where: { id },
data: { callbackSecrets: next },
});
return { totalKeysRemaining: next.length };
}
// ─────────────────────────────────────────────────────────
// Deactivate(软停) // Deactivate(软停)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -175,8 +213,11 @@ export class HostsService { ...@@ -175,8 +213,11 @@ export class HostsService {
active: p.active, active: p.active,
appSecret: maskHash(p.appSecretHash, p.updatedAt), appSecret: maskHash(p.appSecretHash, p.updatedAt),
pushSecrets: p.pushSecretHashes.map((h, i) => toPushSecretEntry(h, i)), pushSecrets: p.pushSecretHashes.map((h, i) => toPushSecretEntry(h, i)),
// 回执密钥存原文 → suffix 用指纹(sha256 末 8),不回显原文任何片段
callbackSecrets: p.callbackSecrets.map((s, i) => toCallbackSecretEntry(s, i)),
pullConfig: (p.pullConfig as PullConfig | null) ?? null, pullConfig: (p.pullConfig as PullConfig | null) ?? null,
actionUrls: (p.actionUrls as ActionUrlsConfig | null) ?? {}, actionUrls: (p.actionUrls as ActionUrlsConfig | null) ?? {},
callbackUrl: p.callbackUrl ?? null,
stats, stats,
}; };
} }
...@@ -265,7 +306,7 @@ export class HostsService { ...@@ -265,7 +306,7 @@ export class HostsService {
async removePushSecretBySuffix(id: string, suffix: string): Promise<RemovePushSecretResponse> { async removePushSecretBySuffix(id: string, suffix: string): Promise<RemovePushSecretResponse> {
const p = await this.getHostOrThrow(id); const p = await this.getHostOrThrow(id);
const matchIdx = p.pushSecretHashes.findIndex((h) => extractSuffix(h) === suffix); const matchIdx = p.pushSecretHashes.findIndex((h) => fingerprintSuffix(h) === suffix);
if (matchIdx < 0) { if (matchIdx < 0) {
throw new BadRequestException(`push secret suffix=${suffix} not found in rotation`); throw new BadRequestException(`push secret suffix=${suffix} not found in rotation`);
} }
...@@ -316,16 +357,6 @@ export class HostsService { ...@@ -316,16 +357,6 @@ export class HostsService {
// 模块辅助:secret mask + suffix(用于 detail 展示 + 删除定位) // 模块辅助:secret mask + suffix(用于 detail 展示 + 删除定位)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
/**
* Hash 形态:`salt:hash`(scrypt)或 `dev:hash`(dev stub)。
* 这里只是给 admin "认出来"是哪条密钥用,不暴露原文也不暴露完整 hash。
* 取末 8 个字符做后缀。
*/
function extractSuffix(hashOrSecret: string): string {
const s = hashOrSecret.length > 8 ? hashOrSecret.slice(-8) : hashOrSecret;
return s;
}
function maskHash(hash: string, updatedAt: Date): { masked: string; rotatedAt: string | null } { function maskHash(hash: string, updatedAt: Date): { masked: string; rotatedAt: string | null } {
if (!hash) return { masked: '(empty)', rotatedAt: null }; if (!hash) return { masked: '(empty)', rotatedAt: null };
// hash 已不是明文,但仍 mask 中间;admin 只关心"是不是同一条" // hash 已不是明文,但仍 mask 中间;admin 只关心"是不是同一条"
...@@ -337,14 +368,22 @@ function maskHash(hash: string, updatedAt: Date): { masked: string; rotatedAt: s ...@@ -337,14 +368,22 @@ function maskHash(hash: string, updatedAt: Date): { masked: string; rotatedAt: s
}; };
} }
function toPushSecretEntry(hash: string, index: number): PushSecretEntry { /// 密钥存原文(HMAC 对称)→ 用 sha256 指纹的末 8 位做识别/删除定位,绝不回显原文片段。
/// push / callback 共用。
function fingerprintSuffix(secret: string): string {
return sha256(secret).slice(-8);
}
function toPushSecretEntry(secret: string, index: number): PushSecretEntry {
return { return {
index, index,
suffix: extractSuffix(hash), suffix: fingerprintSuffix(secret),
role: index === 0 ? 'current' : 'grace', role: index === 0 ? 'current' : 'grace',
}; };
} }
const toCallbackSecretEntry = toPushSecretEntry;
/** /**
* sync_log.direction + triggeredBy → 事件 kind(给前端时间轴用) * sync_log.direction + triggeredBy → 事件 kind(给前端时间轴用)
*/ */
......
import { Injectable, Logger } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
/**
* ExecutionCallbackService — 执行结果回执投递(PAC → 宿主 callbackUrl)。
*
* 契约见 apps/pac-docs/content/docs/integration/execution-callback.mdx:
* POST {callbackUrl}
* Headers: X-PAC-Timestamp / X-PAC-Signature / X-PAC-Event-Id
* 签名: hex(HMAC-SHA256("{timestamp}.{rawBody}", callbackSecret)) — 与 push 入站同一套配方
*
* 由 execution-callback 队列的 worker 调用;失败抛错交 BullMQ 退避重试(耗尽入死信)。
*/
const DELIVER_TIMEOUT_MS = 8_000;
/** 投递结果:worker 据 delivered 决定成功 / 抛错重试。 */
export interface DeliverResult {
delivered: boolean;
/** 跳过原因(未启用回执 / execution 不存在等 —— 非失败,不重试) */
skipped?: string;
httpStatus?: number;
}
@Injectable()
export class ExecutionCallbackService {
private readonly logger = new Logger(ExecutionCallbackService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* 投递单条 execution 的回执。
* - 宿主未配 callbackUrl / 未配密钥 → skipped(opt-in,不算失败)
* - HTTP 2xx → delivered
* - 非 2xx / 超时 / 网络错 → 抛错(worker 交 BullMQ 重试)
*/
async deliver(executionId: string): Promise<DeliverResult> {
const exec = await this.prisma.planExecution.findUnique({ where: { id: executionId } });
if (!exec) return { delivered: false, skipped: 'execution 不存在' };
const plan = await this.prisma.followupPlan.findUnique({
where: { id: exec.planId },
select: { status: true, patientId: true, hostId: true, tenantId: true },
});
if (!plan) return { delivered: false, skipped: 'plan 不存在' };
const host = await this.prisma.host.findUnique({
where: { id: plan.hostId },
select: { callbackUrl: true, callbackSecrets: true },
});
if (!host?.callbackUrl) return { delivered: false, skipped: '未配置 callbackUrl' };
const secret = host.callbackSecrets[0];
if (!secret) return { delivered: false, skipped: '未配置 callback secret' };
const patient = plan.patientId
? await this.prisma.patient.findUnique({
where: { id: plan.patientId },
select: { externalId: true },
})
: null;
// 请求体(字段对齐对接文档;可选字段无值时省略)
const body: Record<string, unknown> = {
eventId: exec.id,
patientExternalId: patient?.externalId ?? null,
operatorUserId: exec.operatorUserId,
channel: exec.channel,
outcome: exec.outcome,
planStatus: plan.status,
occurredAt: exec.createdAt.toISOString(),
};
if (exec.notes) body.notes = exec.notes;
if (exec.scheduledNextAt) body.scheduledNextAt = exec.scheduledNextAt.toISOString();
const rawBody = JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
let res: Response;
try {
res = await fetch(host.callbackUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-PAC-Timestamp': timestamp,
'X-PAC-Signature': signature,
'X-PAC-Event-Id': exec.id,
},
body: rawBody,
signal: AbortSignal.timeout(DELIVER_TIMEOUT_MS),
});
} catch (err) {
// 超时 / 网络错 → 抛错重试
throw new Error(`回执投递网络失败 execution=${executionId}: ${(err as Error).message}`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`回执投递被拒 execution=${executionId} http=${res.status}`);
}
this.logger.log(
`回执已投递 execution=${executionId.slice(0, 8)} outcome=${exec.outcome} http=${res.status}`,
);
return { delivered: true, httpStatus: res.status };
}
}
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types'; import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service';
import { resolveSnoozedUntil } from './recall-suppression'; import { resolveSnoozedUntil } from './recall-suppression';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -69,7 +70,10 @@ export interface SubmitExecutionResult { ...@@ -69,7 +70,10 @@ export interface SubmitExecutionResult {
export class ExecutionService { export class ExecutionService {
private readonly logger = new Logger(ExecutionService.name); private readonly logger = new Logger(ExecutionService.name);
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly queue: QueueProducer,
) {}
async submit( async submit(
scope: TenantScopeContext, scope: TenantScopeContext,
...@@ -187,6 +191,26 @@ export class ExecutionService { ...@@ -187,6 +191,26 @@ export class ExecutionService {
return execution; return execution;
}, { maxWait: 30000, timeout: 60000 }); }, { maxWait: 30000, timeout: 60000 });
// ─── 5. 执行结果回执(opt-in:宿主配了 callbackUrl 才投递)──────
// 事务已提交,回执异步走队列(HMAC 签名 + 退避重试),不阻塞客服提交返回。
const host = await this.prisma.host.findUnique({
where: { id: plan.hostId },
select: { callbackUrl: true },
});
if (host?.callbackUrl) {
await this.queue
.enqueueExecutionCallback({
hostId: plan.hostId,
tenantId: plan.tenantId,
executionId: result.id,
triggeredBy: `execution:${operatorUserId}`,
})
.catch((err) =>
// 入队失败不影响客服提交(execution 已落库);只告警,靠日志兜
this.logger.error(`回执入队失败 execution=${result.id}: ${(err as Error).message}`),
);
}
return { return {
executionId: result.id, executionId: result.id,
planStatus: newStatus, planStatus: newStatus,
......
...@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller'; import { PlanController } from './plan.controller';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
import { PlanEngineService } from './engine/plan-engine.service'; import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service'; import { ChainComposerService } from './engine/chain-composer.service';
...@@ -18,12 +19,13 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -18,12 +19,13 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
providers: [ providers: [
PlanService, PlanService,
ExecutionService, ExecutionService,
ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
PlanEngineService, PlanEngineService,
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService, RecallDebugService,
], ],
exports: [PlanService, ExecutionService, PlanEngineService, ChainComposerService], exports: [PlanService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
}) })
export class PlanModule {} export class PlanModule {}
...@@ -32,3 +32,11 @@ export interface PlanAssetGenerateJob { ...@@ -32,3 +32,11 @@ export interface PlanAssetGenerateJob {
assets?: Array<'script' | 'summary'>; assets?: Array<'script' | 'summary'>;
triggeredBy: string; triggeredBy: string;
} }
export interface ExecutionCallbackJob {
hostId: string;
tenantId: string;
/** 回执数据由 worker 按此 id 现查(execution → plan → patient);id 也是 eventId / 幂等键 */
executionId: string;
triggeredBy: string;
}
import { Logger } from '@nestjs/common';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueName } from '../queue-names';
import type { ExecutionCallbackJob } from '../job-payloads';
import { ExecutionCallbackService } from '../../modules/plan/execution-callback.service';
/**
* execution-callback worker — 把客服执行结果 POST 回宿主 callbackUrl(HMAC 签名)。
*
* 触发:ExecutionService.submit 提交成功且宿主配了 callbackUrl → 入队。
* 重试:非 2xx / 超时 → 抛错交 BullMQ 退避(QueueDefaults);耗尽入 failed 集(死信,Bull Board 可见)。
* 幂等:jobId = executionId,同一 execution 不重复入队;宿主侧再按 X-PAC-Event-Id 去重(at-least-once)。
*/
@Processor(QueueName.EXECUTION_CALLBACK, { concurrency: 4 })
export class ExecutionCallbackProcessor extends WorkerHost {
private readonly logger = new Logger(ExecutionCallbackProcessor.name);
constructor(private readonly callback: ExecutionCallbackService) {
super();
}
async process(job: Job<ExecutionCallbackJob>): Promise<{ delivered: boolean; skipped?: string }> {
const { executionId } = job.data;
const r = await this.callback.deliver(executionId);
if (r.skipped) {
this.logger.debug(`execution-callback skip execution=${executionId.slice(0, 8)}: ${r.skipped}`);
}
return { delivered: r.delivered, skipped: r.skipped };
}
}
...@@ -13,6 +13,8 @@ export const QueueName = { ...@@ -13,6 +13,8 @@ export const QueueName = {
PERSONA_RECOMPUTE: 'persona-recompute', PERSONA_RECOMPUTE: 'persona-recompute',
PLAN_RECOMPUTE: 'plan-recompute', PLAN_RECOMPUTE: 'plan-recompute',
PLAN_ASSET_GENERATE: 'plan-asset-generate', PLAN_ASSET_GENERATE: 'plan-asset-generate',
/// 执行结果回执投递(PAC → 宿主 callbackUrl,HMAC 签名 + 退避重试;jobName = executionId)
EXECUTION_CALLBACK: 'execution-callback',
} as const; } as const;
export type QueueName = (typeof QueueName)[keyof typeof QueueName]; export type QueueName = (typeof QueueName)[keyof typeof QueueName];
...@@ -41,4 +43,14 @@ export const QueueDefaults = { ...@@ -41,4 +43,14 @@ export const QueueDefaults = {
removeOnComplete: { count: 200 }, removeOnComplete: { count: 200 },
removeOnFail: { count: 1_000 }, removeOnFail: { count: 1_000 },
}, },
[QueueName.EXECUTION_CALLBACK]: {
/**
* 网络投递:多试几次熬过宿主短暂抖动。6 次指数退避 ≈ 2s/4s/8s/16s/32s(约 1 分钟);
* 耗尽仍失败 → 留在 failed 集(Bull Board 可见 = 死信),removeOnFail 保留大量供排查。
*/
attempts: 6,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { count: 500 },
removeOnFail: { count: 5_000 },
},
} as const; } as const;
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
PersonaRecomputeJob, PersonaRecomputeJob,
PlanRecomputeJob, PlanRecomputeJob,
PlanAssetGenerateJob, PlanAssetGenerateJob,
ExecutionCallbackJob,
} from './job-payloads'; } from './job-payloads';
/** /**
...@@ -27,6 +28,7 @@ export class QueueProducer { ...@@ -27,6 +28,7 @@ export class QueueProducer {
@InjectQueue(QueueName.PERSONA_RECOMPUTE) private readonly personaQ: Queue, @InjectQueue(QueueName.PERSONA_RECOMPUTE) private readonly personaQ: Queue,
@InjectQueue(QueueName.PLAN_RECOMPUTE) private readonly planQ: Queue, @InjectQueue(QueueName.PLAN_RECOMPUTE) private readonly planQ: Queue,
@InjectQueue(QueueName.PLAN_ASSET_GENERATE) private readonly assetQ: Queue, @InjectQueue(QueueName.PLAN_ASSET_GENERATE) private readonly assetQ: Queue,
@InjectQueue(QueueName.EXECUTION_CALLBACK) private readonly callbackQ: Queue,
) {} ) {}
async enqueuePersonaRecompute(job: PersonaRecomputeJob): Promise<void> { async enqueuePersonaRecompute(job: PersonaRecomputeJob): Promise<void> {
...@@ -59,4 +61,15 @@ export class QueueProducer { ...@@ -59,4 +61,15 @@ export class QueueProducer {
`enqueue plan-asset-generate plan=${job.planId.slice(0, 8)} triggeredBy=${job.triggeredBy}`, `enqueue plan-asset-generate plan=${job.planId.slice(0, 8)} triggeredBy=${job.triggeredBy}`,
); );
} }
async enqueueExecutionCallback(job: ExecutionCallbackJob): Promise<void> {
// jobId = executionId(append-only 唯一)→ 同一 execution 不会重复入队
await this.callbackQ.add(job.executionId, job, {
jobId: `callback_${job.executionId}`,
...QueueDefaults[QueueName.EXECUTION_CALLBACK],
});
this.logger.debug(
`enqueue execution-callback execution=${job.executionId.slice(0, 8)} triggeredBy=${job.triggeredBy}`,
);
}
} }
...@@ -16,6 +16,7 @@ import { DailyHealthReportController } from './daily-health-report.controller'; ...@@ -16,6 +16,7 @@ import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
import { PlanRecomputeProcessor } from './processors/plan-recompute.processor'; import { PlanRecomputeProcessor } from './processors/plan-recompute.processor';
import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor'; import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor';
import { ExecutionCallbackProcessor } from './processors/execution-callback.processor';
/** /**
* Queues — BullMQ 三个队列的统一注册入口。 * Queues — BullMQ 三个队列的统一注册入口。
...@@ -54,6 +55,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro ...@@ -54,6 +55,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
{ name: QueueName.PERSONA_RECOMPUTE }, { name: QueueName.PERSONA_RECOMPUTE },
{ name: QueueName.PLAN_RECOMPUTE }, { name: QueueName.PLAN_RECOMPUTE },
{ name: QueueName.PLAN_ASSET_GENERATE }, { name: QueueName.PLAN_ASSET_GENERATE },
{ name: QueueName.EXECUTION_CALLBACK },
), ),
PersonaModule, PersonaModule,
PlanModule, PlanModule,
...@@ -70,6 +72,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro ...@@ -70,6 +72,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
ExecutionCallbackProcessor,
], ],
exports: [BullModule, QueueProducer, StaleScanService, SyncIncrementalSchedulerService], exports: [BullModule, QueueProducer, StaleScanService, SyncIncrementalSchedulerService],
}) })
......
...@@ -125,6 +125,60 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh: ...@@ -125,6 +125,60 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh:
} }
}; };
const onAddCallbackSecret = async () => {
if (
!(await confirm({
title: '添加新 Callback Secret?',
description: (
<>
新 secret 前插为 <code>current</code>,PAC 之后用它给回执签名;旧 secret 进 grace period。
<br />
等宿主切完再来"删除"旧 secret。
</>
),
confirmText: '生成新 secret',
}))
)
return;
setBusy(true);
try {
const r = await hostApi.addCallbackSecret();
setRevealedSecret({
title: `Callback Secret 已添加(共 ${r.totalKeys} 条)`,
description: '把下面的明文 secret 给宿主用于回执验签;旧的留作 grace,切换后删除。',
secret: r.callbackSecret,
});
onRefresh();
} catch (e) {
toast.error('添加失败', { description: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const onRemoveCallbackSecret = async (suffix: string) => {
if (
!(await confirm({
title: `删除 Callback Secret ...${suffix}?`,
description: '宿主侧若仍用此 secret 验签,PAC 用新 key 签的回执会被拒。确认宿主已切换。',
confirmText: '删除',
danger: true,
}))
)
return;
setBusy(true);
try {
await hostApi.removeCallbackSecret(suffix);
toast.success('已删除', { description: `...${suffix}` });
onRefresh();
} catch (e) {
toast.error('删除失败', { description: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const onToggleActive = async () => { const onToggleActive = async () => {
const goingActive = !data.active; const goingActive = !data.active;
const verb = goingActive ? '激活' : '停用'; const verb = goingActive ? '激活' : '停用';
...@@ -260,6 +314,19 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh: ...@@ -260,6 +314,19 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh:
</ul> </ul>
)} )}
</div> </div>
{/* 回执:callbackUrl + callback secrets(执行结果回调,PAC → 宿主) */}
<CallbackConfig
data={data}
busy={busy}
onAdd={onAddCallbackSecret}
onRemove={onRemoveCallbackSecret}
onSavedUrl={() => {
onRefresh();
toast.success('回执地址已保存');
}}
onError={(msg) => toast.error('保存失败', { description: msg })}
/>
</CardContent> </CardContent>
</Card> </Card>
...@@ -380,6 +447,98 @@ function StatsCard({ data }: { data: HostDetail }) { ...@@ -380,6 +447,98 @@ function StatsCard({ data }: { data: HostDetail }) {
} }
function CallbackConfig({
data,
busy,
onAdd,
onRemove,
onSavedUrl,
onError,
}: {
data: HostDetail;
busy: boolean;
onAdd: () => void;
onRemove: (suffix: string) => void;
onSavedUrl: () => void;
onError: (msg: string) => void;
}) {
const [draftUrl, setDraftUrl] = useState<string>(data.callbackUrl ?? '');
const [saving, setSaving] = useState(false);
const dirty = draftUrl.trim() !== (data.callbackUrl ?? '');
const saveUrl = async () => {
setSaving(true);
try {
// 空串 → null(关闭回执)
await hostApi.update({ callbackUrl: draftUrl.trim() === '' ? null : draftUrl.trim() });
onSavedUrl();
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
return (
<div className="surface-inset p-3">
<div className="text-sm font-medium">执行结果回执(PAC → 宿主)</div>
<div className="mt-1 text-xs text-muted-foreground">
客服提交召回结果后 PAC POST 回该地址;留空 = 不启用。密钥与 push 相互独立。
</div>
{/* callbackUrl */}
<div className="mt-3 flex items-center gap-2">
<Input
compact
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
placeholder="https://host.example.com/pac/callback"
className="font-mono"
/>
<Button size="sm" onClick={saveUrl} disabled={!dirty || saving || busy}>
{saving ? '保存中…' : '保存'}
</Button>
</div>
{/* callback secrets */}
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{data.callbackSecrets.length === 0
? '尚无回执密钥 —— 添加一条并配好地址后启用'
: `共 ${data.callbackSecrets.length} 条,索引 0 = current(签名用),其余 grace`}
</div>
<Button variant="outline" size="sm" onClick={onAdd} disabled={busy}>
<Plus className="h-4 w-4" /> Add new
</Button>
</div>
{data.callbackSecrets.length > 0 && (
<ul className="mt-2 space-y-1.5">
{data.callbackSecrets.map((s) => (
<li
key={s.suffix}
className="flex items-center justify-between rounded-md row-quiet px-3 py-1.5 text-xs"
>
<div className="font-mono">...{s.suffix}</div>
<div className="flex items-center gap-2">
<Badge variant={s.role === 'current' ? 'default' : 'outline'}>{s.role}</Badge>
<Button
variant="ghost"
size="sm"
disabled={busy || data.callbackSecrets.length === 1}
onClick={() => onRemove(s.suffix)}
title={data.callbackSecrets.length === 1 ? '不能删除唯一一条' : '删除'}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function ActionUrlsCard({ function ActionUrlsCard({
urls, urls,
busy, busy,
......
...@@ -5,6 +5,7 @@ import type { ...@@ -5,6 +5,7 @@ import type {
HostStats, HostStats,
HostSummary, HostSummary,
RotatePushSecretResponse, RotatePushSecretResponse,
RotateCallbackSecretResponse,
RotateSecretResponse, RotateSecretResponse,
UpdateHostRequest, UpdateHostRequest,
} from '@pac/types'; } from '@pac/types';
...@@ -21,4 +22,10 @@ export const hostApi = { ...@@ -21,4 +22,10 @@ export const hostApi = {
addPushSecret: () => api.post<RotatePushSecretResponse>(`${BASE}/push-secrets`), addPushSecret: () => api.post<RotatePushSecretResponse>(`${BASE}/push-secrets`),
removePushSecret: (suffix: string) => removePushSecret: (suffix: string) =>
api.delete<{ totalKeysRemaining: number }>(`${BASE}/push-secrets/${encodeURIComponent(suffix)}`), api.delete<{ totalKeysRemaining: number }>(`${BASE}/push-secrets/${encodeURIComponent(suffix)}`),
addCallbackSecret: () => api.post<RotateCallbackSecretResponse>(`${BASE}/callback-secrets`),
removeCallbackSecret: (suffix: string) =>
api.delete<{ totalKeysRemaining: number }>(
`${BASE}/callback-secrets/${encodeURIComponent(suffix)}`,
),
}; };
...@@ -15,16 +15,17 @@ export function useHostAdmin() { ...@@ -15,16 +15,17 @@ export function useHostAdmin() {
const [state, setState] = useState<LoadState>({ status: 'idle' }); const [state, setState] = useState<LoadState>({ status: 'idle' });
const fetch = useCallback(async () => { const fetch = useCallback(async () => {
setState({ status: 'loading' }); // 已有数据时**不切 loading**:否则父组件会把整页换成"加载中"占位、卸载 HostAdminApp,
// 连带把"仅显示一次"的明文 secret 弹窗刷掉。刷新走 stale-while-revalidate,旧数据留屏。
setState((prev) => (prev.status === 'ready' ? prev : { status: 'loading' }));
try { try {
const data = await hostApi.getSelf(); const data = await hostApi.getSelf();
setState({ status: 'ready', data }); setState({ status: 'ready', data });
} catch (err) { } catch (err) {
if (err instanceof ApiError) { const message = err instanceof Error ? err.message : String(err);
setState({ status: 'error', message: err.message, code: err.code }); const code = err instanceof ApiError ? err.code : undefined;
} else { // 刷新失败若已有数据 → 保留旧数据不白屏;仅首屏(无数据)才进错误态。
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); setState((prev) => (prev.status === 'ready' ? prev : { status: 'error', message, code }));
}
} }
}, []); }, []);
......
...@@ -118,6 +118,8 @@ export const UpdateHostRequestSchema = z ...@@ -118,6 +118,8 @@ export const UpdateHostRequestSchema = z
pullConfig: PullConfigSchema.nullable().optional(), pullConfig: PullConfigSchema.nullable().optional(),
actionUrls: ActionUrlsConfigSchema.optional(), actionUrls: ActionUrlsConfigSchema.optional(),
active: z.boolean().optional(), active: z.boolean().optional(),
/// 执行结果回执地址;传空串 / null = 关闭回执
callbackUrl: z.string().url().nullable().optional(),
}) })
.refine((v) => Object.keys(v).length > 0, 'At least one field is required'); .refine((v) => Object.keys(v).length > 0, 'At least one field is required');
export type UpdateHostRequest = z.infer<typeof UpdateHostRequestSchema>; export type UpdateHostRequest = z.infer<typeof UpdateHostRequestSchema>;
...@@ -135,6 +137,13 @@ export const RotatePushSecretResponseSchema = z.object({ ...@@ -135,6 +137,13 @@ export const RotatePushSecretResponseSchema = z.object({
}); });
export type RotatePushSecretResponse = z.infer<typeof RotatePushSecretResponseSchema>; export type RotatePushSecretResponse = z.infer<typeof RotatePushSecretResponseSchema>;
/// 回执密钥新增(语义同 push:数组前插,索引 0 = current 用于签名)
export const RotateCallbackSecretResponseSchema = z.object({
callbackSecret: z.string().describe('New plaintext callback HMAC secret added to front of array'),
totalKeys: z.number().int().min(1).describe('Total keys in rotation after this add'),
});
export type RotateCallbackSecretResponse = z.infer<typeof RotateCallbackSecretResponseSchema>;
// ============================================================= // =============================================================
// Host detail(给宿主自己的 admin 页用 — 比 Summary 多 pullConfig 内容 + push secret meta) // Host detail(给宿主自己的 admin 页用 — 比 Summary 多 pullConfig 内容 + push secret meta)
// ============================================================= // =============================================================
...@@ -201,6 +210,8 @@ export const HostDetailSchema = z.object({ ...@@ -201,6 +210,8 @@ export const HostDetailSchema = z.object({
// 凭据 mask 视图 // 凭据 mask 视图
appSecret: MaskedSecretSchema, appSecret: MaskedSecretSchema,
pushSecrets: z.array(PushSecretEntrySchema), pushSecrets: z.array(PushSecretEntrySchema),
/// 回执密钥列表(结构同 push;索引 0 = current 用于签名)
callbackSecrets: z.array(PushSecretEntrySchema),
// Pull 配置(可编辑) // Pull 配置(可编辑)
pullConfig: PullConfigSchema.nullable(), pullConfig: PullConfigSchema.nullable(),
...@@ -208,6 +219,9 @@ export const HostDetailSchema = z.object({ ...@@ -208,6 +219,9 @@ export const HostDetailSchema = z.object({
// Action URLs(可编辑) // Action URLs(可编辑)
actionUrls: ActionUrlsConfigSchema, actionUrls: ActionUrlsConfigSchema,
/// 执行结果回执地址(可编辑);null = 未启用回执
callbackUrl: z.string().nullable(),
// 运行状态 // 运行状态
stats: HostStatsSchema, stats: HostStatsSchema,
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment