siluzan-tso-cli 1.0.0-beta.10

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 (65) hide show
  1. package/README.md +66 -0
  2. package/assets/siluzan-ads/SKILL.md +123 -0
  3. package/assets/siluzan-ads/references/accounts.md +644 -0
  4. package/assets/siluzan-ads/references/aigc.md +176 -0
  5. package/assets/siluzan-ads/references/finance.md +303 -0
  6. package/assets/siluzan-ads/references/google-ads-test.md +162 -0
  7. package/assets/siluzan-ads/references/google-ads.md +918 -0
  8. package/assets/siluzan-ads/references/open-account-by-media.md +61 -0
  9. package/assets/siluzan-ads/references/open-account-google-ui.md +121 -0
  10. package/assets/siluzan-ads/references/reporting.md +511 -0
  11. package/assets/siluzan-ads/references/setup.md +79 -0
  12. package/assets/siluzan-ads/references/tips.md +236 -0
  13. package/assets/siluzan-ads/references/tso-home.md +96 -0
  14. package/assets/siluzan-ads/references/workflows.md +969 -0
  15. package/dist/commands/account-history.d.ts +13 -0
  16. package/dist/commands/account-history.js +87 -0
  17. package/dist/commands/account-manage.d.ts +253 -0
  18. package/dist/commands/account-manage.js +817 -0
  19. package/dist/commands/ad.d.ts +378 -0
  20. package/dist/commands/ad.js +1169 -0
  21. package/dist/commands/ai-creation.d.ts +54 -0
  22. package/dist/commands/ai-creation.js +208 -0
  23. package/dist/commands/aigc.d.ts +27 -0
  24. package/dist/commands/aigc.js +222 -0
  25. package/dist/commands/balance.d.ts +9 -0
  26. package/dist/commands/balance.js +68 -0
  27. package/dist/commands/clue.d.ts +16 -0
  28. package/dist/commands/clue.js +103 -0
  29. package/dist/commands/config.d.ts +18 -0
  30. package/dist/commands/config.js +135 -0
  31. package/dist/commands/forewarning.d.ts +78 -0
  32. package/dist/commands/forewarning.js +275 -0
  33. package/dist/commands/init.d.ts +10 -0
  34. package/dist/commands/init.js +141 -0
  35. package/dist/commands/invoice-info.d.ts +43 -0
  36. package/dist/commands/invoice-info.js +159 -0
  37. package/dist/commands/invoice.d.ts +55 -0
  38. package/dist/commands/invoice.js +212 -0
  39. package/dist/commands/keyword.d.ts +14 -0
  40. package/dist/commands/keyword.js +125 -0
  41. package/dist/commands/list-accounts.d.ts +11 -0
  42. package/dist/commands/list-accounts.js +219 -0
  43. package/dist/commands/login.d.ts +13 -0
  44. package/dist/commands/login.js +122 -0
  45. package/dist/commands/open-account.d.ts +291 -0
  46. package/dist/commands/open-account.js +988 -0
  47. package/dist/commands/optimize.d.ts +39 -0
  48. package/dist/commands/optimize.js +143 -0
  49. package/dist/commands/report.d.ts +55 -0
  50. package/dist/commands/report.js +274 -0
  51. package/dist/commands/stats.d.ts +13 -0
  52. package/dist/commands/stats.js +109 -0
  53. package/dist/commands/transfer.d.ts +37 -0
  54. package/dist/commands/transfer.js +124 -0
  55. package/dist/config/defaults.d.ts +6 -0
  56. package/dist/config/defaults.js +11 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +1973 -0
  59. package/dist/templates/load-templates.d.ts +5 -0
  60. package/dist/templates/load-templates.js +60 -0
  61. package/dist/types/ads.d.ts +138 -0
  62. package/dist/types/ads.js +4 -0
  63. package/dist/utils/auth.d.ts +40 -0
  64. package/dist/utils/auth.js +277 -0
  65. package/package.json +48 -0
