| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- 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<string, RdtCacheItem> = 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<string, string> = {
- '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<string, string> = {
- '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<string> {
- // 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<string> {
- 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<string> {
- // 请求新的 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<T>(options: {
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
- path: string;
- query?: Record<string, any>;
- body?: any;
- headers?: Record<string, string>;
- context?: {
- // 动态模式的可选上下文
- shopId: string;
- marketplaceId?: string;
- config: ShopConfig;
- };
- requiresRdt?: boolean; // 是否需要 RDT Token
- rdtDataElements?: string[]; // RDT 数据元素列表
- }): Promise<T> {
- 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<string, string> = {
- '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('超过最大重试次数');
- }
- }
|