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.
Files changed (35) hide show
  1. package/README.md +45 -0
  2. package/SKILL.md +204 -376
  3. package/dist/cli.mjs +117 -151
  4. package/docs/database-schema.md +231 -0
  5. package/docs/oauth-callback.md +282 -0
  6. package/docs/retry-and-idempotency.md +295 -0
  7. package/docs/tt-poll-task.md +239 -0
  8. package/docs/tts-product-cache.md +256 -0
  9. package/example/express/README.md +58 -0
  10. package/example/express/package.json +20 -0
  11. package/example/express/server.ts +431 -0
  12. package/example/express/tsconfig.json +12 -0
  13. package/example/nextjs/.env.example +3 -0
  14. package/example/nextjs/README.md +54 -0
  15. package/example/nextjs/app/api/oauth/callback/route.ts +34 -0
  16. package/example/nextjs/app/api/oauth/url/route.ts +30 -0
  17. package/example/nextjs/app/api/products/route.ts +43 -0
  18. package/example/nextjs/app/api/publish/tt/route.ts +116 -0
  19. package/example/nextjs/app/api/publish/tts/route.ts +58 -0
  20. package/example/nextjs/app/api/status/[shareId]/route.ts +41 -0
  21. package/example/nextjs/app/layout.tsx +9 -0
  22. package/example/nextjs/app/page.tsx +80 -0
  23. package/example/nextjs/lib/beervid-client.ts +107 -0
  24. package/example/nextjs/next.config.ts +4 -0
  25. package/example/nextjs/package.json +19 -0
  26. package/example/nextjs/tsconfig.json +23 -0
  27. package/example/standard/README.md +51 -0
  28. package/example/standard/api-client.ts +181 -0
  29. package/example/standard/get-oauth-url.ts +44 -0
  30. package/example/standard/package.json +18 -0
  31. package/example/standard/query-products.ts +141 -0
  32. package/example/standard/tsconfig.json +12 -0
  33. package/example/standard/tt-publish-flow.ts +194 -0
  34. package/example/standard/tts-publish-flow.ts +246 -0
  35. package/package.json +3 -1
