beervid-app-cli 0.2.5 → 0.2.7

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.
@@ -83,6 +83,14 @@ interface OpenApiResponse<T> {
83
83
  - 发布挂车视频时依赖商品信息
84
84
  - 发布后通常立即完成,不走 TT 的轮询链路
85
85
 
86
+ ### TT / TTS 关联场景
87
+
88
+ - TTS 账号本身不支持视频数据查询。
89
+ - 如果同一达人既要挂车发布,又要查询视频播放/点赞/评论等数据,必须同时完成 TTS 授权和 TT 授权。
90
+ - 官方当前没有提供 `uno_id` 之类可直接打通 TT/TTS 的关联字段。
91
+ - 当前推荐在两边授权完成后,调用 `account/info` 拉取账号详情,并以 `username` 作为你方系统内的关联键。
92
+ - 关联完成后,发布挂车继续使用 `creatorUserOpenId`,查询视频数据使用对应 TT 账号的 `businessId`。
93
+
86
94
  ## 六类核心能力
87
95
 
88
96
  只保留能力定位,详细接口说明去看引用文档:
@@ -102,7 +110,7 @@ interface OpenApiResponse<T> {
102
110
 
103
111
  ```text
104
112
  获取 TT OAuth URL
105
- -> 用户授权回调得到 ttAbId
113
+ -> 用户授权回调,从 `state` JSON 中解析得到 ttAbId
106
114
  -> ttAbId 作为 businessId 持久化
107
115
  -> 获取上传凭证
108
116
  -> 上传普通视频,拿到 fileUrl
@@ -116,7 +124,7 @@ interface OpenApiResponse<T> {
116
124
 
117
125
  ```text
118
126
  获取 TTS OAuth URL
119
- -> 用户授权回调得到 ttsAbId
127
+ -> 用户授权回调,从 `state` JSON 中解析得到 ttsAbId
120
128
  -> ttsAbId 作为 creatorUserOpenId 持久化
121
129
  -> 查询商品,得到 productId + productTitle
122
130
  -> 获取上传凭证
@@ -124,22 +132,39 @@ interface OpenApiResponse<T> {
124
132
  -> 发布挂车视频
125
133
  ```
126
134
 
135
+ ### 同一达人既要挂车发布又要查视频数据
136
+
137
+ ```text
138
+ 先授权 TTS
139
+ -> 得到 ttsAbId -> 持久化为 creatorUserOpenId
140
+ -> 调用 account/info -> 记录 TTS username
141
+
142
+ 再授权 TT
143
+ -> 得到 ttAbId -> 持久化为 businessId
144
+ -> 调用 account/info -> 记录 TT username
145
+
146
+ 用 username 在你方系统中建立 TT <-> TTS 关联
147
+ -> 挂车发布时使用 creatorUserOpenId
148
+ -> 视频查数时使用 businessId
149
+ ```
150
+
127
151
  ## 关键参数链路
128
152
 
129
153
  这是主文件里最值得保留的部分,因为它决定了接口如何串起来:
130
154
 
131
155
  | 参数 | 含义 | 产出来源 |
132
156
  | ------------------------ | -------------------- | -------------------------------------------------- |
133
- | `businessId` | TT 账号业务 ID | OAuth 回调参数 `ttAbId` |
134
- | `creatorUserOpenId` | TTS 账号 OpenId | OAuth 回调参数 `ttsAbId` |
157
+ | `businessId` | TT 账号业务 ID | OAuth 回调 `state` JSON 中的 `ttAbId` |
158
+ | `creatorUserOpenId` | TTS 账号 OpenId | OAuth 回调 `state` JSON 中的 `ttsAbId` |
135
159
  | `accountId` | 平台账号 ID | 即 `ttAbId` 或 `ttsAbId`,用于查询账号详情 |
160
+ | `username` | 账号用户名 | `account/info` 返回;当前推荐作为 TT/TTS 关联键 |
136
161
  | `uploadToken` | 上传凭证 | `upload-token/generate` 返回 |
137
162
  | `fileUrl` | 普通上传后的视频 URL | `file-upload` 返回 |
138
163
  | `videoFileId` / `fileId` | TTS 视频文件 ID | `file-upload/tts-video` 返回 |
139
164
  | `shareId` | TT 普通发布追踪 ID | `tiktok/video/publish` 返回 |
140
165
  | `videoId` | TikTok 视频 ID | TTS 发布直接返回;TT 从状态查询 `post_ids[0]` 获取 |
141
166
  | `productId` | 商品 ID | `tts/products/query` 返回的商品列表 |
142
- | `productTitle` | 商品标题 | 同上,最多 29 字符,超出应先截断 |
167
+ | `productTitle` | 商品标题 | 同上,最多 30 字符,超出应先截断 |
143
168
  | `itemIds` | 视频 ID 数组 | 来源于 `videoId`,用于查询视频数据 |
144
169
 
145
170
  ```text
@@ -173,8 +198,9 @@ creatorUserOpenId -> upload-tts-video -> fileId -> shoppable-publish -> videoId
173
198
  ### 常见业务约束
174
199
 
175
200
  - TT 普通视频发布后需要轮询;只有 `PUBLISH_COMPLETE` 且 `post_ids` 非空才算成功完成;TTS 挂车视频通常不需要
176
- - 仅 TT 授权账号可查询视频数据
177
- - `productTitle` 最多 29 字符,超出时应提前截断
201
+ - 仅 TT 授权账号可查询视频数据;TTS-only 账号不能查数
202
+ - 如果同一达人既要 TTS 挂车发布又要查视频数据,必须额外授权 TT;当前推荐以 `username` 关联 TT/TTS,不能依赖官方 `uno_id`
203
+ - `productTitle` 最多 30 字符,超出时应提前截断
178
204
  - 商品图片字段可能是特殊字符串格式,解析细节见 [`docs/tts-product-cache.md`](./docs/tts-product-cache.md)
179
205
  - 视频查询接口可能同时返回 camelCase 与 snake_case 字段,兼容细节见 [`references/api-reference.md`](./references/api-reference.md)
180
206
 
@@ -209,7 +235,7 @@ export BEERVID_APP_BASE_URL="https://open.beervid.ai"
209
235
  | `beervid upload` | 上传视频 | `--file`, `--type tts`, `--creator-id`, `--token` |
210
236
  | `beervid publish` | 发布普通或挂车视频 | `--type normal | shoppable` 加对应业务参数 |
211
237
  | `beervid poll-status` | 轮询 TT 发布状态 | `--business-id`, `--share-id`, `--interval`, `--max-polls` |
212
- | `beervid query-video` | 查询视频数据 | `--business-id`, `--item-ids` |
238
+ | `beervid query-video` | 查询视频数据 | `--business-id`, `--item-ids`, `--cursor`, `--max-count` |
213
239
  | `beervid query-products` | 查询 TTS 商品 | `--creator-id`, `--product-type`, `--cursor` |
214
240
  | `beervid publish-tt-flow` | 执行 TT 完整发布流程 | `--business-id`, `--file`, `--caption` |
215
241
  | `beervid publish-tts-flow` | 执行 TTS 完整发布流程 | `--creator-id`, `--file`, `--interactive`, `--product-id`, `--product-title` |
@@ -247,6 +273,7 @@ beervid poll-status --business-id=biz_12345 --share-id share_abc123
247
273
 
248
274
  # 查询视频数据
249
275
  beervid query-video --business-id=biz_12345 --item-ids 7123456789012345678
276
+ beervid query-video --business-id=biz_12345 --cursor 0 --max-count 20
250
277
 
251
278
  # 查询商品
252
279
  beervid query-products --creator-id=open_user_abc
@@ -266,6 +293,13 @@ beervid publish-tts-flow --creator-id=open_user_abc --file https://example.com/v
266
293
 
267
294
  ## 读哪份文档
268
295
 
296
+ ### 快速开始
297
+
298
+ 如果你是第一次接触 BEERVID Open API,从这里开始:
299
+
300
+ - **5 分钟快速上手**:[`QUICKSTART.md`](./QUICKSTART.md) - 从零到发布第一个视频
301
+ - **常见问题**:[`FAQ.md`](./FAQ.md) - 快速查找常见问题的答案
302
+
269
303
  ### 接口细节
270
304
 
271
305
  当你需要以下内容时,直接读取 [`references/api-reference.md`](./references/api-reference.md):
@@ -286,9 +320,18 @@ beervid publish-tts-flow --creator-id=open_user_abc --file https://example.com/v
286
320
  - TTS 商品缓存:[`docs/tts-product-cache.md`](./docs/tts-product-cache.md)
287
321
  - 重试与幂等:[`docs/retry-and-idempotency.md`](./docs/retry-and-idempotency.md)
288
322
 
323
+ ### 运维与优化
324
+
325
+ 生产环境部署和优化指南:
326
+
327
+ - **性能与限流**:[`docs/performance-and-limits.md`](./docs/performance-and-limits.md) - API 限流策略、并发控制、性能优化
328
+ - **安全最佳实践**:[`docs/security-best-practices.md`](./docs/security-best-practices.md) - API Key 安全、OAuth 安全、生产环境检查清单
329
+ - **故障排查**:[`docs/troubleshooting.md`](./docs/troubleshooting.md) - 常见错误及解决方案、调试技巧
330
+ - **测试指南**:[`docs/testing-guide.md`](./docs/testing-guide.md) - 单元测试、集成测试、Mock 数据
331
+
289
332
  ### 示例工程
290
333
 
291
- 当用户需要“可运行参考实现”时,按技术栈读取:
334
+ 当用户需要”可运行参考实现”时,按技术栈读取:
292
335
 
293
336
  - 纯脚本示例:[`example/standard/README.md`](./example/standard/README.md)
294
337
  - Express 示例:[`example/express/README.md`](./example/express/README.md)
@@ -35,6 +35,7 @@ CREATE TABLE beervid_accounts (
35
35
 
36
36
  -- 账号详情(来自 POST /api/v1/open/account/info)
37
37
  username VARCHAR(256) DEFAULT NULL,
38
+ link_username VARCHAR(256) DEFAULT NULL COMMENT '推荐的 TT/TTS 关联键(建议存归一化后的 username)',
38
39
  display_name VARCHAR(256) DEFAULT NULL,
39
40
  seller_name VARCHAR(256) DEFAULT NULL COMMENT 'TTS 账号的卖家名称',
40
41
  profile_url TEXT DEFAULT NULL COMMENT '头像 URL',
@@ -57,7 +58,8 @@ CREATE TABLE beervid_accounts (
57
58
  UNIQUE KEY uk_account (account_type, account_id),
58
59
  KEY idx_app_user (app_user_id),
59
60
  KEY idx_business_id (business_id),
60
- KEY idx_creator_user_open_id (creator_user_open_id)
61
+ KEY idx_creator_user_open_id (creator_user_open_id),
62
+ KEY idx_link_username (link_username)
61
63
  );
62
64
  ```
63
65
 
@@ -68,9 +70,17 @@ CREATE TABLE beervid_accounts (
68
70
  | `account_id` | OAuth 回调参数 `ttAbId` 或 `ttsAbId` | 唯一标识,与 `account_type` 组成唯一键 |
69
71
  | `business_id` | 等同于 `ttAbId` | TT 账号的所有操作(发布、轮询、查数据)都以此为入参 |
70
72
  | `creator_user_open_id` | 等同于 `ttsAbId` | TTS 账号的所有操作(上传、发布、查商品)都以此为入参 |
73
+ | `link_username` | `account/info` 返回的 `username` 归一化后保存 | 当前推荐用作 TT/TTS 的关联键;官方暂无 `uno_id` |
71
74
  | `access_token` | `account/info` 返回 | 按需存储,用于特殊场景 |
72
75
  | `app_user_id` | 你方系统 | 一个用户可绑定多个 TT/TTS 账号 |
73
76
 
77
+ ### TT / TTS 关联建议
78
+
79
+ - 同一达人如果既授权了 TTS,又授权了 TT,建议保存为两条账号记录。
80
+ - 官方当前没有提供 `uno_id` 这类 TT/TTS 强关联字段。
81
+ - 推荐额外维护 `link_username`,例如保存 `LOWER(TRIM(username))` 后的值,作为当前最稳妥的软关联键。
82
+ - 真正调用接口时不要使用 `link_username` 替代业务 ID;TT 继续使用 `business_id`,TTS 继续使用 `creator_user_open_id`。
83
+
74
84
  ---
75
85
 
76
86
  ## 2. 视频表 `beervid_videos`
@@ -98,7 +108,7 @@ CREATE TABLE beervid_videos (
98
108
 
99
109
  -- TTS 挂车专用
100
110
  product_id VARCHAR(128) DEFAULT NULL COMMENT '关联商品 ID',
101
- product_title VARCHAR(64) DEFAULT NULL COMMENT '关联商品标题(≤29字符)',
111
+ product_title VARCHAR(64) DEFAULT NULL COMMENT '关联商品标题(≤30字符)',
102
112
 
103
113
  -- 发布状态
104
114
  publish_status VARCHAR(32) DEFAULT 'PENDING'
@@ -16,16 +16,24 @@
16
16
  2. **用户跳转授权**:将用户重定向到返回的 URL
17
17
  3. **接收回调**:用户授权后,TikTok 回调你的服务器
18
18
 
19
- > **说明**:获取到的授权链接中通常已经包含 `state` 参数。
20
- > 如果你需要附加自定义安全字段,做法不是新增一个 `state` 参数,而是解析现有 `state`,在其 JSON 中追加字段后再写回授权链接。
19
+ > **说明**:获取到的授权链接中**不一定**携带 `state` 参数。
20
+ > - 如果链接中已经包含 `state`,它的值一定是一个 JSON 字符串。你可以解析该 JSON,在其中追加自定义字段后再写回。
21
+ > - 如果链接中没有 `state`,而你需要透传参数(例如防 CSRF 的安全 token),应自行构造一个 JSON 对象作为 `state` 的值追加到授权链接上。
21
22
 
22
23
  ---
23
24
 
24
25
  ## 回调参数
25
26
 
26
- OAuth 授权完成后,回调 URL 会携带以下关键参数:
27
+ OAuth 授权完成后,回调 URL 会携带 `state` 查询参数,其值是一个 **JSON 字符串**。业务字段包含在这个 JSON 内部:
27
28
 
28
- | 账号类型 | 回调参数 | 含义 | 后续用途 |
29
+ ```
30
+ 回调 URL 示例:
31
+ https://your-app.com/callback?state={"ttAbId":"xxx","code":"yyy",...}
32
+ ```
33
+
34
+ 从 `state` JSON 中提取的关键字段:
35
+
36
+ | 账号类型 | 字段 | 含义 | 后续用途 |
29
37
  |----------|----------|------|----------|
30
38
  | TT | `ttAbId` | TT 账号的 businessId | 所有 TT 操作的入参 |
31
39
  | TTS | `ttsAbId` | TTS 账号的 creatorUserOpenId | 所有 TTS 操作的入参 |
@@ -33,11 +41,46 @@ OAuth 授权完成后,回调 URL 会携带以下关键参数:
33
41
  > **重要**:`ttAbId` 就是后续所有 TT API 的 `businessId`,`ttsAbId` 就是所有 TTS API 的 `creatorUserOpenId`。
34
42
  > 这两个值必须可靠持久化,丢失意味着需要用户重新授权。
35
43
 
44
+ ## TT / TTS 账号关联建议
45
+
46
+ 如果同一达人既要做 TTS 挂车发布,又要查询视频数据,建议按下面方式建模:
47
+
48
+ - TTS 授权和 TT 授权分别落成两条独立账号记录,不要混存成一条。
49
+ - 回调阶段先持久化 `ttAbId` / `ttsAbId`,再异步调用 `account/info` 补全账号详情。
50
+ - 官方当前没有提供 `uno_id` 这类可直接打通 TT/TTS 的稳定字段。
51
+ - 当前推荐使用 `account/info` 返回的 `username` 作为关联键,在你方系统里建立 TT 和 TTS 的软关联。
52
+ - 业务调用时不要把 `username` 当接口入参;挂车发布仍使用 `creatorUserOpenId`,视频查数仍使用 `businessId`。
53
+
54
+ ### 解析 state 中的回调字段
55
+
56
+ ```typescript
57
+ interface OAuthCallbackState {
58
+ ttAbId?: string
59
+ ttsAbId?: string
60
+ code?: string
61
+ [key: string]: unknown // 可能包含你之前追加的自定义字段
62
+ }
63
+
64
+ function parseCallbackState(stateParam: string): OAuthCallbackState {
65
+ try {
66
+ const parsed = JSON.parse(stateParam) as unknown
67
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
68
+ return parsed as OAuthCallbackState
69
+ }
70
+ throw new Error('state 不是合法 JSON 对象')
71
+ } catch {
72
+ throw new Error('state 解析失败')
73
+ }
74
+ }
75
+ ```
76
+
36
77
  ---
37
78
 
38
- ## 获取授权链接后如何向已有 State 追加自定义字段
79
+ ## 获取授权链接后如何设置或追加 State
39
80
 
40
- 如果获取到的授权链接里已经自带 `state`,并且它的值是一个 JSON 字符串,可以在拿到 OAuth URL 后解析这个 JSON,再把你方的安全 token 追加进去:
81
+ 授权链接中**可能包含也可能不包含** `state` 参数:
82
+ - 如果已包含 `state`,其值是一个 JSON 字符串,可以解析后追加字段再写回。
83
+ - 如果未包含 `state`,而你需要透传参数,应自行构造一个 JSON 对象设置为 `state`。
41
84
 
42
85
  ```typescript
43
86
  function tryParseJsonObject(value: string): Record<string, unknown> | null {
@@ -52,25 +95,25 @@ function tryParseJsonObject(value: string): Record<string, unknown> | null {
52
95
  }
53
96
  }
54
97
 
55
- function appendTokenToOAuthUrlState(
98
+ function setOrAppendStateToken(
56
99
  rawUrl: string,
57
100
  customStateToken: string
58
101
  ): string {
59
102
  const url = new URL(rawUrl)
60
103
  const rawState = url.searchParams.get('state')
61
104
 
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
- }
105
+ let nextState: Record<string, unknown>
70
106
 
71
- const nextState = {
72
- ...parsedState,
73
- customStateToken,
107
+ if (rawState) {
108
+ // 链接中已有 state,解析后追加字段
109
+ const parsedState = tryParseJsonObject(rawState)
110
+ if (!parsedState) {
111
+ throw new Error('授权链接中的 state 不是可追加字段的 JSON 对象')
112
+ }
113
+ nextState = { ...parsedState, customStateToken }
114
+ } else {
115
+ // 链接中没有 state,自行构造 JSON
116
+ nextState = { customStateToken }
74
117
  }
75
118
 
76
119
  url.searchParams.set('state', JSON.stringify(nextState))
@@ -80,7 +123,7 @@ function appendTokenToOAuthUrlState(
80
123
  async function getTtOAuthUrlWithState(userId: string): Promise<string> {
81
124
  const customStateToken = generateStateToken(userId)
82
125
  const rawUrl = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
83
- return appendTokenToOAuthUrlState(rawUrl, customStateToken)
126
+ return setOrAppendStateToken(rawUrl, customStateToken)
84
127
  }
85
128
 
86
129
  async function getTtsOAuthUrlWithState(userId: string): Promise<string> {
@@ -88,7 +131,7 @@ async function getTtsOAuthUrlWithState(userId: string): Promise<string> {
88
131
  const data = await openApiGet<{ crossBorderUrl: string }>(
89
132
  '/api/v1/open/thirdparty-auth/tts-url'
90
133
  )
91
- return appendTokenToOAuthUrlState(data.crossBorderUrl, customStateToken)
134
+ return setOrAppendStateToken(data.crossBorderUrl, customStateToken)
92
135
  }
93
136
  ```
94
137
 
@@ -108,7 +151,7 @@ function parseCustomStateToken(state: string): string {
108
151
  ```
109
152
 
110
153
  > **说明**:字段名 `customStateToken` 只是示例,你也可以改成 `token`、`nonce`、`appState` 等业务上更合适的名字。
111
- > 如果授权链接里的 `state` 不是 JSON 对象,就不要按这个示例直接 `JSON.parse`;应改为使用平台允许的编码方式,或采用你当前链路已有的透传机制。
154
+ > 无论链接中原先是否携带 `state`,你设置的 `state` 值都应该是一个 JSON 对象。
112
155
 
113
156
  ---
114
157
 
@@ -178,35 +221,44 @@ async function consumeStateNonce(nonce: string): Promise<boolean> {
178
221
  ```typescript
179
222
  async function handleOAuthCallback(req: Request): Promise<Response> {
180
223
  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')
224
+ const stateParam = url.searchParams.get('state')
184
225
 
185
- // ① 验证 State Token
186
- if (!state) {
226
+ // ① 解析 state JSON,提取回调字段
227
+ if (!stateParam) {
187
228
  return new Response('缺少 state 参数', { status: 400 })
188
229
  }
189
230
 
190
- let statePayload: { userId: string; nonce: string }
231
+ let stateObj: OAuthCallbackState
191
232
  try {
192
- statePayload = verifyStateToken(state)
193
- } catch (err) {
194
- return new Response('授权链接已过期,请重新发起授权', { status: 403 })
233
+ stateObj = parseCallbackState(stateParam)
234
+ } catch {
235
+ return new Response('state 解析失败', { status: 400 })
195
236
  }
196
237
 
197
- // 一次性消费检查
198
- const isFirstUse = await consumeStateNonce(statePayload.nonce)
199
- if (!isFirstUse) {
200
- return new Response('该授权回调已处理过,请勿重复提交', { status: 409 })
238
+ const { ttAbId, ttsAbId } = stateObj
239
+
240
+ // 验证你方追加的自定义安全字段(如果有)
241
+ if (stateObj.customStateToken) {
242
+ let statePayload: { userId: string; nonce: string }
243
+ try {
244
+ statePayload = verifyStateToken(stateObj.customStateToken)
245
+ } catch (err) {
246
+ return new Response('授权链接已过期,请重新发起授权', { status: 403 })
247
+ }
248
+
249
+ // ③ 一次性消费检查
250
+ const isFirstUse = await consumeStateNonce(statePayload.nonce)
251
+ if (!isFirstUse) {
252
+ return new Response('该授权回调已处理过,请勿重复提交', { status: 409 })
253
+ }
201
254
  }
202
255
 
203
- // 判断账号类型并持久化
256
+ // 判断账号类型并持久化
204
257
  if (ttAbId) {
205
258
  await saveAccount({
206
259
  accountType: 'TT',
207
260
  accountId: ttAbId,
208
261
  businessId: ttAbId,
209
- appUserId: statePayload.userId,
210
262
  status: 'ACTIVE',
211
263
  })
212
264
  }
@@ -216,21 +268,19 @@ async function handleOAuthCallback(req: Request): Promise<Response> {
216
268
  accountType: 'TTS',
217
269
  accountId: ttsAbId,
218
270
  creatorUserOpenId: ttsAbId,
219
- appUserId: statePayload.userId,
220
271
  status: 'ACTIVE',
221
272
  })
222
273
  }
223
274
 
224
- // 异步拉取账号详情(不阻塞回调响应)
275
+ // 异步拉取账号详情(不阻塞回调响应)
225
276
  const accountId = ttAbId || ttsAbId
226
277
  const accountType = ttAbId ? 'TT' : 'TTS'
227
278
 
228
- // 使用 fire-and-forget 或投递到消息队列
229
279
  syncAccountInfoAsync(accountType, accountId!).catch((err) => {
230
280
  console.error('异步同步账号信息失败(不影响授权结果):', err.message)
231
281
  })
232
282
 
233
- // 返回成功页面或重定向
283
+ // 返回成功页面或重定向
234
284
  return Response.redirect('/dashboard?oauth=success', 302)
235
285
  }
236
286
  ```
@@ -245,6 +295,7 @@ async function handleOAuthCallback(req: Request): Promise<Response> {
245
295
  - 回调请求应快速响应,避免用户长时间等待
246
296
  - 头像同步等操作允许延迟几秒完成
247
297
  - 即使同步失败,授权本身已成功
298
+ - 如果要关联同一达人的 TT / TTS 账号,建议在这里同步并持久化 `username`
248
299
 
249
300
  ```typescript
250
301
  async function syncAccountInfoAsync(
@@ -258,6 +309,7 @@ async function syncAccountInfoAsync(
258
309
 
259
310
  await updateAccount(accountId, {
260
311
  username: data.username,
312
+ linkUsername: data.username?.trim().toLowerCase(),
261
313
  displayName: data.displayName,
262
314
  sellerName: data.sellerName,
263
315
  profileUrl: data.profileUrl,
@@ -0,0 +1,153 @@
1
+ # 性能优化与限流说明
2
+
3
+ 本文档只保留与 BEERVID Open API 接入直接相关的性能建议,重点关注限流、分页、轮询和批量调用。
4
+
5
+ ## 目录
6
+
7
+ 1. [限流处理](#限流处理)
8
+ 2. [分页与批量查询](#分页与批量查询)
9
+ 3. [TT 发布轮询](#tt-发布轮询)
10
+ 4. [上传与文件处理](#上传与文件处理)
11
+ 5. [实践建议](#实践建议)
12
+
13
+ ---
14
+
15
+ ## 限流处理
16
+
17
+ ### 1. 遇到 `429` 时做退避重试
18
+
19
+ 当前仓库的 `openApiPost` / `openApiGet` 在失败时会抛出 `Error.message`,所以建议按错误消息判断是否命中了限流:
20
+
21
+ ```typescript
22
+ function isRateLimitError(error: unknown): boolean {
23
+ const message = error instanceof Error ? error.message : String(error)
24
+ return message.includes('(code: 429)') || message.includes('Too Many Requests')
25
+ }
26
+
27
+ async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
28
+ for (let i = 0; i < maxRetries; i++) {
29
+ try {
30
+ return await fn()
31
+ } catch (error) {
32
+ if (isRateLimitError(error) && i < maxRetries - 1) {
33
+ const delayMs = Math.pow(2, i) * 1000
34
+ await sleep(delayMs)
35
+ continue
36
+ }
37
+ throw error
38
+ }
39
+ }
40
+
41
+ throw new Error('Max retries exceeded')
42
+ }
43
+ ```
44
+
45
+ ### 2. 非幂等发布不要无脑重试
46
+
47
+ - `query-products`、`query-video`、`poll-status` 这类读操作适合退避重试
48
+ - 上传和发布动作要区分对待
49
+ - 对于 `publish` / `publish-tt-flow` / `publish-tts-flow`,优先保证幂等控制,再决定是否补偿重试
50
+
51
+ ---
52
+
53
+ ## 分页与批量查询
54
+
55
+ ### 1. `query-products` 的 `pageSize` 最大为 `20`
56
+
57
+ OpenAPI 已明确商品查询每页最多 `20` 条,当前 CLI 也已按这个范围校验:
58
+
59
+ ```bash
60
+ beervid query-products --creator-id open_user_abc --page-size 20
61
+ ```
62
+
63
+ 如果需要拉更多商品,使用分页游标继续查:
64
+
65
+ ```bash
66
+ beervid query-products --creator-id open_user_abc --cursor <next-cursor>
67
+ ```
68
+
69
+ ### 2. `query-video` 支持分页
70
+
71
+ 视频查询现在支持:
72
+
73
+ - 不传 `itemIds`,按账号查询视频列表
74
+ - 传 `--cursor`
75
+ - 传 `--max-count`(10-20)
76
+
77
+ ```bash
78
+ beervid query-video --business-id biz_123 --cursor 0 --max-count 20
79
+ beervid query-video --business-id biz_123 --item-ids 7123,7124
80
+ ```
81
+
82
+ ### 3. 批量查询时按 20 条一批拆分
83
+
84
+ ```typescript
85
+ function chunk<T>(array: T[], size: number): T[][] {
86
+ return Array.from(
87
+ { length: Math.ceil(array.length / size) },
88
+ (_, i) => array.slice(i * size, (i + 1) * size)
89
+ )
90
+ }
91
+
92
+ for (const batch of chunk(videoIds, 20)) {
93
+ await openApiPost('/api/v1/open/tiktok/video/query', {
94
+ businessId,
95
+ itemIds: batch,
96
+ })
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ## TT 发布轮询
103
+
104
+ ### 1. `poll-status` 只在 TT 普通发布链路里使用
105
+
106
+ 推荐链路:
107
+
108
+ 1. `publish`
109
+ 2. 拿到 `shareId`
110
+ 3. `poll-status`
111
+ 4. 当 `status === PUBLISH_COMPLETE` 且 `post_ids` 非空时,再去 `query-video`
112
+
113
+ ### 2. 轮询间隔不要压太短
114
+
115
+ 当前 CLI 默认:
116
+
117
+ - `--interval 5`
118
+ - `--max-polls 60`
119
+
120
+ 这套默认值已经覆盖大多数场景;如果你在服务端自己实现轮询任务,也建议保持秒级而不是毫秒级高频轮询。
121
+
122
+ ---
123
+
124
+ ## 上传与文件处理
125
+
126
+ ### 1. 优先上传本地文件,远程 URL 会多一次下载
127
+
128
+ 当前 CLI 的 `--file` 同时支持本地路径和 URL,但传 URL 时会先下载再上传:
129
+
130
+ ```bash
131
+ beervid upload --file ./video.mp4
132
+ beervid upload --file https://example.com/video.mp4
133
+ ```
134
+
135
+ 如果你已经在服务端拿到了文件内容,优先走本地文件/内存文件上传,减少链路耗时。
136
+
137
+ ### 2. 大文件场景加超时与进度监控
138
+
139
+ 如果你不是直接用 CLI,而是自己封装上传,至少补这两项:
140
+
141
+ - 请求超时
142
+ - 上传进度日志
143
+
144
+ 当前项目里如果需要上传进度,仍然推荐优先用 XHR 而不是裸 `fetch`。
145
+
146
+ ---
147
+
148
+ ## 实践建议
149
+
150
+ - 把所有 Open API 调用都收口到统一 client 层,方便集中做超时、重试和日志
151
+ - 读接口优先重试,写接口优先幂等
152
+ - 商品查询和视频查询一律按分页参数设计,不要默认一次拉完全部数据
153
+ - TT 轮询结果只有在 `post_ids` 真正返回后,才进入后续查数链路