beervid-app-cli 0.1.0 → 0.2.1

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 CHANGED
@@ -19,17 +19,25 @@ beervid --help
19
19
  git clone <repo-url> ~/.claude/skills/beervid-app-cli
20
20
  ```
21
21
 
22
- ## 环境变量
22
+ ## 配置
23
23
 
24
24
  ```bash
25
+ # 方式一:通过 config 命令持久化(推荐)
26
+ beervid config --app-key "your-api-key"
27
+
28
+ # 方式二:通过环境变量(优先级高于 config)
25
29
  export BEERVID_APP_KEY="your-api-key"
26
30
  export BEERVID_APP_BASE_URL="https://open.beervid.ai" # 可选,有默认值
31
+
32
+ # 查看当前配置
33
+ beervid config --show
27
34
  ```
28
35
 
29
36
  ## 功能概览
30
37
 
31
38
  | 命令 | 功能 |
32
39
  |------|------|
40
+ | `beervid config` | 设置/查看全局配置(APP_KEY、BASE_URL) |
33
41
  | `beervid get-oauth-url` | 获取 TT/TTS OAuth 授权链接 |
34
42
  | `beervid get-account-info` | 查询账号信息 |
35
43
  | `beervid upload` | 上传视频(支持本地文件和 URL) |
@@ -64,4 +72,4 @@ beervid publish-tts-flow --creator-id open_user_abc --file ./video.mp4 --interac
64
72
  beervid publish-tts-flow --creator-id open_user_abc --file ./video.mp4 --product-id prod_123 --product-title "Widget"
65
73
  ```
66
74
 
67
- 详细用法见 [SKILL.md](./SKILL.md)。如需查看完整 API 参考,请在仓库源码中阅读 `references/api-reference.md`。
75
+ 详细用法见 [SKILL.md](./SKILL.md)。完整 API 参考见 [references/api-reference.md](./references/api-reference.md)。
package/SKILL.md CHANGED
@@ -5,20 +5,18 @@ description: >
5
5
  不同于 BEERVID 自身应用内部的 API。当用户需要以第三方应用身份调用 BEERVID 平台接口、开发 TikTok 视频发布/上传/数据统计功能、
6
6
  处理 TT/TTS 账号授权绑定、查询商品列表、或涉及 openApiGet/openApiPost/openApiUpload 相关代码时,使用此 skill。
7
7
  包括:账号 OAuth 授权、视频上传与发布(普通/挂车)、发布状态轮询、视频数据查询、TTS 商品查询等完整业务流程。
8
- 即使用户只是提到"发布视频"、"绑定账号"、"查询视频数据"、"挂车发布"、"第三方应用"、"APP_KEY"等关键词,也应触发此 skill。
8
+ 当用户在 BEERVID 项目上下文中提到"发布视频"、"绑定账号"、"查询视频数据"、"挂车发布"、"BEERVID 第三方应用"、"BEERVID_APP_KEY"等关键词时,应触发此 skill。
9
9
  ---
10
10
 
11
11
  # BEERVID 第三方应用 Open API 集成开发指南
12
12
 
13
- > **版本:** `0.1.0` &nbsp;|&nbsp; **Node.js:** `>=20.0.0`
14
-
15
13
  本 skill 专用于 **BEERVID 面向第三方应用开放的 Open API**,覆盖 6 大能力模块。
16
14
 
17
15
  > **与 BEERVID 内部 API 的区别:** BEERVID 平台有两套 API 体系:
18
16
  >
19
17
  > - **第三方应用 Open API(本 skill)**:面向外部开发者,通过 `BEERVID_APP_KEY` 认证,API 路径前缀 `/api/v1/open/`,用于第三方应用集成 TikTok 视频发布、账号管理等能力。
20
18
  > - **BEERVID 内部 API**:BEERVID 自身产品使用的接口,认证方式和接口设计不同,不在本 skill 覆盖范围内。
21
- > 详细的请求/响应示例和错误码说明见 `references/api-reference.md`。
19
+ > 详细的请求/响应示例和错误码说明见 [`references/api-reference.md`](./references/api-reference.md)。
22
20
 
23
21
  ## 环境配置
24
22
 
