import { SpApiConfig, LwaTokenResponse, ShopConfig, CreateRestrictedDataTokenRequest, RestrictedDataTokenResponse, RdtCacheItem } from './types.ts'; import { getEndpointByMarketplaceId, getEndpointByRegion } from './marketplace-helper.ts'; /** * Amazon Selling Partner API 基础客户端 * 处理 LWA 认证 (简化模式,无需 AWS V4 签名) * 支持 RDT (Restricted Data Token) 用于访问 PII 数据 */ export class SpApiClient { // 配置在构造函数中现在是可选的,但可以传递用于遗留/静态用法 // 然而,对于基于 Parse 的多租户用法,我们依赖于方法参数。 // 为了保持向后兼容性并支持新的动态模式,我们可以使用混合方法。 // 对于动态用法,我们将主要不使用 this.config。 private staticConfig?: SpApiConfig; // RDT Token 缓存 (内存缓存,按 shopId 和 resourceKey 存储) private rdtCache: Map = new Map(); constructor(config?: SpApiConfig) { this.staticConfig = config; } /** * 获取基础 URL */ private getBaseUrl(marketplaceId?: string, isSandbox?: boolean, region?: string): string { if (marketplaceId) { return getEndpointByMarketplaceId(marketplaceId, isSandbox); } // 如果提供了区域配置,则使用区域 if (region) { return getEndpointByRegion(region, isSandbox); } // 回退到静态配置 (遗留模式) if (this.staticConfig) { const REGION_ENDPOINTS: Record = { 'us-east-1': 'https://sellingpartnerapi-na.amazon.com', 'eu-west-1': 'https://sellingpartnerapi-eu.amazon.com', 'us-west-2': 'https://sellingpartnerapi-fe.amazon.com' }; const SANDBOX_ENDPOINTS: Record = { 'us-east-1': 'https://sandbox.sellingpartnerapi-na.amazon.com', 'eu-west-1': 'https://sandbox.sellingpartnerapi-eu.amazon.com', 'us-west-2': 'https://sandbox.sellingpartnerapi-fe.amazon.com' }; const endpoints = this.staticConfig.sandbox ? SANDBOX_ENDPOINTS : REGION_ENDPOINTS; return endpoints[this.staticConfig.region] || endpoints['us-east-1']; } throw new Error('无法确定 SP-API 端点,MarketplaceId 为必填项。'); } /** * 获取 LWA Access Token * 先从 Shop 表获取缓存的 token,如果不存在或过期则刷新 */ private async getAccessToken(context?: { shopId: string; config: ShopConfig }): Promise { // 1. 动态上下文模式 if (context) { const { shopId, config } = context; // 刷新 Token(内部会先检查 Shop 表的缓存) return this.refreshAccessToken( shopId, config.clientId, config.clientSecret, config.refreshToken ); } throw new Error('缺少 SpApiClient 配置。'); } /** * 刷新 Token 逻辑 * 1. 先从 Shop 表的 config.SpApiConfig.accessToken 获取 * 2. 检查是否过期(通过 config.SpApiConfig.accessTokenExpiresAt) * 3. 如果不存在或过期,则请求新的 token * 4. 将新 token 和过期时间(50分钟后)保存回 Shop 表 */ private async refreshAccessToken( shopId: string, clientId: string, clientSecret: string, refreshToken: string ): Promise { try { // 获取 Parse 实例 const Parse: any = (globalThis as any).Parse; if (!Parse) { console.warn('[SP-API] Parse SDK not available, will fetch new token'); } // 1. 尝试从 Shop 表获取缓存的 accessToken if (Parse) { try { const shopQuery = new Parse.Query('Shop'); const shop = await shopQuery.get(shopId, { useMasterKey: true }); if (shop) { const config = shop.get('config') || {}; const spApiConfig = config.SpApiConfig || {}; const cachedToken = spApiConfig.accessToken; const expiresAt = spApiConfig.accessTokenExpiresAt; // 2. 检查 token 是否存在且未过期 if (cachedToken && expiresAt) { const now = new Date(); const expiryDate = new Date(expiresAt); if (expiryDate > now) { const remainingMinutes = Math.floor( (expiryDate.getTime() - now.getTime()) / 1000 / 60 ); console.log(`[SP-API] 使用缓存的 Access Token (剩余 ${remainingMinutes} 分钟)`); return cachedToken; } else { console.log('[SP-API] 缓存的 Access Token 已过期,正在刷新...'); } } else { console.log('[SP-API] 未找到缓存的 Access Token,正在获取新的...'); } } } catch (error: any) { console.warn('[SP-API] 从 Shop 表获取 token 失败:', error.message); } } // 3. 请求新的 Access Token console.log('[SP-API] 正在刷新 Access Token...'); const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret // scope: 'sellingpartnerapi::migration​' }); const response = await fetch('https://api.amazon.com/auth/o2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Token refresh failed: ${response.status} ${response.statusText} - ${errorText}` ); } const data = (await response.json()) as LwaTokenResponse; console.log('[SP-API] 刷新 Access Token 成功。'); // 4. 保存新 token 到 Shop 表(设置50分钟后过期) if (Parse) { try { const shopQuery = new Parse.Query('Shop'); const shop = await shopQuery.get(shopId, { useMasterKey: true }); if (shop) { const config = shop.get('config') || {}; const spApiConfig = config.SpApiConfig || {}; // 设置过期时间为50分钟后 const expiresAt = new Date(); expiresAt.setMinutes(expiresAt.getMinutes() + 50); // 更新 SpApiConfig spApiConfig.accessToken = data.access_token; spApiConfig.accessTokenExpiresAt = expiresAt.toISOString(); config.SpApiConfig = spApiConfig; shop.set('config', config); await shop.save(null, { useMasterKey: true }); console.log( `[SP-API] Access Token 已保存到 Shop 表 (过期时间: ${expiresAt.toISOString()})` ); } } catch (error: any) { console.error('[SP-API] 保存 token 到 Shop 表失败:', error.message); // 不抛出错误,因为 token 已经获取成功 } } return data.access_token; } catch (error) { console.error('[SP-API] 刷新 token 失败:', error); throw error; } } /** * 获取 RDT Token (带缓存) * @param shopId 店铺ID * @param accessToken LWA Access Token * @param method HTTP 方法 * @param path API 路径 * @param dataElements 可选的数据元素列表 * @param marketplaceId 市场ID * @param isSandbox 是否沙箱环境 * @param region AWS 区域 */ private async getRestrictedDataToken( accessToken: string, baseUrl: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, dataElements?: string[] ): Promise { // 请求新的 RDT Token console.log(`[SP-API] 正在获取 RDT Token for ${method} ${path}...`); const rdtEndpoint = `${baseUrl}/tokens/2021-03-01/restrictedDataToken`; // 确保路径格式正确 const cleanPath = path.startsWith('/') ? path : `/${path}`; const requestBody: CreateRestrictedDataTokenRequest = { restrictedResources: [ { method, path: cleanPath } ] }; console.log(`[SP-API] RDT 请求详情:`, { endpoint: rdtEndpoint, requestBody: JSON.stringify(requestBody, null, 2), accessToken: accessToken.substring(0, 20) + '...' }); try { const response = await fetch(rdtEndpoint, { method: 'POST', headers: { 'x-amz-access-token': accessToken, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const responseText = await response.text(); console.log(`[SP-API] RDT 响应状态: ${response.status}`, responseText); if (!response.ok) { let errorData; try { errorData = JSON.parse(responseText); } catch (e) { errorData = responseText; } // 特殊处理常见错误 if (response.status === 403) { throw new Error( `RDT 权限不足: 应用可能没有访问 PII 数据的权限。错误详情: ${JSON.stringify(errorData)}` ); } else if (response.status === 400) { throw new Error(`RDT 请求参数错误: ${JSON.stringify(errorData)}`); } else if (response.status === 500) { throw new Error( `Amazon 内部服务器错误: ${JSON.stringify(errorData)}。建议稍后重试或检查 API 路径是否正确。` ); } throw new Error( `RDT request failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` ); } const data = JSON.parse(responseText) as RestrictedDataTokenResponse; console.log(`[SP-API] RDT Token 获取成功 (有效期: ${data.expiresIn} 秒)`); return data.restrictedDataToken; } catch (error: any) { console.error('[SP-API] 获取 RDT Token 失败:', error.message); throw error; } } /** * 发送 SP-API 请求 */ async request(options: { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; query?: Record; body?: any; headers?: Record; context?: { // 动态模式的可选上下文 shopId: string; marketplaceId?: string; config: ShopConfig; }; requiresRdt?: boolean; // 是否需要 RDT Token rdtDataElements?: string[]; // RDT 数据元素列表 }): Promise { const accessToken = await this.getAccessToken(options.context); // 确定用于端点选择的 MarketplaceId // 优先级: 上下文中显式指定 -> 查询参数中显式指定 -> 配置默认值 (如果是静态的) let marketplaceId = options.context?.marketplaceId; if (!marketplaceId && options.query) { // 尝试在查询参数中查找 marketplaceId (不区分大小写) const key = Object.keys(options.query).find( k => k.toLowerCase() === 'marketplaceids' || k.toLowerCase() === 'marketplaceid' ); if (key) { const val = options.query[key]; marketplaceId = Array.isArray(val) ? val[0] : (val as string)?.split(',')[0]; } } const isSandbox = options.context?.config.sandbox || this.staticConfig?.sandbox; // 尝试从配置中获取 Region const region = options.context?.config.region || this.staticConfig?.region; const baseUrl = this.getBaseUrl(marketplaceId, isSandbox, region); // 构建 URL 对象以处理 query 参数 // 注意:我们将手动处理 query 字符串构建以解决逗号编码问题 let urlString = baseUrl.replace(/\/$/, '') + (options.path.startsWith('/') ? options.path : '/' + options.path); // 手动构建 query string const queryParts: string[] = []; if (options.query) { Object.entries(options.query).forEach(([key, value]) => { if (value !== undefined && value !== null) { // Special handling for nextToken/pageToken if ((key === 'nextToken' || key === 'pageToken') && typeof value === 'string') { try { // 如果已经是编码过的,先解码再编码,避免双重编码 // 或者直接追加(视上游传递的数据而定) // 为了安全起见,我们假设它可能需要编码,但在 URLSearchParams 中会自动编码 // 这里我们需要手动构建,所以我们要小心 // 现在的策略是:如果看起来像 encoded,先 decode let valToUse = value; if (value.includes('%')) { try { valToUse = decodeURIComponent(value); } catch (e) { } } queryParts.push(`${key}=${encodeURIComponent(valToUse)}`); } catch (e) { queryParts.push(`${key}=${encodeURIComponent(value)}`); } } else { // 对于普通参数,我们使用 encodeURIComponent,但对于逗号我们需要特殊处理 // Amazon SP-API 要求列表参数用逗号分隔,且逗号不能被编码 // 例如: marketplaceIds=A,B -> marketplaceIds=A,B (not A%2CB) // 这里我们将整个 value 编码,然后把 %2C 替换回 , const encodedVal = encodeURIComponent(String(value)).replace(/%2C/g, ','); queryParts.push(`${key}=${encodedVal}`); } } }); } if (queryParts.length > 0) { urlString += (urlString.includes('?') ? '&' : '?') + queryParts.join('&'); } // 获取 RDT Token (如果需要) let rdtToken: string | undefined; if (options.requiresRdt && options.context) { // 检查是否是不需要 RDT 的 API console.log(`[SP-API] 检测到需要 RDT Token 的 API: ${options.path}`); try { rdtToken = await this.getRestrictedDataToken( accessToken, baseUrl, options.method, options.path, options.rdtDataElements ); console.log('[SP-API] RDT Token 获取成功'); } catch (error: any) { console.error('[SP-API] 获取 RDT Token 失败,将使用普通 Access Token:', error.message); // 继续使用普通 Access Token,某些情况下可能仍然有效 } } const headers: Record = { 'x-amz-access-token': rdtToken || accessToken, // 优先使用 RDT Token 'Content-Type': 'application/json', 'User-Agent': 'fmode-amazon-sp-api/1.0', ...(options.headers || {}) }; if (rdtToken) { console.log('[SP-API] 使用 RDT Token 访问受限数据'); } let retries = 0; const maxRetries = 3; while (retries < maxRetries) { try { console.log(`[SP-API] 请求: ${options.method} ${urlString}`, { query: options.query, shopId: options.context?.shopId }); const fetchOptions: RequestInit = { method: options.method, headers: headers }; if (options.body && options.method !== 'GET') { fetchOptions.body = JSON.stringify(options.body); } const response = await fetch(urlString, fetchOptions); if (!response.ok) { // 处理 429 Too Many Requests if (response.status === 429) { retries++; if (retries >= maxRetries) throw new Error(`Rate limit exceeded after ${maxRetries} retries`); // 等待 Retry-After 或指数退避 const retryAfterHeader = response.headers.get('retry-after'); const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 0; const delay = retryAfter ? retryAfter * 1000 : Math.pow(2, retries) * 1000; console.log(`[SP-API] 速率限制。${delay}ms 后重试... (尝试 ${retries}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); continue; } // 处理 5xx 服务器错误 if (response.status >= 500) { retries++; if (retries >= maxRetries) throw new Error(`Server error ${response.status} after ${maxRetries} retries`); const delay = Math.pow(2, retries) * 1000; console.log( `[SP-API] 服务器错误 ${response.status}。${delay}ms 后重试... (尝试 ${retries}/${maxRetries})` ); await new Promise(resolve => setTimeout(resolve, delay)); continue; } // 其他错误,解析响应体并抛出 const errorText = await response.text(); let errorData; try { errorData = JSON.parse(errorText); } catch (e) { errorData = errorText; } console.error(`[SP-API] 错误: ${options.method} ${urlString}`, { status: response.status, data: errorData, statusText: response.statusText }); console.error(errorData) // 构造一个类似 Axios 错误的结构,或者直接抛出包含信息的 Error const error = new Error( `Request failed with status ${response.status}: ${JSON.stringify(errorData)}` ); (error as any).response = { status: response.status, data: errorData, headers: response.headers }; throw error; } // 部分 SP-API(例如无可用 Customer Feedback 时)会返回 204 或空响应体。 const responseText = await response.text(); if (!responseText.trim()) return {} as T; return JSON.parse(responseText) as T; } catch (error: any) { // 如果是我们在上面抛出的带有 response 的错误,说明已经是最终错误了 if (error.response) { throw error; } // 网络错误或其他 fetch 错误 console.error(`[SP-API] 网络/未知错误: ${error.message}`); throw error; } } throw new Error('超过最大重试次数'); } }