@h-ai/cli 0.1.0-alpha5
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/LICENSE +202 -0
- package/README.md +167 -0
- package/dist/chunk-2H7UOFLK.js +10 -0
- package/dist/chunk-2H7UOFLK.js.map +1 -0
- package/dist/chunk-LANIDJC7.js +659 -0
- package/dist/chunk-LANIDJC7.js.map +1 -0
- package/dist/chunk-YV7WPUI6.js +206 -0
- package/dist/chunk-YV7WPUI6.js.map +1 -0
- package/dist/config-templates-S5RI2MC6.js +5 -0
- package/dist/config-templates-S5RI2MC6.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1645 -0
- package/dist/index.js.map +1 -0
- package/dist/skill-templates-N5DU5FNX.js +5 -0
- package/dist/skill-templates-N5DU5FNX.js.map +1 -0
- package/dist/src-XJ6YXNAF.js +15030 -0
- package/dist/src-XJ6YXNAF.js.map +1 -0
- package/package.json +59 -0
- package/templates/README.md +126 -0
- package/templates/apps/admin/messages/en-US.json +33 -0
- package/templates/apps/admin/messages/zh-CN.json +33 -0
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +30 -0
- package/templates/apps/admin/src/routes/+page.svelte.hbs +35 -0
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +44 -0
- package/templates/apps/admin/src/routes/admin/+page.svelte.hbs +45 -0
- package/templates/apps/admin/src/routes/admin/settings/+page.svelte.hbs +37 -0
- package/templates/apps/admin/src/routes/api/health/+server.ts +12 -0
- package/templates/apps/android-app/.env.hbs +3 -0
- package/templates/apps/android-app/capacitor.config.ts.hbs +12 -0
- package/templates/apps/android-app/messages/en-US.json +24 -0
- package/templates/apps/android-app/messages/zh-CN.json +24 -0
- package/templates/apps/android-app/src/lib/api.ts.hbs +30 -0
- package/templates/apps/android-app/src/lib/capacitor.ts +21 -0
- package/templates/apps/android-app/src/routes/+layout.svelte.hbs +42 -0
- package/templates/apps/android-app/src/routes/+layout.ts +7 -0
- package/templates/apps/android-app/src/routes/+page.svelte.hbs +27 -0
- package/templates/apps/api/src/routes/+layout.svelte +14 -0
- package/templates/apps/api/src/routes/+page.svelte +11 -0
- package/templates/apps/api/src/routes/api/v1/health/+server.ts +14 -0
- package/templates/apps/api/src/routes/api/v1/items/+server.ts +37 -0
- package/templates/apps/api/src/routes/api/v1/items/[id]/+server.ts +30 -0
- package/templates/apps/h5/messages/en-US.json +37 -0
- package/templates/apps/h5/messages/zh-CN.json +37 -0
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +57 -0
- package/templates/apps/h5/src/routes/+page.svelte.hbs +27 -0
- package/templates/apps/h5/src/routes/discover/+page.svelte.hbs +27 -0
- package/templates/apps/h5/src/routes/profile/+page.svelte.hbs +27 -0
- package/templates/apps/website/messages/en-US.json +32 -0
- package/templates/apps/website/messages/zh-CN.json +32 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +66 -0
- package/templates/apps/website/src/routes/+page.svelte.hbs +33 -0
- package/templates/apps/website/src/routes/about/+page.svelte.hbs +27 -0
- package/templates/apps/website/src/routes/api/health/+server.ts +12 -0
- package/templates/apps/website/src/routes/contact/+page.svelte.hbs +59 -0
- package/templates/apps/website/src/routes/services/+page.svelte.hbs +63 -0
- package/templates/base/e2e/helpers.ts +49 -0
- package/templates/base/package.json.hbs +67 -0
- package/templates/base/playwright.config.ts +32 -0
- package/templates/base/src/app.css +2 -0
- package/templates/base/src/app.d.ts.hbs +18 -0
- package/templates/base/src/app.html.hbs +16 -0
- package/templates/base/src/hooks.server.ts.hbs +123 -0
- package/templates/base/src/lib/server/init.ts.hbs +190 -0
- package/templates/base/svelte.config.js.hbs +30 -0
- package/templates/base/tsconfig.json +8 -0
- package/templates/base/vite.config.ts.hbs +27 -0
- package/templates/features/ai/routes/api/chat/+server.ts +26 -0
- package/templates/features/datapipe/routes/api/datapipe/pipeline/+server.ts +41 -0
- package/templates/features/iam/routes-admin/(auth)/+layout.svelte.hbs +21 -0
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +98 -0
- package/templates/features/iam/routes-admin/(auth)/auth/register/+page.svelte.hbs +124 -0
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +98 -0
- package/templates/features/iam/routes-h5/auth/register/+page.svelte.hbs +114 -0
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +32 -0
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +15 -0
- package/templates/features/iam/routes-shared/api/auth/me/+server.ts +12 -0
- package/templates/features/iam/routes-shared/api/auth/register/+server.ts +26 -0
- package/templates/features/payment/routes/api/v1/payment/create/+server.ts +30 -0
- package/templates/features/payment/routes/api/v1/payment/notify/[provider]/+server.ts +42 -0
- package/templates/features/payment/routes/api/v1/payment/query/[orderNo]/+server.ts +25 -0
- package/templates/features/storage/routes/api/upload/+server.ts +35 -0
- package/templates/features/vecdb/routes/api/vecdb/search/+server.ts +36 -0
- package/templates/i18n/project.inlang/settings.json +11 -0
- package/templates/skills/AGENTS.md +26 -0
- package/templates/skills/CLAUDE.md +12 -0
- package/templates/skills/copilot-instructions.md +61 -0
- package/templates/skills/hai-ai/SKILL.md +659 -0
- package/templates/skills/hai-api-client/SKILL.md +346 -0
- package/templates/skills/hai-app-create/SKILL.md +371 -0
- package/templates/skills/hai-app-review/SKILL.md +156 -0
- package/templates/skills/hai-app-tests/SKILL.md +468 -0
- package/templates/skills/hai-audit/SKILL.md +141 -0
- package/templates/skills/hai-build/SKILL.md +319 -0
- package/templates/skills/hai-cache/SKILL.md +184 -0
- package/templates/skills/hai-capacitor/SKILL.md +389 -0
- package/templates/skills/hai-core/SKILL.md +544 -0
- package/templates/skills/hai-crypto/SKILL.md +183 -0
- package/templates/skills/hai-datapipe/SKILL.md +214 -0
- package/templates/skills/hai-deploy/SKILL.md +210 -0
- package/templates/skills/hai-iam/SKILL.md +355 -0
- package/templates/skills/hai-kit/SKILL.md +319 -0
- package/templates/skills/hai-payment/SKILL.md +289 -0
- package/templates/skills/hai-reach/SKILL.md +325 -0
- package/templates/skills/hai-reldb/SKILL.md +459 -0
- package/templates/skills/hai-scheduler/SKILL.md +242 -0
- package/templates/skills/hai-storage/SKILL.md +230 -0
- package/templates/skills/hai-ui/SKILL.md +275 -0
- package/templates/skills/hai-vecdb/SKILL.md +273 -0
- package/templates/skills/opencode.json +11 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hai-payment
|
|
3
|
+
description: 使用 @h-ai/payment 接入微信支付、支付宝、Stripe 统一支付;支持创建订单、回调验签、订单查询与退款;当需求涉及支付接入、订单创建、支付回调、退款或客户端调起支付时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# hai-payment
|
|
7
|
+
|
|
8
|
+
> `@h-ai/payment` 是 hai-framework 的统一支付模块,通过 Provider 模式支持微信支付、支付宝、Stripe。服务端处理订单和回调,客户端调起支付。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 依赖
|
|
13
|
+
|
|
14
|
+
| 模块 | 用途 | 是否必需 | 初始化要求 |
|
|
15
|
+
| --- | --- | --- | --- |
|
|
16
|
+
| `@h-ai/audit` | 审计日志(支付操作自动审计) | 可选 | 已初始化时自动写入审计日志 |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 适用场景
|
|
21
|
+
|
|
22
|
+
- 接入微信支付(JSAPI / H5 / Native / App)
|
|
23
|
+
- 接入支付宝(H5 / App / PC)
|
|
24
|
+
- 接入 Stripe(Checkout Session)
|
|
25
|
+
- 支付回调验签与通知处理
|
|
26
|
+
- 订单查询与退款
|
|
27
|
+
- 客户端调起支付(Web / H5 / App)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 使用步骤
|
|
32
|
+
|
|
33
|
+
### 1. 配置
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
# config/_payment.yml(自行创建)
|
|
37
|
+
wechat:
|
|
38
|
+
appId: ${HAI_PAYMENT_WECHAT_APP_ID}
|
|
39
|
+
mchId: ${HAI_PAYMENT_WECHAT_MCH_ID}
|
|
40
|
+
apiV3Key: ${HAI_PAYMENT_WECHAT_API_V3_KEY}
|
|
41
|
+
privateKey: ${HAI_PAYMENT_WECHAT_PRIVATE_KEY}
|
|
42
|
+
serialNo: ${HAI_PAYMENT_WECHAT_SERIAL_NO}
|
|
43
|
+
platformCert: ${HAI_PAYMENT_WECHAT_PLATFORM_CERT} # 可选,回调验签需要
|
|
44
|
+
|
|
45
|
+
alipay:
|
|
46
|
+
appId: ${HAI_PAYMENT_ALIPAY_APP_ID}
|
|
47
|
+
privateKey: ${HAI_PAYMENT_ALIPAY_PRIVATE_KEY}
|
|
48
|
+
alipayPublicKey: ${HAI_PAYMENT_ALIPAY_PUBLIC_KEY}
|
|
49
|
+
signType: RSA2 # 可选,默认 RSA2
|
|
50
|
+
sandbox: false # 可选,默认 false
|
|
51
|
+
|
|
52
|
+
stripe:
|
|
53
|
+
secretKey: ${HAI_PAYMENT_STRIPE_SECRET_KEY}
|
|
54
|
+
webhookSecret: ${HAI_PAYMENT_STRIPE_WEBHOOK_SECRET}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. 初始化(服务端)
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { payment } from '@h-ai/payment'
|
|
61
|
+
|
|
62
|
+
const initResult = await payment.init({
|
|
63
|
+
wechat: {
|
|
64
|
+
appId: 'wx1234567890',
|
|
65
|
+
mchId: '1600000000',
|
|
66
|
+
apiV3Key: 'your-api-key-v3',
|
|
67
|
+
privateKey: '-----BEGIN RSA PRIVATE KEY-----...',
|
|
68
|
+
serialNo: 'CERT_SERIAL_NO',
|
|
69
|
+
},
|
|
70
|
+
alipay: {
|
|
71
|
+
appId: '2021000000000000',
|
|
72
|
+
privateKey: '-----BEGIN RSA PRIVATE KEY-----...',
|
|
73
|
+
alipayPublicKey: '-----BEGIN PUBLIC KEY-----...',
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
if (!initResult.success) {
|
|
77
|
+
// 处理初始化错误
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. 创建订单
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const result = await payment.createOrder('wechat', {
|
|
85
|
+
orderNo: 'ORDER_20250101_001',
|
|
86
|
+
amount: 9900, // 单位:分
|
|
87
|
+
description: '商品名称',
|
|
88
|
+
tradeType: 'jsapi',
|
|
89
|
+
userId: 'user_openid',
|
|
90
|
+
notifyUrl: 'https://example.com/payment/notify/wechat',
|
|
91
|
+
metadata: { source: 'web' },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (result.success) {
|
|
95
|
+
// result.data: PaymentOrder { provider, tradeType, clientParams, prepayId? }
|
|
96
|
+
// clientParams 包含调起支付所需参数
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 4. 处理支付回调
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// API 路由:POST /payment/notify/:provider
|
|
104
|
+
const result = await payment.handleNotify('wechat', {
|
|
105
|
+
body: await request.text(),
|
|
106
|
+
headers: Object.fromEntries(request.headers),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (result.success) {
|
|
110
|
+
// result.data: { orderNo, transactionId, amount, status, paidAt? }
|
|
111
|
+
// 更新订单状态...
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 5. 查询订单
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const result = await payment.queryOrder('wechat', 'ORDER_20250101_001')
|
|
119
|
+
|
|
120
|
+
if (result.success) {
|
|
121
|
+
// result.data.status: 'pending' | 'paid' | 'closed' | 'refunded' | 'failed'
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 6. 退款
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
const result = await payment.refund('wechat', {
|
|
129
|
+
orderNo: 'ORDER_20250101_001',
|
|
130
|
+
refundNo: 'REFUND_20250101_001',
|
|
131
|
+
amount: 9900,
|
|
132
|
+
totalAmount: 9900, // 微信退款必填
|
|
133
|
+
reason: '用户退款',
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 7. 客户端调起支付(浏览器)
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { invokePayment } from '@h-ai/payment/client'
|
|
141
|
+
|
|
142
|
+
// 前端拿到服务端返回的 PaymentOrder 后调起支付
|
|
143
|
+
const payResult = await invokePayment(orderResult.data)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 核心 API(服务端)
|
|
149
|
+
|
|
150
|
+
| API | 用途 | 返回值 |
|
|
151
|
+
| ------------------------------------------------ | -------- | ----------------------------- |
|
|
152
|
+
| `payment.init(config)` | 初始化 | `HaiResult<void>` |
|
|
153
|
+
| `payment.close()` | 关闭 | `void` |
|
|
154
|
+
| `payment.createOrder(providerName, input)` | 创建订单 | `HaiResult<PaymentOrder>` |
|
|
155
|
+
| `payment.handleNotify(providerName, request)` | 处理回调 | `HaiResult<PaymentNotifyResult>` |
|
|
156
|
+
| `payment.queryOrder(providerName, orderNo)` | 查询订单 | `HaiResult<OrderStatus>` |
|
|
157
|
+
| `payment.refund(providerName, input)` | 退款 | `HaiResult<RefundResult>` |
|
|
158
|
+
| `payment.closeOrder(providerName, orderNo)` | 关闭订单 | `HaiResult<void>` |
|
|
159
|
+
| `payment.getProvider(name)` | 获取 Provider | `PaymentProvider \| undefined` |
|
|
160
|
+
| `payment.registerProvider(provider)` | 注册自定义 Provider | `void` |
|
|
161
|
+
|
|
162
|
+
## 客户端 API
|
|
163
|
+
|
|
164
|
+
| API | 用途 | 返回值 |
|
|
165
|
+
| ------------------------ | -------- | ----------------------------- |
|
|
166
|
+
| `invokePayment(options)` | 调起支付 | `HaiResult<InvokePaymentResult>` |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 错误码 — `HaiPaymentError`
|
|
171
|
+
|
|
172
|
+
| 错误码 | code | 说明 |
|
|
173
|
+
|--------|------|------|
|
|
174
|
+
| `HaiPaymentError.CREATE_ORDER_FAILED` | `hai:payment:001` | 创建订单失败 |
|
|
175
|
+
| `HaiPaymentError.QUERY_ORDER_FAILED` | `hai:payment:002` | 查询订单失败 |
|
|
176
|
+
| `HaiPaymentError.REFUND_FAILED` | `hai:payment:003` | 退款失败 |
|
|
177
|
+
| `HaiPaymentError.CLOSE_ORDER_FAILED` | `hai:payment:004` | 关闭订单失败 |
|
|
178
|
+
| `HaiPaymentError.NOT_INITIALIZED` | `hai:payment:010` | 模块未初始化 |
|
|
179
|
+
| `HaiPaymentError.SIGN_FAILED` | `hai:payment:020` | 签名失败 |
|
|
180
|
+
| `HaiPaymentError.PROVIDER_NOT_FOUND` | `hai:payment:030` | Provider 未注册 |
|
|
181
|
+
| `HaiPaymentError.INVALID_AMOUNT` | `hai:payment:040` | 金额无效 |
|
|
182
|
+
| `HaiPaymentError.NOTIFY_VERIFY_FAILED` | `hai:payment:050` | 回调验签失败 |
|
|
183
|
+
| `HaiPaymentError.NOTIFY_PARSE_FAILED` | `hai:payment:051` | 回调解析失败 |
|
|
184
|
+
| `HaiPaymentError.INVOKE_WEB_FAILED` | `hai:payment:060` | Web 端调起支付失败 |
|
|
185
|
+
| `HaiPaymentError.INVOKE_APP_FAILED` | `hai:payment:061` | App 端调起支付失败 |
|
|
186
|
+
| `HaiPaymentError.CONFIG_ERROR` | `hai:payment:070` | 配置无效 |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Provider 模式
|
|
191
|
+
|
|
192
|
+
### PaymentProvider 接口
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
interface PaymentProvider {
|
|
196
|
+
readonly name: string
|
|
197
|
+
createOrder: (input: CreateOrderInput) => Promise<HaiResult<PaymentOrder>>
|
|
198
|
+
handleNotify: (request: PaymentNotifyRequest) => Promise<HaiResult<PaymentNotifyResult>>
|
|
199
|
+
queryOrder: (outTradeNo: string) => Promise<HaiResult<OrderStatus>>
|
|
200
|
+
refund: (input: RefundInput) => Promise<HaiResult<RefundResult>>
|
|
201
|
+
closeOrder: (outTradeNo: string) => Promise<HaiResult<void>>
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
内置 Provider:
|
|
206
|
+
|
|
207
|
+
- **wechat** — 微信支付 API v3(RSA-SHA256 签名、AES-256-GCM 回调解密)
|
|
208
|
+
- **alipay** — 支付宝开放平台(RSA2 签名验签)
|
|
209
|
+
- **stripe** — Stripe Checkout Session 模式
|
|
210
|
+
|
|
211
|
+
### API 契约
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import { paymentEndpoints } from '@h-ai/payment/api'
|
|
215
|
+
|
|
216
|
+
// paymentEndpoints.createOrder — POST /payment/create
|
|
217
|
+
// paymentEndpoints.queryOrder — GET /payment/query
|
|
218
|
+
// paymentEndpoints.notifyWechat — POST /payment/notify/wechat
|
|
219
|
+
// paymentEndpoints.notifyAlipay — POST /payment/notify/alipay
|
|
220
|
+
// paymentEndpoints.notifyStripe — POST /payment/notify/stripe
|
|
221
|
+
// paymentEndpoints.refund — POST /payment/refund
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 常见模式
|
|
227
|
+
|
|
228
|
+
### 服务端路由(SvelteKit)
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { kit } from '@h-ai/kit'
|
|
232
|
+
// src/routes/api/v1/payment/create/+server.ts
|
|
233
|
+
import { payment } from '@h-ai/payment'
|
|
234
|
+
|
|
235
|
+
export const POST = kit.handler(async ({ request, locals }) => {
|
|
236
|
+
const body = await request.json()
|
|
237
|
+
const result = await payment.createOrder(body.provider, {
|
|
238
|
+
orderNo: body.orderNo,
|
|
239
|
+
amount: body.amount,
|
|
240
|
+
description: body.description,
|
|
241
|
+
tradeType: body.tradeType,
|
|
242
|
+
userId: body.userId,
|
|
243
|
+
notifyUrl: body.notifyUrl,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (!result.success) {
|
|
247
|
+
return kit.response.internalError(result.error.message)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return kit.response.ok(result.data, locals.requestId)
|
|
251
|
+
})
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### 完整支付流程
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
1. 客户端 → 服务端:创建订单(api.call(paymentEndpoints.createOrder, {...}))
|
|
258
|
+
2. 服务端 → 支付平台:调用 Provider 创建预支付
|
|
259
|
+
3. 服务端 → 客户端:返回 providerData(含调起参数)
|
|
260
|
+
4. 客户端:invokePayment(providerData) 调起支付
|
|
261
|
+
5. 支付平台 → 服务端:异步回调通知
|
|
262
|
+
6. 服务端:handleNotify() 验签 + 更新订单状态
|
|
263
|
+
7. 客户端:轮询或回调查询订单状态
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## 审计日志
|
|
269
|
+
|
|
270
|
+
关键支付操作成功后自动写入审计日志(依赖 `@h-ai/audit`),无需额外配置。失败仅 warn,不影响支付流程。
|
|
271
|
+
|
|
272
|
+
| 操作 | `action` | `resource` | `resourceId` | `details` |
|
|
273
|
+
| --- | --- | --- | --- | --- |
|
|
274
|
+
| 创建订单 | `create_order` | `payment` | `orderNo` | `{ provider, amount, tradeType }` |
|
|
275
|
+
| 支付回调 | `payment_notify` | `payment` | `orderNo` | `{ provider, transactionId, status, amount }` |
|
|
276
|
+
| 退款 | `refund` | `payment` | `orderNo` | `{ provider, refundNo, amount }` |
|
|
277
|
+
| 关闭订单 | `close_order` | `payment` | `orderNo` | `{ provider }` |
|
|
278
|
+
|
|
279
|
+
> `queryOrder` 为只读操作,不写审计日志。
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 相关 Skills
|
|
284
|
+
|
|
285
|
+
- `hai-kit`:服务端 API 路由与契约处理
|
|
286
|
+
- `hai-api-client`:客户端契约调用
|
|
287
|
+
- `hai-reldb`:订单持久化存储
|
|
288
|
+
- `hai-audit`:审计日志(payment 内部自动调用)
|
|
289
|
+
- `hai-core`:HaiResult 类型、日志、配置
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hai-reach
|
|
3
|
+
description: 使用 @h-ai/reach 进行邮件、短信和 API 回调发送;当需求涉及用户触达、消息通知、验证码发送、多渠道模板管理时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# hai-reach
|
|
7
|
+
|
|
8
|
+
> `@h-ai/reach` 提供统一的用户触达接口,支持同时注册多个 Provider(SMTP 邮件、短信、API 回调),内置模板引擎与免打扰(DND)机制。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 依赖
|
|
13
|
+
|
|
14
|
+
| 模块 | 用途 | 是否必需 | 初始化要求 |
|
|
15
|
+
| --- | --- | --- | --- |
|
|
16
|
+
| `@h-ai/reldb` | 数据库(发送日志与模板持久化) | 可选 | 已初始化时自动启用持久化 |
|
|
17
|
+
| `@h-ai/cache` | 缓存(DND delay 策略分布式锁) | 可选 | 已初始化时自动启用分布式锁 |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 适用场景
|
|
22
|
+
|
|
23
|
+
- 同时使用邮件、短信、API 回调发送通知
|
|
24
|
+
- 发送邮件通知(注册欢迎、密码重置、告警等)
|
|
25
|
+
- 发送短信验证码或通知
|
|
26
|
+
- 通过 HTTP API 回调触发第三方通知
|
|
27
|
+
- 定义和管理消息模板(模板绑定到具体 Provider,支持配置文件定义)
|
|
28
|
+
- 免打扰时段控制
|
|
29
|
+
- 基于 `HaiReachError` 做错误分支处理
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 使用步骤
|
|
34
|
+
|
|
35
|
+
### 1. 配置
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
# config/_reach.yml
|
|
39
|
+
providers:
|
|
40
|
+
- name: email
|
|
41
|
+
type: smtp
|
|
42
|
+
host: ${HAI_REACH_SMTP_HOST:smtp.example.com}
|
|
43
|
+
port: ${HAI_REACH_SMTP_PORT:465}
|
|
44
|
+
secure: true
|
|
45
|
+
user: ${HAI_REACH_SMTP_USER:}
|
|
46
|
+
pass: ${HAI_REACH_SMTP_PASS:}
|
|
47
|
+
from: ${HAI_REACH_SMTP_FROM:noreply@example.com}
|
|
48
|
+
- name: sms
|
|
49
|
+
type: aliyun-sms
|
|
50
|
+
accessKeyId: ${HAI_REACH_SMS_ACCESS_KEY:}
|
|
51
|
+
accessKeySecret: ${HAI_REACH_SMS_SECRET_KEY:}
|
|
52
|
+
signName: ${HAI_REACH_SMS_SIGN_NAME:}
|
|
53
|
+
- name: webhook
|
|
54
|
+
type: api
|
|
55
|
+
url: ${HAI_REACH_WEBHOOK_URL:}
|
|
56
|
+
|
|
57
|
+
# 模板(可选,也可通过代码注册)
|
|
58
|
+
templates:
|
|
59
|
+
- name: verification_code
|
|
60
|
+
provider: email
|
|
61
|
+
subject: '验证码: {code}'
|
|
62
|
+
body: '您的验证码是 {code},有效期 {minutes} 分钟。'
|
|
63
|
+
- name: sms_code
|
|
64
|
+
provider: sms
|
|
65
|
+
body: '验证码: {code},{minutes} 分钟内有效。'
|
|
66
|
+
|
|
67
|
+
# 免打扰(可选)
|
|
68
|
+
dnd:
|
|
69
|
+
enabled: true
|
|
70
|
+
strategy: delay # discard(丢弃)或 delay(延时,DND 结束后集中发送)
|
|
71
|
+
start: '22:00'
|
|
72
|
+
end: '08:00'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. 初始化与关闭
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { cache } from '@h-ai/cache'
|
|
79
|
+
import { core } from '@h-ai/core'
|
|
80
|
+
import { reach } from '@h-ai/reach'
|
|
81
|
+
import { reldb } from '@h-ai/reldb'
|
|
82
|
+
|
|
83
|
+
// 先初始化可选依赖(按需)
|
|
84
|
+
await reldb.init(core.config.get('db')) // 可选,启用发送日志与模板持久化
|
|
85
|
+
await cache.init(core.config.get('cache')) // 可选,启用 DND delay 策略分布式锁
|
|
86
|
+
|
|
87
|
+
// 再初始化触达模块(自动检测已初始化的 reldb/cache 单例)
|
|
88
|
+
await reach.init(core.config.get('reach'))
|
|
89
|
+
// ... 使用触达服务
|
|
90
|
+
await reach.close()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. 保存模板(模板绑定 Provider)
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// 通过代码保存(配置文件中的模板在 init 时自动注册)
|
|
97
|
+
await reach.template.save({
|
|
98
|
+
name: 'verification_code',
|
|
99
|
+
provider: 'email',
|
|
100
|
+
subject: '验证码: {code}',
|
|
101
|
+
body: '您的验证码是 {code},有效期 {minutes} 分钟。',
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await reach.template.saveBatch([
|
|
105
|
+
{ name: 'welcome', provider: 'email', subject: '欢迎 {userName}', body: '亲爱的 {userName},欢迎使用 {appName}!' },
|
|
106
|
+
{ name: 'sms_code', provider: 'sms', body: '验证码: {code},{minutes} 分钟内有效。' },
|
|
107
|
+
])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 4. 发送消息
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
// 使用模板发送邮件(指定 provider)
|
|
114
|
+
const result = await reach.send({
|
|
115
|
+
provider: 'email',
|
|
116
|
+
to: 'user@example.com',
|
|
117
|
+
template: 'verification_code',
|
|
118
|
+
vars: { code: '123456', minutes: '5' },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// 直接发送邮件(无模板)
|
|
122
|
+
await reach.send({
|
|
123
|
+
provider: 'email',
|
|
124
|
+
to: 'user@example.com',
|
|
125
|
+
subject: '通知',
|
|
126
|
+
body: '<h1>Hello</h1>',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// 发送短信(通过 extra 传递 Provider 特有参数)
|
|
130
|
+
await reach.send({
|
|
131
|
+
provider: 'sms',
|
|
132
|
+
to: '13800138000',
|
|
133
|
+
extra: { templateCode: 'SMS_123456' },
|
|
134
|
+
vars: { code: '654321' },
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// API 回调
|
|
138
|
+
await reach.send({
|
|
139
|
+
provider: 'webhook',
|
|
140
|
+
to: 'user@example.com',
|
|
141
|
+
body: '{"event":"signup"}',
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 核心 API
|
|
148
|
+
|
|
149
|
+
### reach 对象
|
|
150
|
+
|
|
151
|
+
| 方法 / 属性 | 签名 | 说明 |
|
|
152
|
+
| --------------------- | -------------------------------------------------------- | ---------------------------------- |
|
|
153
|
+
| `reach.init` | `(config: ReachConfigInput) => Promise<HaiResult<void>>` | 初始化(注册多个 Provider) |
|
|
154
|
+
| `reach.send` | `(message: ReachMessage) => Promise<HaiResult<SendResult>>` | 发送消息(通过 provider 字段路由) |
|
|
155
|
+
| `reach.template` | `ReachTemplateRegistry` | 模板注册表 |
|
|
156
|
+
| `reach.config` | `ReachConfig \| null` | 当前配置 |
|
|
157
|
+
| `reach.isInitialized` | `boolean` | 是否已初始化 |
|
|
158
|
+
| `reach.close` | `() => Promise<void>` | 关闭所有连接 |
|
|
159
|
+
|
|
160
|
+
### ReachConfigInput
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
interface ReachConfigInput {
|
|
164
|
+
providers: ProviderConfig[] // 多个 Provider 配置
|
|
165
|
+
templates?: TemplateConfig[] // 通过配置文件定义的模板
|
|
166
|
+
dnd?: {
|
|
167
|
+
enabled: boolean // 是否启用
|
|
168
|
+
strategy: 'discard' | 'delay' // discard 丢弃 / delay 延时发送
|
|
169
|
+
start: string // 开始时间 HH:mm
|
|
170
|
+
end: string // 结束时间 HH:mm
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### ReachMessage
|
|
176
|
+
|
|
177
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
178
|
+
| ---------- | ------------------------- | ---- | ----------------------------------- |
|
|
179
|
+
| `provider` | `string` | ✅ | 目标 Provider 名称 |
|
|
180
|
+
| `to` | `string` | ✅ | 接收方(邮箱或手机号) |
|
|
181
|
+
| `subject` | `string` | — | 邮件主题(直接发送时) |
|
|
182
|
+
| `body` | `string` | — | 消息正文(直接发送时) |
|
|
183
|
+
| `template` | `string` | — | 模板名称(模板发送时) |
|
|
184
|
+
| `vars` | `Record<string, string>` | — | 模板变量 |
|
|
185
|
+
| `extra` | `Record<string, unknown>` | — | Provider 扩展参数(如短信模板编码) |
|
|
186
|
+
|
|
187
|
+
### ReachTemplate(模板绑定 Provider)
|
|
188
|
+
|
|
189
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
190
|
+
| ---------- | -------- | ---- | -------------------- |
|
|
191
|
+
| `name` | `string` | ✅ | 模板名称 |
|
|
192
|
+
| `provider` | `string` | ✅ | 绑定的 Provider 名称 |
|
|
193
|
+
| `subject` | `string` | — | 邮件主题模板 |
|
|
194
|
+
| `body` | `string` | ✅ | 正文模板 |
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 错误码
|
|
199
|
+
|
|
200
|
+
| 错误码 | code | 说明 |
|
|
201
|
+
|--------|------|------|
|
|
202
|
+
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | 发送失败 |
|
|
203
|
+
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | 模板未找到 |
|
|
204
|
+
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | 模板渲染失败 |
|
|
205
|
+
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 无效接收方 |
|
|
206
|
+
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider 未找到 |
|
|
207
|
+
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | 免打扰丢弃 |
|
|
208
|
+
| `HaiReachError.DND_DEFERRED` | `hai:reach:007` | 免打扰延时暂存 |
|
|
209
|
+
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 模块未初始化 |
|
|
210
|
+
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 不支持的类型 |
|
|
211
|
+
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 配置错误 |
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 常见模式
|
|
216
|
+
|
|
217
|
+
### 多渠道验证码发送
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
reach.template.save({
|
|
221
|
+
name: 'email_code',
|
|
222
|
+
provider: 'email',
|
|
223
|
+
subject: '验证码: {code}',
|
|
224
|
+
body: '您的验证码是 {code},有效期 {minutes} 分钟。',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
reach.template.save({
|
|
228
|
+
name: 'sms_code',
|
|
229
|
+
provider: 'sms',
|
|
230
|
+
body: '验证码: {code},{minutes} 分钟内有效。',
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// 根据用户选择的渠道发送
|
|
234
|
+
async function sendCode(channel: 'email' | 'sms', target: string, code: string) {
|
|
235
|
+
const template = channel === 'email' ? 'email_code' : 'sms_code'
|
|
236
|
+
return reach.send({
|
|
237
|
+
provider: channel,
|
|
238
|
+
to: target,
|
|
239
|
+
template,
|
|
240
|
+
vars: { code, minutes: '5' },
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### 与 IAM 集成(密码重置 / OTP 验证码)
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { iam } from '@h-ai/iam'
|
|
249
|
+
import { reach } from '@h-ai/reach'
|
|
250
|
+
|
|
251
|
+
// 初始化 reach(注册 email 和 sms Provider)
|
|
252
|
+
await reach.init({
|
|
253
|
+
providers: [
|
|
254
|
+
{ name: 'email', type: 'smtp', host: 'smtp.example.com', from: 'noreply@example.com' },
|
|
255
|
+
{ name: 'sms', type: 'aliyun-sms', accessKeyId: '...', accessKeySecret: '...', signName: '...' },
|
|
256
|
+
],
|
|
257
|
+
templates: [
|
|
258
|
+
{ name: 'password_reset', provider: 'email', subject: 'Password Reset', body: 'Token: {token}, expires: {expiresAt}' },
|
|
259
|
+
{ name: 'otp_email', provider: 'email', subject: 'Code: {code}', body: 'Your code is {code}' },
|
|
260
|
+
{ name: 'otp_sms', provider: 'sms', body: 'Your code is {code}' },
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// 初始化 IAM,使用 reach 发送通知
|
|
265
|
+
await iam.init({
|
|
266
|
+
db,
|
|
267
|
+
cache,
|
|
268
|
+
onPasswordResetRequest: async (user, token, expiresAt) => {
|
|
269
|
+
await reach.send({ provider: 'email', to: user.email ?? '', template: 'password_reset', vars: { token, expiresAt: expiresAt.toISOString() } })
|
|
270
|
+
},
|
|
271
|
+
onOtpSendEmail: async (email, code) => {
|
|
272
|
+
await reach.send({ provider: 'email', to: email, template: 'otp_email', vars: { code } })
|
|
273
|
+
},
|
|
274
|
+
onOtpSendSms: async (phone, code) => {
|
|
275
|
+
await reach.send({ provider: 'sms', to: phone, template: 'otp_sms', vars: { code } })
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### 错误处理
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import { HaiReachError } from '@h-ai/reach'
|
|
284
|
+
|
|
285
|
+
const result = await reach.send(message)
|
|
286
|
+
if (!result.success) {
|
|
287
|
+
switch (result.error.code) {
|
|
288
|
+
case HaiReachError.NOT_INITIALIZED.code:
|
|
289
|
+
break
|
|
290
|
+
case HaiReachError.PROVIDER_NOT_FOUND.code:
|
|
291
|
+
break
|
|
292
|
+
case HaiReachError.DND_BLOCKED.code:
|
|
293
|
+
// 免打扰时段(discard 策略),消息已丢弃
|
|
294
|
+
break
|
|
295
|
+
case HaiReachError.TEMPLATE_NOT_FOUND.code:
|
|
296
|
+
break
|
|
297
|
+
case HaiReachError.SEND_FAILED.code:
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### 分布式锁(DND flush 保护)
|
|
304
|
+
|
|
305
|
+
> 多节点部署时,DND(delay 策略)的 pending 消息 flush 通过 `@h-ai/cache` 分布式锁保护,确保同一时刻只有一个节点执行 flush。
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
import { cache } from '@h-ai/cache'
|
|
309
|
+
|
|
310
|
+
// 初始化 cache 后,reach 自动使用分布式锁保护 flush 操作
|
|
311
|
+
await cache.init({ type: 'redis', host: 'localhost', port: 6379 })
|
|
312
|
+
await reach.init({ /* ... */ })
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- 锁键:`reach:flush-pending`,TTL 60 秒
|
|
316
|
+
- 若 cache 未初始化,分布式锁自动禁用,不影响单节点运行
|
|
317
|
+
- 使用稳定的进程级 owner 标识,防止误释放他人锁
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 相关 Skills
|
|
322
|
+
|
|
323
|
+
- `hai-core` — 配置加载、日志、HaiResult 模式
|
|
324
|
+
- `hai-reldb` — 数据库操作(存储发送记录等)
|
|
325
|
+
- `hai-cache` — 缓存操作、分布式锁(DND flush 互斥)
|