@h-ai/cli 0.1.0-alpha.67 → 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.en.md +40 -4
- package/README.ja.md +40 -4
- package/README.md +40 -4
- package/dist/{src-2V6VP4I5.js → chunk-FI56GONY.js} +32 -3103
- package/dist/chunk-FI56GONY.js.map +1 -0
- package/dist/chunk-MBPB37FR.js +1348 -0
- package/dist/chunk-MBPB37FR.js.map +1 -0
- package/dist/{chunk-7DSURTJ4.js → chunk-ZFNBUF77.js} +327 -199
- package/dist/chunk-ZFNBUF77.js.map +1 -0
- package/dist/cli-config-templates-W2LLPMB6.js +5 -0
- package/dist/{cli-config-templates-6AHNUGBN.js.map → cli-config-templates-W2LLPMB6.js.map} +1 -1
- package/dist/{cli-skill-templates-FC3QRKDO.js → cli-skill-templates-US5CCAJ4.js} +3 -3
- package/dist/{cli-skill-templates-FC3QRKDO.js.map → cli-skill-templates-US5CCAJ4.js.map} +1 -1
- package/dist/index.js +920 -336
- package/dist/index.js.map +1 -1
- package/dist/metafile-esm.json +1 -0
- package/dist/src-DSJJXM2F.js +3849 -0
- package/dist/src-DSJJXM2F.js.map +1 -0
- package/package.json +9 -8
- package/templates/apps/admin/messages/ja-JP.json +38 -0
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +16 -6
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +3 -3
- package/templates/apps/api/apps/{{projectName}}-contract/src/index.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
- package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +5 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +0 -10
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +2 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +24 -11
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +8 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +68 -11
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/index.ts.hbs +10 -2
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +2 -2
- package/templates/apps/api/package.json.hbs +1 -1
- package/templates/apps/api/playwright.config.ts.hbs +15 -7
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +7 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/ja-JP.json.hbs +12 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/ja-JP.json.hbs +7 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +1 -1
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +19 -8
- package/templates/apps/fullstack/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +5 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +0 -6
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +2 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +9 -5
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +3 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +16 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +7 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +58 -7
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +9 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/ja-JP.json.hbs +105 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +5 -5
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/audit.svelte.hbs +7 -7
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/users.svelte.hbs +8 -8
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +11 -12
- package/templates/apps/fullstack/playwright.config.ts.hbs +15 -6
- package/templates/apps/h5/messages/ja-JP.json +40 -0
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +14 -3
- package/templates/apps/mobile-app/messages/ja-JP.json +36 -0
- package/templates/apps/mobile-app/src/App.svelte.hbs +13 -3
- package/templates/apps/mobile-app/src/lib/api.ts.hbs +173 -14
- package/templates/apps/website/messages/ja-JP.json +32 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +10 -2
- package/templates/base/e2e/app.spec.ts.hbs +13 -8
- package/templates/base/package.json.hbs +4 -1
- package/templates/base/playwright.config.ts +15 -4
- package/templates/base/src/hooks.server.ts.hbs +15 -6
- package/templates/base/src/lib/server/init.ts.hbs +139 -30
- package/templates/base/vite.config.ts.hbs +2 -2
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +11 -6
- package/templates/features/payment/routes/api/v1/payment/create/+server.ts +18 -25
- package/templates/features/payment/routes/api/v1/payment/notify/[provider]/+server.ts +4 -0
- package/templates/i18n/project.inlang/settings.json +1 -1
- package/templates/shared/e2e-env.ts +98 -0
- package/templates/shared/run-e2e.mjs +51 -12
- package/templates/skills/AGENTS.md +4 -0
- package/templates/skills/bridges/admin/AGENTS.md +5 -1
- package/templates/skills/bridges/api/AGENTS.md +2 -0
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +4 -0
- package/templates/skills/bridges/h5/AGENTS.md +5 -1
- package/templates/skills/bridges/mobile-app/AGENTS.md +5 -1
- package/templates/skills/bridges/website/AGENTS.md +5 -1
- package/templates/skills/hai-ai/SKILL.md +8 -2
- package/templates/skills/hai-ai/guide.md +134 -60
- package/templates/skills/hai-ai/reference.md +51 -11
- package/templates/skills/hai-api-client/SKILL.md +2 -0
- package/templates/skills/hai-api-client/reference.md +17 -5
- package/templates/skills/hai-api-contract/SKILL.md +1 -0
- package/templates/skills/hai-api-contract/reference.md +4 -2
- package/templates/skills/hai-app-create/SKILL.md +4 -0
- package/templates/skills/hai-app-create/development.md +20 -4
- package/templates/skills/hai-app-create/reference.md +2 -1
- package/templates/skills/hai-app-review/SKILL.md +6 -2
- package/templates/skills/hai-app-review/differential.md +34 -0
- package/templates/skills/hai-app-tests/SKILL.md +3 -1
- package/templates/skills/hai-app-tests/reference.md +4 -4
- package/templates/skills/hai-app-tests/validation.md +10 -3
- package/templates/skills/hai-audit/SKILL.md +10 -8
- package/templates/skills/hai-audit/reference.md +3 -0
- package/templates/skills/hai-build/reference.md +6 -4
- package/templates/skills/hai-cache/SKILL.md +9 -3
- package/templates/skills/hai-cache/reference.md +53 -11
- package/templates/skills/hai-capacitor/SKILL.md +3 -1
- package/templates/skills/hai-capacitor/reference.md +9 -5
- package/templates/skills/hai-core/SKILL.md +4 -0
- package/templates/skills/hai-core/reference.md +25 -5
- package/templates/skills/hai-crypto/SKILL.md +10 -2
- package/templates/skills/hai-crypto/reference.md +64 -10
- package/templates/skills/hai-datapipe/SKILL.md +2 -0
- package/templates/skills/hai-datapipe/reference.md +3 -1
- package/templates/skills/hai-deploy/SKILL.md +9 -4
- package/templates/skills/hai-deploy/middleware.md +51 -37
- package/templates/skills/hai-iam/SKILL.md +24 -8
- package/templates/skills/hai-iam/reference.md +181 -154
- package/templates/skills/hai-kit/SKILL.md +23 -7
- package/templates/skills/hai-kit/reference.md +42 -24
- package/templates/skills/hai-payment/SKILL.md +13 -11
- package/templates/skills/hai-payment/reference.md +80 -41
- package/templates/skills/hai-pr-review/SKILL.md +21 -88
- package/templates/skills/hai-reach/SKILL.md +5 -2
- package/templates/skills/hai-reach/reference.md +83 -22
- package/templates/skills/hai-reldb/SKILL.md +5 -2
- package/templates/skills/hai-reldb/reference.md +68 -25
- package/templates/skills/hai-scheduler/SKILL.md +8 -5
- package/templates/skills/hai-scheduler/reference.md +38 -24
- package/templates/skills/hai-serv/SKILL.md +23 -8
- package/templates/skills/hai-serv/reference.md +123 -87
- package/templates/skills/hai-spec-implement/SKILL.md +4 -0
- package/templates/skills/hai-spec-implement/evaluation.md +18 -0
- package/templates/skills/hai-spec-implement/handoff.md +47 -0
- package/templates/skills/hai-spec-implement/templates.md +5 -0
- package/templates/skills/hai-spec-implement/workflow.md +2 -0
- package/templates/skills/hai-spec-review/SKILL.md +4 -0
- package/templates/skills/hai-storage/SKILL.md +8 -2
- package/templates/skills/hai-storage/reference.md +64 -25
- package/templates/skills/hai-ui/SKILL.md +16 -2
- package/templates/skills/hai-ui/guide.md +3 -3
- package/templates/skills/hai-ui/reference.md +17 -6
- package/templates/skills/hai-ui-design/SKILL.md +31 -0
- package/templates/skills/hai-ui-design/patterns.md +26 -0
- package/templates/skills/hai-ui-review/SKILL.md +30 -0
- package/templates/skills/hai-ui-review/checklist.md +22 -0
- package/templates/skills/hai-vecdb/SKILL.md +12 -8
- package/templates/skills/hai-vecdb/reference.md +33 -3
- package/dist/chunk-7DSURTJ4.js.map +0 -1
- package/dist/chunk-AOQ4N657.js +0 -640
- package/dist/chunk-AOQ4N657.js.map +0 -1
- package/dist/cli-config-templates-6AHNUGBN.js +0 -5
- package/dist/src-2V6VP4I5.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hai-reach
|
|
3
|
-
description: "接入邮件、短信、API
|
|
3
|
+
description: "接入邮件、短信、API 通知与模板,处理发送结果、免打扰和可靠延迟入队。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# hai-reach
|
|
@@ -17,8 +17,10 @@ description: "接入邮件、短信、API 通知与模板,处理发送结果
|
|
|
17
17
|
|
|
18
18
|
## 使用路径
|
|
19
19
|
|
|
20
|
-
- 先配置并注册 Provider,再保存绑定 Provider 的模板;模板发送只传 template/to/vars 即可,显式 provider 可覆盖;直接发送须提供 provider
|
|
20
|
+
- 先配置并注册 Provider,再保存绑定 Provider 的模板;模板发送只传 template/to/vars 即可,显式 provider 可覆盖;直接发送须提供 provider。启用 DND delay 必须先初始化 reldb 并确保队列表可用;成功时 `deferred: true` 与 `queueId` 表示可靠入队,尚未投递;缺库、断连或插入失败须按 HaiResult 失败处理。即时投递与排队状态须区分。
|
|
21
21
|
- 验证模板替换和错误分支时使用隔离测试收件人;真实通知须在用户授权范围内。
|
|
22
|
+
- 缓存锁只减少扫描,按实际执行逐条领取并在途续租。API 只有接收端真正具备原子持久去重时才能声明 idempotent=true,全部请求固定用 queueId 作为 Idempotency-Key;旧节点迟到请求也必须去重。SMTP/短信/默认 API 的在途失败或过期转 unknown,禁止自动重发;可信服务端用 queue.get/resolveUnknown 在人工隔离旧 worker、核对外部记录后裁定 sent/discarded。
|
|
23
|
+
- queue 重试策略随消息持久保存,默认最多领取 5 次,指数退避 60 秒至 1 小时;永久失败/超限进 dead,满批失败不会原地自旋。可信服务端用 queue.list 查询 dead/unknown;修复根因后以最新 updatedAt 调用 requeueDead,只能恢复 dead,保留同一 queueId 和接收端幂等要求。unknown 不能重排。
|
|
22
24
|
|
|
23
25
|
## 按需参考
|
|
24
26
|
|
|
@@ -31,6 +33,7 @@ description: "接入邮件、短信、API 通知与模板,处理发送结果
|
|
|
31
33
|
- 核心 API
|
|
32
34
|
- 错误码
|
|
33
35
|
- 常见模式
|
|
36
|
+
- 租约、接收端幂等与未知结果
|
|
34
37
|
- 相关 Skills
|
|
35
38
|
|
|
36
39
|
IAM 通知使用 `otp_email`、`otp_sms`、`password_reset`、`password_reset_sms` 标准模板;将已初始化的 `reach` 传入 `iam.init`。Console Provider 只记录元数据,不输出验证码/令牌,不代替实际邮件/短信发送。
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
| 模块 | 用途 | 是否必需 | 初始化要求 |
|
|
14
14
|
| --- | --- | --- | --- |
|
|
15
|
-
| `@h-ai/reldb` | 数据库(发送日志与模板持久化) |
|
|
15
|
+
| `@h-ai/reldb` | 数据库(发送日志与模板持久化) | DND delay 必需;其他场景可选 | 启用 delay 前先初始化,队列表不可用则 reach.init 返回 CONFIG_ERROR |
|
|
16
16
|
| `@h-ai/cache` | 缓存(DND delay 策略分布式锁) | 可选 | 已初始化时自动启用分布式锁 |
|
|
17
17
|
|
|
18
18
|
## 适用场景
|
|
@@ -48,6 +48,7 @@ providers:
|
|
|
48
48
|
- name: webhook
|
|
49
49
|
type: api
|
|
50
50
|
url: ${HAI_REACH_WEBHOOK_URL:}
|
|
51
|
+
idempotent: false # 只有接收端按 Idempotency-Key 原子、持久化去重时才能设 true
|
|
51
52
|
|
|
52
53
|
# 模板(可选,也可通过代码注册)
|
|
53
54
|
templates:
|
|
@@ -62,7 +63,7 @@ templates:
|
|
|
62
63
|
# 免打扰(可选)
|
|
63
64
|
dnd:
|
|
64
65
|
enabled: true
|
|
65
|
-
strategy: delay #
|
|
66
|
+
strategy: delay # 必须先初始化 reldb;确认持久化成功后才返回 deferred 与 queueId
|
|
66
67
|
start: '22:00'
|
|
67
68
|
end: '08:00'
|
|
68
69
|
```
|
|
@@ -80,12 +81,18 @@ import { core } from '@h-ai/core'
|
|
|
80
81
|
import { reach } from '@h-ai/reach'
|
|
81
82
|
import { reldb } from '@h-ai/reldb'
|
|
82
83
|
|
|
83
|
-
//
|
|
84
|
-
await reldb.init(core.config.get('db'))
|
|
85
|
-
|
|
84
|
+
// 当前配置启用了 delay:数据库和队列表为必需依赖。
|
|
85
|
+
const database = await reldb.init(core.config.get('db'))
|
|
86
|
+
if (!database.success)
|
|
87
|
+
throw new Error(database.error.message)
|
|
88
|
+
const cached = await cache.init(core.config.get('cache')) // 可选,启用 flush 分布式锁
|
|
89
|
+
if (!cached.success)
|
|
90
|
+
throw new Error(cached.error.message)
|
|
86
91
|
|
|
87
92
|
// 再初始化触达模块(自动检测已初始化的 reldb/cache 单例)
|
|
88
|
-
await reach.init(core.config.get('reach'))
|
|
93
|
+
const initialized = await reach.init(core.config.get('reach'))
|
|
94
|
+
if (!initialized.success)
|
|
95
|
+
throw new Error(initialized.error.message)
|
|
89
96
|
// ... 使用触达服务
|
|
90
97
|
await reach.close()
|
|
91
98
|
```
|
|
@@ -119,6 +126,12 @@ const result = await reach.send({
|
|
|
119
126
|
template: 'verification_code',
|
|
120
127
|
vars: { code: '123456', minutes: '5' },
|
|
121
128
|
})
|
|
129
|
+
if (!result.success)
|
|
130
|
+
throw new Error(result.error.message)
|
|
131
|
+
if (result.data.deferred) {
|
|
132
|
+
// queueId 是 hai_reach_send_log.queue_id 中的回执 UUID;此时尚未投递。
|
|
133
|
+
await savePendingReceipt(result.data.queueId)
|
|
134
|
+
}
|
|
122
135
|
|
|
123
136
|
// 直接发送邮件(无模板)
|
|
124
137
|
await reach.send({
|
|
@@ -152,6 +165,10 @@ await reach.send({
|
|
|
152
165
|
| --------------------- | -------------------------------------------------------- | ---------------------------------- |
|
|
153
166
|
| `reach.init` | `(config: ReachConfigInput) => Promise<HaiResult<void>>` | 初始化(注册多个 Provider) |
|
|
154
167
|
| `reach.send` | `(message: ReachMessage) => Promise<HaiResult<SendResult>>` | 发送消息(通过 provider 字段路由) |
|
|
168
|
+
| `reach.queue.get` | `(queueId: string) => Promise<HaiResult<ReachQueueEntry \| null>>` | 不含正文/收件人的队列状态 |
|
|
169
|
+
| `reach.queue.list` | `(options: ReachQueueListOptions) => Promise<HaiResult<ReachQueueEntry[]>>` | 可信服务端分页查询 dead/unknown,每页至多 100 条 |
|
|
170
|
+
| `reach.queue.requeueDead` | `(queueId: string, options: ReachDeadRequeue) => Promise<HaiResult<void>>` | 按 expectedUpdatedAt 条件恢复 dead;unknown 禁止重排 |
|
|
171
|
+
| `reach.queue.resolveUnknown` | `(queueId: string, resolution: ReachUnknownResolution) => Promise<HaiResult<void>>` | 人工核对后将 unknown 裁定为 sent/discarded,不发送 |
|
|
155
172
|
| `reach.template` | `ReachTemplateRegistry` | 模板注册表 |
|
|
156
173
|
| `reach.config` | `ReachConfig \| null` | 当前脱敏配置快照 |
|
|
157
174
|
| `reach.isInitialized` | `boolean` | 是否已初始化 |
|
|
@@ -183,6 +200,7 @@ interface ReachConfigInput {
|
|
|
183
200
|
| `template` | `string` | — | 模板名称(模板发送时) |
|
|
184
201
|
| `vars` | `Record<string, string>` | — | 模板变量 |
|
|
185
202
|
| `extra` | `Record<string, unknown>` | — | Provider 扩展参数(如短信模板编码) |
|
|
203
|
+
| `idempotencyKey` | `string` | — | 直接 API 请求可指定;队列投递由框架固定为 queueId |
|
|
186
204
|
|
|
187
205
|
### ReachTemplate(模板绑定 Provider)
|
|
188
206
|
|
|
@@ -198,18 +216,63 @@ interface ReachConfigInput {
|
|
|
198
216
|
| 错误码 | code | 说明 |
|
|
199
217
|
|--------|------|------|
|
|
200
218
|
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败 |
|
|
219
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API 明确永久拒绝 |
|
|
201
220
|
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板未找到 |
|
|
202
221
|
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败 |
|
|
203
222
|
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方 |
|
|
204
223
|
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未找到 |
|
|
205
224
|
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃 |
|
|
206
|
-
| `HaiReachError.DND_DEFERRED` | `hai:reach:007` | 免打扰延时暂存 |
|
|
207
225
|
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 模块未初始化 |
|
|
208
226
|
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 不支持的类型 |
|
|
209
227
|
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 配置错误 |
|
|
210
228
|
|
|
211
229
|
## 常见模式
|
|
212
230
|
|
|
231
|
+
### 可靠延迟入队
|
|
232
|
+
|
|
233
|
+
启用 delay 后,只有数据库确认插入一条 pending 记录才返回 `deferred: true` 和 `queueId`。缺库、断连、插入失败返回失败 HaiResult,不能向用户报告已入队。`queueId` 对应 `hai_reach_send_log.queue_id`,同一行的 `status` 为 pending / processing / sending / unknown / sent / discarded / dead。节点重启后连接同一数据库、配置同名 Provider,可在 DND 时段外恢复投递。即时发送后的普通日志仍为可选,日志失败不会使已发送消息返回失败。
|
|
234
|
+
|
|
235
|
+
验证码和密码重置等有时效的通知应明确选择 DND 策略;可靠入队成功不代表用户已收到验证码。
|
|
236
|
+
|
|
237
|
+
队列 `created_at` / `updated_at` / `locked_until` / `next_attempt_at` 均为 Unix 毫秒 `BIGINT`(SQLite 为 64 位 INTEGER);仓库把 driver 的字符串/数字归一化为安全整数 number,并拒绝负数、小数和不安全整数。模板仓库的 TIMESTAMP 时间返回 Date。已有表须在部署时同步列结构,包含次数、退避策略和失败分类,模块不自动迁移旧表;跨库验收必须运行 PostgreSQL/MySQL 用例,SQLite 无法证明 32 位溢出已经消除。
|
|
238
|
+
|
|
239
|
+
### 租约、接收端幂等与未知结果
|
|
240
|
+
|
|
241
|
+
每轮最多 100 条,逐条 claim,发送前原子写入 sending 并在途续租;过期 owner 的开始、续租、确认和释放均被拒绝。DND 时段外每分钟恢复遗留任务。cache 锁只是扫描优化,不能提供外部副作用的 exactly-once。
|
|
242
|
+
|
|
243
|
+
API `idempotent: true` 是接收端能力声明:接收方必须把 queueId 对应的去重记录与业务副作用原子、持久化提交。所有尝试用相同 `Idempotency-Key` 头和 `idempotencyKey` 正文,覆盖响应丢失和旧节点恢复;去重记录须覆盖所有迟到请求,迟到无上界时不能过期。同名 Provider 的目标与幂等存储保持一致。只有此模式能自动接管已开始的 API 投递。
|
|
244
|
+
|
|
245
|
+
SMTP、短信和默认 API 一旦开始投递,失败或过期转 unknown,不自动重发。未连接按持久策略退避;发送前解析失败直接进入 dead。unknown 必须在可信服务端管理流程里,先停止/隔离旧 worker 并核对第三方投递记录,再执行以下二选一裁定;discarded 不撤销已开始请求。需要补发时,先确保旧请求不可能继续生效,再创建新消息。
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const state = await reach.queue.get(queueId)
|
|
249
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
250
|
+
if (!resolved.success)
|
|
251
|
+
throw new Error(resolved.error.message)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
管理入口校验回执 UUID、status 与 messageId 长度,只能从 unknown 条件更新为 sent/discarded;不提供重排队选项,并发裁定仅一个成功。
|
|
255
|
+
|
|
256
|
+
### 有限重试与死信运维
|
|
257
|
+
|
|
258
|
+
`queue.maxAttempts` 默认 5(1–100),`retryDelayMs` 默认 60000,`maxRetryDelayMs` 默认 3600000;两项毫秒配置范围 1000–86400000,最大退避不得小于首次退避。策略随入队记录保存,不随新节点配置重置;领取(含崩溃恢复)原子累加 attemptCount。暂时故障保存 nextAttemptAt,按 `min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 退避。DND 外每分钟扫描,一轮最多 100 条且固定资格时间,失败行不会在同一轮反复领取。缺失/未连接 Provider、网络错误、HTTP 408/425/429/5xx 允许有限重试;坏 JSON、幂等 API 其它 4xx 直接 dead;超限(含最后一次领取后崩溃)停止自动处理。非幂等在途仍隔离 unknown,不能借重试预算重发。
|
|
259
|
+
|
|
260
|
+
queue.get/list 返回 attemptCount、maxAttempts、nextAttemptAt、lastFailure、updatedAt 等安全元数据。lastFailure 为 provider_unavailable、invalid_message、permanent_rejection、transient_failure 或 attempts_exhausted,不保存响应正文。可信服务端以 `list({ status: 'dead', limit: 50, offset: 0 })` 分页查询,status 也可选 unknown;limit 1–100,offset 0–1000000。
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// 应用先校验运维权限并修复根因;不向浏览器直接暴露队列管理。
|
|
264
|
+
const state = await reach.queue.get(queueId)
|
|
265
|
+
if (!state.success)
|
|
266
|
+
throw new Error(state.error.message)
|
|
267
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
268
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
269
|
+
if (!restored.success)
|
|
270
|
+
throw new Error(restored.error.message)
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
恢复仅匹配当前 dead 版本,过时/并发请求失败;重置次数但保留 queueId 和原策略。unknown 永远不能通过此入口重排。dead 不代表外部副作用未发生,旧幂等 API 请求仍可能迟到,恢复前确认接收端持久去重;不可修复的坏消息留在 dead 人工排查。
|
|
275
|
+
|
|
213
276
|
### 多渠道验证码发送
|
|
214
277
|
|
|
215
278
|
```typescript
|
|
@@ -244,7 +307,7 @@ import { iam } from '@h-ai/iam'
|
|
|
244
307
|
import { reach } from '@h-ai/reach'
|
|
245
308
|
|
|
246
309
|
// 初始化 reach(注册 email 和 sms Provider)
|
|
247
|
-
await reach.init({
|
|
310
|
+
const initializedReach = await reach.init({
|
|
248
311
|
providers: [
|
|
249
312
|
{ name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com' },
|
|
250
313
|
{ name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '...' },
|
|
@@ -255,23 +318,17 @@ await reach.init({
|
|
|
255
318
|
{ name: 'otp_sms', provider: 'sms', body: 'Your code is {code}' },
|
|
256
319
|
],
|
|
257
320
|
})
|
|
321
|
+
if (!initializedReach.success)
|
|
322
|
+
throw new Error(initializedReach.error.message)
|
|
258
323
|
|
|
259
|
-
//
|
|
260
|
-
await iam.init({
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
onPasswordResetRequest: async (user, token, expiresAt) => {
|
|
264
|
-
await reach.send({ to: user.email ?? '', template: 'password_reset', vars: { token, expiresAt: expiresAt.toISOString() } })
|
|
265
|
-
},
|
|
266
|
-
onOtpSendEmail: async (email, code) => {
|
|
267
|
-
await reach.send({ to: email, template: 'otp_email', vars: { code } })
|
|
268
|
-
},
|
|
269
|
-
onOtpSendSms: async (phone, code) => {
|
|
270
|
-
await reach.send({ to: phone, template: 'otp_sms', vars: { code } })
|
|
271
|
-
},
|
|
272
|
-
})
|
|
324
|
+
// 先确认 reldb/cache/Reach 已初始化成功,再注入 Reach;内置适配器传播发送失败。
|
|
325
|
+
const initializedIam = await iam.init({ ...iamSettings, reach })
|
|
326
|
+
if (!initializedIam.success)
|
|
327
|
+
throw new Error(initializedIam.error.message)
|
|
273
328
|
```
|
|
274
329
|
|
|
330
|
+
若使用显式通知回调覆盖内置适配器,必须检查 `reach.send()` 的 HaiResult,失败时拒绝 Promise;不得吞掉入队失败。需要即时送达的验证码不应启用延迟策略。
|
|
331
|
+
|
|
275
332
|
### 错误处理
|
|
276
333
|
|
|
277
334
|
```typescript
|
|
@@ -300,3 +357,7 @@ if (!result.success) {
|
|
|
300
357
|
- `hai-core` — 配置加载、日志、HaiResult 模式
|
|
301
358
|
- `hai-reldb` — 数据库操作(存储发送记录等)
|
|
302
359
|
- `hai-cache` — 缓存操作、分布式锁(DND flush 互斥)
|
|
360
|
+
|
|
361
|
+
## 生产共享状态
|
|
362
|
+
|
|
363
|
+
生产拒绝 console Provider,须配置真实 SMTP/短信/API 服务。console 的日志成功不是外部投递成功;非幂等服务的未知结果必须核对,不能自动重发证明 exactly-once。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化成功前检查,只有 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试豁免。不要将租约当作最终写入条件,外部未知效果须按稳定业务键核对。
|
|
@@ -13,14 +13,15 @@ description: "使用 @h-ai/reldb 访问 SQLite/PostgreSQL/MySQL,编写 SQL、C
|
|
|
13
13
|
| 适用场景 | 关系数据读写、仓库封装或事务一致性 |
|
|
14
14
|
| 输入 | ReldbConfigInput、参数化 SQL、CRUD 配置、事务句柄 |
|
|
15
15
|
| 输出 | DB 操作为 HaiResult;crud.table 和分页/JSON 工具按签名返回 |
|
|
16
|
-
| 限制 |
|
|
16
|
+
| 限制 | 运行时仅服务端,纯 schemas 入口可用于浏览器;tx.wrap 只在回调抛错时回滚,返回失败 HaiResult 不会触发回滚。动态值参数化,标识符用白名单。 |
|
|
17
17
|
|
|
18
18
|
## 使用路径
|
|
19
19
|
|
|
20
|
-
- `_db.yml` 对应 core.config 的 db;先校验 ReldbConfigSchema,再 init 并检查 success
|
|
20
|
+
- `_db.yml` 对应 core.config 的 db;先校验 ReldbConfigSchema,再 init 并检查 success。仅做配置校验可从 `@h-ai/reldb/schemas` 导入,纯入口只依赖 Zod,不加载数据库实例/驱动,可用于 CLI 和浏览器;运行时数据库操作仍仅服务端。
|
|
21
21
|
- `reldb.crud.table` / `tx.crud.table` 的 `dbType` 必填并与实际后端一致;`BaseReldbCrudRepository` 自动读取已初始化的配置。
|
|
22
22
|
- 事务每步检查结果,避免把失败当普通返回值提交;同一事务操作须共享 tx。
|
|
23
23
|
- SQL 值参数化;where/orderBy/json 列名不是任意用户输入。分页使用 PaginatedResult。
|
|
24
|
+
- MySQL 的 pool 仅 max/acquireTimeout/queueLimit;超时返回 TIMEOUT 并移除排队项,队列满返回 POOL_EXHAUSTED,迟到建连结算前仍占槽位。min/idleTimeout/timezone 不支持。URL 优先于分字段且禁止 query/fragment;TLS 仅 false 或严格对象,默认验证证书和 DNS 身份,验证开启的 IP 目标拒绝,cert/key 成对。详见 reference 的配置章节。
|
|
24
25
|
|
|
25
26
|
## 按需参考
|
|
26
27
|
|
|
@@ -33,3 +34,5 @@ description: "使用 @h-ai/reldb 访问 SQLite/PostgreSQL/MySQL,编写 SQL、C
|
|
|
33
34
|
- 错误码 — `HaiReldbError`
|
|
34
35
|
- 常见模式
|
|
35
36
|
- 相关 Skills
|
|
37
|
+
|
|
38
|
+
JSON 对象合并使用 RFC 7396 补丁,必须将返回的 SQL/params 放在同一次 UPDATE 内;禁止先读对象再覆盖整个列来模拟跨节点原子合并。路径函数的后端差异和完整可类型检查示例见 [reference.md](reference.md)。
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
> `@h-ai/reldb` 提供统一的数据库操作接口,支持 SQLite、PostgreSQL、MySQL,包含 DDL、SQL、CRUD 抽象、事务与分页。
|
|
6
6
|
|
|
7
|
+
|
|
8
|
+
派生 `BaseReldbCrudRepository` 的自定义 SQL 方法先检查 `await this.ensureReady()`;构造函数启动自动建表不等于仓库就绪,服务 init 必须等待候选仓库的就绪入口再发布。PostgreSQL 并发首次 `CREATE TABLE IF NOT EXISTS` 的 `23505/pg_type_typname_nsp_index` 目录冲突仅重执行相同 DDL 一次,其他错误继续失败。
|
|
9
|
+
|
|
7
10
|
## 运行环境
|
|
8
11
|
|
|
9
12
|
> ⚠️ **服务端模块(Node.js only)。** 浏览器端请通过 `@h-ai/api-contract` + `@h-ai/serv` 暴露的 API 端点间接访问数据库。
|
|
@@ -20,6 +23,8 @@
|
|
|
20
23
|
|
|
21
24
|
### 1. 配置
|
|
22
25
|
|
|
26
|
+
只做校验时从 `@h-ai/reldb/schemas` 导入同一组 Schema/类型;该入口只依赖 Zod,无数据库实例或驱动。MySQL `ssl` 只接受 `false` 或严格对象;`{}` 默认开启证书链和 DNS 身份验证,不能用 `true` 或 PostgreSQL 字符串模式。实际连接仍需服务端驱动。
|
|
27
|
+
|
|
23
28
|
```yaml
|
|
24
29
|
# config/_db.yml
|
|
25
30
|
type: sqlite
|
|
@@ -39,20 +44,46 @@ operationLog:
|
|
|
39
44
|
约定环境变量优先于 YAML,例如 `HAI_DB_TYPE`、`HAI_DB_DATABASE`、
|
|
40
45
|
`HAI_DB_OPERATIONLOG_LEVEL`。
|
|
41
46
|
|
|
42
|
-
`operationLog` 在 reldb provider-base 的 DML 操作层统一输出日志,不要在 `reldb-main.ts`、各 raw provider 或调用方重复包装。`read` 覆盖 `query/get/queryPage`,`write` 覆盖 DDL、`execute/batch` 与 `tx.begin/commit/rollback`,`maxLength`
|
|
47
|
+
`operationLog` 在 reldb provider-base 的 DML 操作层统一输出日志,不要在 `reldb-main.ts`、各 raw provider 或调用方重复包装。`read` 覆盖 `query/get/queryPage`,`write` 覆盖 DDL、`execute/batch` 与 `tx.begin/commit/rollback`,`maxLength` 限制安全数量摘要的字符数(0省略摘要);SQL原文、参数值、DDL默认值和驱动错误内容不写入日志,`level` 默认 `debug`。
|
|
43
48
|
|
|
44
49
|
### 2. 初始化与关闭
|
|
45
50
|
|
|
51
|
+
MySQL `pool` 仅支持 `{ max: 10, acquireTimeout: 10000, queueLimit: 1000 }`(也是省略时默认值)。`acquireTimeout` 为 1–2147483647 毫秒的整数,覆盖排队和建连;queueLimit 为正整数。SQL/DDL/CRUD/batch/tx 共用有界 FIFO,超时移除并返回 `TIMEOUT`,满额返回 `POOL_EXHAUSTED`;迟到建连结算前仍占槽位,只归还连接,不执行 SQL。close 立即取消排队,再等待借用/事务和驱动有界建连完成,不中止已启动 SQL。min/idleTimeout/mysql.timezone 明确拒绝,不承诺 MySQL 最小保留或可配置空闲回收;PostgreSQL 保留自己的 min/idleTimeout/acquireTimeout=0 契约,min 不会预建连接。
|
|
52
|
+
|
|
53
|
+
MySQL 仅传 mysql:// URL 即可连接,优先于 host/port/database/user/password;无 URL 时 database 必填。禁止 URL query/fragment,pool/mysql.charset/ssl/operationLog 仍独立适用。ssl 仅 false 或严格对象,空对象默认校验证书链和 DNS 身份;ca/cert/key 为 PEM 内容,cert/key 必须成对。当前驱动不验证 IP 身份,验证开启的 IP 目标返回 CONFIG_ERROR;关闭验证有固定风险日志,加密不代表身份认证。环境变量为 HAI_DB_POOL_* / HAI_DB_SSL_*,min/idleTimeout 仅适用 PostgreSQL,queueLimit/verifyIdentity 仅适用 MySQL。
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// trustedCaPem 从部署信任配置提供。
|
|
57
|
+
const result = await reldb.init({ type: 'mysql', host: 'db.example.com', database: 'app', pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 }, ssl: { ca: trustedCaPem } })
|
|
58
|
+
if (!result.success) return result
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
PostgreSQL 空闲连接错误已监听,由池移除并在下一次查询替换;日志仅含固定说明与安全 code/name,不记录驱动错误原文。关闭会等待已借出事务完成,应用必须提交/回滚并释放句柄。
|
|
62
|
+
|
|
46
63
|
```typescript
|
|
47
64
|
import { core } from '@h-ai/core'
|
|
48
65
|
import { reldb } from '@h-ai/reldb'
|
|
49
66
|
|
|
50
67
|
await reldb.init(core.config.get('db'))
|
|
51
68
|
// ... 使用数据库
|
|
52
|
-
await reldb.close()
|
|
69
|
+
const closed = await reldb.close()
|
|
70
|
+
if (!closed.success) throw new Error(closed.error.message)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
初始化只在健康检查成功后发布连接;失败时释放候选资源。若清理仍失败,初始化错误的 `error.ext.cleanupPending` 为 `true`。`reldb.close()` 返回 `Promise<HaiResult<void>>`,等待正在进行的初始化,并合并并发关闭。开始清理后 `isInitialized` 为 `false`,配置与业务操作不再可用;清理失败仍保留资源,后续 `close()` 或 `init()` 先重试释放,成功前不会创建替代连接。调用方必须检查关闭结果。
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const closed = await reldb.close()
|
|
78
|
+
if (!closed.success) {
|
|
79
|
+
// 处理清理故障后有限重试;再次失败应向应用报告。
|
|
80
|
+
const retried = await reldb.close()
|
|
81
|
+
if (!retried.success) throw new Error(retried.error.message)
|
|
82
|
+
}
|
|
53
83
|
```
|
|
54
84
|
|
|
55
|
-
|
|
85
|
+
|
|
86
|
+
`reldb.config` 返回脱敏后的配置快照;数据库密码、URL 内嵌凭证和 TLS 私钥 `ssl.key` 会隐藏,不能用快照回放原始连接配置。
|
|
56
87
|
|
|
57
88
|
### 3. 选择操作接口
|
|
58
89
|
|
|
@@ -103,7 +134,7 @@ const columns: ReldbTableDef = {
|
|
|
103
134
|
|
|
104
135
|
```typescript
|
|
105
136
|
interface ReldbColumnDef {
|
|
106
|
-
type: 'TEXT' | 'INTEGER' | 'REAL' | 'BLOB' | 'BOOLEAN' | 'TIMESTAMP' | 'JSON'
|
|
137
|
+
type: 'TEXT' | 'INTEGER' | 'BIGINT' | 'REAL' | 'BLOB' | 'BOOLEAN' | 'TIMESTAMP' | 'JSON'
|
|
107
138
|
primaryKey?: boolean
|
|
108
139
|
autoIncrement?: boolean
|
|
109
140
|
notNull?: boolean
|
|
@@ -118,7 +149,8 @@ interface ReldbColumnDef {
|
|
|
118
149
|
| 类型 | SQLite | PostgreSQL | MySQL |
|
|
119
150
|
| --------- | ------- | ---------------- | ------------ |
|
|
120
151
|
| TEXT | TEXT | TEXT | VARCHAR(255) |
|
|
121
|
-
| INTEGER | INTEGER | INTEGER/
|
|
152
|
+
| INTEGER | INTEGER | INTEGER/BIGSERIAL | INT/BIGINT |
|
|
153
|
+
| BIGINT | INTEGER(64 位) | BIGINT | BIGINT |
|
|
122
154
|
| REAL | REAL | DOUBLE PRECISION | DOUBLE |
|
|
123
155
|
| BLOB | BLOB | BYTEA | BLOB |
|
|
124
156
|
| BOOLEAN | INTEGER | BOOLEAN | TINYINT(1) |
|
|
@@ -127,6 +159,8 @@ interface ReldbColumnDef {
|
|
|
127
159
|
|
|
128
160
|
> MySQL 将 TEXT 映射为 VARCHAR(255) 以支持索引和 UNIQUE 约束。INTEGER + autoIncrement 在 MySQL 映射为 BIGINT。
|
|
129
161
|
|
|
162
|
+
Unix 毫秒使用 BIGINT,不能使用 PostgreSQL/MySQL 的普通 INTEGER。原始 SQL/CRUD 与 BaseReldbCrudRepository 的 BIGINT 保留 driver 的 number/string/bigint 类型;业务需要 number 时须在读取边界校验非负整数和 Number.isSafeInteger,拒绝精度丢失。TIMESTAMP 经 BaseReldbCrudRepository 映射为 Date。
|
|
163
|
+
|
|
130
164
|
### SQL — `reldb.sql`
|
|
131
165
|
|
|
132
166
|
| 方法 | 签名 | 说明 |
|
|
@@ -356,7 +390,7 @@ interface PaginatedResult<T> {
|
|
|
356
390
|
|
|
357
391
|
通过 `reldb.json` 构建跨数据库统一的 JSON 路径操作 SQL 表达式,返回 `{ sql, params }` 可直接嵌入 `reldb.sql.*`。
|
|
358
392
|
|
|
359
|
-
|
|
393
|
+
支持简单路径 `$.key`、`$.key.subkey`、`$[0]`。
|
|
360
394
|
|
|
361
395
|
| 操作 | SQLite | PostgreSQL | MySQL |
|
|
362
396
|
| --------- | ------------------ | ------------------------ | ---------------------- |
|
|
@@ -364,30 +398,33 @@ interface PaginatedResult<T> {
|
|
|
364
398
|
| `set` | `json_set` | `jsonb_set` | `JSON_SET` |
|
|
365
399
|
| `insert` | `json_insert` | `jsonb_insert` | `JSON_INSERT` |
|
|
366
400
|
| `remove` | `json_remove` | `#-` (text[]) | `JSON_REMOVE` |
|
|
367
|
-
| `merge` | `json_patch` |
|
|
401
|
+
| `merge` | `json_patch` | 参数化递归 CTE | `JSON_MERGE_PATCH` |
|
|
402
|
+
|
|
403
|
+
`merge` 的补丁根限于可 JSON 序列化对象:三种后端均按 RFC 7396 递归保留未变键、用 null 删除指定键、整体替换数组;PostgreSQL 使用参数化递归 CTE,在同一次 SQL 内更新当前行,不做应用先读再写。路径和值全部参数化,column 只能由开发者静态编写。路径解析只支持简单点号/方括号形式,不是完整 SQL/JSON Path 校验器;PostgreSQL set 要求父路径存在,insert 对已有对象键报错且数组按原生位置插入,不应把不同后端的原生路径行为视为完全相同。
|
|
368
404
|
|
|
369
405
|
```typescript
|
|
370
|
-
|
|
371
|
-
const { sql, params } = reldb.json.extract('settings', '$.theme')
|
|
372
|
-
const rows = await reldb.sql.query(
|
|
373
|
-
`SELECT * FROM users WHERE ${sql} = ?`,
|
|
374
|
-
[...params, '"dark"'],
|
|
375
|
-
)
|
|
406
|
+
import { reldb, type ReldbFunctions } from '@h-ai/reldb'
|
|
376
407
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
`
|
|
381
|
-
|
|
382
|
-
)
|
|
408
|
+
// db 已初始化,users 表已有 settings/profile JSON 列。
|
|
409
|
+
async function updateSettings(db: ReldbFunctions, userId: string) {
|
|
410
|
+
const extracted = db.json.extract('settings', '$.theme')
|
|
411
|
+
const rows = await db.sql.query(`SELECT * FROM users WHERE ${extracted.sql} = ?`, [...extracted.params, '"dark"'])
|
|
412
|
+
if (!rows.success) return rows
|
|
383
413
|
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
414
|
+
const changed = db.json.set('settings', '$.theme', 'dark')
|
|
415
|
+
const updated = await db.sql.execute(`UPDATE users SET settings = ${changed.sql} WHERE id = ?`, [...changed.params, userId])
|
|
416
|
+
if (!updated.success) return updated
|
|
387
417
|
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
418
|
+
const removed = db.json.remove('settings', '$.deprecated')
|
|
419
|
+
const deleted = await db.sql.execute(`UPDATE users SET settings = ${removed.sql} WHERE id = ?`, [...removed.params, userId])
|
|
420
|
+
if (!deleted.success) return deleted
|
|
421
|
+
|
|
422
|
+
const patch = db.json.merge('profile', { bio: '新简介', avatar: null })
|
|
423
|
+
return db.sql.execute(`UPDATE users SET profile = ${patch.sql} WHERE id = ?`, [...patch.params, userId])
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const result = await updateSettings(reldb, 'u1')
|
|
427
|
+
if (!result.success) throw new Error(result.error.message)
|
|
391
428
|
```
|
|
392
429
|
|
|
393
430
|
> `column` 参数为列名,**禁止传入用户输入**(开发者负责安全性)。
|
|
@@ -453,3 +490,9 @@ const result = await reldb.tx.wrap(async (tx) => {
|
|
|
453
490
|
- `hai-core`:配置管理、HaiResult 模型
|
|
454
491
|
- `hai-iam`:IAM 模块内部使用 reldb 进行用户/角色/权限存储
|
|
455
492
|
- `hai-cache`:缓存穿透保护中配合 reldb 使用
|
|
493
|
+
|
|
494
|
+
## 生产共享状态
|
|
495
|
+
|
|
496
|
+
生产仅使用 PostgreSQL/MySQL;SQLite(含磁盘文件)只用于开发/明确测试,不能作为跨主机共享状态。 NODE_ENV 或 HAI_CORE_ENV 为 production 时在初始化成功前检查,只有 HAI_E2E=1/HAI_E2E_MOCK=1 明确测试豁免。不要将租约当作最终写入条件,外部未知效果须按稳定业务键核对。
|
|
497
|
+
|
|
498
|
+
MySQL 空 url(含空环境变量占位)按未配置处理,使用分字段;非空仍严格校验。
|
|
@@ -13,15 +13,18 @@ description: "注册或排查 cron/API/JS/Hook 任务、手工触发、执行日
|
|
|
13
13
|
| 适用场景 | 服务端任务调度、持久化和手工触发 |
|
|
14
14
|
| 输入 | SchedulerConfigInput、任务定义、已初始化依赖与 hooks |
|
|
15
15
|
| 输出 | 任务管理、触发和日志查询的 HaiResult |
|
|
16
|
-
| 限制 | 仅服务端;vm 不是安全沙箱,JS
|
|
16
|
+
| 限制 | 仅服务端;vm 不是安全沙箱,JS 任务只执行可信代码。共享RelDB强制持久journal/租约;unknown保留槽直至可信人工裁定。 |
|
|
17
17
|
|
|
18
18
|
## 使用路径
|
|
19
19
|
|
|
20
|
-
1.
|
|
20
|
+
1. 始终先初始化同一共享 reldb;enableDb 只能 true,跨主机生产使用 PostgreSQL/MySQL,不需要缓存。检查 scheduler.init 的 HaiResult。
|
|
21
21
|
2. 动态共享任务通过 register 管理,不与 init.tasks 重复声明;读取失败、禁用、删除或改期时不得继续旧任务。
|
|
22
|
-
3. 根据可信逻辑选择 API/JS/Hook。Worker/vm 不是安全沙箱;Hook 监听 signal,业务以 occurrenceId
|
|
23
|
-
4. 明确 timeout、响应大小、重试与执行状态;关闭时 await scheduler.close
|
|
24
|
-
5.
|
|
22
|
+
3. 根据可信逻辑选择 API/JS/Hook。Worker/vm 不是安全沙箱;Hook 监听 signal,业务以 occurrenceId 原子持久去重,只有实际满足该约束才设 idempotent:true;API 自动覆盖幂等头。
|
|
23
|
+
4. 明确 timeout、响应大小、重试与执行状态;关闭时 await scheduler.close,再关闭数据库;unknown须先确认旧runner终止并核对副作用,按版本人工resolveUnknown,禁止直接重排。
|
|
24
|
+
5. 验证手动/定时、跨周期租约、手动/cron竞争及暂停接管、失败重试、取消与定义更新;共享租约必须在 SQL 内用存储时钟原子检查,cron 分钟身份也取 DB 时间且默认 UTC;补上节点 ±2h、不同 TZ 与真实行锁跨期限的多进程用例。
|
|
25
|
+
6. 检查业务 status 与独立 persistence.status;pending 通过 execution.listLogFailures/repairLog 从已发布 journal 补写,不能重新触发业务。失租 unconfirmed 不能作为成功证据;验证丢回执和双节点并发补写。
|
|
26
|
+
|
|
27
|
+
7. 核对 JSDoc 示例的真实入口与返回值:HaiResult 先分支,close 直接 await;标注“模块内部上下文”的仓库/runner 不属于根 API。示例编译与共享 DB/进程行为验证分别记录。
|
|
25
28
|
|
|
26
29
|
## 按需参考
|
|
27
30
|
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
# hai-scheduler 参考
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
所有节点必须先初始化同一共享 RelDB(跨主机生产使用 PostgreSQL/MySQL;SQLite 文件只适合可正确共享同一文件的本机进程)。数据库保存任务、执行 journal、全局任务占位和租约,`enableDb` 只能为 true;不再依赖缓存。`leaseMs` 默认 300000,范围 1000~86400000 毫秒,执行中自动续期。cron、手动和跨周期执行均争用同一任务槽,过期 owner 不能续活或提交。节点每个 `tickInterval` 刷新定义,`tasks` 只是最近成功快照;定义读取失败、删除、禁用、改期均中断旧尝试,已发生副作用不会回滚。
|
|
4
4
|
|
|
5
|
-
`
|
|
5
|
+
`context.occurrenceId` 是持久发生标识;同一 cron 分钟跨节点一致,手动可传 1~128 字符 `idempotencyKey` 重用原记录,省略时每次独立。`executionToken` 每次领取变化,`sequence` / `attempt` 提供执行代信息。API 会覆盖 `Idempotency-Key`、`X-Scheduler-Sequence`、`X-Scheduler-Attempt`、`X-Scheduler-Task`(任务 ID 使用 percent-encoding)。Hook 及生命周期回调已领取时的 `event.context` 使用同一发生标识;观测回调若产生副作用也须去重。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`idempotent` 默认为 false;只有接收端已将幂等账本和全部副作用原子持久提交,才声明 true。租约过期的已执行任务仅在 true 时以同 occurrenceId 接管;非幂等或耗尽本 occurrence 保存的 retry.maxAttempts(默认 1,范围 1–100,包含首次执行及恢复)时进入 `unknown` 并继续占住任务槽。租约无法强制撤销暂停进程已发出的外部操作,单有 HTTP 头不构成去重实现。重试同样受幂等声明约束。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
初始化为全有或全无:必须检查 `init()` 的 HaiResult。空 ID、重复 ID(包括与数据库记录重复)、无效 cron、持久化读取或解析失败均使初始化失败并清理状态;修正后重试,不会静默跳过任务。
|
|
12
|
-
|
|
13
|
-
API 任务 `timeout` 默认 30000 毫秒,覆盖请求及完整响应体读取;`maxResponseBytes` 指定实际接收字节上限(正安全整数,默认 1048576 即 1 MiB),例如 `maxResponseBytes: 10 * 1024 * 1024` 允许最大 10 MiB;非法值在请求前失败。超时、超限和非成功 HTTP 状态均失败,重试由任务 retry 策略控制。
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
> `@h-ai/scheduler` 使用统一任务模型管理 API / JS / Hook 三类执行路径,支持持久化、手工触发来源记录、分布式锁与全局生命周期回调。
|
|
9
|
+
`scheduler.execution.get(occurrenceId)` 查询权威状态;`listUnknown({ limit, offset })` 分页获取未知结果,limit 为 1~100,offset 为 0~1000000。可信人工先核实外部效果、确认旧 runner 已终止,再按 `expectedUpdatedAt` CAS 调用 `resolveUnknown(id, { status: 'success' | 'discarded', expectedUpdatedAt })` 释放占位;不能直接把 unknown 重排。`log.occurrenceId` 可作查询回执;`getLogs` 仅为尝试观测日志,日志保留策略不删除 journal/幂等历史。
|
|
18
10
|
|
|
19
11
|
## 运行环境
|
|
20
12
|
|
|
@@ -24,21 +16,18 @@ API 任务 `timeout` 默认 30000 毫秒,覆盖请求及完整响应体读取
|
|
|
24
16
|
|
|
25
17
|
| 模块 | 用途 | 是否必需 | 初始化要求 |
|
|
26
18
|
| --- | --- | --- | --- |
|
|
27
|
-
| `@h-ai/reldb` |
|
|
28
|
-
| `@h-ai/cache` | 分布式锁 | 可选 | 初始化后自动启用 |
|
|
19
|
+
| `@h-ai/reldb` | 任务/执行 journal/租约 | 始终必需 | 先初始化同一共享数据库 |
|
|
29
20
|
|
|
30
21
|
## 使用步骤
|
|
31
22
|
|
|
32
23
|
### 1. 初始化
|
|
33
24
|
|
|
34
25
|
```ts
|
|
35
|
-
import { cache } from '@h-ai/cache'
|
|
36
26
|
import { core } from '@h-ai/core'
|
|
37
27
|
import { reldb } from '@h-ai/reldb'
|
|
38
28
|
import { scheduler } from '@h-ai/scheduler'
|
|
39
29
|
|
|
40
30
|
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
41
|
-
await cache.init({ type: 'memory' })
|
|
42
31
|
|
|
43
32
|
await scheduler.init({
|
|
44
33
|
enableDb: true,
|
|
@@ -67,7 +56,6 @@ await scheduler.register({
|
|
|
67
56
|
description: '每 5 分钟巡检一次接口健康状态',
|
|
68
57
|
cron: '*/5 * * * *',
|
|
69
58
|
params: { channel: 'ops' },
|
|
70
|
-
retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
|
|
71
59
|
handler: {
|
|
72
60
|
kind: 'api',
|
|
73
61
|
url: 'https://api.example.com/health',
|
|
@@ -130,8 +118,9 @@ const logs = await scheduler.getLogs({
|
|
|
130
118
|
| `register` | `(task) => Promise<HaiResult<void>>` | 注册统一任务模型 |
|
|
131
119
|
| `updateTask` | `(taskId, updates) => Promise<HaiResult<void>>` | 更新 cron / params / handler |
|
|
132
120
|
| `refreshTasks` | `() => Promise<HaiResult<void>>` | 主动刷新共享定义,不执行任务 |
|
|
133
|
-
| `register` 扩展字段 | `description / deleteAfterRun / retry` | 支持任务描述、一次性任务、失败重试策略 |
|
|
134
|
-
| `trigger` | `(taskId, { source? }) => Promise<HaiResult<TaskExecutionLog>>` | 手工触发并记录来源 |
|
|
121
|
+
| `register` 扩展字段 | `description / deleteAfterRun / retry / idempotent` | 支持任务描述、一次性任务、失败重试策略 |
|
|
122
|
+
| `trigger` | `(taskId, { source?, idempotencyKey? }) => Promise<HaiResult<TaskExecutionLog>>` | 手工触发并记录来源 |
|
|
123
|
+
| `execution.listLogFailures / repairLog` | `(options?) / (occurrenceId)` | 有界监控并幂等补写已发布 journal,不重执行业务 |
|
|
135
124
|
| `getLogs` | `(options?) => Promise<HaiResult<PaginatedResult<TaskExecutionLog>>>` | 支持按 trigger + startedAfter/startedBefore 过滤日志 |
|
|
136
125
|
| `setHooks` | `(hooks) => HaiResult<void>` | 设置全局生命周期回调 |
|
|
137
126
|
| `clearHooks` | `() => HaiResult<void>` | 清空全局生命周期回调 |
|
|
@@ -165,20 +154,41 @@ await scheduler.trigger('cleanup', { source: 'cli' })
|
|
|
165
154
|
const adminLogs = await scheduler.getLogs({ triggerSource: 'admin-console' })
|
|
166
155
|
```
|
|
167
156
|
|
|
168
|
-
###
|
|
157
|
+
### 共享执行租约
|
|
169
158
|
|
|
170
159
|
```ts
|
|
171
|
-
await cache.init({ type: 'redis', host: 'localhost', port: 6379 })
|
|
172
160
|
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
173
161
|
await scheduler.init({
|
|
174
162
|
enableDb: true,
|
|
175
163
|
maxLogs: 500,
|
|
176
164
|
retentionDays: 14,
|
|
177
|
-
|
|
165
|
+
leaseMs: 300000,
|
|
178
166
|
nodeId: 'node-1',
|
|
179
167
|
})
|
|
180
168
|
```
|
|
181
169
|
|
|
170
|
+
### 日志失败与补写
|
|
171
|
+
|
|
172
|
+
日志的 `id` 是每次尝试固定的 UUID,`persistence.status` 与业务 `status` 独立:`persisted` 表示日志及确认回执已持久化;`pending` 表示业务终态已提交但日志待补写;`unconfirmed` 表示没有已确认的业务终态,不能据此补写成功。只有 owner 与有效租约条件提交的终态会发布完整尝试列表,随后才投影日志;失租节点不能投影成功。日志失败不会把业务成功改成失败,也不会因此重跑业务。
|
|
173
|
+
|
|
174
|
+
补写会缩小 pending 列表;批量补写应反复读取 offset=0,避免一边补写一边增加 offset 跳过记录。
|
|
175
|
+
|
|
176
|
+
可信服务端用 `execution.listLogFailures({ limit, offset })` 监控 pending(范围与 listUnknown 相同),用 `execution.repairLog(occurrenceId)` 从共享 journal 幂等补写;可跨节点、重启及并发调用。写入已提交但回执丢失时通过固定主键和完整内容核对去重。补写返回 `LOG_PERSIST_FAILED`(032)时继续保留 pending,并输出安全告警;全部数据库不可用且终态未提交时仍按 unknown 核对外部效果,不能假定存在可恢复结果。补写与日志保留均不调用业务 Hook。保留策略失败仅告警,不撤销已保存日志。`onTaskFinish` 是本地业务完成通知,其 unconfirmed 状态不是持久化确认;以 trigger 返回值及 execution.get 的 logPersistence 为准。
|
|
177
|
+
|
|
178
|
+
领取前竞争/跳过的 interrupted 仅为观测日志,没有可补写的终态;其写入未确认时返回 unconfirmed。JSON 大载荷保存完整结果和全部重试记录,避免 MySQL 的 255 字符截断;新 schema 不自动迁移旧日志/执行表。关闭会等待在途补写,先关闭 scheduler 再关闭数据库。
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
182
|
+
if (!result.success)
|
|
183
|
+
return result
|
|
184
|
+
if (result.data.persistence.status === 'pending' && result.data.occurrenceId) {
|
|
185
|
+
const repaired = await scheduler.execution.repairLog(result.data.occurrenceId)
|
|
186
|
+
if (!repaired.success)
|
|
187
|
+
return repaired
|
|
188
|
+
}
|
|
189
|
+
// 不用日志补写失败重新 trigger 业务;定期按页扫描 listLogFailures。
|
|
190
|
+
```
|
|
191
|
+
|
|
182
192
|
## 错误码 — `HaiSchedulerError`
|
|
183
193
|
|
|
184
194
|
| 错误码 | code | 说明 |
|
|
@@ -195,8 +205,12 @@ await scheduler.init({
|
|
|
195
205
|
| `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存失败 |
|
|
196
206
|
| `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 已在运行 |
|
|
197
207
|
| `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 未在运行 |
|
|
198
|
-
| `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | 锁获取失败 |
|
|
199
208
|
| `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS 编译失败 |
|
|
200
209
|
| `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 执行失败 |
|
|
210
|
+
| `HaiSchedulerError.LOG_PERSIST_FAILED` | `hai:scheduler:032` | 日志持久化未确认 |
|
|
211
|
+
|
|
212
|
+
调度循环按分钟匹配 cron,同一分生成稳定的持久 occurrenceId。
|
|
213
|
+
|
|
214
|
+
数据库是共享租约的时间权威:claim/begin/renew/finish/expired/isolate 均在执行 SQL 内取存储端时钟并原子核验期限,不用节点 Date.now 或“先查 now 再写”的结果授权。PostgreSQL clock_timestamp、MySQL SYSDATE(3)、SQLite now 在真实锁等待后仍拒绝过期提交;updatedAt 条件版本使用同一数据库时钟并单调推进。cron 从 DB 时间生成分钟身份并匹配;默认 timezone 为 UTC,显式 IANA 时区只改变墙上时间解释,不读取节点 TZ。节点偏差 ±2h、不同 TZ 下同一分钟身份、过期但未接管、接管后的旧 owner 和行锁跨期限均须真实多进程验证。应用仍维护 DB 时钟与外部接收端的持久幂等/fence;未知效果不能靠补写日志或重新 trigger 重做。
|
|
201
215
|
|
|
202
|
-
|
|
216
|
+
公开使用以 @h-ai/scheduler 的实际入口与声明为准。声明 JSDoc 的公开示例检查 HaiResult,关闭调用使用 await scheduler.close()(返回 Promise<void>);内部 repository/runner/compiler/cron 示例标注“模块内部上下文”,不能照抄为根导入。业务 status 与 persistence 分开,pending 补写不会重跑业务;unknown/unconfirmed 先核对共享 journal 与外部效果。应用使用所安装版本的公开类型,并通过本应用已有的类型检查验证使用示例。
|