client.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import {
  2. SpApiConfig,
  3. LwaTokenResponse,
  4. ShopConfig,
  5. CreateRestrictedDataTokenRequest,
  6. RestrictedDataTokenResponse,
  7. RdtCacheItem
  8. } from './types.ts';
  9. import { getEndpointByMarketplaceId, getEndpointByRegion } from './marketplace-helper.ts';
  10. /**
  11. * Amazon Selling Partner API 基础客户端
  12. * 处理 LWA 认证 (简化模式,无需 AWS V4 签名)
  13. * 支持 RDT (Restricted Data Token) 用于访问 PII 数据
  14. */
  15. export class SpApiClient {
  16. // 配置在构造函数中现在是可选的,但可以传递用于遗留/静态用法
  17. // 然而,对于基于 Parse 的多租户用法,我们依赖于方法参数。
  18. // 为了保持向后兼容性并支持新的动态模式,我们可以使用混合方法。
  19. // 对于动态用法,我们将主要不使用 this.config。
  20. private staticConfig?: SpApiConfig;
  21. // RDT Token 缓存 (内存缓存,按 shopId 和 resourceKey 存储)
  22. private rdtCache: Map<string, RdtCacheItem> = new Map();
  23. constructor(config?: SpApiConfig) {
  24. this.staticConfig = config;
  25. }
  26. /**
  27. * 获取基础 URL
  28. */
  29. private getBaseUrl(marketplaceId?: string, isSandbox?: boolean, region?: string): string {
  30. if (marketplaceId) {
  31. return getEndpointByMarketplaceId(marketplaceId, isSandbox);
  32. }
  33. // 如果提供了区域配置,则使用区域
  34. if (region) {
  35. return getEndpointByRegion(region, isSandbox);
  36. }
  37. // 回退到静态配置 (遗留模式)
  38. if (this.staticConfig) {
  39. const REGION_ENDPOINTS: Record<string, string> = {
  40. 'us-east-1': 'https://sellingpartnerapi-na.amazon.com',
  41. 'eu-west-1': 'https://sellingpartnerapi-eu.amazon.com',
  42. 'us-west-2': 'https://sellingpartnerapi-fe.amazon.com'
  43. };
  44. const SANDBOX_ENDPOINTS: Record<string, string> = {
  45. 'us-east-1': 'https://sandbox.sellingpartnerapi-na.amazon.com',
  46. 'eu-west-1': 'https://sandbox.sellingpartnerapi-eu.amazon.com',
  47. 'us-west-2': 'https://sandbox.sellingpartnerapi-fe.amazon.com'
  48. };
  49. const endpoints = this.staticConfig.sandbox ? SANDBOX_ENDPOINTS : REGION_ENDPOINTS;
  50. return endpoints[this.staticConfig.region] || endpoints['us-east-1'];
  51. }
  52. throw new Error('无法确定 SP-API 端点,MarketplaceId 为必填项。');
  53. }
  54. /**
  55. * 获取 LWA Access Token
  56. * 先从 Shop 表获取缓存的 token,如果不存在或过期则刷新
  57. */
  58. private async getAccessToken(context?: { shopId: string; config: ShopConfig }): Promise<string> {
  59. // 1. 动态上下文模式
  60. if (context) {
  61. const { shopId, config } = context;
  62. // 刷新 Token(内部会先检查 Shop 表的缓存)
  63. return this.refreshAccessToken(
  64. shopId,
  65. config.clientId,
  66. config.clientSecret,
  67. config.refreshToken
  68. );
  69. }
  70. throw new Error('缺少 SpApiClient 配置。');
  71. }
  72. /**
  73. * 刷新 Token 逻辑
  74. * 1. 先从 Shop 表的 config.SpApiConfig.accessToken 获取
  75. * 2. 检查是否过期(通过 config.SpApiConfig.accessTokenExpiresAt)
  76. * 3. 如果不存在或过期,则请求新的 token
  77. * 4. 将新 token 和过期时间(50分钟后)保存回 Shop 表
  78. */
  79. private async refreshAccessToken(
  80. shopId: string,
  81. clientId: string,
  82. clientSecret: string,
  83. refreshToken: string
  84. ): Promise<string> {
  85. try {
  86. // 获取 Parse 实例
  87. const Parse: any = (globalThis as any).Parse;
  88. if (!Parse) {
  89. console.warn('[SP-API] Parse SDK not available, will fetch new token');
  90. }
  91. // 1. 尝试从 Shop 表获取缓存的 accessToken
  92. if (Parse) {
  93. try {
  94. const shopQuery = new Parse.Query('Shop');
  95. const shop = await shopQuery.get(shopId, { useMasterKey: true });
  96. if (shop) {
  97. const config = shop.get('config') || {};
  98. const spApiConfig = config.SpApiConfig || {};
  99. const cachedToken = spApiConfig.accessToken;
  100. const expiresAt = spApiConfig.accessTokenExpiresAt;
  101. // 2. 检查 token 是否存在且未过期
  102. if (cachedToken && expiresAt) {
  103. const now = new Date();
  104. const expiryDate = new Date(expiresAt);
  105. if (expiryDate > now) {
  106. const remainingMinutes = Math.floor(
  107. (expiryDate.getTime() - now.getTime()) / 1000 / 60
  108. );
  109. console.log(`[SP-API] 使用缓存的 Access Token (剩余 ${remainingMinutes} 分钟)`);
  110. return cachedToken;
  111. } else {
  112. console.log('[SP-API] 缓存的 Access Token 已过期,正在刷新...');
  113. }
  114. } else {
  115. console.log('[SP-API] 未找到缓存的 Access Token,正在获取新的...');
  116. }
  117. }
  118. } catch (error: any) {
  119. console.warn('[SP-API] 从 Shop 表获取 token 失败:', error.message);
  120. }
  121. }
  122. // 3. 请求新的 Access Token
  123. console.log('[SP-API] 正在刷新 Access Token...');
  124. const params = new URLSearchParams({
  125. grant_type: 'refresh_token',
  126. refresh_token: refreshToken,
  127. client_id: clientId,
  128. client_secret: clientSecret
  129. // scope: 'sellingpartnerapi::migration​'
  130. });
  131. const response = await fetch('https://api.amazon.com/auth/o2/token', {
  132. method: 'POST',
  133. headers: {
  134. 'Content-Type': 'application/x-www-form-urlencoded'
  135. },
  136. body: params
  137. });
  138. if (!response.ok) {
  139. const errorText = await response.text();
  140. throw new Error(
  141. `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
  142. );
  143. }
  144. const data = (await response.json()) as LwaTokenResponse;
  145. console.log('[SP-API] 刷新 Access Token 成功。');
  146. // 4. 保存新 token 到 Shop 表(设置50分钟后过期)
  147. if (Parse) {
  148. try {
  149. const shopQuery = new Parse.Query('Shop');
  150. const shop = await shopQuery.get(shopId, { useMasterKey: true });
  151. if (shop) {
  152. const config = shop.get('config') || {};
  153. const spApiConfig = config.SpApiConfig || {};
  154. // 设置过期时间为50分钟后
  155. const expiresAt = new Date();
  156. expiresAt.setMinutes(expiresAt.getMinutes() + 50);
  157. // 更新 SpApiConfig
  158. spApiConfig.accessToken = data.access_token;
  159. spApiConfig.accessTokenExpiresAt = expiresAt.toISOString();
  160. config.SpApiConfig = spApiConfig;
  161. shop.set('config', config);
  162. await shop.save(null, { useMasterKey: true });
  163. console.log(
  164. `[SP-API] Access Token 已保存到 Shop 表 (过期时间: ${expiresAt.toISOString()})`
  165. );
  166. }
  167. } catch (error: any) {
  168. console.error('[SP-API] 保存 token 到 Shop 表失败:', error.message);
  169. // 不抛出错误,因为 token 已经获取成功
  170. }
  171. }
  172. return data.access_token;
  173. } catch (error) {
  174. console.error('[SP-API] 刷新 token 失败:', error);
  175. throw error;
  176. }
  177. }
  178. /**
  179. * 获取 RDT Token (带缓存)
  180. * @param shopId 店铺ID
  181. * @param accessToken LWA Access Token
  182. * @param method HTTP 方法
  183. * @param path API 路径
  184. * @param dataElements 可选的数据元素列表
  185. * @param marketplaceId 市场ID
  186. * @param isSandbox 是否沙箱环境
  187. * @param region AWS 区域
  188. */
  189. private async getRestrictedDataToken(
  190. accessToken: string,
  191. baseUrl: string,
  192. method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  193. path: string,
  194. dataElements?: string[]
  195. ): Promise<string> {
  196. // 请求新的 RDT Token
  197. console.log(`[SP-API] 正在获取 RDT Token for ${method} ${path}...`);
  198. const rdtEndpoint = `${baseUrl}/tokens/2021-03-01/restrictedDataToken`;
  199. // 确保路径格式正确
  200. const cleanPath = path.startsWith('/') ? path : `/${path}`;
  201. const requestBody: CreateRestrictedDataTokenRequest = {
  202. restrictedResources: [
  203. {
  204. method,
  205. path: cleanPath
  206. }
  207. ]
  208. };
  209. console.log(`[SP-API] RDT 请求详情:`, {
  210. endpoint: rdtEndpoint,
  211. requestBody: JSON.stringify(requestBody, null, 2),
  212. accessToken: accessToken.substring(0, 20) + '...'
  213. });
  214. try {
  215. const response = await fetch(rdtEndpoint, {
  216. method: 'POST',
  217. headers: {
  218. 'x-amz-access-token': accessToken,
  219. 'Content-Type': 'application/json'
  220. },
  221. body: JSON.stringify(requestBody)
  222. });
  223. const responseText = await response.text();
  224. console.log(`[SP-API] RDT 响应状态: ${response.status}`, responseText);
  225. if (!response.ok) {
  226. let errorData;
  227. try {
  228. errorData = JSON.parse(responseText);
  229. } catch (e) {
  230. errorData = responseText;
  231. }
  232. // 特殊处理常见错误
  233. if (response.status === 403) {
  234. throw new Error(
  235. `RDT 权限不足: 应用可能没有访问 PII 数据的权限。错误详情: ${JSON.stringify(errorData)}`
  236. );
  237. } else if (response.status === 400) {
  238. throw new Error(`RDT 请求参数错误: ${JSON.stringify(errorData)}`);
  239. } else if (response.status === 500) {
  240. throw new Error(
  241. `Amazon 内部服务器错误: ${JSON.stringify(errorData)}。建议稍后重试或检查 API 路径是否正确。`
  242. );
  243. }
  244. throw new Error(
  245. `RDT request failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`
  246. );
  247. }
  248. const data = JSON.parse(responseText) as RestrictedDataTokenResponse;
  249. console.log(`[SP-API] RDT Token 获取成功 (有效期: ${data.expiresIn} 秒)`);
  250. return data.restrictedDataToken;
  251. } catch (error: any) {
  252. console.error('[SP-API] 获取 RDT Token 失败:', error.message);
  253. throw error;
  254. }
  255. }
  256. /**
  257. * 发送 SP-API 请求
  258. */
  259. async request<T>(options: {
  260. method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  261. path: string;
  262. query?: Record<string, any>;
  263. body?: any;
  264. headers?: Record<string, string>;
  265. context?: {
  266. // 动态模式的可选上下文
  267. shopId: string;
  268. marketplaceId?: string;
  269. config: ShopConfig;
  270. };
  271. requiresRdt?: boolean; // 是否需要 RDT Token
  272. rdtDataElements?: string[]; // RDT 数据元素列表
  273. }): Promise<T> {
  274. const accessToken = await this.getAccessToken(options.context);
  275. // 确定用于端点选择的 MarketplaceId
  276. // 优先级: 上下文中显式指定 -> 查询参数中显式指定 -> 配置默认值 (如果是静态的)
  277. let marketplaceId = options.context?.marketplaceId;
  278. if (!marketplaceId && options.query) {
  279. // 尝试在查询参数中查找 marketplaceId (不区分大小写)
  280. const key = Object.keys(options.query).find(
  281. k => k.toLowerCase() === 'marketplaceids' || k.toLowerCase() === 'marketplaceid'
  282. );
  283. if (key) {
  284. const val = options.query[key];
  285. marketplaceId = Array.isArray(val) ? val[0] : (val as string)?.split(',')[0];
  286. }
  287. }
  288. const isSandbox = options.context?.config.sandbox || this.staticConfig?.sandbox;
  289. // 尝试从配置中获取 Region
  290. const region = options.context?.config.region || this.staticConfig?.region;
  291. const baseUrl = this.getBaseUrl(marketplaceId, isSandbox, region);
  292. // 构建 URL 对象以处理 query 参数
  293. // 注意:我们将手动处理 query 字符串构建以解决逗号编码问题
  294. let urlString =
  295. baseUrl.replace(/\/$/, '') +
  296. (options.path.startsWith('/') ? options.path : '/' + options.path);
  297. // 手动构建 query string
  298. const queryParts: string[] = [];
  299. if (options.query) {
  300. Object.entries(options.query).forEach(([key, value]) => {
  301. if (value !== undefined && value !== null) {
  302. // Special handling for nextToken/pageToken
  303. if ((key === 'nextToken' || key === 'pageToken') && typeof value === 'string') {
  304. try {
  305. // 如果已经是编码过的,先解码再编码,避免双重编码
  306. // 或者直接追加(视上游传递的数据而定)
  307. // 为了安全起见,我们假设它可能需要编码,但在 URLSearchParams 中会自动编码
  308. // 这里我们需要手动构建,所以我们要小心
  309. // 现在的策略是:如果看起来像 encoded,先 decode
  310. let valToUse = value;
  311. if (value.includes('%')) {
  312. try {
  313. valToUse = decodeURIComponent(value);
  314. } catch (e) { }
  315. }
  316. queryParts.push(`${key}=${encodeURIComponent(valToUse)}`);
  317. } catch (e) {
  318. queryParts.push(`${key}=${encodeURIComponent(value)}`);
  319. }
  320. } else {
  321. // 对于普通参数,我们使用 encodeURIComponent,但对于逗号我们需要特殊处理
  322. // Amazon SP-API 要求列表参数用逗号分隔,且逗号不能被编码
  323. // 例如: marketplaceIds=A,B -> marketplaceIds=A,B (not A%2CB)
  324. // 这里我们将整个 value 编码,然后把 %2C 替换回 ,
  325. const encodedVal = encodeURIComponent(String(value)).replace(/%2C/g, ',');
  326. queryParts.push(`${key}=${encodedVal}`);
  327. }
  328. }
  329. });
  330. }
  331. if (queryParts.length > 0) {
  332. urlString += (urlString.includes('?') ? '&' : '?') + queryParts.join('&');
  333. }
  334. // 获取 RDT Token (如果需要)
  335. let rdtToken: string | undefined;
  336. if (options.requiresRdt && options.context) {
  337. // 检查是否是不需要 RDT 的 API
  338. console.log(`[SP-API] 检测到需要 RDT Token 的 API: ${options.path}`);
  339. try {
  340. rdtToken = await this.getRestrictedDataToken(
  341. accessToken,
  342. baseUrl,
  343. options.method,
  344. options.path,
  345. options.rdtDataElements
  346. );
  347. console.log('[SP-API] RDT Token 获取成功');
  348. } catch (error: any) {
  349. console.error('[SP-API] 获取 RDT Token 失败,将使用普通 Access Token:', error.message);
  350. // 继续使用普通 Access Token,某些情况下可能仍然有效
  351. }
  352. }
  353. const headers: Record<string, string> = {
  354. 'x-amz-access-token': rdtToken || accessToken, // 优先使用 RDT Token
  355. 'Content-Type': 'application/json',
  356. 'User-Agent': 'fmode-amazon-sp-api/1.0',
  357. ...(options.headers || {})
  358. };
  359. if (rdtToken) {
  360. console.log('[SP-API] 使用 RDT Token 访问受限数据');
  361. }
  362. let retries = 0;
  363. const maxRetries = 3;
  364. while (retries < maxRetries) {
  365. try {
  366. console.log(`[SP-API] 请求: ${options.method} ${urlString}`, {
  367. query: options.query,
  368. shopId: options.context?.shopId
  369. });
  370. const fetchOptions: RequestInit = {
  371. method: options.method,
  372. headers: headers
  373. };
  374. if (options.body && options.method !== 'GET') {
  375. fetchOptions.body = JSON.stringify(options.body);
  376. }
  377. const response = await fetch(urlString, fetchOptions);
  378. if (!response.ok) {
  379. // 处理 429 Too Many Requests
  380. if (response.status === 429) {
  381. retries++;
  382. if (retries >= maxRetries)
  383. throw new Error(`Rate limit exceeded after ${maxRetries} retries`);
  384. // 等待 Retry-After 或指数退避
  385. const retryAfterHeader = response.headers.get('retry-after');
  386. const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 0;
  387. const delay = retryAfter ? retryAfter * 1000 : Math.pow(2, retries) * 1000;
  388. console.log(`[SP-API] 速率限制。${delay}ms 后重试... (尝试 ${retries}/${maxRetries})`);
  389. await new Promise(resolve => setTimeout(resolve, delay));
  390. continue;
  391. }
  392. // 处理 5xx 服务器错误
  393. if (response.status >= 500) {
  394. retries++;
  395. if (retries >= maxRetries)
  396. throw new Error(`Server error ${response.status} after ${maxRetries} retries`);
  397. const delay = Math.pow(2, retries) * 1000;
  398. console.log(
  399. `[SP-API] 服务器错误 ${response.status}。${delay}ms 后重试... (尝试 ${retries}/${maxRetries})`
  400. );
  401. await new Promise(resolve => setTimeout(resolve, delay));
  402. continue;
  403. }
  404. // 其他错误,解析响应体并抛出
  405. const errorText = await response.text();
  406. let errorData;
  407. try {
  408. errorData = JSON.parse(errorText);
  409. } catch (e) {
  410. errorData = errorText;
  411. }
  412. console.error(`[SP-API] 错误: ${options.method} ${urlString}`, {
  413. status: response.status,
  414. data: errorData,
  415. statusText: response.statusText
  416. });
  417. console.error(errorData)
  418. // 构造一个类似 Axios 错误的结构,或者直接抛出包含信息的 Error
  419. const error = new Error(
  420. `Request failed with status ${response.status}: ${JSON.stringify(errorData)}`
  421. );
  422. (error as any).response = {
  423. status: response.status,
  424. data: errorData,
  425. headers: response.headers
  426. };
  427. throw error;
  428. }
  429. // 部分 SP-API(例如无可用 Customer Feedback 时)会返回 204 或空响应体。
  430. const responseText = await response.text();
  431. if (!responseText.trim()) return {} as T;
  432. return JSON.parse(responseText) as T;
  433. } catch (error: any) {
  434. // 如果是我们在上面抛出的带有 response 的错误,说明已经是最终错误了
  435. if (error.response) {
  436. throw error;
  437. }
  438. // 网络错误或其他 fetch 错误
  439. console.error(`[SP-API] 网络/未知错误: ${error.message}`);
  440. throw error;
  441. }
  442. }
  443. throw new Error('超过最大重试次数');
  444. }
  445. }