beervid-app-cli 0.2.2 → 0.2.4
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 +45 -0
- package/SKILL.md +204 -376
- package/dist/cli.mjs +117 -151
- package/docs/database-schema.md +231 -0
- package/docs/oauth-callback.md +282 -0
- package/docs/retry-and-idempotency.md +295 -0
- package/docs/tt-poll-task.md +239 -0
- package/docs/tts-product-cache.md +256 -0
- package/example/express/README.md +58 -0
- package/example/express/package.json +20 -0
- package/example/express/server.ts +431 -0
- package/example/express/tsconfig.json +12 -0
- package/example/nextjs/.env.example +3 -0
- package/example/nextjs/README.md +54 -0
- package/example/nextjs/app/api/oauth/callback/route.ts +34 -0
- package/example/nextjs/app/api/oauth/url/route.ts +30 -0
- package/example/nextjs/app/api/products/route.ts +43 -0
- package/example/nextjs/app/api/publish/tt/route.ts +116 -0
- package/example/nextjs/app/api/publish/tts/route.ts +58 -0
- package/example/nextjs/app/api/status/[shareId]/route.ts +41 -0
- package/example/nextjs/app/layout.tsx +9 -0
- package/example/nextjs/app/page.tsx +80 -0
- package/example/nextjs/lib/beervid-client.ts +107 -0
- package/example/nextjs/next.config.ts +4 -0
- package/example/nextjs/package.json +19 -0
- package/example/nextjs/tsconfig.json +23 -0
- package/example/standard/README.md +51 -0
- package/example/standard/api-client.ts +181 -0
- package/example/standard/get-oauth-url.ts +44 -0
- package/example/standard/package.json +18 -0
- package/example/standard/query-products.ts +141 -0
- package/example/standard/tsconfig.json +12 -0
- package/example/standard/tt-publish-flow.ts +194 -0
- package/example/standard/tts-publish-flow.ts +246 -0
- package/package.json +3 -1
|
@@ -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 退避 | — |
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# TT 轮询任务建议
|
|
2
|
+
|
|
3
|
+
> 本文档描述普通视频(TT)发布后的状态轮询策略设计。
|
|
4
|
+
> 挂车视频(TTS)发布后立即完成,**不需要轮询**。
|
|
5
|
+
|
|
6
|
+
## 为什么需要轮询
|
|
7
|
+
|
|
8
|
+
TT 普通视频发布后,TikTok 需要处理视频转码和分发。API 返回的 `shareId` 只是提交凭证,真正的视频 ID(`post_ids`)需要通过持续查询 `/api/v1/open/tiktok/video/status` 获取。
|
|
9
|
+
|
|
10
|
+
### 状态流转
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
┌─── PUBLISH_COMPLETE(post_ids 非空)→ ✅ 完成
|
|
14
|
+
PROCESSING_DOWNLOAD ────┤
|
|
15
|
+
├─── PUBLISH_COMPLETE(post_ids 为空)→ 继续轮询
|
|
16
|
+
│
|
|
17
|
+
└─── FAILED(携带 reason)→ ❌ 失败
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **注意**:`PUBLISH_COMPLETE` 但 `post_ids` 为空是正常的中间态,必须继续轮询直到拿到有值的 `post_ids`。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 轮询间隔建议
|
|
25
|
+
|
|
26
|
+
### 推荐策略:阶梯递增间隔
|
|
27
|
+
|
|
28
|
+
根据经验,视频处理通常在 10-60 秒内完成,但复杂视频可能需要数分钟。推荐使用**阶梯递增间隔**而非固定间隔:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
前 6 次(0-30s):每 5 秒查询一次 → 覆盖大部分快速完成的场景
|
|
32
|
+
第 7-12 次(30s-90s):每 10 秒一次 → 中等耗时视频
|
|
33
|
+
第 13 次起(90s+):每 15 秒一次 → 长耗时视频,降低 API 压力
|
|
34
|
+
最大轮询次数:60 次(约 12 分钟后超时)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
function getPollingInterval(pollCount: number): number {
|
|
39
|
+
if (pollCount <= 6) return 5_000 // 前 30 秒:5s
|
|
40
|
+
if (pollCount <= 12) return 10_000 // 30-90 秒:10s
|
|
41
|
+
return 15_000 // 90 秒之后:15s
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 其他策略对比
|
|
46
|
+
|
|
47
|
+
| 策略 | 优点 | 缺点 | 适用场景 |
|
|
48
|
+
|------|------|------|----------|
|
|
49
|
+
| 固定间隔(5s) | 简单 | API 压力大 | 低流量场景 |
|
|
50
|
+
| 阶梯递增 | 平衡响应速度和 API 压力 | 代码稍复杂 | **推荐** |
|
|
51
|
+
| 指数退避 | 最省 API 调用 | 后期间隔太长,用户体验差 | 后台任务 |
|
|
52
|
+
| 自适应 | 最优 | 实现复杂 | 高流量场景 |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 三层保障机制
|
|
57
|
+
|
|
58
|
+
### 第一层:用户侧主动轮询
|
|
59
|
+
|
|
60
|
+
用户在界面上点击"刷新状态",前端调用后端接口,后端调用 BEERVID API。
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// 后端接口
|
|
64
|
+
app.get('/api/videos/:videoRecordId/refresh-status', async (req, res) => {
|
|
65
|
+
const record = await db.findVideoById(req.params.videoRecordId)
|
|
66
|
+
if (!record || record.publishStatus === 'PUBLISH_COMPLETE') {
|
|
67
|
+
return res.json(record)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = await openApiPost('/api/v1/open/tiktok/video/status', {
|
|
71
|
+
businessId: record.businessId,
|
|
72
|
+
shareId: record.shareId,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const status = data.status ?? 'UNKNOWN'
|
|
76
|
+
const postIds = data.post_ids ?? []
|
|
77
|
+
|
|
78
|
+
await db.updateVideo(record.id, {
|
|
79
|
+
publishStatus: status,
|
|
80
|
+
videoId: postIds[0] ?? null,
|
|
81
|
+
failReason: data.reason ?? null,
|
|
82
|
+
pollCount: record.pollCount + 1,
|
|
83
|
+
lastPolledAt: new Date(),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
res.json({ status, videoId: postIds[0], reason: data.reason })
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 第二层:定时任务(Cron)
|
|
91
|
+
|
|
92
|
+
后台定时扫描所有"未完成"记录,批量轮询状态。
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// 每 30 秒执行一次
|
|
96
|
+
cron.schedule('*/30 * * * * *', async () => {
|
|
97
|
+
// 查找所有需要轮询的记录:
|
|
98
|
+
// 1. 状态为 PROCESSING_DOWNLOAD 或 PUBLISH_COMPLETE(但无 videoId)
|
|
99
|
+
// 2. 轮询次数未超限
|
|
100
|
+
// 3. 距离上次轮询至少 5 秒
|
|
101
|
+
const pendingVideos = await db.query(`
|
|
102
|
+
SELECT * FROM beervid_videos
|
|
103
|
+
WHERE publish_status IN ('PROCESSING_DOWNLOAD', 'PUBLISH_COMPLETE')
|
|
104
|
+
AND video_id IS NULL
|
|
105
|
+
AND poll_count < 60
|
|
106
|
+
AND (last_polled_at IS NULL OR last_polled_at < NOW() - INTERVAL 5 SECOND)
|
|
107
|
+
ORDER BY created_at ASC
|
|
108
|
+
LIMIT 20
|
|
109
|
+
`)
|
|
110
|
+
|
|
111
|
+
// 逐条轮询,单条失败不影响其他
|
|
112
|
+
for (const video of pendingVideos) {
|
|
113
|
+
try {
|
|
114
|
+
await pollAndUpdateSingle(video)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`轮询失败 [shareId=${video.shareId}]:`, err.message)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
async function pollAndUpdateSingle(video: VideoRecord): Promise<void> {
|
|
122
|
+
const data = await openApiPost('/api/v1/open/tiktok/video/status', {
|
|
123
|
+
businessId: video.businessId,
|
|
124
|
+
shareId: video.shareId,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const status = data.status ?? 'UNKNOWN'
|
|
128
|
+
const postIds = data.post_ids ?? []
|
|
129
|
+
const isComplete = status === 'PUBLISH_COMPLETE' && postIds.length > 0
|
|
130
|
+
const isFailed = status === 'FAILED'
|
|
131
|
+
|
|
132
|
+
await db.updateVideo(video.id, {
|
|
133
|
+
publishStatus: isComplete ? 'PUBLISH_COMPLETE' : isFailed ? 'FAILED' : status,
|
|
134
|
+
videoId: postIds[0] ?? null,
|
|
135
|
+
failReason: data.reason ?? null,
|
|
136
|
+
pollCount: video.pollCount + 1,
|
|
137
|
+
lastPolledAt: new Date(),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// 完成后异步触发视频数据查询
|
|
141
|
+
if (isComplete && postIds[0]) {
|
|
142
|
+
syncVideoDataAsync(video.businessId, postIds[0]).catch(() => {})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 第三层:异步队列(可选,高流量场景)
|
|
148
|
+
|
|
149
|
+
发布后投递消息到队列,由消费者异步轮询。适用于发布量大、需要隔离轮询负载的场景。
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// 发布后投递延迟消息
|
|
153
|
+
async function publishAndEnqueue(businessId: string, videoUrl: string): Promise<void> {
|
|
154
|
+
const result = await openApiPost('/api/v1/open/tiktok/video/publish', {
|
|
155
|
+
businessId,
|
|
156
|
+
videoUrl,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
await db.createVideo({ businessId, shareId: result.shareId, publishStatus: 'PROCESSING_DOWNLOAD' })
|
|
160
|
+
|
|
161
|
+
// 投递延迟消息:5 秒后首次检查
|
|
162
|
+
await queue.send('video-status-poll', {
|
|
163
|
+
businessId,
|
|
164
|
+
shareId: result.shareId,
|
|
165
|
+
attempt: 1,
|
|
166
|
+
}, { delaySeconds: 5 })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 队列消费者
|
|
170
|
+
async function handlePollMessage(message: PollMessage): Promise<void> {
|
|
171
|
+
const { businessId, shareId, attempt } = message
|
|
172
|
+
|
|
173
|
+
if (attempt > 60) {
|
|
174
|
+
await db.updateVideo(shareId, { publishStatus: 'TIMEOUT' })
|
|
175
|
+
return // acknowledge,不再重投
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const data = await openApiPost('/api/v1/open/tiktok/video/status', { businessId, shareId })
|
|
179
|
+
const status = data.status ?? 'UNKNOWN'
|
|
180
|
+
const postIds = data.post_ids ?? []
|
|
181
|
+
|
|
182
|
+
if (status === 'FAILED' || (status === 'PUBLISH_COMPLETE' && postIds.length > 0)) {
|
|
183
|
+
// 终态,更新并结束
|
|
184
|
+
await db.updateVideo(shareId, {
|
|
185
|
+
publishStatus: status,
|
|
186
|
+
videoId: postIds[0],
|
|
187
|
+
failReason: data.reason,
|
|
188
|
+
})
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 非终态,投递下一次延迟消息
|
|
193
|
+
const delay = getPollingInterval(attempt) / 1000
|
|
194
|
+
await queue.send('video-status-poll', {
|
|
195
|
+
businessId,
|
|
196
|
+
shareId,
|
|
197
|
+
attempt: attempt + 1,
|
|
198
|
+
}, { delaySeconds: delay })
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 超时处理
|
|
205
|
+
|
|
206
|
+
当轮询次数达到上限时:
|
|
207
|
+
|
|
208
|
+
1. **标记超时**:将 `publish_status` 设为 `TIMEOUT`
|
|
209
|
+
2. **不立即放弃**:后续 Cron 可以用更低频率(如每 5 分钟)继续检查 TIMEOUT 记录
|
|
210
|
+
3. **通知用户**:推送通知告知"视频处理超时,系统将继续在后台检查"
|
|
211
|
+
4. **人工介入入口**:提供"手动重试轮询"的按钮
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 时序图
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
用户 你的后端 BEERVID API TikTok
|
|
219
|
+
│ │ │ │
|
|
220
|
+
│ 发布视频 ──→│ │ │
|
|
221
|
+
│ │ publish ────────→ │ │
|
|
222
|
+
│ │ ←── shareId ──── │ │
|
|
223
|
+
│ │ │ │
|
|
224
|
+
│ │ save(PROCESSING) │ │
|
|
225
|
+
│ │ │ │
|
|
226
|
+
│ │ ── 5s 后 ── │ │
|
|
227
|
+
│ │ poll-status ────→ │ 查询 TikTok ────→│
|
|
228
|
+
│ │ ← PROCESSING ─── │ ← 处理中 ──────── │
|
|
229
|
+
│ │ │ │
|
|
230
|
+
│ │ ── 5s 后 ── │ │
|
|
231
|
+
│ │ poll-status ────→ │ 查询 TikTok ────→│
|
|
232
|
+
│ │ ← COMPLETE ───── │ ← 发布完成 ────── │
|
|
233
|
+
│ │ (post_ids) │ │
|
|
234
|
+
│ │ │ │
|
|
235
|
+
│ │ save(COMPLETE) │ │
|
|
236
|
+
│ │ query-video ────→ │ │
|
|
237
|
+
│ │ ← 播放量 etc ──── │ │
|
|
238
|
+
│ ←── 完成 ── │ │ │
|
|
239
|
+
```
|