@h-ai/reach 0.1.0-alpha.66 → 0.1.0-alpha.68

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.md CHANGED
@@ -4,16 +4,13 @@
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
+
7
9
  用户触达模块,支持同时注册多个 Provider(邮件、短信、API 回调等),通过模板绑定 Provider 实现按场景路由发送。内置免打扰(DND)机制,支持通过配置文件定义模板。
8
10
 
9
11
  模板发送只需 `template`、`to` 和 `vars`,Provider 默认来自模板;显式 `provider` 仍可覆盖。无模板的直接发送必须提供 Provider,缺少时返回失败 HaiResult。
10
12
 
11
- ## 依赖
12
-
13
- - `@h-ai/reldb` — 数据库(发送日志与模板持久化),可选;已初始化时自动启用持久化
14
- - `@h-ai/cache` — 缓存(DND delay 策略分布式锁),可选;已初始化时自动启用分布式锁
15
-
16
- ## 支持的 Provider
13
+ ## 核心功能清单
17
14
 
18
15
  | Provider | 渠道 | 说明 |
19
16
  | ------------ | ---- | ------------------------------------------- |
@@ -22,9 +19,16 @@
22
19
  | `aliyun-sms` | 短信 | 阿里云短信服务(直接调用 HTTP API,无 SDK) |
23
20
  | `api` | 通用 | HTTP API 回调(通用 webhook) |
24
21
 
25
- ## 快速开始
22
+ ## 使用入门
23
+
24
+ ### 依赖
26
25
 
27
- ### 安装
26
+ - `@h-ai/reldb` — 数据库(发送日志与模板持久化);启用 `dnd.enabled: true` 与 `strategy: delay` 时必须先初始化且队列表可用,否则 `reach.init()` 返回 `CONFIG_ERROR`。其他发送场景可选
27
+ - `@h-ai/cache` — 缓存(DND delay 策略分布式锁),可选;已初始化时自动启用分布式锁
28
+
29
+ ### 快速开始
30
+
31
+ #### 安装
28
32
 
29
33
  ```bash
30
34
  pnpm add @h-ai/reach
@@ -32,61 +36,44 @@ pnpm add @h-ai/reach
32
36
 
33
37
  邮件发送需安装 `nodemailer`。
34
38
 
35
- ### 多 Provider 初始化
39
+ #### 多 Provider 初始化
36
40
 
37
41
  ```ts
38
- import { cache } from '@h-ai/cache'
39
42
  import { reach } from '@h-ai/reach'
