new-sp-api-schedule.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. /**
  2. * Amazon SP-API 数据采集调度器(新版)
  3. *
  4. * 相较旧版 sp-api-schedule.ts,本版将四个采集步骤拆分为独立的公共方法:
  5. * - collectListings 采集 Listing 数据
  6. * - collectOrders 采集订单数据
  7. * - collectReports 采集报表数据
  8. * - parseReports 解析/检查未完成报表
  9. *
  10. * 每个方法均接受可选的 ProgressCallback,适合通过 SSE 向前端推送实时进度。
  11. * 定时任务(每天凌晨 1 点)调用上述方法时不传 callback,仅打印日志。
  12. *
  13. * 使用方式:
  14. * import { newSpApiScheduler } from './new-sp-api-schedule.ts';
  15. * // 仅手动触发,不启动 cron(由旧版 spApiScheduler 维护 cron)
  16. * await newSpApiScheduler.collectListings(shopId, progressCallback);
  17. */
  18. import nodeCron from 'npm:node-cron';
  19. // ===== 调度器类 =====
  20. class NewSpApiScheduler {
  21. #cronTask = null;
  22. #reportCronTask = null;
  23. #lastRunTime = null;
  24. // ========== 私有工具 ==========
  25. #delay(ms) {
  26. return new Promise(resolve => setTimeout(resolve, ms));
  27. }
  28. #calculateNextRunTime() {
  29. const now = new Date();
  30. const next = new Date(now);
  31. next.setHours(1, 0, 0, 0);
  32. if (next <= now) next.setDate(next.getDate() + 1);
  33. return next;
  34. }
  35. /**
  36. * 查询活跃的 Amazon 店铺列表
  37. * @param shopId 若传入则只返回该店铺
  38. */
  39. async #getActiveAmazonShops(shopId) {
  40. const Parse = globalThis.Parse;
  41. const query = new Parse.Query('Shop');
  42. query.equalTo('platform', 'amazon');
  43. query.equalTo('status', 'active');
  44. if (shopId) query.equalTo('objectId', shopId);
  45. query.limit(1000);
  46. return await query.find();
  47. }
  48. // ========== 单店铺内部实现 ==========
  49. /**
  50. * 执行单个店铺的 Listing 采集
  51. * 通过 /api/amazon/forward 调用 SP-API,内部 cleanListings 处理全部分页
  52. */
  53. async #doCollectListings(shopId, config, marketplaceId, progress) {
  54. const sellerId = config.SpApiConfig.sellerID;
  55. marketplaceId = marketplaceId || 'ATVPDKIKX0DER';
  56. const Parse = globalThis.Parse;
  57. let lastUpdatedAfter;
  58. try {
  59. const shopPointer = Parse.Object.extend('Shop').createWithoutData(shopId);
  60. const query = new Parse.Query('Listing');
  61. query.equalTo('shop', shopPointer);
  62. query.descending('lastUpdatedDate');
  63. query.limit(1);
  64. const latestListing = await query.first({ useMasterKey: true });
  65. if (latestListing && latestListing.get('lastUpdatedDate')) {
  66. lastUpdatedAfter = latestListing.get('lastUpdatedDate').toISOString();
  67. progress?.({ type: 'info', message: `增量采集,使用最新 lastUpdatedDate: ${lastUpdatedAfter}` });
  68. } else {
  69. const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
  70. lastUpdatedAfter = oneYearAgo.toISOString();
  71. progress?.({ type: 'info', message: `无历史数据,从一年前开始全量采集: ${lastUpdatedAfter}` });
  72. }
  73. } catch (error) {
  74. const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
  75. lastUpdatedAfter = oneYearAgo.toISOString();
  76. progress?.({ type: 'warn', message: `查询 Listing 历史失败,默认使用一年前: ${lastUpdatedAfter}` });
  77. }
  78. const path = `/listings/2021-08-01/items/${sellerId}?marketplaceIds=${marketplaceId}&includedData=summaries,attributes,issues&withStatus=BUYABLE,DISCOVERABLE&sortBy=lastUpdatedDate&sortOrder=ASC&pageSize=10&lastUpdatedAfter=${lastUpdatedAfter}`;
  79. progress?.({ type: 'info', message: '正在调用亚马逊 Listing API(cleanListings 内部处理分页,请耐心等待)...' });
  80. console.log(`[New SP-API Scheduler] 店铺 ${shopId} 请求 Listing API,path: ${path}`);
  81. const response = await fetch('http://localhost:3000/api/amazon/forward', {
  82. method: 'POST',
  83. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  84. body: JSON.stringify({ path, method: 'GET', functionName: 'cleanListings' })
  85. });
  86. const result = await response.json();
  87. if (!result.success) {
  88. throw new Error(result.message || 'Listing 采集失败');
  89. }
  90. const processed = result.data?.processed || 0;
  91. console.log(`[New SP-API Scheduler] 店铺 ${shopId} Listing 采集完成: ${processed} 条记录`);
  92. return { processed };
  93. }
  94. /**
  95. * 执行单个店铺的订单采集(增量)
  96. * 通过 /api/amazon/forward 调用 SP-API,内部 cleanOrders 处理全部分页
  97. */
  98. async #doCollectOrders(shopId, config, marketplaceId, progress) {
  99. marketplaceId = marketplaceId || 'ATVPDKIKX0DER';
  100. const Parse = globalThis.Parse;
  101. let createdAfter;
  102. try {
  103. const shopPointer = Parse.Object.extend('Shop').createWithoutData(shopId);
  104. const query = new Parse.Query('Order');
  105. query.equalTo('shop', shopPointer);
  106. query.descending('orderDate');
  107. query.limit(1);
  108. const latestOrder = await query.first({ useMasterKey: true });
  109. if (latestOrder && latestOrder.get('orderDate')) {
  110. createdAfter = latestOrder.get('orderDate').toISOString();
  111. progress?.({ type: 'info', message: `增量采集,使用最新 orderDate: ${createdAfter}` });
  112. } else {
  113. const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
  114. createdAfter = ninetyDaysAgo.toISOString();
  115. progress?.({ type: 'info', message: `无历史数据,从90天前开始采集: ${createdAfter}` });
  116. }
  117. } catch (error) {
  118. const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
  119. createdAfter = ninetyDaysAgo.toISOString();
  120. progress?.({ type: 'warn', message: `查询 Order 历史失败,默认使用90天前: ${createdAfter}` });
  121. }
  122. progress?.({ type: 'info', message: '正在调用亚马逊 Orders API(cleanOrders 内部处理分页,请耐心等待)...' });
  123. console.log(`[New SP-API Scheduler] 店铺 ${shopId} 请求 Orders API,createdAfter: ${createdAfter}`);
  124. const response = await fetch('http://localhost:3000/api/amazon/forward', {
  125. method: 'POST',
  126. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  127. body: JSON.stringify({
  128. path: `/orders/v0/orders?MarketplaceIds=${marketplaceId}&CreatedAfter=${createdAfter}`,
  129. method: 'GET',
  130. functionName: 'cleanOrders'
  131. })
  132. });
  133. const result = await response.json();
  134. if (!result.success) {
  135. throw new Error(result.message || '订单采集失败');
  136. }
  137. const processed = result.data?.processed || 0;
  138. console.log(`[New SP-API Scheduler] 店铺 ${shopId} 订单采集完成: ${processed} 条记录`);
  139. return { processed };
  140. }
  141. /**
  142. * 执行单个店铺的报表创建与保存
  143. */
  144. async #doCollectReports(shopId, config, marketplaceId, progress) {
  145. const Parse = globalThis.Parse;
  146. const reportTypes = ['GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA'];
  147. for (const reportType of reportTypes) {
  148. try {
  149. // 1. 查询上次 dataEndTime 作为本次起点
  150. let dataStartTime;
  151. try {
  152. const q = new Parse.Query('Reports');
  153. q.equalTo('shop', shopId);
  154. q.equalTo('reportType', reportType);
  155. q.descending('dataEndTime');
  156. q.limit(1);
  157. const last = await q.first({ useMasterKey: true });
  158. dataStartTime = last?.get('dataEndTime')
  159. ? last.get('dataEndTime').toISOString()
  160. : new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
  161. } catch (e) {
  162. dataStartTime = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
  163. }
  164. const dataEndTime = new Date().toISOString();
  165. progress?.({ type: 'info', message: `创建报表 ${reportType},时间范围: ${dataStartTime} ~ ${dataEndTime}` });
  166. console.log(`[New SP-API Scheduler] 店铺 ${shopId} 创建报表 ${reportType}, range: ${dataStartTime} ~ ${dataEndTime}`);
  167. // 2. 创建报表
  168. const createRes = await fetch('http://localhost:3000/api/amazon/forward', {
  169. method: 'POST',
  170. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  171. body: JSON.stringify({
  172. path: '/reports/2021-06-30/reports',
  173. method: 'POST',
  174. body: { reportType, marketplaceIds: [marketplaceId], dataStartTime, dataEndTime }
  175. })
  176. });
  177. const createResult = await createRes.json();
  178. const reportId = createResult?.data?.reportId;
  179. if (!reportId) {
  180. progress?.({ type: 'error', message: `创建报表失败 ${reportType}: ${JSON.stringify(createResult)}` });
  181. console.error(`[New SP-API Scheduler] 创建报表失败 ${reportType}:`, JSON.stringify(createResult));
  182. continue;
  183. }
  184. progress?.({ type: 'info', message: `报表已创建 reportId: ${reportId},等待查询状态...` });
  185. console.log(`[New SP-API Scheduler] 报表已创建, reportId: ${reportId}`);
  186. await this.#delay(3000);
  187. // 3. 查询报表状态
  188. const getRes = await fetch('http://localhost:3000/api/amazon/forward', {
  189. method: 'POST',
  190. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  191. body: JSON.stringify({
  192. path: `/reports/2021-06-30/reports/${reportId}`,
  193. method: 'GET'
  194. })
  195. });
  196. const reportData = await getRes.json();
  197. const reportInfo = reportData?.data || {};
  198. // 4. 存入 Reports 表
  199. const ReportObj = Parse.Object.extend('Reports');
  200. const rpt = new ReportObj();
  201. rpt.set('shop', { __type: 'Pointer', className: 'Shop', objectId: shopId });
  202. rpt.set('reportId', reportId);
  203. rpt.set('reportType', reportType);
  204. rpt.set('marketplaceIds', reportInfo.marketplaceIds || []);
  205. rpt.set('dataStartTime', reportInfo.dataStartTime ? new Date(reportInfo.dataStartTime) : null);
  206. rpt.set('dataEndTime', reportInfo.dataEndTime ? new Date(reportInfo.dataEndTime) : null);
  207. rpt.set('createdTime', reportInfo.createdTime ? new Date(reportInfo.createdTime) : null);
  208. rpt.set('processingStartTime', reportInfo.processingStartTime ? new Date(reportInfo.processingStartTime) : null);
  209. rpt.set('processingEndTime', reportInfo.processingEndTime ? new Date(reportInfo.processingEndTime) : null);
  210. rpt.set('processingStatus', reportInfo.processingStatus || 'IN_QUEUE');
  211. rpt.set('reportDocumentId', reportInfo.reportDocumentId || null);
  212. rpt.set('isParsed', false);
  213. await rpt.save(null, { useMasterKey: true });
  214. progress?.({ type: 'success', message: `报表 ${reportType} 已保存,processingStatus: ${reportInfo.processingStatus || 'IN_QUEUE'}` });
  215. console.log(`[New SP-API Scheduler] 报表已保存, status: ${reportInfo.processingStatus}`);
  216. await this.#delay(2000);
  217. } catch (error) {
  218. progress?.({ type: 'error', message: `报表 ${reportType} 处理失败: ${error.message}` });
  219. console.error(`[New SP-API Scheduler] 报表 ${reportType} 处理失败: ${error.message}`);
  220. }
  221. }
  222. }
  223. /**
  224. * 检查并更新 processingStatus 不为 DONE 的报表
  225. */
  226. async #doCheckPendingReports(shopId, progress) {
  227. const Parse = globalThis.Parse;
  228. const query = new Parse.Query('Reports');
  229. query.notEqualTo('processingStatus', 'DONE');
  230. if (shopId) query.equalTo('shop', shopId);
  231. query.limit(1000);
  232. const pendingReports = await query.find({ useMasterKey: true });
  233. if (pendingReports.length === 0) {
  234. progress?.({ type: 'info', message: '没有状态未完成的报表,跳过状态更新' });
  235. console.log('[New SP-API Scheduler] 没有未完成的报表');
  236. return;
  237. }
  238. progress?.({ type: 'info', message: `发现 ${pendingReports.length} 个未完成报表,逐一查询最新状态...` });
  239. console.log(`[New SP-API Scheduler] 发现 ${pendingReports.length} 个未完成报表`);
  240. for (const report of pendingReports) {
  241. try {
  242. const reportId = report.get('reportId');
  243. const sid = report.get('shop').id;
  244. if (!reportId || !sid) continue;
  245. const getRes = await fetch('http://localhost:3000/api/amazon/forward', {
  246. method: 'POST',
  247. headers: { 'Content-Type': 'application/json', 'shop-objectid': sid },
  248. body: JSON.stringify({ path: `/reports/2021-06-30/reports/${reportId}`, method: 'GET' })
  249. });
  250. const reportData = await getRes.json();
  251. const reportInfo = reportData?.data || {};
  252. const newStatus = reportInfo.processingStatus || report.get('processingStatus');
  253. report.set('processingStatus', newStatus);
  254. if (reportInfo.reportDocumentId) report.set('reportDocumentId', reportInfo.reportDocumentId);
  255. await report.save(null, { useMasterKey: true });
  256. progress?.({ type: 'info', message: `报表 ${reportId} 状态更新为: ${newStatus}` });
  257. console.log(`[New SP-API Scheduler] 报表 ${reportId} 状态更新为: ${newStatus}`);
  258. await this.#delay(10000);
  259. } catch (error) {
  260. progress?.({ type: 'error', message: `更新报表状态失败: ${error.message}` });
  261. console.error(`[New SP-API Scheduler] 更新报表状态失败: ${error.message}`);
  262. }
  263. }
  264. }
  265. /**
  266. * 解析 isParsed=false & processingStatus=DONE 的报表
  267. */
  268. async #doProcessUnparsedReports(shopId, progress) {
  269. const Parse = globalThis.Parse;
  270. const query = new Parse.Query('Reports');
  271. query.equalTo('shop', shopId);
  272. query.equalTo('isParsed', false);
  273. query.equalTo('processingStatus', 'DONE');
  274. query.limit(1000);
  275. const unparsedReports = await query.find();
  276. if (unparsedReports.length === 0) {
  277. progress?.({ type: 'info', message: '没有待解析的报表(状态 DONE 且未解析)' });
  278. console.log(`[New SP-API Scheduler] 店铺 ${shopId} 没有待解析的报表`);
  279. return { processed: 0 };
  280. }
  281. progress?.({ type: 'info', message: `发现 ${unparsedReports.length} 个待解析报表` });
  282. console.log(`[New SP-API Scheduler] 发现 ${unparsedReports.length} 个待解析报表`);
  283. let totalProcessed = 0;
  284. for (const report of unparsedReports) {
  285. try {
  286. const reportDocumentId = report.get('reportDocumentId');
  287. const reportType = report.get('reportType');
  288. if (!reportDocumentId) {
  289. progress?.({ type: 'warn', message: `报表 ${report.id} 缺少 reportDocumentId,跳过` });
  290. continue;
  291. }
  292. progress?.({ type: 'info', message: `正在解析报表 ${reportDocumentId} (${reportType})` });
  293. console.log(`[New SP-API Scheduler] 解析报表 ${reportDocumentId} (${reportType})`);
  294. const response = await fetch('http://localhost:3000/api/amazon/processReport', {
  295. method: 'POST',
  296. headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
  297. body: JSON.stringify({ reportDocumentId, reportType, shopId })
  298. });
  299. const result = await response.json();
  300. if (result.success) {
  301. const parsed = result.data?.processResult?.processed || 0;
  302. report.set('isParsed', true);
  303. report.set('parsedAt', new Date());
  304. report.set('parsedRecords', parsed);
  305. await report.save(null, { useMasterKey: true });
  306. totalProcessed += parsed;
  307. progress?.({ type: 'success', message: `报表 ${reportDocumentId} 解析完成: ${parsed} 条记录`, processed: parsed });
  308. console.log(`[New SP-API Scheduler] 报表 ${reportDocumentId} 解析完成: ${parsed} 条记录`);
  309. } else {
  310. progress?.({ type: 'warn', message: `报表 ${reportDocumentId} 解析未成功: ${result.message}` });
  311. console.warn(`[New SP-API Scheduler] 报表 ${reportDocumentId} 解析未成功: ${result.message}`);
  312. }
  313. await this.#delay(10000);
  314. } catch (error) {
  315. progress?.({ type: 'error', message: `报表解析异常: ${error.message}` });
  316. console.error(`[New SP-API Scheduler] 报表解析异常: ${error.message}`);
  317. }
  318. }
  319. return { processed: totalProcessed };
  320. }
  321. // ========== 构造函数 ==========
  322. constructor() {
  323. console.log('[New SP-API Scheduler] 初始化 Amazon SP-API 数据采集调度器(新版)');
  324. }
  325. // ========== 公共采集方法 ==========
  326. /**
  327. * 采集所有活跃店铺(或指定店铺)的 Listing 数据
  328. * @param shopId 可选,不传则采集全部活跃店铺
  329. * @param progress 可选进度回调,适合 SSE 实时推送
  330. */
  331. async collectListings(shopId, progress) {
  332. const shops = await this.#getActiveAmazonShops(shopId);
  333. if (shops.length === 0) {
  334. const msg = shopId ? `未找到店铺 ${shopId} 或店铺未激活` : '没有找到活跃的 Amazon 店铺';
  335. progress?.({ type: 'warn', message: msg });
  336. console.warn(`[New SP-API Scheduler] ${msg}`);
  337. return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
  338. }
  339. let totalProcessed = 0;
  340. let errorCount = 0;
  341. const errors = [];
  342. for (const shop of shops) {
  343. const sid = shop.id;
  344. const shopName = shop.get('name');
  345. const config = shop.get('config');
  346. const marketplaceId = shop.get('marketplaceId') || 'ATVPDKIKX0DER';
  347. if (!config || !config.SpApiConfig) {
  348. const msg = `店铺【${shopName}】(${sid}) 缺少 SpApiConfig 配置,跳过`;
  349. progress?.({ type: 'error', message: msg });
  350. console.error(`[New SP-API Scheduler] ${msg}`);
  351. errors.push(msg);
  352. errorCount++;
  353. continue;
  354. }
  355. progress?.({ type: 'info', message: `开始采集店铺【${shopName}】的 Listing 数据` });
  356. console.log(`[New SP-API Scheduler] 开始采集店铺 ${shopName} (${sid}) 的 Listing 数据`);
  357. try {
  358. const result = await this.#doCollectListings(sid, config, marketplaceId, progress);
  359. progress?.({ type: 'success', message: `店铺【${shopName}】Listing 采集完成`, processed: result.processed });
  360. totalProcessed += result.processed;
  361. await this.#delay(2000);
  362. } catch (error) {
  363. const msg = `店铺【${shopName}】Listing 采集失败: ${error.message}`;
  364. progress?.({ type: 'error', message: msg });
  365. console.error(`[New SP-API Scheduler] ${msg}`);
  366. errors.push(msg);
  367. errorCount++;
  368. }
  369. }
  370. const message = `Listing 采集完成: 成功 ${shops.length - errorCount}/${shops.length} 个店铺,共 ${totalProcessed} 条记录`;
  371. return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
  372. }
  373. /**
  374. * 采集所有活跃店铺(或指定店铺)的订单数据(增量)
  375. * @param shopId 可选
  376. * @param progress 可选进度回调
  377. */
  378. async collectOrders(shopId, progress) {
  379. const shops = await this.#getActiveAmazonShops(shopId);
  380. if (shops.length === 0) {
  381. const msg = shopId ? `未找到店铺 ${shopId} 或店铺未激活` : '没有找到活跃的 Amazon 店铺';
  382. progress?.({ type: 'warn', message: msg });
  383. console.warn(`[New SP-API Scheduler] ${msg}`);
  384. return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
  385. }
  386. let totalProcessed = 0;
  387. let errorCount = 0;
  388. const errors = [];
  389. for (const shop of shops) {
  390. const sid = shop.id;
  391. const shopName = shop.get('name');
  392. const config = shop.get('config');
  393. const marketplaceId = shop.get('marketplaceId') || 'ATVPDKIKX0DER';
  394. if (!config || !config.SpApiConfig) {
  395. const msg = `店铺【${shopName}】(${sid}) 缺少 SpApiConfig 配置,跳过`;
  396. progress?.({ type: 'error', message: msg });
  397. console.error(`[New SP-API Scheduler] ${msg}`);
  398. errors.push(msg);
  399. errorCount++;
  400. continue;
  401. }
  402. progress?.({ type: 'info', message: `开始采集店铺【${shopName}】的订单数据` });
  403. console.log(`[New SP-API Scheduler] 开始采集店铺 ${shopName} (${sid}) 的订单数据`);
  404. try {
  405. const result = await this.#doCollectOrders(sid, config, marketplaceId, progress);
  406. progress?.({ type: 'success', message: `店铺【${shopName}】订单采集完成`, processed: result.processed });
  407. totalProcessed += result.processed;
  408. await this.#delay(2000);
  409. } catch (error) {
  410. const msg = `店铺【${shopName}】订单采集失败: ${error.message}`;
  411. progress?.({ type: 'error', message: msg });
  412. console.error(`[New SP-API Scheduler] ${msg}`);
  413. errors.push(msg);
  414. errorCount++;
  415. }
  416. }
  417. const message = `订单采集完成: 成功 ${shops.length - errorCount}/${shops.length} 个店铺,共 ${totalProcessed} 条记录`;
  418. return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
  419. }
  420. /**
  421. * 为所有活跃店铺(或指定店铺)创建并保存报表
  422. * @param shopId 可选
  423. * @param progress 可选进度回调
  424. */
  425. async collectReports(shopId, progress) {
  426. const shops = await this.#getActiveAmazonShops(shopId);
  427. if (shops.length === 0) {
  428. const msg = shopId ? `未找到店铺 ${shopId} 或店铺未激活` : '没有找到活跃的 Amazon 店铺';
  429. progress?.({ type: 'warn', message: msg });
  430. console.warn(`[New SP-API Scheduler] ${msg}`);
  431. return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
  432. }
  433. let successCount = 0;
  434. let errorCount = 0;
  435. const errors = [];
  436. for (const shop of shops) {
  437. const sid = shop.id;
  438. const shopName = shop.get('name');
  439. const config = shop.get('config');
  440. const marketplaceId = shop.get('marketplaceId') || 'ATVPDKIKX0DER';
  441. if (!config || !config.SpApiConfig) {
  442. const msg = `店铺【${shopName}】(${sid}) 缺少 SpApiConfig 配置,跳过`;
  443. progress?.({ type: 'error', message: msg });
  444. console.error(`[New SP-API Scheduler] ${msg}`);
  445. errors.push(msg);
  446. errorCount++;
  447. continue;
  448. }
  449. progress?.({ type: 'info', message: `开始采集店铺【${shopName}】的报表数据` });
  450. console.log(`[New SP-API Scheduler] 开始采集店铺 ${shopName} (${sid}) 的报表数据`);
  451. try {
  452. await this.#doCollectReports(sid, config, marketplaceId, progress);
  453. progress?.({ type: 'success', message: `店铺【${shopName}】报表采集完成` });
  454. successCount++;
  455. await this.#delay(2000);
  456. } catch (error) {
  457. const msg = `店铺【${shopName}】报表采集失败: ${error.message}`;
  458. progress?.({ type: 'error', message: msg });
  459. console.error(`[New SP-API Scheduler] ${msg}`);
  460. errors.push(msg);
  461. errorCount++;
  462. }
  463. }
  464. const message = `报表采集完成: 成功 ${successCount}/${shops.length} 个店铺`;
  465. return { success: errorCount === 0, message, processed: successCount, errorCount, errors };
  466. }
  467. /**
  468. * 检查未完成报表状态并解析已完成的报表
  469. * @param shopId 可选
  470. * @param progress 可选进度回调
  471. */
  472. async parseReports(shopId, progress) {
  473. const shops = await this.#getActiveAmazonShops(shopId);
  474. if (shops.length === 0) {
  475. const msg = shopId ? `未找到店铺 ${shopId} 或店铺未激活` : '没有找到活跃的 Amazon 店铺';
  476. progress?.({ type: 'warn', message: msg });
  477. console.warn(`[New SP-API Scheduler] ${msg}`);
  478. return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
  479. }
  480. // 先全局检查并更新 pending 状态
  481. progress?.({ type: 'info', message: '检查所有未完成报表的最新状态...' });
  482. try {
  483. await this.#doCheckPendingReports(shopId, progress);
  484. } catch (error) {
  485. progress?.({ type: 'warn', message: `检查未完成报表状态时出错: ${error.message}` });
  486. console.warn(`[New SP-API Scheduler] 检查未完成报表状态出错: ${error.message}`);
  487. }
  488. let totalProcessed = 0;
  489. let errorCount = 0;
  490. const errors = [];
  491. for (const shop of shops) {
  492. const sid = shop.id;
  493. const shopName = shop.get('name');
  494. progress?.({ type: 'info', message: `开始解析店铺【${shopName}】的已完成报表` });
  495. console.log(`[New SP-API Scheduler] 开始解析店铺 ${shopName} (${sid}) 的已完成报表`);
  496. try {
  497. const result = await this.#doProcessUnparsedReports(sid, progress);
  498. progress?.({ type: 'success', message: `店铺【${shopName}】报表解析完成: ${result.processed} 条记录`, processed: result.processed });
  499. totalProcessed += result.processed;
  500. await this.#delay(2000);
  501. } catch (error) {
  502. const msg = `店铺【${shopName}】报表解析失败: ${error.message}`;
  503. progress?.({ type: 'error', message: msg });
  504. console.error(`[New SP-API Scheduler] ${msg}`);
  505. errors.push(msg);
  506. errorCount++;
  507. }
  508. }
  509. const message = `报表解析完成: 成功 ${shops.length - errorCount}/${shops.length} 个店铺,共解析 ${totalProcessed} 条记录`;
  510. return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
  511. }
  512. // ========== 定时任务管理 ==========
  513. /**
  514. * 启动定时任务
  515. * - 凌晨 1 点:采集 Listing / Order / Report
  516. * - 凌晨 2 点:解析已完成报表
  517. */
  518. async start() {
  519. if (this.#cronTask) {
  520. console.log('[New SP-API Scheduler] 定时任务已在运行中,跳过重复启动');
  521. return;
  522. }
  523. this.#cronTask = nodeCron.schedule('0 1 * * *', async () => {
  524. console.log('[New SP-API Scheduler] ===== 凌晨1点:开始每日数据采集 =====');
  525. this.#lastRunTime = new Date();
  526. try {
  527. await this.collectListings(undefined, undefined);
  528. await this.collectOrders(undefined, undefined);
  529. await this.collectReports(undefined, undefined);
  530. } catch (e) {
  531. console.error('[New SP-API Scheduler] 每日采集任务失败:', e.message);
  532. }
  533. console.log('[New SP-API Scheduler] ===== 每日数据采集完成 =====');
  534. }, { timezone: 'Asia/Shanghai' });
  535. this.#reportCronTask = nodeCron.schedule('0 2 * * *', async () => {
  536. console.log('[New SP-API Scheduler] ===== 凌晨2点:开始报表解析 =====');
  537. try {
  538. await this.parseReports(undefined, undefined);
  539. } catch (e) {
  540. console.error('[New SP-API Scheduler] 报表解析任务失败:', e.message);
  541. }
  542. console.log('[New SP-API Scheduler] ===== 报表解析完成 =====');
  543. }, { timezone: 'Asia/Shanghai' });
  544. console.log('[New SP-API Scheduler] 定时任务已启动(凌晨1点采集,凌晨2点解析)');
  545. }
  546. /**
  547. * 停止定时任务
  548. */
  549. stop() {
  550. if (this.#cronTask) {
  551. this.#cronTask.stop();
  552. this.#cronTask = null;
  553. console.log('[New SP-API Scheduler] 采集定时任务已停止');
  554. }
  555. if (this.#reportCronTask) {
  556. this.#reportCronTask.stop();
  557. this.#reportCronTask = null;
  558. console.log('[New SP-API Scheduler] 报表解析定时任务已停止');
  559. }
  560. }
  561. /**
  562. * 获取调度器状态
  563. */
  564. getStatus() {
  565. return {
  566. isRunning: !!(this.#cronTask || this.#reportCronTask),
  567. lastRunTime: this.#lastRunTime,
  568. nextRunTime: this.#calculateNextRunTime()
  569. };
  570. }
  571. }
  572. // 导出单例
  573. export const newSpApiScheduler = new NewSpApiScheduler();