@@ -0,0 +1,231 @@
1
+ # 数据表字段建议
2
+
3
+ > 本文档为接入 BEERVID 第三方应用 Open API 的后端系统提供数据库表结构设计建议。
4
+ > 以 SQL DDL 呈现,兼顾 MySQL 和 PostgreSQL 语法。
5
+
6
+ ## 总览
7
+
8
+ 接入 BEERVID Open API 通常需要持久化以下实体:
9
+
10
+ | 表名 | 作用 | 关联 API |
11
+ |------|------|----------|
12
+ | `beervid_accounts` | 存储 TT/TTS 授权账号信息 | OAuth 回调、`account/info` |
13
+ | `beervid_videos` | 视频发布记录与状态追踪 | `publish`、`poll-status`、`query-video` |
14
+ | `beervid_products` | TTS 商品缓存 | `products/query` |
15
+
16
+ ---
17
+
18
+ ## 1. 账号表 `beervid_accounts`
19
+
20
+ 存储通过 OAuth 授权绑定的 TT/TTS 账号。
21
+
22
+ ```sql
23
+ CREATE TABLE beervid_accounts (
24
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
25
+
26
+ -- 账号标识
27
+ account_type VARCHAR(8) NOT NULL COMMENT 'TT 或 TTS',
28
+ account_id VARCHAR(128) NOT NULL COMMENT 'OAuth 回调返回的 ttAbId 或 ttsAbId',
29
+
30
+ -- TT 账号专用:即 businessId,所有 TT 操作的入参
31
+ business_id VARCHAR(128) DEFAULT NULL COMMENT 'TT 业务 ID(= ttAbId)',
32
+
33
+ -- TTS 账号专用:即 creatorUserOpenId,所有 TTS 操作的入参
34
+ creator_user_open_id VARCHAR(128) DEFAULT NULL COMMENT 'TTS 用户 OpenId(= ttsAbId)',
35
+
36
+ -- 账号详情(来自 POST /api/v1/open/account/info)
37
+ username VARCHAR(256) DEFAULT NULL,
38
+ display_name VARCHAR(256) DEFAULT NULL,
39
+ seller_name VARCHAR(256) DEFAULT NULL COMMENT 'TTS 账号的卖家名称',
40
+ profile_url TEXT DEFAULT NULL COMMENT '头像 URL',
41
+ followers_count INT DEFAULT 0,
42
+ access_token VARCHAR(512) DEFAULT NULL COMMENT '访问令牌',
43
+
44
+ -- 业务归属
45
+ app_user_id BIGINT DEFAULT NULL COMMENT '你方系统的用户 ID(多对一关系)',
46
+
47
+ -- 状态
48
+ status VARCHAR(32) DEFAULT 'ACTIVE' COMMENT 'ACTIVE / EXPIRED / REVOKED',
49
+
50
+ -- 时间
51
+ authorized_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'OAuth 授权时间',
52
+ deleted_at TIMESTAMP DEFAULT NULL COMMENT '软删除时间',
53
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
54
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
55
+
56
+ -- 索引
57
+ UNIQUE KEY uk_account (account_type, account_id),
58
+ KEY idx_app_user (app_user_id),
59
+ KEY idx_business_id (business_id),
60
+ KEY idx_creator_user_open_id (creator_user_open_id)
61
+ );
62
+ ```
63
+
64
+ ### 关键说明
65
+
66
+ | 字段 | 来源 | 备注 |
67
+ |------|------|------|
68
+ | `account_id` | OAuth 回调参数 `ttAbId` 或 `ttsAbId` | 唯一标识,与 `account_type` 组成唯一键 |
69
+ | `business_id` | 等同于 `ttAbId` | TT 账号的所有操作(发布、轮询、查数据)都以此为入参 |
70
+ | `creator_user_open_id` | 等同于 `ttsAbId` | TTS 账号的所有操作(上传、发布、查商品)都以此为入参 |
71
+ | `access_token` | `account/info` 返回 | 按需存储,用于特殊场景 |
72
+ | `app_user_id` | 你方系统 | 一个用户可绑定多个 TT/TTS 账号 |
73
+
74
+ ---
75
+
76
+ ## 2. 视频表 `beervid_videos`
77
+
78
+ 记录每次视频发布的全生命周期。
79
+
80
+ ```sql
81
+ CREATE TABLE beervid_videos (
82
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
83
+
84
+ -- 关联账号
85
+ account_id BIGINT NOT NULL COMMENT '关联 beervid_accounts.id',
86
+ publish_type VARCHAR(16) NOT NULL COMMENT 'NORMAL 或 SHOPPABLE',
87
+
88
+ -- 发布前:上传信息
89
+ file_url TEXT DEFAULT NULL COMMENT '普通上传返回的 fileUrl',
90
+ video_file_id VARCHAR(128) DEFAULT NULL COMMENT 'TTS 上传返回的 videoFileId',
91
+ file_name VARCHAR(256) DEFAULT NULL,
92
+ file_size BIGINT DEFAULT NULL COMMENT '文件大小(字节)',
93
+ caption TEXT DEFAULT NULL COMMENT '视频描述/文案',
94
+
95
+ -- 发布后:追踪 ID
96
+ share_id VARCHAR(128) DEFAULT NULL COMMENT '普通发布返回,用于轮询',
97
+ video_id VARCHAR(128) DEFAULT NULL COMMENT 'TikTok 视频 ID',
98
+
99
+ -- TTS 挂车专用
100
+ product_id VARCHAR(128) DEFAULT NULL COMMENT '关联商品 ID',
101
+ product_title VARCHAR(64) DEFAULT NULL COMMENT '关联商品标题(≤29字符)',
102
+
103
+ -- 发布状态
104
+ publish_status VARCHAR(32) DEFAULT 'PENDING'
105
+ COMMENT 'PENDING / PROCESSING_DOWNLOAD / PUBLISH_COMPLETE / FAILED / TIMEOUT',
106
+ fail_reason TEXT DEFAULT NULL COMMENT '失败原因',
107
+ poll_count INT DEFAULT 0 COMMENT '已轮询次数',
108
+ last_polled_at TIMESTAMP DEFAULT NULL COMMENT '最后一次轮询时间',
109
+
110
+ -- 视频数据统计(来自 query-video)
111
+ video_views INT DEFAULT NULL,
112
+ likes INT DEFAULT NULL,
113
+ comments INT DEFAULT NULL,
114
+ shares INT DEFAULT NULL,
115
+ share_url TEXT DEFAULT NULL,
116
+ thumbnail_url TEXT DEFAULT NULL,
117
+ data_synced_at TIMESTAMP DEFAULT NULL COMMENT '最后一次数据同步时间',
118
+
119
+ -- 幂等控制
120
+ idempotency_key VARCHAR(128) DEFAULT NULL COMMENT '发布请求的稳定幂等键,防止重复发布',
121
+
122
+ -- 时间
123
+ deleted_at TIMESTAMP DEFAULT NULL COMMENT '软删除时间',
124
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
125
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
126
+
127
+ -- 索引
128
+ KEY idx_account (account_id),
129
+ KEY idx_share_id (share_id),
130
+ KEY idx_video_id (video_id),
131
+ KEY idx_publish_status (publish_status),
132
+ UNIQUE KEY uk_idempotency (idempotency_key),
133
+ KEY idx_status_poll (publish_status, last_polled_at)
134
+ COMMENT '轮询定时任务:查找需要继续轮询的记录'
135
+ );
136
+ ```
137
+
138
+ ### 关键说明
139
+
140
+ | 字段 | 用途 |
141
+ |------|------|
142
+ | `share_id` | 普通视频发布返回,用于后续 `poll-status` 轮询 |
143
+ | `video_id` | 挂车发布直接返回;普通发布从轮询结果 `post_ids[0]` 获取 |
144
+ | `publish_status` | 核心状态字段,定时任务依据此字段扫描待轮询记录 |
145
+ | `idempotency_key` | 建议使用你方业务侧稳定唯一值,如 `publish_request_id`、草稿 ID 或客户端 requestId;不要拼接时间戳 |
146
+ | `idx_status_poll` | 复合索引,加速"查找所有 PROCESSING_DOWNLOAD 且距上次轮询超过 N 秒"的查询 |
147
+
148
+ ---
149
+
150
+ ## 3. 商品缓存表 `beervid_products`
151
+
152
+ 缓存 TTS 商品数据,减少重复查询。
153
+
154
+ ```sql
155
+ CREATE TABLE beervid_products (
156
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
157
+
158
+ -- 商品标识
159
+ product_id VARCHAR(128) NOT NULL COMMENT 'BEERVID 商品 ID',
160
+ creator_user_open_id VARCHAR(128) NOT NULL COMMENT '所属 TTS 账号',
161
+
162
+ -- 商品信息
163
+ title VARCHAR(256) NOT NULL,
164
+ price_amount VARCHAR(32) DEFAULT NULL,
165
+ price_currency VARCHAR(8) DEFAULT NULL,
166
+ images JSON DEFAULT NULL COMMENT '商品图片 URL 数组(已解析)',
167
+ sales_count INT DEFAULT 0,
168
+ brand_name VARCHAR(256) DEFAULT NULL,
169
+ shop_name VARCHAR(256) DEFAULT NULL,
170
+ source VARCHAR(16) DEFAULT NULL COMMENT 'shop 或 showcase',
171
+
172
+ -- 状态
173
+ review_status VARCHAR(32) DEFAULT NULL COMMENT 'APPROVED / PENDING / REJECTED',
174
+ inventory_status VARCHAR(32) DEFAULT NULL COMMENT 'IN_STOCK / OUT_OF_STOCK',
175
+
176
+ -- 缓存管理
177
+ cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '首次缓存时间',
178
+ refreshed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '最后刷新时间',
179
+ deleted_at TIMESTAMP DEFAULT NULL COMMENT '软删除时间',
180
+
181
+ -- 索引
182
+ UNIQUE KEY uk_product_creator (product_id, creator_user_open_id),
183
+ KEY idx_creator (creator_user_open_id),
184
+ KEY idx_review_inventory (review_status, inventory_status)
185
+ COMMENT '过滤可发布商品:APPROVED + IN_STOCK',
186
+ KEY idx_sales (creator_user_open_id, sales_count DESC)
187
+ COMMENT '按销量排序选择商品'
188
+ );
189
+ ```
190
+
191
+ ### 关键说明
192
+
193
+ | 字段 | 备注 |
194
+ |------|------|
195
+ | `images` | 存储已解析的图片 URL 数组(非 BEERVID 原始格式),解析方法见 SKILL.md |
196
+ | `review_status` + `inventory_status` | 筛选可发布商品:仅 `APPROVED` + `IN_STOCK` 可用于挂车发布 |
197
+ | `refreshed_at` | 缓存淘汰依据,建议超过 24 小时重新拉取 |
198
+ | `deleted_at` | 若采用 `docs/tts-product-cache.md` 中的全量替换方案,需要用它标记旧缓存失效 |
199
+
200
+ ---
201
+
202
+ ## ER 关系
203
+
204
+ ```
205
+ ┌─────────────────────┐ ┌──────────────────────┐
206
+ │ beervid_accounts │ 1 N │ beervid_videos │
207
+ │ │───────│ │
208
+ │ id (PK) │ │ account_id (FK) │
209
+ │ account_type │ │ publish_type │
210
+ │ business_id │ │ share_id │
211
+ │ creator_user_open_id│ │ video_id │
212
+ │ app_user_id │ │ publish_status │
213
+ └─────────────────────┘ └──────────────────────┘
214
+ │ 1
215
+ │ N
216
+ ┌─────────────────────┐
217
+ │ beervid_products │
218
+ │ │
219
+ │ creator_user_open_id│
220
+ │ product_id │
221
+ │ title │
222
+ │ sales_count │
223
+ └─────────────────────┘
224
+ ```
225
+
226
+ ## 补充建议
227
+
228
+ 1. **软删除**:本文示例已将 `deleted_at` 纳入推荐表结构;如果你不采用软删除,也要同步调整 `docs/tts-product-cache.md` 中依赖该字段的 SQL
229
+ 2. **审计日志**:高敏感操作(发布、授权)建议独立记录操作日志表
230
+ 3. **分库分表**:如视频表数据量大,可按 `account_id` 分片
231
+ 4. **PostgreSQL 用户**:将 `AUTO_INCREMENT` 替换为 `GENERATED ALWAYS AS IDENTITY`,`JSON` 替换为 `JSONB`
@@ -0,0 +1,282 @@
1
+ # OAuth 回调存储建议
2
+
3
+ > 本文档描述接入 BEERVID OAuth 授权回调后,如何安全地处理回调参数、防止 CSRF 攻击、以及持久化账号信息。
4
+
5
+ ## 授权流程总览
6
+
7
+ ```
8
+ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
9
+ │ 你的应用 │────→│ BEERVID │────→│ TikTok │────→│ 回调 URL │
10
+ │ │ ① │ OAuth URL│ ② │ 授权页面 │ ③ │ 你的服务器│
11
+ └─────────┘ └──────────┘ └──────────┘ └──────────┘
12
+ 获取 URL 重定向 用户授权 收到回调参数
13
+ ```
14
+
15
+ 1. **获取 OAuth URL**:调用 `GET /api/v1/open/thirdparty-auth/tt-url` 或 `tts-url`
16
+ 2. **用户跳转授权**:将用户重定向到返回的 URL
17
+ 3. **接收回调**:用户授权后,TikTok 回调你的服务器
18
+
19
+ > **说明**:获取到的授权链接中通常已经包含 `state` 参数。
20
+ > 如果你需要附加自定义安全字段,做法不是新增一个 `state` 参数,而是解析现有 `state`,在其 JSON 中追加字段后再写回授权链接。
21
+
22
+ ---
23
+
24
+ ## 回调参数
25
+
26
+ OAuth 授权完成后,回调 URL 会携带以下关键参数:
27
+
28
+ | 账号类型 | 回调参数 | 含义 | 后续用途 |
29
+ |----------|----------|------|----------|
30
+ | TT | `ttAbId` | TT 账号的 businessId | 所有 TT 操作的入参 |
31
+ | TTS | `ttsAbId` | TTS 账号的 creatorUserOpenId | 所有 TTS 操作的入参 |
32
+
33
+ > **重要**:`ttAbId` 就是后续所有 TT API 的 `businessId`,`ttsAbId` 就是所有 TTS API 的 `creatorUserOpenId`。
34
+ > 这两个值必须可靠持久化,丢失意味着需要用户重新授权。
35
+
36
+ ---
37
+
38
+ ## 获取授权链接后如何向已有 State 追加自定义字段
39
+
40
+ 如果获取到的授权链接里已经自带 `state`,并且它的值是一个 JSON 字符串,可以在拿到 OAuth URL 后解析这个 JSON,再把你方的安全 token 追加进去:
41
+
42
+ ```typescript
43
+ function tryParseJsonObject(value: string): Record<string, unknown> | null {
44
+ try {
45
+ const parsed = JSON.parse(value) as unknown
46
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
47
+ return parsed as Record<string, unknown>
48
+ }
49
+ return null
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ function appendTokenToOAuthUrlState(
56
+ rawUrl: string,
57
+ customStateToken: string
58
+ ): string {
59
+ const url = new URL(rawUrl)
60
+ const rawState = url.searchParams.get('state')
61
+
62
+ if (!rawState) {
63
+ throw new Error('授权链接中缺少 state 参数')
64
+ }
65
+
66
+ const parsedState = tryParseJsonObject(rawState)
67
+ if (!parsedState) {
68
+ throw new Error('授权链接中的 state 不是可追加字段的 JSON 对象')
69
+ }
70
+
71
+ const nextState = {
72
+ ...parsedState,
73
+ customStateToken,
74
+ }
75
+
76
+ url.searchParams.set('state', JSON.stringify(nextState))
77
+ return url.toString()
78
+ }
79
+
80
+ async function getTtOAuthUrlWithState(userId: string): Promise<string> {
81
+ const customStateToken = generateStateToken(userId)
82
+ const rawUrl = await openApiGet<string>('/api/v1/open/thirdparty-auth/tt-url')
83
+ return appendTokenToOAuthUrlState(rawUrl, customStateToken)
84
+ }
85
+
86
+ async function getTtsOAuthUrlWithState(userId: string): Promise<string> {
87
+ const customStateToken = generateStateToken(userId)
88
+ const data = await openApiGet<{ crossBorderUrl: string }>(
89
+ '/api/v1/open/thirdparty-auth/tts-url'
90
+ )
91
+ return appendTokenToOAuthUrlState(data.crossBorderUrl, customStateToken)
92
+ }
93
+ ```
94
+
95
+ 回调时再从 `state` JSON 中取回你追加的字段并校验:
96
+
97
+ ```typescript
98
+ function parseCustomStateToken(state: string): string {
99
+ const parsed = tryParseJsonObject(state) as { customStateToken?: string } | null
100
+ if (!parsed) {
101
+ throw new Error('state 不是合法 JSON 对象')
102
+ }
103
+ if (!parsed.customStateToken) {
104
+ throw new Error('state 中缺少 customStateToken')
105
+ }
106
+ return parsed.customStateToken
107
+ }
108
+ ```
109
+
110
+ > **说明**:字段名 `customStateToken` 只是示例,你也可以改成 `token`、`nonce`、`appState` 等业务上更合适的名字。
111
+ > 如果授权链接里的 `state` 不是 JSON 对象,就不要按这个示例直接 `JSON.parse`;应改为使用平台允许的编码方式,或采用你当前链路已有的透传机制。
112
+
113
+ ---
114
+
115
+ ## State Token 防 CSRF
116
+
117
+ ### 为什么需要
118
+
119
+ OAuth 回调容易受到 CSRF 攻击——攻击者可以伪造回调请求,将恶意账号绑定到受害者系统。
120
+
121
+ ### 推荐方案:JWT 格式 State Token
122
+
123
+ ```typescript
124
+ import jwt from 'jsonwebtoken'
125
+
126
+ const STATE_SECRET = process.env.OAUTH_STATE_SECRET!
127
+
128
+ // ① 生成 State Token(在获取 OAuth URL 时)
129
+ function generateStateToken(userId: string): string {
130
+ return jwt.sign(
131
+ {
132
+ userId, // 当前登录用户 ID
133
+ purpose: 'beervid-oauth',
134
+ nonce: crypto.randomUUID(), // 一次性随机值
135
+ },
136
+ STATE_SECRET,
137
+ { expiresIn: '10m' } // 短过期时间:10 分钟
138
+ )
139
+ }
140
+
141
+ // ② 验证 State Token(在回调中)
142
+ function verifyStateToken(state: string): { userId: string; nonce: string } {
143
+ try {
144
+ const payload = jwt.verify(state, STATE_SECRET) as {
145
+ userId: string
146
+ purpose: string
147
+ nonce: string
148
+ }
149
+ if (payload.purpose !== 'beervid-oauth') {
150
+ throw new Error('Invalid state purpose')
151
+ }
152
+ return { userId: payload.userId, nonce: payload.nonce }
153
+ } catch {
154
+ throw new Error('State Token 验证失败:可能已过期或被篡改')
155
+ }
156
+ }
157
+ ```
158
+
159
+ ### 一次性消费
160
+
161
+ 为防止重放攻击,State Token 应确保只使用一次:
162
+
163
+ ```typescript
164
+ // 使用 Redis 记录已消费的 nonce
165
+ const NONCE_TTL = 600 // 与 State Token 过期时间一致
166
+
167
+ async function consumeStateNonce(nonce: string): Promise<boolean> {
168
+ // SET NX:仅当 key 不存在时设置成功
169
+ const result = await redis.set(`oauth:nonce:${nonce}`, '1', 'EX', NONCE_TTL, 'NX')
170
+ return result === 'OK' // true = 首次使用,false = 已被消费
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 回调处理完整流程
177
+
178
+ ```typescript
179
+ async function handleOAuthCallback(req: Request): Promise<Response> {
180
+ const url = new URL(req.url)
181
+ const state = url.searchParams.get('state')
182
+ const ttAbId = url.searchParams.get('ttAbId')
183
+ const ttsAbId = url.searchParams.get('ttsAbId')
184
+
185
+ // ① 验证 State Token
186
+ if (!state) {
187
+ return new Response('缺少 state 参数', { status: 400 })
188
+ }
189
+
190
+ let statePayload: { userId: string; nonce: string }
191
+ try {
192
+ statePayload = verifyStateToken(state)
193
+ } catch (err) {
194
+ return new Response('授权链接已过期,请重新发起授权', { status: 403 })
195
+ }
196
+
197
+ // ② 一次性消费检查
198
+ const isFirstUse = await consumeStateNonce(statePayload.nonce)
199
+ if (!isFirstUse) {
200
+ return new Response('该授权回调已处理过,请勿重复提交', { status: 409 })
201
+ }
202
+
203
+ // ③ 判断账号类型并持久化
204
+ if (ttAbId) {
205
+ await saveAccount({
206
+ accountType: 'TT',
207
+ accountId: ttAbId,
208
+ businessId: ttAbId,
209
+ appUserId: statePayload.userId,
210
+ status: 'ACTIVE',
211
+ })
212
+ }
213
+
214
+ if (ttsAbId) {
215
+ await saveAccount({
216
+ accountType: 'TTS',
217
+ accountId: ttsAbId,
218
+ creatorUserOpenId: ttsAbId,
219
+ appUserId: statePayload.userId,
220
+ status: 'ACTIVE',
221
+ })
222
+ }
223
+
224
+ // ④ 异步拉取账号详情(不阻塞回调响应)
225
+ const accountId = ttAbId || ttsAbId
226
+ const accountType = ttAbId ? 'TT' : 'TTS'
227
+
228
+ // 使用 fire-and-forget 或投递到消息队列
229
+ syncAccountInfoAsync(accountType, accountId!).catch((err) => {
230
+ console.error('异步同步账号信息失败(不影响授权结果):', err.message)
231
+ })
232
+
233
+ // ⑤ 返回成功页面或重定向
234
+ return Response.redirect('/dashboard?oauth=success', 302)
235
+ }
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 异步账号信息同步
241
+
242
+ 授权回调只返回 `accountId`,详细信息(头像、粉丝数、用户名等)需要额外调用 `account/info` 接口获取。
243
+
244
+ **为什么异步?**
245
+ - 回调请求应快速响应,避免用户长时间等待
246
+ - 头像同步等操作允许延迟几秒完成
247
+ - 即使同步失败,授权本身已成功
248
+
249
+ ```typescript
250
+ async function syncAccountInfoAsync(
251
+ accountType: 'TT' | 'TTS',
252
+ accountId: string
253
+ ): Promise<void> {
254
+ const data = await openApiPost('/api/v1/open/account/info', {
255
+ accountType,
256
+ accountId,
257
+ })
258
+
259
+ await updateAccount(accountId, {
260
+ username: data.username,
261
+ displayName: data.displayName,
262
+ sellerName: data.sellerName,
263
+ profileUrl: data.profileUrl,
264
+ followersCount: data.followersCount,
265
+ accessToken: data.accessToken,
266
+ })
267
+ }
268
+ ```
269
+
270
+ ---
271
+
272
+ ## 安全要点速查
273
+
274
+ | 要点 | 做法 |
275
+ |------|------|
276
+ | 防 CSRF | 使用 JWT State Token,回调时验证签名和过期时间 |
277
+ | 防重放 | 一次性 nonce,Redis 记录已消费 |
278
+ | 短过期 | State Token 有效期 10 分钟 |
279
+ | 用户绑定 | State Token 中嵌入 userId,确保回调绑定到正确用户 |
280
+ | 数据完整性 | 回调中立即持久化 accountId,异步补充详情 |
281
+ | 失败容忍 | 异步同步失败不影响授权成功状态 |
282
+ | HTTPS | 回调 URL 必须使用 HTTPS |