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,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEERVID Open API — Express 后端集成示例
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - OAuth 授权(获取 URL + 回调处理)
|
|
6
|
+
* - TT 完整发布流程(含后台轮询任务)
|
|
7
|
+
* - TTS 完整发布流程
|
|
8
|
+
* - 发布状态查询
|
|
9
|
+
* - 商品列表查询
|
|
10
|
+
*
|
|
11
|
+
* 运行: npx tsx server.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import express from 'express'
|
|
15
|
+
|
|
16
|
+
const app = express()
|
|
17
|
+
app.use(express.json())
|
|
18
|
+
|
|
19
|
+
const PORT = parseInt(process.env['PORT'] ?? '3000', 10)
|
|
20
|
+
const API_KEY = process.env['BEERVID_APP_KEY'] ?? ''
|
|
21
|
+
const BASE_URL = process.env['BEERVID_APP_BASE_URL'] ?? 'https://open.beervid.ai'
|
|
22
|
+
|
|
23
|
+
if (!API_KEY) {
|
|
24
|
+
console.error('请设置环境变量: export BEERVID_APP_KEY="your-api-key"')
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
// BEERVID API 客户端封装
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
interface OpenApiResponse<T> {
|
|
33
|
+
code: number
|
|
34
|
+
message: string
|
|
35
|
+
data: T
|
|
36
|
+
success: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function openApiGet<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
40
|
+
const url = new URL(path, BASE_URL)
|
|
41
|
+
if (params) {
|
|
42
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
|
43
|
+
}
|
|
44
|
+
const res = await fetch(url.toString(), {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: { 'X-API-KEY': API_KEY, 'Content-Type': 'application/json' },
|
|
47
|
+
})
|
|
48
|
+
const json = (await res.json()) as OpenApiResponse<T>
|
|
49
|
+
if (json.code !== 0 || !json.success) {
|
|
50
|
+
throw new Error(`Open API 错误 [${path}]: ${json.message} (code: ${json.code})`)
|
|
51
|
+
}
|
|
52
|
+
return json.data
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function openApiPost<T>(path: string, body?: Record<string, unknown>): Promise<T> {
|
|
56
|
+
const url = new URL(path, BASE_URL)
|
|
57
|
+
const res = await fetch(url.toString(), {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'X-API-KEY': API_KEY, 'Content-Type': 'application/json' },
|
|
60
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
61
|
+
})
|
|
62
|
+
const json = (await res.json()) as OpenApiResponse<T>
|
|
63
|
+
if (json.code !== 0 || !json.success) {
|
|
64
|
+
throw new Error(`Open API 错误 [${path}]: ${json.message} (code: ${json.code})`)
|
|
65
|
+
}
|
|
66
|
+
return json.data
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
// 内存存储(生产环境请替换为数据库)
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
|
|
73
|
+
interface VideoRecord {
|
|
74
|
+
id: string
|
|
75
|
+
businessId: string
|
|
76
|
+
shareId: string
|
|
77
|
+
publishStatus: string
|
|
78
|
+
videoId: string | null
|
|
79
|
+
failReason: string | null
|
|
80
|
+
pollCount: number
|
|
81
|
+
createdAt: Date
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const videoStore = new Map<string, VideoRecord>()
|
|
85
|
+
const accountStore = new Map<string, Record<string, unknown>>()
|
|
86
|
+
let idCounter = 1
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
// 轮询策略:阶梯递增间隔
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 推荐的轮询间隔策略:
|
|
94
|
+
* - 前 6 次(0-30s):每 5 秒 → 覆盖大部分快速完成的场景
|
|
95
|
+
* - 第 7-12 次(30s-90s):每 10 秒 → 中等耗时视频
|
|
96
|
+
* - 第 13 次起(90s+):每 15 秒 → 长耗时视频,降低 API 压力
|
|
97
|
+
*/
|
|
98
|
+
function getPollingInterval(pollCount: number): number {
|
|
99
|
+
if (pollCount <= 6) return 5_000
|
|
100
|
+
if (pollCount <= 12) return 10_000
|
|
101
|
+
return 15_000
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const MAX_POLLS = 60
|
|
105
|
+
|
|
106
|
+
async function pollVideoStatusInBackground(record: VideoRecord): Promise<void> {
|
|
107
|
+
const { businessId, shareId } = record
|
|
108
|
+
|
|
109
|
+
for (let i = 1; i <= MAX_POLLS; i++) {
|
|
110
|
+
const interval = getPollingInterval(i)
|
|
111
|
+
await new Promise((r) => setTimeout(r, interval))
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const data = await openApiPost<{
|
|
115
|
+
status?: string
|
|
116
|
+
Status?: string
|
|
117
|
+
reason?: string
|
|
118
|
+
post_ids?: string[]
|
|
119
|
+
}>('/api/v1/open/tiktok/video/status', { businessId, shareId })
|
|
120
|
+
|
|
121
|
+
const status = data.status ?? data.Status ?? 'UNKNOWN'
|
|
122
|
+
const postIds = data.post_ids ?? []
|
|
123
|
+
record.pollCount = i
|
|
124
|
+
|
|
125
|
+
if (status === 'FAILED') {
|
|
126
|
+
record.publishStatus = 'FAILED'
|
|
127
|
+
record.failReason = data.reason ?? '未知原因'
|
|
128
|
+
console.log(`[轮询] ${shareId} 发布失败: ${record.failReason}`)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (status === 'PUBLISH_COMPLETE' && postIds.length > 0) {
|
|
133
|
+
record.publishStatus = 'PUBLISH_COMPLETE'
|
|
134
|
+
record.videoId = postIds[0]!
|
|
135
|
+
console.log(`[轮询] ${shareId} 发布完成,视频 ID: ${record.videoId}(第 ${i} 次查询)`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`[轮询] ${shareId} [${i}/${MAX_POLLS}] 状态: ${status},${interval / 1000}s 后重试`)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`[轮询] ${shareId} 第 ${i} 次查询失败:`, (err as Error).message)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
record.publishStatus = 'TIMEOUT'
|
|
146
|
+
console.log(`[轮询] ${shareId} 超时(${MAX_POLLS} 次查询后)`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
// OAuth 路由
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
152
|
+
|
|
153
|
+
// 获取 TT OAuth URL 并重定向
|
|
154
|
+
app.get('/oauth/tt', async (_req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const url = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
|
|
157
|
+
// 生产环境:先判断授权链接中的 state 是否为 JSON 对象;
|
|
158
|
+
// 若是,再在该 JSON 中追加你方自定义安全字段,详见 docs/oauth-callback.md
|
|
159
|
+
res.redirect(url)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
res.status(500).json({ error: (err as Error).message })
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// 获取 TTS OAuth URL 并重定向
|
|
166
|
+
app.get('/oauth/tts', async (_req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const data = await openApiGet<{ crossBorderUrl: string }>(
|
|
169
|
+
'/api/v1/open/thirdparty-auth/tts-url'
|
|
170
|
+
)
|
|
171
|
+
// 生产环境:先判断授权链接中的 state 是否为 JSON 对象;
|
|
172
|
+
// 若是,再在该 JSON 中追加你方自定义安全字段,详见 docs/oauth-callback.md
|
|
173
|
+
res.redirect(data.crossBorderUrl)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
res.status(500).json({ error: (err as Error).message })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// OAuth 回调处理
|
|
180
|
+
app.get('/oauth/callback', async (req, res) => {
|
|
181
|
+
const ttAbId = req.query['ttAbId'] as string | undefined
|
|
182
|
+
const ttsAbId = req.query['ttsAbId'] as string | undefined
|
|
183
|
+
|
|
184
|
+
// 生产环境:① 验证 state token ② 一次性消费检查
|
|
185
|
+
// 详见 docs/oauth-callback.md
|
|
186
|
+
|
|
187
|
+
if (!ttAbId && !ttsAbId) {
|
|
188
|
+
res.status(400).json({ error: '缺少 ttAbId 或 ttsAbId 参数' })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const accountId = (ttAbId ?? ttsAbId)!
|
|
193
|
+
const accountType = ttAbId ? 'TT' : 'TTS'
|
|
194
|
+
|
|
195
|
+
// 持久化账号
|
|
196
|
+
accountStore.set(accountId, {
|
|
197
|
+
accountType,
|
|
198
|
+
accountId,
|
|
199
|
+
businessId: ttAbId ?? null,
|
|
200
|
+
creatorUserOpenId: ttsAbId ?? null,
|
|
201
|
+
authorizedAt: new Date().toISOString(),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// 异步拉取账号详情(不阻塞回调响应)
|
|
205
|
+
openApiPost('/api/v1/open/account/info', { accountType, accountId })
|
|
206
|
+
.then((info) => {
|
|
207
|
+
const existing = accountStore.get(accountId) ?? {}
|
|
208
|
+
accountStore.set(accountId, { ...existing, ...info })
|
|
209
|
+
console.log(`[异步] 账号信息已同步: ${accountId}`)
|
|
210
|
+
})
|
|
211
|
+
.catch((err) => {
|
|
212
|
+
console.error(`[异步] 账号信息同步失败: ${(err as Error).message}`)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
res.json({
|
|
216
|
+
success: true,
|
|
217
|
+
message: `${accountType} 账号授权成功`,
|
|
218
|
+
accountId,
|
|
219
|
+
businessId: ttAbId ?? undefined,
|
|
220
|
+
creatorUserOpenId: ttsAbId ?? undefined,
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
225
|
+
// TT 完整发布流程
|
|
226
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
227
|
+
|
|
228
|
+
app.post('/api/publish/tt', async (req, res) => {
|
|
229
|
+
const { businessId, videoUrl, caption } = req.body as {
|
|
230
|
+
businessId?: string
|
|
231
|
+
videoUrl?: string
|
|
232
|
+
caption?: string
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!businessId || !videoUrl) {
|
|
236
|
+
res.status(400).json({ error: '缺少 businessId 或 videoUrl' })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// ① 发布视频(不重试——发布操作非幂等)
|
|
242
|
+
const publishResult = await openApiPost<{ shareId: string }>(
|
|
243
|
+
'/api/v1/open/tiktok/video/publish',
|
|
244
|
+
{ businessId, videoUrl, caption: caption ?? '' }
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
// ② 创建本地记录
|
|
248
|
+
const record: VideoRecord = {
|
|
249
|
+
id: String(idCounter++),
|
|
250
|
+
businessId,
|
|
251
|
+
shareId: publishResult.shareId,
|
|
252
|
+
publishStatus: 'PROCESSING_DOWNLOAD',
|
|
253
|
+
videoId: null,
|
|
254
|
+
failReason: null,
|
|
255
|
+
pollCount: 0,
|
|
256
|
+
createdAt: new Date(),
|
|
257
|
+
}
|
|
258
|
+
videoStore.set(record.shareId, record)
|
|
259
|
+
|
|
260
|
+
// ③ 启动后台轮询任务(使用阶梯递增间隔策略)
|
|
261
|
+
pollVideoStatusInBackground(record).catch((err) => {
|
|
262
|
+
console.error(`[后台轮询] 异常:`, (err as Error).message)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ④ 立即返回 shareId,客户端可通过 /api/status/:shareId 查询进度
|
|
266
|
+
res.json({
|
|
267
|
+
success: true,
|
|
268
|
+
shareId: publishResult.shareId,
|
|
269
|
+
message: '视频已提交发布,后台正在轮询状态',
|
|
270
|
+
statusUrl: `/api/status/${publishResult.shareId}`,
|
|
271
|
+
})
|
|
272
|
+
} catch (err) {
|
|
273
|
+
res.status(500).json({ error: (err as Error).message })
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
278
|
+
// TTS 完整发布流程
|
|
279
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
280
|
+
|
|
281
|
+
app.post('/api/publish/tts', async (req, res) => {
|
|
282
|
+
const { creatorId, videoFileId, productId, productTitle, caption } = req.body as {
|
|
283
|
+
creatorId?: string
|
|
284
|
+
videoFileId?: string
|
|
285
|
+
productId?: string
|
|
286
|
+
productTitle?: string
|
|
287
|
+
caption?: string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!creatorId || !videoFileId || !productId || !productTitle) {
|
|
291
|
+
res.status(400).json({
|
|
292
|
+
error: '缺少必填参数',
|
|
293
|
+
required: ['creatorId', 'videoFileId', 'productId', 'productTitle'],
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
// 商品标题最多 29 字符
|
|
300
|
+
const normalizedTitle = productTitle.slice(0, 29)
|
|
301
|
+
|
|
302
|
+
// 挂车发布(不重试——发布操作非幂等)
|
|
303
|
+
const publishResult = await openApiPost<{ videoId: string }>(
|
|
304
|
+
'/api/v1/open/tts/shoppable-video/publish',
|
|
305
|
+
{
|
|
306
|
+
creatorUserOpenId: creatorId,
|
|
307
|
+
fileId: videoFileId,
|
|
308
|
+
title: caption ?? '',
|
|
309
|
+
productId,
|
|
310
|
+
productTitle: normalizedTitle,
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// 挂车视频发布后立即完成,无需轮询
|
|
315
|
+
res.json({
|
|
316
|
+
success: true,
|
|
317
|
+
videoId: publishResult.videoId,
|
|
318
|
+
message: '挂车视频发布完成',
|
|
319
|
+
productTitleUsed: normalizedTitle,
|
|
320
|
+
})
|
|
321
|
+
} catch (err) {
|
|
322
|
+
res.status(500).json({ error: (err as Error).message })
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
327
|
+
// 状态查询
|
|
328
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
329
|
+
|
|
330
|
+
app.get('/api/status/:shareId', async (req, res) => {
|
|
331
|
+
const { shareId } = req.params
|
|
332
|
+
|
|
333
|
+
// 先查本地记录
|
|
334
|
+
const record = videoStore.get(shareId)
|
|
335
|
+
if (record) {
|
|
336
|
+
res.json({
|
|
337
|
+
shareId,
|
|
338
|
+
publishStatus: record.publishStatus,
|
|
339
|
+
videoId: record.videoId,
|
|
340
|
+
failReason: record.failReason,
|
|
341
|
+
pollCount: record.pollCount,
|
|
342
|
+
})
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 本地无记录,直接查 BEERVID API(需要 businessId)
|
|
347
|
+
const businessId = req.query['businessId'] as string | undefined
|
|
348
|
+
if (!businessId) {
|
|
349
|
+
res.status(400).json({ error: '本地无记录,请提供 businessId 参数' })
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const data = await openApiPost<{
|
|
355
|
+
status?: string
|
|
356
|
+
Status?: string
|
|
357
|
+
reason?: string
|
|
358
|
+
post_ids?: string[]
|
|
359
|
+
}>('/api/v1/open/tiktok/video/status', { businessId, shareId })
|
|
360
|
+
|
|
361
|
+
res.json({
|
|
362
|
+
shareId,
|
|
363
|
+
publishStatus: data.status ?? data.Status,
|
|
364
|
+
videoId: data.post_ids?.[0] ?? null,
|
|
365
|
+
failReason: data.reason ?? null,
|
|
366
|
+
})
|
|
367
|
+
} catch (err) {
|
|
368
|
+
res.status(500).json({ error: (err as Error).message })
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
373
|
+
// 商品查询
|
|
374
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
375
|
+
|
|
376
|
+
app.get('/api/products/:creatorId', async (req, res) => {
|
|
377
|
+
const { creatorId } = req.params
|
|
378
|
+
const productType = (req.query['type'] as string) ?? 'shop'
|
|
379
|
+
const pageSize = parseInt((req.query['pageSize'] as string) ?? '20', 10)
|
|
380
|
+
const pageToken = (req.query['pageToken'] as string) ?? ''
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const data = await openApiPost('/api/v1/open/tts/products/query', {
|
|
384
|
+
creatorUserOpenId: creatorId,
|
|
385
|
+
productType,
|
|
386
|
+
pageSize,
|
|
387
|
+
pageToken,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
res.json({ success: true, data })
|
|
391
|
+
} catch (err) {
|
|
392
|
+
res.status(500).json({ error: (err as Error).message })
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
397
|
+
// 首页
|
|
398
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
399
|
+
|
|
400
|
+
app.get('/', (_req, res) => {
|
|
401
|
+
res.send(`
|
|
402
|
+
<h1>BEERVID Express 集成示例</h1>
|
|
403
|
+
<h2>OAuth</h2>
|
|
404
|
+
<ul>
|
|
405
|
+
<li><a href="/oauth/tt">TT OAuth 授权</a></li>
|
|
406
|
+
<li><a href="/oauth/tts">TTS OAuth 授权</a></li>
|
|
407
|
+
</ul>
|
|
408
|
+
<h2>API</h2>
|
|
409
|
+
<pre>
|
|
410
|
+
POST /api/publish/tt — TT 完整发布(含后台轮询)
|
|
411
|
+
POST /api/publish/tts — TTS 挂车发布
|
|
412
|
+
GET /api/status/:sid — 查询发布状态
|
|
413
|
+
GET /api/products/:cid — 查询商品列表
|
|
414
|
+
</pre>
|
|
415
|
+
<p>详见 README.md</p>
|
|
416
|
+
`)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─── 启动 ───────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
app.listen(PORT, () => {
|
|
422
|
+
console.log(`BEERVID Express 示例运行在 http://localhost:${PORT}`)
|
|
423
|
+
console.log('路由:')
|
|
424
|
+
console.log(' GET /oauth/tt — TT OAuth')
|
|
425
|
+
console.log(' GET /oauth/tts — TTS OAuth')
|
|
426
|
+
console.log(' GET /oauth/callback — 回调处理')
|
|
427
|
+
console.log(' POST /api/publish/tt — TT 完整发布')
|
|
428
|
+
console.log(' POST /api/publish/tts — TTS 完整发布')
|
|
429
|
+
console.log(' GET /api/status/:shareId — 状态查询')
|
|
430
|
+
console.log(' GET /api/products/:cid — 商品查询')
|
|
431
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Next.js API Route 集成示例
|
|
2
|
+
|
|
3
|
+
使用 Next.js App Router + API Route 模式集成 BEERVID Open API 的全栈示例。
|
|
4
|
+
|
|
5
|
+
## 前置条件
|
|
6
|
+
|
|
7
|
+
- Node.js ≥ 20
|
|
8
|
+
- BEERVID APP_KEY
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd example/nextjs
|
|
14
|
+
npm install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 配置
|
|
18
|
+
|
|
19
|
+
复制环境变量文件并填入 APP_KEY:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cp .env.example .env.local
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
编辑 `.env.local`:
|
|
26
|
+
```
|
|
27
|
+
BEERVID_APP_KEY=your-api-key
|
|
28
|
+
BEERVID_APP_BASE_URL=https://open.beervid.ai
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 运行
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
访问 `http://localhost:3000`
|
|
38
|
+
|
|
39
|
+
## API Routes
|
|
40
|
+
|
|
41
|
+
| 方法 | 路径 | 说明 |
|
|
42
|
+
|------|------|------|
|
|
43
|
+
| GET | `/api/oauth/url?type=tt` | 获取 OAuth URL |
|
|
44
|
+
| GET | `/api/oauth/callback` | OAuth 回调处理 |
|
|
45
|
+
| POST | `/api/publish/tt` | TT 完整发布流程 |
|
|
46
|
+
| POST | `/api/publish/tts` | TTS 完整发布流程 |
|
|
47
|
+
| GET | `/api/status/[shareId]?businessId=xxx` | 发布状态查询 |
|
|
48
|
+
| GET/POST | `/api/products?creatorId=xxx` | 商品查询 |
|
|
49
|
+
|
|
50
|
+
## 架构说明
|
|
51
|
+
|
|
52
|
+
- `lib/beervid-client.ts` — 服务端 BEERVID API 客户端封装
|
|
53
|
+
- `app/api/` — API Route Handlers
|
|
54
|
+
- `app/page.tsx` — 简单首页展示 API 调用方式
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/oauth/callback?ttAbId=xxx 或 ?ttsAbId=xxx
|
|
3
|
+
* OAuth 回调处理
|
|
4
|
+
*/
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
6
|
+
import { openApiPost } from '@/lib/beervid-client'
|
|
7
|
+
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const ttAbId = request.nextUrl.searchParams.get('ttAbId')
|
|
10
|
+
const ttsAbId = request.nextUrl.searchParams.get('ttsAbId')
|
|
11
|
+
|
|
12
|
+
// 生产环境:① 验证 state token ② 一次性消费检查
|
|
13
|
+
// 详见 docs/oauth-callback.md
|
|
14
|
+
|
|
15
|
+
if (!ttAbId && !ttsAbId) {
|
|
16
|
+
return NextResponse.json({ error: '缺少 ttAbId 或 ttsAbId 参数' }, { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const accountId = (ttAbId ?? ttsAbId)!
|
|
20
|
+
const accountType = ttAbId ? 'TT' : 'TTS'
|
|
21
|
+
|
|
22
|
+
// 异步拉取账号详情(不阻塞回调响应)
|
|
23
|
+
openApiPost('/api/v1/open/account/info', { accountType, accountId }).catch((err) => {
|
|
24
|
+
console.error('[异步] 账号信息同步失败:', (err as Error).message)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
success: true,
|
|
29
|
+
message: `${accountType} 账号授权成功`,
|
|
30
|
+
accountId,
|
|
31
|
+
businessId: ttAbId ?? undefined,
|
|
32
|
+
creatorUserOpenId: ttsAbId ?? undefined,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/oauth/url?type=tt|tts
|
|
3
|
+
* 获取 OAuth 授权 URL
|
|
4
|
+
*
|
|
5
|
+
* 生产环境:如果要往授权链接里追加你方自定义安全字段,
|
|
6
|
+
* 先判断现有 state 是否为 JSON 对象;若是,再在该 JSON 中追加字段。
|
|
7
|
+
* 详见 docs/oauth-callback.md
|
|
8
|
+
*/
|
|
9
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
10
|
+
import { openApiGet } from '@/lib/beervid-client'
|
|
11
|
+
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
const type = request.nextUrl.searchParams.get('type') ?? 'tt'
|
|
14
|
+
|
|
15
|
+
if (type !== 'tt' && type !== 'tts') {
|
|
16
|
+
return NextResponse.json({ error: 'type 参数必须为 tt 或 tts' }, { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (type === 'tt') {
|
|
21
|
+
const url = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
|
|
22
|
+
return NextResponse.json({ type: 'tt', url })
|
|
23
|
+
} else {
|
|
24
|
+
const data = await openApiGet<{ crossBorderUrl: string }>('/api/v1/open/thirdparty-auth/tts-url')
|
|
25
|
+
return NextResponse.json({ type: 'tts', url: data.crossBorderUrl })
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/products?creatorId=xxx&type=shop&pageSize=20&pageToken=
|
|
3
|
+
* 查询 TTS 商品列表
|
|
4
|
+
*/
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
6
|
+
import { openApiPost } from '@/lib/beervid-client'
|
|
7
|
+
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const creatorId = request.nextUrl.searchParams.get('creatorId')
|
|
10
|
+
const productType = request.nextUrl.searchParams.get('type') ?? 'shop'
|
|
11
|
+
const pageSize = parseInt(request.nextUrl.searchParams.get('pageSize') ?? '20', 10)
|
|
12
|
+
const pageToken = request.nextUrl.searchParams.get('pageToken') ?? ''
|
|
13
|
+
|
|
14
|
+
if (!creatorId) {
|
|
15
|
+
return NextResponse.json({ error: '缺少 creatorId 参数' }, { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const data = await openApiPost('/api/v1/open/tts/products/query', {
|
|
20
|
+
creatorUserOpenId: creatorId,
|
|
21
|
+
productType,
|
|
22
|
+
pageSize,
|
|
23
|
+
pageToken,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// 解析商品图片 URL
|
|
27
|
+
const groups = Array.isArray(data) ? data : [data]
|
|
28
|
+
for (const group of groups as Array<{ products?: Array<{ images?: string[] }> }>) {
|
|
29
|
+
for (const product of group.products ?? []) {
|
|
30
|
+
if (product.images) {
|
|
31
|
+
product.images = product.images.map((img: string) => {
|
|
32
|
+
const match = img.match(/url=([^,}]+)/)
|
|
33
|
+
return match?.[1]?.trim() ?? img
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return NextResponse.json({ success: true, data })
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
|
|
42
|
+
}
|
|
43
|
+
}
|