@@ -149,7 +147,7 @@ async function openApiUpload<T>(
149
147
  **状态流转:**
150
148
 
151
149
  ```
152
- PROCESSING_DOWNLOAD → PUBLISH_COMPLETE(成功,携带 post_ids
150
+ PROCESSING_DOWNLOAD → PUBLISH_COMPLETE(仅当携带非空 post_ids 时才算完成)
153
151
  → FAILED(失败,携带 reason)
154
152
  ```
155
153
 
@@ -264,7 +262,7 @@ OAuth 回调
264
262
  1. 获取上传凭证 POST /api/v1/open/upload-token/generate
265
263
  2. 上传视频文件 POST /api/v1/open/file-upload(返回 fileUrl)
266
264
  3. 发布视频 POST /api/v1/open/tiktok/video/publish(返回 shareId)
267
- 4. 轮询发布状态 POST /api/v1/open/tiktok/video/status(直到 PUBLISH_COMPLETE)
265
+ 4. 轮询发布状态 POST /api/v1/open/tiktok/video/status(直到 PUBLISH_COMPLETE 且返回非空 post_ids
268
266
  5. 拉取视频数据 POST /api/v1/open/tiktok/video/query(获取播放量等)
269
267
  ```
270
268
 
@@ -285,23 +283,38 @@ OAuth 回调
285
283
  4. 发布挂车视频 POST /api/v1/open/tts/shoppable-video/publish(立即完成)
286
284
  ```
287
285
 
288
- 详细的请求/响应示例见 → `references/api-reference.md`
286
+ 详细的请求/响应示例见 → [`references/api-reference.md`](./references/api-reference.md)
289
287
 
290
288
  ## CLI 命令
291
289
 
292
290
  统一使用已安装的 `beervid` 命令;
293
291
 
294
- **前置条件:** 设置环境变量后即可使用:
292
+ **前置条件:** 设置 APP_KEY 后即可使用(任选一种方式):
295
293
 
296
294
  ```bash
295
+ # 方式一:通过 config 命令持久化(推荐,设置一次永久生效)
296
+ beervid config --app-key "your-api-key"
297
+
298
+ # 方式二:通过环境变量(优先级高于 config)
297
299
  export BEERVID_APP_KEY="your-api-key"
298
300
  export BEERVID_APP_BASE_URL="https://open.beervid.ai" # 可选,有默认值
299
301
  ```
300
302
 
303
+ > **注意:** 当参数值以 `-` 开头时(如 `businessId`、`creatorUserOpenId`),必须使用 `=` 连接选项名和值,否则 CLI 会将其误判为选项标志:
304
+ >
305
+ > ```bash
306
+ > # 正确
307
+ > beervid publish-tt-flow --business-id=-0006dmtMOdKRY...
308
+ >
309
+ > # 错误 — 会报 "Unknown option `-0`"
310
+ > beervid publish-tt-flow --business-id -0006dmtMOdKRY...
311
+ > ```
312
+
301
313
  ### 命令一览
302
314
 
303
315
  | 命令 | 功能 | 核心参数 |
304
316
  | -------------------------- | ------------------------------ | --------------------------------------------------- |
317
+ | `beervid config` | 设置/查看全局配置 | `--app-key <key> [--base-url <url>] [--show]` |
305
318
  | `beervid get-oauth-url` | 获取 OAuth 授权链接 | `--type tt\|tts` |
306
319
  | `beervid get-account-info` | 查询账号信息 | `--type TT\|TTS --account-id <id>` |
307
320
  | `beervid upload` | 上传视频(支持本地文件和 URL) | `--file <路径或URL> [--type tts --creator-id <id>]` |
@@ -314,6 +327,19 @@ export BEERVID_APP_BASE_URL="https://open.beervid.ai" # 可选,有默认值
314
327
 
315
328
  ### 使用示例
316
329
 
330
+ #### 设置全局配置
331
+
332
+ ```bash
333
+ # 设置 APP_KEY(持久化到 ~/.beervid/config.json)
334
+ beervid config --app-key k9aqh41e...
335
+
336
+ # 设置自定义 API 地址
337
+ beervid config --base-url https://custom.api.com
338
+
339
+ # 查看当前配置(APP_KEY 脱敏显示)
340
+ beervid config --show
341
+ ```
342
+
317
343
  #### 获取授权链接
318
344
 
319
345
  ```bash
@@ -364,7 +390,7 @@ beervid publish --type shoppable \
364
390
  #### 轮询发布状态
365
391
 
366
392
  ```bash
367
- # 默认每 3 秒轮询一次,最多 60 次
393
+ # 默认每 5 秒轮询一次,最多 60 次
368
394
  beervid poll-status --business-id biz_12345 --share-id share_abc123
369
395
 
370
396
  # 自定义间隔和次数
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "BEERVID App CLI"
3
+ short_description: "BEERVID third-party Open API CLI skill"
4
+ default_prompt: "Use $beervid-app-cli to implement or troubleshoot BEERVID third-party Open API flows for OAuth, TikTok publishing, status polling, and product queries."
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
package/dist/cli.mjs CHANGED
@@ -4,18 +4,44 @@
4
4
  import cac from "cac";
5
5
 
6
6
  // src/client/index.ts
7
- import { readFileSync, existsSync } from "fs";
7
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
8
8
  import { resolve, basename } from "path";
9
+
10
+ // src/config.ts
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ var CONFIG_DIR = join(homedir(), ".beervid");
15
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
16
+ function loadConfig() {
17
+ if (!existsSync(CONFIG_FILE)) return {};
18
+ try {
19
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+ function saveConfig(config) {
25
+ mkdirSync(CONFIG_DIR, { recursive: true });
26
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
27
+ }
28
+ function getConfigPath() {
29
+ return CONFIG_FILE;
30
+ }
31
+
32
+ // src/client/index.ts
9
33
  function getApiKey() {
10
- const key = process.env["BEERVID_APP_KEY"];
34
+ const key = process.env["BEERVID_APP_KEY"] || loadConfig().appKey;
11
35
  if (!key) {
12
- console.error("\u9519\u8BEF: \u8BF7\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF BEERVID_APP_KEY");
36
+ console.error("\u9519\u8BEF: \u8BF7\u5148\u8BBE\u7F6E APP_KEY\uFF0C\u4EFB\u9009\u4E00\u79CD\u65B9\u5F0F:");
37
+ console.error(" 1. beervid config --app-key <your-key>");
38
+ console.error(" 2. export BEERVID_APP_KEY=<your-key>");
13
39
  process.exit(1);
14
40
  }
15
41
  return key;
16
42
  }
17
43
  function getBaseUrl() {
18
- return process.env["BEERVID_APP_BASE_URL"] ?? "https://open.beervid.ai";
44
+ return process.env["BEERVID_APP_BASE_URL"] || loadConfig().baseUrl || "https://open.beervid.ai";
19
45
  }
20
46
  async function handleResponse(res, path) {
21
47
  if (!res.ok && res.status >= 500) {
@@ -79,11 +105,11 @@ function detectInputType(input2) {
79
105
  }
80
106
  function localFileToFile(filePath) {
81
107
  const absPath = resolve(filePath);
82
- if (!existsSync(absPath)) {
108
+ if (!existsSync2(absPath)) {
83
109
  console.error(`\u9519\u8BEF: \u6587\u4EF6\u4E0D\u5B58\u5728 \u2014 ${absPath}`);
84
110
  process.exit(1);
85
111
  }
86
- const buffer = readFileSync(absPath);
112
+ const buffer = readFileSync2(absPath);
87
113
  const fileName = basename(absPath);
88
114
  const ext = fileName.split(".").pop()?.toLowerCase();
89
115
  const mimeMap = {
@@ -345,12 +371,11 @@ function register4(cli2) {
345
371
  }
346
372
 
347
373
  // src/commands/poll-status.ts
348
- var TERMINAL_STATUSES = ["PUBLISH_COMPLETE", "FAILED"];
349
374
  function sleep(ms) {
350
375
  return new Promise((resolve2) => setTimeout(resolve2, ms));
351
376
  }
352
377
  function register5(cli2) {
353
- cli2.command("poll-status", "\u8F6E\u8BE2\u666E\u901A\u89C6\u9891\u53D1\u5E03\u72B6\u6001").option("--business-id <id>", "TT \u8D26\u53F7 businessId\uFF08\u5FC5\u586B\uFF09").option("--share-id <id>", "\u53D1\u5E03\u65F6\u8FD4\u56DE\u7684 shareId\uFF08\u5FC5\u586B\uFF09").option("--interval <sec>", "\u8F6E\u8BE2\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 3\uFF09").option("--max-polls <n>", "\u6700\u5927\u8F6E\u8BE2\u6B21\u6570\uFF08\u9ED8\u8BA4 60\uFF09").action(
378
+ cli2.command("poll-status", "\u8F6E\u8BE2\u666E\u901A\u89C6\u9891\u53D1\u5E03\u72B6\u6001").option("--business-id <id>", "TT \u8D26\u53F7 businessId\uFF08\u5FC5\u586B\uFF09").option("--share-id <id>", "\u53D1\u5E03\u65F6\u8FD4\u56DE\u7684 shareId\uFF08\u5FC5\u586B\uFF09").option("--interval <sec>", "\u8F6E\u8BE2\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 5\uFF09").option("--max-polls <n>", "\u6700\u5927\u8F6E\u8BE2\u6B21\u6570\uFF08\u9ED8\u8BA4 60\uFF09").action(
354
379
  async (options) => {
355
380
  if (!options.businessId || !options.shareId) {
356
381
  const missing = [
@@ -362,7 +387,7 @@ function register5(cli2) {
362
387
  console.error("\u7528\u6CD5: beervid poll-status --business-id <id> --share-id <id>");
363
388
  process.exit(1);
364
389
  }
365
- const intervalSec = parseInt(options.interval ?? "3", 10);
390
+ const intervalSec = parseInt(options.interval ?? "5", 10);
366
391
  const maxPolls = parseInt(options.maxPolls ?? "60", 10);
367
392
  if (Number.isNaN(intervalSec) || intervalSec <= 0) {
368
393
  console.error("\u9519\u8BEF: --interval \u5FC5\u987B\u4E3A\u5927\u4E8E 0 \u7684\u6574\u6570");
@@ -377,35 +402,43 @@ function register5(cli2) {
377
402
  console.log(`businessId: ${options.businessId}`);
378
403
  console.log(`shareId: ${options.shareId}
379
404
  `);
405
+ let lastStatus = "UNKNOWN";
380
406
  for (let i = 1; i <= maxPolls; i++) {
381
407
  const data = await openApiPost("/api/v1/open/tiktok/video/status", {
382
408
  businessId: options.businessId,
383
409
  shareId: options.shareId
384
410
  });
385
411
  const status = data.status ?? data.Status ?? "UNKNOWN";
412
+ const postIds = data.post_ids ?? [];
413
+ lastStatus = status;
386
414
  console.log(`[${i}/${maxPolls}] \u72B6\u6001: ${status}`);
387
- if (TERMINAL_STATUSES.includes(status)) {
415
+ if (status === "FAILED") {
388
416
  console.log("");
389
- if (status === "PUBLISH_COMPLETE") {
390
- console.log("\u53D1\u5E03\u6210\u529F!");
391
- if (data.post_ids && data.post_ids.length > 0) {
392
- console.log(`\u89C6\u9891 ID: ${data.post_ids[0]}`);
393
- console.log(
394
- `\u63D0\u793A: \u4F7F\u7528 beervid query-video --business-id ${options.businessId} --item-ids ${data.post_ids[0]} \u67E5\u8BE2\u6570\u636E`
395
- );
396
- }
397
- } else {
398
- console.log(`\u53D1\u5E03\u5931\u8D25: ${data.reason ?? "\u672A\u77E5\u539F\u56E0"}`);
399
- }
417
+ console.log(`\u53D1\u5E03\u5931\u8D25: ${data.reason ?? "\u672A\u77E5\u539F\u56E0"}`);
400
418
  printResult(data);
401
- process.exit(status === "PUBLISH_COMPLETE" ? 0 : 1);
419
+ process.exit(1);
420
+ }
421
+ if (status === "PUBLISH_COMPLETE" && postIds.length > 0) {
422
+ console.log("");
423
+ console.log("\u53D1\u5E03\u6210\u529F!");
424
+ console.log(`\u89C6\u9891 ID: ${postIds[0]}`);
425
+ console.log(
426
+ `\u63D0\u793A: \u4F7F\u7528 beervid query-video --business-id ${options.businessId} --item-ids ${postIds[0]} \u67E5\u8BE2\u6570\u636E`
427
+ );
428
+ printResult(data);
429
+ process.exit(0);
402
430
  }
403
431
  if (i < maxPolls) {
404
432
  await sleep(intervalSec * 1e3);
405
433
  }
406
434
  }
407
- console.error(`
408
- \u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u72B6\u6001\u4ECD\u672A\u7EC8\u7ED3`);
435
+ if (lastStatus === "PUBLISH_COMPLETE") {
436
+ console.error(`
437
+ \u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u72B6\u6001\u4E3A PUBLISH_COMPLETE \u4F46 post_ids \u4ECD\u4E3A\u7A7A`);
438
+ } else {
439
+ console.error(`
440
+ \u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u4ECD\u672A\u62FF\u5230 post_ids`);
441
+ }
409
442
  process.exit(2);
410
443
  } catch (err) {
411
444
  rethrowIfProcessExit(err);
@@ -610,7 +643,6 @@ function register7(cli2) {
610
643
  import { createInterface } from "readline/promises";
611
644
  import { stdin as input, stdout as output } from "process";
612
645
  var MAX_PRODUCT_TITLE_LENGTH2 = 29;
613
- var TERMINAL_STATUSES2 = ["PUBLISH_COMPLETE", "FAILED"];
614
646
  function sleep2(ms) {
615
647
  return new Promise((resolve2) => setTimeout(resolve2, ms));
616
648
  }
@@ -636,18 +668,32 @@ async function publishTtsVideo(creatorId, fileId, productId, productTitle, capti
636
668
  return { publish, productTitle: normalizedTitle };
637
669
  }
638
670
  async function pollNormalVideoStatus(businessId, shareId, intervalSec, maxPolls) {
671
+ let lastData = null;
672
+ let lastStatus = "UNKNOWN";
639
673
  for (let i = 1; i <= maxPolls; i++) {
640
674
  const data = await openApiPost("/api/v1/open/tiktok/video/status", {
641
675
  businessId,
642
676
  shareId
643
677
  });
678
+ lastData = data;
644
679
  const status = data.status ?? data.Status ?? "UNKNOWN";
645
- if (TERMINAL_STATUSES2.includes(status)) {
680
+ const postIds = data.post_ids ?? [];
681
+ lastStatus = status;
682
+ if (status === "FAILED") {
683
+ return {
684
+ pollCount: i,
685
+ finalStatus: status,
686
+ reason: data.reason ?? null,
687
+ postIds,
688
+ raw: data
689
+ };
690
+ }
691
+ if (status === "PUBLISH_COMPLETE" && postIds.length > 0) {
646
692
  return {
647
693
  pollCount: i,
648
694
  finalStatus: status,
649
695
  reason: data.reason ?? null,
650
- postIds: data.post_ids ?? [],
696
+ postIds,
651
697
  raw: data
652
698
  };
653
699
  }
@@ -658,9 +704,9 @@ async function pollNormalVideoStatus(businessId, shareId, intervalSec, maxPolls)
658
704
  return {
659
705
  pollCount: maxPolls,
660
706
  finalStatus: "TIMEOUT",
661
- reason: `\u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u72B6\u6001\u4ECD\u672A\u7EC8\u7ED3`,
707
+ reason: lastStatus === "PUBLISH_COMPLETE" ? `\u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u72B6\u6001\u4E3A PUBLISH_COMPLETE \u4F46 post_ids \u4ECD\u4E3A\u7A7A` : `\u8D85\u8FC7\u6700\u5927\u8F6E\u8BE2\u6B21\u6570 (${maxPolls})\uFF0C\u4ECD\u672A\u62FF\u5230 post_ids`,
662
708
  postIds: [],
663
- raw: null
709
+ raw: lastData
664
710
  };
665
711
  }
666
712
  function normalizeVideoQuery(data) {
@@ -868,7 +914,7 @@ function parsePositiveInt(value, optionName, defaultValue) {
868
914
  return parsed;
869
915
  }
870
916
  function register8(cli2) {
871
- cli2.command("publish-tt-flow", "\u6267\u884C TT \u5B8C\u6574\u53D1\u5E03\u6D41\u7A0B\uFF1A\u4E0A\u4F20\u3001\u53D1\u5E03\u3001\u8F6E\u8BE2\u3001\u67E5\u8BE2\u6570\u636E").option("--business-id <id>", "TT \u8D26\u53F7 businessId\uFF08\u5FC5\u586B\uFF09").option("--file <path>", "\u89C6\u9891\u6587\u4EF6\u8DEF\u5F84\u6216 URL\uFF08\u5FC5\u586B\uFF09").option("--caption <text>", "\u89C6\u9891\u63CF\u8FF0/\u6587\u6848\uFF08\u53EF\u9009\uFF09").option("--token <token>", "\u5DF2\u6709\u4E0A\u4F20\u51ED\u8BC1\uFF08\u53EF\u9009\uFF09").option("--interval <sec>", "\u8F6E\u8BE2\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 3\uFF09").option("--max-polls <n>", "\u6700\u5927\u8F6E\u8BE2\u6B21\u6570\uFF08\u9ED8\u8BA4 60\uFF09").option("--query-interval <sec>", "\u89C6\u9891\u6570\u636E\u67E5\u8BE2\u91CD\u8BD5\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 5\uFF09").option("--query-max-attempts <n>", "\u89C6\u9891\u6570\u636E\u67E5\u8BE2\u6700\u5927\u91CD\u8BD5\u6B21\u6570\uFF08\u9ED8\u8BA4 3\uFF09").action(
917
+ cli2.command("publish-tt-flow", "\u6267\u884C TT \u5B8C\u6574\u53D1\u5E03\u6D41\u7A0B\uFF1A\u4E0A\u4F20\u3001\u53D1\u5E03\u3001\u8F6E\u8BE2\u3001\u67E5\u8BE2\u6570\u636E").option("--business-id <id>", "TT \u8D26\u53F7 businessId\uFF08\u5FC5\u586B\uFF09").option("--file <path>", "\u89C6\u9891\u6587\u4EF6\u8DEF\u5F84\u6216 URL\uFF08\u5FC5\u586B\uFF09").option("--caption <text>", "\u89C6\u9891\u63CF\u8FF0/\u6587\u6848\uFF08\u53EF\u9009\uFF09").option("--token <token>", "\u5DF2\u6709\u4E0A\u4F20\u51ED\u8BC1\uFF08\u53EF\u9009\uFF09").option("--interval <sec>", "\u8F6E\u8BE2\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 5\uFF09").option("--max-polls <n>", "\u6700\u5927\u8F6E\u8BE2\u6B21\u6570\uFF08\u9ED8\u8BA4 60\uFF09").option("--query-interval <sec>", "\u89C6\u9891\u6570\u636E\u67E5\u8BE2\u91CD\u8BD5\u95F4\u9694\u79D2\u6570\uFF08\u9ED8\u8BA4 5\uFF09").option("--query-max-attempts <n>", "\u89C6\u9891\u6570\u636E\u67E5\u8BE2\u6700\u5927\u91CD\u8BD5\u6B21\u6570\uFF08\u9ED8\u8BA4 3\uFF09").action(
872
918
  async (options) => {
873
919
  if (!options.businessId || !options.file) {
874
920
  const missing = [
@@ -882,7 +928,7 @@ function register8(cli2) {
882
928
  );
883
929
  process.exit(1);
884
930
  }
885
- const intervalSec = parsePositiveInt(options.interval, "--interval", 3);
931
+ const intervalSec = parsePositiveInt(options.interval, "--interval", 5);
886
932
  const maxPolls = parsePositiveInt(options.maxPolls, "--max-polls", 60);
887
933
  const queryIntervalSec = parsePositiveInt(options.queryInterval, "--query-interval", 5);
888
934
  const queryMaxAttempts = parsePositiveInt(
@@ -920,11 +966,6 @@ function register8(cli2) {
920
966
  );
921
967
  query = queryResult.query;
922
968
  warnings.push(...queryResult.warnings);
923
- } else if (status.finalStatus === "PUBLISH_COMPLETE") {
924
- warnings.push({
925
- code: "VIDEO_ID_MISSING",
926
- message: "\u53D1\u5E03\u6210\u529F\uFF0C\u4F46\u72B6\u6001\u7ED3\u679C\u4E2D\u672A\u8FD4\u56DE videoId\uFF0C\u5DF2\u8DF3\u8FC7\u89C6\u9891\u6570\u636E\u67E5\u8BE2"
927
- });
928
969
  }
929
970
  const result = {
930
971
  flowType: "tt",
@@ -1105,8 +1146,49 @@ function register9(cli2) {
1105
1146
  );
1106
1147
  }
1107
1148
 
1149
+ // src/commands/config.ts
1150
+ function register10(cli2) {
1151
+ cli2.command("config", "\u8BBE\u7F6E BEERVID_APP_KEY \u7B49\u5168\u5C40\u914D\u7F6E").option("--app-key <key>", "\u8BBE\u7F6E APP_KEY\uFF08\u6301\u4E45\u5316\u5230 ~/.beervid/config.json\uFF09").option("--base-url <url>", "\u8BBE\u7F6E API \u57FA\u7840 URL").option("--show", "\u663E\u793A\u5F53\u524D\u914D\u7F6E").action((options) => {
1152
+ if (options.show) {
1153
+ const config2 = loadConfig();
1154
+ console.log(`\u914D\u7F6E\u6587\u4EF6: ${getConfigPath()}
1155
+ `);
1156
+ if (!config2.appKey && !config2.baseUrl) {
1157
+ console.log("\uFF08\u6682\u65E0\u914D\u7F6E\uFF09");
1158
+ } else {
1159
+ if (config2.appKey) {
1160
+ const masked = config2.appKey.length > 8 ? config2.appKey.slice(0, 4) + "****" + config2.appKey.slice(-4) : "****";
1161
+ console.log(`APP_KEY: ${masked}`);
1162
+ }
1163
+ if (config2.baseUrl) {
1164
+ console.log(`BASE_URL: ${config2.baseUrl}`);
1165
+ }
1166
+ }
1167
+ return;
1168
+ }
1169
+ if (!options.appKey && !options.baseUrl) {
1170
+ console.error("\u8BF7\u6307\u5B9A\u8981\u8BBE\u7F6E\u7684\u914D\u7F6E\u9879\uFF0C\u4F8B\u5982:\n");
1171
+ console.error(" beervid config --app-key <your-key>");
1172
+ console.error(" beervid config --base-url <url>");
1173
+ console.error(" beervid config --show");
1174
+ process.exit(1);
1175
+ }
1176
+ const config = loadConfig();
1177
+ if (options.appKey) {
1178
+ config.appKey = options.appKey;
1179
+ }
1180
+ if (options.baseUrl) {
1181
+ config.baseUrl = options.baseUrl;
1182
+ }
1183
+ saveConfig(config);
1184
+ console.log("\u914D\u7F6E\u5DF2\u4FDD\u5B58\u5230", getConfigPath());
1185
+ });
1186
+ }
1187
+
1108
1188
  // src/cli.ts
1109
1189
  var cli = cac("beervid");
1190
+ var cliVersion = true ? "0.2.1" : pkg.version;
1191
+ register10(cli);
1110
1192
  register(cli);
1111
1193
  register2(cli);
1112
1194
  register3(cli);
@@ -1117,7 +1199,7 @@ register7(cli);
1117
1199
  register8(cli);
1118
1200
  register9(cli);
1119
1201
  cli.help();
1120
- cli.version("1.0.0");
1202
+ cli.version(cliVersion);
1121
1203
  if (process.argv.slice(2).length === 0) {
1122
1204
  cli.outputHelp();
1123
1205
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beervid-app-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "BEERVID App CLI — TikTok video publish, account auth, and data query",
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,6 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "dist/",
14
+ "agents/",
15
+ "references/",
14
16
  "SKILL.md",
15
17
  "README.md"
16
18
  ],
@@ -0,0 +1,605 @@
1
+ # BEERVID 第三方应用 Open API — 请求/响应参考
2
+
3
+ > 本文档包含 BEERVID 面向第三方应用开放的所有端点的详细请求参数、响应结构和错误码示例。
4
+ > API 路径统一前缀:`/api/v1/open/`,认证方式:`X-API-KEY` 请求头。
5
+
6
+ ## 目录
7
+
8
+ 1. [账号授权](#1-账号授权)
9
+ 2. [视频上传](#2-视频上传)
10
+ 3. [视频发布](#3-视频发布)
11
+ 4. [发布状态查询](#4-发布状态查询)
12
+ 5. [视频数据查询](#5-视频数据查询)
13
+ 6. [TTS 商品查询](#6-tts-商品查询)
14
+ 7. [错误码速查表](#7-错误码速查表)
15
+
16
+ ---
17
+
18
+ ## 1. 账号授权
19
+
20
+ ### GET /api/v1/open/thirdparty-auth/tt-url
21
+
22
+ 获取 TikTok 普通账号 OAuth 授权链接。
23
+
24
+ **请求:** 无参数
25
+
26
+ **响应:**
27
+ ```json
28
+ {
29
+ "code": 0,
30
+ "success": true,
31
+ "message": "ok",
32
+ "data": "https://www.tiktok.com/v2/auth/authorize?client_key=..."
33
+ }
34
+ ```
35
+
36
+ **调用示例:**
37
+ ```typescript
38
+ const { data: url } = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
39
+ // url = "https://www.tiktok.com/v2/auth/authorize?client_key=..."
40
+ ```
41
+
42
+ ---
43
+
44
+ ### GET /api/v1/open/thirdparty-auth/tts-url
45
+
46
+ 获取 TikTok Shop 账号 OAuth 授权链接(跨境)。
47
+
48
+ **请求:** 无参数
49
+
50
+ **响应:**
51
+ ```json
52
+ {
53
+ "code": 0,
54
+ "success": true,
55
+ "message": "ok",
56
+ "data": {
57
+ "crossBorderUrl": "https://services.tiktokshop.com/open/authorize?..."
58
+ }
59
+ }
60
+ ```
61
+
62
+ **调用示例:**
63
+ ```typescript
64
+ const { data } = await openApiGet<{ crossBorderUrl: string }>('/api/v1/open/thirdparty-auth/tts-url')
65
+ const url = data.crossBorderUrl
66
+ ```
67
+
68
+ ---
69
+
70
+ ### POST /api/v1/open/account/info
71
+
72
+ 查询已授权账号的详细信息。
73
+
74
+ **请求:**
75
+ ```json
76
+ {
77
+ "accountType": "TT",
78
+ "accountId": "7281234567890"
79
+ }
80
+ ```
81
+
82
+ | 字段 | 类型 | 必填 | 说明 |
83
+ |------|------|------|------|
84
+ | `accountType` | `'TT' \| 'TTS'` | 是 | 账号类型 |
85
+ | `accountId` | `string` | 是 | 平台返回的账号 ID |
86
+
87
+ **响应:**
88
+ ```json
89
+ {
90
+ "code": 0,
91
+ "success": true,
92
+ "data": {
93
+ "accountType": "TT",
94
+ "accountId": "7281234567890",
95
+ "username": "creator_name",
96
+ "displayName": "Creator Display Name",
97
+ "sellerName": "",
98
+ "profileUrl": "https://p16-sign.tiktokcdn.com/...",
99
+ "followersCount": 15000,
100
+ "accessToken": "act.xxx...",
101
+ "ext": {}
102
+ }
103
+ }
104
+ ```
105
+
106
+ | 响应字段 | 类型 | 说明 |
107
+ |---------|------|------|
108
+ | `accountType` | `string` | 账号类型 |
109
+ | `accountId` | `string` | 账号 ID |
110
+ | `username` | `string` | 用户名 |
111
+ | `displayName` | `string` | 显示名称 |
112
+ | `sellerName` | `string` | 卖家名称(TTS 账号) |
113
+ | `profileUrl` | `string` | 头像 URL |
114
+ | `followersCount` | `number` | 粉丝数 |
115
+ | `accessToken` | `string` | 访问令牌 |
116
+ | `ext` | `Record<string, unknown>` | 扩展字段 |
117
+
118
+ **调用示例:**
119
+ ```typescript
120
+ const { data: accountInfo } = await openApiPost<AccountInfo>(
121
+ '/api/v1/open/account/info',
122
+ { accountType: 'TT', accountId: '7281234567890' }
123
+ )
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 2. 视频上传
129
+
130
+ ### POST /api/v1/open/upload-token/generate
131
+
132
+ 生成视频上传凭证。
133
+
134
+ **请求:** 无 body
135
+
136
+ **响应:**
137
+ ```json
138
+ {
139
+ "code": 0,
140
+ "success": true,
141
+ "data": {
142
+ "uploadToken": "upt.xxx...",
143
+ "expiresIn": 1800,
144
+ "message": ""
145
+ }
146
+ }
147
+ ```
148
+
149
+ | 响应字段 | 类型 | 说明 |
150
+ |---------|------|------|
151
+ | `uploadToken` | `string` | 上传凭证 |
152
+ | `expiresIn` | `number` | 过期时间(秒) |
153
+
154
+ ---
155
+
156
+ ### POST /api/v1/open/file-upload
157
+
158
+ 上传普通视频文件(TT 账号使用)。
159
+
160
+ **请求:** `multipart/form-data`
161
+
162
+ | 字段 | 类型 | 必填 | 说明 |
163
+ |------|------|------|------|
164
+ | `file` | `File` | 是 | 视频文件 |
165
+
166
+ **请求头:** `X-UPLOAD-TOKEN`(值为上传凭证接口返回的 `uploadToken`)
167
+
168
+ **响应:**
169
+ ```json
170
+ {
171
+ "code": 0,
172
+ "success": true,
173
+ "data": {
174
+ "fileUrl": "https://cdn.beervid.ai/uploads/xxx.mp4",
175
+ "fileName": "video.mp4",
176
+ "fileSize": 15728640,
177
+ "contentType": "video/mp4"
178
+ }
179
+ }
180
+ ```
181
+
182
+ | 响应字段 | 类型 | 说明 |
183
+ |---------|------|------|
184
+ | `fileUrl` | `string` | 上传后的视频 URL,用于后续发布 |
185
+ | `fileName` | `string` | 文件名 |
186
+ | `fileSize` | `number` | 文件大小(字节) |
187
+ | `contentType` | `string` | MIME 类型 |
188
+
189
+ ---
190
+
191
+ ### POST /api/v1/open/file-upload/tts-video
192
+
193
+ 上传挂车视频文件(TTS 账号使用)。
194
+
195
+ **请求:** `multipart/form-data`
196
+
197
+ | 字段 | 类型 | 必填 | 说明 |
198
+ |------|------|------|------|
199
+ | `file` | `File` | 是 | 视频文件 |
200
+
201
+ **Query 参数:**
202
+
203
+ | 参数 | 类型 | 必填 | 说明 |
204
+ |------|------|------|------|
205
+ | `creatorUserOpenId` | `string` | 是 | TTS 账号的 OpenId |
206
+
207
+ **响应:**
208
+ ```json
209
+ {
210
+ "code": 0,
211
+ "success": true,
212
+ "data": {
213
+ "videoFileId": "vf_abc123def456",
214
+ "md5": "d41d8cd98f00b204e9800998ecf8427e",
215
+ "uploadType": "tts"
216
+ }
217
+ }
218
+ ```
219
+
220
+ | 响应字段 | 类型 | 说明 |
221
+ |---------|------|------|
222
+ | `videoFileId` | `string` | 视频文件 ID,用于后续挂车发布 |
223
+ | `md5` | `string` | 文件 MD5 |
224
+ | `uploadType` | `string` | 上传类型标识 |
225
+
226
+ **注意:** 普通上传返回 `fileUrl`,TTS 上传返回 `videoFileId`,两者用于不同的发布端点。
227
+
228
+ ---
229
+
230
+ ## 3. 视频发布
231
+
232
+ ### POST /api/v1/open/tiktok/video/publish
233
+
234
+ 发布普通 TikTok 视频。
235
+
236
+ **请求:**
237
+ ```json
238
+ {
239
+ "businessId": "biz_12345",
240
+ "videoUrl": "https://cdn.beervid.ai/uploads/xxx.mp4",
241
+ "caption": "Check out this amazing video! #viral"
242
+ }
243
+ ```
244
+
245
+ | 字段 | 类型 | 必填 | 说明 |
246
+ |------|------|------|------|
247
+ | `businessId` | `string` | 是 | TT 账号的 businessId |
248
+ | `videoUrl` | `string` | 是 | 上传后获得的视频 URL |
249
+ | `caption` | `string` | 否 | 视频描述/文案 |
250
+
251
+ **响应:**
252
+ ```json
253
+ {
254
+ "code": 0,
255
+ "success": true,
256
+ "data": {
257
+ "shareId": "share_abc123",
258
+ "status": "PROCESSING_DOWNLOAD",
259
+ "message": ""
260
+ }
261
+ }
262
+ ```
263
+
264
+ **后续:** 使用返回的 `shareId` 轮询 `/api/v1/open/tiktok/video/status` 获取发布进度。
265
+
266
+ ---
267
+
268
+ ### POST /api/v1/open/tts/shoppable-video/publish
269
+
270
+ 发布挂车视频(TTS 账号,带商品链接)。
271
+
272
+ **请求:**
273
+ ```json
274
+ {
275
+ "creatorUserOpenId": "open_user_abc",
276
+ "fileId": "vf_abc123def456",
277
+ "title": "Amazing product review",
278
+ "productId": "prod_789",
279
+ "productTitle": "Premium Widget Pro"
280
+ }
281
+ ```
282
+
283
+ | 字段 | 类型 | 必填 | 说明 |
284
+ |------|------|------|------|
285
+ | `creatorUserOpenId` | `string` | 是 | TTS 账号 OpenId |
286
+ | `fileId` | `string` | 是 | 上传返回的 `videoFileId` |
287
+ | `title` | `string` | 否 | 视频标题 |
288
+ | `productId` | `string` | 是 | 商品 ID |
289
+ | `productTitle` | `string` | 是 | 商品标题(**最多 29 字符**,超出应截断) |
290
+
291
+ **响应:**
292
+ ```json
293
+ {
294
+ "code": 0,
295
+ "success": true,
296
+ "data": {
297
+ "videoId": "vid_xyz789",
298
+ "status": "PUBLISH_COMPLETE",
299
+ "message": ""
300
+ }
301
+ }
302
+ ```
303
+
304
+ **注意:** 挂车视频发布后立即完成(`PUBLISH_COMPLETE`),无需轮询状态。
305
+
306
+ ---
307
+
308
+ ## 4. 发布状态查询
309
+
310
+ ### POST /api/v1/open/tiktok/video/status
311
+
312
+ 查询普通视频的发布进度(仅 TT 普通视频需要,TTS 挂车视频无需轮询)。
313
+
314
+ **请求:**
315
+ ```json
316
+ {
317
+ "businessId": "biz_12345",
318
+ "shareId": "share_abc123"
319
+ }
320
+ ```
321
+
322
+ | 字段 | 类型 | 必填 | 说明 |
323
+ |------|------|------|------|
324
+ | `businessId` | `string` | 是 | TT 账号的 businessId |
325
+ | `shareId` | `string` | 是 | 发布时返回的 shareId |
326
+
327
+ **响应 — 处理中:**
328
+ ```json
329
+ {
330
+ "code": 0,
331
+ "success": true,
332
+ "data": {
333
+ "status": "PROCESSING_DOWNLOAD"
334
+ }
335
+ }
336
+ ```
337
+
338
+ **响应 — 发布成功:**
339
+ ```json
340
+ {
341
+ "code": 0,
342
+ "success": true,
343
+ "data": {
344
+ "status": "PUBLISH_COMPLETE",
345
+ "post_ids": ["7123456789012345678"]
346
+ }
347
+ }
348
+ ```
349
+
350
+ **响应 — 发布失败:**
351
+ ```json
352
+ {
353
+ "code": 0,
354
+ "success": true,
355
+ "data": {
356
+ "status": "FAILED",
357
+ "reason": "Video format not supported"
358
+ }
359
+ }
360
+ ```
361
+
362
+ **状态值说明:**
363
+
364
+ | 状态 | 含义 | 是否终态 |
365
+ |------|------|---------|
366
+ | `PROCESSING_DOWNLOAD` | 视频处理中 | 否,继续轮询 |
367
+ | `PUBLISH_COMPLETE` | 发布接口已完成;当 `post_ids` 非空时可视为成功完成 | 条件成立时是 |
368
+ | `FAILED` | 发布失败 | 是 |
369
+
370
+ **注意:** 如果返回 `PUBLISH_COMPLETE` 但 `post_ids` 为空,应继续轮询,直到拿到 `post_ids` 或达到超时上限。
371
+
372
+ **成功后:** `post_ids[0]` 即为 TikTok 上的视频 ID,可用于后续数据查询。
373
+
374
+ ---
375
+
376
+ ## 5. 视频数据查询
377
+
378
+ ### POST /api/v1/open/tiktok/video/query
379
+
380
+ 批量查询视频统计数据(播放量、点赞、评论、分享等)。
381
+
382
+ **请求:**
383
+ ```json
384
+ {
385
+ "businessId": "biz_12345",
386
+ "itemIds": ["7123456789012345678", "7123456789012345679"]
387
+ }
388
+ ```
389
+
390
+ | 字段 | 类型 | 必填 | 说明 |
391
+ |------|------|------|------|
392
+ | `businessId` | `string` | 是 | TT 账号的 businessId |
393
+ | `itemIds` | `string[]` | 是 | 视频 ID 数组 |
394
+
395
+ **响应(新版格式 — camelCase):**
396
+ ```json
397
+ {
398
+ "code": 0,
399
+ "success": true,
400
+ "data": {
401
+ "videoList": [
402
+ {
403
+ "itemId": "7123456789012345678",
404
+ "thumbnailUrl": "https://p16-sign.tiktokcdn.com/...",
405
+ "shareUrl": "https://www.tiktok.com/@user/video/...",
406
+ "videoViews": 52300,
407
+ "likes": 1200,
408
+ "comments": 89,
409
+ "shares": 45
410
+ }
411
+ ]
412
+ }
413
+ }
414
+ ```
415
+
416
+ **响应(旧版格式 — snake_case):**
417
+ ```json
418
+ {
419
+ "code": 0,
420
+ "success": true,
421
+ "data": {
422
+ "videos": [
423
+ {
424
+ "item_id": "7123456789012345678",
425
+ "thumbnail_url": "https://...",
426
+ "share_url": "https://...",
427
+ "video_views": 52300,
428
+ "likes": 1200,
429
+ "comments": 89,
430
+ "shares": 45
431
+ }
432
+ ]
433
+ }
434
+ }
435
+ ```
436
+
437
+ **字段对照表:**
438
+
439
+ | 数据项 | 新版字段 | 旧版字段 | 类型 |
440
+ |--------|---------|---------|------|
441
+ | 视频列表 | `videoList` | `videos` | `array` |
442
+ | 视频 ID | `itemId` | `item_id` | `string` |
443
+ | 缩略图 | `thumbnailUrl` | `thumbnail_url` | `string` |
444
+ | 分享链接 | `shareUrl` | `share_url` | `string` |
445
+ | 播放量 | `videoViews` | `video_views` | `number` |
446
+ | 点赞数 | `likes` | `likes` | `number` |
447
+ | 评论数 | `comments` | `comments` | `number` |
448
+ | 分享数 | `shares` | `shares` | `number` |
449
+
450
+ **兼容写法示例:**
451
+ ```typescript
452
+ const list = data.videoList ?? data.videos ?? []
453
+ const video = list[0]
454
+ if (video) {
455
+ const itemId = video.itemId ?? video.item_id
456
+ const views = video.videoViews ?? video.video_views ?? 0
457
+ const thumbnailUrl = video.thumbnailUrl ?? video.thumbnail_url
458
+ const shareUrl = video.shareUrl ?? video.share_url
459
+ }
460
+ ```
461
+
462
+ **约束:** 仅拥有 TT 授权的账号才可查询视频数据。TTS-only 账号无此能力。
463
+
464
+ ---
465
+
466
+ ## 6. TTS 商品查询
467
+
468
+ ### POST /api/v1/open/tts/products/query
469
+
470
+ 查询创作者的店铺/橱窗商品列表,用于挂车发布时选择商品。
471
+
472
+ **请求:**
473
+ ```json
474
+ {
475
+ "creatorUserOpenId": "open_user_abc",
476
+ "productType": "shop",
477
+ "pageSize": 20,
478
+ "pageToken": ""
479
+ }
480
+ ```
481
+
482
+ | 字段 | 类型 | 必填 | 说明 |
483
+ |------|------|------|------|
484
+ | `creatorUserOpenId` | `string` | 是 | TTS 账号 OpenId |
485
+ | `productType` | `'shop' \| 'showcase'` | 是 | 商品来源类型 |
486
+ | `pageSize` | `number` | 是 | 每页数量(建议 20) |
487
+ | `pageToken` | `string` | 否 | 分页游标(首页留空) |
488
+
489
+ **响应:**
490
+ ```json
491
+ {
492
+ "code": 0,
493
+ "success": true,
494
+ "data": [
495
+ {
496
+ "productType": "shop",
497
+ "products": [
498
+ {
499
+ "id": "prod_123",
500
+ "title": "Premium Widget Pro",
501
+ "price": { "amount": "29.99", "currency": "USD" },
502
+ "images": ["{height=200, url=https://img.tiktokcdn.com/xxx.jpg, width=200}"],
503
+ "addedStatus": "ADDED",
504
+ "reviewStatus": "APPROVED",
505
+ "inventoryStatus": "IN_STOCK",
506
+ "brandName": "WidgetCo",
507
+ "shopName": "Widget Store",
508
+ "salesCount": 1500,
509
+ "source": "shop"
510
+ }
511
+ ],
512
+ "totalCount": 45,
513
+ "nextPageToken": "eyJ..."
514
+ }
515
+ ]
516
+ }
517
+ ```
518
+
519
+ **商品字段说明:**
520
+
521
+ | 字段 | 类型 | 说明 |
522
+ |------|------|------|
523
+ | `id` | `string` | 商品 ID,发布时传入 `productId` |
524
+ | `title` | `string` | 商品标题,发布时传入 `productTitle`(注意 29 字符限制) |
525
+ | `price` | `object` | 价格信息 |
526
+ | `images` | `string[]` | 商品图片(特殊格式,需解析) |
527
+ | `addedStatus` | `string` | 添加状态 |
528
+ | `reviewStatus` | `string` | 审核状态 |
529
+ | `inventoryStatus` | `string` | 库存状态 |
530
+ | `salesCount` | `number` | 销量 |
531
+ | `nextPageToken` | `string \| null` | 下一页游标(`null` 表示最后一页) |
532
+
533
+ **图片 URL 提取:**
534
+
535
+ 商品图片返回特殊格式,需正则解析:
536
+ ```typescript
537
+ // 原始格式: "{height=200, url=https://img.tiktokcdn.com/xxx.jpg, width=200}"
538
+ function extractImageUrl(imageStr: string): string {
539
+ const match = imageStr.match(/url=([^,}]+)/)
540
+ return match?.[1]?.trim() ?? ''
541
+ }
542
+ ```
543
+
544
+ **分页游标处理:**
545
+
546
+ 建议同时查询 `shop` 和 `showcase` 两种类型,使用复合游标管理分页状态:
547
+ ```typescript
548
+ // 编码游标
549
+ function encodeCursor(shopToken?: string, showcaseToken?: string): string {
550
+ return btoa(JSON.stringify({ shopToken: shopToken ?? '', showcaseToken: showcaseToken ?? '' }))
551
+ }
552
+
553
+ // 解码游标
554
+ function decodeCursor(cursor: string): { shopToken: string; showcaseToken: string } {
555
+ return JSON.parse(atob(cursor))
556
+ }
557
+ ```
558
+
559
+ **去重合并:** 同一商品可能同时出现在 shop 和 showcase 中,应按 `id` 去重。
560
+
561
+ ---
562
+
563
+ ## 7. 错误码速查表
564
+
565
+ ### Open API 层
566
+
567
+ 所有端点共用的响应 `code` 字段:
568
+
569
+ | code | 含义 | 处理方式 |
570
+ |------|------|---------|
571
+ | `0` | 成功 | 正常处理 `data` 字段 |
572
+ | 非零 | 业务错误 | 读取 `message` 获取错误详情 |
573
+
574
+ ### 常见业务错误场景
575
+
576
+ | 场景 | 建议 HTTP 状态码 | 建议 code | 示例 message |
577
+ |------|-----------------|-----------|-------------|
578
+ | 缺少必填参数 | 400 | 400 | `"accountId 为必填项"` |
579
+ | 参数值非法 | 400 | 400 | `"publishType 非法"` |
580
+ | 账号未授权 | 403 | 403 | `"该账号未授权普通视频发布"` |
581
+ | OAuth 授权过期 | 403 | 403 | `"授权已过期或无效,请重新授权"` |
582
+ | State Token 用户不匹配 | 403 | 403 | `"授权用户不匹配,请重新登录后授权"` |
583
+ | 资源不存在 | 404 | 404 | `"TikTok 账号不存在"` |
584
+ | Open API 调用失败 | 500 | 500 | `"Open API 错误 [/api/v1/open/xxx]: Bad request (code: 40001)"` |
585
+ | 上传凭证获取失败 | 500 | 500 | `"获取上传凭证失败"` |
586
+ | API 成功但本地存储失败 | 200 | 200 | `"视频已提交至 TikTok,但本地记录保存失败"`(附 `dbSaved: false`) |
587
+
588
+ ### 客户端上传错误
589
+
590
+ | 错误消息 | 原因 | 处理建议 |
591
+ |---------|------|---------|
592
+ | `上传失败,响应解析失败` | 服务端返回非 JSON 内容 | 检查上传 URL 和凭证是否正确 |
593
+ | `上传失败,状态码: {status}` | HTTP 非 2xx | 检查认证头和文件格式 |
594
+ | `上传失败` | API 返回 `code !== 0` | 查看 `message` 获取具体原因 |
595
+ | `上传成功但未返回可用结果` | 响应中既无 `fileUrl` 也无 `videoFileId` | API 异常,需排查 |
596
+ | `上传失败,网络错误` | 网络连接中断 | 提示用户检查网络后重试 |
597
+ | `Upload aborted` | 用户取消或页面卸载 | 正常行为,无需处理 |
598
+
599
+ ### 队列重试建议
600
+
601
+ | 参数 | 建议值 | 说明 |
602
+ |------|--------|------|
603
+ | 最大重试次数 | 3 | 超过后停止重试并记录日志 |
604
+ | 重试间隔 | 2 秒 | 固定间隔或指数退避均可 |
605
+ | 超限处理 | acknowledge | 防止消息无限重投 |