beervid-app-cli 0.2.3 → 0.2.5
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 +57 -1
- package/dist/cli.mjs +84 -65
- package/package.json +2 -4
- package/skills/beervid-app-cli/SKILL.md +318 -0
- package/skills/beervid-app-cli/docs/database-schema.md +231 -0
- package/skills/beervid-app-cli/docs/oauth-callback.md +282 -0
- package/skills/beervid-app-cli/docs/retry-and-idempotency.md +295 -0
- package/skills/beervid-app-cli/docs/tt-poll-task.md +239 -0
- package/skills/beervid-app-cli/docs/tts-product-cache.md +256 -0
- package/skills/beervid-app-cli/example/express/README.md +58 -0
- package/skills/beervid-app-cli/example/express/package.json +20 -0
- package/skills/beervid-app-cli/example/express/server.ts +431 -0
- package/skills/beervid-app-cli/example/express/tsconfig.json +12 -0
- package/skills/beervid-app-cli/example/nextjs/.env.example +3 -0
- package/skills/beervid-app-cli/example/nextjs/README.md +54 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/oauth/callback/route.ts +34 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/oauth/url/route.ts +30 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/products/route.ts +43 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/publish/tt/route.ts +116 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/publish/tts/route.ts +58 -0
- package/skills/beervid-app-cli/example/nextjs/app/api/status/[shareId]/route.ts +41 -0
- package/skills/beervid-app-cli/example/nextjs/app/layout.tsx +9 -0
- package/skills/beervid-app-cli/example/nextjs/app/page.tsx +80 -0
- package/skills/beervid-app-cli/example/nextjs/lib/beervid-client.ts +107 -0
- package/skills/beervid-app-cli/example/nextjs/next.config.ts +4 -0
- package/skills/beervid-app-cli/example/nextjs/package.json +19 -0
- package/skills/beervid-app-cli/example/nextjs/tsconfig.json +23 -0
- package/skills/beervid-app-cli/example/standard/README.md +51 -0
- package/skills/beervid-app-cli/example/standard/api-client.ts +181 -0
- package/skills/beervid-app-cli/example/standard/get-oauth-url.ts +44 -0
- package/skills/beervid-app-cli/example/standard/package.json +18 -0
- package/skills/beervid-app-cli/example/standard/query-products.ts +141 -0
- package/skills/beervid-app-cli/example/standard/tsconfig.json +12 -0
- package/skills/beervid-app-cli/example/standard/tt-publish-flow.ts +194 -0
- package/skills/beervid-app-cli/example/standard/tts-publish-flow.ts +246 -0
- package/SKILL.md +0 -486
- package/agents/openai.yaml +0 -7
- /package/{references → skills/beervid-app-cli/references}/api-reference.md +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# OAuth 回调存储建议
|
|
2
|
+
|
|
3
|
+
> 本文档描述接入 BEERVID OAuth 授权回调后,如何安全地处理回调参数、防止 CSRF 攻击、以及持久化账号信息。
|
|
4
|
+
|
|
5
|
+
## 授权流程总览
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
9
|
+
│ 你的应用 │────→│ BEERVID │────→│ TikTok │────→│ 回调 URL │
|
|
10
|
+
│ │ ① │ OAuth URL│ ② │ 授权页面 │ ③ │ 你的服务器│
|
|
11
|
+
└─────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
12
|
+
获取 URL 重定向 用户授权 收到回调参数
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
1. **获取 OAuth URL**:调用 `GET /api/v1/open/thirdparty-auth/tt-url` 或 `tts-url`
|
|
16
|
+
2. **用户跳转授权**:将用户重定向到返回的 URL
|
|
17
|
+
3. **接收回调**:用户授权后,TikTok 回调你的服务器
|
|
18
|
+
|
|
19
|
+
> **说明**:获取到的授权链接中通常已经包含 `state` 参数。
|
|
20
|
+
> 如果你需要附加自定义安全字段,做法不是新增一个 `state` 参数,而是解析现有 `state`,在其 JSON 中追加字段后再写回授权链接。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 回调参数
|
|
25
|
+
|
|
26
|
+
OAuth 授权完成后,回调 URL 会携带以下关键参数:
|
|
27
|
+
|
|
28
|
+
| 账号类型 | 回调参数 | 含义 | 后续用途 |
|
|
29
|
+
|----------|----------|------|----------|
|
|
30
|
+
| TT | `ttAbId` | TT 账号的 businessId | 所有 TT 操作的入参 |
|
|
31
|
+
| TTS | `ttsAbId` | TTS 账号的 creatorUserOpenId | 所有 TTS 操作的入参 |
|
|
32
|
+
|
|
33
|
+
> **重要**:`ttAbId` 就是后续所有 TT API 的 `businessId`,`ttsAbId` 就是所有 TTS API 的 `creatorUserOpenId`。
|
|
34
|
+
> 这两个值必须可靠持久化,丢失意味着需要用户重新授权。
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 获取授权链接后如何向已有 State 追加自定义字段
|
|
39
|
+
|
|
40
|
+
如果获取到的授权链接里已经自带 `state`,并且它的值是一个 JSON 字符串,可以在拿到 OAuth URL 后解析这个 JSON,再把你方的安全 token 追加进去:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
function tryParseJsonObject(value: string): Record<string, unknown> | null {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(value) as unknown
|
|
46
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
47
|
+
return parsed as Record<string, unknown>
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function appendTokenToOAuthUrlState(
|
|
56
|
+
rawUrl: string,
|
|
57
|
+
customStateToken: string
|
|
58
|
+
): string {
|
|
59
|
+
const url = new URL(rawUrl)
|
|
60
|
+
const rawState = url.searchParams.get('state')
|
|
61
|
+
|
|
62
|
+
if (!rawState) {
|
|
63
|
+
throw new Error('授权链接中缺少 state 参数')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parsedState = tryParseJsonObject(rawState)
|
|
67
|
+
if (!parsedState) {
|
|
68
|
+
throw new Error('授权链接中的 state 不是可追加字段的 JSON 对象')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const nextState = {
|
|
72
|
+
...parsedState,
|
|
73
|
+
customStateToken,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
url.searchParams.set('state', JSON.stringify(nextState))
|
|
77
|
+
return url.toString()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getTtOAuthUrlWithState(userId: string): Promise<string> {
|
|
81
|
+
const customStateToken = generateStateToken(userId)
|
|
82
|
+
const rawUrl = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
|
|
83
|
+
return appendTokenToOAuthUrlState(rawUrl, customStateToken)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getTtsOAuthUrlWithState(userId: string): Promise<string> {
|
|
87
|
+
const customStateToken = generateStateToken(userId)
|
|
88
|
+
const data = await openApiGet<{ crossBorderUrl: string }>(
|
|
89
|
+
'/api/v1/open/thirdparty-auth/tts-url'
|
|
90
|
+
)
|
|
91
|
+
return appendTokenToOAuthUrlState(data.crossBorderUrl, customStateToken)
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
回调时再从 `state` JSON 中取回你追加的字段并校验:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
function parseCustomStateToken(state: string): string {
|
|
99
|
+
const parsed = tryParseJsonObject(state) as { customStateToken?: string } | null
|
|
100
|
+
if (!parsed) {
|
|
101
|
+
throw new Error('state 不是合法 JSON 对象')
|
|
102
|
+
}
|
|
103
|
+
if (!parsed.customStateToken) {
|
|
104
|
+
throw new Error('state 中缺少 customStateToken')
|
|
105
|
+
}
|
|
106
|
+
return parsed.customStateToken
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
> **说明**:字段名 `customStateToken` 只是示例,你也可以改成 `token`、`nonce`、`appState` 等业务上更合适的名字。
|
|
111
|
+
> 如果授权链接里的 `state` 不是 JSON 对象,就不要按这个示例直接 `JSON.parse`;应改为使用平台允许的编码方式,或采用你当前链路已有的透传机制。
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## State Token 防 CSRF
|
|
116
|
+
|
|
117
|
+
### 为什么需要
|
|
118
|
+
|
|
119
|
+
OAuth 回调容易受到 CSRF 攻击——攻击者可以伪造回调请求,将恶意账号绑定到受害者系统。
|
|
120
|
+
|
|
121
|
+
### 推荐方案:JWT 格式 State Token
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import jwt from 'jsonwebtoken'
|
|
125
|
+
|
|
126
|
+
const STATE_SECRET = process.env.OAUTH_STATE_SECRET!
|
|
127
|
+
|
|
128
|
+
// ① 生成 State Token(在获取 OAuth URL 时)
|
|
129
|
+
function generateStateToken(userId: string): string {
|
|
130
|
+
return jwt.sign(
|
|
131
|
+
{
|
|
132
|
+
userId, // 当前登录用户 ID
|
|
133
|
+
purpose: 'beervid-oauth',
|
|
134
|
+
nonce: crypto.randomUUID(), // 一次性随机值
|
|
135
|
+
},
|
|
136
|
+
STATE_SECRET,
|
|
137
|
+
{ expiresIn: '10m' } // 短过期时间:10 分钟
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ② 验证 State Token(在回调中)
|
|
142
|
+
function verifyStateToken(state: string): { userId: string; nonce: string } {
|
|
143
|
+
try {
|
|
144
|
+
const payload = jwt.verify(state, STATE_SECRET) as {
|
|
145
|
+
userId: string
|
|
146
|
+
purpose: string
|
|
147
|
+
nonce: string
|
|
148
|
+
}
|
|
149
|
+
if (payload.purpose !== 'beervid-oauth') {
|
|
150
|
+
throw new Error('Invalid state purpose')
|
|
151
|
+
}
|
|
152
|
+
return { userId: payload.userId, nonce: payload.nonce }
|
|
153
|
+
} catch {
|
|
154
|
+
throw new Error('State Token 验证失败:可能已过期或被篡改')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 一次性消费
|
|
160
|
+
|
|
161
|
+
为防止重放攻击,State Token 应确保只使用一次:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// 使用 Redis 记录已消费的 nonce
|
|
165
|
+
const NONCE_TTL = 600 // 与 State Token 过期时间一致
|
|
166
|
+
|
|
167
|
+
async function consumeStateNonce(nonce: string): Promise<boolean> {
|
|
168
|
+
// SET NX:仅当 key 不存在时设置成功
|
|
169
|
+
const result = await redis.set(`oauth:nonce:${nonce}`, '1', 'EX', NONCE_TTL, 'NX')
|
|
170
|
+
return result === 'OK' // true = 首次使用,false = 已被消费
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 回调处理完整流程
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
async function handleOAuthCallback(req: Request): Promise<Response> {
|
|
180
|
+
const url = new URL(req.url)
|
|
181
|
+
const state = url.searchParams.get('state')
|
|
182
|
+
const ttAbId = url.searchParams.get('ttAbId')
|
|
183
|
+
const ttsAbId = url.searchParams.get('ttsAbId')
|
|
184
|
+
|
|
185
|
+
// ① 验证 State Token
|
|
186
|
+
if (!state) {
|
|
187
|
+
return new Response('缺少 state 参数', { status: 400 })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let statePayload: { userId: string; nonce: string }
|
|
191
|
+
try {
|
|
192
|
+
statePayload = verifyStateToken(state)
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return new Response('授权链接已过期,请重新发起授权', { status: 403 })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ② 一次性消费检查
|
|
198
|
+
const isFirstUse = await consumeStateNonce(statePayload.nonce)
|
|
199
|
+
if (!isFirstUse) {
|
|
200
|
+
return new Response('该授权回调已处理过,请勿重复提交', { status: 409 })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ③ 判断账号类型并持久化
|
|
204
|
+
if (ttAbId) {
|
|
205
|
+
await saveAccount({
|
|
206
|
+
accountType: 'TT',
|
|
207
|
+
accountId: ttAbId,
|
|
208
|
+
businessId: ttAbId,
|
|
209
|
+
appUserId: statePayload.userId,
|
|
210
|
+
status: 'ACTIVE',
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (ttsAbId) {
|
|
215
|
+
await saveAccount({
|
|
216
|
+
accountType: 'TTS',
|
|
217
|
+
accountId: ttsAbId,
|
|
218
|
+
creatorUserOpenId: ttsAbId,
|
|
219
|
+
appUserId: statePayload.userId,
|
|
220
|
+
status: 'ACTIVE',
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ④ 异步拉取账号详情(不阻塞回调响应)
|
|
225
|
+
const accountId = ttAbId || ttsAbId
|
|
226
|
+
const accountType = ttAbId ? 'TT' : 'TTS'
|
|
227
|
+
|
|
228
|
+
// 使用 fire-and-forget 或投递到消息队列
|
|
229
|
+
syncAccountInfoAsync(accountType, accountId!).catch((err) => {
|
|
230
|
+
console.error('异步同步账号信息失败(不影响授权结果):', err.message)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ⑤ 返回成功页面或重定向
|
|
234
|
+
return Response.redirect('/dashboard?oauth=success', 302)
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 异步账号信息同步
|
|
241
|
+
|
|
242
|
+
授权回调只返回 `accountId`,详细信息(头像、粉丝数、用户名等)需要额外调用 `account/info` 接口获取。
|
|
243
|
+
|
|
244
|
+
**为什么异步?**
|
|
245
|
+
- 回调请求应快速响应,避免用户长时间等待
|
|
246
|
+
- 头像同步等操作允许延迟几秒完成
|
|
247
|
+
- 即使同步失败,授权本身已成功
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
async function syncAccountInfoAsync(
|
|
251
|
+
accountType: 'TT' | 'TTS',
|
|
252
|
+
accountId: string
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const data = await openApiPost('/api/v1/open/account/info', {
|
|
255
|
+
accountType,
|
|
256
|
+
accountId,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await updateAccount(accountId, {
|
|
260
|
+
username: data.username,
|
|
261
|
+
displayName: data.displayName,
|
|
262
|
+
sellerName: data.sellerName,
|
|
263
|
+
profileUrl: data.profileUrl,
|
|
264
|
+
followersCount: data.followersCount,
|
|
265
|
+
accessToken: data.accessToken,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 安全要点速查
|
|
273
|
+
|
|
274
|
+
| 要点 | 做法 |
|
|
275
|
+
|------|------|
|
|
276
|
+
| 防 CSRF | 使用 JWT State Token,回调时验证签名和过期时间 |
|
|
277
|
+
| 防重放 | 一次性 nonce,Redis 记录已消费 |
|
|
278
|
+
| 短过期 | State Token 有效期 10 分钟 |
|
|
279
|
+
| 用户绑定 | State Token 中嵌入 userId,确保回调绑定到正确用户 |
|
|
280
|
+
| 数据完整性 | 回调中立即持久化 accountId,异步补充详情 |
|
|
281
|
+
| 失败容忍 | 异步同步失败不影响授权成功状态 |
|
|
282
|
+
| HTTPS | 回调 URL 必须使用 HTTPS |
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# 失败重试与幂等建议
|
|
2
|
+
|
|
3
|
+
> 本文档分析 BEERVID Open API 各接口的幂等性,提供重试策略和幂等键设计的最佳实践。
|
|
4
|
+
|
|
5
|
+
## 各 API 幂等性分析
|
|
6
|
+
|
|
7
|
+
> **幂等**:同一请求多次执行,结果与执行一次相同,不产生副作用。
|
|
8
|
+
|
|
9
|
+
| API | 端点 | 幂等性 | 安全重试 | 说明 |
|
|
10
|
+
|-----|------|--------|----------|------|
|
|
11
|
+
| 获取 OAuth URL | `GET tt-url / tts-url` | ✅ 天然幂等 | ✅ | 每次返回新 URL,但不产生副作用 |
|
|
12
|
+
| 查询账号信息 | `POST account/info` | ✅ 天然幂等 | ✅ | 只读查询 |
|
|
13
|
+
| 获取上传凭证 | `POST upload-token/generate` | ❌ 非幂等 | ✅ | 每次返回新 token,但旧 token 仍有效 |
|
|
14
|
+
| 视频上传 | `POST file-upload` | ⚠️ 条件幂等 | ⚠️ | 同文件重复上传会产生不同 fileUrl |
|
|
15
|
+
| 普通视频发布 | `POST video/publish` | ❌ 非幂等 | ❌ | 重复调用可能发布多个视频 |
|
|
16
|
+
| 挂车视频发布 | `POST shoppable-video/publish` | ❌ 非幂等 | ❌ | 同上 |
|
|
17
|
+
| 轮询发布状态 | `POST video/status` | ✅ 天然幂等 | ✅ | 只读查询 |
|
|
18
|
+
| 查询视频数据 | `POST video/query` | ✅ 天然幂等 | ✅ | 只读查询 |
|
|
19
|
+
| 查询商品列表 | `POST products/query` | ✅ 天然幂等 | ✅ | 只读查询 |
|
|
20
|
+
|
|
21
|
+
### 关键结论
|
|
22
|
+
|
|
23
|
+
| 场景 | 风险 | 防护手段 |
|
|
24
|
+
|------|------|----------|
|
|
25
|
+
| **视频发布重试** | 🔴 高风险 — 重复发布产生多条视频 | 必须客户端幂等保护 |
|
|
26
|
+
| **视频上传重试** | 🟡 中风险 — 产生冗余文件但不影响业务 | 建议 MD5 去重 |
|
|
27
|
+
| **查询类重试** | 🟢 低风险 — 只读操作无副作用 | 可放心重试 |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 重试策略
|
|
32
|
+
|
|
33
|
+
### 推荐:指数退避 + 抖动
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
interface RetryOptions {
|
|
37
|
+
maxRetries: number // 最大重试次数
|
|
38
|
+
baseDelay: number // 基础延迟(毫秒)
|
|
39
|
+
maxDelay: number // 最大延迟(毫秒)
|
|
40
|
+
jitterFactor: number // 抖动因子 (0-1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_RETRY: RetryOptions = {
|
|
44
|
+
maxRetries: 3,
|
|
45
|
+
baseDelay: 1000, // 1 秒
|
|
46
|
+
maxDelay: 30000, // 30 秒
|
|
47
|
+
jitterFactor: 0.5, // ±50% 随机抖动
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function calculateDelay(attempt: number, options: RetryOptions): number {
|
|
51
|
+
// 指数退避:1s → 2s → 4s → 8s → ...
|
|
52
|
+
const exponentialDelay = Math.min(
|
|
53
|
+
options.baseDelay * Math.pow(2, attempt - 1),
|
|
54
|
+
options.maxDelay
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// 加入随机抖动,避免多客户端同时重试的"惊群"
|
|
58
|
+
const jitter = exponentialDelay * options.jitterFactor * (Math.random() * 2 - 1)
|
|
59
|
+
return Math.max(0, exponentialDelay + jitter)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function withRetry<T>(
|
|
63
|
+
fn: () => Promise<T>,
|
|
64
|
+
options: RetryOptions = DEFAULT_RETRY,
|
|
65
|
+
shouldRetry?: (error: Error) => boolean
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
let lastError: Error | null = null
|
|
68
|
+
|
|
69
|
+
for (let attempt = 1; attempt <= options.maxRetries + 1; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
return await fn()
|
|
72
|
+
} catch (err) {
|
|
73
|
+
lastError = err as Error
|
|
74
|
+
|
|
75
|
+
// 判断是否可重试
|
|
76
|
+
if (attempt > options.maxRetries) break
|
|
77
|
+
if (shouldRetry && !shouldRetry(lastError)) break
|
|
78
|
+
|
|
79
|
+
const delay = calculateDelay(attempt, options)
|
|
80
|
+
console.warn(`第 ${attempt} 次失败,${(delay / 1000).toFixed(1)}s 后重试: ${lastError.message}`)
|
|
81
|
+
await new Promise(r => setTimeout(r, delay))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw lastError
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 可重试条件
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
function isRetryableError(error: Error): boolean {
|
|
93
|
+
const message = error.message
|
|
94
|
+
|
|
95
|
+
// 网络错误:可重试
|
|
96
|
+
if (message.includes('fetch failed') || message.includes('ECONNRESET')) return true
|
|
97
|
+
|
|
98
|
+
// 5xx 服务端错误:可重试
|
|
99
|
+
if (/HTTP 5\d\d/.test(message)) return true
|
|
100
|
+
|
|
101
|
+
// 超时:可重试
|
|
102
|
+
if (message.includes('timeout') || message.includes('ETIMEDOUT')) return true
|
|
103
|
+
|
|
104
|
+
// 429 限流:可重试(通常需要更长等待)
|
|
105
|
+
if (message.includes('429') || message.includes('rate limit')) return true
|
|
106
|
+
|
|
107
|
+
// 4xx 客户端错误:通常不可重试(参数错误、权限不足等)
|
|
108
|
+
if (/HTTP 4\d\d/.test(message)) return false
|
|
109
|
+
|
|
110
|
+
// Open API 业务错误:通常不可重试
|
|
111
|
+
if (message.includes('Open API 错误')) return false
|
|
112
|
+
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 各场景推荐配置
|
|
118
|
+
|
|
119
|
+
| 场景 | maxRetries | baseDelay | 说明 |
|
|
120
|
+
|------|-----------|-----------|------|
|
|
121
|
+
| 查询类(只读) | 3 | 1s | 安全重试,快速回复 |
|
|
122
|
+
| 上传凭证获取 | 3 | 2s | 凭证有效期 30 分钟,有充裕时间 |
|
|
123
|
+
| 视频上传 | 2 | 5s | 大文件上传耗时,间隔适当加大 |
|
|
124
|
+
| 视频发布 | **0** | — | **不重试**,使用幂等机制保护 |
|
|
125
|
+
| 队列消费 | 3 | 2s | 配合 acknowledge 限制总重试次数 |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 幂等键设计
|
|
130
|
+
|
|
131
|
+
### 发布幂等
|
|
132
|
+
|
|
133
|
+
发布是最需要幂等保护的操作。推荐方案:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// 生成幂等键
|
|
137
|
+
function generatePublishIdempotencyKey(
|
|
138
|
+
accountId: string,
|
|
139
|
+
publishRequestId: string // 由你方业务生成,并在重试时复用
|
|
140
|
+
): string {
|
|
141
|
+
return `publish:${accountId}:${publishRequestId}`
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`publishRequestId` 应该是一次“发布意图”的稳定标识,例如:
|
|
146
|
+
|
|
147
|
+
- 你方数据库里的发布草稿 ID
|
|
148
|
+
- 前端首次点击发布时生成的 `clientRequestId`
|
|
149
|
+
- 业务单号 / 任务 ID
|
|
150
|
+
|
|
151
|
+
关键点是:**首次提交和后续重试必须使用同一个值**。不要把时间戳、当前分钟窗口这类会变化的值拼进幂等键,否则跨时间窗口重试时会失去防重效果。
|
|
152
|
+
|
|
153
|
+
### 数据库层幂等
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
async function publishWithIdempotency(params: PublishParams): Promise<PublishResult> {
|
|
157
|
+
const idempotencyKey = generatePublishIdempotencyKey(
|
|
158
|
+
params.accountId,
|
|
159
|
+
params.publishRequestId
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// ① 检查是否已有记录
|
|
163
|
+
const existing = await db.findVideoByIdempotencyKey(idempotencyKey)
|
|
164
|
+
if (existing) {
|
|
165
|
+
console.log('检测到重复发布请求,返回已有记录')
|
|
166
|
+
return existing
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ② 先创建记录(占位)
|
|
170
|
+
const record = await db.createVideo({
|
|
171
|
+
...params,
|
|
172
|
+
idempotencyKey,
|
|
173
|
+
publishStatus: 'PENDING',
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// ③ 调用 BEERVID API
|
|
178
|
+
const result = await openApiPost('/api/v1/open/tiktok/video/publish', {
|
|
179
|
+
businessId: params.businessId,
|
|
180
|
+
videoUrl: params.fileUrl,
|
|
181
|
+
caption: params.caption,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ④ 更新记录
|
|
185
|
+
await db.updateVideo(record.id, {
|
|
186
|
+
shareId: result.shareId,
|
|
187
|
+
publishStatus: 'PROCESSING_DOWNLOAD',
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// ⑤ 发布失败,标记记录
|
|
193
|
+
await db.updateVideo(record.id, {
|
|
194
|
+
publishStatus: 'FAILED',
|
|
195
|
+
failReason: (err as Error).message,
|
|
196
|
+
})
|
|
197
|
+
throw err
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
> **不要只用 `fileUrl` / `videoFileId` 当幂等键。**
|
|
203
|
+
> 同一个素材在业务上可能允许被多次发布;真正应该唯一的是“这一次发布请求”。
|
|
204
|
+
|
|
205
|
+
### 上传幂等(MD5 去重)
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { createHash } from 'node:crypto'
|
|
209
|
+
import { readFileSync } from 'node:fs'
|
|
210
|
+
|
|
211
|
+
function computeFileMD5(filePath: string): string {
|
|
212
|
+
const buffer = readFileSync(filePath)
|
|
213
|
+
return createHash('md5').update(buffer).digest('hex')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function uploadWithDedup(filePath: string): Promise<UploadResult> {
|
|
217
|
+
const md5 = computeFileMD5(filePath)
|
|
218
|
+
|
|
219
|
+
// 检查是否已上传过
|
|
220
|
+
const existing = await db.findUploadByMD5(md5)
|
|
221
|
+
if (existing && existing.fileUrl) {
|
|
222
|
+
console.log('文件已上传过,复用已有 URL')
|
|
223
|
+
return existing
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 执行上传
|
|
227
|
+
const result = await uploadNormalVideo(filePath)
|
|
228
|
+
|
|
229
|
+
// 记录上传结果
|
|
230
|
+
await db.saveUploadRecord({
|
|
231
|
+
md5,
|
|
232
|
+
fileUrl: result.fileUrl,
|
|
233
|
+
fileName: filePath,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 队列消费的幂等处理
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
interface QueueMessage {
|
|
246
|
+
messageId: string // 消息唯一 ID
|
|
247
|
+
body: unknown
|
|
248
|
+
deliveryCount: number // 投递次数
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function handleMessage(message: QueueMessage): Promise<void> {
|
|
252
|
+
// ① 投递次数检查
|
|
253
|
+
if (message.deliveryCount > 3) {
|
|
254
|
+
console.error(`消息 ${message.messageId} 已投递 ${message.deliveryCount} 次,放弃处理`)
|
|
255
|
+
await queue.acknowledge(message) // 丢弃,防止无限重投
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ② 消息去重(基于 messageId)
|
|
260
|
+
const processed = await redis.get(`msg:${message.messageId}`)
|
|
261
|
+
if (processed) {
|
|
262
|
+
console.log(`消息 ${message.messageId} 已处理过,跳过`)
|
|
263
|
+
await queue.acknowledge(message)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
// ③ 业务处理
|
|
269
|
+
await processBusinessLogic(message.body)
|
|
270
|
+
|
|
271
|
+
// ④ 标记已处理
|
|
272
|
+
await redis.set(`msg:${message.messageId}`, '1', 'EX', 86400) // 24h 过期
|
|
273
|
+
await queue.acknowledge(message)
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// ⑤ 处理失败:不 acknowledge,让消息重新投递
|
|
276
|
+
console.error(`消息处理失败: ${err.message},等待重投`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 完整策略速查表
|
|
284
|
+
|
|
285
|
+
| 操作 | 安全重试 | 幂等保护 | 重试策略 | 幂等方案 |
|
|
286
|
+
|------|----------|----------|----------|----------|
|
|
287
|
+
| 获取 OAuth URL | ✅ | 不需要 | 3 次 / 1s 退避 | — |
|
|
288
|
+
| 查询账号信息 | ✅ | 不需要 | 3 次 / 1s 退避 | — |
|
|
289
|
+
| 获取上传凭证 | ✅ | 不需要 | 3 次 / 2s 退避 | — |
|
|
290
|
+
| 视频上传 | ⚠️ | 建议 | 2 次 / 5s 退避 | MD5 去重 |
|
|
291
|
+
| 普通视频发布 | ❌ | **必须** | 不重试 | 数据库幂等键 |
|
|
292
|
+
| 挂车视频发布 | ❌ | **必须** | 不重试 | 数据库幂等键 |
|
|
293
|
+
| 轮询发布状态 | ✅ | 不需要 | 3 次 / 1s 退避 | — |
|
|
294
|
+
| 查询视频数据 | ✅ | 不需要 | 3 次 / 1s 退避 | — |
|
|
295
|
+
| 查询商品列表 | ✅ | 不需要 | 3 次 / 1s 退避 | — |
|