@@ -0,0 +1,817 @@
1
+ import { spawn } from "node:child_process";
2
+ import { loadConfig, apiFetch, validateBaseUrl } from "../utils/auth.js";
3
+ import { deriveWebUrl } from "./config.js";
4
+ /**
5
+ * 断开广告账户与当前账号的关联关系。
6
+ * 单个:PUT /command/media-account/{entityId}/delink
7
+ * 批量:POST /command/media-account/delinkList
8
+ */
9
+ export async function runAccountDelink(opts) {
10
+ if (!opts.id && (!opts.ids || opts.ids.length === 0)) {
11
+ console.error("\n❌ 请通过 --id 或 --ids 提供要断开关联的账户 entityId\n");
12
+ process.exit(1);
13
+ }
14
+ const config = loadConfig(opts.token);
15
+ // S-Command-Type 是前端固定传的操作类型头
16
+ const delinkHeader = { "S-Command-Type": "delinkMediaAccount" };
17
+ if (opts.ids && opts.ids.length > 1) {
18
+ // 批量断开
19
+ const url = `${config.apiBaseUrl}/command/media-account/delinkList`;
20
+ try {
21
+ await apiFetch(url, config, {
22
+ method: "POST",
23
+ body: JSON.stringify(opts.ids),
24
+ headers: delinkHeader,
25
+ }, opts.verbose);
26
+ }
27
+ catch (err) {
28
+ console.error(`\n❌ 批量断开关联失败:${err instanceof Error ? err.message : String(err)}\n`);
29
+ process.exit(1);
30
+ }
31
+ console.log(`\n✅ 已批量断开 ${opts.ids.length} 个账户的关联关系\n`);
32
+ }
33
+ else {
34
+ // 单个断开
35
+ const entityId = opts.id ?? opts.ids[0];
36
+ const url = `${config.apiBaseUrl}/command/media-account/${entityId}/delink`;
37
+ try {
38
+ await apiFetch(url, config, { method: "PUT", body: "{}", headers: delinkHeader }, opts.verbose);
39
+ }
40
+ catch (err) {
41
+ console.error(`\n❌ 断开关联失败:${err instanceof Error ? err.message : String(err)}\n`);
42
+ process.exit(1);
43
+ }
44
+ console.log(`\n✅ 账户 ${entityId} 已断开关联\n`);
45
+ }
46
+ }
47
+ /**
48
+ * 将 Google 广告账户分享给指定手机号的丝路赞用户。
49
+ *
50
+ * 流程:
51
+ * 1. GET mainApiUrl/query/account/SimpleAccountInfo?phone=xxx → 获取被分享人 accountId
52
+ * 2. POST /command/media-account/TSOMediaAccountShared/{entityId} { AccountId: userId }
53
+ */
54
+ export async function runAccountShare(opts) {
55
+ const config = loadConfig(opts.token);
56
+ if (!config.mainApiUrl) {
57
+ console.error("\n❌ 无法推导主 API 地址,请检查 apiBaseUrl 配置\n");
58
+ process.exit(1);
59
+ }
60
+ // 第一步:用手机号查询目标用户 ID
61
+ let userId;
62
+ try {
63
+ const lookupUrl = `${config.mainApiUrl}/query/account/SimpleAccountInfo?phone=${encodeURIComponent(opts.phone)}`;
64
+ const users = await apiFetch(lookupUrl, config, {}, opts.verbose);
65
+ if (!Array.isArray(users) || users.length === 0) {
66
+ console.error(`\n❌ 未找到手机号 ${opts.phone} 对应的丝路赞账号,请确认该用户已完成注册\n`);
67
+ process.exit(1);
68
+ }
69
+ userId = users[0].id;
70
+ }
71
+ catch (err) {
72
+ console.error(`\n❌ 查询用户信息失败:${err instanceof Error ? err.message : String(err)}\n`);
73
+ process.exit(1);
74
+ }
75
+ // 第二步:执行分享
76
+ const shareUrl = `${config.apiBaseUrl}/command/media-account/TSOMediaAccountShared/${opts.id}`;
77
+ try {
78
+ await apiFetch(shareUrl, config, { method: "POST", body: JSON.stringify({ AccountId: userId }) }, opts.verbose);
79
+ }
80
+ catch (err) {
81
+ console.error(`\n❌ 分享失败:${err instanceof Error ? err.message : String(err)}\n`);
82
+ process.exit(1);
83
+ }
84
+ console.log(`\n✅ 账户已成功分享给手机号 ${opts.phone} 的用户(AccountId: ${userId})\n`);
85
+ }
86
+ /**
87
+ * 取消 Google 广告账户的分享。
88
+ * POST /command/media-account/TSOMediaAccountSharedRemove/{entityId}
89
+ * Body: { AccountId: "<被分享人的 accountId>" }
90
+ */
91
+ export async function runAccountUnshare(opts) {
92
+ const config = loadConfig(opts.token);
93
+ const url = `${config.apiBaseUrl}/command/media-account/TSOMediaAccountSharedRemove/${opts.id}`;
94
+ try {
95
+ await apiFetch(url, config, { method: "POST", body: JSON.stringify({ AccountId: opts.accountId }) }, opts.verbose);
96
+ }
97
+ catch (err) {
98
+ console.error(`\n❌ 取消分享失败:${err instanceof Error ? err.message : String(err)}\n`);
99
+ process.exit(1);
100
+ }
101
+ console.log(`\n✅ 已取消账户 ${opts.id} 对用户 ${opts.accountId} 的分享\n`);
102
+ }
103
+ /**
104
+ * 查询指定账户的分享详情(被分享给哪些用户)。
105
+ * GET mainApiUrl/query/account/GetAccountsByMediaAcountId?mediaAcountId=xxx
106
+ */
107
+ export async function runAccountShareDetail(opts) {
108
+ const config = loadConfig(opts.token);
109
+ if (!config.mainApiUrl) {
110
+ console.error("\n❌ 无法推导主 API 地址,请检查 apiBaseUrl 配置\n");
111
+ process.exit(1);
112
+ }
113
+ const url = `${config.mainApiUrl}/query/account/GetAccountsByMediaAcountId?mediaAcountId=${encodeURIComponent(opts.mediaCustomerId)}`;
114
+ let data;
115
+ try {
116
+ data = await apiFetch(url, config, {}, opts.verbose);
117
+ }
118
+ catch (err) {
119
+ console.error(`\n❌ 查询失败:${err instanceof Error ? err.message : String(err)}\n`);
120
+ process.exit(1);
121
+ }
122
+ if (opts.json) {
123
+ console.log(JSON.stringify(data, null, 2));
124
+ return;
125
+ }
126
+ const users = Array.isArray(data) ? data : [];
127
+ console.log(`\n账户 ${opts.mediaCustomerId} 的分享详情(共 ${users.length} 人)\n`);
128
+ if (users.length === 0) {
129
+ console.log(" 暂未分享给任何用户。\n");
130
+ return;
131
+ }
132
+ const header = [
133
+ "账号 ID".padEnd(36),
134
+ "姓名".padEnd(16),
135
+ "手机号".padEnd(14),
136
+ "邮箱",
137
+ ].join(" ");
138
+ console.log(" " + header);
139
+ console.log(" " + "-".repeat(header.length));
140
+ for (const u of users) {
141
+ console.log(" " + [
142
+ (u.id ?? u.entityId ?? "").padEnd(36),
143
+ (u.name ?? "").padEnd(16),
144
+ (u.phone ?? "").padEnd(14),
145
+ u.email ?? "",
146
+ ].join(" "));
147
+ }
148
+ console.log();
149
+ }
150
+ /**
151
+ * Meta 在 API 调用中使用 "FacebookAds"(与其他媒体的枚举值不同)。
152
+ */
153
+ function toApiMediaType(media) {
154
+ return media === "Meta" ? "FacebookAds" : media;
155
+ }
156
+ /**
157
+ * 用系统默认浏览器打开 URL,跨平台(Windows/macOS/Linux)。
158
+ * 失败时静默,让调用方回退到打印 URL。
159
+ */
160
+ function tryOpenBrowser(url) {
161
+ try {
162
+ // 仅允许 HTTPS 授权链接,避免执行非网页协议。
163
+ const parsed = new URL(url);
164
+ if (parsed.protocol !== "https:") {
165
+ return false;
166
+ }
167
+ if (process.platform === "win32") {
168
+ // 使用系统 URL 处理器打开链接,避免拼接 shell 命令字符串。
169
+ const child = spawn("rundll32", ["url.dll,FileProtocolHandler", parsed.toString()], {
170
+ detached: true,
171
+ stdio: "ignore",
172
+ windowsHide: true,
173
+ });
174
+ child.unref();
175
+ }
176
+ else if (process.platform === "darwin") {
177
+ const child = spawn("open", [parsed.toString()], { detached: true, stdio: "ignore" });
178
+ child.unref();
179
+ }
180
+ else {
181
+ const child = spawn("xdg-open", [parsed.toString()], { detached: true, stdio: "ignore" });
182
+ child.unref();
183
+ }
184
+ return true;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ /**
191
+ * 发起媒体账户 OAuth 授权。
192
+ *
193
+ * 流程:
194
+ * 1. 调 GET /command/media-account/link/{mediaType}?returnUrl=... 获取 redirectUrl
195
+ * 2. 用系统浏览器打开 redirectUrl(fallback 输出链接让用户手动粘贴)
196
+ * 3. 用户在浏览器完成授权后会跳回丝路赞回调页面,自动完成账户绑定
197
+ */
198
+ export async function runAccountAuth(opts) {
199
+ const config = loadConfig(opts.token);
200
+ const apiMediaType = toApiMediaType(opts.media);
201
+ const webUrl = deriveWebUrl(config.apiBaseUrl);
202
+ // returnUrl 与前端生产环境保持一致:{webUrl}/v3/foreign_trade/tso/{mediaType}/accountsAuthorizationBind
203
+ const returnPath = encodeURIComponent(`${webUrl}/v3/foreign_trade/tso/${apiMediaType}/accountsAuthorizationBind`);
204
+ const linkUrl = `${config.apiBaseUrl}/command/media-account/link/${apiMediaType}?returnUrl=${returnPath}`;
205
+ console.log(`\n正在获取 ${opts.media} 授权链接…\n`);
206
+ let redirectUrl;
207
+ try {
208
+ const res = await apiFetch(linkUrl, config, {}, opts.verbose);
209
+ if (!res.redirectUrl) {
210
+ console.error("\n❌ 接口未返回 redirectUrl,该媒体类型可能暂不支持授权。\n");
211
+ process.exit(1);
212
+ }
213
+ redirectUrl = res.redirectUrl;
214
+ }
215
+ catch (err) {
216
+ console.error(`\n❌ 获取授权链接失败:${err instanceof Error ? err.message : String(err)}\n`);
217
+ process.exit(1);
218
+ }
219
+ const opened = tryOpenBrowser(redirectUrl);
220
+ if (opened) {
221
+ console.log(`✅ 已在默认浏览器中打开 ${opts.media} 授权页面。`);
222
+ console.log(` 完成授权后浏览器会自动跳回丝路赞,账户绑定即生效。\n`);
223
+ }
224
+ else {
225
+ console.log(`⚠️ 无法自动打开浏览器,请手动复制以下链接到浏览器中完成授权:\n`);
226
+ }
227
+ // 无论是否自动打开,都打印链接方便用户参考
228
+ console.log(` ${redirectUrl}\n`);
229
+ }
230
+ // ─── Google MCC 绑定 / 解绑 ─────────────────────────────────────────────────
231
+ /**
232
+ * 与前端 `splitMCCIds` 一致:从用户输入解析出经理账户 ID(MCC customer id),
233
+ * 结果为 **数字**(JSON 中为 number),与网页 `managerIds: idList` 一致。
234
+ * 支持逗号、中文逗号、分号、顿号等分隔符。
235
+ */
236
+ function parseMccManagerIds(input) {
237
+ const normalized = input.replace(/[,,;、]/g, ";");
238
+ return normalized
239
+ .split(";")
240
+ .map((part) => {
241
+ const digits = part.replace(/\D/g, "");
242
+ return digits ? +digits : NaN;
243
+ })
244
+ .filter((n) => Number.isFinite(n) && n > 0);
245
+ }
246
+ /** 前端 MCC 绑定请求体中的 agentTypes,需与网页保持一致 */
247
+ const MCC_AGENT_TYPES = ["ChengGongYi", "SiGeUS", "SiGeCN", "Dee"];
248
+ function googleGatewayBase(config) {
249
+ const raw = (config.googleApiUrl ?? "").replace(/\/$/, "");
250
+ if (!raw) {
251
+ console.error("\n❌ 未配置 Google 网关地址(googleApiUrl)。请运行:\n" +
252
+ " siluzan-tso config set --google-api https://googleapi.mysiluzan.com\n" +
253
+ " 测试环境:https://googleapi-ci.mysiluzan.com\n");
254
+ process.exit(1);
255
+ }
256
+ const err = validateBaseUrl(raw);
257
+ if (err) {
258
+ console.error(`\n❌ googleApiUrl 不合法:${err}\n`);
259
+ process.exit(1);
260
+ }
261
+ return raw;
262
+ }
263
+ /** 判断接口返回的成功列表中是否包含某个经理 ID(兼容 number / string) */
264
+ function mccResponseHasSuccess(res, managerId) {
265
+ if (!Array.isArray(res))
266
+ return false;
267
+ return res.some((item) => String(item) === String(managerId));
268
+ }
269
+ /**
270
+ * 将指定 Google 子账户绑定到一个或多个 MCC(经理账户)。
271
+ * 与网页一致:对每个子账户 POST `{googleApiUrl}/command/media-account/{mediaCustomerId}/CustomerLinks`
272
+ */
273
+ export async function runAccountMccBind(opts) {
274
+ const config = loadConfig(opts.token);
275
+ const base = googleGatewayBase(config);
276
+ const managerIds = parseMccManagerIds(opts.mcc);
277
+ if (managerIds.length === 0) {
278
+ console.error("\n❌ --mcc 中未解析出有效的数字型 MCC 客户 ID\n");
279
+ process.exit(1);
280
+ }
281
+ if (opts.customers.length === 0) {
282
+ console.error("\n❌ 请通过 --customers 指定至少一个 Google 子账户 mediaCustomerId\n");
283
+ process.exit(1);
284
+ }
285
+ const body = { agentTypes: [...MCC_AGENT_TYPES], managerIds };
286
+ const results = [];
287
+ for (const customerId of opts.customers) {
288
+ const url = `${base}/command/media-account/${customerId}/CustomerLinks`;
289
+ try {
290
+ const response = await apiFetch(url, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
291
+ results.push({ customerId, response });
292
+ }
293
+ catch (err) {
294
+ console.error(`\n❌ 账户 ${customerId} MCC 绑定请求失败:${err instanceof Error ? err.message : String(err)}\n`);
295
+ process.exit(1);
296
+ }
297
+ }
298
+ if (opts.json) {
299
+ console.log(JSON.stringify(results, null, 2));
300
+ return;
301
+ }
302
+ let hasFailure = false;
303
+ for (const { customerId, response } of results) {
304
+ const failed = managerIds.filter((mid) => !mccResponseHasSuccess(response, mid));
305
+ if (failed.length > 0) {
306
+ hasFailure = true;
307
+ console.error(` ❌ 账户 ${customerId}:以下 MCC 未在成功列表中 → ${failed.join(",")}`);
308
+ }
309
+ else {
310
+ console.log(` ✅ 账户 ${customerId}:已向 ${managerIds.join("、")} 发起绑定(接口返回成功)`);
311
+ }
312
+ }
313
+ console.log(hasFailure ? "\n⚠️ 部分 MCC 绑定可能未生效,请结合网页或 --json 核对。\n" : "\n✅ MCC 绑定流程已完成。\n");
314
+ }
315
+ /**
316
+ * 将指定 Google 子账户从给定 MCC 下解绑。
317
+ * POST `{googleApiUrl}/command/media-account/{mediaCustomerId}/CustomerUnlinks`,body 为 managerIds 数组。
318
+ */
319
+ export async function runAccountMccUnbind(opts) {
320
+ const config = loadConfig(opts.token);
321
+ const base = googleGatewayBase(config);
322
+ const managerIds = parseMccManagerIds(opts.mcc);
323
+ if (managerIds.length === 0) {
324
+ console.error("\n❌ --mcc 中未解析出有效的数字型 MCC 客户 ID\n");
325
+ process.exit(1);
326
+ }
327
+ if (opts.customers.length === 0) {
328
+ console.error("\n❌ 请通过 --customers 指定至少一个 Google 子账户 mediaCustomerId\n");
329
+ process.exit(1);
330
+ }
331
+ const results = [];
332
+ for (const customerId of opts.customers) {
333
+ const url = `${base}/command/media-account/${customerId}/CustomerUnlinks`;
334
+ try {
335
+ const response = await apiFetch(url, config, { method: "POST", body: JSON.stringify(managerIds) }, opts.verbose);
336
+ results.push({ customerId, response });
337
+ }
338
+ catch (err) {
339
+ console.error(`\n❌ 账户 ${customerId} MCC 解绑请求失败:${err instanceof Error ? err.message : String(err)}\n`);
340
+ process.exit(1);
341
+ }
342
+ }
343
+ if (opts.json) {
344
+ console.log(JSON.stringify(results, null, 2));
345
+ return;
346
+ }
347
+ let hasFailure = false;
348
+ for (const { customerId, response } of results) {
349
+ const failed = managerIds.filter((mid) => !mccResponseHasSuccess(response, mid));
350
+ if (failed.length > 0) {
351
+ hasFailure = true;
352
+ console.error(` ❌ 账户 ${customerId}:以下 MCC 未在成功解绑列表中 → ${failed.join(",")}`);
353
+ }
354
+ else {
355
+ console.log(` ✅ 账户 ${customerId}:已向 ${managerIds.join("、")} 发起解绑(接口返回成功)`);
356
+ }
357
+ }
358
+ console.log(hasFailure ? "\n⚠️ 部分 MCC 解绑可能未生效,请结合网页或 --json 核对。\n" : "\n✅ MCC 解绑流程已完成。\n");
359
+ }
360
+ /**
361
+ * 关闭(停用)TikTok 广告账户。
362
+ * POST /command/media-account/AdvertiserDisable
363
+ * Body: 广告账户 mediaCustomerId 数组
364
+ *
365
+ * 注意:此操作仅支持 TikTok 账户,且不可恢复,请谨慎使用。
366
+ */
367
+ export async function runAccountClose(opts) {
368
+ if (!opts.accountIds || opts.accountIds.length === 0) {
369
+ console.error("\n❌ 请通过 --accounts 提供要关闭的 TikTok 广告账户 mediaCustomerId\n");
370
+ process.exit(1);
371
+ }
372
+ const config = loadConfig(opts.token);
373
+ const url = `${config.apiBaseUrl}/command/media-account/AdvertiserDisable`;
374
+ let result;
375
+ try {
376
+ result = await apiFetch(url, config, { method: "POST", body: JSON.stringify(opts.accountIds) }, opts.verbose);
377
+ }
378
+ catch (err) {
379
+ console.error(`\n❌ 账户关闭失败:${err instanceof Error ? err.message : String(err)}\n`);
380
+ process.exit(1);
381
+ }
382
+ if (opts.json) {
383
+ console.log(JSON.stringify(result, null, 2));
384
+ return;
385
+ }
386
+ console.log(`\n✅ 已提交关闭请求,涉及账户:${opts.accountIds.join("、")}\n`);
387
+ console.log(" 注意:TikTok 账户关闭后将停止投放,如需恢复请联系丝路赞客服。\n");
388
+ }
389
+ /**
390
+ * 查询被封(Suspended)Google 广告账户列表,用于判断哪些账户可以提现。
391
+ * GET /query/media-account/Google/GetLinkedMediaAccountInfosFiterByStatesLatest?statesJson=["Suspended"]
392
+ *
393
+ * 注意:接口返回的是 Google 侧标记为 Suspended 的账户(mai.status === "Suspended")。
394
+ * 平台侧(list-accounts)可能显示为"正常",因为 OAuth Token 仍有效,两者是不同维度的状态。
395
+ */
396
+ export async function runAccountWithdrawList(opts) {
397
+ const config = loadConfig(opts.token);
398
+ const url = `${config.apiBaseUrl}/query/media-account/Google/GetLinkedMediaAccountInfosFiterByStatesLatest` +
399
+ `?statesJson=${encodeURIComponent(JSON.stringify(["Suspended"]))}`;
400
+ let data;
401
+ try {
402
+ data = await apiFetch(url, config, {}, opts.verbose);
403
+ }
404
+ catch (err) {
405
+ console.error(`\n❌ 查询失败:${err instanceof Error ? err.message : String(err)}\n`);
406
+ process.exit(1);
407
+ }
408
+ if (opts.json) {
409
+ console.log(JSON.stringify(data, null, 2));
410
+ return;
411
+ }
412
+ const allItems = Array.isArray(data) ? data : [];
413
+ // 过滤掉非 Suspended 状态的账户(接口理论上只返回 Suspended,防御性过滤)
414
+ const suspendedItems = allItems.filter((item) => item.mai?.status === "Suspended");
415
+ const skippedCount = allItems.length - suspendedItems.length;
416
+ if (skippedCount > 0) {
417
+ console.warn(`\n⚠️ 接口返回 ${allItems.length} 条,其中 ${skippedCount} 条非 Suspended 状态已过滤。\n`);
418
+ }
419
+ if (suspendedItems.length === 0) {
420
+ console.log("\n 暂无 Google Suspended(被封)广告账户。\n");
421
+ return;
422
+ }
423
+ // 区分有余额可提现 vs 余额为零的账户
424
+ const withdrawable = suspendedItems.filter((item) => {
425
+ const balance = Number(item.mai?.remainingAccountBudget ?? 0);
426
+ const adjustments = Number(item.mai?.totalAdjustmentsMicros ?? 0);
427
+ return balance - adjustments > 0;
428
+ });
429
+ const zeroBalance = suspendedItems.filter((item) => {
430
+ const balance = Number(item.mai?.remainingAccountBudget ?? 0);
431
+ const adjustments = Number(item.mai?.totalAdjustmentsMicros ?? 0);
432
+ return balance - adjustments <= 0;
433
+ });
434
+ console.log(`\nGoogle Suspended(被封禁)账户(共 ${suspendedItems.length} 条,其中 ${withdrawable.length} 条有余额可提现)\n`);
435
+ const header = [
436
+ "entityId".padEnd(38),
437
+ "mediaCustomerId".padEnd(20),
438
+ "账户名称".padEnd(28),
439
+ "Google状态".padEnd(12),
440
+ "余额".padEnd(10),
441
+ "赠送金".padEnd(10),
442
+ "货币".padEnd(6),
443
+ "可提现",
444
+ ].join(" ");
445
+ console.log(" " + header);
446
+ console.log(" " + "-".repeat(header.length));
447
+ for (const item of suspendedItems) {
448
+ const ma = item.ma ?? {};
449
+ const mai = item.mai ?? {};
450
+ const balance = Number(mai.remainingAccountBudget ?? 0);
451
+ const adjustments = Number(mai.totalAdjustmentsMicros ?? 0);
452
+ const net = balance - adjustments;
453
+ const currency = String(mai.currencyCode ?? ma.currencyCode ?? "");
454
+ const canWithdraw = net > 0 ? "✅ 是" : "❌ 否(净额≤0)";
455
+ console.log(" " + [
456
+ String(ma.entityId ?? "").padEnd(38),
457
+ String(ma.mediaCustomerId ?? "").padEnd(20),
458
+ String(ma.mediaCustomerName ?? "").slice(0, 26).padEnd(28),
459
+ String(mai.status ?? "Suspended").padEnd(12),
460
+ balance.toFixed(2).padEnd(10),
461
+ adjustments.toFixed(2).padEnd(10),
462
+ currency.padEnd(6),
463
+ canWithdraw,
464
+ ].join(" "));
465
+ }
466
+ if (zeroBalance.length > 0) {
467
+ console.log(`\n ℹ️ ${zeroBalance.length} 个账户余额净额 ≤ 0,无法提现。`);
468
+ }
469
+ console.log("\n提示:使用 account withdraw-submit --accounts <entityId,...> 提交有余额账户的提现申请。\n");
470
+ console.log(" 注意:Google Suspended = Google 平台封号,与丝路赞平台的 OAuth「失效」状态是不同维度,二者互不影响。\n");
471
+ }
472
+ /**
473
+ * 提交被封 Google 账户提现申请。
474
+ * 流程:
475
+ * 1. 查询被封账户详情(获取余额、货币、代理类型等)
476
+ * 2. 查询管理费比例 GET /service-charge/fees/get-fee-rate
477
+ * 3. 批量提交 POST /AccountWithdraws/batch
478
+ *
479
+ * 金额计算:可提现额 = 余额 - 赠送金;总扣款 = 可提现额 × (1 + feeRate) × (1 + taxRate)
480
+ * CNY 账户 taxRate=0.06,USD 账户 taxRate=0
481
+ */
482
+ export async function runAccountWithdrawSubmit(opts) {
483
+ if (!opts.accounts || opts.accounts.length === 0) {
484
+ console.error("\n❌ 请通过 --accounts 提供要提现的账户 entityId(可逗号分隔多个)\n");
485
+ process.exit(1);
486
+ }
487
+ const config = loadConfig(opts.token);
488
+ // 第一步:查询被封账户列表获取账户详情
489
+ console.log(" [1/2] 正在查询被封账户信息...");
490
+ const listUrl = `${config.apiBaseUrl}/query/media-account/Google/GetLinkedMediaAccountInfosFiterByStatesLatest` +
491
+ `?statesJson=${encodeURIComponent(JSON.stringify(["Suspended"]))}`;
492
+ let allSuspended;
493
+ try {
494
+ allSuspended = await apiFetch(listUrl, config, {}, opts.verbose);
495
+ }
496
+ catch (err) {
497
+ console.error(`\n❌ 查询被封账户列表失败:${err instanceof Error ? err.message : String(err)}\n`);
498
+ process.exit(1);
499
+ }
500
+ const targetAccounts = (allSuspended ?? []).filter((item) => item.ma?.entityId && opts.accounts.includes(item.ma.entityId));
501
+ if (targetAccounts.length === 0) {
502
+ console.error("\n❌ 在被封账户列表中未找到指定的账户,请先通过 account withdraw-list 查看可提现账户。\n");
503
+ process.exit(1);
504
+ }
505
+ // 第二步:按币种查询管理费比例(前端按 mediaType + currencyCode + amount 查询)
506
+ console.log(" [2/2] 正在查询管理费比例...");
507
+ // 计算各账户基础数据,汇总需要查询的币种
508
+ const accountDetails = targetAccounts.map((item) => {
509
+ const ma = item.ma ?? {};
510
+ const mai = item.mai ?? {};
511
+ const currency = String(mai.currencyCode ?? ma.currencyCode ?? "USD");
512
+ const balance = Number(mai.remainingAccountBudget ?? 0);
513
+ const adjustments = Number(mai.totalAdjustmentsMicros ?? 0);
514
+ const availableAmount = Math.max(0, balance - adjustments);
515
+ return { ma, mai, currency, balance, adjustments, availableAmount };
516
+ });
517
+ // 按币种 + 金额查询管理费(与前端行为一致)
518
+ const feeRateCache = new Map();
519
+ for (const { currency, availableAmount } of accountDetails) {
520
+ const cacheKey = `${currency}-${availableAmount.toFixed(2)}`;
521
+ if (feeRateCache.has(cacheKey))
522
+ continue;
523
+ try {
524
+ const feeParams = new URLSearchParams({
525
+ mediaType: "Google",
526
+ currencyCode: currency,
527
+ amount: availableAmount.toFixed(2),
528
+ });
529
+ const feeData = await apiFetch(`${config.apiBaseUrl}/service-charge/fees/get-fee-rate?${feeParams}`, config, {}, opts.verbose);
530
+ feeRateCache.set(cacheKey, feeData?.feeRate ?? 0);
531
+ }
532
+ catch {
533
+ console.warn(` ⚠️ ${currency} 管理费查询失败,将以 0 费率提交。`);
534
+ feeRateCache.set(cacheKey, 0);
535
+ }
536
+ }
537
+ // 构造提现请求体(与前端字段保持一致)
538
+ const body = accountDetails.map(({ ma, mai, currency, balance, adjustments, availableAmount }) => {
539
+ const cacheKey = `${currency}-${availableAmount.toFixed(2)}`;
540
+ const feeRate = feeRateCache.get(cacheKey) ?? 0;
541
+ const taxRate = currency === "CNY" ? 0.06 : 0;
542
+ const totalAmounts = availableAmount * (1 + feeRate) * (1 + taxRate);
543
+ return {
544
+ MediaType: "Google",
545
+ MediaAccountId: ma.entityId ?? "",
546
+ // 优先取 mai.mediaCustomerId(Google 侧 ID),与前端保持一致
547
+ MediaCustomerId: String(mai.mediaCustomerId ?? ma.mediaCustomerId ?? ""),
548
+ MediaCustomerName: String(ma.mediaCustomerName ?? ""),
549
+ AdvertiserName: String(ma.mediaCustomerName ?? ""),
550
+ Currency: currency,
551
+ // 使用 toFixed(2) 保持与前端精度一致
552
+ Balance: balance.toFixed(2),
553
+ AgentType: String(ma.accountType ?? ""),
554
+ FeeRate: feeRate,
555
+ TaxRate: taxRate,
556
+ Amounts: totalAmounts.toFixed(2),
557
+ Adjustments: adjustments.toFixed(2),
558
+ };
559
+ });
560
+ let result;
561
+ try {
562
+ result = await apiFetch(`${config.apiBaseUrl}/AccountWithdraws/batch`, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
563
+ }
564
+ catch (err) {
565
+ console.error(`\n❌ 提现申请提交失败:${err instanceof Error ? err.message : String(err)}\n`);
566
+ process.exit(1);
567
+ }
568
+ if (opts.json) {
569
+ console.log(JSON.stringify(result, null, 2));
570
+ return;
571
+ }
572
+ // 取第一个账户的费率用于摘要显示(多账户时各自费率见 --json)
573
+ const firstFeeRate = feeRateCache.size > 0 ? [...feeRateCache.values()][0] : 0;
574
+ console.log(`\n✅ 已提交 ${targetAccounts.length} 个账户的提现申请(管理费率 ${(firstFeeRate * 100).toFixed(1)}%)。\n`);
575
+ console.log(" 注意:提现审核完成后,金额将退回到您的丝路赞钱包,请留意站内通知。\n");
576
+ }
577
+ // ─── TikTok BC 绑定 / 解绑 ────────────────────────────────────────────────────
578
+ /** 获取 TikTok 媒体网关基地址,不存在时报错退出 */
579
+ function tiktokGatewayBase(config) {
580
+ const raw = (config.tiktokApiUrl ?? "").replace(/\/$/, "");
581
+ if (!raw) {
582
+ console.error("\n❌ 未能推导 TikTok 网关地址(tiktokApiUrl)。\n" +
583
+ " 请确认 apiBaseUrl 已正确配置:\n" +
584
+ " siluzan-tso config set --api-base https://tso-api.siluzan.com\n");
585
+ process.exit(1);
586
+ }
587
+ return raw;
588
+ }
589
+ /**
590
+ * 将 TikTok 广告账户绑定到 Business Center(BC)。
591
+ * POST ${tiktokApiUrl}/bcmanagement/AddPartnerList
592
+ * Body: { bcIds: string[], mediacustomerIds: string[] }
593
+ * 返回数组,每项含 code(0=成功)和 message
594
+ */
595
+ export async function runAccountBcBind(opts) {
596
+ if (!opts.customers || opts.customers.length === 0) {
597
+ console.error("\n❌ 请通过 --customers 提供要绑定的 TikTok 广告账户 mediaCustomerId\n");
598
+ process.exit(1);
599
+ }
600
+ if (!opts.bcIds || opts.bcIds.length === 0) {
601
+ console.error("\n❌ 请通过 --bc-ids 提供 Business Center ID\n");
602
+ process.exit(1);
603
+ }
604
+ const config = loadConfig(opts.token);
605
+ const base = tiktokGatewayBase(config);
606
+ const url = `${base}/bcmanagement/AddPartnerList`;
607
+ const body = { bcIds: opts.bcIds, mediacustomerIds: opts.customers };
608
+ let result;
609
+ try {
610
+ result = await apiFetch(url, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
611
+ }
612
+ catch (err) {
613
+ console.error(`\n❌ BC 绑定失败:${err instanceof Error ? err.message : String(err)}\n`);
614
+ process.exit(1);
615
+ }
616
+ if (opts.json) {
617
+ console.log(JSON.stringify(result, null, 2));
618
+ return;
619
+ }
620
+ // 检查是否有失败项
621
+ const resultArr = Array.isArray(result) ? result : [];
622
+ const failed = resultArr.filter((r) => r.code !== 0);
623
+ if (failed.length > 0) {
624
+ console.warn(`\n⚠️ 部分绑定失败(${failed.length} 条):`);
625
+ for (const f of failed) {
626
+ console.warn(` ${f.message ?? JSON.stringify(f)}`);
627
+ }
628
+ }
629
+ console.log(`\n✅ TikTok BC 绑定请求已提交` +
630
+ `(账户:${opts.customers.join("、")},BC:${opts.bcIds.join("、")})\n`);
631
+ }
632
+ /**
633
+ * 将 TikTok 广告账户从 Business Center(BC)下解绑。
634
+ * DELETE ${tiktokApiUrl}/bcmanagement/DeletePartnerAssetList
635
+ * Body: [{ bcId, mediacustomerIds }]
636
+ * 返回:空对象 = 全部成功;非空 = key 为失败的 mediaCustomerId,value 为失败原因
637
+ */
638
+ export async function runAccountBcUnbind(opts) {
639
+ if (!opts.customers || opts.customers.length === 0) {
640
+ console.error("\n❌ 请通过 --customers 提供要解绑的 TikTok 广告账户 mediaCustomerId\n");
641
+ process.exit(1);
642
+ }
643
+ if (!opts.bcId) {
644
+ console.error("\n❌ 请通过 --bc-id 提供 Business Center ID\n");
645
+ process.exit(1);
646
+ }
647
+ const config = loadConfig(opts.token);
648
+ const base = tiktokGatewayBase(config);
649
+ const url = `${base}/bcmanagement/DeletePartnerAssetList`;
650
+ const body = [{ bcId: opts.bcId, mediacustomerIds: opts.customers }];
651
+ let result;
652
+ try {
653
+ result = await apiFetch(url, config, { method: "DELETE", body: JSON.stringify(body) }, opts.verbose);
654
+ }
655
+ catch (err) {
656
+ console.error(`\n❌ BC 解绑失败:${err instanceof Error ? err.message : String(err)}\n`);
657
+ process.exit(1);
658
+ }
659
+ if (opts.json) {
660
+ console.log(JSON.stringify(result, null, 2));
661
+ return;
662
+ }
663
+ // 接口返回非空对象时,key=mediaCustomerId,value=失败原因
664
+ const failedMap = (result && typeof result === "object" && !Array.isArray(result))
665
+ ? result
666
+ : {};
667
+ const failedIds = Object.keys(failedMap);
668
+ if (failedIds.length > 0) {
669
+ console.warn(`\n⚠️ 以下账户解绑失败:`);
670
+ for (const id of failedIds) {
671
+ console.warn(` ${id}: ${failedMap[id]}`);
672
+ }
673
+ }
674
+ else {
675
+ console.log(`\n✅ 已将账户 ${opts.customers.join("、")} 从 BC(${opts.bcId})下解绑。\n`);
676
+ }
677
+ }
678
+ /**
679
+ * 查询 Google 广告账户的邮箱授权列表。
680
+ * GET ${googleApiUrl}/query/media-account/GetCustomerUserAccessInvitation
681
+ * Params: { CustomerId, AgentType }
682
+ */
683
+ export async function runAccountEmailAuthList(opts) {
684
+ const config = loadConfig(opts.token);
685
+ const base = googleGatewayBase(config);
686
+ const params = new URLSearchParams({ CustomerId: opts.customerId, AgentType: opts.agentType });
687
+ const url = `${base}/query/media-account/GetCustomerUserAccessInvitation?${params}`;
688
+ let data;
689
+ try {
690
+ data = await apiFetch(url, config, {}, opts.verbose);
691
+ }
692
+ catch (err) {
693
+ console.error(`\n❌ 查询邮箱授权列表失败:${err instanceof Error ? err.message : String(err)}\n`);
694
+ process.exit(1);
695
+ }
696
+ if (opts.json) {
697
+ console.log(JSON.stringify(data, null, 2));
698
+ return;
699
+ }
700
+ const items = Array.isArray(data) ? data : [];
701
+ if (items.length === 0) {
702
+ console.log("\n 暂无邮箱授权记录。\n");
703
+ return;
704
+ }
705
+ console.log(`\n账户 ${opts.customerId} 的邮箱授权列表(共 ${items.length} 条)\n`);
706
+ const header = [
707
+ "邀请 ID".padEnd(20),
708
+ "邮箱".padEnd(34),
709
+ "权限".padEnd(12),
710
+ "状态",
711
+ ].join(" ");
712
+ console.log(" " + header);
713
+ console.log(" " + "-".repeat(header.length));
714
+ for (const item of items) {
715
+ // invitationId 或 invitationld(接口拼写不一致)
716
+ const invId = String(item.invitationId ?? item.invitationld ?? "");
717
+ console.log(" " + [
718
+ invId.padEnd(20),
719
+ String(item.emailAddress ?? "").padEnd(34),
720
+ String(item.accessRole ?? "").padEnd(12),
721
+ String(item.invitationStatus ?? ""),
722
+ ].join(" "));
723
+ }
724
+ console.log("\n提示:使用 account email-deauth 解除授权,--invitation-id 来自上方邀请 ID。\n");
725
+ }
726
+ /**
727
+ * 向指定邮箱发送 Google 广告账户授权邀请。
728
+ * POST ${googleApiUrl}/command/media-account/MutateCustomerUserAccessInvitation
729
+ * Body: { CustomerId, EmailAddress, AccessRole, AgentType }
730
+ */
731
+ export async function runAccountEmailAuth(opts) {
732
+ const config = loadConfig(opts.token);
733
+ const base = googleGatewayBase(config);
734
+ const url = `${base}/command/media-account/MutateCustomerUserAccessInvitation`;
735
+ const body = {
736
+ CustomerId: opts.customerId,
737
+ EmailAddress: opts.email,
738
+ AccessRole: opts.accessRole ?? "Standard",
739
+ AgentType: opts.agentType,
740
+ };
741
+ let result;
742
+ try {
743
+ result = await apiFetch(url, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
744
+ }
745
+ catch (err) {
746
+ console.error(`\n❌ 邮箱授权失败:${err instanceof Error ? err.message : String(err)}\n`);
747
+ process.exit(1);
748
+ }
749
+ if (opts.json) {
750
+ console.log(JSON.stringify(result, null, 2));
751
+ return;
752
+ }
753
+ console.log(`\n✅ 已向 ${opts.email} 发送 Google 广告账户(${opts.customerId})授权邀请` +
754
+ `(权限:${opts.accessRole ?? "Standard"})。\n`);
755
+ }
756
+ /**
757
+ * 解除 Google 广告账户的邮箱授权。
758
+ * 已接受:DELETE ${googleApiUrl}/command/media-account/RemoveCustomerUserAccess
759
+ * 待接受:DELETE ${googleApiUrl}/command/media-account/RemoveCustomerUserAccessInvitation
760
+ *
761
+ * 注意:接口请求体中 invitationld 为 "l" 而非 "I"(接口拼写问题,与前端保持一致)。
762
+ */
763
+ export async function runAccountEmailDeauth(opts) {
764
+ const config = loadConfig(opts.token);
765
+ const base = googleGatewayBase(config);
766
+ const endpoint = (opts.accepted !== false)
767
+ ? "RemoveCustomerUserAccess"
768
+ : "RemoveCustomerUserAccessInvitation";
769
+ const url = `${base}/command/media-account/${endpoint}`;
770
+ const body = {
771
+ CustomerId: opts.customerId,
772
+ // 接口字段为 invitationld(小写 L),非 invitationId,与前端保持一致
773
+ invitationld: opts.invitationId,
774
+ AgentType: opts.agentType,
775
+ resourceName: opts.resourceName,
776
+ };
777
+ let result;
778
+ try {
779
+ result = await apiFetch(url, config, { method: "DELETE", body: JSON.stringify(body) }, opts.verbose);
780
+ }
781
+ catch (err) {
782
+ console.error(`\n❌ 解除邮箱授权失败:${err instanceof Error ? err.message : String(err)}\n`);
783
+ process.exit(1);
784
+ }
785
+ if (opts.json) {
786
+ console.log(JSON.stringify(result, null, 2));
787
+ return;
788
+ }
789
+ console.log(`\n✅ 已解除邀请 ${opts.invitationId} 的邮箱授权。\n`);
790
+ }
791
+ /**
792
+ * 将 Meta 广告账户绑定到指定 Business Manager(BM)。
793
+ * POST /MetaAccount/Management/BindBM
794
+ * Body: { accountId, bmId, actionType }
795
+ */
796
+ export async function runAccountBmBind(opts) {
797
+ const config = loadConfig(opts.token);
798
+ const url = `${config.apiBaseUrl}/MetaAccount/Management/BindBM`;
799
+ const body = {
800
+ accountId: opts.accountId,
801
+ bmId: opts.bmId,
802
+ actionType: opts.actionType ?? "bind",
803
+ };
804
+ let result;
805
+ try {
806
+ result = await apiFetch(url, config, { method: "POST", body: JSON.stringify(body) }, opts.verbose);
807
+ }
808
+ catch (err) {
809
+ console.error(`\n❌ BM 绑定失败:${err instanceof Error ? err.message : String(err)}\n`);
810
+ process.exit(1);
811
+ }
812
+ if (opts.json) {
813
+ console.log(JSON.stringify(result, null, 2));
814
+ return;
815
+ }
816
+ console.log(`\n✅ 已将账户 ${opts.accountId} 绑定到 Business Manager(BM ID:${opts.bmId})\n`);
817
+ }