@h-ai/reach 1.0.0-beta.2 → 1.0.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +189 -97
- package/README.ja.md +188 -96
- package/README.md +181 -89
- package/dist/index.d.ts +57 -3
- package/dist/index.js +664 -301
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -4,30 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
[框架首页](../../README.md) · 相关模块:[@h-ai/cache](../cache/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/reldb](../reldb/README.md)
|
|
6
6
|
|
|
7
|
-
## 简介
|
|
8
|
-
|
|
9
7
|
用户触达模块,支持同时注册多个 Provider(邮件、短信、API 回调等),通过模板绑定 Provider 实现按场景路由发送。内置免打扰(DND)机制,支持通过配置文件定义模板。
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
## 核心功能清单
|
|
14
|
-
|
|
15
|
-
| Provider | 渠道 | 说明 |
|
|
16
|
-
| ------------ | ---- | ------------------------------------------- |
|
|
17
|
-
| `console` | 通用 | 控制台输出(开发/测试用) |
|
|
18
|
-
| `smtp` | 邮件 | SMTP 协议发送邮件(需安装 nodemailer) |
|
|
19
|
-
| `aliyun-sms` | 短信 | 阿里云短信服务(直接调用 HTTP API,无 SDK) |
|
|
20
|
-
| `api` | 通用 | HTTP API 回调(通用 webhook) |
|
|
21
|
-
|
|
22
|
-
## 使用入门
|
|
9
|
+
## 快速开始
|
|
23
10
|
|
|
24
11
|
### 依赖
|
|
25
12
|
|
|
26
|
-
- `@h-ai/reldb` —
|
|
13
|
+
- `@h-ai/reldb` — 数据库(发送日志与模板持久化);配置非空 `templates` 或启用 DND delay 时必须先初始化,模板保存或必需表准备失败时 `reach.init()` 返回 `CONFIG_ERROR` 并关闭候选 Provider。不使用模板/延迟队列的即时发送可选
|
|
27
14
|
- `@h-ai/cache` — 缓存(DND delay 策略分布式锁),可选;已初始化时自动启用分布式锁
|
|
28
15
|
|
|
29
|
-
### 快速开始
|
|
30
|
-
|
|
31
16
|
#### 安装
|
|
32
17
|
|
|
33
18
|
```bash
|
|
@@ -39,35 +24,174 @@ pnpm add @h-ai/reach
|
|
|
39
24
|
#### 多 Provider 初始化
|
|
40
25
|
|
|
41
26
|
```ts
|
|
27
|
+
import { core } from '@h-ai/core'
|
|
42
28
|
import { reach } from '@h-ai/reach'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
import { reldb } from '@h-ai/reldb'
|
|
30
|
+
|
|
31
|
+
const runtime = core.module.createRuntime([
|
|
32
|
+
{
|
|
33
|
+
name: 'reldb',
|
|
34
|
+
start: () => reldb.init({ type: 'sqlite', database: ':memory:' }),
|
|
35
|
+
close: () => reldb.close()
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'reach',
|
|
39
|
+
start: () => reach.init({
|
|
40
|
+
providers: [
|
|
41
|
+
{ name: 'local', type: 'console' }
|
|
42
|
+
],
|
|
43
|
+
templates: [
|
|
44
|
+
{
|
|
45
|
+
name: 'welcome',
|
|
46
|
+
provider: 'local',
|
|
47
|
+
subject: 'Welcome',
|
|
48
|
+
body: 'Hello {userName}'
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
close: () => reach.close()
|
|
53
|
+
},
|
|
54
|
+
])
|
|
55
|
+
const initialized = await runtime.start()
|
|
48
56
|
if (!initialized.success)
|
|
49
57
|
throw new Error(initialized.error.message)
|
|
58
|
+
|
|
59
|
+
let result: Awaited<ReturnType<typeof reach.send>>
|
|
50
60
|
try {
|
|
51
|
-
|
|
61
|
+
result = await reach.send({
|
|
52
62
|
to: 'user@example.com',
|
|
53
63
|
template: 'welcome',
|
|
54
|
-
vars: { userName: 'Alice' }
|
|
64
|
+
vars: { userName: 'Alice' }
|
|
55
65
|
})
|
|
56
|
-
if (!result.success)
|
|
57
|
-
throw new Error(result.error.message)
|
|
58
66
|
}
|
|
59
|
-
|
|
60
|
-
await
|
|
67
|
+
catch (error) {
|
|
68
|
+
const closed = await runtime.close()
|
|
69
|
+
throw new AggregateError(closed.success
|
|
70
|
+
? [error]
|
|
71
|
+
: [error, closed.error], 'Reach operation failed')
|
|
72
|
+
}
|
|
73
|
+
const closed = await runtime.close()
|
|
74
|
+
if (!closed.success) {
|
|
75
|
+
throw new AggregateError(result.success
|
|
76
|
+
? [closed.error]
|
|
77
|
+
: [result.error, closed.error], 'Reach cleanup failed')
|
|
61
78
|
}
|
|
79
|
+
if (!result.success)
|
|
80
|
+
throw new Error(result.error.message)
|
|
62
81
|
```
|
|
63
82
|
|
|
64
83
|
以上示例使用开发 Console Provider,只记录发送元数据,不实际发邮件或短信。需要真实投递时,按下方配置 SMTP、短信或 API Provider;数据库持久化和共享缓存按需初始化。
|
|
65
84
|
|
|
66
|
-
`reach.config` 返回的是**脱敏后的配置快照**:`api.url` / `endpoint`
|
|
85
|
+
`reach.config` 返回的是**脱敏后的配置快照**:`api.url` / `endpoint` 的内嵌凭证、所有自定义请求头值、SMTP `pass` 和短信密钥都会隐藏。网关正文不进入快照;部署向导把 API 请求头整体保存到私有环境文件,不依赖头名猜测认证信息。
|
|
86
|
+
|
|
87
|
+
Provider 失败保留完整模块错误定义(含 HTTP 状态),SMTP/短信原始响应不进入返回消息或 cause;前端使用本地化提示,服务端日志保留诊断上下文。
|
|
88
|
+
|
|
89
|
+
一次模板发送使用同一个模板快照渲染主题、正文并选择默认 Provider,避免并发编辑时混用不同版本。显式 `provider` 仍可覆盖模板渠道。
|
|
90
|
+
|
|
91
|
+
## 功能
|
|
92
|
+
|
|
93
|
+
模板发送只需 `template`、`to` 和 `vars`,Provider 默认来自模板;显式 `provider` 仍可覆盖。无模板的直接发送必须提供 Provider,缺少时返回失败 HaiResult。
|
|
94
|
+
|
|
95
|
+
### 功能输入输出
|
|
96
|
+
|
|
97
|
+
以下片段依赖本节完整初始化流程。示例中的业务值由宿主校验和授权后传入;异步领域结果先判断 success,再读取 data。表中 void 表示操作已完成且没有返回数据,null 表示不存在;同步工厂/工具直接返回表中类型。
|
|
98
|
+
|
|
99
|
+
name 是模板名称,vars 是替换变量,template 是包含 provider/body/subject 的定义;queueId 使用持久化 UUID,恢复/裁定输入包含已查询版本与人工决定,不得由外部请求自报。
|
|
100
|
+
|
|
101
|
+
#### reach
|
|
102
|
+
|
|
103
|
+
通知输入 provider 或 template、接收方及变量,返回 sent/queued 等 SendResult;受理不等于最终送达,未知结果先核对。
|
|
104
|
+
|
|
105
|
+
| 入口 | 输入参数 | 成功结果 | 调用示例 |
|
|
106
|
+
| ------------ | -------- | ------------ | -------------------------------------------------------------------------------------------- |
|
|
107
|
+
| `reach.send` | message | `SendResult` | `await reach.send({ template: 'welcome', to: 'user@example.com', vars: { name: 'Alice' } })` |
|
|
108
|
+
|
|
109
|
+
#### reach.template
|
|
110
|
+
|
|
111
|
+
模板管理:name 得到模板,模板对象/数组保存或名称删除返回 void,list 返回模板数组,render 输入 name/vars 返回 body、subject 和 provider。
|
|
112
|
+
|
|
113
|
+
| 入口 | 输入参数 | 成功结果 | 调用示例 |
|
|
114
|
+
| -------------------------- | ---------- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
|
115
|
+
| `reach.template.resolve` | name | `ReachTemplate` | `await reach.template.resolve('welcome')` |
|
|
116
|
+
| `reach.template.save` | template | `void` | `await reach.template.save({ name: 'welcome', provider: 'mail', body: 'Hello {{name}}', subject: 'Welcome' })` |
|
|
117
|
+
| `reach.template.saveBatch` | templates | `void` | `await reach.template.saveBatch([{ name: 'welcome', provider: 'mail', body: 'Hello {{name}}' }])` |
|
|
118
|
+
| `reach.template.remove` | name | `void` | `await reach.template.remove('welcome')` |
|
|
119
|
+
| `reach.template.list` | 无参数 | `ReachTemplate[]` | `await reach.template.list()` |
|
|
120
|
+
| `reach.template.render` | name, vars | `RenderedTemplate` | `await reach.template.render('welcome', { name: 'Alice' })` |
|
|
121
|
+
|
|
122
|
+
#### reach.queue
|
|
123
|
+
|
|
124
|
+
运维按 queueId 查询或筛选状态列表;get 返回项/null,list 返回数组。恢复 dead 需期望版本/策略,unknown 需人工证据裁定,成功 void;不能用重试抹去未知副作用。
|
|
125
|
+
|
|
126
|
+
| 入口 | 输入参数 | 成功结果 | 调用示例 |
|
|
127
|
+
| ---------------------------- | ------------------- | ------------------------- | ------------------------------------------------------------- |
|
|
128
|
+
| `reach.queue.get` | queueId | `ReachQueueEntry \| null` | `await reach.queue.get(queueId)` |
|
|
129
|
+
| `reach.queue.list` | options | `ReachQueueEntry[]` | `await reach.queue.list({ status: 'dead', limit: 20 })` |
|
|
130
|
+
| `reach.queue.requeueDead` | queueId, options | `void` | `await reach.queue.requeueDead(queueId, requeueDecision)` |
|
|
131
|
+
| `reach.queue.resolveUnknown` | queueId, resolution | `void` | `await reach.queue.resolveUnknown(queueId, verifiedDecision)` |
|
|
132
|
+
|
|
133
|
+
### 分布式锁
|
|
134
|
+
|
|
135
|
+
缓存锁只减少重复扫描,300 秒后可能有多个节点同时扫描;正确性由数据库投递状态保证。每轮最多逐条领取 100 条,实际发送前原子校验有效 owner 并写入 `sending`,在途每隔租约的三分之一续租。过期 owner 不能复活租约、开始新投递或提交结果。DND 时段外每分钟继续恢复遗留队列。
|
|
136
|
+
|
|
137
|
+
- 状态为 `pending → processing → sending → sent`;`processing` 过期可安全重领。
|
|
138
|
+
- API 只有配置 `idempotent: true` 才允许重领在途消息。这是对接收端能力的声明,不会凭空实现幂等。每次请求的 `Idempotency-Key` 请求头和 `idempotencyKey` 正文字段均固定为持久化 `queueId`;接收端必须把去重记录和副作用原子提交。去重记录必须覆盖所有迟到请求,包括暂停后恢复的旧节点;无法界定最长迟到时间时不能过期删除。各节点同名 Provider 必须连接同一目标和幂等存储。
|
|
139
|
+
- SMTP、阿里云短信、默认 API 没有外部幂等保证。已经进入 `sending` 后的失败、异常或租约丢失按未知结果处理,过期后转为 `unknown`,新消费者不会自动重发。未连接 Provider 按持久策略退避;发送前解析失败直接进入 dead。Console 仅输出开发元数据,不产生真实投递。
|
|
140
|
+
- 续租、数据库 owner 条件和取消信号都不能撤销已发出的邮件、短信或 HTTP 副作用;模块不提供通用 exactly-once 保证。
|
|
141
|
+
|
|
142
|
+
### 未知结果人工核对
|
|
143
|
+
|
|
144
|
+
可信服务端管理流程使用 `reach.queue.get(queueId)` 查询 `pending / processing / sending / unknown / sent / discarded / dead`;结果不含正文或收件人。出现 unknown 时先停止或隔离旧 worker,按 queueId、Provider 日志和第三方投递记录核对,再调用以下裁定接口。接口校验 UUID 和状态,只能把 unknown 条件更新为 sent 或 discarded,并发裁定仅一个成功;不会重新发送。discarded 只终止后续处理,不能撤销已经开始的请求。如需补发,须先确认旧请求不可能继续生效,再显式创建新消息。
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const state = await reach.queue.get(queueId)
|
|
148
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
149
|
+
if (!resolved.success)
|
|
150
|
+
throw new Error(resolved.error.message)
|
|
151
|
+
```
|
|
67
152
|
|
|
68
|
-
###
|
|
153
|
+
### 死信查询与恢复
|
|
69
154
|
|
|
70
|
-
|
|
155
|
+
可信服务端可用 `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` 查询死信,也可查询 `unknown`;每页最多 100 条,offset 上限 1000000。get/list 返回状态、attemptCount、maxAttempts、nextAttemptAt、lastFailure 和 updatedAt,不含正文、收件人及 owner。修复 Provider 配置或消息根因后,查询最新 dead 版本再恢复:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const state = await reach.queue.get(queueId)
|
|
159
|
+
if (!state.success)
|
|
160
|
+
throw new Error(state.error.message)
|
|
161
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
162
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
163
|
+
if (!restored.success)
|
|
164
|
+
throw new Error(restored.error.message)
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
恢复仅条件更新同一版本的 dead;并发/过时请求失败,unknown 无法恢复。它重置领取次数并保留 queueId、原退避策略及接收端幂等要求。dead 仅表示停止自动处理,不证明外部副作用未发生;曾开始的幂等 API 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
|
|
169
|
+
|
|
170
|
+
### 生产共享状态
|
|
171
|
+
|
|
172
|
+
生产拒绝 console Provider,须配置真实 SMTP/短信/API 服务。console 的日志成功不是外部投递成功;非幂等服务的未知结果必须核对,不能自动重发证明 exactly-once。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化发布成功前校验;仅 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试可豁免。 [状态、并发和恢复边界](../../.agents/instructions/distributed-state.md).
|
|
173
|
+
|
|
174
|
+
### 凭据通知与持久化准备
|
|
175
|
+
|
|
176
|
+
发送密码、OTP 或重置令牌时使用 `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })`。expiresAt 是 Unix 毫秒,稳定操作键不可包含凭据。必须有共享发送账本:只持久化无载荷回执/状态,接收方脱敏,不存正文、变量或低熵凭据摘要,也不进入延迟队列。发送成功返回 receiptId;DND/过期不投递,异常或不明状态不自动重投。明确启用的开发控制台仍可显示业务允许的通知,持久采集观察器被隔离;生产 Console Provider 限制不变。普通通知可用 deferPolicy: reject 阻止二次排队。
|
|
177
|
+
|
|
178
|
+
`migrateReach({ dryRun: true })` 预检持久结构,仓储初始化执行支持的准备步骤;失败时停止初始化。执行前备份并停止冲突写入,保留发送账本,不把结构准备作为重新发送凭据的理由。
|
|
179
|
+
|
|
180
|
+
### API 回执与目标边界
|
|
181
|
+
|
|
182
|
+
空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
|
|
183
|
+
|
|
184
|
+
API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
|
|
185
|
+
|
|
186
|
+
PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
|
|
187
|
+
|
|
188
|
+
## 配置
|
|
189
|
+
|
|
190
|
+
生命周期:init 输入后端/业务配置,成功后才能使用;close 在释放借用依赖前等待完成。返回值以快速开始示例和公开签名为准。
|
|
191
|
+
|
|
192
|
+
Provider 名称必须唯一,重复配置在连接前返回 CONFIG_ERROR。`close()` 等待在途初始化并回收其 Provider;关闭返回后不会迟到发布模块或定时器。
|
|
193
|
+
|
|
194
|
+
### Console(开发/测试)
|
|
71
195
|
|
|
72
196
|
```ts
|
|
73
197
|
const config = { name: 'dev', type: 'console' }
|
|
@@ -95,7 +219,24 @@ const config = { name: 'dev', type: 'console' }
|
|
|
95
219
|
| `accessKeyId` | `string` | ✅ | — | AccessKey ID |
|
|
96
220
|
| `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
|
|
97
221
|
| `signName` | `string` | ✅ | — | 短信签名 |
|
|
98
|
-
| `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` |
|
|
222
|
+
| `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | HTTPS GET 直连端点 |
|
|
223
|
+
| `gateway` | `object` | — | — | 可选的隔离网关 |
|
|
224
|
+
|
|
225
|
+
在 `aliyun-sms` Provider 下添加 `gateway` 后,Reach 向完整 `url` 发送 POST。阿里云签名使用 `POST`、RPC 路径 `/` 和查询参数。网关路径、请求头和请求体仅用于网关这一跳。
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
gateway:
|
|
229
|
+
url: https://sms-gateway.example.com/dx
|
|
230
|
+
headers:
|
|
231
|
+
X-Gateway-Client: delivery
|
|
232
|
+
body: {}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`headers` 接受字符串键值,`body` 可为 JSON 对象、数组或原始文本。省略 `body` 时发送无正文请求。对象和数组默认为 `application/json`,文本默认为 `text/plain; charset=utf-8`,可以用 `Content-Type` 覆盖。受信内网使用 HTTP 时须显式设 `allowInsecureHttp: true`。HTTP 下签名查询串、手机号及模板变量是明文,网关和代理的访问日志应对查询串脱敏。
|
|
236
|
+
|
|
237
|
+
在服务端通过 `reach.init(config, { gatewayHooks: { sms: buildGatewayFields } })` 绑定同步或异步函数,其中 `sms` 是启用网关的 Provider 名称。函数接收 `{ method, url, body, signal }`,可返回 `{ headers, body }`。`body` 入参是已序列化的静态正文。函数返回的头按名称忽略大小写覆盖静态头,返回的正文整段替换。省略字段时使用静态配置。`Host`、`Content-Length` 等传输头由客户端管理。函数接收的已签名 URL 仅供服务端构造请求,`reach.config` 的网关快照仅展示地址和 HTTP 开关。
|
|
238
|
+
|
|
239
|
+
函数和 HTTP 请求共用 10 秒期限,禁止重定向。HTTP 2xx 且阿里云返回 `Code: OK` 表示受理,`BizId` 可用于查询送达结果。发送结果不明时按原投递策略处理,网关分支不会自动回退直连。
|
|
99
240
|
|
|
100
241
|
#### API 回调
|
|
101
242
|
|
|
@@ -132,7 +273,7 @@ if (result.data.deferred) {
|
|
|
132
273
|
}
|
|
133
274
|
```
|
|
134
275
|
|
|
135
|
-
队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date
|
|
276
|
+
队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;结构准备由模块维护,见下文持久化准备。
|
|
136
277
|
|
|
137
278
|
#### 可靠队列重试
|
|
138
279
|
|
|
@@ -146,7 +287,13 @@ if (result.data.deferred) {
|
|
|
146
287
|
|
|
147
288
|
每次领取在数据库原子递增 `attemptCount`。暂时失败在 `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 之后才有资格再次领取;DND 外每分钟扫描,因此实际发送可能更晚。一轮最多处理 100 条,并固定本轮资格时间;失败的消息在该轮不会重新入选。未连接/缺失 Provider、网络错误、HTTP 408/425/429/5xx 可以有限重试;持久化载荷解析失败、幂等 API 的其他 HTTP 4xx 直接进入 `dead`。超出次数或最后一次领取后崩溃也进入 `dead`。非幂等在途请求仍优先进入 `unknown`,不因剩余预算而重发。
|
|
148
289
|
|
|
149
|
-
|
|
290
|
+
配置由宿主加载和校验,再传入本模块;私密值放服务端私有环境,不传到浏览器。按实际使用场景逐项选择后端、容量、期限和日志策略,修改后重新初始化并核验成功结果。
|
|
291
|
+
|
|
292
|
+
[逐项配置说明、适用场景和示例](../../docs/usage/configuration/operations.md).
|
|
293
|
+
|
|
294
|
+
## 错误处理
|
|
295
|
+
|
|
296
|
+
领域操作先判断 `result.success`,失败按本模块错误定义与安全提示处理,服务端保留错误码、关联编号和脱敏原因。同步工具、工厂、流迭代及生命周期按各自签名处理,不把 Promise 完成视为业务成功。具体失败与恢复边界见对应功能。
|
|
150
297
|
|
|
151
298
|
所有操作返回 `HaiResult<T>`,错误码定义在 `HaiReachError`:
|
|
152
299
|
|
|
@@ -170,61 +317,6 @@ if (!result.success) {
|
|
|
170
317
|
}
|
|
171
318
|
```
|
|
172
319
|
|
|
173
|
-
### 分布式锁
|
|
174
|
-
|
|
175
|
-
缓存锁只减少重复扫描,300 秒后可能有多个节点同时扫描;正确性由数据库投递状态保证。每轮最多逐条领取 100 条,实际发送前原子校验有效 owner 并写入 `sending`,在途每隔租约的三分之一续租。过期 owner 不能复活租约、开始新投递或提交结果。DND 时段外每分钟继续恢复遗留队列。
|
|
176
|
-
|
|
177
|
-
- 状态为 `pending → processing → sending → sent`;`processing` 过期可安全重领。
|
|
178
|
-
- API 只有配置 `idempotent: true` 才允许重领在途消息。这是对接收端能力的声明,不会凭空实现幂等。每次请求的 `Idempotency-Key` 请求头和 `idempotencyKey` 正文字段均固定为持久化 `queueId`;接收端必须把去重记录和副作用原子提交。去重记录必须覆盖所有迟到请求,包括暂停后恢复的旧节点;无法界定最长迟到时间时不能过期删除。各节点同名 Provider 必须连接同一目标和幂等存储。
|
|
179
|
-
- SMTP、阿里云短信、默认 API 没有外部幂等保证。已经进入 `sending` 后的失败、异常或租约丢失按未知结果处理,过期后转为 `unknown`,新消费者不会自动重发。未连接 Provider 按持久策略退避;发送前解析失败直接进入 dead。Console 仅输出开发元数据,不产生真实投递。
|
|
180
|
-
- 续租、数据库 owner 条件和取消信号都不能撤销已发出的邮件、短信或 HTTP 副作用;模块不提供通用 exactly-once 保证。
|
|
181
|
-
|
|
182
|
-
### 未知结果人工核对
|
|
183
|
-
|
|
184
|
-
可信服务端管理流程使用 `reach.queue.get(queueId)` 查询 `pending / processing / sending / unknown / sent / discarded / dead`;结果不含正文或收件人。出现 unknown 时先停止或隔离旧 worker,按 queueId、Provider 日志和第三方投递记录核对,再调用以下裁定接口。接口校验 UUID 和状态,只能把 unknown 条件更新为 sent 或 discarded,并发裁定仅一个成功;不会重新发送。discarded 只终止后续处理,不能撤销已经开始的请求。如需补发,须先确认旧请求不可能继续生效,再显式创建新消息。
|
|
185
|
-
|
|
186
|
-
```ts
|
|
187
|
-
const state = await reach.queue.get(queueId)
|
|
188
|
-
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
-
if (!resolved.success)
|
|
190
|
-
throw new Error(resolved.error.message)
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
### 死信查询与恢复
|
|
194
|
-
|
|
195
|
-
可信服务端可用 `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` 查询死信,也可查询 `unknown`;每页最多 100 条,offset 上限 1000000。get/list 返回状态、attemptCount、maxAttempts、nextAttemptAt、lastFailure 和 updatedAt,不含正文、收件人及 owner。修复 Provider 配置或消息根因后,查询最新 dead 版本再恢复:
|
|
196
|
-
|
|
197
|
-
```ts
|
|
198
|
-
const state = await reach.queue.get(queueId)
|
|
199
|
-
if (!state.success)
|
|
200
|
-
throw new Error(state.error.message)
|
|
201
|
-
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
-
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
-
if (!restored.success)
|
|
204
|
-
throw new Error(restored.error.message)
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
恢复仅条件更新同一版本的 dead;并发/过时请求失败,unknown 无法恢复。它重置领取次数并保留 queueId、原退避策略及接收端幂等要求。dead 仅表示停止自动处理,不证明外部副作用未发生;曾开始的幂等 API 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
|
|
209
|
-
|
|
210
|
-
## 生产共享状态
|
|
211
|
-
|
|
212
|
-
生产拒绝 console Provider,须配置真实 SMTP/短信/API 服务。console 的日志成功不是外部投递成功;非幂等服务的未知结果必须核对,不能自动重发证明 exactly-once。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化发布成功前校验;仅 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试可豁免。 [状态、并发和恢复边界](../../.agents/instructions/distributed-state.md)。
|
|
213
|
-
|
|
214
|
-
## 凭据通知与旧库升级
|
|
215
|
-
|
|
216
|
-
发送密码、OTP 或重置令牌时使用 `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })`。expiresAt 是 Unix 毫秒,稳定操作键不可包含凭据。必须有共享发送账本:只持久化无载荷回执/状态,接收方脱敏,不存正文、变量或低熵凭据摘要,也不进入延迟队列。发送成功返回 receiptId;DND/过期不投递,异常或不明状态不自动重投。明确启用的开发控制台仍可显示业务允许的通知,持久采集观察器被隔离;生产 Console Provider 限制不变。普通通知可用 deferPolicy: reject 阻止二次排队。
|
|
217
|
-
|
|
218
|
-
`migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
|
|
219
|
-
|
|
220
|
-
## API 回执与目标边界
|
|
221
|
-
|
|
222
|
-
空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
|
|
223
|
-
|
|
224
|
-
API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
|
|
225
|
-
|
|
226
|
-
PostgreSQL/MySQL 队列的领取、续租和最终确认使用 SQL 执行时的数据库时钟;扫描截止也从数据库读取。节点时差及行锁等待跨过期限后不能续活旧 owner。本地 SQLite 只用于开发/测试,可控制测试时钟,不作为生产共享状态。
|
|
227
|
-
|
|
228
320
|
## 开发测试
|
|
229
321
|
|
|
230
322
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -59,6 +59,12 @@ declare const AliyunSmsProviderConfigSchema: z.ZodObject<{
|
|
|
59
59
|
accessKeySecret: z.ZodString;
|
|
60
60
|
signName: z.ZodString;
|
|
61
61
|
endpoint: z.ZodDefault<z.ZodString>;
|
|
62
|
+
gateway: z.ZodOptional<z.ZodObject<{
|
|
63
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
64
|
+
body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
|
|
65
|
+
url: z.ZodURL;
|
|
66
|
+
allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
|
|
67
|
+
}, z.core.$strict>>;
|
|
62
68
|
}, z.core.$strip>;
|
|
63
69
|
/**
|
|
64
70
|
* HTTP回调配置;idempotent默认false,只有接收端按稳定Idempotency-Key原子持久去重并覆盖旧请求迟到窗口才允许true。
|
|
@@ -115,6 +121,12 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
115
121
|
accessKeySecret: z.ZodString;
|
|
116
122
|
signName: z.ZodString;
|
|
117
123
|
endpoint: z.ZodDefault<z.ZodString>;
|
|
124
|
+
gateway: z.ZodOptional<z.ZodObject<{
|
|
125
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
126
|
+
body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
|
|
127
|
+
url: z.ZodURL;
|
|
128
|
+
allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
|
|
129
|
+
}, z.core.$strict>>;
|
|
118
130
|
}, z.core.$strip>, z.ZodObject<{
|
|
119
131
|
name: z.ZodString;
|
|
120
132
|
type: z.ZodLiteral<"api">;
|
|
@@ -344,6 +356,12 @@ declare const ReachConfigSchema: z.ZodObject<{
|
|
|
344
356
|
accessKeySecret: z.ZodString;
|
|
345
357
|
signName: z.ZodString;
|
|
346
358
|
endpoint: z.ZodDefault<z.ZodString>;
|
|
359
|
+
gateway: z.ZodOptional<z.ZodObject<{
|
|
360
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
361
|
+
body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
|
|
362
|
+
url: z.ZodURL;
|
|
363
|
+
allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
|
|
364
|
+
}, z.core.$strict>>;
|
|
347
365
|
}, z.core.$strip>, z.ZodObject<{
|
|
348
366
|
name: z.ZodString;
|
|
349
367
|
type: z.ZodLiteral<"api">;
|
|
@@ -440,6 +458,38 @@ declare const HaiReachError: {
|
|
|
440
458
|
* ```
|
|
441
459
|
*/
|
|
442
460
|
type ReachChannel = 'email' | 'sms' | 'api';
|
|
461
|
+
/**
|
|
462
|
+
* 发送前为网关提供请求头或替换请求体。URL 已包含签名和可能敏感的查询数据,函数必须在可信服务端运行。
|
|
463
|
+
* 返回 undefined 保留静态配置;字符串 body 按原样发送。signal 覆盖函数执行与 HTTP 请求的共同期限。
|
|
464
|
+
*
|
|
465
|
+
* @example
|
|
466
|
+
* ```ts
|
|
467
|
+
* import type { ReachGatewayHook } from '@h-ai/reach'
|
|
468
|
+
* const hook: ReachGatewayHook = ({ body, signal }) => {
|
|
469
|
+
* signal.throwIfAborted()
|
|
470
|
+
* return { headers: { 'X-Client': 'delivery' }, body: body ?? '{}' }
|
|
471
|
+
* }
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
type ReachGatewayHook = (request: Readonly<{
|
|
475
|
+
method: 'POST';
|
|
476
|
+
url: string;
|
|
477
|
+
body: string | undefined;
|
|
478
|
+
signal: AbortSignal;
|
|
479
|
+
}>) => Pick<NonNullable<AliyunSmsProviderConfig['gateway']>, 'headers' | 'body'> | void | Promise<Pick<NonNullable<AliyunSmsProviderConfig['gateway']>, 'headers' | 'body'> | void>;
|
|
480
|
+
/**
|
|
481
|
+
* 服务端运行时注入选项,与可序列化的配置分离。
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* ```ts
|
|
485
|
+
* import type { ReachInitOptions } from '@h-ai/reach'
|
|
486
|
+
* const options: ReachInitOptions = { gatewayHooks: { sms: () => ({ headers: { 'X-Client': 'delivery' } }) } }
|
|
487
|
+
* ```
|
|
488
|
+
*/
|
|
489
|
+
interface ReachInitOptions {
|
|
490
|
+
/** 以已配置网关的 Provider 名称绑定自定义函数。 */
|
|
491
|
+
gatewayHooks?: Readonly<Record<string, ReachGatewayHook>>;
|
|
492
|
+
}
|
|
443
493
|
/**
|
|
444
494
|
* 直接发送必须指定Provider名和内容;模板发送可由持久模板推导路由,vars做文本替换。
|
|
445
495
|
* 队列幂等键固定为持久queueId;直接调用的业务重试由应用提供稳定idempotencyKey并核验接收端契约。
|
|
@@ -1215,6 +1265,7 @@ interface ReachFunctions extends SendOperations {
|
|
|
1215
1265
|
/**
|
|
1216
1266
|
* 初始化触达模块(注册多个 Provider)
|
|
1217
1267
|
* @param config - 实际操作输入,按本方法约束校验。
|
|
1268
|
+
* @param options - 可选服务端运行时网关函数,按 Provider 名称绑定。
|
|
1218
1269
|
* @returns HaiResult;先检查success,条件提交失败不能伪装成功。
|
|
1219
1270
|
*
|
|
1220
1271
|
* @example
|
|
@@ -1244,7 +1295,7 @@ interface ReachFunctions extends SendOperations {
|
|
|
1244
1295
|
* }
|
|
1245
1296
|
* ```
|
|
1246
1297
|
*/
|
|
1247
|
-
init: (config: ReachConfigInput) => Promise<HaiResult<void>>;
|
|
1298
|
+
init: (config: ReachConfigInput, options?: ReachInitOptions) => Promise<HaiResult<void>>;
|
|
1248
1299
|
/** 当前脱敏后的配置快照(未初始化时为 null) */
|
|
1249
1300
|
readonly config: ReachConfig | null;
|
|
1250
1301
|
/** 是否已初始化 */
|
|
@@ -1314,7 +1365,9 @@ interface ReachFunctions extends SendOperations {
|
|
|
1314
1365
|
* }
|
|
1315
1366
|
* else { const providerMessageId = result.data.messageId }
|
|
1316
1367
|
* }
|
|
1317
|
-
* finally {
|
|
1368
|
+
* finally {
|
|
1369
|
+
* await reach.close()
|
|
1370
|
+
* }
|
|
1318
1371
|
* }
|
|
1319
1372
|
* finally {
|
|
1320
1373
|
* const closed = await reldb.close()
|
|
@@ -1336,4 +1389,5 @@ declare function migrateReach(options?: {
|
|
|
1336
1389
|
dryRun?: boolean;
|
|
1337
1390
|
}, db?: ReldbFunctions): Promise<_h_ai_core.HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
|
|
1338
1391
|
|
|
1339
|
-
export {
|
|
1392
|
+
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, ReachDeadRequeueSchema, ReachQueueConfigSchema, ReachQueueIdSchema, ReachQueueListSchema, ReachUnknownResolutionSchema, SmtpProviderConfigSchema, TemplateConfigSchema, migrateReach, reach };
|
|
1393
|
+
export type { AliyunSmsProviderConfig, ApiProviderConfig, ConsoleProviderConfig, DndConfig, ProviderConfig, ReachChannel, ReachConfig, ReachConfigInput, ReachDeadRequeue, ReachFunctions, ReachGatewayHook, ReachInitOptions, ReachMessage, ReachQueueConfig, ReachQueueEntry, ReachQueueFailure, ReachQueueListOptions, ReachQueueOperations, ReachQueueStatus, ReachTemplate, ReachTemplateRegistry, ReachUnknownResolution, RenderedTemplate, SendOperations, SendResult, SmtpProviderConfig, TemplateConfig };
|