40
- import { reldb } from '@h-ai/reldb'
41
-
42
- // 1. 初始化可选依赖(按需)
43
- await reldb.init({ type: 'sqlite', database: './data.db' }) // 可选,启用发送日志与模板持久化
44
- await cache.init({ type: 'memory' }) // 可选,启用 DND delay 策略分布式锁
45
-
46
- // 2. 初始化触达模块(自动检测已初始化的 reldb/cache 单例)
47
- await reach.init({
48
- providers: [
49
- { name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com' },
50
- { name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '某某科技' },
51
- { name: 'webhook', type: 'api', url: 'https://api.example.com/notify' },
52
- ],
53
- templates: [
54
- { name: 'welcome_email', provider: 'email', subject: '欢迎 {userName}', body: '亲爱的 {userName},欢迎!' },
55
- { name: 'sms_code', provider: 'sms', body: '验证码: {code},{minutes} 分钟内有效。' },
56
- ],
57
- dnd: { enabled: true, strategy: 'delay', start: '22:00', end: '08:00' },
58
- })
59
-
60
- // 通过 provider 字段选择发送渠道
61
- await reach.send({
62
- to: 'user@example.com',
63
- template: 'welcome_email',
64
- vars: { userName: '张三' },
65
- })
66
43
 
67
- // 发送短信(通过 extra 传递 Provider 特有参数)
68
- await reach.send({
69
- provider: 'sms',
70
- to: '13800138000',
71
- extra: { templateCode: 'SMS_123456' },
72
- vars: { code: '123456' },
44
+ const initialized = await reach.init({
45
+ providers: [{ name: 'local', type: 'console' }],
46
+ templates: [{ name: 'welcome', provider: 'local', subject: 'Welcome', body: 'Hello {userName}' }],
73
47
  })
74
-
75
- // 关闭所有 Provider
76
- await reach.close()
48
+ if (!initialized.success)
49
+ throw new Error(initialized.error.message)
50
+ try {
51
+ const result = await reach.send({
52
+ to: 'user@example.com',
53
+ template: 'welcome',
54
+ vars: { userName: 'Alice' },
55
+ })
56
+ if (!result.success)
57
+ throw new Error(result.error.message)
58
+ }
59
+ finally {
60
+ await reach.close()
61
+ }
77
62
  ```
78
63
 
64
+ 以上示例使用开发 Console Provider,只记录发送元数据,不实际发邮件或短信。需要真实投递时,按下方配置 SMTP、短信或 API Provider;数据库持久化和共享缓存按需初始化。
65
+
79
66
  `reach.config` 返回的是**脱敏后的配置快照**:`api.url` / `endpoint` 的内嵌凭证,以及 `headers.Authorization`、SMTP `pass`、短信密钥等敏感值会自动隐藏。
80
67
 
81
- ## 配置
68
+ ### 配置
82
69
 
83
- ### Console(开发/测试)
70
+ #### Console(开发/测试)
84
71
 
85
72
  ```ts
86
73
  const config = { name: 'dev', type: 'console' }
87
74
  ```
88
75
 
89
- ### SMTP
76
+ #### SMTP
90
77
 
91
78
  | 字段 | 类型 | 必填 | 默认值 | 说明 |
92
79
  | -------- | --------- | ---- | ------ | --------------- |
@@ -99,7 +86,7 @@ const config = { name: 'dev', type: 'console' }
99
86
  | `pass` | `string` | — | — | 认证密码 |
100
87
  | `from` | `string` | ✅ | — | 发件人地址 |
101
88
 
102
- ### 阿里云短信
89
+ #### 阿里云短信
103
90
 
104
91
  | 字段 | 类型 | 必填 | 默认值 | 说明 |
105
92
  | ----------------- | -------- | ---- | ----------------------- | ------------------- |
@@ -110,20 +97,21 @@ const config = { name: 'dev', type: 'console' }
110
97
  | `signName` | `string` | ✅ | — | 短信签名 |
111
98
  | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API 端点 |
112
99
 
113
- ### API 回调
100
+ #### API 回调
114
101
 
115
- | 字段 | 类型 | 必填 | 默认值 | 说明 |
116
- | --------- | ------------------------ | ---- | ------- | ---------------- |
117
- | `name` | `string` | ✅ | — | Provider 名称 |
118
- | `type` | `string` | ✅ | — | 固定 `'api'` |
119
- | `url` | `string` | ✅ | — | HTTP(S) 回调 URL |
120
- | `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP 方法 |
121
- | `headers` | `Record<string, string>` | — | — | 自定义请求头 |
122
- | `timeout` | `number` | — | `10000` | 超时毫秒数 |
102
+ | 字段 | 类型 | 必填 | 默认值 | 说明 |
103
+ | ------------ | ------------------------ | ---- | ------- | -------------------------------------------- |
104
+ | `name` | `string` | ✅ | — | Provider 名称 |
105
+ | `type` | `string` | ✅ | — | 固定 `'api'` |
106
+ | `url` | `string` | ✅ | — | HTTP(S) 回调 URL |
107
+ | `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP 方法 |
108
+ | `headers` | `Record<string, string>` | — | — | 自定义请求头 |
109
+ | `timeout` | `number` | — | `10000` | 超时毫秒数 |
110
+ | `idempotent` | `boolean` | — | `false` | 接收端具有持久化、原子副作用去重能力时才启用 |
123
111
 
124
112
  API 回调地址属于服务端可信配置。日志只记录回调 origin,不记录 URL 凭据、查询参数、收件人或第三方失败响应体。若回调地址来自租户/用户输入,应用层必须配置域名 allowlist;企业部署还应通过出口代理或网络策略阻止访问内网元数据地址。
125
113
 
126
- ### DND(免打扰)
114
+ #### DND(免打扰)
127
115
 
128
116
  | 字段 | 类型 | 必填 | 默认值 | 说明 |
129
117
  | ---------- | ---------------------- | ---- | --------- | --------------------------------------- |
@@ -132,22 +120,48 @@ API 回调地址属于服务端可信配置。日志只记录回调 origin,不
132
120
  | `start` | `string` | — | `00:00` | 开始时间(HH:mm 格式) |
133
121
  | `end` | `string` | — | `00:00` | 结束时间(HH:mm 格式) |
134
122
 
135
- ## 错误处理
123
+ `delay` 仅在消息写入数据库并确认成功后返回 `HaiResult` 成功,结果为 `{ success: true, deferred: true, queueId }`;这表示已入队、尚未投递。`queueId` 是持久化回执 UUID,对应 `hai_reach_send_log.queue_id`,可查询该记录的 `status`(`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`)。缺少数据库、断连、插入失败均不会返回 deferred 成功,发送时返回 `SEND_FAILED`。节点重启后,只要连接同一数据库、配置同名 Provider,便可在 DND 时段外恢复投递。即时发送成功后的普通日志保存仍为可选,不会因日志失败把已投递的消息报告为失败。
124
+
125
+ ```ts
126
+ const result = await reach.send(message)
127
+ if (!result.success)
128
+ throw new Error(result.error.message)
129
+ if (result.data.deferred) {
130
+ // 保存 queueId 供后台跟踪;此时不能向用户显示“已送达”。
131
+ await savePendingReceipt(result.data.queueId)
132
+ }
133
+ ```
134
+
135
+ 队列的 `created_at`、`updated_at`、`locked_until`、`next_attempt_at` 统一保存 Unix 毫秒,列类型为 `BIGINT`(SQLite 映射为 64 位 `INTEGER`)。Reach 仓库将 PostgreSQL 等驱动返回的整数字符串归一化为安全整数 `number`;负数、非整数及超出 JS 安全整数范围的存储值返回 `SEND_FAILED`,不会静默舍入。模板仓库的 `TIMESTAMP` 创建/更新时间映射为 `Date`。部署时须同步表结构,包括次数、退避策略和失败分类列;框架不自动迁移旧表。
136
+
137
+ #### 可靠队列重试
138
+
139
+ `queue` 策略在入队时随每条消息持久保存;其它节点及重启后的配置变化不会重置已有消息预算。
140
+
141
+ | 配置 | 默认值 | 约束 |
142
+ | ----------------------- | --------- | ------------------------------- |
143
+ | `queue.maxAttempts` | `5` | 1–100,含首次领取和崩溃恢复 |
144
+ | `queue.retryDelayMs` | `60000` | 首次退避,1000–86400000 毫秒 |
145
+ | `queue.maxRetryDelayMs` | `3600000` | 同上范围,且不小于 retryDelayMs |
146
+
147
+ 每次领取在数据库原子递增 `attemptCount`。暂时失败在 `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 之后才有资格再次领取;DND 外每分钟扫描,因此实际发送可能更晚。一轮最多处理 100 条,并固定本轮资格时间;失败的消息在该轮不会重新入选。未连接/缺失 Provider、网络错误、HTTP 408/425/429/5xx 可以有限重试;持久化载荷解析失败、幂等 API 的其他 HTTP 4xx 直接进入 `dead`。超出次数或最后一次领取后崩溃也进入 `dead`。非幂等在途请求仍优先进入 `unknown`,不因剩余预算而重发。
148
+
149
+ ### 错误处理
136
150
 
137
151
  所有操作返回 `HaiResult<T>`,错误码定义在 `HaiReachError`:
138
152
 
139
- | 错误码 | code | 说明 |
140
- | -------------------------------------- | --------------- | --------------- |
141
- | `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败 |
142
- | `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板不存在 |
143
- | `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败 |
144
- | `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方 |
145
- | `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未注册 |
146
- | `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃 |
147
- | `HaiReachError.DND_DEFERRED` | `hai:reach:007` | 免打扰延时暂存 |
148
- | `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初始化 |
149
- | `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 不支持的类型 |
150
- | `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 配置错误 |
153
+ | 错误码 | code | 说明 |
154
+ | -------------------------------------- | --------------- | ---------------- |
155
+ | `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败 |
156
+ | `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板不存在 |
157
+ | `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败 |
158
+ | `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方 |
159
+ | `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未注册 |
160
+ | `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃 |
161
+ | `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初始化 |
162
+ | `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 不支持的类型 |
163
+ | `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 配置错误 |
164
+ | `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API 明确永久拒绝 |
151
165
 
152
166
  ```ts
153
167
  const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
@@ -156,21 +170,55 @@ if (!result.success) {
156
170
  }
157
171
  ```
158
172
 
159
- ## 分布式锁
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 可能仍有旧请求迟到,恢复前仍须确认接收端持久去重有效。不可修复的坏载荷应保留死信并人工排查,重复恢复不会修复内容。
160
209
 
161
- 多节点部署时,DND(delay 策略)的 pending 消息 flush 操作通过 `@h-ai/cache` 分布式锁保护,确保同一时刻只有一个节点执行 flush。
210
+ ## 生产共享状态
162
211
 
163
- - 锁基于 `cache.lock.acquire('reach:flush-pending')`,TTL 300 秒
164
- - flush 会按批 claim pending 记录;处理中的记录带有过期时间,节点异常退出后可由后续节点接管,降低重复发送风险
165
- - 若 `@h-ai/cache` 未初始化,分布式锁自动禁用,不影响单节点运行
166
- - 使用稳定的进程级 owner 标识,防止误释放他人锁
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)。
167
213
 
168
- ## 测试
214
+ ## 开发测试
169
215
 
170
216
  ```bash
171
217
  pnpm --filter @h-ai/reach test
172
218
  ```
173
219
 
174
- ## License
220
+ 跨库时间回归用例 `tests/reach-repository-send-log-databases.test.ts` 通过 Testcontainers 启动隔离 PostgreSQL 16.15 / MySQL 8.4.11,并在结束时清理容器;须提供可用的 Docker API。使用 Podman 时,将 `DOCKER_HOST` 指向其兼容 socket;rootless 环境可按 Testcontainers 要求设置 `TESTCONTAINERS_RYUK_DISABLED=true`。
221
+
222
+ ## 许可证
175
223
 
176
224
  Apache-2.0