Эх сурвалжийг харах

feat: sync jianhen VOC relay, deploy scripts, and secret cleanup

Move Amazon test credentials to environment variables and keep runtime secrets out of the repo.
Yi Jiarui 19 цаг өмнө
parent
commit
91fa409cda
100 өөрчлөгдсөн 10651 нэмэгдсэн , 979 устгасан
  1. 6 1
      backend/api/module/schedule/new-sp-api-schedule.ts
  2. 20 16
      backend/api/module/schedule/routes-schedule.ts
  3. 204 92
      backend/api/module/schedule/sorftime-api-schedule.ts
  4. 10 8
      backend/api/module/schedule/sp-api-schedule.ts
  5. 62 0
      backend/api/relay/credential-routes.js
  6. 56 0
      backend/api/relay/credential-routes.ts
  7. 0 72
      backend/api/relay/sorftime-routes.ts
  8. 0 78
      backend/api/relay/tikhub-routes.ts
  9. 59 29
      backend/api/routes.ts
  10. 1 3
      backend/cloud/providers/index.js
  11. 548 0
      backend/cloud/providers/managed-user.service.js
  12. 3 24
      backend/cloud/providers/routes.js
  13. 11 32
      backend/cloud/providers/user.service.js
  14. 4747 0
      backend/deploy-runtime-routes.js
  15. 6 5
      backend/modules/fmode-amazon-sp-api/src/client.ts
  16. 26 20
      backend/modules/fmode-amazon-sp-api/src/function.ts
  17. 3 2
      backend/modules/fmode-amazon-sp-api/src/marketplace-helper.ts
  18. 2 2
      backend/modules/fmode-amazon-sp-api/src/middleware/auth.ts
  19. 12 1
      backend/modules/fmode-amazon-sp-api/src/routes.ts
  20. 7 8
      backend/modules/fmode-amazon-sp-api/test_auth.ts
  21. 75 0
      backend/src/relay/relay-billing.ts
  22. 17 8
      backend/src/relay/relay-client.ts
  23. 9 7
      backend/src/relay/relay-config.ts
  24. 107 0
      backend/src/relay/relay-credential-store.ts
  25. 185 0
      backend/src/relay/relay-runtime.js
  26. 364 0
      deploy/backfill-listings.mjs
  27. 12 0
      deploy/backup.sh
  28. 442 0
      deploy/bootstrap-schemas.mjs
  29. 138 0
      deploy/bootstrap-shops.mjs
  30. 289 0
      deploy/collect-sorftime-reviews-year.mjs
  31. 304 0
      deploy/collect-sp-api-core.mjs
  32. 118 0
      deploy/collect-sp-api-feedback-inbound.mjs
  33. 79 0
      deploy/collect-sp-api-finances.mjs
  34. 310 0
      deploy/collect-sp-api-reports-all.mjs
  35. 124 0
      deploy/dedupe-return-records.mjs
  36. 73 0
      deploy/dedupe-sp-api-classes.mjs
  37. 23 0
      deploy/ecosystem.config.cjs
  38. 167 0
      deploy/enrich-products.mjs
  39. 21 0
      deploy/full-sp-api-refresh.sh
  40. 166 0
      deploy/full-sp-api-sync.mjs
  41. 14 0
      deploy/jianhen-voc-backup.service
  42. 10 0
      deploy/jianhen-voc-backup.timer
  43. 10 0
      deploy/jianhen-voc-pm2.logrotate
  44. 19 0
      deploy/jianhen-voc-reviews.service
  45. 20 0
      deploy/jianhen-voc-sp-api-refresh.service
  46. 11 0
      deploy/jianhen-voc-sp-api-refresh.timer
  47. 24 0
      deploy/jianhen-voc.service
  48. 48 0
      deploy/nginx.http.conf
  49. 86 0
      deploy/nginx.https-dashboard.conf
  50. 26 0
      deploy/parse-dashboard.service
  51. 18 0
      deploy/pm2-voc
  52. 30 0
      deploy/pm2-voc.service
  53. 58 0
      deploy/probe-review-pagination.mjs
  54. 175 0
      deploy/process-sp-api-reports.mjs
  55. 80 0
      deploy/reparse-sales-traffic-reports.mjs
  56. 181 0
      deploy/sp-api-collector-lib.mjs
  57. 8 0
      deploy/start-backend.sh
  58. 281 0
      deploy/sync-sp-api-pages.mjs
  59. 110 31
      docs/验收清单.md
  60. 3 2
      frontend/src/app/app.config.ts
  61. 9 0
      frontend/src/app/core/services/api-auth.ts
  62. 11 25
      frontend/src/app/core/services/api-connectivity.service.ts
  63. 24 0
      frontend/src/app/core/services/api-session.interceptor.ts
  64. 4 4
      frontend/src/app/core/services/api.config.ts
  65. 11 4
      frontend/src/app/core/services/category-api.service.ts
  66. 65 0
      frontend/src/app/core/services/company-relay-auth.service.ts
  67. 30 3
      frontend/src/app/core/services/data-store.service.ts
  68. 16 16
      frontend/src/app/core/services/data-sync.service.ts
  69. 129 31
      frontend/src/app/core/services/http-api.service.ts
  70. 1 0
      frontend/src/app/core/services/index.ts
  71. 4 1
      frontend/src/app/core/services/keyword-api.service.ts
  72. 20 4
      frontend/src/app/core/services/runtime-config.service.ts
  73. 6 12
      frontend/src/app/core/services/sorftime-api.service.ts
  74. 3 2
      frontend/src/assets/runtime-config.json
  75. 44 0
      frontend/src/modules/dashboard/dashboard.component.html
  76. 71 0
      frontend/src/modules/dashboard/dashboard.component.ts
  77. 35 1
      frontend/src/modules/dashboard/pages/order-list/order-list.component.ts
  78. 1 2
      frontend/src/modules/knowledge-center/competitor-detail/product-variations/product-variations.component.ts
  79. 1 1
      frontend/src/modules/knowledge-center/export-reviews-dialog/export-reviews-dialog.component.ts
  80. 1 1
      frontend/src/modules/knowledge-center/fetch-count-dialog/fetch-count-dialog.component.ts
  81. 1 3
      frontend/src/modules/knowledge-center/influencer-detail/influencer-detail.component.ts
  82. 8 19
      frontend/src/modules/knowledge-center/knowledge-center.component.ts
  83. 4 38
      frontend/src/modules/login/login.component.ts
  84. 1 1
      frontend/src/modules/market-insight/components/emerging-market-insights/emerging-market-insights.component.ts
  85. 36 32
      frontend/src/modules/market-insight/pages/keyword-research/keyword-research.component.ts
  86. 1 1
      frontend/src/modules/market-insight/pages/new-product-trend/new-product-trend.component.scss
  87. 13 94
      frontend/src/modules/market-insight/pages/new-product-trend/new-product-trend.component.ts
  88. 6 33
      frontend/src/modules/market-insight/pages/website-research/website-research.component.ts
  89. 4 64
      frontend/src/modules/monitoring/components/traffic-source-monitoring/traffic-source-monitoring.component.ts
  90. 1 1
      frontend/src/modules/monitoring/pages/competitor-update/campaign-create-panel/campaign-create-panel.component.html
  91. 1 1
      frontend/src/modules/monitoring/pages/competitor-update/competitor-update.component.ts
  92. 8 24
      frontend/src/modules/monitoring/pages/store-detail/store-detail.component.ts
  93. 1 1
      frontend/src/modules/product-lifecycle-review/product-lifecycle-review.component.html
  94. 1 4
      frontend/src/modules/product-lifecycle-review/product-lifecycle-review.component.ts
  95. 5 8
      frontend/src/modules/return-depth-analysis/return-depth-analysis.component.ts
  96. 5 24
      frontend/src/modules/shared/components/filter-bar/filter-bar.component.ts
  97. 2 0
      frontend/src/modules/shared/components/header/header.component.ts
  98. 32 24
      frontend/src/modules/shared/services/ai-analysis.service.ts
  99. 14 30
      frontend/src/modules/shared/services/competitor-monitor-prefs.service.ts
  100. 3 29
      frontend/src/modules/shared/services/emerging-insight-data.service.ts

+ 6 - 1
backend/api/module/schedule/new-sp-api-schedule.ts

@@ -50,7 +50,7 @@ class NewSpApiScheduler {
     query.equalTo('status', 'active');
     if (shopId) query.equalTo('objectId', shopId);
     query.limit(1000);
-    return await query.find();
+    return await query.find({ useMasterKey: true });
   }
 
   // ========== 单店铺内部实现 ==========
@@ -415,6 +415,11 @@ class NewSpApiScheduler {
         continue;
       }
 
+      if (config.SpApiConfig.listingEnabled === false) {
+        progress?.({ type: 'warn', message: `店铺【${shopName}】未配置区域 Seller ID,跳过 Listing` });
+        continue;
+      }
+
       progress?.({ type: 'info', message: `开始采集店铺【${shopName}】的 Listing 数据` });
       console.log(`[New SP-API Scheduler] 开始采集店铺 ${shopName} (${sid}) 的 Listing 数据`);
 

+ 20 - 16
backend/api/module/schedule/routes-schedule.ts

@@ -15,11 +15,15 @@ import { sorftimeScheduler } from './sorftime-api-schedule.ts';
     console.error('[API Routes] Amazon SP-API定时任务启动失败:', error);
   }
 
-  try {
-    await sorftimeScheduler.start();
-    console.log('[API Routes] Sorftime定时任务已启动');
-  } catch (error) {
-    console.error('[API Routes] Sorftime定时任务启动失败:', error);
+  if (process.env.ENABLE_SORFTIME_SCHEDULES === 'true') {
+    try {
+      await sorftimeScheduler.start();
+      console.log('[API Routes] Sorftime定时任务已启动');
+    } catch (error) {
+      console.error('[API Routes] Sorftime定时任务启动失败:', error);
+    }
+  } else {
+    console.log('[API Routes] Sorftime定时任务已禁用');
   }
 })();
 
@@ -124,7 +128,7 @@ router.post('/sp-api-schedule/collect-listing', async (req, res) => {
   const { shopId } = req.body || {};
   const { send, close } = createSseResponse(res);
 
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: `开始采集 Listing 数据${shopId ? ',店铺: ' + shopId : '(全部活跃店铺)'}` });
@@ -151,7 +155,7 @@ router.post('/sp-api-schedule/collect-order', async (req, res) => {
   const { shopId } = req.body || {};
   const { send, close } = createSseResponse(res);
 
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: `开始采集订单数据${shopId ? ',店铺: ' + shopId : '(全部活跃店铺)'}` });
@@ -178,7 +182,7 @@ router.post('/sp-api-schedule/collect-report', async (req, res) => {
   const { shopId } = req.body || {};
   const { send, close } = createSseResponse(res);
 
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: `开始采集报表数据${shopId ? ',店铺: ' + shopId : '(全部活跃店铺)'}` });
@@ -205,7 +209,7 @@ router.post('/sp-api-schedule/parse-report', async (req, res) => {
   const { shopId } = req.body || {};
   const { send, close } = createSseResponse(res);
 
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: `开始解析报表${shopId ? ',店铺: ' + shopId : '(全部活跃店铺)'}` });
@@ -244,7 +248,7 @@ router.get('/sorftime-schedule/status', (req, res) => {
 router.post('/sorftime-schedule/trigger-products', async (req, res) => {
   const { shopId, nodeIds } = req.body || {};
   const { send, close } = createSseResponse(res);
-  req.on('close', () => close());
+  req.on('aborted', () => close());
   try {
     send('progress', { type: 'info', message: '开始采集类目热销产品...' });
     const result = await sorftimeScheduler.triggerCategoryProducts(
@@ -270,7 +274,7 @@ router.post('/sorftime-schedule/trigger-products', async (req, res) => {
 router.post('/sorftime-schedule/trigger-keywords', async (req, res) => {
   const { shopId, nodeIds } = req.body || {};
   const { send, close } = createSseResponse(res);
-  req.on('close', () => close());
+  req.on('aborted', () => close());
   try {
     send('progress', { type: 'info', message: '开始采集类目关键词...' });
     const result = await sorftimeScheduler.triggerCategoryKeywords(
@@ -296,7 +300,7 @@ router.post('/sorftime-schedule/trigger-keywords', async (req, res) => {
 router.post('/sorftime-schedule/trigger-category-trends', async (req, res) => {
   const { shopId, nodeIds } = req.body || {};
   const { send, close } = createSseResponse(res);
-  req.on('close', () => close());
+  req.on('aborted', () => close());
   try {
     send('progress', { type: 'info', message: '开始采集类目市场趋势...' });
     const result = await sorftimeScheduler.triggerCategoryTrends(
@@ -324,7 +328,7 @@ router.post('/sorftime-schedule/trigger-single-category', async (req, res) => {
     return res.status(400).json({ success: false, message: '缺少 nodeId 参数' });
   }
   const { send, close } = createSseResponse(res);
-  req.on('close', () => close());
+  req.on('aborted', () => close());
   try {
     send('progress', { type: 'info', message: `开始采集类目 ${nodeId} 全量数据(热销产品 + 关键词 + 市场趋势)` });
     const result = await sorftimeScheduler.triggerSingleCategory(
@@ -348,7 +352,7 @@ router.post('/sorftime-schedule/trigger-single-category', async (req, res) => {
  */
 router.post('/sorftime-schedule/collect-reviews', async (req, res) => {
   const { send, close } = createSseResponse(res);
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: '开始采集产品评论数据...' });
@@ -374,7 +378,7 @@ router.post('/sorftime-schedule/collect-shop-products', async (req, res) => {
   const { shopId } = req.body || {};
   const { send, close } = createSseResponse(res);
 
-  req.on('close', () => close());
+  req.on('aborted', () => close());
 
   try {
     send('progress', { type: 'info', message: `开始采集店铺产品数据${shopId ? ',店铺: ' + shopId : '(全部活跃店铺)'}` });
@@ -399,4 +403,4 @@ router.get('/test', (req, res)=> {
   });
 })
 
-export default router;
+export default router;

+ 204 - 92
backend/api/module/schedule/sorftime-api-schedule.ts

@@ -13,6 +13,114 @@
 
 // node-cron 将在需要时动态加载,避免编译时依赖问题
 import nodeCron from 'npm:node-cron';
+import { relayClient } from '../../../src/relay/relay-client.ts';
+
+function unwrapRelayData(result) {
+  let data = result?.data ?? result?.Data ?? result;
+  if (data && typeof data === 'object' && !Array.isArray(data)) {
+    data = data.data ?? data.Data ?? data;
+  }
+  return data;
+}
+
+function relaySucceeded(result) {
+  const code = Number(result?.code ?? result?.Code ?? 200);
+  return code === 0 || code === 200;
+}
+
+function siteCode(marketplaceId, domain) {
+  const byMarketplace = {
+    ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br',
+    A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es',
+    APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae',
+    A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in',
+  };
+  return byMarketplace[marketplaceId] || String(domain || '');
+}
+
+async function upsertSorftimeProduct(raw, shopId, shopName, marketplaceId, domain) {
+  const Parse = globalThis.Parse;
+  const asin = String(raw?.Asin || raw?.ASIN || raw?.asin || '').trim().toUpperCase();
+  if (!asin) return false;
+  const shop = Parse.Object.extend('Shop').createWithoutData(shopId);
+  const common = {
+    asin,
+    parentAsin: raw.ParentAsin || raw.ParentASIN || '',
+    title: raw.Title || raw.title || '',
+    photo: raw.Photo || raw.photo || '',
+    imageUrl: Array.isArray(raw.Photo) ? raw.Photo[0] : (raw.Photo || ''),
+    price: Number(raw.Price || 0),
+    salesPrice: Number(raw.SalesPrice || raw.Price || 0),
+    brand: raw.Brand || '',
+    sellerId: raw.BuyboxSellerId || '',
+    ratings: Number(raw.Ratings || 0),
+    rating: Number(raw.Ratings || 0),
+    ratingsCount: Number(raw.RatingsCount || 0),
+    category: raw.Category || '',
+    bsrCategory: raw.BsrCategory || [],
+    rank: Number(raw.Rank || 0),
+    listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+    ListingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+    listingSalesOfMonth: Number(raw.ListingSalesOfMonth || 0),
+    marketplaceId,
+    domain: String(domain || ''),
+    site: siteCode(marketplaceId, domain),
+    storeName: shopName || '',
+    shopName: shopName || '',
+    shopId,
+    shop,
+    source: 'sorftime',
+    onlineDate: String(raw.OnlineDate || '').slice(0, 10),
+    rawData: raw,
+  };
+
+  for (const className of ['Product', 'ProductDetail', 'SorftimeProduct']) {
+    const query = new Parse.Query(className);
+    query.equalTo('asin', asin);
+    if (className !== 'SorftimeProduct') query.equalTo('shop', shop);
+    let object = null;
+    try {
+      object = await query.first({ useMasterKey: true });
+    } catch (error) {
+      const message = String(error?.message || error || '');
+      if (!message.includes('does not exist') && !message.includes('non-existent class')) throw error;
+    }
+    if (!object) object = new Parse.Object(className);
+    for (const [key, value] of Object.entries(common)) {
+      if (value !== undefined && value !== null) object.set(key, value);
+    }
+    await object.save(null, { useMasterKey: true });
+  }
+  return true;
+}
+
+async function upsertSorftimeReview(raw, asin, shopId) {
+  const Parse = globalThis.Parse;
+  const reviewId = String(raw?.ReviewId || raw?.Id || raw?.ID || raw?.id || '').trim();
+  const uniqueId = reviewId || `${asin}:${raw?.ReviewsDate || raw?.ReviewDate || raw?.Date || ''}:${raw?.ProfileName || raw?.Author || ''}`;
+  const query = new Parse.Query('SorftimeReviews');
+  query.equalTo('reviewId', uniqueId);
+  let object = null;
+  try {
+    object = await query.first({ useMasterKey: true });
+  } catch (error) {
+    const message = String(error?.message || error || '');
+    if (!message.includes('does not exist') && !message.includes('non-existent class')) throw error;
+  }
+  if (!object) object = new Parse.Object('SorftimeReviews');
+  object.set('reviewId', uniqueId);
+  object.set('asin', asin);
+  object.set('star', Number(raw?.Star || raw?.Rating || raw?.rating || 0));
+  object.set('rating', Number(raw?.Star || raw?.Rating || raw?.rating || 0));
+  object.set('title', raw?.Title || raw?.ReviewTitle || '');
+  object.set('content', raw?.Content || raw?.ReviewContent || raw?.Body || '');
+  object.set('reviewDate', raw?.ReviewsDate || raw?.ReviewDate || raw?.Date || '');
+  object.set('author', raw?.ConsumerName || raw?.ProfileName || raw?.Author || '');
+  object.set('verified', Boolean(raw?.IsVP || raw?.OnlyPurchase || raw?.VerifiedPurchase));
+  object.set('shop', Parse.Object.extend('Shop').createWithoutData(shopId));
+  object.set('rawData', raw);
+  await object.save(null, { useMasterKey: true });
+}
 
 
 
@@ -99,7 +207,7 @@ class SorftimeScheduler {
     query.equalTo('status', 'active');
     if (shopId) query.equalTo('objectId', shopId);
     query.limit(1000);
-    return await query.find();
+    return await query.find({ useMasterKey: true });
   }
 
   /**
@@ -157,15 +265,14 @@ class SorftimeScheduler {
           functionId
         };
         
-        const response = await fetch('http://localhost:3000/api/sorftime/forward', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify(requestBody)
+        const result = await relayClient.forwardSorftime({
+          path: requestBody.path,
+          method: requestBody.method,
+          query: requestBody.query,
+          body: requestBody.body
         });
-
-        const result = await response.json();
         
-        if (result.Code !== 0) {
+        if (!relaySucceeded(result)) {
           throw new Error(result.message || `第${page}页数据采集失败`);
         }
 
@@ -205,15 +312,14 @@ class SorftimeScheduler {
         functionId
       };
 
-      const response = await fetch('http://localhost:3000/api/sorftime/forward', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify(requestBody)
+      const result = await relayClient.forwardSorftime({
+        path: requestBody.path,
+        method: requestBody.method,
+        query: requestBody.query,
+        body: requestBody.body
       });
-
-      const result = await response.json();
       
-      if (result.Code !== 0) {
+      if (!relaySucceeded(result)) {
         throw new Error(result.message || '关键词采集失败');
       }
 
@@ -233,7 +339,7 @@ class SorftimeScheduler {
    * @param {Function} progress - 可选进度回调
    * @returns {Promise<{processed: number, pages: number}>}
    */
-  async #doCollectShopProducts(shopId, sellerId, domain, progress) {
+  async #doCollectShopProducts(shopId, shopName, sellerId, domain, marketplaceId, progress) {
     const functionId = 'GSjAsvw9FK';
     let page = 1;
     let hasMore = true;
@@ -246,38 +352,36 @@ class SorftimeScheduler {
       progress?.({ type: 'info', message: `正在采集第 ${page} 页...` });
       console.log(`[Sorftime Scheduler] 店铺 ${shopId} 采集第 ${page} 页`);
 
-      const response = await fetch('http://localhost:3000/api/sorftime/forward', {
+      const result = await relayClient.forwardSorftime({
+        path: '/api/ProductQuery',
         method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          path: '/api/ProductQuery',
-          method: 'POST',
-          query: { domain },
-          body: {
-            Query: 1,
-            QueryType: 5,
-            Pattern: sellerId,
-            Page: page
-          },
-          shop: shopId,
-          functionId
-        })
+        query: { domain },
+        body: {
+          Query: 1,
+          QueryType: 5,
+          Pattern: sellerId,
+          Page: page
+        }
       });
 
-      const result = await response.json();
-
-      if (result.Code !== 0) {
-        throw new Error(`第 ${page} 页采集失败: ${result.Message || JSON.stringify(result)}`);
+      if (!relaySucceeded(result)) {
+        throw new Error(`第 ${page} 页采集失败: ${result.message || result.Message || JSON.stringify(result)}`);
       }
 
-      const items = result.Data?.Items || result.Data?.Products || (Array.isArray(result.Data) ? result.Data : []);
+      const payload = unwrapRelayData(result);
+      const items = payload?.Items || payload?.Products || (Array.isArray(payload) ? payload : []);
       const count = Array.isArray(items) ? items.length : 0;
+      let saved = 0;
+      for (const item of items) {
+        if (await upsertSorftimeProduct(item, shopId, shopName, item?.MarketplaceId || marketplaceId || '', domain)) saved++;
+      }
       totalProcessed += count;
 
-      progress?.({ type: 'info', message: `第 ${page} 页获取 ${count} 条产品`, processed: totalProcessed });
-      console.log(`[Sorftime Scheduler] 店铺 ${shopId} 第 ${page} 页获取 ${count} 条产品,累计 ${totalProcessed} 条`);
+      progress?.({ type: 'info', message: `第 ${page} 页获取 ${count} 条产品,入库 ${saved} 条`, processed: totalProcessed });
+      console.log(`[Sorftime Scheduler] 店铺 ${shopId} 第 ${page} 页获取 ${count} 条产品,入库 ${saved} 条,累计 ${totalProcessed} 条`);
 
-      if (count === 0 || page >= 100) {
+      const pageCount = Number(payload?.PageCount || 0);
+      if (count === 0 || (pageCount > 0 && page >= pageCount) || page >= 100) {
         hasMore = false;
       } else {
         page++;
@@ -314,14 +418,13 @@ class SorftimeScheduler {
           functionId
         };
 
-        const response = await fetch('http://localhost:3000/api/sorftime/forward', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify(requestBody)
+        const result = await relayClient.forwardSorftime({
+          path: requestBody.path,
+          method: requestBody.method,
+          query: requestBody.query,
+          body: requestBody.body
         });
-
-        const result = await response.json();
-        if (result.Code !== 0) {
+        if (!relaySucceeded(result)) {
           throw new Error(result.message || `市场趋势类型 ${TrendIndex} 采集失败`);
         }
 
@@ -822,7 +925,13 @@ class SorftimeScheduler {
           reviewQuery.equalTo('asin', asin);
           reviewQuery.descending('updatedAt');
           reviewQuery.limit(1);
-          const latestReview = await reviewQuery.first({ useMasterKey: true });
+          let latestReview = null;
+          try {
+            latestReview = await reviewQuery.first({ useMasterKey: true });
+          } catch (error) {
+            const message = String(error?.message || error || '');
+            if (!message.includes('does not exist') && !message.includes('non-existent class')) throw error;
+          }
 
           if (latestReview) {
             const updateAt = latestReview.get('updatedAt');
@@ -836,12 +945,9 @@ class SorftimeScheduler {
           console.log(`[Sorftime Scheduler] 开始采集评论,ASIN: ${asin}, 查询起始日期: ${queryStartDt}`);
 
           // 调用 Sorftime 接口获取评论信息
-          const response = await fetch('http://localhost:3000/api/sorftime/forward', {
-            method: 'POST',
-            headers: {
-              'Content-Type': 'application/json'
-            },
-            body: JSON.stringify({
+          let result;
+          try {
+            result = await relayClient.forwardSorftime({
               path: '/api/ProductReviewsQuery',
               method: 'POST',
               query: {
@@ -853,25 +959,24 @@ class SorftimeScheduler {
                 PageIndex: 1,
                 OnlyPurchase: 1,
                 Star: '1,2,3,4,5',
-                Querystartdt: queryStartDt
-              },
-              functionId: 'gExKf7T2HD'
-            })
-          });
-
-          if (!response.ok) {
-            const errorMsg = `请求评论数据失败,ASIN: ${asin}, 状态码: ${response.status}`;
+                QueryStartDt: queryStartDt
+              }
+            });
+          } catch (error) {
+            const errorMsg = `请求评论数据失败,ASIN: ${asin}, ${error.message || error}`;
             errors.push(errorMsg);
             errorCount++;
             console.error(`[Sorftime Scheduler] ${errorMsg}`);
             continue;
           }
-
-          const result = await response.json();
-          if (result.success && result.data) {
+          const payload = unwrapRelayData(result);
+          const reviews = payload?.Reviews || payload?.Items || payload?.List || (Array.isArray(payload) ? payload : []);
+          if (relaySucceeded(result)) {
+            for (const review of reviews) await upsertSorftimeReview(review, asin, shop.id);
             successCount++;
-            console.log(`[Sorftime Scheduler] 评论数据采集成功,ASIN: ${asin}`);
-            progress?.({ type: 'success', message: `评论数据采集成功,ASIN: ${asin}`, processed: successCount });
+            const statusText = reviews.length ? `入库 ${reviews.length} 条` : '无新增评论';
+            console.log(`[Sorftime Scheduler] 评论数据采集成功,ASIN: ${asin},${statusText}`);
+            progress?.({ type: 'success', message: `评论数据采集成功,ASIN: ${asin},${statusText}`, processed: successCount });
           } else {
             const errorMsg = `评论数据获取失败,ASIN: ${asin}`;
             errors.push(errorMsg);
@@ -973,18 +1078,20 @@ class SorftimeScheduler {
 
     console.log('[Sorftime Scheduler] 类目关键词定时任务已启动,将在每周一凌晨1点执行');
 
-    // 每天凌晨1点采集产品评论数据
-    this.#reviewsCronTask = nodeCron.schedule('0 1 * * *', async () => {
-      console.log('[Sorftime Scheduler] ===== 凌晨1点:开始产品评论采集 =====');
-      try {
-        await this.#executeProductReviewsCollection(undefined);
-      } catch (e) {
-        console.error('[Sorftime Scheduler] 产品评论采集失败:', e.message);
-      }
-      console.log('[Sorftime Scheduler] ===== 产品评论采集完成 =====');
-    }, { timezone: 'Asia/Shanghai' });
-
-    console.log('[Sorftime Scheduler] 产品评论采集定时任务已启动,将在每天凌晨1点执行');
+    if (process.env.ENABLE_SORFTIME_REVIEW_SCHEDULES === 'true') {
+      this.#reviewsCronTask = nodeCron.schedule('0 1 * * *', async () => {
+        console.log('[Sorftime Scheduler] ===== 凌晨1点:开始产品评论采集 =====');
+        try {
+          await this.#executeProductReviewsCollection(undefined);
+        } catch (e) {
+          console.error('[Sorftime Scheduler] 产品评论采集失败:', e.message);
+        }
+        console.log('[Sorftime Scheduler] ===== 产品评论采集完成 =====');
+      }, { timezone: 'Asia/Shanghai' });
+      console.log('[Sorftime Scheduler] 产品评论采集定时任务已启动,将在每天凌晨1点执行');
+    } else {
+      console.log('[Sorftime Scheduler] 产品评论采集定时任务已禁用');
+    }
 
     // 每天凌晨2点采集店铺产品数据
     this.#shopProductsCronTask = nodeCron.schedule('0 2 * * *', async () => {
@@ -999,18 +1106,17 @@ class SorftimeScheduler {
 
     console.log('[Sorftime Scheduler] 店铺产品采集定时任务已启动,将在每天凌晨2点执行');
 
-    // 每两小时执行一次云函数调用
-    this.#cloudFunctionCronTask = nodeCron.schedule('0 */2 * * *', async () => {
-      console.log('[Sorftime Scheduler] ===== 每两小时:开始云函数调用 =====');
-      try {
-        await this.#executeCloudFunctionInvocation();
-      } catch (e) {
-        console.error('[Sorftime Scheduler] 云函数调用失败:', e.message);
-      }
-      console.log('[Sorftime Scheduler] ===== 云函数调用完成 =====');
-    }, { timezone: 'Asia/Shanghai' });
-
-    console.log('[Sorftime Scheduler] 云函数调用定时任务已启动,将每两小时执行一次');
+    if (process.env.ENABLE_LEGACY_CLOUD_FUNCTION_SCHEDULES === 'true') {
+      this.#cloudFunctionCronTask = nodeCron.schedule('0 */2 * * *', async () => {
+        try {
+          await this.#executeCloudFunctionInvocation();
+        } catch (e) {
+          console.error('[Sorftime Scheduler] 遗留云函数调用失败:', e.message);
+        }
+      }, { timezone: 'Asia/Shanghai' });
+    } else {
+      console.log('[Sorftime Scheduler] 遗留动态云函数定时任务已禁用');
+    }
   }
 
 
@@ -1272,7 +1378,8 @@ class SorftimeScheduler {
       const sid = shop.id;
       const shopName = shop.get('name');
       const config = shop.get('config');
-      const domain = 1;
+      const domain = Number(shop.get('domain') || 1);
+      const marketplaceId = shop.get('marketplaceId') || '';
 
       if (!config || !config.SpApiConfig || !config.SpApiConfig.sellerID) {
         const msg = `店铺【${shopName}】(${sid}) 缺少 SpApiConfig.sellerID 配置,跳过`;
@@ -1283,12 +1390,17 @@ class SorftimeScheduler {
         continue;
       }
 
+      if (config.SpApiConfig.listingEnabled === false) {
+        progress?.({ type: 'warn', message: `店铺【${shopName}】未配置区域 Seller ID,跳过 Sorftime 店铺产品采集` });
+        continue;
+      }
+
       const sellerId = config.SpApiConfig.sellerID;
       progress?.({ type: 'info', message: `开始采集店铺【${shopName}】的产品数据 (SellerId: ${sellerId})` });
       console.log(`[Sorftime Scheduler] 开始采集店铺 ${shopName} (${sid}) 产品数据`);
 
       try {
-        const result = await this.#doCollectShopProducts(sid, sellerId, domain, progress);
+        const result = await this.#doCollectShopProducts(sid, shopName, sellerId, domain, marketplaceId, progress);
         progress?.({ type: 'success', message: `店铺【${shopName}】产品采集完成: 共 ${result.pages} 页 ${result.processed} 条`, processed: result.processed });
         console.log(`[Sorftime Scheduler] 店铺 ${shopName} 产品采集完成: ${result.processed} 条`);
         totalProcessed += result.processed;

+ 10 - 8
backend/api/module/schedule/sp-api-schedule.ts

@@ -64,7 +64,7 @@ class SpApiScheduler {
       query.equalTo('objectId', shopId);
     }
     query.limit(1000);
-    return await query.find();
+    return await query.find({ useMasterKey: true });
   }
 
   /**
@@ -437,12 +437,14 @@ class SpApiScheduler {
       throw new Error('店铺配置缺失');
     }
     // 1. 采集 Listing 数据
-    try {
-      console.log(`[SP-API Scheduler] 采集店铺 ${shopName} 的 Listing 数据`);
-      await this.#collectListings(shopId, config, marketplaceId);
-      await this.#delay(3000);
-    } catch (error) {
-      console.error(`[SP-API Scheduler] Listing 采集失败: ${error.message}`);
+    if (config.SpApiConfig.listingEnabled !== false) {
+      try {
+        console.log(`[SP-API Scheduler] 采集店铺 ${shopName} 的 Listing 数据`);
+        await this.#collectListings(shopId, config, marketplaceId);
+        await this.#delay(3000);
+      } catch (error) {
+        console.error(`[SP-API Scheduler] Listing 采集失败: ${error.message}`);
+      }
     }
 
     // 2. 采集订单数据(增量同步)
@@ -673,4 +675,4 @@ class SpApiScheduler {
 }
 
 // 导出单例
-export const spApiScheduler = new SpApiScheduler();
+export const spApiScheduler = new SpApiScheduler();

+ 62 - 0
backend/api/relay/credential-routes.js

@@ -0,0 +1,62 @@
+import express from "npm:express";
+import {
+  getRelayBalanceStatus,
+  getStoredRelayAuth,
+  isUsableAuth,
+  queryRelayBalance,
+  redactSecrets,
+  saveStoredRelayCredential,
+  toAuthorization
+} from "../../src/relay/relay-runtime.js";
+
+const router = express.Router();
+
+router.get("/token", async (_req, res) => {
+  try {
+    const auth = await getStoredRelayAuth();
+    if (!isUsableAuth(auth)) {
+      return res.status(409).json({ success: false, message: "尚未配置 API 密钥" });
+    }
+    res.json({ success: true, data: { authorization: auth } });
+  } catch (error) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || "获取密钥失败") });
+  }
+});
+
+router.get("/status", async (_req, res) => {
+  try {
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data });
+  } catch (error) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || "状态查询失败") });
+  }
+});
+
+router.post("/status/refresh", async (_req, res) => {
+  try {
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data });
+  } catch (error) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || "状态查询失败") });
+  }
+});
+
+router.post("/credential", async (req, res) => {
+  try {
+    const raw = String(req.body?.apiKey || req.body?.key || "").trim();
+    const auth = toAuthorization(raw);
+    await queryRelayBalance(auth);
+    const saved = await saveStoredRelayCredential(raw, req.currentUser?.get?.("username") || req.currentUser?.id || "");
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data: { ...data, maskedKey: saved.maskedKey } });
+  } catch (error) {
+    res.status(400).json({ success: false, message: redactSecrets(error?.message || "密钥保存失败") });
+  }
+});
+
+router.get("/health", async (_req, res) => {
+  const auth = await getStoredRelayAuth();
+  res.json({ service: "relay-credential", configured: Boolean(auth), timestamp: new Date().toISOString() });
+});
+
+export default router;

+ 56 - 0
backend/api/relay/credential-routes.ts

@@ -0,0 +1,56 @@
+import express from 'npm:express';
+import { getRelayBalanceStatus, queryRelayBalance } from '../../src/relay/relay-billing.ts';
+import { getStoredRelayAuth, isUsableAuth, saveStoredRelayCredential, toAuthorization } from '../../src/relay/relay-credential-store.ts';
+import { redactSecrets } from '../../src/relay/relay-errors.ts';
+
+const router = express.Router();
+
+router.get('/token', async (_req, res) => {
+  try {
+    const auth = await getStoredRelayAuth();
+    if (!isUsableAuth(auth)) {
+      return res.status(409).json({ success: false, message: '尚未配置 API 密钥' });
+    }
+    res.json({ success: true, data: { authorization: auth } });
+  } catch (error: any) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || '获取密钥失败') });
+  }
+});
+
+router.get('/status', async (_req, res) => {
+  try {
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data });
+  } catch (error: any) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || '状态查询失败') });
+  }
+});
+
+router.post('/status/refresh', async (_req, res) => {
+  try {
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data });
+  } catch (error: any) {
+    res.status(500).json({ success: false, message: redactSecrets(error?.message || '状态查询失败') });
+  }
+});
+
+router.post('/credential', async (req, res) => {
+  try {
+    const raw = String(req.body?.apiKey || req.body?.key || '').trim();
+    const auth = toAuthorization(raw);
+    await queryRelayBalance(auth);
+    const saved = await saveStoredRelayCredential(raw, req.currentUser?.get?.('username') || req.currentUser?.id || '');
+    const data = await getRelayBalanceStatus();
+    res.json({ success: true, data: { ...data, maskedKey: saved.maskedKey } });
+  } catch (error: any) {
+    res.status(400).json({ success: false, message: redactSecrets(error?.message || '密钥保存失败') });
+  }
+});
+
+router.get('/health', async (_req, res) => {
+  const auth = await getStoredRelayAuth();
+  res.json({ service: 'relay-credential', configured: Boolean(auth), timestamp: new Date().toISOString() });
+});
+
+export default router;

+ 0 - 72
backend/api/relay/sorftime-routes.ts

@@ -1,72 +0,0 @@
-import express from 'npm:express';
-import { relayClient, stripIdentityOverrides } from '../../src/relay/relay-client.ts';
-import { isRelayError, redactSecrets } from '../../src/relay/relay-errors.ts';
-
-const router = express.Router();
-
-function requestIdOf(req: any): string {
-  return (
-    (req.headers['x-request-id'] as string) ||
-    `sf_${Date.now()}_${Math.random().toString(16).slice(2)}`
-  );
-}
-
-router.get('/health', (_req, res) => {
-  res.json({
-    service: 'sorftime-relay',
-    mode: 'company-relay',
-    upstream: '/api/voc-ecom/forward',
-    authConfigured: relayClient.authConfigured,
-    timestamp: new Date().toISOString(),
-  });
-});
-
-router.get('/test', (_req, res) => {
-  res.json({ message: 'Sorftime relay router loaded (company voc-ecom)' });
-});
-
-/**
- * 保持前端契约:POST /api/sorftime/forward
- * 内部转发到公司 https://server.fmode.cn/api/voc-ecom/forward
- */
-router.post('/forward', async (req: any, res: any) => {
-  const requestId = requestIdOf(req);
-  res.setHeader('X-Request-Id', requestId);
-
-  try {
-    const raw = stripIdentityOverrides(req.body || {});
-    const { path, method = 'POST', query, body, isRefresh } = raw;
-    if (!path) {
-      res.status(400).json({ success: false, message: 'Missing required parameter: path' });
-      return;
-    }
-
-    const result = await relayClient.forwardSorftime({
-      path,
-      method,
-      query,
-      body,
-      isRefresh: Boolean(isRefresh),
-      requestId,
-    });
-    res.json(result);
-  } catch (error: any) {
-    console.warn('[sorftime-relay] forward failed:', redactSecrets(error?.message || error));
-    if (isRelayError(error)) {
-      res.status(error.status).json({
-        success: false,
-        code: error.code,
-        message: error.message,
-        requestId,
-      });
-      return;
-    }
-    res.status(500).json({
-      success: false,
-      message: error?.message || 'Sorftime relay failed',
-      requestId,
-    });
-  }
-});
-
-export default router;

+ 0 - 78
backend/api/relay/tikhub-routes.ts

@@ -1,78 +0,0 @@
-import express from 'npm:express';
-import { relayClient, stripIdentityOverrides } from '../../src/relay/relay-client.ts';
-import { isRelayError, redactSecrets } from '../../src/relay/relay-errors.ts';
-
-const router = express.Router();
-
-function requestIdOf(req: any): string {
-  return (
-    (req.headers['x-request-id'] as string) ||
-    `th_${Date.now()}_${Math.random().toString(16).slice(2)}`
-  );
-}
-
-router.get('/health', (_req, res) => {
-  res.json({
-    service: 'tikhub-relay',
-    mode: 'company-relay',
-    upstream: '/api/voc-social/*',
-    authConfigured: relayClient.authConfigured,
-    timestamp: new Date().toISOString(),
-  });
-});
-
-router.get('/test', (_req, res) => {
-  res.json({ message: 'TikHub relay router loaded (company voc-social)' });
-});
-
-/**
- * 保持前端契约:POST /api/tikhub/forward
- * 内部转发到公司 https://server.fmode.cn/api/voc-social/{suffix}
- */
-router.post('/forward', async (req: any, res: any) => {
-  const requestId = requestIdOf(req);
-  res.setHeader('X-Request-Id', requestId);
-
-  try {
-    const raw = stripIdentityOverrides(req.body || {});
-    const { path, method = 'GET', query, body, isRefresh } = raw;
-    if (!path) {
-      res.status(400).json({
-        success: false,
-        message: 'Missing required parameter: path',
-        timestamp: new Date().toISOString(),
-      });
-      return;
-    }
-
-    const result = await relayClient.forwardTikhub({
-      path,
-      method,
-      query,
-      body,
-      isRefresh: Boolean(isRefresh),
-      requestId,
-    });
-    res.json(result);
-  } catch (error: any) {
-    console.warn('[tikhub-relay] forward failed:', redactSecrets(error?.message || error));
-    if (isRelayError(error)) {
-      res.status(error.status).json({
-        success: false,
-        code: error.code,
-        message: error.message,
-        requestId,
-        timestamp: new Date().toISOString(),
-      });
-      return;
-    }
-    res.status(500).json({
-      success: false,
-      message: error?.message || 'TikHub relay failed',
-      requestId,
-      timestamp: new Date().toISOString(),
-    });
-  }
-});
-
-export default router;

+ 59 - 29
backend/api/routes.ts

@@ -1,14 +1,14 @@
 /**
  * API 路由主入口(客户独立实例)
  * - Amazon:保留本地 SP-API 模块
- * - Sorftime / TikHub:仅走公司中转 Relay(src/relay + api/relay)
+ * - Sorftime / TikHub:浏览器直打公司中转;定时任务经 src/relay RelayClient
  * - 已移除:直连 sorftime / tikhub / eccang 模块
  */
 import express from 'npm:express';
 
 const router = express.Router();
 
-console.log('[API Routes] Initializing TARGET customer instance...');
+console.log('[API Routes] Initializing Jianhen customer instance...');
 
 router.use((req, res, next) => {
   console.log(`[API Router] Hit: ${req.method} ${req.url} (Original: ${req.originalUrl})`);
@@ -16,56 +16,86 @@ router.use((req, res, next) => {
 });
 
 router.get('/ping', (req, res) => {
-  res.json({ message: 'pong', instance: 'TARGET-voc-instance' });
+  res.json({ message: 'pong', instance: 'jianhen-voc' });
 });
 
+function isInternalRequest(req) {
+  const address = String(req.socket?.remoteAddress || '');
+  return !req.headers['x-external-request']
+    && (address === '127.0.0.1' || address === '::1' || address.endsWith(':127.0.0.1'));
+}
+
+async function requireApiUser(req, res, next) {
+  if (isInternalRequest(req)) return next();
+  const token = String(req.headers['x-parse-session-token'] || '').trim();
+  if (!token) return res.status(401).json({ success: false, message: '需要登录' });
+
+  try {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query('_Session');
+    query.equalTo('sessionToken', token);
+    query.include('user');
+    const session = await query.first({ useMasterKey: true });
+    const user = session?.get('user');
+    if (!user || user.get('status') === 'disabled') {
+      return res.status(401).json({ success: false, message: '会话无效' });
+    }
+    req.currentUser = user;
+    next();
+  } catch {
+    res.status(401).json({ success: false, message: '会话无效' });
+  }
+}
+
+function requireAdmin(req, res, next) {
+  if (isInternalRequest(req) || req.currentUser?.get?.('isAdmin') === true) return next();
+  res.status(403).json({ success: false, message: '需要管理员权限' });
+}
+
+function requireUserExceptHealth(req, res, next) {
+  if (req.path === '/health') return next();
+  return requireApiUser(req, res, next);
+}
+
+function requireAdminExceptToken(req, res, next) {
+  if (req.path === '/token') return next();
+  return requireAdmin(req, res, next);
+}
+
 /**
  * 亚马逊相关接口
  * 必须携带店铺的 id
  */
-let createSpApiRouter;
-try {
-  ({ createSpApiRouter } = await globalThis.loadModule(
-    'https://repos.fmode.cn/x/fmode-amazon-sp-api/0.1.3/fmode-amazon-sp-api.min.js?code=xxxxxxx'
-  ));
-} catch (e) {
-  console.warn('[API Routes] CDN 加载失败, 使用本地模块回退', e);
-  ({ createSpApiRouter } = await import('../modules/fmode-amazon-sp-api/src/mod.ts'));
-}
-router.use('/amazon', createSpApiRouter());
+import { createSpApiRouter } from '../modules/fmode-amazon-sp-api/src/mod.ts';
+router.use('/amazon', requireApiUser, createSpApiRouter());
 
 /**
- * Sorftime / TikHub:公司中转 Relay
- * 对外契约:
- *   POST /api/sorftime/forward
- *   POST /api/tikhub/forward
+ * 公司中转密钥/余额:仅管理员
+ * Sorftime / TikHub 浏览器请求不再经过本机 /forward。
  */
-import sorftimeRelayRouter from './relay/sorftime-routes.ts';
-import tikhubRelayRouter from './relay/tikhub-routes.ts';
+import relayCredentialRouter from './relay/credential-routes.ts';
 
-router.use('/sorftime', sorftimeRelayRouter);
-router.use('/tikhub', tikhubRelayRouter);
-console.log('[API Routes] ✅ Sorftime/TikHub relay mounted (company server.fmode.cn)');
+router.use('/relay', requireApiUser, requireAdminExceptToken, relayCredentialRouter);
+console.log('[API Routes] ✅ Company relay credential routes mounted');
 
-// 定时任务管理路由(内部已改为打本机 /api/sorftime/forward)
+// 定时任务管理路由(内部已改为 RelayClient 直打公司中转)
 import scheduleRouter from './module/schedule/routes-schedule.ts';
 try {
-  router.use('/schedule', scheduleRouter);
+  router.use('/schedule', requireApiUser, requireAdmin, scheduleRouter);
 } catch (e) {
   console.warn('[API Routes] scheduleRouter 加载失败', e);
 }
 
 router.get('/', (req, res) => {
   res.json({
-    message: 'TARGET VOC Instance API Routes Loaded',
+    message: 'Jianhen VOC Instance API Routes Loaded',
     version: '1.0.1-customer-relay',
     timestamp: new Date().toISOString(),
     availableRoutes: [
       '/api/amazon',
-      '/api/sorftime/forward',
-      '/api/sorftime/health',
-      '/api/tikhub/forward',
-      '/api/tikhub/health',
+      '/api/relay/token',
+      '/api/relay/status',
+      '/api/relay/credential',
       '/api/schedule',
     ],
     removedModules: [

+ 1 - 3
backend/cloud/providers/index.js

@@ -1,10 +1,8 @@
 // Providers 统一导出入口
 // 符合 ESM 模块规范
 
-// 导出七牛云服务
-export { qiniuUptoken } from './qiniu.service.js';
-
 // 导入路由注册(会自动注册 Parse Cloud 函数)
 import './routes.js';
+import './managed-user.service.js';
 
 export default {};

+ 548 - 0
backend/cloud/providers/managed-user.service.js

@@ -0,0 +1,548 @@
+function publicUser(user) {
+  return {
+    objectId: user.id,
+    username: user.get('username') || '',
+    realname: user.get('realname') || '',
+    email: user.get('email') || '',
+    mobile: user.get('mobile') || '',
+    department: user.get('department') || '',
+    status: user.get('status') || 'active',
+    isAdmin: user.get('isAdmin') === true,
+    role: user.get('role') || (user.get('isAdmin') ? 'admin' : 'user'),
+    createdTime: user.createdAt?.toISOString?.() || '',
+  };
+}
+
+async function requireUser(request) {
+  if (!request.user) throw new Parse.Error(Parse.Error.SESSION_MISSING, '需要登录');
+  if (request.user.get('status') === 'disabled') {
+    throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, '账号已禁用');
+  }
+  return request.user;
+}
+
+async function requireAdmin(request) {
+  const user = await requireUser(request);
+  if (user.get('isAdmin') !== true) {
+    throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, '需要管理员权限');
+  }
+  return user;
+}
+
+async function queryRows(className, asins = [], limit = 1000) {
+  const query = new Parse.Query(className);
+  if (asins.length) query.containedIn('asin', asins);
+  query.limit(Math.min(Math.max(limit, 1), 5000));
+  const rows = await query.find({ useMasterKey: true });
+  return rows.map(row => row.toJSON());
+}
+
+const CNY_RATES = {
+  CNY: 1,
+  USD: 7.20,
+  CAD: 5.25,
+  MXN: 0.38,
+  EUR: 7.80,
+  GBP: 9.40,
+  JPY: 0.048,
+  AUD: 4.70,
+  BRL: 1.30,
+  SEK: 0.68,
+  PLN: 1.85,
+  SGD: 5.50,
+  AED: 1.96,
+  SAR: 1.92,
+};
+
+function roundMoney(value) {
+  return Math.round((Number(value) || 0) * 100) / 100;
+}
+
+function toCny(amount, currency) {
+  const code = String(currency || '').trim().toUpperCase();
+  const rate = CNY_RATES[code];
+  if (!rate) return null;
+  return roundMoney(amount * rate);
+}
+
+const PERMISSION_CLASS = 'UserProductPermission';
+
+function cleanKeys(values) {
+  return [...new Set((Array.isArray(values) ? values : []).map(value => String(value || '').trim()).filter(Boolean))];
+}
+
+function emptyPolicy() {
+  return {
+    status: 'active',
+    scopeType: 'product',
+    productLayer: 'own_store_product',
+    mode: 'store_designer_intersection',
+    storeKeys: [],
+    designerKeys: [],
+    stores: [],
+    designers: [],
+    rules: [],
+    permissionVersion: 1,
+  };
+}
+
+function policyFromRow(row) {
+  if (!row) return emptyPolicy();
+  const policy = row.get('policy') || {};
+  return {
+    ...emptyPolicy(),
+    ...policy,
+    objectId: row.id,
+    status: policy.status || 'active',
+    storeKeys: cleanKeys(policy.storeKeys),
+    designerKeys: cleanKeys(policy.designerKeys),
+    stores: Array.isArray(policy.stores) ? policy.stores : [],
+    designers: Array.isArray(policy.designers) ? policy.designers : [],
+    rules: Array.isArray(policy.rules) ? policy.rules : [],
+    permissionVersion: Number(policy.permissionVersion || 1),
+  };
+}
+
+async function getPermissionRow(user) {
+  const query = new Parse.Query(PERMISSION_CLASS);
+  query.equalTo('user', user);
+  return query.first({ useMasterKey: true });
+}
+
+async function getPermissionPolicy(user) {
+  return policyFromRow(await getPermissionRow(user));
+}
+
+function normalizePermissionPolicy(input) {
+  const source = input && typeof input === 'object' ? input : {};
+  const policy = emptyPolicy();
+  policy.mode = source.mode === 'store_designer_matrix' ? source.mode : 'store_designer_intersection';
+  policy.storeKeys = cleanKeys(source.storeKeys);
+  policy.designerKeys = cleanKeys(source.designerKeys);
+  policy.stores = Array.isArray(source.stores) ? source.stores : [];
+  policy.designers = Array.isArray(source.designers) ? source.designers : [];
+  policy.rules = Array.isArray(source.rules) ? source.rules : [];
+  return policy;
+}
+
+async function savePermissionPolicy(target, input, updatedBy) {
+  const policy = normalizePermissionPolicy(input);
+  const row = await getPermissionRow(target) || new Parse.Object(PERMISSION_CLASS);
+  row.set('user', target);
+  row.set('policy', policy);
+  row.set('updatedBy', updatedBy);
+  await row.save(null, { useMasterKey: true });
+  return policy;
+}
+
+function permissionScope(user, policy = emptyPolicy()) {
+  const isAdmin = user.get('isAdmin') === true;
+  return {
+    isAdmin,
+    status: user.get('status') || 'active',
+    permissionVersion: policy.permissionVersion || 1,
+    productScope: {
+      all: isAdmin,
+      productLayer: 'own_store_product',
+      mode: policy.mode || 'store_designer_intersection',
+      storeKeys: isAdmin ? [] : policy.storeKeys,
+      designerKeys: isAdmin ? [] : policy.designerKeys,
+      stores: isAdmin ? [] : policy.stores,
+      designers: isAdmin ? [] : policy.designers,
+    },
+  };
+}
+
+function rowMatchesPermission(row, policy) {
+  const stores = new Set(policy.storeKeys);
+  const designers = new Set(policy.designerKeys);
+  if (!stores.size && !designers.size) return false;
+  const storeCandidates = [row.get('shopId'), row.get('storeId'), row.get('shopName'), row.get('storeName'), row.get('sellerId'), row.get('userAccount')];
+  const userAccount = String(row.get('userAccount') || '').trim();
+  const sellerId = String(row.get('sellerId') || '').trim();
+  const site = String(row.get('site') || '').trim();
+  if (userAccount && site) storeCandidates.push(`${userAccount}::${site}`);
+  if (sellerId && site) storeCandidates.push(`${sellerId}::${site}`);
+  const designerCandidates = [row.get('designerId'), row.get('designerName')];
+  const storeMatched = storeCandidates.some(value => stores.has(String(value || '').trim()));
+  const designerMatched = designerCandidates.some(value => designers.has(String(value || '').trim()));
+  if (stores.size && !storeMatched) return false;
+  if (designers.size && !designerMatched) return false;
+  return true;
+}
+
+function productSummary(row) {
+  const data = row.toJSON();
+  return {
+    objectId: row.id,
+    asin: data.asin || '',
+    parentAsin: data.parentAsin || '',
+    sellerSku: data.sellerSku || '',
+    productSku: data.productSku || '',
+    itemName: data.itemName || '',
+    site: data.site || '',
+    userAccount: data.userAccount || data.shopName || '',
+    designerName: data.designerName || '',
+    imageUrl: data.imageUrl || '',
+    shopId: data.shopId || '',
+    shopName: data.shopName || '',
+  };
+}
+
+async function findAll(queryFactory, maxRows = 10000) {
+  const rows = [];
+  while (rows.length < maxRows) {
+    const query = queryFactory();
+    query.skip(rows.length);
+    query.limit(Math.min(1000, maxRows - rows.length));
+    const page = await query.find({ useMasterKey: true });
+    rows.push(...page);
+    if (page.length < 1000) break;
+  }
+  return rows;
+}
+
+Parse.Cloud.define('managed_user_admin', async request => {
+  const user = await requireUser(request);
+  const { action, ...params } = request.params || {};
+
+  if (action === 'resolveMyScope') {
+    const policy = user.get('isAdmin') === true ? emptyPolicy() : await getPermissionPolicy(user);
+    return { success: true, data: permissionScope(user, policy) };
+  }
+
+  if (action === 'getProductDimensions') {
+    const rows = await queryRows('ProductDetail', params.asins || []);
+    const dimensions = {};
+    rows.forEach(row => { if (row.asin) dimensions[row.asin] = row; });
+    return { success: true, data: { dimensions } };
+  }
+
+  if (action === 'getAsinSkuMappings') {
+    const rows = await queryRows('AsinSkuMapping', params.asins || []);
+    const mappings = {};
+    rows.forEach(row => { if (row.asin) mappings[row.asin] = row; });
+    return { success: true, data: { mappings } };
+  }
+
+  if (action === 'findAsinSkuRowsByAsins' || action === 'findAsinSkuRowsByFields') {
+    const identifiers = params.asins || params.identifiers || [];
+    return { success: true, data: { rows: await queryRows('AsinSkuMapping', identifiers) } };
+  }
+
+  if (action === 'findProductDetailRows') {
+    const asinRows = await queryRows('ProductDetail', params.asins || []);
+    const parentQuery = new Parse.Query('ProductDetail');
+    if ((params.parentAsins || []).length) parentQuery.containedIn('parentAsin', params.parentAsins);
+    else parentQuery.equalTo('objectId', '__none__');
+    parentQuery.limit(1000);
+    const parentRows = (await parentQuery.find({ useMasterKey: true })).map(row => row.toJSON());
+    return { success: true, data: { asinRows, parentRows } };
+  }
+
+  if (action === 'resolveDimensionFilterAsins' || action === 'findDimensionAsinsBySearch') {
+    return { success: true, data: { asins: null } };
+  }
+
+  if (action === 'listAccessibleStoreProducts') {
+    const page = Math.max(Number(params.page || 1), 1);
+    const pageSize = Math.min(Math.max(Number(params.pageSize || 50), 1), 5000);
+    const isAdmin = user.get('isAdmin') === true;
+    const policy = isAdmin ? emptyPolicy() : await getPermissionPolicy(user);
+    const rows = await findAll(() => new Parse.Query('ProductDetail'));
+    const visible = isAdmin ? rows : rows.filter(row => rowMatchesPermission(row, policy));
+    const start = (page - 1) * pageSize;
+    return {
+      success: true,
+      data: {
+        products: visible.slice(start, start + pageSize).map(productSummary),
+        total: visible.length,
+        page,
+        pageSize,
+        stats: { total: visible.length, active: visible.length, fba: 0, fbm: 0 },
+        siteDistribution: [],
+        options: { sites: [], shops: [], designers: [], fulfillments: [], operators: [] },
+        scope: permissionScope(user, policy),
+      },
+    };
+  }
+
+  await requireAdmin(request);
+
+  if (action === 'list') {
+    const page = Math.max(Number(params.page || 1), 1);
+    const pageSize = Math.min(Math.max(Number(params.pageSize || 20), 1), 100);
+    const query = new Parse.Query(Parse.User);
+    const filters = params.filters || {};
+    if (filters.username) query.matches('username', filters.username, 'i');
+    if (filters.realname) query.matches('realname', filters.realname, 'i');
+    if (filters.status) query.equalTo('status', filters.status);
+    if (filters.department) query.equalTo('department', filters.department);
+    query.skip((page - 1) * pageSize);
+    query.limit(pageSize);
+    const [users, total] = await Promise.all([
+      query.find({ useMasterKey: true }),
+      query.count({ useMasterKey: true }),
+    ]);
+    return { success: true, data: { users: users.map(publicUser), total } };
+  }
+
+  if (action === 'create') {
+    const target = new Parse.User();
+    ['username', 'password', 'realname', 'email', 'mobile', 'department', 'status', 'isAdmin'].forEach(key => {
+      if (params[key] !== undefined && params[key] !== '') target.set(key, params[key]);
+    });
+    target.set('role', params.isAdmin ? 'admin' : 'user');
+    await target.save(null, { useMasterKey: true });
+    if (params.isAdmin !== true && params.permissionPolicy) {
+      await savePermissionPolicy(target, params.permissionPolicy, user);
+    }
+    return { success: true, data: publicUser(target) };
+  }
+
+  if (['update', 'resetPassword', 'delete'].includes(action)) {
+    const target = await new Parse.Query(Parse.User).get(params.objectId, { useMasterKey: true });
+    if (action === 'delete') {
+      const existing = await getPermissionRow(target);
+      if (existing) await existing.destroy({ useMasterKey: true });
+      await target.destroy({ useMasterKey: true });
+    }
+    if (action === 'resetPassword') {
+      target.set('password', params.password);
+      target.set('forceChangePassword', Boolean(params.forceChange));
+      await target.save(null, { useMasterKey: true });
+    }
+    if (action === 'update') {
+      ['realname', 'email', 'mobile', 'department', 'status', 'isAdmin'].forEach(key => {
+        if (params[key] !== undefined) target.set(key, params[key]);
+      });
+      target.set('role', params.isAdmin ? 'admin' : 'user');
+      await target.save(null, { useMasterKey: true });
+      if (params.permissionPolicy) {
+        if (params.isAdmin === true) {
+          const existing = await getPermissionRow(target);
+          if (existing) await existing.destroy({ useMasterKey: true });
+        } else {
+          await savePermissionPolicy(target, params.permissionPolicy, user);
+        }
+      }
+    }
+    return { success: true, data: action === 'delete' ? {} : publicUser(target) };
+  }
+
+  if (action === 'listOptions') {
+    const rows = await findAll(() => new Parse.Query('ProductDetail'));
+    const stores = new Map();
+    const designers = new Map();
+    rows.forEach(row => {
+      const shopId = String(row.get('shopId') || row.get('storeId') || '').trim();
+      const shopName = String(row.get('shopName') || row.get('storeName') || '').trim();
+      if (shopId || shopName) {
+        const key = shopId || shopName;
+        const current = stores.get(key) || { key, value: key, label: shopName || shopId, count: 0 };
+        current.count += 1;
+        stores.set(key, current);
+      }
+      const designerId = String(row.get('designerId') || '').trim();
+      const designerName = String(row.get('designerName') || '').trim();
+      if (designerId || designerName) {
+        const key = designerId || designerName;
+        const current = designers.get(key) || {
+          key,
+          value: key,
+          label: designerName || designerId,
+          designerId,
+          designerName,
+          count: 0,
+        };
+        current.count += 1;
+        designers.set(key, current);
+      }
+    });
+    const byLabel = (left, right) => left.label.localeCompare(right.label, 'zh-CN');
+    return {
+      success: true,
+      data: {
+        stores: [...stores.values()].sort(byLabel),
+        designers: [...designers.values()].sort(byLabel),
+      },
+    };
+  }
+  if (action === 'getPolicy') {
+    const target = await new Parse.Query(Parse.User).get(params.objectId, { useMasterKey: true });
+    return { success: true, data: await getPermissionPolicy(target) };
+  }
+  if (action === 'savePolicy') {
+    const target = await new Parse.Query(Parse.User).get(params.objectId, { useMasterKey: true });
+    await savePermissionPolicy(target, params.permissionPolicy || {}, user);
+    return { success: true, data: {} };
+  }
+  if (action === 'previewPolicy') {
+    const policy = normalizePermissionPolicy(params.permissionPolicy || params);
+    const rows = await findAll(() => new Parse.Query('ProductDetail'));
+    const visible = rows.filter(row => rowMatchesPermission(row, policy));
+    return { success: true, data: { total: visible.length, samples: visible.slice(0, 8).map(productSummary) } };
+  }
+
+  throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Unknown action: ${action}`);
+});
+
+Parse.Cloud.define('dashboard_metrics', async request => {
+  await requireUser(request);
+  const shopId = String(request.params?.shop || '').trim();
+  const shop = shopId ? Parse.Object.extend('Shop').createWithoutData(shopId) : null;
+  const startDate = request.params?.startDate ? new Date(request.params.startDate) : null;
+  const endDate = request.params?.endDate ? new Date(request.params.endDate) : null;
+  const hasStartDate = startDate && !Number.isNaN(startDate.getTime());
+  const hasEndDate = endDate && !Number.isNaN(endDate.getTime());
+
+  const productQuery = new Parse.Query('Product');
+  const reviewQuery = new Parse.Query('SorftimeReviews');
+  const returnQuery = new Parse.Query('ReturnRecord');
+  if (shop) {
+    productQuery.equalTo('shop', shop);
+    reviewQuery.equalTo('shop', shop);
+    returnQuery.equalTo('shop', shop);
+  }
+  if (hasStartDate) returnQuery.greaterThanOrEqualTo('returnDate', startDate);
+  if (hasEndDate) returnQuery.lessThanOrEqualTo('returnDate', endDate);
+  productQuery.limit(1000);
+  reviewQuery.limit(5000);
+  const [products, orders, reviews, returnCount] = await Promise.all([
+    productQuery.find({ useMasterKey: true }),
+    findAll(() => {
+      const query = new Parse.Query('Order');
+      if (shop) query.equalTo('shop', shop);
+      if (hasStartDate) query.greaterThanOrEqualTo('orderDate', startDate);
+      if (hasEndDate) query.lessThanOrEqualTo('orderDate', endDate);
+      return query;
+    }),
+    reviewQuery.find({ useMasterKey: true }),
+    returnQuery.count({ useMasterKey: true }),
+  ]);
+
+  const productJson = products.map(row => row.toJSON());
+  const reviewJson = reviews.map(row => row.toJSON());
+  const ratings = productJson.map(row => Number(row.ratings || row.rating || 0)).filter(value => value > 0);
+  const averageRating = ratings.length ? ratings.reduce((sum, value) => sum + value, 0) / ratings.length : 0;
+  const positiveReviews = reviewJson.filter(row => Number(row.star || row.rating || row.Star || 0) >= 4).length;
+  const positiveEmotionRate = reviewJson.length
+    ? positiveReviews / reviewJson.length * 100
+    : (averageRating > 0 ? Math.max(0, Math.min(100, (averageRating - 1) / 4 * 100)) : 0);
+  const currencyByMarketplace = {
+    ATVPDKIKX0DER: 'USD', A2EUQ1WTGCTBG2: 'CAD', A1AM78C64UM0Y8: 'MXN', A2Q3Y263D00KWC: 'BRL',
+    A1F83G8C2ARO7P: 'GBP', A1PA6795UKMFR9: 'EUR', A13V1IB3VIYZZH: 'EUR', A1RKKUPIHCS9HS: 'EUR',
+    APJ6JRA9NG5V4: 'EUR', A1805IZSGTT6HS: 'EUR', A2NODRKZP88ZB9: 'SEK', A1C3SOZRARQ6R3: 'PLN',
+    AMEN7PMS3EDWL: 'EUR', A28R8C7NBKEWEA: 'EUR', A1VC38T7YXB528: 'JPY', A39IBJ37TRP1C6: 'AUD',
+    A19VAU5U5O7RUS: 'SGD', A2VIGQ35RCS4UG: 'AED', A17E79C6D8DWNP: 'SAR',
+  };
+  const salesByCurrency = {};
+  const salesCnyByCurrency = {};
+  const unknownCurrencies = new Set();
+  let totalSalesCny = 0;
+  orders.forEach(row => {
+    const amount = Number(row.get('totalAmount') || 0);
+    if (!amount) return;
+    const currency = String(
+      row.get('currency') || currencyByMarketplace[row.get('marketplaceId')] || 'UNKNOWN'
+    ).toUpperCase();
+    salesByCurrency[currency] = (salesByCurrency[currency] || 0) + amount;
+    const cnyAmount = toCny(amount, currency);
+    if (cnyAmount == null) {
+      unknownCurrencies.add(currency);
+      return;
+    }
+    salesCnyByCurrency[currency] = (salesCnyByCurrency[currency] || 0) + cnyAmount;
+    totalSalesCny += cnyAmount;
+  });
+  Object.keys(salesByCurrency).forEach(currency => {
+    salesByCurrency[currency] = roundMoney(salesByCurrency[currency]);
+  });
+  Object.keys(salesCnyByCurrency).forEach(currency => {
+    salesCnyByCurrency[currency] = roundMoney(salesCnyByCurrency[currency]);
+  });
+  totalSalesCny = roundMoney(totalSalesCny);
+  const currencies = Object.keys(salesByCurrency).sort();
+  const mixedCurrency = currencies.length > 1;
+
+  return {
+    success: true,
+    data: {
+      positiveEmotionRate: Math.round(positiveEmotionRate * 10) / 10,
+      satisfactionScore: Math.round(averageRating * 100) / 100,
+      returnRate: orders.length ? Math.round(returnCount / orders.length * 1000) / 10 : 0,
+      vocVolume: reviewJson.length || productJson.reduce((sum, row) => sum + Number(row.ratingsCount || 0), 0),
+      newCustomerRate: 0,
+      totalOrders: orders.length,
+      totalSales: totalSalesCny,
+      averageOrderPrice: orders.length ? roundMoney(totalSalesCny / orders.length) : 0,
+      currency: 'CNY',
+      mixedCurrency,
+      salesByCurrency,
+      salesCnyByCurrency,
+      fxBaseCurrency: 'CNY',
+      fxMissingCurrencies: [...unknownCurrencies].sort(),
+      trendChange: 0,
+      trendChangeType: 'stable',
+    },
+  };
+});
+
+Parse.Cloud.define('upsert_amazon_product', async request => {
+  await requireUser(request);
+  const raw = request.params?.data?.Data || request.params?.data || request.params?.product || {};
+  const asin = String(raw.Asin || raw.ASIN || raw.asin || '').trim().toUpperCase();
+  if (!asin) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'ASIN is required');
+
+  const query = new Parse.Query('AmazonProduct');
+  query.equalTo('asin', asin);
+  let product = null;
+  try {
+    product = await query.first({ useMasterKey: true });
+  } catch (error) {
+    const message = String(error?.message || error || '');
+    if (!message.includes('does not exist') && !message.includes('non-existent class')) throw error;
+  }
+  if (!product) product = new Parse.Object('AmazonProduct');
+
+  const fields = {
+    asin,
+    parentAsin: raw.ParentAsin || raw.ParentASIN || raw.parentAsin || '',
+    title: raw.Title || raw.title || '',
+    brand: raw.Brand || raw.brand || '',
+    imageUrl: Array.isArray(raw.Photo) ? raw.Photo[0] : (raw.Photo || raw.imageUrl || ''),
+    photo: Array.isArray(raw.Photo) ? raw.Photo[0] : (raw.Photo || raw.photo || ''),
+    category: Array.isArray(raw.Category || raw.category)
+      ? String((raw.Category || raw.category)[0] || '')
+      : String(raw.Category || raw.category || ''),
+    bsrCategory: raw.BsrCategory || raw.bsrCategory || [],
+    price: Number(raw.Price || raw.price || 0),
+    salesPrice: Number(raw.SalesPrice || raw.salesPrice || raw.Price || raw.price || 0),
+    ratings: Number(raw.Ratings || raw.ratings || raw.Rating || raw.rating || 0),
+    rating: Number(raw.Ratings || raw.ratings || raw.Rating || raw.rating || 0),
+    ratingsCount: Number(raw.RatingsCount || raw.ratingsCount || 0),
+    rank: Number(raw.Rank || raw.rank || 0),
+    listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || raw.listingSalesVolumeOfMonth || 0),
+    sellerId: raw.BuyboxSellerId || raw.sellerId || '',
+    source: 'sorftime',
+    rawData: raw,
+  };
+  Object.entries(fields).forEach(([key, value]) => product.set(key, value));
+  await product.save(null, { useMasterKey: true });
+  return { success: true, data: product.toJSON() };
+});
+
+Parse.Cloud.define('return_records', async request => {
+  await requireUser(request);
+  const query = new Parse.Query('ReturnRecord');
+  const shopId = String(request.params?.shop || '').trim();
+  if (shopId) query.equalTo('shop', Parse.Object.extend('Shop').createWithoutData(shopId));
+  const startDate = request.params?.startDate ? new Date(request.params.startDate) : null;
+  const endDate = request.params?.endDate ? new Date(request.params.endDate) : null;
+  if (startDate && !Number.isNaN(startDate.getTime())) query.greaterThanOrEqualTo('returnDate', startDate);
+  if (endDate && !Number.isNaN(endDate.getTime())) query.lessThanOrEqualTo('returnDate', endDate);
+  query.descending('returnDate');
+  query.limit(5000);
+  const rows = await query.find({ useMasterKey: true });
+  return { success: true, data: rows.map(row => row.toJSON()) };
+});

+ 3 - 24
backend/cloud/providers/routes.js

@@ -1,28 +1,7 @@
-import {qiniuUptoken} from './qiniu.service.js';
 import './user.service.js';
 
-Parse.Cloud.define("qiniu_uptoken", async (request) => {
-    // 从全局配置中获取七牛云配置
-    let configQiniu = globalThis.appConfig?.qiniu || {
-      ak: 'RBzrmzIHEo-0-Obmm5lueNjYducqXIW0-Sgf2FD3',
-      sk: 'HOvK_8u9R7FeKgYzw-teuugNzInzwszhMFiNq_D1',
-      bucket: '',
-      domain: '',
-      zoneUrl: '',
-      zone: 'z2',
-      max_file_size: 400
-    };
-
-    let cid = request.params.company;
-    if(cid){
-        let query = new Parse.Query("Company");
-        let company = await query.get(cid);
-        if(company.get("configQiniu") && company.get("configQiniu").ak){
-            configQiniu = company.get("configQiniu");
-        }
-    }
-    console.log('七牛云配置:', configQiniu);
-    return qiniuUptoken(configQiniu);
+Parse.Cloud.define("qiniu_uptoken", async () => {
+    throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, '文件上传服务未启用');
 });
 
-export default {}
+export default {}

+ 11 - 32
backend/cloud/providers/user.service.js

@@ -5,39 +5,15 @@
  * 客户端/服务端传输应使用 HTTPS 保证安全。
  */
 import crypto from 'crypto';
+import fs from 'fs';
 
 console.log('Loading user.service.js...');
 
-// RSA 私钥,用于解密前端发来的数据
-// 警告:在生产环境中,请不要硬编码私钥,建议通过环境变量注入
-const RSA_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDo3GIkDFqMjRmx
-Dt0LnM/HhSIKNMdBWOXUBqALY811q0RDWAoiAX/4L5BKntJumopMhIAJYjIsZTFz
-h8f3/afrejeUiV7DyXYD5ZKTD/ZyKyxcrFgEdLOqx3zovxuWExnQXrBnnUNruPMB
-eQvQgL3Cng5hq0gLrqdEqtz6qAfImtKhp2Kzwbj+LzQPkiABEU/4RLShB5m7rLgu
-gLrFYKEFRIkz/CwFtbQh8clJuwzoVtxtFC9E3c2F6T3vnoBzCqq5dXKfPm6laZJO
-3SkBBC8CmYTi+DyQnaDiGOIvP4/6jZzzvpz2jq+mltkRcXnHzCMEd0encDdCQj8B
-sfHgbIDxAgMBAAECggEACxH4FfHi56HpHeRgoJXTwG7dOYo9C0eiRHhMqUH1QXHz
-ELcJo0h6OJatR8jhZOnyp3TYn+mTzE3CwjTrLihYUG7q38RAlbL5CfIi69wjSZXx
-GdZFKHASXwz1DJ0SPwoZLPFldkWREBjWr6QdKkxGiS8prJ2/kvOaht6PGxj8Jr6T
-SJv8aAdy/oMB+maME1s1JSern+AINjD6f6yOwajxhE9KK8F9rHnyt7+tlGS/ATIv
-9FkEahKhz/K/ety8ns56ckOVIw7a740aq8eJ/JnOw4ISLRvMexJdCZKtLr4PpFnC
-TDL/VzrJ57eVAFj/YA3kXMBdKzzOPoIFpxETMc2dCQKBgQD8w7ctRjSo3o/fhLIA
-9LVjAgoWBFlATrXR/ncoPjHjCCo2G8X6IeMcL1RZTSs3NuX+xQZcqPcHmQRLceBJ
-PqvroKE8e/z8WgtPQn0kGKxwU0G9dKHbB57QB5GO5gHc9I0pVEYBNpq37VR7tQ1R
-BDJB2mP+l7yApmQFhffXhPt2yQKBgQDr13IPsRVNq2COTYP72Q2726xtk0+83AxW
-2y+CGPCW1gnM6bZzykeFZImBcOnlyC9rCqVWM5ZXcic1zApIX7HOaVGlkyLhi5ac
-IFrnKX9Ejz/6B+NccPGT+9b+3d7WWh4aANWwgk6l2sOAkJPMKH1nyusPqOIL5Hia
-KlporABE6QKBgQC8Fue4SQXmo7dsHNJY48AWbO4UdCf2MpwaJ8GDsMRurSS8Hu6z
-s4PM9WtPCJ2f+LotW2/gg9tPTA5mFddQqdJFBML2v00Ce/7ufAujULkA3POOh7vp
-wUHsi7VFIONCCLkCiDOPs5JuS60SYSgHoZfEaT7/tOoQ0cCcW5SSS+LUAQKBgQCi
-Y/jwARkQH/8ePRWyJmSkg8AYuyRegI4miEoQJKgU1FBvPkYZOKFColVkQaFsTQkJ
-hY0O6/qLP9jr3BEAgJPn8vD7N3L/JqSP4F93aEVvqct9mEzfbA2Lvg7t80vrAKZV
-dVmubitFXHrdXa/HFeIzw69TWxA67Bh23s7d4AFXcQKBgDMnq/jtZwyBoJhmwLKX
-nx/pG+G7X2Jt4BW9dbttBn5QeUw5ZwjOpOEU4NqYoM7Td7qpeTTQ/WS0OFsOfXcd
-baH3/NOVr6ntip6y38iYN1tbyaT4fCCHuesj6NdoicekM7564JrNhBAr1nqoc+9B
-q6YYTf819dRCh272VeKIFzjt
------END PRIVATE KEY-----`;
+function getLoginPrivateKey() {
+  const keyPath = process.env.LOGIN_RSA_PRIVATE_KEY_FILE;
+  if (!keyPath) throw new Error('LOGIN_RSA_PRIVATE_KEY_FILE is not configured');
+  return fs.readFileSync(keyPath, 'utf8');
+}
 
 /**
  * 解密 RSA 加密的数据
@@ -49,7 +25,7 @@ function decryptRSA(encryptedData) {
     const buffer = Buffer.from(encryptedData, 'base64');
     const decrypted = crypto.privateDecrypt(
       {
-        key: RSA_PRIVATE_KEY,
+        key: getLoginPrivateKey(),
         // 根据前端使用的 padding 方式进行调整,常见的是 crypto.constants.RSA_PKCS1_PADDING 或 RSA_PKCS1_OAEP_PADDING
         padding: crypto.constants.RSA_PKCS1_PADDING, 
       },
@@ -68,7 +44,7 @@ function decryptRSA(encryptedData) {
  * 此时传入的 username 和 password 为 RSA 加密并经过 Base64 编码的字符串
  */
 Parse.Cloud.define('user_login', async request => {
-  console.log('Cloud Function called: user_login', request.params);
+  console.log('Cloud Function called: user_login');
   const { username: encryptedUsername, password: encryptedPassword } = request.params;
 
   // 参数校验
@@ -106,6 +82,9 @@ Parse.Cloud.define('user_login', async request => {
  * 调用方式: Parse.Cloud.run('user_signup', { username, password, email, ...attributes })
  */
 Parse.Cloud.define('user_signup', async request => {
+  if (!request.master) {
+    throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, '公开注册已禁用');
+  }
   console.log('Cloud Function called: user_signup');
   const { username, password, email, ...attributes } = request.params;
 

+ 4747 - 0
backend/deploy-runtime-routes.js

@@ -0,0 +1,4747 @@
+// backend/api/routes.ts
+import express4 from "npm:express";
+import { getStoredRelayAuth, isUsableAuth } from "../src/relay/relay-runtime.js";
+import relayCredentialRouter from "./relay/credential-routes.js";
+
+// backend/modules/fmode-amazon-sp-api/src/routes.ts
+import { Router } from "express";
+
+// backend/modules/fmode-amazon-sp-api/src/marketplace-helper.ts
+var ENDPOINTS = {
+  NA: "https://sellingpartnerapi-na.amazon.com",
+  EU: "https://sellingpartnerapi-eu.amazon.com",
+  FE: "https://sellingpartnerapi-fe.amazon.com"
+};
+var SANDBOX_ENDPOINTS = {
+  NA: "https://sandbox.sellingpartnerapi-na.amazon.com",
+  EU: "https://sandbox.sellingpartnerapi-eu.amazon.com",
+  FE: "https://sandbox.sellingpartnerapi-fe.amazon.com"
+};
+var MARKETPLACE_REGION_MAP = {
+  // 北美
+  "A2EUQ1WTGCTBG2": "NA",
+  // 加拿大
+  "ATVPDKIKX0DER": "NA",
+  // 美国
+  "A1AM78C64UM0Y8": "NA",
+  // 墨西哥
+  "A2Q3Y263D00KWC": "NA",
+  // 巴西
+  // 欧洲
+  "A1RKKUPIHCS9HS": "EU",
+  // 西班牙
+  "A1F83G8C2ARO7P": "EU",
+  // 英国
+  "A13V1IB3VIYZZH": "EU",
+  // 法国
+  "A1805IZSGTT6HS": "EU",
+  // 荷兰
+  "A1PA6795UKMFR9": "EU",
+  // 德国
+  "APJ6JRA9NG5V4": "EU",
+  // 意大利
+  "A2NODRKZP88ZB9": "EU",
+  // 瑞典
+  "AE08WJ6EMKS27": "EU",
+  // 埃及
+  "A1C3SOZRARQ6R3": "EU",
+  // 波兰
+  "AMEN7PMS3EDWL": "EU",
+  // 比利时
+  "A28R8C7NBKEWEA": "EU",
+  // 爱尔兰
+  "A33AVAJ2PDY3EV": "EU",
+  // 土耳其
+  "A17E79C6D8DWNP": "EU",
+  // 沙特阿拉伯
+  "A2VIGQ35RCS4UG": "EU",
+  // 阿联酋
+  "ARBP9OOSHTCHU": "EU",
+  // 埃及 (重复?)
+  "A21TJRUUN4KGV": "EU",
+  // 印度
+  // 远东
+  "A1VC38T7YXB528": "FE",
+  // 日本
+  "A39IBJ37TRP1C6": "FE",
+  // 澳大利亚
+  "A19VAU5U5O7RUS": "FE"
+  // 新加坡
+};
+var getEndpointByMarketplaceId = (marketplaceId, isSandbox = false) => {
+  const region = MARKETPLACE_REGION_MAP[marketplaceId] || "NA";
+  const endpoints = isSandbox ? SANDBOX_ENDPOINTS : ENDPOINTS;
+  return endpoints[region];
+};
+var getEndpointByRegion = (regionCode, isSandbox = false) => {
+  const endpoints = isSandbox ? SANDBOX_ENDPOINTS : ENDPOINTS;
+  const awsRegionMap = {
+    "us-east-1": "NA",
+    "eu-west-1": "EU",
+    "us-west-2": "FE"
+  };
+  const regionKey = awsRegionMap[regionCode] || regionCode;
+  if (endpoints[regionKey]) {
+    return endpoints[regionKey];
+  }
+  return endpoints.NA;
+};
+
+// backend/modules/fmode-amazon-sp-api/src/client.ts
+var SpApiClient = class {
+  // 配置在构造函数中现在是可选的,但可以传递用于遗留/静态用法
+  // 然而,对于基于 Parse 的多租户用法,我们依赖于方法参数。
+  // 为了保持向后兼容性并支持新的动态模式,我们可以使用混合方法。
+  // 对于动态用法,我们将主要不使用 this.config。
+  staticConfig;
+  // RDT Token 缓存 (内存缓存,按 shopId 和 resourceKey 存储)
+  rdtCache = /* @__PURE__ */ new Map();
+  constructor(config) {
+    this.staticConfig = config;
+  }
+  /**
+   * 获取基础 URL
+   */
+  getBaseUrl(marketplaceId, isSandbox, region) {
+    if (marketplaceId) {
+      return getEndpointByMarketplaceId(marketplaceId, isSandbox);
+    }
+    if (region) {
+      return getEndpointByRegion(region, isSandbox);
+    }
+    if (this.staticConfig) {
+      const REGION_ENDPOINTS = {
+        "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_ENDPOINTS2 = {
+        "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_ENDPOINTS2 : REGION_ENDPOINTS;
+      return endpoints[this.staticConfig.region] || endpoints["us-east-1"];
+    }
+    throw new Error("\u65E0\u6CD5\u786E\u5B9A SP-API \u7AEF\u70B9\uFF0CMarketplaceId \u4E3A\u5FC5\u586B\u9879\u3002");
+  }
+  /**
+   * 获取 LWA Access Token
+   * 先从 Shop 表获取缓存的 token,如果不存在或过期则刷新
+   */
+  async getAccessToken(context) {
+    if (context) {
+      const { shopId, config } = context;
+      return this.refreshAccessToken(
+        shopId,
+        config.clientId,
+        config.clientSecret,
+        config.refreshToken
+      );
+    }
+    throw new Error("\u7F3A\u5C11 SpApiClient \u914D\u7F6E\u3002");
+  }
+  /**
+   * 刷新 Token 逻辑
+   * 1. 先从 Shop 表的 config.SpApiConfig.accessToken 获取
+   * 2. 检查是否过期(通过 config.SpApiConfig.accessTokenExpiresAt)
+   * 3. 如果不存在或过期,则请求新的 token
+   * 4. 将新 token 和过期时间(50分钟后)保存回 Shop 表
+   */
+  async refreshAccessToken(shopId, clientId, clientSecret, refreshToken) {
+    try {
+      const Parse = globalThis.Parse;
+      if (!Parse) {
+        console.warn("[SP-API] Parse SDK not available, will fetch new token");
+      }
+      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;
+            if (cachedToken && expiresAt) {
+              const now = /* @__PURE__ */ new Date();
+              const expiryDate = new Date(expiresAt);
+              if (expiryDate > now) {
+                const remainingMinutes = Math.floor(
+                  (expiryDate.getTime() - now.getTime()) / 1e3 / 60
+                );
+                console.log(`[SP-API] \u4F7F\u7528\u7F13\u5B58\u7684 Access Token (\u5269\u4F59 ${remainingMinutes} \u5206\u949F)`);
+                return cachedToken;
+              } else {
+                console.log("[SP-API] \u7F13\u5B58\u7684 Access Token \u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u5237\u65B0...");
+              }
+            } else {
+              console.log("[SP-API] \u672A\u627E\u5230\u7F13\u5B58\u7684 Access Token\uFF0C\u6B63\u5728\u83B7\u53D6\u65B0\u7684...");
+            }
+          }
+        } catch (error) {
+          console.warn("[SP-API] \u4ECE Shop \u8868\u83B7\u53D6 token \u5931\u8D25:", error.message);
+        }
+      }
+      console.log("[SP-API] \u6B63\u5728\u5237\u65B0 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();
+      console.log("[SP-API] \u5237\u65B0 Access Token \u6210\u529F\u3002");
+      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 expiresAt = /* @__PURE__ */ new Date();
+            expiresAt.setMinutes(expiresAt.getMinutes() + 50);
+            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 \u5DF2\u4FDD\u5B58\u5230 Shop \u8868 (\u8FC7\u671F\u65F6\u95F4: ${expiresAt.toISOString()})`
+            );
+          }
+        } catch (error) {
+          console.error("[SP-API] \u4FDD\u5B58 token \u5230 Shop \u8868\u5931\u8D25:", error.message);
+        }
+      }
+      return data.access_token;
+    } catch (error) {
+      console.error("[SP-API] \u5237\u65B0 token \u5931\u8D25:", 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 区域
+   */
+  async getRestrictedDataToken(accessToken, baseUrl, method, path, dataElements) {
+    console.log(`[SP-API] \u6B63\u5728\u83B7\u53D6 RDT Token for ${method} ${path}...`);
+    const rdtEndpoint = `${baseUrl}/tokens/2021-03-01/restrictedDataToken`;
+    const cleanPath = path.startsWith("/") ? path : `/${path}`;
+    const requestBody = {
+      restrictedResources: [
+        {
+          method,
+          path: cleanPath
+        }
+      ]
+    };
+    console.log(`[SP-API] RDT \u8BF7\u6C42\u8BE6\u60C5:`, {
+      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 \u54CD\u5E94\u72B6\u6001: ${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 \u6743\u9650\u4E0D\u8DB3: \u5E94\u7528\u53EF\u80FD\u6CA1\u6709\u8BBF\u95EE PII \u6570\u636E\u7684\u6743\u9650\u3002\u9519\u8BEF\u8BE6\u60C5: ${JSON.stringify(errorData)}`
+          );
+        } else if (response.status === 400) {
+          throw new Error(`RDT \u8BF7\u6C42\u53C2\u6570\u9519\u8BEF: ${JSON.stringify(errorData)}`);
+        } else if (response.status === 500) {
+          throw new Error(
+            `Amazon \u5185\u90E8\u670D\u52A1\u5668\u9519\u8BEF: ${JSON.stringify(errorData)}\u3002\u5EFA\u8BAE\u7A0D\u540E\u91CD\u8BD5\u6216\u68C0\u67E5 API \u8DEF\u5F84\u662F\u5426\u6B63\u786E\u3002`
+          );
+        }
+        throw new Error(
+          `RDT request failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`
+        );
+      }
+      const data = JSON.parse(responseText);
+      console.log(`[SP-API] RDT Token \u83B7\u53D6\u6210\u529F (\u6709\u6548\u671F: ${data.expiresIn} \u79D2)`);
+      return data.restrictedDataToken;
+    } catch (error) {
+      console.error("[SP-API] \u83B7\u53D6 RDT Token \u5931\u8D25:", error.message);
+      throw error;
+    }
+  }
+  /**
+   * 发送 SP-API 请求
+   */
+  async request(options) {
+    const accessToken = await this.getAccessToken(options.context);
+    let marketplaceId = options.context?.marketplaceId;
+    if (!marketplaceId && options.query) {
+      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?.split(",")[0];
+      }
+    }
+    const isSandbox = options.context?.config.sandbox || this.staticConfig?.sandbox;
+    const region = options.context?.config.region || this.staticConfig?.region;
+    const baseUrl = this.getBaseUrl(marketplaceId, isSandbox, region);
+    let urlString = baseUrl.replace(/\/$/, "") + (options.path.startsWith("/") ? options.path : "/" + options.path);
+    const queryParts = [];
+    if (options.query) {
+      Object.entries(options.query).forEach(([key, value]) => {
+        if (value !== void 0 && value !== null) {
+          if ((key === "nextToken" || key === "pageToken") && typeof value === "string") {
+            try {
+              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 {
+            const encodedVal = encodeURIComponent(String(value)).replace(/%2C/g, ",");
+            queryParts.push(`${key}=${encodedVal}`);
+          }
+        }
+      });
+    }
+    if (queryParts.length > 0) {
+      urlString += (urlString.includes("?") ? "&" : "?") + queryParts.join("&");
+    }
+    let rdtToken;
+    if (options.requiresRdt && options.context) {
+      console.log(`[SP-API] \u68C0\u6D4B\u5230\u9700\u8981 RDT Token \u7684 API: ${options.path}`);
+      try {
+        rdtToken = await this.getRestrictedDataToken(
+          accessToken,
+          baseUrl,
+          options.method,
+          options.path,
+          options.rdtDataElements
+        );
+        console.log("[SP-API] RDT Token \u83B7\u53D6\u6210\u529F");
+      } catch (error) {
+        console.error("[SP-API] \u83B7\u53D6 RDT Token \u5931\u8D25\uFF0C\u5C06\u4F7F\u7528\u666E\u901A Access Token:", error.message);
+      }
+    }
+    const headers = {
+      "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] \u4F7F\u7528 RDT Token \u8BBF\u95EE\u53D7\u9650\u6570\u636E");
+    }
+    let retries = 0;
+    const maxRetries = 3;
+    while (retries < maxRetries) {
+      try {
+        console.log(`[SP-API] \u8BF7\u6C42: ${options.method} ${urlString}`, {
+          query: options.query,
+          shopId: options.context?.shopId
+        });
+        const fetchOptions = {
+          method: options.method,
+          headers
+        };
+        if (options.body && options.method !== "GET") {
+          fetchOptions.body = JSON.stringify(options.body);
+        }
+        const response = await fetch(urlString, fetchOptions);
+        if (!response.ok) {
+          if (response.status === 429) {
+            retries++;
+            if (retries >= maxRetries)
+              throw new Error(`Rate limit exceeded after ${maxRetries} retries`);
+            const retryAfterHeader = response.headers.get("retry-after");
+            const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 0;
+            const delay = retryAfter ? retryAfter * 1e3 : Math.pow(2, retries) * 1e3;
+            console.log(`[SP-API] \u901F\u7387\u9650\u5236\u3002${delay}ms \u540E\u91CD\u8BD5... (\u5C1D\u8BD5 ${retries}/${maxRetries})`);
+            await new Promise((resolve) => setTimeout(resolve, delay));
+            continue;
+          }
+          if (response.status >= 500) {
+            retries++;
+            if (retries >= maxRetries)
+              throw new Error(`Server error ${response.status} after ${maxRetries} retries`);
+            const delay = Math.pow(2, retries) * 1e3;
+            console.log(
+              `[SP-API] \u670D\u52A1\u5668\u9519\u8BEF ${response.status}\u3002${delay}ms \u540E\u91CD\u8BD5... (\u5C1D\u8BD5 ${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] \u9519\u8BEF: ${options.method} ${urlString}`, {
+            status: response.status,
+            data: errorData,
+            statusText: response.statusText
+          });
+          console.error(errorData);
+          const error = new Error(
+            `Request failed with status ${response.status}: ${JSON.stringify(errorData)}`
+          );
+          error.response = {
+            status: response.status,
+            data: errorData,
+            headers: response.headers
+          };
+          throw error;
+        }
+        const responseText = await response.text();
+        if (!responseText.trim())
+          return {};
+        return JSON.parse(responseText);
+      } catch (error) {
+        if (error.response) {
+          throw error;
+        }
+        console.error(`[SP-API] \u7F51\u7EDC/\u672A\u77E5\u9519\u8BEF: ${error.message}`);
+        throw error;
+      }
+    }
+    throw new Error("\u8D85\u8FC7\u6700\u5927\u91CD\u8BD5\u6B21\u6570");
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/parse-init.ts
+var isInitialized = false;
+var getParse = async () => {
+  let Parse = globalThis.Parse;
+  if (!Parse) {
+    try {
+      const mod = await import("parse/lib/node/Parse.js");
+      Parse = mod.default || mod;
+    } catch {
+      const mod2 = await import("parse");
+      Parse = mod2.default || mod2;
+    }
+    const appId = process.env.PARSE_APP_ID || "TARGET_PARSE_APP_ID";
+    const serverURL = process.env.PARSE_SERVER_URL || "http://localhost:3000/parse";
+    const masterKey = process.env.PARSE_MASTER_KEY || "TARGET_PARSE_MASTER_KEY";
+    Parse.initialize(appId, void 0, masterKey);
+    Parse.serverURL = serverURL;
+    globalThis.Parse = Parse;
+    isInitialized = true;
+  }
+  return Parse;
+};
+
+// backend/modules/fmode-amazon-sp-api/src/middleware/auth.ts
+var shopAuthMiddleware = async (req, res, next) => {
+  if (req.path === "/test") {
+    return next();
+  }
+  try {
+    const shopId = req.headers["shop-objectid"] || req.headers["x-shop-objectid"];
+    if (!shopId) {
+      return res.status(400).json({
+        success: false,
+        message: "\u7F3A\u5C11\u5FC5\u9700\u7684 header: shop-objectId"
+      });
+    }
+    const Parse = await getParse();
+    const query = new Parse.Query("Shop");
+    let shop;
+    try {
+      shop = await query.get(shopId, { useMasterKey: true });
+      console.log("[SP-API] \u8BA4\u8BC1\u4E2D\u95F4\u4EF6:", shop.get("name"));
+    } catch (error) {
+      console.log(error);
+      return res.status(400).json({
+        success: false,
+        message: `\u65E0\u6548\u7684 shopId: ${shopId}`
+      });
+    }
+    const config = shop.get("config").SpApiConfig;
+    console.log("[SP-API] \u5E97\u94FA\u51ED\u8BC1\u5DF2\u52A0\u8F7D");
+    if (!config || !config.clientId || !config.clientSecret || !config.refreshToken) {
+      return res.status(400).json({
+        success: false,
+        message: "\u7F3A\u5C11\u4E9A\u9A6C\u900A\u914D\u7F6E\u4FE1\u606F"
+      });
+    }
+    req.spApiContext = {
+      shopId,
+      marketplaceId: shop.get("marketplaceId"),
+      config
+    };
+    next();
+  } catch (error) {
+    console.error("[SP-API] \u8BA4\u8BC1\u4E2D\u95F4\u4EF6\u9519\u8BEF:", error);
+    res.status(500).json({
+      success: false,
+      message: "\u5E97\u94FA\u8BA4\u8BC1\u671F\u95F4\u53D1\u751F\u5185\u90E8\u670D\u52A1\u5668\u9519\u8BEF",
+      error: error.message
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/customerFeedback.ts
+var CustomerFeedbackApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 检索某件商品的十个最正面和十个最负面的评论主题。
+   * GET /customerFeedback/2024-06-01/items/{asin}/reviews/topics
+   */
+  async getItemReviewTopics(params) {
+    const { asin, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/items/${asin}/reviews/topics`,
+      query: queryParams
+    });
+  }
+  /**
+   * 该API返回请求ASIN的关联浏览节点。
+   * GET /customerFeedback/2024-06-01/items/{asin}/browseNode
+   */
+  async getItemBrowseNode(params) {
+    const { asin, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/items/${asin}/browseNode`,
+      query: queryParams
+    });
+  }
+  /**
+   * 检索浏览节点中十个最正面和十个最负面的评论主题。
+   * GET /customerFeedback/2024-06-01/browseNodes/{browseNodeId}/reviews/topics
+   */
+  async getBrowseNodeReviewTopics(params) {
+    const { browseNodeId, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/browseNodes/${browseNodeId}/reviews/topics`,
+      query: queryParams
+    });
+  }
+  /**
+   * 检索过去六个月商品的正面和负面评价趋势。
+   * GET /customerFeedback/2024-06-01/items/{asin}/reviews/trends
+   */
+  async getItemReviewTrends(params) {
+    const { asin, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/items/${asin}/reviews/trends`,
+      query: queryParams
+    });
+  }
+  /**
+   * 检索过去六个月浏览节点的正面和负面评价趋势。
+   * GET /customerFeedback/2024-06-01/browseNodes/{browseNodeId}/reviews/trends
+   */
+  async getBrowseNodeReviewTrends(params) {
+    const { browseNodeId, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/browseNodes/${browseNodeId}/reviews/trends`,
+      query: queryParams
+    });
+  }
+  /**
+   * 检索客户在浏览节点退货时提到的主题。
+   * GET /customerFeedback/2024-06-01/browseNodes/{browseNodeId}/returns/topics
+   */
+  async getBrowseNodeReturnTopics(params) {
+    const { browseNodeId, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/browseNodes/${browseNodeId}/returns/topics`,
+      query: queryParams
+    });
+  }
+  /**
+   * 检索客户在浏览节点退货时提到的话题趋势。
+   * GET /customerFeedback/2024-06-01/browseNodes/{browseNodeId}/returns/trends
+   */
+  async getBrowseNodeReturnTrends(params) {
+    const { browseNodeId, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/customerFeedback/2024-06-01/browseNodes/${browseNodeId}/returns/trends`,
+      query: queryParams
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/orders.ts
+var OrdersApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 获取订单列表
+   * GET /orders/v0/orders
+   */
+  async getOrders(params) {
+    return this.client.request({
+      method: "GET",
+      path: "/orders/v0/orders",
+      query: params
+    });
+  }
+  /**
+   * 获取订单详情
+   * GET /orders/v0/orders/{orderId}
+   */
+  async getOrder(orderId) {
+    return this.client.request({
+      method: "GET",
+      path: `/orders/v0/orders/${orderId}`
+    });
+  }
+  /**
+   * 获取订单商品
+   * GET /orders/v0/orders/{orderId}/orderItems
+   */
+  async getOrderItems(orderId, nextToken) {
+    return this.client.request({
+      method: "GET",
+      path: `/orders/v0/orders/${orderId}/orderItems`,
+      query: nextToken ? { NextToken: nextToken } : void 0
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/sales.ts
+var SalesApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 回报给定买方类型的给定区间内汇总订单指标
+   * GET /sales/v1/orderMetrics
+   */
+  async getOrderMetrics(params) {
+    return this.client.request({
+      method: "GET",
+      path: "/sales/v1/orderMetrics",
+      query: params
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/listings.ts
+var ListingsApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 列出所有列表项
+   * GET /listings/2021-08-01/items/{sellerId}
+   */
+  async searchListingsItems(params) {
+    const { sellerId, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/listings/2021-08-01/items/${sellerId}`,
+      query: queryParams
+    });
+  }
+  /**
+   * 获取列表项详情
+   * GET /listings/2021-08-01/items/{sellerId}/{sku}
+   */
+  async getListingsItem(params) {
+    const { sellerId, sku, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/listings/2021-08-01/items/${sellerId}/${sku}`,
+      query: queryParams
+    });
+  }
+  /**
+   * Put (创建/更新) 列表项
+   * PUT /listings/2021-08-01/items/{sellerId}/{sku}
+   */
+  async putListingsItem(sellerId, sku, marketplaceIds, body) {
+    return this.client.request({
+      method: "PUT",
+      path: `/listings/2021-08-01/items/${sellerId}/${sku}`,
+      query: { marketplaceIds: marketplaceIds.join(",") },
+      body
+    });
+  }
+  /**
+   * 删除列表项
+   * DELETE /listings/2021-08-01/items/{sellerId}/{sku}
+   */
+  async deleteListingsItem(sellerId, sku, marketplaceIds) {
+    return this.client.request({
+      method: "DELETE",
+      path: `/listings/2021-08-01/items/${sellerId}/${sku}`,
+      query: { marketplaceIds: marketplaceIds.join(",") }
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/externalFulfillment.ts
+var ExternalFulfillmentApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 退货列表
+   * GET /externalFulfillment/2024-09-11/returns
+   */
+  async listReturns(params) {
+    return this.client.request({
+      method: "GET",
+      path: "/externalFulfillment/2024-09-11/returns",
+      query: params
+    });
+  }
+  /**
+   * 获取退货详情
+   * GET /externalFulfillment/2024-09-11/returns/{returnId}
+   */
+  async getReturn(returnId) {
+    return this.client.request({
+      method: "GET",
+      path: `/externalFulfillment/2024-09-11/returns/${returnId}`
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/catalogItems.ts
+var CatalogItemsApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 列表目录分类
+   * GET /catalog/v0/categories
+   */
+  async listCatalogCategories(params) {
+    return this.client.request({
+      method: "GET",
+      path: "/catalog/v0/categories",
+      query: params
+    });
+  }
+  /**
+   * 搜索亚马逊目录商品
+   * GET /catalog/2022-04-01/items
+   */
+  async searchCatalogItems(params) {
+    return this.client.request({
+      method: "GET",
+      path: "/catalog/2022-04-01/items",
+      query: params
+    });
+  }
+  /**
+   * 获取类目信息
+   * GET /catalog/2022-04-01/items/{asin}
+   */
+  async getCatalogItem(params) {
+    const { asin, ...queryParams } = params;
+    return this.client.request({
+      method: "GET",
+      path: `/catalog/2022-04-01/items/${asin}`,
+      query: queryParams
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/api/sellers.ts
+var SellersApi = class {
+  constructor(client) {
+    this.client = client;
+  }
+  /**
+   * 返回卖家账户及其市场的信息
+   * GET /sellers/v1/account
+   */
+  async getAccount() {
+    return this.client.request({
+      method: "GET",
+      path: "/sellers/v1/account"
+    });
+  }
+  /**
+   * 返回卖家可以列出商品的市场列表以及卖家在这些市场的参与信息
+   * GET /sellers/v1/marketplaceParticipations
+   */
+  async getMarketplaceParticipations() {
+    return this.client.request({
+      method: "GET",
+      path: "/sellers/v1/marketplaceParticipations"
+    });
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/function.ts
+function parseNumber(value) {
+  if (!value || value.trim() === "")
+    return 0;
+  const num = parseFloat(value.replace(/[^0-9.-]/g, ""));
+  return isNaN(num) ? 0 : num;
+}
+function parseIntegerValue(value) {
+  if (!value || value.trim() === "")
+    return 0;
+  const num = parseInt(value.replace(/[^0-9-]/g, ""));
+  return isNaN(num) ? 0 : num;
+}
+function parseDate(value) {
+  if (!value || value.trim() === "")
+    return null;
+  try {
+    const date = new Date(value);
+    return isNaN(date.getTime()) ? null : date;
+  } catch (error) {
+    return null;
+  }
+}
+async function runCloudFunction(data, context, functionId) {
+  if (!functionId) {
+    throw new Error("functionId is required");
+  }
+  try {
+    console.log(`[SP-API] Running cloud function: ${functionId}`);
+    const response = await fetch("http://localhost:3000/api/functions", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify({
+        id: functionId,
+        data,
+        context
+      })
+    });
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({ message: response.statusText }));
+      throw new Error(`Cloud function failed: ${errorData.message || response.statusText}`);
+    }
+    const result = await response.json();
+    console.log(`[SP-API] Cloud function ${functionId} executed successfully`);
+    return result;
+  } catch (error) {
+    console.error(`[SP-API] Cloud function ${functionId} execution failed:`, error.message);
+    throw error;
+  }
+}
+var functions = {
+  /**
+   * 处理 Amazon 报表文件
+   * 下载、解析并存储报表数据到数据库
+   * @param data 包含报表 URL 或报表信息的数据
+   * @param context 上下文信息
+   */
+  async processReportFile(data, context) {
+    const Parse = await getParse();
+    if (!data || !data.reportDocumentUrl && !data.url) {
+      console.warn("[SP-API] processReportFile \u7F3A\u5C11\u62A5\u8868 URL");
+      return { success: false, message: "\u7F3A\u5C11\u62A5\u8868 URL", processed: 0 };
+    }
+    const shopId = context?.shopId;
+    if (!shopId) {
+      console.warn("[SP-API] processReportFile \u4E0A\u4E0B\u6587\u4E2D\u7F3A\u5C11 shopId");
+      return { success: false, message: "\u7F3A\u5C11 shopId", processed: 0 };
+    }
+    const reportUrl = data.reportDocumentUrl || data.url;
+    const reportType = data.reportType || "UNKNOWN";
+    const reportId = data.reportId || "unknown";
+    const compressionAlgorithm = data.compressionAlgorithm;
+    console.log(`[SP-API] \u6B63\u5728\u5904\u7406\u62A5\u8868\u6587\u4EF6: ${reportType}`, {
+      reportId,
+      shopId,
+      compressionAlgorithm: compressionAlgorithm || "NONE"
+    });
+    try {
+      console.log("[SP-API] \u6B63\u5728\u4E0B\u8F7D\u62A5\u8868\u6587\u4EF6...");
+      const response = await fetch(reportUrl);
+      if (!response.ok) {
+        throw new Error(`\u4E0B\u8F7D\u62A5\u8868\u5931\u8D25: ${response.status} ${response.statusText}`);
+      }
+      let reportContent;
+      if (compressionAlgorithm && compressionAlgorithm !== "NONE") {
+        console.log(`[SP-API] \u68C0\u6D4B\u5230\u538B\u7F29\u6587\u4EF6\uFF0C\u538B\u7F29\u7B97\u6CD5: ${compressionAlgorithm}`);
+        const compressedData = await response.arrayBuffer();
+        console.log(`[SP-API] \u538B\u7F29\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F\uFF0C\u5927\u5C0F: ${compressedData.byteLength} \u5B57\u8282`);
+        reportContent = await this.decompressReportData(compressedData, compressionAlgorithm);
+        console.log(`[SP-API] \u6587\u4EF6\u89E3\u538B\u6210\u529F\uFF0C\u89E3\u538B\u540E\u5927\u5C0F: ${reportContent.length} \u5B57\u7B26`);
+      } else {
+        reportContent = await response.text();
+        console.log(`[SP-API] \u62A5\u8868\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F\uFF0C\u5927\u5C0F: ${reportContent.length} \u5B57\u7B26`);
+      }
+      let parsedData = [];
+      let tableName = "Report";
+      switch (reportType) {
+        case "GET_MERCHANT_LISTINGS_DATA":
+          parsedData = await this.parseMerchantListingsReport(reportContent);
+          tableName = "Product";
+          break;
+        case "GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA":
+          parsedData = await this.parseReturnsReport(reportContent);
+          tableName = "ReturnRecord";
+          break;
+        case "GET_FLAT_FILE_ORDERS_RECONCILIATION_DATA":
+          parsedData = await this.parseOrdersReconciliationReport(reportContent);
+          tableName = "OrderReconciliation";
+          break;
+        case "GET_SALES_AND_TRAFFIC_REPORT":
+          parsedData = await this.parseSalesTrafficReport(reportContent);
+          tableName = "SalesTraffic";
+          break;
+        default:
+          parsedData = await this.parseGenericReport(reportContent, reportType);
+          tableName = "GenericReport";
+      }
+      if (parsedData.length === 0) {
+        console.warn("[SP-API] \u62A5\u8868\u89E3\u6790\u540E\u6CA1\u6709\u6570\u636E");
+        return { success: true, message: "\u62A5\u8868\u4E3A\u7A7A", processed: 0 };
+      }
+      const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+      const objectsToSave = [];
+      for (const item of parsedData) {
+        const reportObj = new Parse.Object(tableName);
+        reportObj.set("shop", shopPointer);
+        Object.keys(item).forEach((key) => {
+          if (item[key] !== null && item[key] !== void 0 && item[key] !== "") {
+            reportObj.set(key, item[key]);
+          }
+        });
+        objectsToSave.push(reportObj);
+      }
+      const batchSize = 100;
+      let totalSaved = 0;
+      for (let i = 0; i < objectsToSave.length; i += batchSize) {
+        const batch = objectsToSave.slice(i, i + batchSize);
+        try {
+          await Parse.Object.saveAll(batch, { useMasterKey: true });
+          totalSaved += batch.length;
+          console.log(`[SP-API] \u5DF2\u4FDD\u5B58 ${totalSaved}/${objectsToSave.length} \u6761\u8BB0\u5F55\u5230 ${tableName} \u8868`);
+        } catch (error) {
+          console.error(`[SP-API] \u4FDD\u5B58\u6279\u6B21 ${i}-${i + batch.length} \u5931\u8D25:`, error.message);
+        }
+      }
+      console.log(`[SP-API] \u62A5\u8868\u5904\u7406\u5B8C\u6210: ${reportType}\uFF0C\u5171\u5904\u7406 ${totalSaved} \u6761\u8BB0\u5F55`);
+      return {
+        success: true,
+        processed: totalSaved,
+        reportType,
+        reportId,
+        tableName
+      };
+    } catch (error) {
+      console.error("[SP-API] \u5904\u7406\u62A5\u8868\u6587\u4EF6\u5931\u8D25:", error.message);
+      return {
+        success: false,
+        message: error.message,
+        processed: 0
+      };
+    }
+  },
+  /**
+   * 解析商品列表报表 (GET_MERCHANT_LISTINGS_DATA)
+   */
+  async parseMerchantListingsReport(content) {
+    const lines = content.split("\n");
+    if (lines.length < 2)
+      return [];
+    const headers = lines[0].split("	");
+    const results = [];
+    for (let i = 1; i < lines.length; i++) {
+      const line = lines[i].trim();
+      if (!line)
+        continue;
+      const values = line.split("	");
+      const item = {};
+      const rawData = {};
+      headers.forEach((header, index) => {
+        const value = values[index] || "";
+        const cleanHeader = header.trim().replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
+        rawData[cleanHeader] = value;
+        rawData[header.trim()] = value;
+      });
+      item.item_name = rawData["item-name"] || rawData["item_name"] || rawData["product-name"] || "";
+      item.listing_id = rawData["listing-id"] || rawData["listing_id"] || "";
+      item.seller_sku = rawData["seller-sku"] || rawData["seller_sku"] || rawData["sku"] || "";
+      item.asin1 = rawData["asin1"] || "";
+      item.asin2 = rawData["asin2"] || "";
+      item.asin3 = rawData["asin3"] || "";
+      item.product_id = rawData["product-id"] || rawData["product_id"] || "";
+      item.product_id_type = rawData["product-id-type"] || rawData["product_id_type"] || "1";
+      if (i == 1) {
+        console.log(item, rawData);
+      }
+      item.price = parseNumber(rawData["price"] || rawData["list-price"] || rawData["your-price"]);
+      item.quantity = parseIntegerValue(rawData["quantity"] || rawData["available-quantity"]);
+      item.pending_quantity = parseIntegerValue(rawData["pending-quantity"] || rawData["pending_quantity"]);
+      item.business_price = parseNumber(rawData["business-price"] || rawData["business_price"]);
+      item.quantity_price_type = rawData["quantity-price-type"] || rawData["quantity_price_type"] || "";
+      item.open_date = parseDate(rawData["open-date"] || rawData["open_date"] || rawData["date-created"]);
+      item.fulfillment_channel = rawData["fulfillment-channel"] || rawData["fulfillment_channel"] || "";
+      item.will_ship_internationally = rawData["will-ship-internationally"] || rawData["will_ship_internationally"] || "";
+      item.expedited_shipping = rawData["expedited-shipping"] || rawData["expedited_shipping"] || "";
+      item.zshop_shipping_fee = parseNumber(rawData["zshop-shipping-fee"] || rawData["zshop_shipping_fee"]);
+      item.merchant_shipping_group = rawData["merchant-shipping-group"] || rawData["merchant_shipping_group"] || "";
+      item.item_description = rawData["item-description"] || rawData["item_description"] || rawData["description"] || "";
+      item.item_note = rawData["item-note"] || rawData["item_note"] || "";
+      item.item_condition = rawData["item-condition"] || rawData["item_condition"] || rawData["condition"] || "";
+      item.zshop_category1 = rawData["zshop-category1"] || rawData["zshop_category1"] || "";
+      item.zshop_browse_path = rawData["zshop-browse-path"] || rawData["zshop_browse_path"] || "";
+      item.zshop_storefront_feature = rawData["zshop-storefront-feature"] || rawData["zshop_storefront_feature"] || "";
+      item.zshop_boldface = rawData["zshop-boldface"] || rawData["zshop_boldface"] || "";
+      item.image_url = rawData["image-url"] || rawData["image_url"] || rawData["main-image-url"] || "";
+      item.item_is_marketplace = rawData["item-is-marketplace"] || rawData["item_is_marketplace"] || "";
+      item.license_plate_number = rawData["license-plate-number"] || rawData["license_plate_number"] || "";
+      if (item.seller_sku || item.asin1 || item.item_name) {
+        results.push(item);
+      }
+    }
+    return results;
+  },
+  /**
+   * 解析退货报表 (GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA)
+   */
+  async parseReturnsReport(content) {
+    const lines = content.split("\n");
+    if (lines.length < 2)
+      return [];
+    const headers = lines[0].split("	");
+    const results = [];
+    for (let i = 1; i < lines.length; i++) {
+      const line = lines[i].trim();
+      if (!line)
+        continue;
+      const values = line.split("	");
+      const item = {};
+      headers.forEach((header, index) => {
+        const value = values[index] || "";
+        const cleanHeader = header.trim().replace(/[^a-zA-Z0-9]/g, "_");
+        if (header.includes("date") || header.includes("Date")) {
+          item[cleanHeader] = value ? new Date(value) : null;
+        } else if (header.includes("quantity") || header.includes("Quantity")) {
+          item[cleanHeader] = value ? parseInt(value) : 0;
+        } else {
+          item[cleanHeader] = value;
+        }
+      });
+      item.returnDate = item.return_date || null;
+      item.orderId = item.order_id || item.amazon_order_id || "";
+      item.sku = item.sku || item.seller_sku || "";
+      item.asin = item.asin || "";
+      item.fnsku = item.fnsku || "";
+      item.product_name = item.product_name || "";
+      item.fulfillment_center_id = item.fulfillment_center_id || "";
+      item.detailed_disposition = item.detailed_disposition || "";
+      item.reason = item.reason || "";
+      item.license_plate_number = item.license_plate_number || "";
+      item.customer_comments = item.customer_comments || "";
+      results.push(item);
+    }
+    return results;
+  },
+  /**
+   * 解析订单对账报表 (GET_FLAT_FILE_ORDERS_RECONCILIATION_DATA)
+   */
+  async parseOrdersReconciliationReport(content) {
+    const lines = content.split("\n");
+    if (lines.length < 2)
+      return [];
+    const headers = lines[0].split("	");
+    const results = [];
+    for (let i = 1; i < lines.length; i++) {
+      const line = lines[i].trim();
+      if (!line)
+        continue;
+      const values = line.split("	");
+      const item = {};
+      headers.forEach((header, index) => {
+        const value = values[index] || "";
+        const cleanHeader = header.trim().replace(/[^a-zA-Z0-9]/g, "_");
+        if (header.includes("amount") || header.includes("Amount") || header.includes("price") || header.includes("Price")) {
+          item[cleanHeader] = value ? parseFloat(value) : 0;
+        } else if (header.includes("quantity") || header.includes("Quantity")) {
+          item[cleanHeader] = value ? parseInt(value) : 0;
+        } else if (header.includes("date") || header.includes("Date")) {
+          item[cleanHeader] = value ? new Date(value) : null;
+        } else {
+          item[cleanHeader] = value;
+        }
+      });
+      results.push(item);
+    }
+    return results;
+  },
+  /**
+   * 解析销售流量报表 (GET_SALES_AND_TRAFFIC_REPORT)
+   */
+  async parseSalesTrafficReport(content) {
+    try {
+      const jsonData = JSON.parse(content);
+      const results = [];
+      if (jsonData.salesAndTrafficByDate) {
+        jsonData.salesAndTrafficByDate.forEach((item) => {
+          results.push({
+            date: new Date(item.date),
+            orderedProductSales: parseFloat(item.salesByDate?.orderedProductSales?.amount || 0),
+            orderedProductSalesB2B: parseFloat(item.salesByDate?.orderedProductSalesB2B?.amount || 0),
+            unitsOrdered: parseInt(item.salesByDate?.unitsOrdered || 0),
+            unitsOrderedB2B: parseInt(item.salesByDate?.unitsOrderedB2B || 0),
+            totalOrderItems: parseInt(item.salesByDate?.totalOrderItems || 0),
+            totalOrderItemsB2B: parseInt(item.salesByDate?.totalOrderItemsB2B || 0),
+            averageSalesPerOrderItem: parseFloat(item.salesByDate?.averageSalesPerOrderItem?.amount || 0),
+            averageUnitsPerOrderItem: parseFloat(item.salesByDate?.averageUnitsPerOrderItem || 0),
+            sessions: parseInt(item.trafficByDate?.sessions || 0),
+            sessionPercentage: parseFloat(item.trafficByDate?.sessionPercentage || 0),
+            pageViews: parseInt(item.trafficByDate?.pageViews || 0),
+            pageViewsPercentage: parseFloat(item.trafficByDate?.pageViewsPercentage || 0),
+            buyBoxPercentage: parseFloat(item.trafficByDate?.buyBoxPercentage || 0)
+          });
+        });
+      }
+      return results;
+    } catch (error) {
+      return this.parseGenericReport(content, "GET_SALES_AND_TRAFFIC_REPORT");
+    }
+  },
+  /**
+   * 通用报表解析器(CSV/TSV 格式)
+   */
+  async parseGenericReport(content, reportType) {
+    const lines = content.split("\n");
+    if (lines.length < 2)
+      return [];
+    const delimiter = lines[0].includes("	") ? "	" : ",";
+    const headers = lines[0].split(delimiter);
+    const results = [];
+    for (let i = 1; i < lines.length; i++) {
+      const line = lines[i].trim();
+      if (!line)
+        continue;
+      const values = line.split(delimiter);
+      const item = {};
+      headers.forEach((header, index) => {
+        const value = values[index] || "";
+        const cleanHeader = header.trim().replace(/[^a-zA-Z0-9]/g, "_");
+        if (value.match(/^\d{4}-\d{2}-\d{2}/) || value.match(/^\d{2}\/\d{2}\/\d{4}/)) {
+          item[cleanHeader] = new Date(value);
+        } else if (value.match(/^\d+\.\d+$/)) {
+          item[cleanHeader] = parseFloat(value);
+        } else if (value.match(/^\d+$/)) {
+          item[cleanHeader] = parseInt(value);
+        } else {
+          item[cleanHeader] = value;
+        }
+      });
+      item.reportType = reportType;
+      results.push(item);
+    }
+    return results;
+  },
+  /**
+   * 解压缩报表数据
+   * @param compressedData 压缩的二进制数据
+   * @param compressionAlgorithm 压缩算法 (GZIP)
+   * @returns 解压后的文本内容
+   */
+  async decompressReportData(compressedData, compressionAlgorithm) {
+    try {
+      if (compressionAlgorithm.toUpperCase() === "GZIP") {
+        const zlib = await import("zlib");
+        const util = await import("util");
+        const buffer = Buffer.from(compressedData);
+        const gunzip = util.promisify(zlib.gunzip);
+        const decompressed = await gunzip(buffer);
+        return decompressed.toString("utf-8");
+      } else {
+        throw new Error(`\u4E0D\u652F\u6301\u7684\u538B\u7F29\u7B97\u6CD5: ${compressionAlgorithm}`);
+      }
+    } catch (error) {
+      console.error(`[SP-API] \u89E3\u538B\u7F29\u5931\u8D25:`, error.message);
+      throw new Error(`\u89E3\u538B\u7F29\u5931\u8D25: ${error.message}`);
+    }
+  },
+  // 清洗Orders数据 并且通过Parse将数据存储到数据库里面
+  async cleanOrders(data, context) {
+    const Parse = await getParse();
+    if (!data || !data.payload || !data.payload.Orders) {
+      console.warn("[SP-API] cleanOrders \u63A5\u6536\u5230\u65E0\u6548\u7684\u6570\u636E\u7ED3\u6784:", JSON.stringify(data).substring(0, 200));
+      return { success: false, message: "\u65E0\u6548\u7684\u6570\u636E\u7ED3\u6784", processed: 0 };
+    }
+    const shopId = context?.shopId;
+    if (!shopId) {
+      console.warn("[SP-API] cleanOrders \u4E0A\u4E0B\u6587\u4E2D\u7F3A\u5C11 shopId");
+      return { success: false, message: "\u7F3A\u5C11 shopId", processed: 0 };
+    }
+    const orders = data.payload.Orders;
+    console.log("[SP-API] \u6B63\u5728\u6E05\u6D17\u8BA2\u5355...", orders.length, "\u4E2A\u8BA2\u5355\uFF0C\u5E97\u94FAID:", shopId);
+    const objectsToSave = [];
+    const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+    const orderIds = orders.map((o) => o.AmazonOrderId);
+    let existingOrders = [];
+    try {
+      const query = new Parse.Query("Order");
+      query.containedIn("platformOrderId", orderIds);
+      query.equalTo("shop", shopPointer);
+      query.limit(1e3);
+      existingOrders = await query.find({ useMasterKey: true });
+    } catch (error) {
+      console.warn("[SP-API] \u67E5\u8BE2\u73B0\u6709\u8BA2\u5355\u8B66\u544A (\u5C06\u4F5C\u4E3A\u65B0\u8BA2\u5355\u7EE7\u7EED):", error);
+      existingOrders = [];
+    }
+    const existingMap = /* @__PURE__ */ new Map();
+    existingOrders.forEach((o) => existingMap.set(o.get("platformOrderId"), o));
+    for (const orderData of orders) {
+      let orderObj = existingMap.get(orderData.AmazonOrderId);
+      if (!orderObj) {
+        orderObj = new Parse.Object("Order");
+        orderObj.set("platformOrderId", orderData.AmazonOrderId);
+        orderObj.set("shop", shopPointer);
+      }
+      orderObj.set("orderDate", new Date(orderData.PurchaseDate));
+      orderObj.set("status", orderData.OrderStatus);
+      orderObj.set("marketplaceId", orderData.MarketplaceId);
+      if (orderData.OrderTotal && orderData.OrderTotal.Amount) {
+        orderObj.set("totalAmount", parseFloat(orderData.OrderTotal.Amount));
+      } else {
+        if (orderObj.isNew()) {
+          orderObj.set("totalAmount", 0);
+        }
+      }
+      if (orderData.BuyerInfo) {
+        orderObj.set("buyerInfo", orderData.BuyerInfo);
+      }
+      if (orderData.ShippingAddress) {
+        orderObj.set("shippingAddress", orderData.ShippingAddress);
+        orderObj.set("customerRegion", orderData.ShippingAddress.CountryCode);
+      }
+      if (orderObj.isNew()) {
+        orderObj.set("items", []);
+      }
+      orderObj.set("lastUpdateDate", new Date(orderData.LastUpdateDate));
+      orderObj.set("fulfillmentChannel", orderData.FulfillmentChannel);
+      orderObj.set("orderType", orderData.OrderType);
+      if (orderData.ShippingAddress) {
+        orderObj.set("shippingCountry", orderData.ShippingAddress.CountryCode);
+        orderObj.set("shippingState", orderData.ShippingAddress.StateOrRegion);
+        orderObj.set("shippingCity", orderData.ShippingAddress.City);
+      }
+      orderObj.set("isPrime", orderData.IsPrime || false);
+      orderObj.set("isBusinessOrder", orderData.IsBusinessOrder || false);
+      if (orderData.EarliestShipDate) {
+        orderObj.set("earliestShipDate", new Date(orderData.EarliestShipDate));
+      }
+      if (orderData.LatestShipDate) {
+        orderObj.set("latestShipDate", new Date(orderData.LatestShipDate));
+      }
+      objectsToSave.push(orderObj);
+    }
+    if (objectsToSave.length > 0) {
+      try {
+        await Parse.Object.saveAll(objectsToSave, { useMasterKey: true });
+        console.log(`[SP-API] \u6210\u529F\u4FDD\u5B58/\u66F4\u65B0 ${objectsToSave.length} \u4E2A\u8BA2\u5355\u3002`);
+      } catch (error) {
+        console.error("[SP-API] \u4FDD\u5B58\u8BA2\u5355\u9519\u8BEF:", error);
+        for (const obj of objectsToSave) {
+          try {
+            await obj.save(null, { useMasterKey: true });
+          } catch (e) {
+            console.error("\u4FDD\u5B58\u5355\u4E2A\u5BF9\u8C61\u9519\u8BEF:", obj.toJSON(), e);
+          }
+        }
+        return { success: false, message: "\u4FDD\u5B58\u5230\u6570\u636E\u5E93\u9519\u8BEF (\u90E8\u5206\u5931\u8D25\u8BF7\u68C0\u67E5\u65E5\u5FD7)", error };
+      }
+    }
+    const nextToken = data?.payload?.NextToken || data?.NextToken;
+    if (nextToken && context?.client) {
+      const currentNextToken = String(nextToken).trim();
+      const previousNextToken = context.previousNextToken;
+      if (previousNextToken && previousNextToken === currentNextToken) {
+        console.warn("[SP-API] \u68C0\u6D4B\u5230\u91CD\u590D\u7684 NextToken\uFF0C\u505C\u6B62\u5206\u9875\u4EE5\u9632\u6B62\u6B7B\u5FAA\u73AF\u3002");
+        return { success: true, processed: objectsToSave.length, hasNext: false, message: "Duplicate NextToken detected" };
+      }
+      try {
+        const originalPath = context.requestPath || "";
+        const [basePath, rawQuery] = originalPath.split("?");
+        const keysToRemove = ["NextToken", "nextToken", "pageToken", "offset", "page", "startIndex"];
+        let newQueryString = "";
+        if (rawQuery) {
+          const urlParams = new URLSearchParams(rawQuery);
+          const parts = [];
+          urlParams.forEach((value, key) => {
+            if (!keysToRemove.includes(key)) {
+              parts.push(`${key}=${encodeURIComponent(value)}`);
+            }
+          });
+          newQueryString = parts.join("&");
+        }
+        if (context.requestQuery) {
+          const extraParts = [];
+          Object.entries(context.requestQuery).forEach(([k, v]) => {
+            if (v === void 0 || v === null)
+              return;
+            if (!keysToRemove.includes(k) && !newQueryString.includes(`${k}=`)) {
+              const valStr = Array.isArray(v) ? v.join(",") : String(v);
+              extraParts.push(`${k}=${encodeURIComponent(valStr)}`);
+            }
+          });
+          if (extraParts.length > 0) {
+            newQueryString = newQueryString ? `${newQueryString}&${extraParts.join("&")}` : extraParts.join("&");
+          }
+        }
+        const encodedToken = encodeURIComponent(currentNextToken);
+        const nextPath = `${basePath}?NextToken=${encodedToken}`;
+        await new Promise((resolve) => setTimeout(resolve, 3e4));
+        const nextResult = await context.client.request({
+          method: "GET",
+          path: nextPath,
+          context: {
+            shopId: context.shopId,
+            marketplaceId: context.marketplaceId,
+            config: context.config
+          }
+        });
+        context.requestCount = (context.requestCount || 1) + 1;
+        context.previousNextToken = currentNextToken;
+        return await this.cleanOrders(nextResult, context);
+      } catch (e) {
+        console.error("[SP-API] \u83B7\u53D6\u4E0B\u4E00\u9875\u8BA2\u5355\u9519\u8BEF:", e);
+      }
+    }
+    return { success: true, processed: objectsToSave.length, hasNext: !!nextToken };
+  },
+  /**
+   * 清洗 Listings 数据并存储到数据库
+   * 对应 Parse Schema: Listing
+   * 字段设计:
+   * - shop: Pointer<Shop> 必填
+   * - sku: String (sku) - 卖家SKU
+   * - asin: String (summaries[0].asin) - 亚马逊ASIN 必填
+   * - title: String (summaries[0].itemName) - 商品标题 必填
+   * - mainImage: String (summaries[0].mainImage.link) - 主图链接
+   * - productType: String (summaries[0].productType) - 产品类型
+   * - status: String (summaries[0].status[0]) - 状态
+   * - createdDate: Date (summaries[0].createdDate)
+   * - lastUpdatedDate: Date (summaries[0].lastUpdatedDate)
+   * - marketplaceId: String (summaries[0].marketplaceId)
+   * - attributes: Object (attributes) - 其他属性
+   * - parentSku: String (attributes?.child_parent_sku_relationship?.[0]?.parent_sku) - 父SKU
+   * - issues: Array (issues) - 刊登问题
+   */
+  async cleanListings(data, context) {
+    const Parse = await getParse();
+    if (typeof Parse === "undefined") {
+      console.error("[SP-API] cleanListings \u5931\u8D25: \u5168\u5C40 Parse \u5BF9\u8C61\u4E0D\u5B58\u5728");
+      return { success: false, message: "\u5168\u5C40 Parse \u5BF9\u8C61\u4E0D\u5B58\u5728", processed: 0 };
+    }
+    if (!data || !data.items || !Array.isArray(data.items)) {
+      console.warn("[SP-API] cleanListings \u63A5\u6536\u5230\u65E0\u6548\u7684\u6570\u636E\u7ED3\u6784");
+      return { success: false, message: "\u65E0\u6548\u7684\u6570\u636E\u7ED3\u6784", processed: 0 };
+    }
+    const shopId = context?.shopId;
+    if (!shopId) {
+      console.warn("[SP-API] cleanListings \u4E0A\u4E0B\u6587\u4E2D\u7F3A\u5C11 shopId");
+      return { success: false, message: "\u7F3A\u5C11 shopId", processed: 0 };
+    }
+    const requestCount = context?.requestCount || 1;
+    console.log(`[SP-API] cleanListings \u7B2C ${requestCount} \u6B21\u5FAA\u73AF\u8BF7\u6C42`);
+    const items = data.items;
+    console.log(
+      `[SP-API] \u6B63\u5728\u6E05\u6D17 ${items.length} \u6761 Listing...`,
+      data.numberOfResults,
+      data.pagination
+    );
+    const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+    const objectsToSave = [];
+    const skus = items.map((item) => item.sku);
+    console.log(`[SP-API] \u63D0\u53D6\u5230 ${skus.length} \u4E2A SKU \u7528\u4E8E\u67E5\u8BE2`, skus, context.requestCount);
+    let existingListings = [];
+    try {
+      const query = new Parse.Query("Listing");
+      query.containedIn("sku", skus);
+      query.equalTo("shop", shopPointer);
+      query.limit(data.numberOfResults);
+      existingListings = await query.find({ useMasterKey: true });
+    } catch (error) {
+      console.warn("[SP-API] \u67E5\u8BE2\u73B0\u6709 Listing \u8B66\u544A (\u5C06\u4F5C\u4E3A\u65B0 Listing \u7EE7\u7EED):", error);
+    }
+    const existingMap = /* @__PURE__ */ new Map();
+    existingListings.forEach((l) => existingMap.set(l.get("asin"), l));
+    for (const item of items) {
+      if (!item.summaries || item.summaries.length === 0)
+        continue;
+      let listingObj = existingMap.get(item.summaries[0].asin);
+      if (!listingObj) {
+        listingObj = new Parse.Object("Listing");
+        listingObj.set("sku", item.sku);
+        listingObj.set("shop", shopPointer);
+      }
+      if (item.attributes) {
+        const parentageList = item.attributes.parentage_level;
+        if (Array.isArray(parentageList)) {
+          const isChild = parentageList.some((p) => p.value === "child");
+          if (isChild) {
+            const relationships = item.attributes.child_parent_sku_relationship;
+            if (Array.isArray(relationships) && relationships.length > 0) {
+              const rel = relationships[0];
+              if (rel && rel.parent_sku) {
+                listingObj.set("parentSku", rel.parent_sku);
+              }
+            }
+          }
+        }
+      }
+      if (item.summaries && item.summaries.length > 0) {
+        const summary = item.summaries[0];
+        listingObj.set("asin", summary.asin);
+        listingObj.set("title", summary.itemName);
+        listingObj.set("productType", summary.productType);
+        listingObj.set("marketplaceId", summary.marketplaceId);
+        if (summary.status && summary.status.length > 0) {
+          listingObj.set("status", summary.status[0]);
+        }
+        if (summary.createdDate) {
+          listingObj.set("createdDate", new Date(summary.createdDate));
+        }
+        if (summary.lastUpdatedDate) {
+          listingObj.set("lastUpdatedDate", new Date(summary.lastUpdatedDate));
+        }
+        if (summary.mainImage && summary.mainImage.link) {
+          listingObj.set("mainImage", summary.mainImage.link);
+        }
+      }
+      if (item.attributes) {
+        listingObj.set("attributes", item.attributes);
+      }
+      if (item.issues) {
+        listingObj.set("issues", item.issues);
+      }
+      if (item.attributes?.list_price && item.attributes?.list_price.length > 0) {
+        const listPrice = item.attributes?.list_price[0];
+        if (listPrice.value) {
+          listingObj.set("price", parseFloat(listPrice.value));
+          listingObj.set("currency", listPrice.currency);
+        }
+      }
+      if (item.fulfillmentAvailability && item.fulfillmentAvailability.length > 0) {
+        const fa = item.fulfillmentAvailability[0];
+        listingObj.set("quantity", fa.quantity);
+      }
+      objectsToSave.push(listingObj);
+    }
+    if (objectsToSave.length > 0) {
+      try {
+        await Parse.Object.saveAll(objectsToSave, { useMasterKey: true });
+        console.log(`[SP-API] \u6210\u529F\u4FDD\u5B58/\u66F4\u65B0 ${objectsToSave.length} \u6761 Listing\u3002`);
+      } catch (error) {
+        console.error("[SP-API] \u4FDD\u5B58 Listing \u9519\u8BEF:", error);
+      }
+    }
+    if (context.requestCount === 50) {
+      console.log(data.pagination, data.pagination.nextToken, context?.client);
+    }
+    if (data.pagination && data.pagination.nextToken && context?.client) {
+      const currentNextToken = data.pagination.nextToken;
+      const previousNextToken = context.previousNextToken;
+      if (previousNextToken && previousNextToken === currentNextToken) {
+        console.warn("[SP-API] \u68C0\u6D4B\u5230\u91CD\u590D\u7684 nextToken\uFF0C\u505C\u6B62\u5206\u9875\u4EE5\u9632\u6B62\u6B7B\u5FAA\u73AF\u3002");
+        return { success: true, processed: objectsToSave.length, hasNext: false, message: "Duplicate nextToken detected" };
+      }
+      const pageSize = items.length || 10;
+      const maxRequests = Math.ceil((data.numberOfResults || 1e4) / pageSize) + 10;
+      if (requestCount >= maxRequests) {
+        console.warn(`[SP-API] \u5DF2\u8FBE\u5230\u6700\u5927\u8BF7\u6C42\u6B21\u6570 ${maxRequests}\uFF0C\u505C\u6B62\u5206\u9875\u3002`);
+        return { success: true, processed: objectsToSave.length, hasNext: true, message: `Reached max requests limit (${maxRequests})` };
+      }
+      console.log(`[SP-API] \u6B63\u5728\u83B7\u53D6\u4E0B\u4E00\u9875 Listing... (\u7B2C ${requestCount + 1} \u9875)`);
+      try {
+        const originalPath = context.requestPath || "";
+        const [basePath, rawQuery] = originalPath.split("?");
+        let newQueryString = "";
+        const keysToRemove = ["pageToken", "nextToken", "offset", "page", "startIndex"];
+        if (rawQuery) {
+          newQueryString = rawQuery.split("&").filter(Boolean).filter((part) => !keysToRemove.includes(decodeURIComponent(part.split("=")[0] || ""))).join("&");
+        }
+        if (context.requestQuery) {
+          const extraParts = [];
+          Object.entries(context.requestQuery).forEach(([k, v]) => {
+            if (v === void 0 || v === null)
+              return;
+            if (!keysToRemove.includes(k)) {
+              if (!newQueryString.includes(`${k}=`)) {
+                const valStr = Array.isArray(v) ? v.join(",") : String(v);
+                extraParts.push(`${k}=${encodeURIComponent(valStr)}`);
+              }
+            }
+          });
+          if (extraParts.length > 0) {
+            newQueryString = newQueryString ? `${newQueryString}&${extraParts.join("&")}` : extraParts.join("&");
+          }
+        }
+        const tokenVal = currentNextToken.trim();
+        console.log(`[SP-API] NextToken (Page ${requestCount + 1}):`, tokenVal.substring(0, 50) + "...");
+        const encodedToken = encodeURIComponent(tokenVal);
+        const marketplaceQuery = context.marketplaceId ? `marketplaceIds=${encodeURIComponent(String(context.marketplaceId))}&` : "";
+        const nextPath = `${basePath}?${marketplaceQuery}pageToken=${encodedToken}`;
+        console.log("[SP-API] Next Path:", nextPath.substring(0, 200) + "...");
+        await new Promise((resolve) => setTimeout(resolve, 3e3));
+        const nextResult = await context.client.request({
+          method: "GET",
+          path: nextPath,
+          context: {
+            shopId,
+            marketplaceId: context.marketplaceId,
+            config: context.config
+          }
+        });
+        context.requestCount = requestCount + 1;
+        context.previousNextToken = currentNextToken;
+        return await this.cleanListings(nextResult, context);
+      } catch (error) {
+        console.error("[SP-API] \u83B7\u53D6\u4E0B\u4E00\u9875\u9519\u8BEF:", error.message);
+        return {
+          success: true,
+          processed: objectsToSave.length,
+          hasNext: true,
+          error: error.message,
+          message: "\u5206\u9875\u8BF7\u6C42\u5931\u8D25\uFF0C\u5DF2\u4FDD\u5B58\u5F53\u524D\u9875\u6570\u636E"
+        };
+      }
+    }
+    return { success: true, processed: objectsToSave.length, hasNext: false };
+  }
+};
+
+// backend/modules/fmode-amazon-sp-api/src/routes.ts
+var sendResponse = (res, data) => {
+  res.json({
+    success: true,
+    data,
+    timestamp: (/* @__PURE__ */ new Date()).toISOString()
+  });
+};
+var asyncHandler = (fn) => (req, res, next) => {
+  Promise.resolve(fn(req, res, next)).catch(next);
+};
+var createSpApiRouter = (staticClient) => {
+  const router5 = Router();
+  router5.use(shopAuthMiddleware);
+  const getClient = (req) => {
+    return staticClient || new SpApiClient();
+  };
+  const createScopedApi = (ApiClass, req) => {
+    const baseClient = getClient(req);
+    const scopedClient = {
+      request: async (options) => {
+        return baseClient.request({
+          ...options,
+          context: {
+            shopId: req.spApiContext?.shopId,
+            marketplaceId: req.spApiContext?.marketplaceId,
+            config: req.spApiContext?.config
+          }
+        });
+      }
+    };
+    return new ApiClass(scopedClient);
+  };
+  router5.get(
+    "/test",
+    asyncHandler(async (req, res) => {
+      sendResponse(res, { message: "SP-API \u8DEF\u7531\u6D4B\u8BD5\u6210\u529F" });
+    })
+  );
+  router5.get(
+    "/customerFeedback/items/:asin/reviews/topics",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getItemReviewTopics({ ...req.query, asin: req.params.asin });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/items/:asin/browseNode",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getItemBrowseNode({ ...req.query, asin: req.params.asin });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/browseNodes/:browseNodeId/reviews/topics",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getBrowseNodeReviewTopics({
+        ...req.query,
+        browseNodeId: req.params.browseNodeId
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/items/:asin/reviews/trends",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getItemReviewTrends({ ...req.query, asin: req.params.asin });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/browseNodes/:browseNodeId/reviews/trends",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getBrowseNodeReviewTrends({
+        ...req.query,
+        browseNodeId: req.params.browseNodeId
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/browseNodes/:browseNodeId/returns/topics",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getBrowseNodeReturnTopics({
+        ...req.query,
+        browseNodeId: req.params.browseNodeId
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/customerFeedback/browseNodes/:browseNodeId/returns/trends",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CustomerFeedbackApi, req);
+      const result = await api.getBrowseNodeReturnTrends({
+        ...req.query,
+        browseNodeId: req.params.browseNodeId
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/orders",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(OrdersApi, req);
+      const result = await api.getOrders(req.query);
+      console.log("result", result);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/orders/:orderId",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(OrdersApi, req);
+      const result = await api.getOrder(req.params.orderId);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/orders/:orderId/items",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(OrdersApi, req);
+      const result = await api.getOrderItems(req.params.orderId, req.query.NextToken);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/sales/orderMetrics",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(SalesApi, req);
+      const result = await api.getOrderMetrics(req.query);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/listings/items/:sellerId",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ListingsApi, req);
+      const result = await api.searchListingsItems({
+        ...req.query,
+        sellerId: req.params.sellerId
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/listings/items/:sellerId/:sku",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ListingsApi, req);
+      const result = await api.getListingsItem({
+        ...req.query,
+        sellerId: req.params.sellerId,
+        sku: req.params.sku
+      });
+      sendResponse(res, result);
+    })
+  );
+  router5.put(
+    "/listings/items/:sellerId/:sku",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ListingsApi, req);
+      const marketplaceIds = req.query.marketplaceIds?.split(",") || [];
+      const result = await api.putListingsItem(
+        req.params.sellerId,
+        req.params.sku,
+        marketplaceIds,
+        req.body
+      );
+      sendResponse(res, result);
+    })
+  );
+  router5.delete(
+    "/listings/items/:sellerId/:sku",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ListingsApi, req);
+      const marketplaceIds = req.query.marketplaceIds?.split(",") || [];
+      const result = await api.deleteListingsItem(
+        req.params.sellerId,
+        req.params.sku,
+        marketplaceIds
+      );
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/externalFulfillment/returns",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ExternalFulfillmentApi, req);
+      const result = await api.listReturns(req.query);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/externalFulfillment/returns/:returnId",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(ExternalFulfillmentApi, req);
+      const result = await api.getReturn(req.params.returnId);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/catalog/categories",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CatalogItemsApi, req);
+      const result = await api.listCatalogCategories(req.query);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/catalog/items",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CatalogItemsApi, req);
+      const result = await api.searchCatalogItems(req.query);
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/catalog/items/:asin",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(CatalogItemsApi, req);
+      const result = await api.getCatalogItem({ ...req.query, asin: req.params.asin });
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/sellers/account",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(SellersApi, req);
+      const result = await api.getAccount();
+      sendResponse(res, result);
+    })
+  );
+  router5.get(
+    "/sellers/marketplaceParticipations",
+    asyncHandler(async (req, res) => {
+      const api = createScopedApi(SellersApi, req);
+      const result = await api.getMarketplaceParticipations();
+      sendResponse(res, result);
+    })
+  );
+  router5.post(
+    "/forward",
+    asyncHandler(async (req, res) => {
+      const {
+        path,
+        method = "GET",
+        query,
+        body,
+        functionName,
+        functionId,
+        requiresRdt,
+        rdtDataElements
+      } = req.body;
+      if (!path) {
+        res.status(400).json({
+          success: false,
+          message: "Missing required parameter: path"
+        });
+        return;
+      }
+      const client = getClient(req);
+      const result = await client.request({
+        method: method.toUpperCase(),
+        path,
+        query,
+        body,
+        requiresRdt,
+        rdtDataElements,
+        context: {
+          shopId: req.spApiContext?.shopId,
+          marketplaceId: req.spApiContext?.marketplaceId,
+          config: req.spApiContext?.config
+        }
+      });
+      console.log(result, result?.numberOfResults);
+      if (functionId) {
+        try {
+          console.log(`[SP-API] Executing cloud function: ${functionId}`);
+          const cloudResult = await runCloudFunction(
+            result,
+            {
+              ...req.spApiContext,
+              requestPath: path,
+              requestQuery: query,
+              requestBody: body,
+              requestCount: 1
+            },
+            functionId
+          );
+          sendResponse(res, {
+            apiResult: result,
+            cloudFunctionResult: cloudResult
+          });
+          return;
+        } catch (error) {
+          console.error(`[SP-API] Error executing cloud function ${functionId}:`, error.message);
+          res.status(500).json({
+            success: false,
+            message: `Cloud function execution failed: ${error.message}`,
+            data: result
+          });
+          return;
+        }
+      }
+      if (functionName && functions[functionName]) {
+        try {
+          console.log(`[SP-API] Executing function: ${functionName}`);
+          await functions[functionName](result, {
+            ...req.spApiContext,
+            client,
+            requestPath: path,
+            requestQuery: query,
+            requestCount: 1
+          });
+        } catch (error) {
+          console.error(`[SP-API] Error executing function ${functionName}:`, error);
+        }
+      }
+      sendResponse(res, result);
+    })
+  );
+  router5.post(
+    "/processReport",
+    asyncHandler(async (req, res) => {
+      const { reportDocumentId, reportType } = req.body;
+      if (!reportDocumentId) {
+        res.status(400).json({
+          success: false,
+          message: "Missing required parameter: reportDocumentId"
+        });
+        return;
+      }
+      const client = getClient(req);
+      try {
+        console.log(`[SP-API] \u6B63\u5728\u83B7\u53D6\u62A5\u8868\u6587\u6863: ${reportDocumentId}`);
+        const documentResult = await client.request({
+          method: "GET",
+          path: `/reports/2021-06-30/documents/${reportDocumentId}`,
+          context: {
+            shopId: req.spApiContext?.shopId,
+            marketplaceId: req.spApiContext?.marketplaceId,
+            config: req.spApiContext?.config
+          }
+        });
+        if (!documentResult || !documentResult.url) {
+          res.status(404).json({
+            success: false,
+            message: "Report document not found or URL not available"
+          });
+          return;
+        }
+        console.log(`[SP-API] \u6B63\u5728\u5904\u7406\u62A5\u8868\u6587\u4EF6: ${documentResult.url}`);
+        console.log(`[SP-API] \u538B\u7F29\u7B97\u6CD5: ${documentResult.compressionAlgorithm || "NONE"}`);
+        const processResult = await functions.processReportFile(
+          {
+            reportDocumentUrl: documentResult.url,
+            reportType: reportType || "UNKNOWN",
+            reportId: reportDocumentId,
+            compressionAlgorithm: documentResult.compressionAlgorithm
+          },
+          {
+            shopId: req.spApiContext?.shopId,
+            marketplaceId: req.spApiContext?.marketplaceId,
+            config: req.spApiContext?.config
+          }
+        );
+        sendResponse(res, {
+          documentInfo: {
+            reportDocumentId,
+            url: documentResult.url,
+            reportType: reportType || "UNKNOWN"
+          },
+          processResult
+        });
+      } catch (error) {
+        console.error(`[SP-API] \u5904\u7406\u62A5\u8868\u6587\u6863\u5931\u8D25:`, error.message);
+        res.status(500).json({
+          success: false,
+          message: `Failed to process report document: ${error.message}`,
+          reportDocumentId
+        });
+      }
+    })
+  );
+  router5.use((error, _req, res, _next) => {
+    const status = Number(error?.response?.status || error?.status || 500);
+    const upstream = error?.response?.data;
+    res.status(status).json({
+      success: false,
+      status,
+      code: upstream?.errors?.[0]?.code || error?.code || "SP_API_ERROR",
+      message: upstream?.errors?.[0]?.message || error?.message || "SP-API request failed",
+      details: upstream?.errors?.[0]?.details || ""
+    });
+  });
+  return router5;
+};
+
+// backend/api/module/schedule/routes-schedule.ts
+import express3 from "npm:express";
+
+// backend/api/module/schedule/sp-api-schedule.ts
+import nodeCron from "npm:node-cron";
+var SpApiScheduler = class {
+  // 私有属性(先声明)
+  #isRunning = false;
+  #lastRunTime = null;
+  #cronTask = null;
+  #reportCronTask = null;
+  #cronEnabled = true;
+  // ========== 私有工具方法(最优先声明,避免调用时未定义) ==========
+  /**
+   * 延迟函数
+   * @private
+   * @param {number} ms - 延迟毫秒数
+   * @returns {Promise<void>} 延迟 Promise
+   */
+  #delay(ms) {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+  /**
+   * 计算下次执行时间(凌晨1点)
+   * @private
+   * @returns {Date} 下次执行时间
+   */
+  #calculateNextRunTime() {
+    const now = /* @__PURE__ */ new Date();
+    const next = new Date(now);
+    next.setHours(1, 0, 0, 0);
+    if (next <= now) {
+      next.setDate(next.getDate() + 1);
+    }
+    return next;
+  }
+  /**
+   * 获取所有活跃的 Amazon 店铺
+   * @private
+   * @returns {Promise<any[]>} 活跃店铺列表
+   */
+  async #getActiveAmazonShops(shopId) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Shop");
+    query.equalTo("platform", "amazon");
+    query.equalTo("status", "active");
+    if (shopId) {
+      query.equalTo("objectId", shopId);
+    }
+    query.limit(1e3);
+    return await query.find({ useMasterKey: true });
+  }
+  /**
+   * 记录执行日志
+   * @private
+   * @param {any} logData - 日志数据对象
+   * @returns {Promise<void>}
+   */
+  async #logExecution(logData) {
+    try {
+      const Parse = globalThis.Parse;
+      const TaskLog = Parse.Object.extend("TaskExecutionLog");
+      const log = new TaskLog();
+      log.set("taskName", logData.taskName);
+      log.set("startTime", logData.startTime);
+      log.set("endTime", logData.endTime);
+      log.set("duration", logData.duration);
+      log.set("successCount", logData.successCount);
+      log.set("errorCount", logData.errorCount);
+      log.set("errors", logData.errors);
+      log.set("status", logData.status);
+      await log.save(null, { useMasterKey: true });
+      console.log("[SP-API Scheduler] \u6267\u884C\u65E5\u5FD7\u5DF2\u4FDD\u5B58");
+    } catch (error) {
+      console.error("[SP-API Scheduler] \u4FDD\u5B58\u6267\u884C\u65E5\u5FD7\u5931\u8D25:", error.message);
+    }
+  }
+  /**
+   * 采集 Listing 数据
+   * @private
+   * @param {string} shopId - 店铺ID
+   * @param {any} config - 店铺配置
+   * @param {string} marketplaceId - 市场ID
+   * @returns {Promise<void>}
+   */
+  async #collectListings(shopId, config, marketplaceId) {
+    const sellerId = config.SpApiConfig.sellerID;
+    marketplaceId = marketplaceId || "ATVPDKIKX0DER";
+    const Parse = globalThis.Parse;
+    let lastUpdatedAfter;
+    try {
+      const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+      const query = new Parse.Query("Listing");
+      query.equalTo("shop", shopPointer);
+      query.descending("lastUpdatedDate");
+      query.limit(1);
+      const latestListing = await query.first({ useMasterKey: true });
+      if (latestListing && latestListing.get("lastUpdatedDate")) {
+        lastUpdatedAfter = latestListing.get("lastUpdatedDate").toISOString();
+        console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u4F7F\u7528\u6700\u65B0\u7684 lastUpdatedDate: ${lastUpdatedAfter}`);
+      } else {
+        const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1e3);
+        lastUpdatedAfter = oneYearAgo.toISOString();
+        console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u6CA1\u6709Listing\u6570\u636E,\u4F7F\u7528\u4E00\u5E74\u524D\u7684\u65F6\u95F4: ${lastUpdatedAfter}`);
+      }
+    } catch (error) {
+      const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1e3);
+      lastUpdatedAfter = oneYearAgo.toISOString();
+      console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u67E5\u8BE2Listing\u5931\u8D25,\u4F7F\u7528\u4E00\u5E74\u524D\u7684\u65F6\u95F4: ${lastUpdatedAfter}, \u9519\u8BEF: ${error.message}`);
+    }
+    let path = `/listings/2021-08-01/items/${sellerId}?marketplaceIds=${marketplaceId}&includedData=summaries,attributes,issues&withStatus=BUYABLE,DISCOVERABLE&sortBy=lastUpdatedDate&sortOrder=ASC&pageSize=10&lastUpdatedAfter=${lastUpdatedAfter}`;
+    const response = await fetch("http://localhost:3000/api/amazon/forward", {
+      method: "POST",
+      headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+      body: JSON.stringify({
+        path,
+        method: "GET",
+        functionName: "cleanListings"
+      })
+    });
+    const result = await response.json();
+    if (!result.success) {
+      throw new Error(result.message || "Listing \u91C7\u96C6\u5931\u8D25");
+    }
+    console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} Listing \u6570\u636E\u91C7\u96C6\u5B8C\u6210: ${result.processed || 0} \u6761\u8BB0\u5F55`);
+  }
+  /**
+   * 采集订单数据(增量同步)
+   * @private
+   * @param {string} shopId - 店铺ID
+   * @param {any} config - 店铺配置
+   * @param {string[]} marketplaceIds - 市场ID列表
+   * @param {any} shop - 店铺对象
+   * @returns {Promise<void>}
+   */
+  async #collectOrders(shopId, config, marketplaceId) {
+    marketplaceId = marketplaceId || "ATVPDKIKX0DER";
+    const Parse = globalThis.Parse;
+    let createdAfter;
+    try {
+      const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+      const query = new Parse.Query("Order");
+      query.equalTo("shop", shopPointer);
+      query.descending("orderDate");
+      query.limit(1);
+      const latestOrder = await query.first({ useMasterKey: true });
+      if (latestOrder && latestOrder.get("orderDate")) {
+        createdAfter = latestOrder.get("orderDate").toISOString();
+        console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u4F7F\u7528\u6700\u65B0\u7684 orderDate: ${createdAfter}`);
+      } else {
+        const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
+        createdAfter = ninetyDaysAgo.toISOString();
+        console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u6CA1\u6709Order\u6570\u636E,\u4F7F\u752890\u5929\u524D\u7684\u65F6\u95F4: ${createdAfter}`);
+      }
+    } catch (error) {
+      const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
+      createdAfter = ninetyDaysAgo.toISOString();
+      console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u67E5\u8BE2Order\u5931\u8D25,\u4F7F\u752890\u5929\u524D\u7684\u65F6\u95F4: ${createdAfter}, \u9519\u8BEF: ${error.message}`);
+    }
+    const response = await fetch("http://localhost:3000/api/amazon/forward", {
+      method: "POST",
+      headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+      body: JSON.stringify({
+        path: `/orders/v0/orders?MarketplaceIds=${marketplaceId}&CreatedAfter=${createdAfter}`,
+        method: "GET",
+        functionName: "cleanOrders"
+      })
+    });
+    const result = await response.json();
+    if (!result.success) {
+      throw new Error(result.message || "\u8BA2\u5355\u91C7\u96C6\u5931\u8D25");
+    }
+    console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u8BA2\u5355\u6570\u636E\u91C7\u96C6\u5B8C\u6210: ${result.processed || 0} \u6761\u8BB0\u5F55`);
+  }
+  /**
+   * 采集报表数据
+   * @private
+   * @param {string} shopId - 店铺ID
+   * @param {any} config - 店铺配置
+   * @param {string[]} marketplaceId - 市场ID列表
+   * @returns {Promise<void>}
+   */
+  async #collectReports(shopId, config, marketplaceId) {
+    const Parse = globalThis.Parse;
+    const reportTypes = ["GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA"];
+    for (const reportType of reportTypes) {
+      try {
+        let dataStartTime;
+        try {
+          const q = new Parse.Query("Reports");
+          q.equalTo("shop", shopId);
+          q.equalTo("reportType", reportType);
+          q.descending("dataEndTime");
+          q.limit(1);
+          const last = await q.first({ useMasterKey: true });
+          if (last && last.get("dataEndTime")) {
+            dataStartTime = last.get("dataEndTime").toISOString();
+          } else {
+            dataStartTime = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3).toISOString();
+          }
+        } catch (e) {
+          dataStartTime = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3).toISOString();
+        }
+        const dataEndTime = (/* @__PURE__ */ new Date()).toISOString();
+        console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u521B\u5EFA\u62A5\u8868 ${reportType}, range: ${dataStartTime} ~ ${dataEndTime}`);
+        const createRes = await fetch("http://localhost:3000/api/amazon/forward", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({
+            path: "/reports/2021-06-30/reports",
+            method: "POST",
+            body: { reportType, marketplaceIds: [marketplaceId], dataStartTime, dataEndTime }
+          })
+        });
+        const createResult = await createRes.json();
+        const reportId = createResult?.data?.reportId;
+        if (!reportId) {
+          console.error(`[SP-API Scheduler] \u521B\u5EFA\u62A5\u8868\u5931\u8D25 ${reportType}:`, JSON.stringify(createResult));
+          continue;
+        }
+        console.log(`[SP-API Scheduler] \u62A5\u8868\u5DF2\u521B\u5EFA, reportId: ${reportId}`);
+        await this.#delay(3e3);
+        const getRes = await fetch("http://localhost:3000/api/amazon/forward", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({
+            path: `/reports/2021-06-30/reports/${reportId}`,
+            method: "GET"
+          })
+        });
+        const reportData = await getRes.json();
+        const reportInfo = reportData?.data || {};
+        const ReportObj = Parse.Object.extend("Reports");
+        const rpt = new ReportObj();
+        rpt.set("shop", {
+          __type: "Pointer",
+          className: "Shop",
+          objectId: shopId
+        });
+        rpt.set("reportId", reportId);
+        rpt.set("reportType", reportType);
+        rpt.set("marketplaceIds", reportInfo.marketplaceIds || []);
+        rpt.set("dataStartTime", reportInfo.dataStartTime ? new Date(reportInfo.dataStartTime) : null);
+        rpt.set("dataEndTime", reportInfo.dataEndTime ? new Date(reportInfo.dataEndTime) : null);
+        rpt.set("createdTime", reportInfo.createdTime ? new Date(reportInfo.createdTime) : null);
+        rpt.set("processingStartTime", reportInfo.processingStartTime ? new Date(reportInfo.processingStartTime) : null);
+        rpt.set("processingEndTime", reportInfo.processingEndTime ? new Date(reportInfo.processingEndTime) : null);
+        rpt.set("processingStatus", reportInfo.processingStatus || "IN_QUEUE");
+        rpt.set("reportDocumentId", reportInfo.reportDocumentId || null);
+        rpt.set("isParsed", false);
+        await rpt.save(null, { useMasterKey: true });
+        console.log(`[SP-API Scheduler] \u62A5\u8868\u5DF2\u4FDD\u5B58\u5230 Reports \u8868, status: ${reportInfo.processingStatus}`);
+        await this.#delay(2e3);
+      } catch (error) {
+        console.error(`[SP-API Scheduler] \u62A5\u8868 ${reportType} \u5904\u7406\u5931\u8D25: ${error.message}`);
+      }
+    }
+  }
+  /**
+   * 处理未解析的报表
+   * @private
+   * @param {string} shopId - 店铺ID
+   * @returns {Promise<void>}
+   */
+  async #processUnparsedReports(shopId) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Reports");
+    query.equalTo("shop", shopId);
+    query.equalTo("isParsed", false);
+    query.equalTo("processingStatus", "DONE");
+    query.limit(1e3);
+    const unparsedReports = await query.find();
+    if (unparsedReports.length === 0) {
+      console.log(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u6CA1\u6709\u672A\u89E3\u6790\u7684\u62A5\u8868`);
+      return;
+    }
+    console.log(`[SP-API Scheduler] \u53D1\u73B0 ${unparsedReports.length} \u4E2A\u672A\u89E3\u6790\u62A5\u8868`);
+    for (const report of unparsedReports) {
+      try {
+        const reportDocumentId = report.get("reportDocumentId");
+        const reportType = report.get("reportType");
+        const response = await fetch("http://localhost:3000/api/amazon/processReport", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({
+            reportDocumentId,
+            reportType,
+            shopId
+          })
+        });
+        const result = await response.json();
+        if (result.success) {
+          report.set("isParsed", true);
+          report.set("parsedAt", /* @__PURE__ */ new Date());
+          report.set("parsedRecords", result.processed || 0);
+          await report.save(null, { useMasterKey: true });
+          console.log(`[SP-API Scheduler] \u62A5\u8868 ${reportDocumentId} \u89E3\u6790\u5B8C\u6210: ${result.processed} \u6761\u8BB0\u5F55`);
+        }
+        await this.#delay(1e4);
+      } catch (error) {
+        console.error(`[SP-API Scheduler] \u62A5\u8868\u89E3\u6790\u5931\u8D25: ${error.message}`);
+      }
+    }
+  }
+  /**
+   * 检查并更新未完成的报表状态
+   * 每天凌晨2点执行:查询 Reports 表中 status 不为 DONE 的报表,
+   * 通过 reportId 获取最新状态并更新,然后解析已完成的报表
+   * @private
+   * @returns {Promise<void>}
+   */
+  async #checkPendingReports(shopid) {
+    const Parse = globalThis.Parse;
+    console.log("[SP-API Scheduler] ========================================");
+    console.log("[SP-API Scheduler] \u5F00\u59CB\u68C0\u67E5\u672A\u5B8C\u6210\u7684\u62A5\u8868\u72B6\u6001");
+    console.log("[SP-API Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[SP-API Scheduler] ========================================");
+    try {
+      const query = new Parse.Query("Reports");
+      query.notEqualTo("processingStatus", "DONE");
+      if (shopid) {
+        query.equalTo("shop", shopid);
+      }
+      query.limit(1e3);
+      const pendingReports = await query.find({ useMasterKey: true });
+      if (pendingReports.length === 0) {
+        console.log("[SP-API Scheduler] \u6CA1\u6709\u672A\u5B8C\u6210\u7684\u62A5\u8868");
+        return;
+      }
+      console.log(`[SP-API Scheduler] \u53D1\u73B0 ${pendingReports.length} \u4E2A\u672A\u5B8C\u6210\u62A5\u8868`);
+      const shopIdsToProcess = /* @__PURE__ */ new Set();
+      for (const report of pendingReports) {
+        try {
+          const reportId = report.get("reportId");
+          const shopId = report.get("shop").id;
+          if (!reportId || !shopId)
+            continue;
+          console.log(`[SP-API Scheduler] \u68C0\u67E5\u62A5\u8868 ${reportId} \u72B6\u6001...`);
+          const getRes = await fetch("http://localhost:3000/api/amazon/forward", {
+            method: "POST",
+            headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+            body: JSON.stringify({
+              path: `/reports/2021-06-30/reports/${reportId}`,
+              method: "GET"
+            })
+          });
+          const reportData = await getRes.json();
+          const reportInfo = reportData?.data || {};
+          const newStatus = reportInfo.processingStatus || report.get("processingStatus");
+          report.set("processingStatus", newStatus);
+          if (reportInfo.reportDocumentId) {
+            report.set("reportDocumentId", reportInfo.reportDocumentId);
+          }
+          await report.save(null, { useMasterKey: true });
+          console.log(`[SP-API Scheduler] \u62A5\u8868 ${reportId} \u72B6\u6001\u66F4\u65B0\u4E3A: ${newStatus}`);
+          if (newStatus === "DONE") {
+            shopIdsToProcess.add(shopId);
+          }
+          await this.#delay(1e4);
+        } catch (error) {
+          console.error(`[SP-API Scheduler] \u66F4\u65B0\u62A5\u8868\u72B6\u6001\u5931\u8D25: ${error.message}`);
+        }
+      }
+      for (const shopId of shopIdsToProcess) {
+        try {
+          console.log(`[SP-API Scheduler] \u89E3\u6790\u5E97\u94FA ${shopId} \u7684\u5DF2\u5B8C\u6210\u62A5\u8868`);
+          await this.#processUnparsedReports(shopId);
+        } catch (error) {
+          console.error(`[SP-API Scheduler] \u5E97\u94FA ${shopId} \u62A5\u8868\u89E3\u6790\u5931\u8D25: ${error.message}`);
+        }
+      }
+      console.log("[SP-API Scheduler] \u672A\u5B8C\u6210\u62A5\u8868\u68C0\u67E5\u5B8C\u6BD5");
+    } catch (error) {
+      console.error(`[SP-API Scheduler] \u68C0\u67E5\u672A\u5B8C\u6210\u62A5\u8868\u5931\u8D25: ${error.message}`);
+    }
+  }
+  /**
+   * 处理单个店铺的数据采集
+   * @private
+   * @param {any} shop - 店铺对象
+   * @returns {Promise<void>}
+   */
+  async #processShop(shop) {
+    const shopId = shop.id;
+    const shopName = shop.get("name");
+    const config = shop.get("config");
+    const marketplaceId = shop.get("marketplaceId");
+    if (!config || !config.SpApiConfig) {
+      throw new Error("\u5E97\u94FA\u914D\u7F6E\u7F3A\u5931");
+    }
+    if (config.SpApiConfig.listingEnabled !== false) {
+      try {
+        console.log(`[SP-API Scheduler] \u91C7\u96C6\u5E97\u94FA ${shopName} \u7684 Listing \u6570\u636E`);
+        await this.#collectListings(shopId, config, marketplaceId);
+        await this.#delay(3e3);
+      } catch (error) {
+        console.error(`[SP-API Scheduler] Listing \u91C7\u96C6\u5931\u8D25: ${error.message}`);
+      }
+    }
+    try {
+      console.log(`[SP-API Scheduler] \u91C7\u96C6\u5E97\u94FA ${shopName} \u7684\u8BA2\u5355\u6570\u636E`);
+      await this.#collectOrders(shopId, config, marketplaceId);
+      await this.#delay(3e3);
+    } catch (error) {
+      console.error(`[SP-API Scheduler] \u8BA2\u5355\u91C7\u96C6\u5931\u8D25: ${error.message}`);
+    }
+    try {
+      console.log(`[SP-API Scheduler] \u91C7\u96C6\u5E97\u94FA ${shopName} \u7684\u62A5\u8868\u6570\u636E`);
+      await this.#collectReports(shopId, config, marketplaceId);
+      await this.#delay(3e3);
+    } catch (error) {
+      console.error(`[SP-API Scheduler] \u62A5\u8868\u91C7\u96C6\u5931\u8D25: ${error.message}`);
+    }
+    try {
+      console.log(`[SP-API Scheduler] \u5904\u7406\u5E97\u94FA ${shopName} \u7684\u672A\u89E3\u6790\u62A5\u8868`);
+      await this.#checkPendingReports(shopId);
+    } catch (error) {
+      console.error(`[SP-API Scheduler] \u62A5\u8868\u5904\u7406\u5931\u8D25: ${error.message}`);
+    }
+    shop.set("lastSyncTime", /* @__PURE__ */ new Date());
+    shop.set("syncStatus", "completed");
+    await shop.save(null, { useMasterKey: true });
+  }
+  /**
+   * 执行数据采集任务(核心方法:移到调用方之前)
+   * @private
+   * @returns {Promise<any>} 执行结果对象,包含成功数、失败数、耗时等信息
+   */
+  async #executeDataCollection(shopId) {
+    if (this.#isRunning) {
+      console.log("[SP-API Scheduler] \u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D\uFF0C\u8DF3\u8FC7\u672C\u6B21\u8C03\u5EA6");
+      return {
+        success: false,
+        message: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D",
+        successCount: 0,
+        errorCount: 0,
+        duration: 0,
+        errors: []
+      };
+    }
+    this.#isRunning = true;
+    const startTime = Date.now();
+    const errors = [];
+    let successCount = 0;
+    let errorCount = 0;
+    console.log("[SP-API Scheduler] ========================================");
+    console.log("[SP-API Scheduler] \u5F00\u59CB\u6267\u884C\u6BCF\u65E5\u6570\u636E\u91C7\u96C6\u4EFB\u52A1");
+    console.log("[SP-API Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[SP-API Scheduler] ========================================");
+    try {
+      const shops = await this.#getActiveAmazonShops(shopId);
+      if (shops.length === 0) {
+        console.log("[SP-API Scheduler] \u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA");
+        return {
+          success: true,
+          message: "\u6CA1\u6709\u6D3B\u8DC3\u5E97\u94FA\u9700\u8981\u5904\u7406",
+          successCount: 0,
+          errorCount: 0,
+          duration: Date.now() - startTime,
+          errors: []
+        };
+      }
+      console.log(`[SP-API Scheduler] \u53D1\u73B0 ${shops.length} \u4E2A Amazon \u5E97\u94FA`);
+      for (const shop of shops) {
+        try {
+          console.log(`
+[SP-API Scheduler] ----------------------------------------`);
+          console.log(`[SP-API Scheduler] \u5F00\u59CB\u5904\u7406\u5E97\u94FA: ${shop.get("name")} (${shop.id})`);
+          await this.#processShop(shop);
+          successCount++;
+          console.log(`[SP-API Scheduler] \u5E97\u94FA ${shop.get("name")} \u5904\u7406\u5B8C\u6210`);
+          await this.#delay(2e3);
+        } catch (error) {
+          errorCount++;
+          const errorMsg = `\u5E97\u94FA ${shop.get("name")} \u5904\u7406\u5931\u8D25: ${error.message}`;
+          errors.push(errorMsg);
+          console.error(`[SP-API Scheduler] ${errorMsg}`);
+        }
+      }
+      const duration = Date.now() - startTime;
+      await this.#logExecution({
+        taskName: "sp-api-daily-sync",
+        startTime: new Date(startTime),
+        endTime: /* @__PURE__ */ new Date(),
+        duration,
+        successCount,
+        errorCount,
+        errors,
+        status: errorCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed"
+      });
+      this.#lastRunTime = /* @__PURE__ */ new Date();
+      const message = `\u6570\u636E\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${shops.length} \u4E2A\u5E97\u94FA\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[SP-API Scheduler] ========================================`);
+      console.log(`[SP-API Scheduler] ${message}`);
+      console.log(`[SP-API Scheduler] ========================================
+`);
+      return {
+        success: errorCount === 0,
+        message,
+        successCount,
+        errorCount,
+        duration,
+        errors
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u6570\u636E\u91C7\u96C6\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[SP-API Scheduler] ${errorMsg}`);
+      return {
+        success: false,
+        message: errorMsg,
+        successCount,
+        errorCount: errorCount + 1,
+        duration,
+        errors: [...errors, errorMsg]
+      };
+    } finally {
+      this.#isRunning = false;
+    }
+  }
+  // ========== 公共方法(后声明,因为依赖前面的私有方法) ==========
+  /**
+   * 构造函数
+   * 初始化 Amazon SP-API 数据采集调度器
+   */
+  constructor() {
+    console.log("[SP-API Scheduler] \u521D\u59CB\u5316 Amazon SP-API \u6570\u636E\u91C7\u96C6\u8C03\u5EA6\u5668");
+  }
+  /**
+   * 启动定时任务
+   * 每天凌晨1点自动执行数据采集
+   * @returns {Promise<void>}
+   */
+  async start() {
+    if (this.#cronTask) {
+      console.log("[SP-API Scheduler] \u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5728\u8FD0\u884C\u4E2D");
+      return;
+    }
+    this.#cronTask = nodeCron.schedule("0 1 * * *", async () => {
+      await this.#executeDataCollection();
+    }, {
+      timezone: "Asia/Shanghai"
+    });
+    console.log("[SP-API Scheduler] \u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u5929\u51CC\u66681\u70B9\u6267\u884C");
+    this.#reportCronTask = nodeCron.schedule("0 2 * * *", async () => {
+      await this.#checkPendingReports();
+    }, {
+      timezone: "Asia/Shanghai"
+    });
+    console.log("[SP-API Scheduler] \u62A5\u8868\u72B6\u6001\u68C0\u67E5\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u5929\u51CC\u66682\u70B9\u6267\u884C");
+  }
+  /**
+   * 停止定时任务
+   * @returns {void}
+   */
+  stop() {
+    if (this.#cronTask) {
+      this.#cronTask.stop();
+      this.#cronTask = null;
+      console.log("[SP-API Scheduler] \u6570\u636E\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#reportCronTask) {
+      this.#reportCronTask.stop();
+      this.#reportCronTask = null;
+      console.log("[SP-API Scheduler] \u62A5\u8868\u72B6\u6001\u68C0\u67E5\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+  }
+  /**
+   * 获取调度器状态
+   * @returns {object} 调度器状态对象
+   */
+  getStatus() {
+    const nextRunTime = this.#calculateNextRunTime();
+    return {
+      isRunning: this.#isRunning,
+      lastRunTime: this.#lastRunTime,
+      nextRunTime,
+      cronEnabled: this.#cronEnabled
+    };
+  }
+  /**
+   * 手动触发数据采集
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerManually(shopId) {
+    console.log("[SP-API Scheduler] \u624B\u52A8\u89E6\u53D1\u6570\u636E\u91C7\u96C6\u4EFB\u52A1");
+    return await this.#executeDataCollection(shopId);
+  }
+};
+var spApiScheduler = new SpApiScheduler();
+
+// backend/api/module/schedule/new-sp-api-schedule.ts
+import nodeCron2 from "npm:node-cron";
+var NewSpApiScheduler = class {
+  #cronTask = null;
+  #reportCronTask = null;
+  #lastRunTime = null;
+  // ========== 私有工具 ==========
+  #delay(ms) {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+  #calculateNextRunTime() {
+    const now = /* @__PURE__ */ new Date();
+    const next = new Date(now);
+    next.setHours(1, 0, 0, 0);
+    if (next <= now)
+      next.setDate(next.getDate() + 1);
+    return next;
+  }
+  /**
+   * 查询活跃的 Amazon 店铺列表
+   * @param shopId 若传入则只返回该店铺
+   */
+  async #getActiveAmazonShops(shopId) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Shop");
+    query.equalTo("platform", "amazon");
+    query.equalTo("status", "active");
+    if (shopId)
+      query.equalTo("objectId", shopId);
+    query.limit(1e3);
+    return await query.find({ useMasterKey: true });
+  }
+  // ========== 单店铺内部实现 ==========
+  /**
+   * 执行单个店铺的 Listing 采集
+   * 通过 /api/amazon/forward 调用 SP-API,内部 cleanListings 处理全部分页
+   */
+  async #doCollectListings(shopId, config, marketplaceId, progress) {
+    const sellerId = config.SpApiConfig.sellerID;
+    marketplaceId = marketplaceId || "ATVPDKIKX0DER";
+    const Parse = globalThis.Parse;
+    let lastUpdatedAfter;
+    try {
+      const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+      const query = new Parse.Query("Listing");
+      query.equalTo("shop", shopPointer);
+      query.descending("lastUpdatedDate");
+      query.limit(1);
+      const latestListing = await query.first({ useMasterKey: true });
+      if (latestListing && latestListing.get("lastUpdatedDate")) {
+        lastUpdatedAfter = latestListing.get("lastUpdatedDate").toISOString();
+        progress?.({ type: "info", message: `\u589E\u91CF\u91C7\u96C6\uFF0C\u4F7F\u7528\u6700\u65B0 lastUpdatedDate: ${lastUpdatedAfter}` });
+      } else {
+        const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1e3);
+        lastUpdatedAfter = oneYearAgo.toISOString();
+        progress?.({ type: "info", message: `\u65E0\u5386\u53F2\u6570\u636E\uFF0C\u4ECE\u4E00\u5E74\u524D\u5F00\u59CB\u5168\u91CF\u91C7\u96C6: ${lastUpdatedAfter}` });
+      }
+    } catch (error) {
+      const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1e3);
+      lastUpdatedAfter = oneYearAgo.toISOString();
+      progress?.({ type: "warn", message: `\u67E5\u8BE2 Listing \u5386\u53F2\u5931\u8D25\uFF0C\u9ED8\u8BA4\u4F7F\u7528\u4E00\u5E74\u524D: ${lastUpdatedAfter}` });
+    }
+    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}`;
+    progress?.({ type: "info", message: "\u6B63\u5728\u8C03\u7528\u4E9A\u9A6C\u900A Listing API\uFF08cleanListings \u5185\u90E8\u5904\u7406\u5206\u9875\uFF0C\u8BF7\u8010\u5FC3\u7B49\u5F85\uFF09..." });
+    console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} \u8BF7\u6C42 Listing API\uFF0Cpath: ${path}`);
+    const response = await fetch("http://localhost:3000/api/amazon/forward", {
+      method: "POST",
+      headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+      body: JSON.stringify({ path, method: "GET", functionName: "cleanListings" })
+    });
+    const result = await response.json();
+    if (!result.success) {
+      throw new Error(result.message || "Listing \u91C7\u96C6\u5931\u8D25");
+    }
+    const processed = result.data?.processed || 0;
+    console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} Listing \u91C7\u96C6\u5B8C\u6210: ${processed} \u6761\u8BB0\u5F55`);
+    return { processed };
+  }
+  /**
+   * 执行单个店铺的订单采集(增量)
+   * 通过 /api/amazon/forward 调用 SP-API,内部 cleanOrders 处理全部分页
+   */
+  async #doCollectOrders(shopId, config, marketplaceId, progress) {
+    marketplaceId = marketplaceId || "ATVPDKIKX0DER";
+    const Parse = globalThis.Parse;
+    let createdAfter;
+    try {
+      const shopPointer = Parse.Object.extend("Shop").createWithoutData(shopId);
+      const query = new Parse.Query("Order");
+      query.equalTo("shop", shopPointer);
+      query.descending("orderDate");
+      query.limit(1);
+      const latestOrder = await query.first({ useMasterKey: true });
+      if (latestOrder && latestOrder.get("orderDate")) {
+        createdAfter = latestOrder.get("orderDate").toISOString();
+        progress?.({ type: "info", message: `\u589E\u91CF\u91C7\u96C6\uFF0C\u4F7F\u7528\u6700\u65B0 orderDate: ${createdAfter}` });
+      } else {
+        const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
+        createdAfter = ninetyDaysAgo.toISOString();
+        progress?.({ type: "info", message: `\u65E0\u5386\u53F2\u6570\u636E\uFF0C\u4ECE90\u5929\u524D\u5F00\u59CB\u91C7\u96C6: ${createdAfter}` });
+      }
+    } catch (error) {
+      const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
+      createdAfter = ninetyDaysAgo.toISOString();
+      progress?.({ type: "warn", message: `\u67E5\u8BE2 Order \u5386\u53F2\u5931\u8D25\uFF0C\u9ED8\u8BA4\u4F7F\u752890\u5929\u524D: ${createdAfter}` });
+    }
+    progress?.({ type: "info", message: "\u6B63\u5728\u8C03\u7528\u4E9A\u9A6C\u900A Orders API\uFF08cleanOrders \u5185\u90E8\u5904\u7406\u5206\u9875\uFF0C\u8BF7\u8010\u5FC3\u7B49\u5F85\uFF09..." });
+    console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} \u8BF7\u6C42 Orders API\uFF0CcreatedAfter: ${createdAfter}`);
+    const response = await fetch("http://localhost:3000/api/amazon/forward", {
+      method: "POST",
+      headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+      body: JSON.stringify({
+        path: `/orders/v0/orders?MarketplaceIds=${marketplaceId}&CreatedAfter=${createdAfter}`,
+        method: "GET",
+        functionName: "cleanOrders"
+      })
+    });
+    const result = await response.json();
+    if (!result.success) {
+      throw new Error(result.message || "\u8BA2\u5355\u91C7\u96C6\u5931\u8D25");
+    }
+    const processed = result.data?.processed || 0;
+    console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} \u8BA2\u5355\u91C7\u96C6\u5B8C\u6210: ${processed} \u6761\u8BB0\u5F55`);
+    return { processed };
+  }
+  /**
+   * 执行单个店铺的报表创建与保存
+   */
+  async #doCollectReports(shopId, config, marketplaceId, progress) {
+    const Parse = globalThis.Parse;
+    const reportTypes = ["GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA"];
+    for (const reportType of reportTypes) {
+      try {
+        let dataStartTime;
+        try {
+          const q = new Parse.Query("Reports");
+          q.equalTo("shop", shopId);
+          q.equalTo("reportType", reportType);
+          q.descending("dataEndTime");
+          q.limit(1);
+          const last = await q.first({ useMasterKey: true });
+          dataStartTime = last?.get("dataEndTime") ? last.get("dataEndTime").toISOString() : new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3).toISOString();
+        } catch (e) {
+          dataStartTime = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3).toISOString();
+        }
+        const dataEndTime = (/* @__PURE__ */ new Date()).toISOString();
+        progress?.({ type: "info", message: `\u521B\u5EFA\u62A5\u8868 ${reportType}\uFF0C\u65F6\u95F4\u8303\u56F4: ${dataStartTime} ~ ${dataEndTime}` });
+        console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} \u521B\u5EFA\u62A5\u8868 ${reportType}, range: ${dataStartTime} ~ ${dataEndTime}`);
+        const createRes = await fetch("http://localhost:3000/api/amazon/forward", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({
+            path: "/reports/2021-06-30/reports",
+            method: "POST",
+            body: { reportType, marketplaceIds: [marketplaceId], dataStartTime, dataEndTime }
+          })
+        });
+        const createResult = await createRes.json();
+        const reportId = createResult?.data?.reportId;
+        if (!reportId) {
+          progress?.({ type: "error", message: `\u521B\u5EFA\u62A5\u8868\u5931\u8D25 ${reportType}: ${JSON.stringify(createResult)}` });
+          console.error(`[New SP-API Scheduler] \u521B\u5EFA\u62A5\u8868\u5931\u8D25 ${reportType}:`, JSON.stringify(createResult));
+          continue;
+        }
+        progress?.({ type: "info", message: `\u62A5\u8868\u5DF2\u521B\u5EFA reportId: ${reportId}\uFF0C\u7B49\u5F85\u67E5\u8BE2\u72B6\u6001...` });
+        console.log(`[New SP-API Scheduler] \u62A5\u8868\u5DF2\u521B\u5EFA, reportId: ${reportId}`);
+        await this.#delay(3e3);
+        const getRes = await fetch("http://localhost:3000/api/amazon/forward", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({
+            path: `/reports/2021-06-30/reports/${reportId}`,
+            method: "GET"
+          })
+        });
+        const reportData = await getRes.json();
+        const reportInfo = reportData?.data || {};
+        const ReportObj = Parse.Object.extend("Reports");
+        const rpt = new ReportObj();
+        rpt.set("shop", { __type: "Pointer", className: "Shop", objectId: shopId });
+        rpt.set("reportId", reportId);
+        rpt.set("reportType", reportType);
+        rpt.set("marketplaceIds", reportInfo.marketplaceIds || []);
+        rpt.set("dataStartTime", reportInfo.dataStartTime ? new Date(reportInfo.dataStartTime) : null);
+        rpt.set("dataEndTime", reportInfo.dataEndTime ? new Date(reportInfo.dataEndTime) : null);
+        rpt.set("createdTime", reportInfo.createdTime ? new Date(reportInfo.createdTime) : null);
+        rpt.set("processingStartTime", reportInfo.processingStartTime ? new Date(reportInfo.processingStartTime) : null);
+        rpt.set("processingEndTime", reportInfo.processingEndTime ? new Date(reportInfo.processingEndTime) : null);
+        rpt.set("processingStatus", reportInfo.processingStatus || "IN_QUEUE");
+        rpt.set("reportDocumentId", reportInfo.reportDocumentId || null);
+        rpt.set("isParsed", false);
+        await rpt.save(null, { useMasterKey: true });
+        progress?.({ type: "success", message: `\u62A5\u8868 ${reportType} \u5DF2\u4FDD\u5B58\uFF0CprocessingStatus: ${reportInfo.processingStatus || "IN_QUEUE"}` });
+        console.log(`[New SP-API Scheduler] \u62A5\u8868\u5DF2\u4FDD\u5B58, status: ${reportInfo.processingStatus}`);
+        await this.#delay(2e3);
+      } catch (error) {
+        progress?.({ type: "error", message: `\u62A5\u8868 ${reportType} \u5904\u7406\u5931\u8D25: ${error.message}` });
+        console.error(`[New SP-API Scheduler] \u62A5\u8868 ${reportType} \u5904\u7406\u5931\u8D25: ${error.message}`);
+      }
+    }
+  }
+  /**
+   * 检查并更新 processingStatus 不为 DONE 的报表
+   */
+  async #doCheckPendingReports(shopId, progress) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Reports");
+    query.notEqualTo("processingStatus", "DONE");
+    if (shopId)
+      query.equalTo("shop", shopId);
+    query.limit(1e3);
+    const pendingReports = await query.find({ useMasterKey: true });
+    if (pendingReports.length === 0) {
+      progress?.({ type: "info", message: "\u6CA1\u6709\u72B6\u6001\u672A\u5B8C\u6210\u7684\u62A5\u8868\uFF0C\u8DF3\u8FC7\u72B6\u6001\u66F4\u65B0" });
+      console.log("[New SP-API Scheduler] \u6CA1\u6709\u672A\u5B8C\u6210\u7684\u62A5\u8868");
+      return;
+    }
+    progress?.({ type: "info", message: `\u53D1\u73B0 ${pendingReports.length} \u4E2A\u672A\u5B8C\u6210\u62A5\u8868\uFF0C\u9010\u4E00\u67E5\u8BE2\u6700\u65B0\u72B6\u6001...` });
+    console.log(`[New SP-API Scheduler] \u53D1\u73B0 ${pendingReports.length} \u4E2A\u672A\u5B8C\u6210\u62A5\u8868`);
+    for (const report of pendingReports) {
+      try {
+        const reportId = report.get("reportId");
+        const sid = report.get("shop").id;
+        if (!reportId || !sid)
+          continue;
+        const getRes = await fetch("http://localhost:3000/api/amazon/forward", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": sid },
+          body: JSON.stringify({ path: `/reports/2021-06-30/reports/${reportId}`, method: "GET" })
+        });
+        const reportData = await getRes.json();
+        const reportInfo = reportData?.data || {};
+        const newStatus = reportInfo.processingStatus || report.get("processingStatus");
+        report.set("processingStatus", newStatus);
+        if (reportInfo.reportDocumentId)
+          report.set("reportDocumentId", reportInfo.reportDocumentId);
+        await report.save(null, { useMasterKey: true });
+        progress?.({ type: "info", message: `\u62A5\u8868 ${reportId} \u72B6\u6001\u66F4\u65B0\u4E3A: ${newStatus}` });
+        console.log(`[New SP-API Scheduler] \u62A5\u8868 ${reportId} \u72B6\u6001\u66F4\u65B0\u4E3A: ${newStatus}`);
+        await this.#delay(1e4);
+      } catch (error) {
+        progress?.({ type: "error", message: `\u66F4\u65B0\u62A5\u8868\u72B6\u6001\u5931\u8D25: ${error.message}` });
+        console.error(`[New SP-API Scheduler] \u66F4\u65B0\u62A5\u8868\u72B6\u6001\u5931\u8D25: ${error.message}`);
+      }
+    }
+  }
+  /**
+   * 解析 isParsed=false & processingStatus=DONE 的报表
+   */
+  async #doProcessUnparsedReports(shopId, progress) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Reports");
+    query.equalTo("shop", shopId);
+    query.equalTo("isParsed", false);
+    query.equalTo("processingStatus", "DONE");
+    query.limit(1e3);
+    const unparsedReports = await query.find();
+    if (unparsedReports.length === 0) {
+      progress?.({ type: "info", message: "\u6CA1\u6709\u5F85\u89E3\u6790\u7684\u62A5\u8868\uFF08\u72B6\u6001 DONE \u4E14\u672A\u89E3\u6790\uFF09" });
+      console.log(`[New SP-API Scheduler] \u5E97\u94FA ${shopId} \u6CA1\u6709\u5F85\u89E3\u6790\u7684\u62A5\u8868`);
+      return { processed: 0 };
+    }
+    progress?.({ type: "info", message: `\u53D1\u73B0 ${unparsedReports.length} \u4E2A\u5F85\u89E3\u6790\u62A5\u8868` });
+    console.log(`[New SP-API Scheduler] \u53D1\u73B0 ${unparsedReports.length} \u4E2A\u5F85\u89E3\u6790\u62A5\u8868`);
+    let totalProcessed = 0;
+    for (const report of unparsedReports) {
+      try {
+        const reportDocumentId = report.get("reportDocumentId");
+        const reportType = report.get("reportType");
+        if (!reportDocumentId) {
+          progress?.({ type: "warn", message: `\u62A5\u8868 ${report.id} \u7F3A\u5C11 reportDocumentId\uFF0C\u8DF3\u8FC7` });
+          continue;
+        }
+        progress?.({ type: "info", message: `\u6B63\u5728\u89E3\u6790\u62A5\u8868 ${reportDocumentId} (${reportType})` });
+        console.log(`[New SP-API Scheduler] \u89E3\u6790\u62A5\u8868 ${reportDocumentId} (${reportType})`);
+        const response = await fetch("http://localhost:3000/api/amazon/processReport", {
+          method: "POST",
+          headers: { "Content-Type": "application/json", "shop-objectid": shopId },
+          body: JSON.stringify({ reportDocumentId, reportType, shopId })
+        });
+        const result = await response.json();
+        if (result.success) {
+          const parsed = result.data?.processResult?.processed || 0;
+          report.set("isParsed", true);
+          report.set("parsedAt", /* @__PURE__ */ new Date());
+          report.set("parsedRecords", parsed);
+          await report.save(null, { useMasterKey: true });
+          totalProcessed += parsed;
+          progress?.({ type: "success", message: `\u62A5\u8868 ${reportDocumentId} \u89E3\u6790\u5B8C\u6210: ${parsed} \u6761\u8BB0\u5F55`, processed: parsed });
+          console.log(`[New SP-API Scheduler] \u62A5\u8868 ${reportDocumentId} \u89E3\u6790\u5B8C\u6210: ${parsed} \u6761\u8BB0\u5F55`);
+        } else {
+          progress?.({ type: "warn", message: `\u62A5\u8868 ${reportDocumentId} \u89E3\u6790\u672A\u6210\u529F: ${result.message}` });
+          console.warn(`[New SP-API Scheduler] \u62A5\u8868 ${reportDocumentId} \u89E3\u6790\u672A\u6210\u529F: ${result.message}`);
+        }
+        await this.#delay(1e4);
+      } catch (error) {
+        progress?.({ type: "error", message: `\u62A5\u8868\u89E3\u6790\u5F02\u5E38: ${error.message}` });
+        console.error(`[New SP-API Scheduler] \u62A5\u8868\u89E3\u6790\u5F02\u5E38: ${error.message}`);
+      }
+    }
+    return { processed: totalProcessed };
+  }
+  // ========== 构造函数 ==========
+  constructor() {
+    console.log("[New SP-API Scheduler] \u521D\u59CB\u5316 Amazon SP-API \u6570\u636E\u91C7\u96C6\u8C03\u5EA6\u5668\uFF08\u65B0\u7248\uFF09");
+  }
+  // ========== 公共采集方法 ==========
+  /**
+   * 采集所有活跃店铺(或指定店铺)的 Listing 数据
+   * @param shopId 可选,不传则采集全部活跃店铺
+   * @param progress 可选进度回调,适合 SSE 实时推送
+   */
+  async collectListings(shopId, progress) {
+    const shops = await this.#getActiveAmazonShops(shopId);
+    if (shops.length === 0) {
+      const msg = shopId ? `\u672A\u627E\u5230\u5E97\u94FA ${shopId} \u6216\u5E97\u94FA\u672A\u6FC0\u6D3B` : "\u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA";
+      progress?.({ type: "warn", message: msg });
+      console.warn(`[New SP-API Scheduler] ${msg}`);
+      return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
+    }
+    let totalProcessed = 0;
+    let errorCount = 0;
+    const errors = [];
+    for (const shop of shops) {
+      const sid = shop.id;
+      const shopName = shop.get("name");
+      const config = shop.get("config");
+      const marketplaceId = shop.get("marketplaceId") || "ATVPDKIKX0DER";
+      if (!config || !config.SpApiConfig) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011(${sid}) \u7F3A\u5C11 SpApiConfig \u914D\u7F6E\uFF0C\u8DF3\u8FC7`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+        continue;
+      }
+      if (config.SpApiConfig.listingEnabled === false) {
+        progress?.({ type: "warn", message: `\u5E97\u94FA\u3010${shopName}\u3011\u672A\u914D\u7F6E\u533A\u57DF Seller ID\uFF0C\u8DF3\u8FC7 Listing` });
+        continue;
+      }
+      progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u5E97\u94FA\u3010${shopName}\u3011\u7684 Listing \u6570\u636E` });
+      console.log(`[New SP-API Scheduler] \u5F00\u59CB\u91C7\u96C6\u5E97\u94FA ${shopName} (${sid}) \u7684 Listing \u6570\u636E`);
+      try {
+        const result = await this.#doCollectListings(sid, config, marketplaceId, progress);
+        progress?.({ type: "success", message: `\u5E97\u94FA\u3010${shopName}\u3011Listing \u91C7\u96C6\u5B8C\u6210`, processed: result.processed });
+        totalProcessed += result.processed;
+        await this.#delay(2e3);
+      } catch (error) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011Listing \u91C7\u96C6\u5931\u8D25: ${error.message}`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+      }
+    }
+    const message = `Listing \u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${shops.length - errorCount}/${shops.length} \u4E2A\u5E97\u94FA\uFF0C\u5171 ${totalProcessed} \u6761\u8BB0\u5F55`;
+    return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
+  }
+  /**
+   * 采集所有活跃店铺(或指定店铺)的订单数据(增量)
+   * @param shopId 可选
+   * @param progress 可选进度回调
+   */
+  async collectOrders(shopId, progress) {
+    const shops = await this.#getActiveAmazonShops(shopId);
+    if (shops.length === 0) {
+      const msg = shopId ? `\u672A\u627E\u5230\u5E97\u94FA ${shopId} \u6216\u5E97\u94FA\u672A\u6FC0\u6D3B` : "\u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA";
+      progress?.({ type: "warn", message: msg });
+      console.warn(`[New SP-API Scheduler] ${msg}`);
+      return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
+    }
+    let totalProcessed = 0;
+    let errorCount = 0;
+    const errors = [];
+    for (const shop of shops) {
+      const sid = shop.id;
+      const shopName = shop.get("name");
+      const config = shop.get("config");
+      const marketplaceId = shop.get("marketplaceId") || "ATVPDKIKX0DER";
+      if (!config || !config.SpApiConfig) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011(${sid}) \u7F3A\u5C11 SpApiConfig \u914D\u7F6E\uFF0C\u8DF3\u8FC7`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+        continue;
+      }
+      progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u5E97\u94FA\u3010${shopName}\u3011\u7684\u8BA2\u5355\u6570\u636E` });
+      console.log(`[New SP-API Scheduler] \u5F00\u59CB\u91C7\u96C6\u5E97\u94FA ${shopName} (${sid}) \u7684\u8BA2\u5355\u6570\u636E`);
+      try {
+        const result = await this.#doCollectOrders(sid, config, marketplaceId, progress);
+        progress?.({ type: "success", message: `\u5E97\u94FA\u3010${shopName}\u3011\u8BA2\u5355\u91C7\u96C6\u5B8C\u6210`, processed: result.processed });
+        totalProcessed += result.processed;
+        await this.#delay(2e3);
+      } catch (error) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011\u8BA2\u5355\u91C7\u96C6\u5931\u8D25: ${error.message}`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+      }
+    }
+    const message = `\u8BA2\u5355\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${shops.length - errorCount}/${shops.length} \u4E2A\u5E97\u94FA\uFF0C\u5171 ${totalProcessed} \u6761\u8BB0\u5F55`;
+    return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
+  }
+  /**
+   * 为所有活跃店铺(或指定店铺)创建并保存报表
+   * @param shopId 可选
+   * @param progress 可选进度回调
+   */
+  async collectReports(shopId, progress) {
+    const shops = await this.#getActiveAmazonShops(shopId);
+    if (shops.length === 0) {
+      const msg = shopId ? `\u672A\u627E\u5230\u5E97\u94FA ${shopId} \u6216\u5E97\u94FA\u672A\u6FC0\u6D3B` : "\u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA";
+      progress?.({ type: "warn", message: msg });
+      console.warn(`[New SP-API Scheduler] ${msg}`);
+      return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
+    }
+    let successCount = 0;
+    let errorCount = 0;
+    const errors = [];
+    for (const shop of shops) {
+      const sid = shop.id;
+      const shopName = shop.get("name");
+      const config = shop.get("config");
+      const marketplaceId = shop.get("marketplaceId") || "ATVPDKIKX0DER";
+      if (!config || !config.SpApiConfig) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011(${sid}) \u7F3A\u5C11 SpApiConfig \u914D\u7F6E\uFF0C\u8DF3\u8FC7`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+        continue;
+      }
+      progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u5E97\u94FA\u3010${shopName}\u3011\u7684\u62A5\u8868\u6570\u636E` });
+      console.log(`[New SP-API Scheduler] \u5F00\u59CB\u91C7\u96C6\u5E97\u94FA ${shopName} (${sid}) \u7684\u62A5\u8868\u6570\u636E`);
+      try {
+        await this.#doCollectReports(sid, config, marketplaceId, progress);
+        progress?.({ type: "success", message: `\u5E97\u94FA\u3010${shopName}\u3011\u62A5\u8868\u91C7\u96C6\u5B8C\u6210` });
+        successCount++;
+        await this.#delay(2e3);
+      } catch (error) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011\u62A5\u8868\u91C7\u96C6\u5931\u8D25: ${error.message}`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+      }
+    }
+    const message = `\u62A5\u8868\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${shops.length} \u4E2A\u5E97\u94FA`;
+    return { success: errorCount === 0, message, processed: successCount, errorCount, errors };
+  }
+  /**
+   * 检查未完成报表状态并解析已完成的报表
+   * @param shopId 可选
+   * @param progress 可选进度回调
+   */
+  async parseReports(shopId, progress) {
+    const shops = await this.#getActiveAmazonShops(shopId);
+    if (shops.length === 0) {
+      const msg = shopId ? `\u672A\u627E\u5230\u5E97\u94FA ${shopId} \u6216\u5E97\u94FA\u672A\u6FC0\u6D3B` : "\u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA";
+      progress?.({ type: "warn", message: msg });
+      console.warn(`[New SP-API Scheduler] ${msg}`);
+      return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
+    }
+    progress?.({ type: "info", message: "\u68C0\u67E5\u6240\u6709\u672A\u5B8C\u6210\u62A5\u8868\u7684\u6700\u65B0\u72B6\u6001..." });
+    try {
+      await this.#doCheckPendingReports(shopId, progress);
+    } catch (error) {
+      progress?.({ type: "warn", message: `\u68C0\u67E5\u672A\u5B8C\u6210\u62A5\u8868\u72B6\u6001\u65F6\u51FA\u9519: ${error.message}` });
+      console.warn(`[New SP-API Scheduler] \u68C0\u67E5\u672A\u5B8C\u6210\u62A5\u8868\u72B6\u6001\u51FA\u9519: ${error.message}`);
+    }
+    let totalProcessed = 0;
+    let errorCount = 0;
+    const errors = [];
+    for (const shop of shops) {
+      const sid = shop.id;
+      const shopName = shop.get("name");
+      progress?.({ type: "info", message: `\u5F00\u59CB\u89E3\u6790\u5E97\u94FA\u3010${shopName}\u3011\u7684\u5DF2\u5B8C\u6210\u62A5\u8868` });
+      console.log(`[New SP-API Scheduler] \u5F00\u59CB\u89E3\u6790\u5E97\u94FA ${shopName} (${sid}) \u7684\u5DF2\u5B8C\u6210\u62A5\u8868`);
+      try {
+        const result = await this.#doProcessUnparsedReports(sid, progress);
+        progress?.({ type: "success", message: `\u5E97\u94FA\u3010${shopName}\u3011\u62A5\u8868\u89E3\u6790\u5B8C\u6210: ${result.processed} \u6761\u8BB0\u5F55`, processed: result.processed });
+        totalProcessed += result.processed;
+        await this.#delay(2e3);
+      } catch (error) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011\u62A5\u8868\u89E3\u6790\u5931\u8D25: ${error.message}`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[New SP-API Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+      }
+    }
+    const message = `\u62A5\u8868\u89E3\u6790\u5B8C\u6210: \u6210\u529F ${shops.length - errorCount}/${shops.length} \u4E2A\u5E97\u94FA\uFF0C\u5171\u89E3\u6790 ${totalProcessed} \u6761\u8BB0\u5F55`;
+    return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
+  }
+  // ========== 定时任务管理 ==========
+  /**
+   * 启动定时任务
+   * - 凌晨 1 点:采集 Listing / Order / Report
+   * - 凌晨 2 点:解析已完成报表
+   */
+  async start() {
+    if (this.#cronTask) {
+      console.log("[New SP-API Scheduler] \u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5728\u8FD0\u884C\u4E2D\uFF0C\u8DF3\u8FC7\u91CD\u590D\u542F\u52A8");
+      return;
+    }
+    this.#cronTask = nodeCron2.schedule("0 1 * * *", async () => {
+      console.log("[New SP-API Scheduler] ===== \u51CC\u66681\u70B9\uFF1A\u5F00\u59CB\u6BCF\u65E5\u6570\u636E\u91C7\u96C6 =====");
+      this.#lastRunTime = /* @__PURE__ */ new Date();
+      try {
+        await this.collectListings(void 0, void 0);
+        await this.collectOrders(void 0, void 0);
+        await this.collectReports(void 0, void 0);
+      } catch (e) {
+        console.error("[New SP-API Scheduler] \u6BCF\u65E5\u91C7\u96C6\u4EFB\u52A1\u5931\u8D25:", e.message);
+      }
+      console.log("[New SP-API Scheduler] ===== \u6BCF\u65E5\u6570\u636E\u91C7\u96C6\u5B8C\u6210 =====");
+    }, { timezone: "Asia/Shanghai" });
+    this.#reportCronTask = nodeCron2.schedule("0 2 * * *", async () => {
+      console.log("[New SP-API Scheduler] ===== \u51CC\u66682\u70B9\uFF1A\u5F00\u59CB\u62A5\u8868\u89E3\u6790 =====");
+      try {
+        await this.parseReports(void 0, void 0);
+      } catch (e) {
+        console.error("[New SP-API Scheduler] \u62A5\u8868\u89E3\u6790\u4EFB\u52A1\u5931\u8D25:", e.message);
+      }
+      console.log("[New SP-API Scheduler] ===== \u62A5\u8868\u89E3\u6790\u5B8C\u6210 =====");
+    }, { timezone: "Asia/Shanghai" });
+    console.log("[New SP-API Scheduler] \u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF08\u51CC\u66681\u70B9\u91C7\u96C6\uFF0C\u51CC\u66682\u70B9\u89E3\u6790\uFF09");
+  }
+  /**
+   * 停止定时任务
+   */
+  stop() {
+    if (this.#cronTask) {
+      this.#cronTask.stop();
+      this.#cronTask = null;
+      console.log("[New SP-API Scheduler] \u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#reportCronTask) {
+      this.#reportCronTask.stop();
+      this.#reportCronTask = null;
+      console.log("[New SP-API Scheduler] \u62A5\u8868\u89E3\u6790\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+  }
+  /**
+   * 获取调度器状态
+   */
+  getStatus() {
+    return {
+      isRunning: !!(this.#cronTask || this.#reportCronTask),
+      lastRunTime: this.#lastRunTime,
+      nextRunTime: this.#calculateNextRunTime()
+    };
+  }
+};
+var newSpApiScheduler = new NewSpApiScheduler();
+
+// backend/api/module/schedule/sorftime-api-schedule.ts
+import nodeCron3 from "npm:node-cron";
+function unwrapRelayData(result) {
+  let data = result?.data ?? result?.Data ?? result;
+  if (data && typeof data === "object" && !Array.isArray(data)) {
+    data = data.data ?? data.Data ?? data;
+  }
+  return data;
+}
+function relaySucceeded(result) {
+  const code = Number(result?.code ?? result?.Code ?? 200);
+  return code === 0 || code === 200;
+}
+function siteCode(marketplaceId, domain) {
+  const byMarketplace = {
+    ATVPDKIKX0DER: "us",
+    A2EUQ1WTGCTBG2: "ca",
+    A1AM78C64UM0Y8: "mx",
+    A2Q3Y263D00KWC: "br",
+    A1F83G8C2ARO7P: "uk",
+    A1PA6795UKMFR9: "de",
+    A13V1IB3VIYZZH: "fr",
+    A1RKKUPIHCS9HS: "es",
+    APJ6JRA9NG5V4: "it",
+    A1VC38T7YXB528: "jp",
+    A39IBJ37TRP1C6: "au",
+    A2VIGQ35RCS4UG: "ae",
+    A17E79C6D8DWNP: "sa",
+    A21TJRUUN4KGV: "in"
+  };
+  return byMarketplace[marketplaceId] || String(domain || "");
+}
+async function upsertSorftimeProduct(raw, shopId, shopName, marketplaceId, domain) {
+  const Parse = globalThis.Parse;
+  const asin = String(raw?.Asin || raw?.ASIN || raw?.asin || "").trim().toUpperCase();
+  if (!asin)
+    return false;
+  const shop = Parse.Object.extend("Shop").createWithoutData(shopId);
+  const common = {
+    asin,
+    parentAsin: raw.ParentAsin || raw.ParentASIN || "",
+    title: raw.Title || raw.title || "",
+    photo: raw.Photo || raw.photo || "",
+    imageUrl: Array.isArray(raw.Photo) ? raw.Photo[0] : raw.Photo || "",
+    price: Number(raw.Price || 0),
+    salesPrice: Number(raw.SalesPrice || raw.Price || 0),
+    brand: raw.Brand || "",
+    sellerId: raw.BuyboxSellerId || "",
+    ratings: Number(raw.Ratings || 0),
+    rating: Number(raw.Ratings || 0),
+    ratingsCount: Number(raw.RatingsCount || 0),
+    category: raw.Category || "",
+    bsrCategory: raw.BsrCategory || [],
+    rank: Number(raw.Rank || 0),
+    listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+    ListingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+    listingSalesOfMonth: Number(raw.ListingSalesOfMonth || 0),
+    marketplaceId,
+    domain: String(domain || ""),
+    site: siteCode(marketplaceId, domain),
+    storeName: shopName || "",
+    shopName: shopName || "",
+    shopId,
+    shop,
+    source: "sorftime",
+    onlineDate: String(raw.OnlineDate || "").slice(0, 10),
+    rawData: raw
+  };
+  for (const className of ["Product", "ProductDetail", "SorftimeProduct"]) {
+    const query = new Parse.Query(className);
+    query.equalTo("asin", asin);
+    if (className !== "SorftimeProduct")
+      query.equalTo("shop", shop);
+    let object = null;
+    try {
+      object = await query.first({ useMasterKey: true });
+    } catch (error) {
+      const message = String(error?.message || error || "");
+      if (!message.includes("does not exist") && !message.includes("non-existent class"))
+        throw error;
+    }
+    if (!object)
+      object = new Parse.Object(className);
+    for (const [key, value] of Object.entries(common)) {
+      if (value !== void 0 && value !== null)
+        object.set(key, value);
+    }
+    await object.save(null, { useMasterKey: true });
+  }
+  return true;
+}
+async function upsertSorftimeReview(raw, asin, shopId) {
+  const Parse = globalThis.Parse;
+  const reviewId = String(raw?.ReviewId || raw?.Id || raw?.ID || raw?.id || "").trim();
+  const uniqueId = reviewId || `${asin}:${raw?.ReviewsDate || raw?.ReviewDate || raw?.Date || ""}:${raw?.ProfileName || raw?.Author || ""}`;
+  const query = new Parse.Query("SorftimeReviews");
+  query.equalTo("reviewId", uniqueId);
+  let object = null;
+  try {
+    object = await query.first({ useMasterKey: true });
+  } catch (error) {
+    const message = String(error?.message || error || "");
+    if (!message.includes("does not exist") && !message.includes("non-existent class"))
+      throw error;
+  }
+  if (!object)
+    object = new Parse.Object("SorftimeReviews");
+  object.set("reviewId", uniqueId);
+  object.set("asin", asin);
+  object.set("star", Number(raw?.Star || raw?.Rating || raw?.rating || 0));
+  object.set("rating", Number(raw?.Star || raw?.Rating || raw?.rating || 0));
+  object.set("title", raw?.Title || raw?.ReviewTitle || "");
+  object.set("content", raw?.Content || raw?.ReviewContent || raw?.Body || "");
+  object.set("reviewDate", raw?.ReviewsDate || raw?.ReviewDate || raw?.Date || "");
+  object.set("author", raw?.ConsumerName || raw?.ProfileName || raw?.Author || "");
+  object.set("verified", Boolean(raw?.IsVP || raw?.OnlyPurchase || raw?.VerifiedPurchase));
+  object.set("shop", Parse.Object.extend("Shop").createWithoutData(shopId));
+  object.set("rawData", raw);
+  await object.save(null, { useMasterKey: true });
+}
+var SorftimeScheduler = class {
+  // 私有属性(先声明)
+  #isRunning = false;
+  #lastRunTime = null;
+  #categoryProductsCronTask = null;
+  #keywordsCronTask = null;
+  #marketTrendCronTask = null;
+  #shopProductsCronTask = null;
+  #productDetailCronTask = null;
+  #cloudFunctionCronTask = null;
+  #reviewsCronTask = null;
+  #cronEnabled = true;
+  // ========== 私有工具方法(最优先声明,避免调用时未定义) ==========
+  /**
+   * 延迟函数
+   * @private
+   * @param {number} ms - 延迟毫秒数
+   * @returns {Promise<void>} 延迟 Promise
+   */
+  #delay(ms) {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+  /**
+   * 获取所有叶子节点类目
+   * @private
+   * @returns {Promise<any[]>} 叶子节点类目列表
+   */
+  async #getLeafCategories() {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("SelfCategory");
+    query.equalTo("isLeaf", true);
+    query.limit(1e4);
+    return await query.find({ useMasterKey: true });
+  }
+  /**
+   * 根据 shopId 或 nodeIds 筛选类目
+   * - nodeIds 优先:直接按 nodeId 数组过滤 SelfCategory
+   * - shopId:读取店铺的 nodeIds 字段后过滤 SelfCategory
+   * - 两者均未传:返回全部叶子节点类目
+   * @private
+   * @param {string} shopId - 可选
+   * @param {string[]} nodeIds - 可选
+   * @returns {Promise<any[]>} 类目列表
+   */
+  async #getLeafCategoriesByFilter(shopId, nodeIds) {
+    const Parse = globalThis.Parse;
+    if (nodeIds && nodeIds.length > 0) {
+      const query = new Parse.Query("SelfCategory");
+      query.containedIn("nodeId", nodeIds);
+      query.limit(1e4);
+      return await query.find({ useMasterKey: true });
+    }
+    if (shopId) {
+      const shopQuery = new Parse.Query("Shop");
+      const shop = await shopQuery.get(shopId, { useMasterKey: true });
+      const shopNodeIds = shop.get("nodeIds") || [];
+      if (shopNodeIds.length > 0) {
+        const catQuery = new Parse.Query("SelfCategory");
+        catQuery.containedIn("nodeId", shopNodeIds);
+        catQuery.limit(1e4);
+        return await catQuery.find({ useMasterKey: true });
+      }
+    }
+    return await this.#getLeafCategories();
+  }
+  /**
+   * 获取活跃的 Amazon 店铺列表
+   * @private
+   * @param {string} shopId - 可选,传入则只返回该店铺
+   * @returns {Promise<any[]>} 店铺列表
+   */
+  async #getActiveAmazonShops(shopId) {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("Shop");
+    query.equalTo("platform", "amazon");
+    query.equalTo("status", "active");
+    if (shopId)
+      query.equalTo("objectId", shopId);
+    query.limit(1e3);
+    return await query.find({ useMasterKey: true });
+  }
+  /**
+   * 记录执行日志
+   * @private
+   * @param {any} logData - 日志数据对象
+   * @returns {Promise<void>}
+   */
+  async #logExecution(logData) {
+    try {
+      const Parse = globalThis.Parse;
+      const TaskLog = Parse.Object.extend("TaskExecutionLog");
+      const log = new TaskLog();
+      log.set("taskName", logData.taskName);
+      log.set("startTime", logData.startTime);
+      log.set("endTime", logData.endTime);
+      log.set("duration", logData.duration);
+      log.set("successCount", logData.successCount);
+      log.set("errorCount", logData.errorCount);
+      log.set("errors", logData.errors);
+      log.set("status", logData.status);
+      await log.save(null, { useMasterKey: true });
+      console.log("[Sorftime Scheduler] \u6267\u884C\u65E5\u5FD7\u5DF2\u4FDD\u5B58");
+    } catch (error) {
+      console.error("[Sorftime Scheduler] \u4FDD\u5B58\u6267\u884C\u65E5\u5FD7\u5931\u8D25:", error.message);
+    }
+  }
+  /**
+   * 采集类目热销产品(每月1号凌晨执行)
+   * @private
+   * @param {string} nodeId - 类目节点ID
+   * @param {number} domain - 站点域名代码
+   * @returns {Promise<void>}
+   */
+  async #collectCategoryProducts(nodeId, domain, progress) {
+    console.log(`[Sorftime Scheduler] \u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u7684\u70ED\u9500\u4EA7\u54C1 (domain: ${domain})`);
+    const functionId = "ZmYNPsoX9X";
+    for (let page = 1; page <= 4; page++) {
+      try {
+        progress?.({ type: "info", message: `\u7C7B\u76EE ${nodeId} \u7B2C ${page}/4 \u9875\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u4E2D...` });
+        const requestBody = {
+          path: "/api/CategoryProducts",
+          method: "POST",
+          query: { domain },
+          body: {
+            NodeId: nodeId,
+            Page: page,
+            Range: 400
+          },
+          functionId
+        };
+        const result = await relayClient.forwardSorftime({
+          path: requestBody.path,
+          method: requestBody.method,
+          query: requestBody.query,
+          body: requestBody.body
+        });
+        if (!relaySucceeded(result)) {
+          throw new Error(result.message || `\u7B2C${page}\u9875\u6570\u636E\u91C7\u96C6\u5931\u8D25`);
+        }
+        console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u7B2C${page}\u9875\u6570\u636E\u91C7\u96C6\u5B8C\u6210`);
+        await this.#delay(2e3);
+      } catch (error) {
+        console.error(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u7B2C${page}\u9875\u91C7\u96C6\u5931\u8D25:`, error.message);
+        throw error;
+      }
+    }
+  }
+  /**
+   * 采集类目关键词(每周一凌晨1点执行)
+   * @private
+   * @param {string} nodeId - 类目节点ID
+   * @param {number} domain - 站点域名代码
+   * @returns {Promise<void>}
+   */
+  async #collectCategoryKeywords(nodeId, domain, progress) {
+    console.log(`[Sorftime Scheduler] \u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u7684\u5173\u952E\u8BCD (domain: ${domain})`);
+    const functionId = "KNN4L19aoi";
+    try {
+      const requestBody = {
+        path: "/api/CategoryRequestKeyword",
+        method: "POST",
+        query: { domain },
+        body: {
+          Nodeid: nodeId,
+          PageIndex: 1,
+          PageSize: 100
+        },
+        functionId
+      };
+      const result = await relayClient.forwardSorftime({
+        path: requestBody.path,
+        method: requestBody.method,
+        query: requestBody.query,
+        body: requestBody.body
+      });
+      if (!relaySucceeded(result)) {
+        throw new Error(result.message || "\u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25");
+      }
+      console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210`);
+    } catch (error) {
+      console.error(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25:`, error.message);
+      throw error;
+    }
+  }
+  /**
+   * 通过 SellerId 分页采集单个店铺的产品数据
+   * @private
+   * @param {string} shopId - 店铺 Parse objectId
+   * @param {string} sellerId - 亚马逊 SellerId
+   * @param {number} domain - 站点代码(1=US)
+   * @param {Function} progress - 可选进度回调
+   * @returns {Promise<{processed: number, pages: number}>}
+   */
+  async #doCollectShopProducts(shopId, shopName, sellerId, domain, marketplaceId, progress) {
+    const functionId = "GSjAsvw9FK";
+    let page = 1;
+    let hasMore = true;
+    let totalProcessed = 0;
+    progress?.({ type: "info", message: `SellerId: ${sellerId}\uFF0C\u5F00\u59CB\u5206\u9875\u91C7\u96C6\u4EA7\u54C1\u6570\u636E...` });
+    console.log(`[Sorftime Scheduler] \u5E97\u94FA ${shopId} SellerId: ${sellerId} \u5F00\u59CB\u91C7\u96C6`);
+    while (hasMore) {
+      progress?.({ type: "info", message: `\u6B63\u5728\u91C7\u96C6\u7B2C ${page} \u9875...` });
+      console.log(`[Sorftime Scheduler] \u5E97\u94FA ${shopId} \u91C7\u96C6\u7B2C ${page} \u9875`);
+      const result = await relayClient.forwardSorftime({
+        path: "/api/ProductQuery",
+        method: "POST",
+        query: { domain },
+        body: {
+          Query: 1,
+          QueryType: 5,
+          Pattern: sellerId,
+          Page: page
+        }
+      });
+      if (!relaySucceeded(result)) {
+        throw new Error(`\u7B2C ${page} \u9875\u91C7\u96C6\u5931\u8D25: ${result.message || result.Message || JSON.stringify(result)}`);
+      }
+      const payload = unwrapRelayData(result);
+      const items = payload?.Items || payload?.Products || (Array.isArray(payload) ? payload : []);
+      const count = Array.isArray(items) ? items.length : 0;
+      let saved = 0;
+      for (const item of items) {
+        if (await upsertSorftimeProduct(item, shopId, shopName, item?.MarketplaceId || marketplaceId || "", domain))
+          saved++;
+      }
+      totalProcessed += count;
+      progress?.({ type: "info", message: `\u7B2C ${page} \u9875\u83B7\u53D6 ${count} \u6761\u4EA7\u54C1\uFF0C\u5165\u5E93 ${saved} \u6761`, processed: totalProcessed });
+      console.log(`[Sorftime Scheduler] \u5E97\u94FA ${shopId} \u7B2C ${page} \u9875\u83B7\u53D6 ${count} \u6761\u4EA7\u54C1\uFF0C\u5165\u5E93 ${saved} \u6761\uFF0C\u7D2F\u8BA1 ${totalProcessed} \u6761`);
+      const pageCount = Number(payload?.PageCount || 0);
+      if (count === 0 || pageCount > 0 && page >= pageCount || page >= 100) {
+        hasMore = false;
+      } else {
+        page++;
+        await this.#delay(2e3);
+      }
+    }
+    return { processed: totalProcessed, pages: page };
+  }
+  /**
+   * 采集类目市场趋势(每月1号凌晨1点执行)
+   * @private
+   * @param {string} nodeId - 类目节点ID
+   * @param {number} domain - 站点域名代码
+   * @returns {Promise<void>}
+   */
+  async #collectCategoryMarketTrend(nodeId, domain, progress) {
+    console.log(`[Sorftime Scheduler] \u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u7684\u5E02\u573A\u8D8B\u52BF (domain: ${domain})`);
+    const functionId = "OCOKi93GjS";
+    const trendIndexes = [0, 1, 2, 3, 4, 5];
+    for (const TrendIndex of trendIndexes) {
+      try {
+        progress?.({ type: "info", message: `\u7C7B\u76EE ${nodeId} \u8D8B\u52BF\u7C7B\u578B ${TrendIndex}/5 \u91C7\u96C6\u4E2D...` });
+        const requestBody = {
+          path: "/api/CategoryTrend",
+          method: "POST",
+          query: { domain },
+          body: {
+            NodeId: nodeId,
+            TrendIndex
+          },
+          functionId
+        };
+        const result = await relayClient.forwardSorftime({
+          path: requestBody.path,
+          method: requestBody.method,
+          query: requestBody.query,
+          body: requestBody.body
+        });
+        if (!relaySucceeded(result)) {
+          throw new Error(result.message || `\u5E02\u573A\u8D8B\u52BF\u7C7B\u578B ${TrendIndex} \u91C7\u96C6\u5931\u8D25`);
+        }
+        console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u5E02\u573A\u8D8B\u52BF\u7C7B\u578B ${TrendIndex} \u91C7\u96C6\u5B8C\u6210`);
+        await this.#delay(1e3);
+      } catch (error) {
+        console.error(`[Sorftime Scheduler] \u7C7B\u76EE ${nodeId} \u5E02\u573A\u8D8B\u52BF\u7C7B\u578B ${TrendIndex} \u91C7\u96C6\u5931\u8D25:`, error.message);
+        throw error;
+      }
+    }
+  }
+  /**
+   * 执行类目市场趋势采集任务(每月1号凌晨1点)
+   * @private
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async #executeCategoryMarketTrendCollection(shopId, nodeIds, progress) {
+    if (this.#isRunning) {
+      console.log("[Sorftime Scheduler] \u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D\uFF0C\u8DF3\u8FC7\u672C\u6B21\u8C03\u5EA6");
+      return {
+        success: false,
+        message: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D",
+        successCount: 0,
+        errorCount: 0,
+        duration: 0,
+        errors: []
+      };
+    }
+    this.#isRunning = true;
+    const startTime = Date.now();
+    const errors = [];
+    let successCount = 0;
+    let errorCount = 0;
+    console.log("[Sorftime Scheduler] ========================================");
+    console.log("[Sorftime Scheduler] \u5F00\u59CB\u6267\u884C\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u4EFB\u52A1");
+    console.log("[Sorftime Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[Sorftime Scheduler] ========================================");
+    try {
+      const categories = await this.#getLeafCategoriesByFilter(shopId, nodeIds);
+      if (categories.length === 0) {
+        console.log("[Sorftime Scheduler] \u6CA1\u6709\u627E\u5230\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE\u6570\u636E");
+        return {
+          success: true,
+          message: "\u6CA1\u6709\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE\u9700\u8981\u5904\u7406",
+          successCount: 0,
+          errorCount: 0,
+          duration: Date.now() - startTime,
+          errors: []
+        };
+      }
+      console.log(`[Sorftime Scheduler] \u53D1\u73B0 ${categories.length} \u4E2A\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE`);
+      for (const category of categories) {
+        try {
+          const nodeId = category.get("nodeId");
+          const categoryName = category.get("name");
+          const domain = category.get("domain") || 1;
+          console.log(`
+[Sorftime Scheduler] ----------------------------------------`);
+          console.log(`[Sorftime Scheduler] \u5F00\u59CB\u5904\u7406\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF: ${categoryName} (${nodeId}, domain: ${domain})`);
+          progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u3010${categoryName}\u3011\u5E02\u573A\u8D8B\u52BF` });
+          await this.#collectCategoryMarketTrend(nodeId, domain, progress);
+          successCount++;
+          progress?.({ type: "success", message: `\u7C7B\u76EE\u3010${categoryName}\u3011\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5B8C\u6210`, processed: successCount });
+          console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${categoryName} \u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5B8C\u6210`);
+          await this.#delay(2e3);
+        } catch (error) {
+          errorCount++;
+          const errorMsg = `\u7C7B\u76EE ${category.get("name")} \u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5931\u8D25: ${error.message}`;
+          errors.push(errorMsg);
+          progress?.({ type: "error", message: errorMsg });
+          console.error(`[Sorftime Scheduler] ${errorMsg}`);
+        }
+      }
+      const duration = Date.now() - startTime;
+      await this.#logExecution({
+        taskName: "sorftime-category-trend-monthly",
+        startTime: new Date(startTime),
+        endTime: /* @__PURE__ */ new Date(),
+        duration,
+        successCount,
+        errorCount,
+        errors,
+        status: errorCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed"
+      });
+      this.#lastRunTime = /* @__PURE__ */ new Date();
+      const message = `\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${categories.length} \u4E2A\u7C7B\u76EE\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[Sorftime Scheduler] ========================================`);
+      console.log(`[Sorftime Scheduler] ${message}`);
+      console.log(`[Sorftime Scheduler] ========================================
+`);
+      return {
+        success: errorCount === 0,
+        message,
+        successCount,
+        errorCount,
+        duration,
+        errors
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[Sorftime Scheduler] ${errorMsg}`);
+      return {
+        success: false,
+        message: errorMsg,
+        successCount,
+        errorCount: errorCount + 1,
+        duration,
+        errors: [...errors, errorMsg]
+      };
+    } finally {
+      this.#isRunning = false;
+    }
+  }
+  /**
+   * 执行类目热销产品采集任务(每月1号)
+   * @private
+   * @returns {Promise<any>} 执行结果对象,包含成功数、失败数、耗时等信息
+   */
+  async #executeCategoryProductsCollection(shopId, nodeIds, progress) {
+    if (this.#isRunning) {
+      console.log("[Sorftime Scheduler] \u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D\uFF0C\u8DF3\u8FC7\u672C\u6B21\u8C03\u5EA6");
+      return {
+        success: false,
+        message: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D",
+        successCount: 0,
+        errorCount: 0,
+        duration: 0,
+        errors: []
+      };
+    }
+    this.#isRunning = true;
+    const startTime = Date.now();
+    const errors = [];
+    let successCount = 0;
+    let errorCount = 0;
+    console.log("[Sorftime Scheduler] ========================================");
+    console.log("[Sorftime Scheduler] \u5F00\u59CB\u6267\u884C\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u4EFB\u52A1");
+    console.log("[Sorftime Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[Sorftime Scheduler] ========================================");
+    try {
+      const categories = await this.#getLeafCategoriesByFilter(shopId, nodeIds);
+      if (categories.length === 0) {
+        console.log("[Sorftime Scheduler] \u6CA1\u6709\u627E\u5230\u7C7B\u76EE\u6570\u636E");
+        return {
+          success: true,
+          message: "\u6CA1\u6709\u7C7B\u76EE\u9700\u8981\u5904\u7406",
+          successCount: 0,
+          errorCount: 0,
+          duration: Date.now() - startTime,
+          errors: []
+        };
+      }
+      console.log(`[Sorftime Scheduler] \u53D1\u73B0 ${categories.length} \u4E2A\u7C7B\u76EE`);
+      for (const category of categories) {
+        try {
+          const nodeId = category.get("nodeId");
+          const categoryName = category.get("name");
+          const domain = category.get("domain") || 1;
+          console.log(`
+[Sorftime Scheduler] ----------------------------------------`);
+          console.log(`[Sorftime Scheduler] \u5F00\u59CB\u5904\u7406\u7C7B\u76EE: ${categoryName} (${nodeId}, domain: ${domain})`);
+          progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u3010${categoryName}\u3011\u70ED\u9500\u4EA7\u54C1` });
+          await this.#collectCategoryProducts(nodeId, domain, progress);
+          successCount++;
+          progress?.({ type: "success", message: `\u7C7B\u76EE\u3010${categoryName}\u3011\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210`, processed: successCount });
+          console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${categoryName} \u5904\u7406\u5B8C\u6210`);
+          await this.#delay(3e3);
+        } catch (error) {
+          errorCount++;
+          const errorMsg = `\u7C7B\u76EE ${category.get("name")} \u5904\u7406\u5931\u8D25: ${error.message}`;
+          errors.push(errorMsg);
+          progress?.({ type: "error", message: errorMsg });
+          console.error(`[Sorftime Scheduler] ${errorMsg}`);
+        }
+      }
+      const duration = Date.now() - startTime;
+      await this.#logExecution({
+        taskName: "sorftime-category-products-monthly",
+        startTime: new Date(startTime),
+        endTime: /* @__PURE__ */ new Date(),
+        duration,
+        successCount,
+        errorCount,
+        errors,
+        status: errorCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed"
+      });
+      this.#lastRunTime = /* @__PURE__ */ new Date();
+      const message = `\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${categories.length} \u4E2A\u7C7B\u76EE\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[Sorftime Scheduler] ========================================`);
+      console.log(`[Sorftime Scheduler] ${message}`);
+      console.log(`[Sorftime Scheduler] ========================================
+`);
+      return {
+        success: errorCount === 0,
+        message,
+        successCount,
+        errorCount,
+        duration,
+        errors
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[Sorftime Scheduler] ${errorMsg}`);
+      return {
+        success: false,
+        message: errorMsg,
+        successCount,
+        errorCount: errorCount + 1,
+        duration,
+        errors: [...errors, errorMsg]
+      };
+    } finally {
+      this.#isRunning = false;
+    }
+  }
+  /**
+   * 执行类目关键词采集任务(每周一)
+   * @private
+   * @returns {Promise<any>} 执行结果对象,包含成功数、失败数、耗时等信息
+   */
+  async #executeCategoryKeywordsCollection(shopId, nodeIds, progress) {
+    if (this.#isRunning) {
+      console.log("[Sorftime Scheduler] \u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D\uFF0C\u8DF3\u8FC7\u672C\u6B21\u8C03\u5EA6");
+      return {
+        success: false,
+        message: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D",
+        successCount: 0,
+        errorCount: 0,
+        duration: 0,
+        errors: []
+      };
+    }
+    this.#isRunning = true;
+    const startTime = Date.now();
+    const errors = [];
+    let successCount = 0;
+    let errorCount = 0;
+    console.log("[Sorftime Scheduler] ========================================");
+    console.log("[Sorftime Scheduler] \u5F00\u59CB\u6267\u884C\u7C7B\u76EE\u5173\u952E\u8BCD\u91C7\u96C6\u4EFB\u52A1");
+    console.log("[Sorftime Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[Sorftime Scheduler] ========================================");
+    try {
+      const categories = await this.#getLeafCategoriesByFilter(shopId, nodeIds);
+      if (categories.length === 0) {
+        console.log("[Sorftime Scheduler] \u6CA1\u6709\u627E\u5230\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE\u6570\u636E");
+        return {
+          success: true,
+          message: "\u6CA1\u6709\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE\u9700\u8981\u5904\u7406",
+          successCount: 0,
+          errorCount: 0,
+          duration: Date.now() - startTime,
+          errors: []
+        };
+      }
+      console.log(`[Sorftime Scheduler] \u53D1\u73B0 ${categories.length} \u4E2A\u53F6\u5B50\u8282\u70B9\u7C7B\u76EE`);
+      for (const category of categories) {
+        try {
+          const nodeId = category.get("nodeId");
+          const categoryName = category.get("name");
+          const domain = category.get("domain") || 1;
+          console.log(`
+[Sorftime Scheduler] ----------------------------------------`);
+          console.log(`[Sorftime Scheduler] \u5F00\u59CB\u5904\u7406\u7C7B\u76EE: ${categoryName} (${nodeId}, domain: ${domain})`);
+          progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u3010${categoryName}\u3011\u5173\u952E\u8BCD` });
+          await this.#collectCategoryKeywords(nodeId, domain, progress);
+          successCount++;
+          progress?.({ type: "success", message: `\u7C7B\u76EE\u3010${categoryName}\u3011\u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210`, processed: successCount });
+          console.log(`[Sorftime Scheduler] \u7C7B\u76EE ${categoryName} \u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210`);
+          await this.#delay(2e3);
+        } catch (error) {
+          errorCount++;
+          const errorMsg = `\u7C7B\u76EE ${category.get("name")} \u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25: ${error.message}`;
+          errors.push(errorMsg);
+          progress?.({ type: "error", message: errorMsg });
+          console.error(`[Sorftime Scheduler] ${errorMsg}`);
+        }
+      }
+      const duration = Date.now() - startTime;
+      await this.#logExecution({
+        taskName: "sorftime-category-keywords-weekly",
+        startTime: new Date(startTime),
+        endTime: /* @__PURE__ */ new Date(),
+        duration,
+        successCount,
+        errorCount,
+        errors,
+        status: errorCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed"
+      });
+      this.#lastRunTime = /* @__PURE__ */ new Date();
+      const message = `\u7C7B\u76EE\u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${categories.length} \u4E2A\u7C7B\u76EE\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[Sorftime Scheduler] ========================================`);
+      console.log(`[Sorftime Scheduler] ${message}`);
+      console.log(`[Sorftime Scheduler] ========================================
+`);
+      return {
+        success: errorCount === 0,
+        message,
+        successCount,
+        errorCount,
+        duration,
+        errors
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u7C7B\u76EE\u5173\u952E\u8BCD\u91C7\u96C6\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[Sorftime Scheduler] ${errorMsg}`);
+      return {
+        success: false,
+        message: errorMsg,
+        successCount,
+        errorCount: errorCount + 1,
+        duration,
+        errors: [...errors, errorMsg]
+      };
+    } finally {
+      this.#isRunning = false;
+    }
+  }
+  /**
+   * 执行云函数调用任务(每两小时)
+   * 调用指定的云函数进行数据处理
+   * @private
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async #executeCloudFunctionInvocation() {
+    console.log("[Sorftime Scheduler] ========================================");
+    console.log("[Sorftime Scheduler] \u5F00\u59CB\u6267\u884C\u4E91\u51FD\u6570\u8C03\u7528\u4EFB\u52A1");
+    console.log("[Sorftime Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[Sorftime Scheduler] ========================================");
+    const startTime = Date.now();
+    try {
+      const response = await fetch("http://localhost:3000/api/functions", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json"
+        },
+        body: JSON.stringify({
+          id: "Z4z6SB4o9e"
+        })
+      });
+      const duration = Date.now() - startTime;
+      if (!response.ok) {
+        const errorMsg = `\u4E91\u51FD\u6570\u8C03\u7528\u5931\u8D25\uFF0C\u72B6\u6001\u7801: ${response.status}`;
+        console.error(`[Sorftime Scheduler] ${errorMsg}`);
+        return {
+          success: false,
+          message: errorMsg,
+          duration,
+          errors: [errorMsg]
+        };
+      }
+      const result = await response.json();
+      const message = `\u4E91\u51FD\u6570\u8C03\u7528\u5B8C\u6210\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[Sorftime Scheduler] ========================================`);
+      console.log(`[Sorftime Scheduler] ${message}`);
+      console.log(`[Sorftime Scheduler] ========================================
+`);
+      return {
+        success: true,
+        message,
+        duration,
+        data: result,
+        errors: []
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u4E91\u51FD\u6570\u8C03\u7528\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[Sorftime Scheduler] ${errorMsg}`);
+      return {
+        success: false,
+        message: errorMsg,
+        duration,
+        errors: [errorMsg]
+      };
+    }
+  }
+  /**
+   * 执行产品评论采集任务(每天凌晨1点)
+   * 根据 Product 表中的 ASIN,采集对应的评论信息
+   * @private
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async #executeProductReviewsCollection(progress) {
+    console.log("[Sorftime Scheduler] ========================================");
+    console.log("[Sorftime Scheduler] \u5F00\u59CB\u6267\u884C\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u4EFB\u52A1");
+    console.log("[Sorftime Scheduler] \u6267\u884C\u65F6\u95F4:", (/* @__PURE__ */ new Date()).toISOString());
+    console.log("[Sorftime Scheduler] ========================================");
+    const startTime = Date.now();
+    let successCount = 0;
+    let errorCount = 0;
+    const errors = [];
+    progress?.({ type: "info", message: "\u5F00\u59CB\u6267\u884C\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u4EFB\u52A1" });
+    try {
+      const Parse = globalThis.Parse;
+      const productQuery = new Parse.Query("Product");
+      productQuery.limit(1e4);
+      const products = await productQuery.find({ useMasterKey: true });
+      if (products.length === 0) {
+        console.log("[Sorftime Scheduler] \u6CA1\u6709\u627E\u5230 Product \u6570\u636E");
+        progress?.({ type: "info", message: "\u6CA1\u6709 Product \u6570\u636E\u9700\u8981\u5904\u7406" });
+        return {
+          success: true,
+          message: "\u6CA1\u6709 Product \u6570\u636E\u9700\u8981\u5904\u7406",
+          successCount: 0,
+          errorCount: 0,
+          duration: Date.now() - startTime,
+          errors: []
+        };
+      }
+      console.log(`[Sorftime Scheduler] \u53D1\u73B0 ${products.length} \u4E2A Product \u8BB0\u5F55`);
+      progress?.({ type: "info", message: `\u5171\u53D1\u73B0 ${products.length} \u4E2A\u4EA7\u54C1\u9700\u8981\u68C0\u67E5\u8BC4\u8BBA` });
+      for (const product of products) {
+        try {
+          const asin = product.get("asin");
+          if (!asin) {
+            console.log("[Sorftime Scheduler] \u8DF3\u8FC7\u65E0 ASIN \u7684 Product");
+            continue;
+          }
+          progress?.({ type: "info", message: `\u5F00\u59CB\u5904\u7406 ASIN: ${asin}` });
+          const shopRelation = product.get("shop");
+          if (!shopRelation) {
+            console.log(`[Sorftime Scheduler] Product \u65E0\u5173\u8054 Shop\uFF0CASIN: ${asin}`);
+            continue;
+          }
+          const shop = await shopRelation.fetch({ useMasterKey: true });
+          const shopDomain = shop.get("domain") || 1;
+          let queryStartDt = "2025-01-01";
+          const reviewQuery = new Parse.Query("SorftimeReviews");
+          reviewQuery.equalTo("asin", asin);
+          reviewQuery.descending("updatedAt");
+          reviewQuery.limit(1);
+          let latestReview = null;
+          try {
+            latestReview = await reviewQuery.first({ useMasterKey: true });
+          } catch (error) {
+            const message2 = String(error?.message || error || "");
+            if (!message2.includes("does not exist") && !message2.includes("non-existent class"))
+              throw error;
+          }
+          if (latestReview) {
+            const updateAt = latestReview.get("updatedAt");
+            if (updateAt) {
+              const date = new Date(updateAt);
+              queryStartDt = date.toISOString().split("T")[0];
+            }
+          }
+          console.log(`[Sorftime Scheduler] \u5F00\u59CB\u91C7\u96C6\u8BC4\u8BBA\uFF0CASIN: ${asin}, \u67E5\u8BE2\u8D77\u59CB\u65E5\u671F: ${queryStartDt}`);
+          let result;
+          try {
+            result = await relayClient.forwardSorftime({
+              path: "/api/ProductReviewsQuery",
+              method: "POST",
+              query: {
+                domain: shopDomain,
+                shopId: shop.id
+              },
+              body: {
+                ASIN: asin,
+                PageIndex: 1,
+                OnlyPurchase: 1,
+                Star: "1,2,3,4,5",
+                QueryStartDt: queryStartDt
+              }
+            });
+          } catch (error) {
+            const errorMsg = `\u8BF7\u6C42\u8BC4\u8BBA\u6570\u636E\u5931\u8D25\uFF0CASIN: ${asin}, ${error.message || error}`;
+            errors.push(errorMsg);
+            errorCount++;
+            console.error(`[Sorftime Scheduler] ${errorMsg}`);
+            continue;
+          }
+          const payload = unwrapRelayData(result);
+          const reviews = payload?.Reviews || payload?.Items || payload?.List || (Array.isArray(payload) ? payload : []);
+          if (relaySucceeded(result)) {
+            for (const review of reviews)
+              await upsertSorftimeReview(review, asin, shop.id);
+            successCount++;
+            const statusText = reviews.length ? `\u5165\u5E93 ${reviews.length} \u6761` : "\u65E0\u65B0\u589E\u8BC4\u8BBA";
+            console.log(`[Sorftime Scheduler] \u8BC4\u8BBA\u6570\u636E\u91C7\u96C6\u6210\u529F\uFF0CASIN: ${asin}\uFF0C${statusText}`);
+            progress?.({ type: "success", message: `\u8BC4\u8BBA\u6570\u636E\u91C7\u96C6\u6210\u529F\uFF0CASIN: ${asin}\uFF0C${statusText}`, processed: successCount });
+          } else {
+            const errorMsg = `\u8BC4\u8BBA\u6570\u636E\u83B7\u53D6\u5931\u8D25\uFF0CASIN: ${asin}`;
+            errors.push(errorMsg);
+            errorCount++;
+            console.error(`[Sorftime Scheduler] ${errorMsg}`);
+            progress?.({ type: "error", message: errorMsg });
+          }
+          await this.#delay(500);
+        } catch (error) {
+          errorCount++;
+          const errorMsg = `\u5904\u7406 Product \u5931\u8D25: ${error.message}`;
+          errors.push(errorMsg);
+          console.error(`[Sorftime Scheduler] ${errorMsg}`);
+          progress?.({ type: "error", message: errorMsg });
+        }
+      }
+      const duration = Date.now() - startTime;
+      const message = `\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${successCount}/${products.length} \u4E2A\u4EA7\u54C1\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2`;
+      console.log(`
+[Sorftime Scheduler] ========================================`);
+      console.log(`[Sorftime Scheduler] ${message}`);
+      console.log(`[Sorftime Scheduler] ========================================
+`);
+      progress?.({ type: errorCount === 0 ? "success" : "warning", message });
+      return {
+        success: errorCount === 0,
+        message,
+        successCount,
+        errorCount,
+        duration,
+        errors
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      const errorMsg = `\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u4EFB\u52A1\u6267\u884C\u5931\u8D25: ${error.message}`;
+      console.error(`[Sorftime Scheduler] ${errorMsg}`);
+      progress?.({ type: "error", message: errorMsg });
+      return {
+        success: false,
+        message: errorMsg,
+        successCount: 0,
+        errorCount: 1,
+        duration,
+        errors: [errorMsg]
+      };
+    }
+  }
+  // ========== 公共方法(后声明,因为依赖前面的私有方法) ==========
+  /**
+   * 构造函数
+   * 初始化 Sorftime API 数据采集调度器
+   */
+  constructor() {
+    console.log("[Sorftime Scheduler] \u521D\u59CB\u5316 Sorftime API \u6570\u636E\u91C7\u96C6\u8C03\u5EA6\u5668");
+  }
+  /**
+   * 启动定时任务
+   * @returns {Promise<void>}
+   */
+  async start() {
+    if (this.#categoryProductsCronTask || this.#keywordsCronTask || this.#marketTrendCronTask || this.#shopProductsCronTask || this.#productDetailCronTask || this.#cloudFunctionCronTask || this.#reviewsCronTask) {
+      console.log("[Sorftime Scheduler] \u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5728\u8FD0\u884C\u4E2D");
+      return;
+    }
+    this.#categoryProductsCronTask = nodeCron3.schedule("0 0 1 * *", async () => {
+      await this.#executeCategoryProductsCollection(void 0, void 0, void 0);
+    }, {
+      timezone: "Asia/Shanghai"
+    });
+    console.log("[Sorftime Scheduler] \u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u67081\u53F7\u51CC\u6668\u6267\u884C");
+    this.#marketTrendCronTask = nodeCron3.schedule("0 1 1 * *", async () => {
+      await this.#executeCategoryMarketTrendCollection(void 0, void 0, void 0);
+    }, {
+      timezone: "Asia/Shanghai"
+    });
+    console.log("[Sorftime Scheduler] \u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u67081\u53F7\u51CC\u66681\u70B9\u6267\u884C");
+    this.#keywordsCronTask = nodeCron3.schedule("0 1 * * 1", async () => {
+      await this.#executeCategoryKeywordsCollection(void 0, void 0, void 0);
+    }, {
+      timezone: "Asia/Shanghai"
+    });
+    console.log("[Sorftime Scheduler] \u7C7B\u76EE\u5173\u952E\u8BCD\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u5468\u4E00\u51CC\u66681\u70B9\u6267\u884C");
+    if (process.env.ENABLE_SORFTIME_REVIEW_SCHEDULES === "true") {
+      this.#reviewsCronTask = nodeCron3.schedule("0 1 * * *", async () => {
+        console.log("[Sorftime Scheduler] ===== \u51CC\u66681\u70B9\uFF1A\u5F00\u59CB\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6 =====");
+        try {
+          await this.#executeProductReviewsCollection(void 0);
+        } catch (e) {
+          console.error("[Sorftime Scheduler] \u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5931\u8D25:", e.message);
+        }
+        console.log("[Sorftime Scheduler] ===== \u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5B8C\u6210 =====");
+      }, { timezone: "Asia/Shanghai" });
+      console.log("[Sorftime Scheduler] \u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u5929\u51CC\u66681\u70B9\u6267\u884C");
+    } else {
+      console.log("[Sorftime Scheduler] \u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u7981\u7528");
+    }
+    this.#shopProductsCronTask = nodeCron3.schedule("0 2 * * *", async () => {
+      console.log("[Sorftime Scheduler] ===== \u51CC\u66682\u70B9\uFF1A\u5F00\u59CB\u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6 =====");
+      try {
+        await this.collectShopProducts(void 0, void 0);
+      } catch (e) {
+        console.error("[Sorftime Scheduler] \u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6\u5931\u8D25:", e.message);
+      }
+      console.log("[Sorftime Scheduler] ===== \u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210 =====");
+    }, { timezone: "Asia/Shanghai" });
+    console.log("[Sorftime Scheduler] \u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8\uFF0C\u5C06\u5728\u6BCF\u5929\u51CC\u66682\u70B9\u6267\u884C");
+    if (process.env.ENABLE_LEGACY_CLOUD_FUNCTION_SCHEDULES === "true") {
+      this.#cloudFunctionCronTask = nodeCron3.schedule("0 */2 * * *", async () => {
+        try {
+          await this.#executeCloudFunctionInvocation();
+        } catch (e) {
+          console.error("[Sorftime Scheduler] \u9057\u7559\u4E91\u51FD\u6570\u8C03\u7528\u5931\u8D25:", e.message);
+        }
+      }, { timezone: "Asia/Shanghai" });
+    } else {
+      console.log("[Sorftime Scheduler] \u9057\u7559\u52A8\u6001\u4E91\u51FD\u6570\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u7981\u7528");
+    }
+  }
+  /**
+   * 停止定时任务
+   * @returns {void}
+   */
+  stop() {
+    if (this.#categoryProductsCronTask) {
+      this.#categoryProductsCronTask.stop();
+      this.#categoryProductsCronTask = null;
+      console.log("[Sorftime Scheduler] \u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#marketTrendCronTask) {
+      this.#marketTrendCronTask.stop();
+      this.#marketTrendCronTask = null;
+      console.log("[Sorftime Scheduler] \u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#keywordsCronTask) {
+      this.#keywordsCronTask.stop();
+      this.#keywordsCronTask = null;
+      console.log("[Sorftime Scheduler] \u7C7B\u76EE\u5173\u952E\u8BCD\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#shopProductsCronTask) {
+      this.#shopProductsCronTask.stop();
+      this.#shopProductsCronTask = null;
+      console.log("[Sorftime Scheduler] \u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#productDetailCronTask) {
+      this.#productDetailCronTask.stop();
+      this.#productDetailCronTask = null;
+      console.log("[Sorftime Scheduler] \u4EA7\u54C1\u8BE6\u60C5\u540C\u6B65\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#cloudFunctionCronTask) {
+      this.#cloudFunctionCronTask.stop();
+      this.#cloudFunctionCronTask = null;
+      console.log("[Sorftime Scheduler] \u4E91\u51FD\u6570\u8C03\u7528\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+    if (this.#reviewsCronTask) {
+      this.#reviewsCronTask.stop();
+      this.#reviewsCronTask = null;
+      console.log("[Sorftime Scheduler] \u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u505C\u6B62");
+    }
+  }
+  /**
+   * 获取调度器状态
+   * @returns {object} 调度器状态对象
+   */
+  getStatus() {
+    return {
+      isRunning: this.#isRunning,
+      lastRunTime: this.#lastRunTime,
+      cronEnabled: this.#cronEnabled,
+      tasks: {
+        categoryProducts: {
+          enabled: !!this.#categoryProductsCronTask,
+          schedule: "\u6BCF\u67081\u53F7\u51CC\u6668"
+        },
+        marketTrend: {
+          enabled: !!this.#marketTrendCronTask,
+          schedule: "\u6BCF\u67081\u53F7\u51CC\u66681\u70B9"
+        },
+        keywords: {
+          enabled: !!this.#keywordsCronTask,
+          schedule: "\u6BCF\u5468\u4E00\u51CC\u66681\u70B9"
+        },
+        shopProducts: {
+          enabled: !!this.#shopProductsCronTask,
+          schedule: "\u6BCF\u5929\u51CC\u66682\u70B9"
+        },
+        productDetails: {
+          enabled: !!this.#productDetailCronTask,
+          schedule: "\u6BCF\u5C0F\u65F6"
+        },
+        cloudFunction: {
+          enabled: !!this.#cloudFunctionCronTask,
+          schedule: "\u6BCF\u4E24\u5C0F\u65F6"
+        },
+        productReviews: {
+          enabled: !!this.#reviewsCronTask,
+          schedule: "\u6BCF\u5929\u51CC\u66681\u70B9"
+        }
+      }
+    };
+  }
+  /**
+   * 手动触发类目热销产品采集
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerCategoryProducts(shopId, nodeIds, progress) {
+    console.log("[Sorftime Scheduler] \u624B\u52A8\u89E6\u53D1\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u4EFB\u52A1");
+    return await this.#executeCategoryProductsCollection(shopId, nodeIds, progress);
+  }
+  /**
+   * 手动触发类目关键词采集
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerCategoryKeywords(shopId, nodeIds, progress) {
+    console.log("[Sorftime Scheduler] \u624B\u52A8\u89E6\u53D1\u7C7B\u76EE\u5173\u952E\u8BCD\u91C7\u96C6\u4EFB\u52A1");
+    return await this.#executeCategoryKeywordsCollection(shopId, nodeIds, progress);
+  }
+  /**
+   * 手动触发类目市场趋势采集
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerCategoryTrends(shopId, nodeIds, progress) {
+    console.log("[Sorftime Scheduler] \u624B\u52A8\u89E6\u53D1\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u4EFB\u52A1");
+    return await this.#executeCategoryMarketTrendCollection(shopId, nodeIds, progress);
+  }
+  /**
+   * 手动触发产品评论采集
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerProductReviews(progress) {
+    console.log("[Sorftime Scheduler] \u624B\u52A8\u89E6\u53D1\u4EA7\u54C1\u8BC4\u8BBA\u91C7\u96C6\u4EFB\u52A1");
+    return await this.#executeProductReviewsCollection(progress);
+  }
+  /**
+   * 按需触发单个类目的全量数据采集(热销产品 + 关键词 + 市场趋势)
+   * 前端选中某个类目但数据库无数据时调用
+   * @param {string} nodeId - 类目节点ID
+   * @param {number} domain - 站点域名代码(默认1=美国)
+   * @returns {Promise<any>} 执行结果对象
+   */
+  async triggerSingleCategory(nodeId, domain = 1, progress) {
+    const startTime = Date.now();
+    const errors = [];
+    let tasks = { products: false, keywords: false, trends: false };
+    console.log(`[Sorftime Scheduler] \u6309\u9700\u89E6\u53D1\u5355\u7C7B\u76EE\u91C7\u96C6: nodeId=${nodeId}, domain=${domain}`);
+    try {
+      try {
+        progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u70ED\u9500\u4EA7\u54C1` });
+        await this.#collectCategoryProducts(nodeId, domain, progress);
+        tasks.products = true;
+        progress?.({ type: "success", message: `\u7C7B\u76EE ${nodeId} \u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210` });
+        console.log(`[Sorftime Scheduler] \u5355\u7C7B\u76EE ${nodeId} \u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210`);
+      } catch (error) {
+        const errMsg = error?.message || String(error);
+        progress?.({ type: "error", message: `\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5931\u8D25: ${errMsg}` });
+        errors.push(`\u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5931\u8D25: ${errMsg}`);
+        console.error(`[Sorftime Scheduler] \u70ED\u9500\u4EA7\u54C1\u91C7\u96C6\u5931\u8D25:`, error);
+      }
+      await new Promise((resolve) => setTimeout(resolve, 500));
+      try {
+        progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u5173\u952E\u8BCD` });
+        await this.#collectCategoryKeywords(nodeId, domain, progress);
+        tasks.keywords = true;
+        progress?.({ type: "success", message: `\u7C7B\u76EE ${nodeId} \u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210` });
+        console.log(`[Sorftime Scheduler] \u5355\u7C7B\u76EE ${nodeId} \u5173\u952E\u8BCD\u91C7\u96C6\u5B8C\u6210`);
+      } catch (error) {
+        const errMsg = error?.message || String(error);
+        progress?.({ type: "error", message: `\u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25: ${errMsg}` });
+        errors.push(`\u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25: ${errMsg}`);
+        console.error(`[Sorftime Scheduler] \u5173\u952E\u8BCD\u91C7\u96C6\u5931\u8D25:`, error);
+      }
+      await new Promise((resolve) => setTimeout(resolve, 500));
+      try {
+        progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u5E02\u573A\u8D8B\u52BF` });
+        await this.#collectCategoryMarketTrend(nodeId, domain, progress);
+        tasks.trends = true;
+        progress?.({ type: "success", message: `\u7C7B\u76EE ${nodeId} \u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5B8C\u6210` });
+        console.log(`[Sorftime Scheduler] \u5355\u7C7B\u76EE ${nodeId} \u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5B8C\u6210`);
+      } catch (error) {
+        const errMsg = error?.message || String(error);
+        progress?.({ type: "error", message: `\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5931\u8D25: ${errMsg}` });
+        errors.push(`\u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5931\u8D25: ${errMsg}`);
+        console.error(`[Sorftime Scheduler] \u5E02\u573A\u8D8B\u52BF\u91C7\u96C6\u5931\u8D25:`, error);
+      }
+      const duration = Date.now() - startTime;
+      const allSuccess = errors.length === 0;
+      const successCount = Object.values(tasks).filter(Boolean).length;
+      if (this.#logExecution) {
+        this.#logExecution({
+          taskName: `trigger-single-category-${nodeId}`,
+          startTime: new Date(startTime),
+          endTime: /* @__PURE__ */ new Date(),
+          duration,
+          successCount,
+          errorCount: errors.length,
+          errors,
+          status: allSuccess ? "success" : "partial_success"
+        }).catch((err) => console.error("[Sorftime Scheduler] Log execution error:", err));
+      }
+      const result = {
+        success: allSuccess,
+        message: allSuccess ? `\u7C7B\u76EE ${nodeId} \u5168\u91CF\u91C7\u96C6\u5B8C\u6210\uFF0C\u8017\u65F6 ${Math.round(duration / 1e3)}\u79D2` : `\u7C7B\u76EE ${nodeId} \u90E8\u5206\u91C7\u96C6\u5931\u8D25\uFF08\u6210\u529F ${successCount}/3 \u4E2A\u4EFB\u52A1\uFF09`,
+        nodeId,
+        domain,
+        tasks,
+        duration,
+        errors: errors.length > 0 ? errors : void 0
+      };
+      console.log(`[Sorftime Scheduler] \u91C7\u96C6\u7ED3\u679C:`, result);
+      return result;
+    } catch (err) {
+      const duration = Date.now() - startTime;
+      const errMsg = err?.message || String(err);
+      console.error(`[Sorftime Scheduler] \u91C7\u96C6\u5F02\u5E38:`, err);
+      return {
+        success: false,
+        message: `\u7C7B\u76EE ${nodeId} \u91C7\u96C6\u5F02\u5E38: ${errMsg}`,
+        nodeId,
+        domain,
+        tasks,
+        duration,
+        errors: [errMsg]
+      };
+    }
+  }
+  /**
+   * 采集所有活跃 Amazon 店铺(或指定店铺)的产品数据
+   * 通过 SellerId (QueryType=5) 分页请求 /api/ProductQuery
+   * @param {string} shopId - 可选,不传则采集全部活跃 Amazon 店铺
+   * @param {Function} progress - 可选进度回调,适合 SSE 实时推送
+   * @returns {Promise<any>} 执行结果
+   */
+  async collectShopProducts(shopId, progress) {
+    const shops = await this.#getActiveAmazonShops(shopId);
+    if (shops.length === 0) {
+      const msg = shopId ? `\u672A\u627E\u5230\u5E97\u94FA ${shopId} \u6216\u5E97\u94FA\u672A\u6FC0\u6D3B` : "\u6CA1\u6709\u627E\u5230\u6D3B\u8DC3\u7684 Amazon \u5E97\u94FA";
+      progress?.({ type: "warn", message: msg });
+      console.warn(`[Sorftime Scheduler] ${msg}`);
+      return { success: true, message: msg, processed: 0, errorCount: 0, errors: [] };
+    }
+    let totalProcessed = 0;
+    let errorCount = 0;
+    const errors = [];
+    for (const shop of shops) {
+      const sid = shop.id;
+      const shopName = shop.get("name");
+      const config = shop.get("config");
+      const domain = Number(shop.get("domain") || 1);
+      const marketplaceId = shop.get("marketplaceId") || "";
+      if (!config || !config.SpApiConfig || !config.SpApiConfig.sellerID) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011(${sid}) \u7F3A\u5C11 SpApiConfig.sellerID \u914D\u7F6E\uFF0C\u8DF3\u8FC7`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[Sorftime Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+        continue;
+      }
+      if (config.SpApiConfig.listingEnabled === false) {
+        progress?.({ type: "warn", message: `\u5E97\u94FA\u3010${shopName}\u3011\u672A\u914D\u7F6E\u533A\u57DF Seller ID\uFF0C\u8DF3\u8FC7 Sorftime \u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6` });
+        continue;
+      }
+      const sellerId = config.SpApiConfig.sellerID;
+      progress?.({ type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u5E97\u94FA\u3010${shopName}\u3011\u7684\u4EA7\u54C1\u6570\u636E (SellerId: ${sellerId})` });
+      console.log(`[Sorftime Scheduler] \u5F00\u59CB\u91C7\u96C6\u5E97\u94FA ${shopName} (${sid}) \u4EA7\u54C1\u6570\u636E`);
+      try {
+        const result = await this.#doCollectShopProducts(sid, shopName, sellerId, domain, marketplaceId, progress);
+        progress?.({ type: "success", message: `\u5E97\u94FA\u3010${shopName}\u3011\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210: \u5171 ${result.pages} \u9875 ${result.processed} \u6761`, processed: result.processed });
+        console.log(`[Sorftime Scheduler] \u5E97\u94FA ${shopName} \u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210: ${result.processed} \u6761`);
+        totalProcessed += result.processed;
+        await this.#delay(2e3);
+      } catch (error) {
+        const msg = `\u5E97\u94FA\u3010${shopName}\u3011\u4EA7\u54C1\u91C7\u96C6\u5931\u8D25: ${error.message}`;
+        progress?.({ type: "error", message: msg });
+        console.error(`[Sorftime Scheduler] ${msg}`);
+        errors.push(msg);
+        errorCount++;
+      }
+    }
+    const message = `\u5E97\u94FA\u4EA7\u54C1\u91C7\u96C6\u5B8C\u6210: \u6210\u529F ${shops.length - errorCount}/${shops.length} \u4E2A\u5E97\u94FA\uFF0C\u5171 ${totalProcessed} \u6761\u8BB0\u5F55`;
+    return { success: errorCount === 0, message, processed: totalProcessed, errorCount, errors };
+  }
+};
+var sorftimeScheduler = new SorftimeScheduler();
+
+// backend/api/module/schedule/routes-schedule.ts
+(async () => {
+  try {
+    await spApiScheduler.start();
+    console.log("[API Routes] Amazon SP-API\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8");
+  } catch (error) {
+    console.error("[API Routes] Amazon SP-API\u5B9A\u65F6\u4EFB\u52A1\u542F\u52A8\u5931\u8D25:", error);
+  }
+  if (process.env.ENABLE_SORFTIME_SCHEDULES === "true") {
+    try {
+      await sorftimeScheduler.start();
+      console.log("[API Routes] Sorftime\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u542F\u52A8");
+    } catch (error) {
+      console.error("[API Routes] Sorftime\u5B9A\u65F6\u4EFB\u52A1\u542F\u52A8\u5931\u8D25:", error);
+    }
+  } else {
+    console.log("[API Routes] Sorftime\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u7981\u7528");
+  }
+})();
+var router3 = express3.Router();
+console.log("\u52A0\u8F7Dschedule\u8DEF\u7531 /api/schedule/");
+router3.use(express3.json({
+  charset: "utf-8",
+  // 额外配置:确保解析URL编码的请求体也支持中文
+  type: "application/json"
+}));
+router3.get("/sp-api-schedule/status", (req, res) => {
+  res.json({
+    success: true,
+    data: {
+      spApi: spApiScheduler.getStatus()
+    }
+  });
+});
+router3.get("/sp-api-schedule/start", async (req, res) => {
+  console.log("[API Routes] \u542F\u52A8\u5B9A\u65F6\u4EFB\u52A1");
+  try {
+    const result = await spApiScheduler.start();
+    res.json({
+      success: true,
+      message: "\u5B9A\u65F6\u5668\u6267\u884C\u9648\u529F",
+      data: result
+    });
+  } catch (error) {
+    res.status(500).json({
+      success: false,
+      message: error.message
+    });
+  }
+});
+router3.post("/sp-api-schedule/trigger", async (req, res) => {
+  try {
+    const shopId = req.body.shopId;
+    const result = await spApiScheduler.triggerManually(shopId);
+    res.json({
+      success: result.success,
+      message: result.message,
+      data: result
+    });
+  } catch (error) {
+    res.status(500).json({
+      success: false,
+      message: error.message
+    });
+  }
+});
+function createSseResponse(res) {
+  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
+  res.setHeader("Cache-Control", "no-cache");
+  res.setHeader("Connection", "keep-alive");
+  res.setHeader("X-Accel-Buffering", "no");
+  res.setHeader("Access-Control-Allow-Origin", "*");
+  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+  res.flushHeaders();
+  const send = (event, data) => {
+    if (!res.writableEnded) {
+      res.write(`event: ${event}
+data: ${JSON.stringify(data)}
+
+`);
+    }
+  };
+  const heartbeatTimer = setInterval(() => {
+    if (!res.writableEnded)
+      res.write(": heartbeat\n\n");
+  }, 15e3);
+  const close = () => {
+    clearInterval(heartbeatTimer);
+    if (!res.writableEnded)
+      res.end();
+  };
+  return { send, close };
+}
+router3.post("/sp-api-schedule/collect-listing", async (req, res) => {
+  const { shopId } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u91C7\u96C6 Listing \u6570\u636E${shopId ? "\uFF0C\u5E97\u94FA: " + shopId : "\uFF08\u5168\u90E8\u6D3B\u8DC3\u5E97\u94FA\uFF09"}` });
+    const result = await newSpApiScheduler.collectListings(
+      shopId || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, processed: result.processed, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6 Listing \u6570\u636E\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sp-api-schedule/collect-order", async (req, res) => {
+  const { shopId } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u8BA2\u5355\u6570\u636E${shopId ? "\uFF0C\u5E97\u94FA: " + shopId : "\uFF08\u5168\u90E8\u6D3B\u8DC3\u5E97\u94FA\uFF09"}` });
+    const result = await newSpApiScheduler.collectOrders(
+      shopId || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, processed: result.processed, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u8BA2\u5355\u6570\u636E\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sp-api-schedule/collect-report", async (req, res) => {
+  const { shopId } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u62A5\u8868\u6570\u636E${shopId ? "\uFF0C\u5E97\u94FA: " + shopId : "\uFF08\u5168\u90E8\u6D3B\u8DC3\u5E97\u94FA\uFF09"}` });
+    const result = await newSpApiScheduler.collectReports(
+      shopId || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, processed: result.processed, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u62A5\u8868\u6570\u636E\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sp-api-schedule/parse-report", async (req, res) => {
+  const { shopId } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u89E3\u6790\u62A5\u8868${shopId ? "\uFF0C\u5E97\u94FA: " + shopId : "\uFF08\u5168\u90E8\u6D3B\u8DC3\u5E97\u94FA\uFF09"}` });
+    const result = await newSpApiScheduler.parseReports(
+      shopId || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, processed: result.processed, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u89E3\u6790\u62A5\u8868\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.get("/sorftime-schedule/status", (req, res) => {
+  res.json({
+    success: true,
+    data: {
+      sorftime: sorftimeScheduler.getStatus()
+    }
+  });
+});
+router3.post("/sorftime-schedule/trigger-products", async (req, res) => {
+  const { shopId, nodeIds } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: "\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1..." });
+    const result = await sorftimeScheduler.triggerCategoryProducts(
+      shopId || void 0,
+      nodeIds || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, successCount: result.successCount, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u7C7B\u76EE\u70ED\u9500\u4EA7\u54C1\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sorftime-schedule/trigger-keywords", async (req, res) => {
+  const { shopId, nodeIds } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: "\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u5173\u952E\u8BCD..." });
+    const result = await sorftimeScheduler.triggerCategoryKeywords(
+      shopId || void 0,
+      nodeIds || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, successCount: result.successCount, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u7C7B\u76EE\u5173\u952E\u8BCD\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sorftime-schedule/trigger-category-trends", async (req, res) => {
+  const { shopId, nodeIds } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: "\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF..." });
+    const result = await sorftimeScheduler.triggerCategoryTrends(
+      shopId || void 0,
+      nodeIds || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, successCount: result.successCount, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u7C7B\u76EE\u5E02\u573A\u8D8B\u52BF\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sorftime-schedule/trigger-single-category", async (req, res) => {
+  const { nodeId, domain } = req.body || {};
+  if (!nodeId) {
+    return res.status(400).json({ success: false, message: "\u7F3A\u5C11 nodeId \u53C2\u6570" });
+  }
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u7C7B\u76EE ${nodeId} \u5168\u91CF\u6570\u636E\uFF08\u70ED\u9500\u4EA7\u54C1 + \u5173\u952E\u8BCD + \u5E02\u573A\u8D8B\u52BF\uFF09` });
+    const result = await sorftimeScheduler.triggerSingleCategory(
+      nodeId,
+      domain || 1,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, tasks: result.tasks, duration: result.duration, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u5355\u7C7B\u76EE\u6570\u636E\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sorftime-schedule/collect-reviews", async (req, res) => {
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: "\u5F00\u59CB\u91C7\u96C6\u4EA7\u54C1\u8BC4\u8BBA\u6570\u636E..." });
+    const result = await sorftimeScheduler.triggerProductReviews(
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, successCount: result.successCount, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u4EA7\u54C1\u8BC4\u8BBA\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.post("/sorftime-schedule/collect-shop-products", async (req, res) => {
+  const { shopId } = req.body || {};
+  const { send, close } = createSseResponse(res);
+  req.on("aborted", () => close());
+  try {
+    send("progress", { type: "info", message: `\u5F00\u59CB\u91C7\u96C6\u5E97\u94FA\u4EA7\u54C1\u6570\u636E${shopId ? "\uFF0C\u5E97\u94FA: " + shopId : "\uFF08\u5168\u90E8\u6D3B\u8DC3\u5E97\u94FA\uFF09"}` });
+    const result = await sorftimeScheduler.collectShopProducts(
+      shopId || void 0,
+      (event) => send("progress", event)
+    );
+    send("done", { success: result.success, message: result.message, processed: result.processed, errorCount: result.errorCount, errors: result.errors });
+  } catch (error) {
+    send("error", { success: false, message: error.message || "\u91C7\u96C6\u5E97\u94FA\u4EA7\u54C1\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF" });
+  } finally {
+    close();
+  }
+});
+router3.get("/test", (req, res) => {
+  res.json({
+    message: "\u6D4B\u8BD5 sp-api-schedule",
+    body: req.body
+  });
+});
+var routes_schedule_default = router3;
+
+// backend/api/routes.ts
+var router4 = express4.Router();
+console.log("[API Routes] Initializing Jianhen customer instance...");
+router4.use((req, res, next) => {
+  console.log(`[API Router] Hit: ${req.method} ${req.url} (Original: ${req.originalUrl})`);
+  next();
+});
+router4.get("/ping", (req, res) => {
+  res.json({ message: "pong", instance: "jianhen-voc" });
+});
+function isInternalRequest(req) {
+  const address = String(req.socket?.remoteAddress || "");
+  return !req.headers["x-external-request"] && (address === "127.0.0.1" || address === "::1" || address.endsWith(":127.0.0.1"));
+}
+async function requireApiUser(req, res, next) {
+  if (isInternalRequest(req))
+    return next();
+  const token = String(req.headers["x-parse-session-token"] || "").trim();
+  if (!token)
+    return res.status(401).json({ success: false, message: "\u9700\u8981\u767B\u5F55" });
+  try {
+    const Parse = globalThis.Parse;
+    const query = new Parse.Query("_Session");
+    query.equalTo("sessionToken", token);
+    query.include("user");
+    const session = await query.first({ useMasterKey: true });
+    const user = session?.get("user");
+    if (!user || user.get("status") === "disabled") {
+      return res.status(401).json({ success: false, message: "\u4F1A\u8BDD\u65E0\u6548" });
+    }
+    req.currentUser = user;
+    next();
+  } catch {
+    res.status(401).json({ success: false, message: "\u4F1A\u8BDD\u65E0\u6548" });
+  }
+}
+function requireAdmin(req, res, next) {
+  if (isInternalRequest(req) || req.currentUser?.get?.("isAdmin") === true)
+    return next();
+  res.status(403).json({ success: false, message: "\u9700\u8981\u7BA1\u7406\u5458\u6743\u9650" });
+}
+function requireUserExceptHealth(req, res, next) {
+  if (req.path === "/health")
+    return next();
+  return requireApiUser(req, res, next);
+}
+function requireAdminExceptToken(req, res, next) {
+  if (req.path === "/token")
+    return next();
+  return requireAdmin(req, res, next);
+}
+router4.use("/amazon", requireApiUser, createSpApiRouter());
+router4.use("/relay", requireApiUser, requireAdminExceptToken, relayCredentialRouter);
+console.log("[API Routes] \u2705 Company relay credential routes mounted");
+try {
+  router4.use("/schedule", requireApiUser, requireAdmin, routes_schedule_default);
+} catch (e) {
+  console.warn("[API Routes] scheduleRouter \u52A0\u8F7D\u5931\u8D25", e);
+}
+router4.get("/", (req, res) => {
+  res.json({
+    message: "Jianhen VOC Instance API Routes Loaded",
+    version: "1.0.1-customer-relay",
+    timestamp: (/* @__PURE__ */ new Date()).toISOString(),
+    availableRoutes: [
+      "/api/amazon",
+      "/api/relay/token",
+      "/api/relay/status",
+      "/api/relay/credential",
+      "/api/schedule"
+    ],
+    removedModules: [
+      "fmode-sorftime-api (direct upstream)",
+      "fmode-tikhub-api (direct upstream)",
+      "fmode-tikhub-api-customize",
+      "fmode-eccang-api"
+    ],
+    relay: {
+      companyBase: "https://server.fmode.cn",
+      sorftimeUpstream: "/api/voc-ecom/forward",
+      tikhubUpstream: "/api/voc-social/*"
+    }
+  });
+});
+var routes_default = router4;
+export {
+  routes_default as default
+};

+ 6 - 5
backend/modules/fmode-amazon-sp-api/src/client.ts

@@ -103,7 +103,7 @@ export class SpApiClient {
       if (Parse) {
         try {
           const shopQuery = new Parse.Query('Shop');
-          const shop = await shopQuery.get(shopId);
+            const shop = await shopQuery.get(shopId, { useMasterKey: true });
 
           if (shop) {
             const config = shop.get('config') || {};
@@ -166,7 +166,7 @@ export class SpApiClient {
       if (Parse) {
         try {
           const shopQuery = new Parse.Query('Shop');
-          const shop = await shopQuery.get(shopId);
+          const shop = await shopQuery.get(shopId, { useMasterKey: true });
 
           if (shop) {
             const config = shop.get('config') || {};
@@ -486,9 +486,10 @@ export class SpApiClient {
           throw error;
         }
 
-        // 成功响应
-        const data = await response.json();
-        return data as T;
+        // 部分 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) {

+ 26 - 20
backend/modules/fmode-amazon-sp-api/src/function.ts

@@ -196,7 +196,7 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
       for (let i = 0; i < objectsToSave.length; i += batchSize) {
         const batch = objectsToSave.slice(i, i + batchSize);
         try {
-          await Parse.Object.saveAll(batch);
+          await Parse.Object.saveAll(batch, { useMasterKey: true });
           totalSaved += batch.length;
           console.log(`[SP-API] 已保存 ${totalSaved}/${objectsToSave.length} 条记录到 ${tableName} 表`);
         } catch (error: any) {
@@ -549,7 +549,7 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
         query.containedIn('platformOrderId', orderIds);
         query.equalTo('shop', shopPointer); // 确保是当前店铺的
         query.limit(1000);
-        existingOrders = await query.find();
+        existingOrders = await query.find({ useMasterKey: true });
     } catch (error: any) {
         // ParseError: [object Object] { code: 1 } 通常表示内部服务器错误,
         // 但在 find() 中,如果 Class 不存在,可能会抛出。
@@ -641,14 +641,14 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
     // 4. 批量保存
     if (objectsToSave.length > 0) {
         try {
-            await Parse.Object.saveAll(objectsToSave);
+            await Parse.Object.saveAll(objectsToSave, { useMasterKey: true });
             console.log(`[SP-API] 成功保存/更新 ${objectsToSave.length} 个订单。`);
         } catch (error: any) {
             console.error('[SP-API] 保存订单错误:', error);
             // 尝试逐个保存以定位问题
              for (const obj of objectsToSave) {
                 try {
-                    await obj.save();
+                    await obj.save(null, { useMasterKey: true });
                 } catch (e) {
                     console.error('保存单个对象错误:', obj.toJSON(), e);
                 }
@@ -774,7 +774,7 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
         query.containedIn('sku', skus);
         query.equalTo('shop', shopPointer);
         query.limit(data.numberOfResults);
-        existingListings = await query.find();
+        existingListings = await query.find({ useMasterKey: true });
     } catch (error: any) {
         console.warn('[SP-API] 查询现有 Listing 警告 (将作为新 Listing 继续):', error);
     }
@@ -865,7 +865,7 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
     // 4. 批量保存
     if (objectsToSave.length > 0) {
         try {
-            await Parse.Object.saveAll(objectsToSave);
+            await Parse.Object.saveAll(objectsToSave, { useMasterKey: true });
             console.log(`[SP-API] 成功保存/更新 ${objectsToSave.length} 条 Listing。`);
         } catch (error: any) {
             console.error('[SP-API] 保存 Listing 错误:', error);
@@ -901,21 +901,17 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
             const originalPath = context.requestPath || '';
             const [basePath, rawQuery] = originalPath.split('?');
             
-            // 2. 准备新的查询参数部分
+            // 2. 保留原始查询串的字节表示。Amazon 会校验 pageToken 与首页参数一致性。
             let newQueryString = '';
             const keysToRemove = ['pageToken', 'nextToken', 'offset', 'page', 'startIndex'];
 
-            // 3. 重建查询字符串 (保留原始参数)
+            // 3. 只删除旧分页字段,不对逗号、冒号等字符二次编码。
             if (rawQuery) {
-                const urlParams = new URLSearchParams(rawQuery);
-                const parts: string[] = [];
-                
-                urlParams.forEach((value, key) => {
-                    if (!keysToRemove.includes(key)) {
-                        parts.push(`${key}=${encodeURIComponent(value)}`);
-                    }
-                });
-                newQueryString = parts.join('&');
+                newQueryString = rawQuery
+                    .split('&')
+                    .filter(Boolean)
+                    .filter((part: string) => !keysToRemove.includes(decodeURIComponent(part.split('=')[0] || '')))
+                    .join('&');
             }
 
             // 4. 补充 requestQuery (作为后备)
@@ -941,8 +937,12 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
             
             const encodedToken = encodeURIComponent(tokenVal);
             
-            // 6. 重组 URL
-            const nextPath = `${basePath}?${newQueryString ? newQueryString + '&' : ''}pageToken=${encodedToken}`;
+            // 6. Amazon 分页请求只保留 marketplaceIds 和 pageToken。
+            // 其他首页搜索参数与 pageToken 同时传入会被判定为不匹配。
+            const marketplaceQuery = context.marketplaceId
+                ? `marketplaceIds=${encodeURIComponent(String(context.marketplaceId))}&`
+                : '';
+            const nextPath = `${basePath}?${marketplaceQuery}pageToken=${encodedToken}`;
             
             console.log('[SP-API] Next Path:', nextPath.substring(0, 200) + '...');
 
@@ -976,7 +976,13 @@ export const functions: Record<string, (data: any, context?: any) => Promise<any
             //     error: error.message,
             //     message: '分页请求失败,已保存当前页数据'
             // };
-            return await this.cleanListings(data, context);
+            return {
+                success: true,
+                processed: objectsToSave.length,
+                hasNext: true,
+                error: error.message,
+                message: '分页请求失败,已保存当前页数据'
+            };
         }
     }
     

+ 3 - 2
backend/modules/fmode-amazon-sp-api/src/marketplace-helper.ts

@@ -26,13 +26,14 @@ const MARKETPLACE_REGION_MAP: Record<string, keyof typeof ENDPOINTS> = {
   'A1RKKUPIHCS9HS': 'EU', // 西班牙
   'A1F83G8C2ARO7P': 'EU', // 英国
   'A13V1IB3VIYZZH': 'EU', // 法国
-  'A1805IZSGTT6HS': 'EU', // 比利时
+  'A1805IZSGTT6HS': 'EU', // 荷兰
   'A1PA6795UKMFR9': 'EU', // 德国
   'APJ6JRA9NG5V4': 'EU', // 意大利
   'A2NODRKZP88ZB9': 'EU', // 瑞典
   'AE08WJ6EMKS27': 'EU', // 埃及
   'A1C3SOZRARQ6R3': 'EU', // 波兰
-  'AMEN7PMS3EDWL': 'EU', // 比利时 (重复? 检查中...) - 实际上 BE 是 A1805IZSGTT6HS.
+  'AMEN7PMS3EDWL': 'EU', // 比利时
+  'A28R8C7NBKEWEA': 'EU', // 爱尔兰
   'A33AVAJ2PDY3EV': 'EU', // 土耳其
   'A17E79C6D8DWNP': 'EU', // 沙特阿拉伯
   'A2VIGQ35RCS4UG': 'EU', // 阿联酋

+ 2 - 2
backend/modules/fmode-amazon-sp-api/src/middleware/auth.ts

@@ -22,7 +22,7 @@ export const shopAuthMiddleware = async (req: Request, res: Response, next: Next
     let shop: Parse.Object;
 
     try {
-      shop = await query.get(shopId);
+      shop = await query.get(shopId, { useMasterKey: true });
       console.log('[SP-API] 认证中间件:', shop.get('name'));
     } catch (error) {
       console.log(error)
@@ -33,7 +33,7 @@ export const shopAuthMiddleware = async (req: Request, res: Response, next: Next
     }
 
     const config = shop.get('config').SpApiConfig as ShopConfig;
-    console.log('[SP-API] 认证中间件:', config);
+    console.log('[SP-API] 店铺凭证已加载');
     if (!config || !config.clientId || !config.clientSecret || !config.refreshToken) {
       return res.status(400).json({
         success: false,

+ 12 - 1
backend/modules/fmode-amazon-sp-api/src/routes.ts

@@ -306,7 +306,6 @@ export const createSpApiRouter = (staticClient?: SpApiClient) => {
     asyncHandler(async (req, res) => {
       const api = createScopedApi(SellersApi, req);
       const result: any = await api.getAccount();
-      console.log(result.data.errors);
       sendResponse(res, result);
     })
   );
@@ -530,6 +529,18 @@ export const createSpApiRouter = (staticClient?: SpApiClient) => {
     })
   );
 
+  router.use((error: any, _req: Request, res: Response, _next: NextFunction) => {
+    const status = Number(error?.response?.status || error?.status || 500);
+    const upstream = error?.response?.data;
+    res.status(status).json({
+      success: false,
+      status,
+      code: upstream?.errors?.[0]?.code || error?.code || 'SP_API_ERROR',
+      message: upstream?.errors?.[0]?.message || error?.message || 'SP-API request failed',
+      details: upstream?.errors?.[0]?.details || '',
+    });
+  });
+
   return router;
 };
 

+ 7 - 8
backend/modules/fmode-amazon-sp-api/test_auth.ts

@@ -2,19 +2,18 @@
 import { SpApiClient } from './src/client.ts';
 import { SpApiConfig } from './src/types.ts';
 
-// 从环境变量读取配置,或者使用占位符
 const config: SpApiConfig = {
-  clientId:'amzn1.application-oa2-client.afc01e883d4143bcae466247ac5fe692',
-  clientSecret:'amzn1.oa2-cs.v1.eb3330dce4b5c835b0d22e855c8db1082632b99ed7226db7114557b3c562d038',
-  refreshToken:'Atzr|IwEBIC8ubYHG1E9Li2-_madN1smv4tKNgv9-SZTkW-OxlyeiXVQMbRon_7-ON9s2spa-fBsi6G1KXdk8eSO2SlyM-5BxMUufTTclXDCmDZWHg6wmi6HPwRceGDbWhqjIuPWseNj4uyBBjUcd9F6O_RYzEsu1yss67RcZikPI7t_i-tUhF5W8l2Ufj2Sl9JXGycxNoJKbZzApPVVLwja_JaiAlqhZX4JYM1gfK9dgynJNgcPhnVZRJ0vNOkurQPtSp6_Yix6itvMhN5QeXZwJdd5xW1T7C6TJXsHJLiEPfTeJIEj7m7UwgdDS7YvdryIL8cMl5Gc',
+  clientId: process.env.AMAZON_LWA_CLIENT_ID || '',
+  clientSecret: process.env.AMAZON_LWA_CLIENT_SECRET || '',
+  refreshToken: process.env.AMAZON_REFRESH_TOKEN || '',
   region: 'us-east-1'
 };
 
+if (!config.clientId || !config.clientSecret || !config.refreshToken) {
+  throw new Error('Set AMAZON_LWA_CLIENT_ID, AMAZON_LWA_CLIENT_SECRET and AMAZON_REFRESH_TOKEN');
+}
 
-
-console.log('Initializing SP-API Client with config:', {
-  ...config
-});
+console.log('Initializing SP-API Client with environment credentials');
 
 const client = new SpApiClient(config);
 

+ 75 - 0
backend/src/relay/relay-billing.ts

@@ -0,0 +1,75 @@
+import { RELAY_CONFIG } from './relay-config.ts';
+import { getStoredRelayAuth, isUsableAuth, maskRelayKey } from './relay-credential-store.ts';
+
+export interface RelayBalanceStatus {
+  configured: boolean;
+  maskedKey: string;
+  availableCny: number | null;
+  usedCny: number | null;
+  sufficient: boolean | null;
+  source: 'user' | 'fallback' | 'none';
+  updatedAt: string;
+  message?: string;
+}
+
+function billingUrl(): string {
+  return RELAY_CONFIG.baseUrl.replace(/\/+$/, '') + '/api/fmode/billing/query';
+}
+
+export async function queryRelayBalance(auth: string): Promise<any> {
+  if (!isUsableAuth(auth)) throw new Error('API 密钥未配置');
+  const response = await fetch(billingUrl(), {
+    method: 'POST',
+    headers: {
+      Accept: 'application/json',
+      'Content-Type': 'application/json',
+      Authorization: auth.startsWith('Bearer ') ? auth : 'Bearer ' + auth,
+    },
+    body: JSON.stringify({ include: ['balance'] }),
+  });
+  const json = await response.json().catch(() => null);
+  if (!response.ok || json?.code !== 200) {
+    throw new Error(json?.message || json?.mess || '余额查询失败');
+  }
+  return json.data?.balance || {};
+}
+
+export async function getRelayBalanceStatus(): Promise<RelayBalanceStatus> {
+  const auth = await getStoredRelayAuth();
+  if (!isUsableAuth(auth)) {
+    return {
+      configured: false,
+      maskedKey: '',
+      availableCny: null,
+      usedCny: null,
+      sufficient: null,
+      source: 'none',
+      updatedAt: new Date().toISOString(),
+      message: '尚未配置 API 密钥',
+    };
+  }
+  const source = 'user';
+  try {
+    const balance = await queryRelayBalance(auth);
+    return {
+      configured: true,
+      maskedKey: maskRelayKey(auth),
+      availableCny: Number(balance.availableCny ?? balance.userQuotaCny ?? null),
+      usedCny: Number(balance.usedCny ?? null),
+      sufficient: balance.sufficient ?? null,
+      source,
+      updatedAt: new Date().toISOString(),
+    };
+  } catch (error: any) {
+    return {
+      configured: true,
+      maskedKey: maskRelayKey(auth),
+      availableCny: null,
+      usedCny: null,
+      sufficient: null,
+      source,
+      updatedAt: new Date().toISOString(),
+      message: error?.message || '余额查询失败',
+    };
+  }
+}

+ 17 - 8
backend/src/relay/relay-client.ts

@@ -5,6 +5,7 @@ import {
   TIKHUB_MEDIA_PREFIXES,
 } from './relay-config.ts';
 import { RelayError, redactSecrets } from './relay-errors.ts';
+import { getStoredRelayAuth, isUsableAuth } from './relay-credential-store.ts';
 
 export interface ForwardPayload {
   path: string;
@@ -104,15 +105,19 @@ export class RelayClient {
   }
 
   get authConfigured(): boolean {
-    return Boolean(this.config.auth)
-      && !this.config.auth.includes('TARGET_RELAY_SK')
-      && this.config.auth.replace(/^Bearer\s+/i, '').trim().length > 0;
+    return false;
+  }
+
+  private async resolveAuth(): Promise<string> {
+    const stored = await getStoredRelayAuth();
+    return isUsableAuth(stored) ? stored : '';
   }
 
   async forwardSorftime(payload: ForwardPayload): Promise<any> {
-    if (!this.authConfigured) {
+    const auth = await this.resolveAuth();
+    if (!isUsableAuth(auth)) {
       throw new RelayError(
-        'Company relay credential is not configured (sk-TARGET_RELAY_SK)',
+        'Company relay credential is not configured',
         503,
         'RELAY_AUTH_MISSING',
       );
@@ -133,6 +138,7 @@ export class RelayClient {
       method: 'POST',
       body,
       requestId: payload.requestId,
+      auth,
     });
 
     // 公司中转: { code, message, data } → 兼容前端 Data/data 解包
@@ -157,9 +163,10 @@ export class RelayClient {
   }
 
   async forwardTikhub(payload: ForwardPayload): Promise<any> {
-    if (!this.authConfigured) {
+    const auth = await this.resolveAuth();
+    if (!isUsableAuth(auth)) {
       throw new RelayError(
-        'Company relay credential is not configured (sk-TARGET_RELAY_SK)',
+        'Company relay credential is not configured',
         503,
         'RELAY_AUTH_MISSING',
       );
@@ -186,6 +193,7 @@ export class RelayClient {
       method,
       body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,
       requestId: payload.requestId,
+      auth,
     });
 
     if (result && typeof result === 'object') {
@@ -212,6 +220,7 @@ export class RelayClient {
     method: string;
     body?: unknown;
     requestId?: string;
+    auth?: string;
   }): Promise<any> {
     const requestId = input.requestId || createRequestId();
     const base = this.config.baseUrl.replace(/\/+$/, '');
@@ -230,7 +239,7 @@ export class RelayClient {
           headers: {
             Accept: 'application/json',
             'Content-Type': 'application/json',
-            Authorization: this.config.auth,
+            Authorization: input.auth || '',
             'X-Request-Id': requestId,
           },
           body: input.body === undefined ? undefined : JSON.stringify(input.body),

+ 9 - 7
backend/src/relay/relay-config.ts

@@ -12,14 +12,16 @@ export interface RelayConfig {
   maxRetries: number;
 }
 
+const runtimeConfig = ((globalThis as any).appConfig?.relay || {}) as Partial<RelayConfig>;
+const runtimeEnv = typeof process !== 'undefined' ? process.env : {};
+
 export const RELAY_CONFIG: RelayConfig = {
-  baseUrl: 'https://server.fmode.cn',
-  // 交付时替换为公司发放的实例级 sk-
-  auth: 'Bearer sk-TARGET_RELAY_SK',
-  sorftimePath: '/api/voc-ecom/forward',
-  tikhubBasePath: '/api/voc-social',
-  timeoutMs: 30_000,
-  maxRetries: 1,
+  baseUrl: runtimeEnv.COMPANY_RELAY_BASE_URL || runtimeConfig.baseUrl || 'https://server.fmode.cn',
+  auth: runtimeEnv.COMPANY_RELAY_AUTH || runtimeConfig.auth || '',
+  sorftimePath: runtimeConfig.sorftimePath || '/api/voc-ecom/forward',
+  tikhubBasePath: runtimeConfig.tikhubBasePath || '/api/voc-social',
+  timeoutMs: Number(runtimeConfig.timeoutMs || 30_000),
+  maxRetries: Number(runtimeConfig.maxRetries || 1),
 };
 
 /** Sorftime 上游 path 白名单前缀 */

+ 107 - 0
backend/src/relay/relay-credential-store.ts

@@ -0,0 +1,107 @@
+const CLASS_NAME = 'RelayCredential';
+const RECORD_KEY = 'company-relay';
+const CACHE_TTL_MS = 15_000;
+
+type ParseLike = any;
+
+export interface StoredRelayCredential {
+  auth: string;
+  maskedKey: string;
+  updatedAt?: string;
+  updatedBy?: string;
+}
+
+let cache: { auth: string; expiresAt: number } | null = null;
+
+function getParse(): ParseLike {
+  return (globalThis as any).Parse;
+}
+
+export function normalizeRelayKey(raw: string): string {
+  const value = String(raw || '').trim();
+  const token = value.replace(/^Bearer\s+/i, '').trim();
+  if (!token || !token.startsWith('sk-') || token.length < 20) {
+    throw new Error('请输入有效的 API 密钥');
+  }
+  return token;
+}
+
+export function toAuthorization(raw: string): string {
+  return 'Bearer ' + normalizeRelayKey(raw);
+}
+
+export function maskRelayKey(raw: string): string {
+  const token = String(raw || '').replace(/^Bearer\s+/i, '').trim();
+  if (!token) return '';
+  if (token.length <= 8) return 'sk-****';
+  return token.slice(0, 3) + '****' + token.slice(-4);
+}
+
+export function isUsableAuth(auth: string): boolean {
+  const token = String(auth || '').replace(/^Bearer\s+/i, '').trim();
+  return Boolean(token) && token.startsWith('sk-') && !token.includes('TARGET_RELAY_SK');
+}
+
+export async function getStoredRelayAuth(force = false): Promise<string> {
+  if (!force && cache && cache.expiresAt > Date.now()) return cache.auth;
+  const Parse = getParse();
+  if (!Parse) {
+    cache = { auth: '', expiresAt: Date.now() + CACHE_TTL_MS };
+    return '';
+  }
+  try {
+    const query = new Parse.Query(CLASS_NAME);
+    query.equalTo('key', RECORD_KEY);
+    const row = await query.first({ useMasterKey: true });
+    const stored = String(row?.get('auth') || '');
+    const auth = isUsableAuth(stored) ? stored : '';
+    cache = { auth, expiresAt: Date.now() + CACHE_TTL_MS };
+    return auth;
+  } catch {
+    cache = { auth: '', expiresAt: Date.now() + 5_000 };
+    return '';
+  }
+}
+
+export async function getStoredRelayCredential(): Promise<StoredRelayCredential | null> {
+  const Parse = getParse();
+  if (!Parse) return null;
+  const query = new Parse.Query(CLASS_NAME);
+  query.equalTo('key', RECORD_KEY);
+  const row = await query.first({ useMasterKey: true });
+  if (!row) return null;
+  const auth = String(row.get('auth') || '');
+  if (!isUsableAuth(auth)) return null;
+  return {
+    auth,
+    maskedKey: String(row.get('maskedKey') || maskRelayKey(auth)),
+    updatedAt: row.updatedAt?.toISOString?.() || '',
+    updatedBy: String(row.get('updatedBy') || ''),
+  };
+}
+
+export async function saveStoredRelayCredential(rawKey: string, updatedBy = ''): Promise<StoredRelayCredential> {
+  const Parse = getParse();
+  if (!Parse) throw new Error('Parse 未初始化');
+  const auth = toAuthorization(rawKey);
+  const query = new Parse.Query(CLASS_NAME);
+  query.equalTo('key', RECORD_KEY);
+  const existing = await query.first({ useMasterKey: true });
+  const row = existing || new Parse.Object(CLASS_NAME);
+  row.set('key', RECORD_KEY);
+  row.set('auth', auth);
+  row.set('maskedKey', maskRelayKey(auth));
+  row.set('updatedBy', updatedBy);
+  await row.save(null, { useMasterKey: true });
+  cache = { auth, expiresAt: Date.now() + CACHE_TTL_MS };
+  return {
+    auth,
+    maskedKey: maskRelayKey(auth),
+    updatedAt: row.updatedAt?.toISOString?.() || new Date().toISOString(),
+    updatedBy,
+  };
+}
+
+export function clearRelayAuthCache(): void {
+  cache = null;
+}

+ 185 - 0
backend/src/relay/relay-runtime.js

@@ -0,0 +1,185 @@
+// Plain JS runtime helpers for production Deno/fmode-server.
+// No TypeScript types. Used by both local source and the compiled routes bundle.
+
+const CLASS_NAME = "RelayCredential";
+const RECORD_KEY = "company-relay";
+const CACHE_TTL_MS = 15000;
+
+let cache = null;
+
+function getParse() {
+  return globalThis.Parse;
+}
+
+function getRelayConfig() {
+  const runtimeConfig = globalThis.appConfig?.relay || {};
+  const runtimeEnv = typeof process !== "undefined" ? process.env : {};
+  return {
+    baseUrl: runtimeEnv.COMPANY_RELAY_BASE_URL || runtimeConfig.baseUrl || "https://server.fmode.cn",
+    auth: runtimeEnv.COMPANY_RELAY_AUTH || runtimeConfig.auth || "",
+    sorftimePath: runtimeConfig.sorftimePath || "/api/voc-ecom/forward",
+    tikhubBasePath: runtimeConfig.tikhubBasePath || "/api/voc-social",
+    timeoutMs: Number(runtimeConfig.timeoutMs || 30000),
+    maxRetries: Number(runtimeConfig.maxRetries || 1)
+  };
+}
+
+export function normalizeRelayKey(raw) {
+  const value = String(raw || "").trim();
+  const token = value.replace(/^Bearer\s+/i, "").trim();
+  if (!token || !token.startsWith("sk-") || token.length < 20) {
+    throw new Error("请输入有效的 API 密钥");
+  }
+  return token;
+}
+
+export function toAuthorization(raw) {
+  return "Bearer " + normalizeRelayKey(raw);
+}
+
+export function maskRelayKey(raw) {
+  const token = String(raw || "").replace(/^Bearer\s+/i, "").trim();
+  if (!token) return "";
+  if (token.length <= 8) return "sk-****";
+  return token.slice(0, 3) + "****" + token.slice(-4);
+}
+
+export function isUsableAuth(auth) {
+  const token = String(auth || "").replace(/^Bearer\s+/i, "").trim();
+  return Boolean(token) && token.startsWith("sk-") && !token.includes("TARGET_RELAY_SK");
+}
+
+export async function getStoredRelayAuth(force) {
+  if (!force && cache && cache.expiresAt > Date.now()) return cache.auth;
+  const Parse = getParse();
+  if (!Parse) {
+    cache = { auth: "", expiresAt: Date.now() + CACHE_TTL_MS };
+    return "";
+  }
+  try {
+    const query = new Parse.Query(CLASS_NAME);
+    query.equalTo("key", RECORD_KEY);
+    const row = await query.first({ useMasterKey: true });
+    const stored = String(row?.get("auth") || "");
+    const auth = isUsableAuth(stored) ? stored : "";
+    cache = { auth, expiresAt: Date.now() + CACHE_TTL_MS };
+    return auth;
+  } catch {
+    cache = { auth: "", expiresAt: Date.now() + 5000 };
+    return "";
+  }
+}
+
+export async function getStoredRelayCredential() {
+  const Parse = getParse();
+  if (!Parse) return null;
+  const query = new Parse.Query(CLASS_NAME);
+  query.equalTo("key", RECORD_KEY);
+  const row = await query.first({ useMasterKey: true });
+  if (!row) return null;
+  const auth = String(row.get("auth") || "");
+  if (!isUsableAuth(auth)) return null;
+  return {
+    auth,
+    maskedKey: String(row.get("maskedKey") || maskRelayKey(auth)),
+    updatedAt: row.updatedAt?.toISOString?.() || "",
+    updatedBy: String(row.get("updatedBy") || "")
+  };
+}
+
+export async function saveStoredRelayCredential(rawKey, updatedBy) {
+  const Parse = getParse();
+  if (!Parse) throw new Error("Parse 未初始化");
+  const auth = toAuthorization(rawKey);
+  const query = new Parse.Query(CLASS_NAME);
+  query.equalTo("key", RECORD_KEY);
+  const existing = await query.first({ useMasterKey: true });
+  const row = existing || new Parse.Object(CLASS_NAME);
+  row.set("key", RECORD_KEY);
+  row.set("auth", auth);
+  row.set("maskedKey", maskRelayKey(auth));
+  row.set("updatedBy", updatedBy || "");
+  await row.save(null, { useMasterKey: true });
+  cache = { auth, expiresAt: Date.now() + CACHE_TTL_MS };
+  return {
+    auth,
+    maskedKey: maskRelayKey(auth),
+    updatedAt: row.updatedAt?.toISOString?.() || new Date().toISOString(),
+    updatedBy: updatedBy || ""
+  };
+}
+
+export function clearRelayAuthCache() {
+  cache = null;
+}
+
+function billingUrl() {
+  return getRelayConfig().baseUrl.replace(/\/+$/, "") + "/api/fmode/billing/query";
+}
+
+export async function queryRelayBalance(auth) {
+  if (!isUsableAuth(auth)) throw new Error("API 密钥未配置");
+  const response = await fetch(billingUrl(), {
+    method: "POST",
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+      Authorization: auth.startsWith("Bearer ") ? auth : "Bearer " + auth
+    },
+    body: JSON.stringify({ include: ["balance"] })
+  });
+  const json = await response.json().catch(() => null);
+  if (!response.ok || json?.code !== 200) {
+    throw new Error(json?.message || json?.mess || "余额查询失败");
+  }
+  return json.data?.balance || {};
+}
+
+export async function getRelayBalanceStatus() {
+  const auth = await getStoredRelayAuth();
+  if (!isUsableAuth(auth)) {
+    return {
+      configured: false,
+      maskedKey: "",
+      availableCny: null,
+      usedCny: null,
+      sufficient: null,
+      source: "none",
+      updatedAt: new Date().toISOString(),
+      message: "尚未配置 API 密钥"
+    };
+  }
+  const source = "user";
+  try {
+    const balance = await queryRelayBalance(auth);
+    return {
+      configured: true,
+      maskedKey: maskRelayKey(auth),
+      availableCny: Number(balance.availableCny ?? balance.userQuotaCny ?? null),
+      usedCny: Number(balance.usedCny ?? null),
+      sufficient: balance.sufficient ?? null,
+      source,
+      updatedAt: new Date().toISOString()
+    };
+  } catch (error) {
+    return {
+      configured: true,
+      maskedKey: maskRelayKey(auth),
+      availableCny: null,
+      usedCny: null,
+      sufficient: null,
+      source,
+      updatedAt: new Date().toISOString(),
+      message: error?.message || "余额查询失败"
+    };
+  }
+}
+
+export function redactSecrets(input) {
+  const text = typeof input === "string" ? input : JSON.stringify(input);
+  return text
+    .replace(/Bearer\s+[A-Za-z0-9\-_=+./]+/gi, "Bearer ***")
+    .replace(/sk-[A-Za-z0-9\-_=+./]+/gi, "sk-***")
+    .replace(/BasicAuth\s+[A-Za-z0-9\-_=+./]+/gi, "BasicAuth ***")
+    .replace(/postgresql:\/\/[^@\s]+@/gi, "postgresql://***@");
+}

+ 364 - 0
deploy/backfill-listings.mjs

@@ -0,0 +1,364 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+
+if (!appId || !masterKey) throw new Error('Missing Parse bootstrap environment variables');
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function request(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const result = await response.json();
+  if (!response.ok || result.error) throw new Error(`${method} ${path}: ${JSON.stringify(result.error || response.status)}`);
+  return result;
+}
+
+async function allRows(className, keys = '', where = undefined) {
+  const rows = [];
+  let skip = 0;
+  const whereParam = where === undefined ? '' : `&where=${encodeURIComponent(JSON.stringify(where))}`;
+  while (true) {
+    const page = await request(`/classes/${className}?limit=1000&skip=${skip}&order=objectId${keys ? `&keys=${keys}` : ''}${whereParam}`);
+    rows.push(...(page.results || []));
+    if ((page.results || []).length < 1000) return rows;
+    skip += page.results.length;
+  }
+}
+
+const siteByMarketplace = {
+  ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br',
+  A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es',
+  APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae',
+  A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in',
+};
+
+const currencyByMarketplace = {
+  ATVPDKIKX0DER: 'USD', A2EUQ1WTGCTBG2: 'CAD', A1AM78C64UM0Y8: 'MXN', A2Q3Y263D00KWC: 'BRL',
+  A1F83G8C2ARO7P: 'GBP', A1PA6795UKMFR9: 'EUR', A13V1IB3VIYZZH: 'EUR', A1RKKUPIHCS9HS: 'EUR',
+  APJ6JRA9NG5V4: 'EUR', A1805IZSGTT6HS: 'EUR', A2NODRKZP88ZB9: 'SEK', A1C3SOZRARQ6R3: 'PLN',
+  AMEN7PMS3EDWL: 'EUR', A28R8C7NBKEWEA: 'EUR', A39IBJ37TRP1C6: 'AUD', A19VAU5U5O7RUS: 'SGD',
+  A2VIGQ35RCS4UG: 'AED', A17E79C6D8DWNP: 'SAR',
+};
+
+const normalize = value => String(value || '').trim().toUpperCase();
+const rowKey = (shopId, asin) => `${shopId}:${normalize(asin)}`;
+const skuKey = (shopId, sku) => `${shopId}:${normalize(sku)}`;
+
+function firstAttribute(attributes, names) {
+  for (const name of names) {
+    const entry = attributes?.[name];
+    const value = Array.isArray(entry) ? entry[0]?.value : entry?.value;
+    if (value !== undefined && value !== null && String(value).trim()) return String(value).trim();
+  }
+  return '';
+}
+
+function catalogImage(catalog) {
+  const images = (catalog?.images || catalog?.rawData?.images || [])
+    .flatMap(group => group?.images || [])
+    .filter(image => image?.link);
+  return images
+    .slice()
+    .sort((left, right) => {
+      const leftMain = left.variant === 'MAIN' ? 1 : 0;
+      const rightMain = right.variant === 'MAIN' ? 1 : 0;
+      return rightMain - leftMain || Number(right.width || 0) - Number(left.width || 0);
+    })[0]?.link || '';
+}
+
+function pricingOffer(pricing, sku) {
+  const offers = pricing?.product?.Offers || pricing?.rawData?.Product?.Offers || [];
+  return offers.find(offer => normalize(offer?.SellerSKU) === normalize(sku)) || offers[0] || null;
+}
+
+function numberValue(value) {
+  let normalized = String(value ?? '').trim().replace(/[^0-9,.-]/g, '');
+  if (normalized.includes(',') && !normalized.includes('.')) normalized = normalized.replace(',', '.');
+  else normalized = normalized.replace(/,/g, '');
+  const result = Number(normalized);
+  return Number.isFinite(result) ? result : 0;
+}
+
+function listingDate(value) {
+  const raw = String(value || '').trim();
+  if (!raw) return undefined;
+  let timestamp = Date.parse(raw);
+  if (!Number.isFinite(timestamp)) {
+    const match = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}):(\d{2}))?/);
+    if (match) timestamp = Date.UTC(
+      Number(match[3]), Number(match[2]) - 1, Number(match[1]),
+      Number(match[4] || 0), Number(match[5] || 0), Number(match[6] || 0),
+    );
+  }
+  return Number.isFinite(timestamp) ? { __type: 'Date', iso: new Date(timestamp).toISOString() } : undefined;
+}
+
+async function upsert(className, where, payload) {
+  const encoded = encodeURIComponent(JSON.stringify(where));
+  const existing = await request(`/classes/${className}?where=${encoded}&limit=1`);
+  const objectId = existing.results?.[0]?.objectId;
+  return objectId
+    ? request(`/classes/${className}/${objectId}`, 'PUT', payload)
+    : request(`/classes/${className}`, 'POST', payload);
+}
+
+const shopsResult = await request('/classes/Shop?limit=1000');
+const shops = new Map(shopsResult.results.map(shop => [shop.objectId, shop]));
+const shopsByMarketplace = new Map(shopsResult.results.map(shop => [shop.marketplaceId, shop]));
+for (const shop of shopsResult.results) {
+  await request(`/classes/Shop/${shop.objectId}`, 'PUT', {
+    shopName: shop.name || '',
+    storeName: shop.name || '',
+    disable: Boolean(shop.disable),
+    deleted: Boolean(shop.deleted),
+  });
+}
+
+// Listings Items requires a region-specific account ID, while merchant-listing
+// reports only require the authorized shop token. Materialize single-marketplace
+// reports so every product is attached to the marketplace that produced it.
+const merchantReportRows = await allRows(
+  'AmazonReportRow',
+  'reportId,marketplaceIds,rowData',
+  { reportType: 'GET_MERCHANT_LISTINGS_ALL_DATA' },
+);
+let reportListingUpserts = 0;
+for (const reportRow of merchantReportRows) {
+  if (!Array.isArray(reportRow.marketplaceIds) || reportRow.marketplaceIds.length !== 1) continue;
+  const marketplaceId = reportRow.marketplaceIds[0];
+  const shop = shopsByMarketplace.get(marketplaceId);
+  const row = reportRow.rowData || {};
+  if (!shop) continue;
+
+  const asin = normalize(row.asin1 || row['product-id'] || row.product_id);
+  const sku = String(row['seller-sku'] || row.seller_sku || '').trim();
+  if (!asin || !sku) continue;
+
+  const pointer = { __type: 'Pointer', className: 'Shop', objectId: shop.objectId };
+  const createdDate = listingDate(row['open-date'] || row.open_date);
+  await upsert('Listing', { sku, shop: pointer }, {
+    asin,
+    sku,
+    sellerSku: sku,
+    title: String(row['item-name'] || row.item_name || '').trim(),
+    itemName: String(row['item-name'] || row.item_name || '').trim(),
+    marketplaceId,
+    price: numberValue(row.price),
+    currency: currencyByMarketplace[marketplaceId] || '',
+    quantity: numberValue(row.quantity),
+    status: String(row.status || '').trim(),
+    condition: String(row['item-condition'] || row.item_condition || '').trim(),
+    fulfillmentType: String(row['fulfillment-channel'] || row['fulfilment-channel'] || '').trim(),
+    source: 'amazon-sp-api-report',
+    rawData: row,
+    shop: pointer,
+    ...(createdDate ? { createdDate } : {}),
+  });
+  reportListingUpserts += 1;
+}
+
+const [listingRows, catalogRows, pricingRows, inventoryRows, orderRows, orderItemRows] = await Promise.all([
+  allRows('Listing'),
+  allRows('AmazonCatalogItem'),
+  allRows('AmazonProductPricing'),
+  allRows('AmazonInventorySummary'),
+  allRows('Order', 'objectId,platformOrderId,orderDate,status,shop'),
+  allRows('AmazonOrderItem', 'amazonOrderId,asin,sellerSku,quantityOrdered,shop'),
+]);
+const listingResult = { results: listingRows };
+const catalogByProduct = new Map(catalogRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
+const pricingByProduct = new Map(pricingRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
+const inventoryBySku = new Map(inventoryRows.map(row => [skuKey(row.shop?.objectId || '', row.sellerSku), row]));
+const inventoryByProduct = new Map(inventoryRows.map(row => [rowKey(row.shop?.objectId || '', row.asin), row]));
+const recentOrderIds = new Set(orderRows.filter(order => {
+  if (['canceled', 'cancelled'].includes(String(order.status || '').trim().toLowerCase())) return false;
+  const iso = order.orderDate?.iso || order.orderDate || '';
+  const time = new Date(iso).getTime();
+  return Number.isFinite(time) && time >= Date.now() - 30 * 24 * 60 * 60 * 1000;
+}).map(order => order.platformOrderId).filter(Boolean));
+const monthlySalesByProduct = new Map();
+for (const item of orderItemRows) {
+  if (!recentOrderIds.has(item.amazonOrderId)) continue;
+  const key = rowKey(item.shop?.objectId || '', item.asin);
+  monthlySalesByProduct.set(key, (monthlySalesByProduct.get(key) || 0) + Number(item.quantityOrdered || 0));
+}
+let productUpserts = 0;
+let mappingUpserts = 0;
+for (const listing of listingResult.results) {
+  const asin = String(listing.asin || '').trim().toUpperCase();
+  const shopId = listing.shop?.objectId || '';
+  if (!asin || !shopId) continue;
+  const shop = shops.get(shopId) || {};
+  const marketplaceId = listing.marketplaceId || shop.marketplaceId || '';
+  const site = siteByMarketplace[marketplaceId] || String(shop.domain || '');
+  const pointer = { __type: 'Pointer', className: 'Shop', objectId: shopId };
+  const key = rowKey(shopId, asin);
+  const catalog = catalogByProduct.get(key) || {};
+  const pricing = pricingByProduct.get(key) || {};
+  const inventory = inventoryBySku.get(skuKey(shopId, listing.sku)) || inventoryByProduct.get(key) || {};
+  const offer = pricingOffer(pricing, listing.sku);
+  const catalogSummary = catalog.summaries?.[0] || catalog.rawData?.summaries?.[0] || {};
+  const imageUrl = catalogImage(catalog) || listing.mainImage || listing.imageUrl || '';
+  const title = catalog.title || catalogSummary.itemName || listing.title || '';
+  const price = Number(
+    offer?.BuyingPrice?.ListingPrice?.Amount
+    ?? offer?.BuyingPrice?.LandedPrice?.Amount
+    ?? listing.price
+    ?? 0
+  );
+  const currency = offer?.BuyingPrice?.ListingPrice?.CurrencyCode || listing.currency || '';
+  const monthlySales = Number(monthlySalesByProduct.get(key) || 0);
+  const listingColor = firstAttribute(listing.attributes, ['color_name', 'color', 'color_map'])
+    || catalogSummary.color || firstAttribute(catalog.attributes, ['color']);
+  const listingSize = firstAttribute(listing.attributes, ['size_name', 'size'])
+    || catalogSummary.size || firstAttribute(catalog.attributes, ['size']);
+  const createdDate = String(listing.createdDate?.iso || listing.createdDate || '').slice(0, 10);
+  const createdTime = new Date(createdDate).getTime();
+  const onlineDays = Number.isFinite(createdTime)
+    ? Math.max(0, Math.floor((Date.now() - createdTime) / 86400000))
+    : 0;
+  const payload = {
+    asin,
+    parentAsin: listing.parentAsin || '',
+    sku: listing.sku || '',
+    sellerSku: listing.sku || '',
+    title,
+    itemName: title,
+    imageUrl,
+    photo: imageUrl,
+    listingImageUrl: imageUrl,
+    category: catalogSummary.browseClassification?.displayName
+      ? [catalogSummary.browseClassification.displayName]
+      : (listing.productType ? [listing.productType] : []),
+    categoryName: catalogSummary.browseClassification?.displayName || '',
+    productType: catalog.productType || listing.productType || '',
+    brand: catalog.brand || catalogSummary.brand || '',
+    marketplaceId,
+    domain: String(shop.domain || ''),
+    site,
+    storeName: shop.name || '',
+    shopName: shop.name || '',
+    shopId,
+    storeId: shopId,
+    status: listing.status || '',
+    onlineDate: createdDate,
+    onlineDays,
+    price,
+    salesPrice: price,
+    listingPrice: price,
+    regularPrice: Number(offer?.RegularPrice?.Amount || price),
+    currency,
+    fnsku: inventory.fnSku || '',
+    fulfillmentType: inventory.objectId ? 'FBA' : '',
+    inventoryQuantity: Number(inventory.totalQuantity || 0),
+    listingColor,
+    listingSize,
+    listingSalesVolumeOfMonth: monthlySales,
+    ListingSalesVolumeOfMonth: monthlySales,
+    listingSalesOfMonth: monthlySales,
+    monthlySales,
+    attributes: catalog.attributes || listing.attributes || {},
+    catalogData: catalog.rawData || {},
+    pricingData: pricing.product || pricing.rawData || {},
+    inventoryData: inventory.inventoryDetails || inventory.rawData || {},
+    source: 'amazon-sp-api',
+    shop: pointer,
+  };
+  const where = { asin, shop: pointer };
+  await upsert('Product', where, payload);
+  await upsert('ProductDetail', where, payload);
+  await upsert('AsinSkuMapping', { asin, sellerSku: listing.sku || '', shop: pointer }, {
+    recordKey: `${shopId}:${asin}:${listing.sku || ''}`,
+    asin,
+    parentAsin: listing.parentAsin || '',
+    sku: listing.sku || '',
+    sellerSku: listing.sku || '',
+    productSku: listing.sku || '',
+    fnsku: inventory.fnSku || '',
+    title,
+    itemName: title,
+    imageUrl,
+    photo: imageUrl,
+    productType: catalog.productType || listing.productType || '',
+    category: catalogSummary.browseClassification?.displayName || listing.productType || '',
+    brand: catalog.brand || catalogSummary.brand || '',
+    marketplaceId,
+    domain: String(shop.domain || ''),
+    site,
+    storeName: shop.name || '',
+    shopName: shop.name || '',
+    shopId,
+    storeId: shopId,
+    status: listing.status || '',
+    onlineDate: createdDate,
+    onlineDays,
+    price,
+    salesPrice: price,
+    currency,
+    fulfillmentType: inventory.objectId ? 'FBA' : '',
+    inventoryQuantity: Number(inventory.totalQuantity || 0),
+    color: listingColor,
+    size: listingSize,
+    listingColor,
+    listingSize,
+    source: 'amazon-sp-api',
+    shop: pointer,
+  });
+  await request(`/classes/Listing/${listing.objectId}`, 'PUT', {
+    site,
+    storeName: shop.name || '',
+    shopName: shop.name || '',
+    shopId,
+    sellerSku: listing.sku || '',
+    fnsku: inventory.fnSku || '',
+    fulfillmentType: inventory.objectId ? 'FBA' : '',
+    inventoryQuantity: Number(inventory.totalQuantity || 0),
+    listingColor,
+    listingSize,
+    listingSalesVolumeOfMonth: monthlySales,
+    ListingSalesVolumeOfMonth: monthlySales,
+    listingSalesOfMonth: monthlySales,
+  });
+  productUpserts += 1;
+  mappingUpserts += 1;
+}
+
+for (const className of ['Product', 'ProductDetail', 'SorftimeProduct']) {
+  const rows = await request(`/classes/${className}?limit=1000`);
+  for (const row of rows.results || []) {
+    const shopId = row.shop?.objectId || '';
+    if (!shopId) continue;
+    const shop = shops.get(shopId) || {};
+    const marketplaceId = row.marketplaceId || shop.marketplaceId || '';
+    await request(`/classes/${className}/${row.objectId}`, 'PUT', {
+      site: row.site || siteByMarketplace[marketplaceId] || String(shop.domain || ''),
+      marketplaceId,
+      domain: String(row.domain || shop.domain || ''),
+      storeName: row.storeName || shop.name || '',
+      shopName: row.shopName || shop.name || '',
+      shopId,
+    });
+  }
+}
+
+console.log(JSON.stringify({
+  shops: shops.size,
+  listings: listingResult.results.length,
+  productUpserts,
+  mappingUpserts,
+  catalogRows: catalogRows.length,
+  pricingRows: pricingRows.length,
+  inventoryRows: inventoryRows.length,
+  orderItemRows: orderItemRows.length,
+  productsWithMonthlySales: monthlySalesByProduct.size,
+  merchantReportRows: merchantReportRows.length,
+  reportListingUpserts,
+}));

+ 12 - 0
deploy/backup.sh

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BACKUP_DIR=/var/backups/jianhen-voc
+CONFIG=/etc/jianhen-voc/config.json
+STAMP="$(date +%Y%m%d-%H%M%S)"
+
+install -d -o voc -g voc -m 0750 "$BACKUP_DIR"
+DATABASE_URI="$(node -e "const c=require('$CONFIG');process.stdout.write(c.parse.databaseURI)")"
+pg_dump --format=custom --no-owner --no-acl --dbname="$DATABASE_URI" \
+  --file="$BACKUP_DIR/jianhen-voc-$STAMP.dump"
+find "$BACKUP_DIR" -type f -name 'jianhen-voc-*.dump' -mtime +14 -delete

+ 442 - 0
deploy/bootstrap-schemas.mjs

@@ -0,0 +1,442 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+
+if (!appId || !masterKey) throw new Error('Missing Parse bootstrap environment variables');
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function request(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const result = await response.json();
+  if (!response.ok || result.error) throw new Error(`${method} ${path}: ${result.error || response.status}`);
+  return result;
+}
+
+const StringField = { type: 'String' };
+const NumberField = { type: 'Number' };
+const BooleanField = { type: 'Boolean' };
+const DateField = { type: 'Date' };
+const ArrayField = { type: 'Array' };
+const ObjectField = { type: 'Object' };
+const pointer = targetClass => ({ type: 'Pointer', targetClass });
+
+const productFields = {
+  recordKey: StringField, asin: StringField, Asin: StringField, parentAsin: StringField, sku: StringField,
+  sellerSku: StringField, productSku: StringField, fnsku: StringField,
+  title: StringField, itemName: StringField, imageUrl: StringField, photo: StringField,
+  productType: StringField, platform: StringField, currency: StringField,
+  brand: StringField, category: StringField, categoryName: StringField, nodeId: StringField,
+  categoryNodeId: StringField, sellerId: StringField, marketplace: StringField,
+  marketplaceId: StringField, domain: StringField, site: StringField, country: StringField,
+  countryCode: StringField, storeName: StringField, shopName: StringField,
+  shopId: StringField, storeId: StringField,
+  userAccount: StringField, designerId: StringField, designerName: StringField,
+  operatorId: StringField, operatorName: StringField,
+  developerId: StringField, developerName: StringField,
+  departCode: StringField, accessUserId: StringField, fulfillmentType: StringField,
+  onlineDate: StringField, openDateSite: StringField, listingOpenDateSite: StringField,
+  firstOrderDateSite: StringField, onlineDays: NumberField,
+  listingColor: StringField, listingSize: StringField, listingVariation: StringField,
+  color: StringField, size: StringField, variation: StringField,
+  listingImageUrl: StringField, listingSmallImageUrl: StringField,
+  source: StringField, status: StringField, itemStatus: NumberField,
+  price: NumberField, salesPrice: NumberField, listingPrice: NumberField,
+  regularPrice: NumberField, rating: NumberField, ratings: NumberField,
+  ratingsCount: NumberField, reviewCount: NumberField, rank: NumberField,
+  monthlySales: NumberField, listingSalesVolumeOfMonth: NumberField,
+  ListingSalesVolumeOfMonth: NumberField, listingSalesOfMonth: NumberField,
+  inventoryQuantity: NumberField, sellerCount: NumberField,
+  bsrCategory: ArrayField, attributes: ObjectField, rawData: ObjectField,
+  catalogData: ObjectField, pricingData: ObjectField, inventoryData: ObjectField,
+  shop: pointer('Shop'),
+};
+
+const reviewFields = {
+  recordKey: StringField, reviewId: StringField, asin: StringField,
+  marketplaceId: StringField, domain: NumberField,
+  asins: ArrayField, marketplaceIds: ArrayField, domains: ArrayField, shopIds: ArrayField,
+  title: StringField, content: StringField,
+  author: StringField, reviewDate: StringField, sentiment: StringField,
+  date: StringField, createTime: StringField, source: StringField,
+  reviewPublishedAt: DateField, capturedAt: DateField,
+  translatedTitle: StringField, translatedContent: StringField,
+  star: NumberField, rating: NumberField, verified: BooleanField,
+  keywords: ArrayField, topics: ArrayField, rawData: ObjectField, shop: pointer('Shop'),
+};
+
+const schemaDefinitions = {
+  Shop: {
+    name: StringField, shopName: StringField, storeName: StringField, platform: StringField,
+    region: StringField, marketplaceId: StringField, domain: StringField, nodeIds: ArrayField,
+    sync_status: StringField, syncStatus: StringField, status: StringField,
+    disable: BooleanField, deleted: BooleanField, deletedAt: DateField,
+    last_sync_at: DateField, lastSyncTime: DateField, config: ObjectField,
+  },
+  Product: productFields,
+  ProductDetail: productFields,
+  SorftimeProduct: productFields,
+  AmazonProduct: productFields,
+  AsinSkuMapping: productFields,
+  SorftimeReviews: reviewFields,
+  SorftimeReview: reviewFields,
+  Review: reviewFields,
+  ProductReview: reviewFields,
+  Listing: {
+    ...productFields, createdDate: DateField, lastUpdatedDate: DateField,
+    issues: ArrayField, mainImage: StringField, currency: StringField, parentSku: StringField,
+    productType: StringField, quantity: NumberField, condition: StringField,
+  },
+  Order: {
+    platformOrderId: StringField, orderId: StringField, orderDate: DateField,
+    lastUpdateDate: DateField, status: StringField, marketplaceId: StringField,
+    totalAmount: NumberField, currency: StringField, items: ArrayField,
+    buyerInfo: ObjectField, shippingAddress: ObjectField, customerRegion: StringField,
+    shippingCountry: StringField, shippingState: StringField, shippingCity: StringField,
+    fulfillmentChannel: StringField, orderType: StringField, isPrime: BooleanField,
+    isBusinessOrder: BooleanField, earliestShipDate: DateField, latestShipDate: DateField,
+    itemCount: NumberField, itemsSyncedAt: DateField, shop: pointer('Shop'),
+  },
+  OrderMetrics: {
+    date: StringField, orderCount: NumberField, unitCount: NumberField,
+    revenue: NumberField, currency: StringField, shop: pointer('Shop'),
+  },
+  ReturnRecord: {
+    asin: StringField, sku: StringField, sellerSku: StringField, productSku: StringField,
+    fnsku: StringField, orderId: StringField, order_id: StringField,
+    amazonOrderId: StringField, returnDate: DateField, return_date: DateField,
+    reason: StringField, reasonCode: StringField, comment: StringField,
+    customer_comments: StringField, disposition: StringField, detailed_disposition: StringField,
+    productName: StringField, product_name: StringField, status: StringField, site: StringField,
+    marketplaceId: StringField, storeName: StringField, quantity: NumberField,
+    amount: NumberField, price: NumberField, licensePlateNumber: StringField,
+    license_plate_number: StringField, fulfillmentCenterId: StringField,
+    fulfillment_center_id: StringField, shop: pointer('Shop'), rawData: ObjectField,
+  },
+  CompetitorMonitorPrefs: {
+    ownerId: StringField, selectedAsins: ArrayField, groupMap: ObjectField,
+    customGroups: ArrayField, tagMap: ObjectField, allTags: ArrayField,
+    hiddenAsins: ArrayField, socialBrands: ArrayField,
+  },
+  AmazonCategory: {
+    categoryId: StringField, nodeId: StringField, parentId: StringField,
+    name: StringField, cnName: StringField, url: StringField,
+    domain: NumberField, isLeaf: BooleanField, level: NumberField,
+    children: ArrayField, rawData: ObjectField,
+  },
+  SelfCategory: {
+    categoryId: StringField, nodeId: StringField, parentId: StringField,
+    name: StringField, cnName: StringField, url: StringField,
+    domain: NumberField, isLeaf: BooleanField, level: NumberField,
+    path: ArrayField, keywords: ArrayField,
+  },
+  SorftimeCategory: {
+    categoryId: StringField, nodeId: StringField, parentId: StringField,
+    name: StringField, domain: NumberField, isLeaf: BooleanField,
+    sales: NumberField, satisfaction: NumberField, marketShare: NumberField,
+    source: StringField, rawData: ObjectField,
+  },
+  MarketTrend: {
+    nodeId: StringField, categoryId: StringField, domain: NumberField,
+    trendIndex: NumberField, date: StringField, name: StringField,
+    value: NumberField, data: ObjectField, rawData: ObjectField,
+  },
+  Reports: {
+    recordKey: StringField, region: StringField, reportId: StringField,
+    reportDocumentId: StringField, reportType: StringField,
+    processingStatus: StringField, isParsed: BooleanField, marketplaceIds: ArrayField,
+    dataStartTime: DateField, dataEndTime: DateField, shop: pointer('Shop'), rawData: ObjectField,
+    createdTime: DateField, processingStartTime: DateField, processingEndTime: DateField,
+    parsedAt: DateField, parsedRecords: NumberField,
+  },
+  TaskExecutionLog: {
+    taskName: StringField, startTime: DateField, endTime: DateField, duration: NumberField,
+    successCount: NumberField, errorCount: NumberField, errors: ArrayField,
+    status: StringField, processed: NumberField,
+  },
+  ReviewSentiment: { asin: StringField, sentiment: StringField, score: NumberField, data: ObjectField },
+  SocialMediaCache: { cacheKey: StringField, dimension: StringField, data: ObjectField, expiresAt: DateField },
+  AiPromptConfig: { promptKey: StringField, name: StringField, content: StringField, config: ObjectField },
+  TodoStatus: {
+    todoId: StringField, sourceId: StringField, status: StringField,
+    userId: StringField, updatedAt_ts: NumberField, data: ObjectField,
+  },
+  WorkOrder: {
+    suggestionId: StringField, productId: StringField, title: StringField,
+    description: StringField, status: StringField, priority: StringField,
+    dueDate: DateField, assignee: StringField, issueType: StringField,
+    issueId: StringField, sourceId: StringField, createdBy: StringField,
+    actionSuggestionStatus: StringField, actionSuggestionType: StringField,
+    actionSuggestionAssignee: StringField, priorityScore: NumberField,
+    updatedAt_ts: NumberField, aiAnalysisSnapshot: ObjectField,
+    actionSuggestionDetailPatch: ObjectField, workOrder: ObjectField,
+    workOrderId: StringField, workOrderOwner: StringField, nextAction: StringField,
+    slaStatus: StringField, acceptance: ObjectField, issueTaxonomy: ObjectField,
+    rootCause: ObjectField, impact: ObjectField, actionPlan: ArrayField,
+    designerNames: ArrayField, operatorNames: ArrayField,
+    productSkus: ArrayField, fulfillmentTypes: ArrayField,
+    verificationResult: ObjectField, data: ObjectField,
+  },
+  ActionSuggestionStatus: {
+    suggestionId: StringField, status: StringField, assignee: StringField,
+    updatedAt_ts: NumberField, verificationResult: ObjectField,
+    detailPatch: ObjectField, data: ObjectField,
+  },
+  ActionSuggestionAudit: {
+    suggestionId: StringField, action: StringField, workOrderId: StringField,
+    eventType: StringField, status: StringField, title: StringField,
+    assignee: StringField, summary: StringField, detailPatch: ObjectField,
+    verificationResult: ObjectField, createdAt_ts: NumberField, data: ObjectField,
+  },
+  Notification: {
+    type: StringField, targetType: StringField, sourceType: StringField,
+    sourceId: StringField, suggestionId: StringField, actionStatus: StringField,
+    eventType: StringField, channel: StringField, title: StringField,
+    content: StringField, status: StringField, createdAt_ts: NumberField,
+    data: ObjectField,
+  },
+  RequirementStrengthScore: { asin: StringField, score: NumberField, data: ObjectField },
+  ReturnImprovementAction: {
+    actionId: StringField, shopId: StringField, asin: StringField,
+    reasonCategory: StringField, reasonDetail: StringField, action: StringField,
+    owner: StringField, status: StringField, actionCreatedAt: StringField,
+    completedAt: StringField, beforeRate: NumberField, afterRate: NumberField,
+    improvement: NumberField, verificationNote: StringField, source: StringField,
+    updatedAt_ts: NumberField, data: ObjectField,
+  },
+  SizeChartKnowledge: { name: StringField, note: StringField, category: StringField, rows: ArrayField, columns: ArrayField },
+  Keyword: { keyword: StringField, searchVolume: NumberField, trend: NumberField, competition: NumberField, cpcAmount: NumberField, currency: StringField },
+  Category: { categoryId: StringField, name: StringField, parentId: StringField, productCount: NumberField },
+  SorftimeKeyword: {
+    keyword: StringField, searchVolume: NumberField, trend: NumberField,
+    growthRate: NumberField, source: StringField, rawData: ObjectField,
+  },
+  SorftimeMarketCapacity: {
+    nodeId: StringField, domain: NumberField, category: StringField,
+    totalMarketSize: NumberField, competitionLevel: StringField,
+    source: StringField, data: ObjectField, rawData: ObjectField,
+  },
+  EmergingTrendSignal: {
+    signalId: StringField, keyword: StringField, category: StringField,
+    label: StringField, score: NumberField, trend: NumberField,
+    source: StringField, fetchedAt: StringField, ttlMinutes: NumberField,
+    data: ObjectField,
+  },
+  EmergingTrendKeyword: {
+    keyword: StringField, text: StringField, normalizedText: StringField,
+    score: NumberField, weight: NumberField, platform: StringField,
+    growth: NumberField, rank: NumberField, fetchedAt: StringField,
+    lastSeenAt: StringField, cacheAgeMinutes: NumberField,
+    freshnessLevel: StringField, sourceStatus: StringField,
+    sourceLatencyMs: NumberField, sampleCount: NumberField,
+    confidence: NumberField, trendVelocity: NumberField,
+    ttlMinutes: NumberField, source: StringField, data: ObjectField,
+  },
+  EmergingOpportunity: {
+    opportunityId: StringField, title: StringField, score: NumberField,
+    gapIndex: NumberField, description: StringField, tags: ArrayField,
+    status: StringField, source: StringField, fetchedAt: StringField,
+    data: ObjectField,
+  },
+  EmergingSocialTrendSnapshot: {
+    snapshotId: StringField, status: StringField, fetchedAt: StringField,
+    nextRefreshAt: StringField, cacheAgeMinutes: NumberField,
+    ttlMinutes: NumberField, freshnessLevel: StringField,
+    sourceStatus: StringField, sourceLatencyMs: NumberField,
+    totalKeywords: NumberField, realtimeCount: NumberField,
+    freshCount: NumberField, staleCount: NumberField,
+    platformBreakdown: ObjectField, data: ObjectField,
+  },
+  KnowledgeCompetitor: {
+    asin: StringField, title: StringField, name: StringField,
+    brandName: StringField, category: StringField, price: NumberField,
+    rating: NumberField, reviewCount: NumberField, monthlySales: NumberField,
+    platform: StringField, source: StringField, data: ObjectField,
+  },
+  KnowledgeInfluencer: {
+    uniqueId: StringField, platform: StringField, name: StringField,
+    avatar: StringField, followers: NumberField, engagementRate: NumberField,
+    tier: StringField, mainCategory: StringField, cooperationPrice: NumberField,
+    source: StringField, data: ObjectField,
+  },
+  KnowledgeInfluencerProfile: {
+    uniqueId: StringField, platform: StringField, nickname: StringField,
+    avatar: StringField, signature: StringField, followerCount: NumberField,
+    heartCount: NumberField, videoCount: NumberField, followingCount: NumberField,
+    engagementRate: NumberField, cooperationScore: NumberField,
+    tags: ArrayField, data: ObjectField,
+  },
+  KnowledgeInfluencerPost: {
+    postId: StringField, uniqueId: StringField, authorUniqueId: StringField,
+    platform: StringField, description: StringField, coverUrl: StringField,
+    playCount: NumberField, likeCount: NumberField, commentCount: NumberField,
+    shareCount: NumberField, createTime: StringField, data: ObjectField,
+  },
+  KnowledgeKeyword: {
+    keyword: StringField, rank: NumberField, searchVolume: NumberField,
+    competition: StringField, source: StringField, data: ObjectField,
+  },
+  KeywordSummary: { keyword: StringField, rank: NumberField, data: ObjectField },
+  KnowledgePattern: {
+    patternId: StringField, name: StringField, category: StringField,
+    type: StringField, measurements: ObjectField, fitType: StringField,
+    sizeRange: ArrayField, userFeedback: ObjectField,
+    satisfactionScore: NumberField, problemRate: NumberField,
+    optimizationSuggestions: ArrayField, applicableProducts: ArrayField,
+    applicableScenarios: ArrayField, popularity: NumberField,
+    season: StringField, referenceImages: ArrayField, source: StringField,
+    updatedAt_custom: StringField, data: ObjectField,
+  },
+  KnowledgeHotProduct: {
+    asin: StringField, title: StringField, productName: StringField,
+    rank: NumberField, bsrRank: NumberField, category: StringField,
+    price: NumberField, rating: NumberField, reviewCount: NumberField,
+    monthlySales: NumberField, keyFeatures: ArrayField,
+    successFactors: ArrayField, source: StringField, data: ObjectField,
+  },
+  CompetitorCampaign: { name: StringField, status: StringField, asins: ArrayField, data: ObjectField },
+  TikTokSearchHistory: {
+    keyword: StringField, platform: StringField, region: StringField,
+    sortType: NumberField, publishTime: NumberField, videoCount: NumberField,
+    totalViews: NumberField, totalLikes: NumberField, totalComments: NumberField,
+    totalShares: NumberField, avgEngagement: NumberField,
+    searchTime: StringField, videosData: ArrayField, data: ObjectField,
+  },
+  MonitoringStoreDetail: {
+    shopId: StringField, name: StringField, totalSales: NumberField,
+    totalAmount: NumberField, avgPrice: NumberField, healthScore: NumberField,
+    rating: NumberField, returnRate: NumberField, positiveRate: NumberField,
+    vocVolume: NumberField, newArrivalCount: NumberField, data: ObjectField,
+  },
+  VocReviewAnnotation: {
+    annotationId: StringField, reviewKey: StringField, asin: StringField,
+    star: NumberField, reviewTitle: StringField, reviewDate: StringField,
+    annotationStatus: StringField, status: StringField, sentiment: StringField,
+    level1Category: StringField, level2Category: StringField,
+    severity: StringField, returnRelated: BooleanField, owner: StringField,
+    source: StringField, annotatedAt: StringField, data: ObjectField,
+  },
+  RegionReturnData: { region: StringField, returnRate: NumberField, data: ObjectField },
+  RealTimeVoc: { data: ObjectField },
+  MerchantListing: productFields,
+  SorftimeReviewProduct: {
+    recordKey: StringField, reviewId: StringField, asin: StringField,
+    marketplaceId: StringField, domain: NumberField, source: StringField,
+    capturedAt: DateField, shop: pointer('Shop'),
+  },
+  SpApiSellerAccount: {
+    recordKey: StringField, sellerId: StringField, marketplaceId: StringField,
+    businessType: StringField, region: StringField, capturedAt: DateField,
+    rawData: ObjectField, shop: pointer('Shop'),
+  },
+  SpApiMarketplaceParticipation: {
+    recordKey: StringField, marketplaceId: StringField, name: StringField,
+    countryCode: StringField, region: StringField, participation: ObjectField,
+    capturedAt: DateField, rawData: ObjectField, shop: pointer('Shop'),
+  },
+  AmazonInventorySummary: {
+    recordKey: StringField, marketplaceId: StringField, asin: StringField,
+    sellerSku: StringField, fnSku: StringField, condition: StringField,
+    productName: StringField, totalQuantity: NumberField, inventoryDetails: ObjectField,
+    lastUpdatedTime: DateField, capturedAt: DateField, rawData: ObjectField,
+    shop: pointer('Shop'),
+  },
+  AmazonCatalogItem: {
+    recordKey: StringField, marketplaceId: StringField, asin: StringField,
+    title: StringField, brand: StringField, productType: StringField,
+    attributes: ObjectField, dimensions: ArrayField, identifiers: ArrayField,
+    images: ArrayField, relationships: ArrayField, salesRanks: ArrayField,
+    summaries: ArrayField, capturedAt: DateField, rawData: ObjectField,
+    shop: pointer('Shop'),
+  },
+  AmazonProductPricing: {
+    recordKey: StringField, marketplaceId: StringField, asin: StringField,
+    sku: StringField, status: StringField, product: ObjectField,
+    capturedAt: DateField, rawData: ObjectField, shop: pointer('Shop'),
+  },
+  AmazonSalesMetric: {
+    recordKey: StringField, marketplaceId: StringField, interval: StringField,
+    granularity: StringField, unitCount: NumberField, orderItemCount: NumberField,
+    orderCount: NumberField, averageUnitPrice: ObjectField, totalSales: ObjectField,
+    capturedAt: DateField, rawData: ObjectField, shop: pointer('Shop'),
+  },
+  AmazonOrderItem: {
+    recordKey: StringField, marketplaceId: StringField, amazonOrderId: StringField,
+    orderItemId: StringField, asin: StringField, sellerSku: StringField,
+    title: StringField, quantityOrdered: NumberField, quantityShipped: NumberField,
+    itemPrice: ObjectField, itemTax: ObjectField, promotionDiscount: ObjectField,
+    promotionIds: ArrayField, capturedAt: DateField, rawData: ObjectField,
+    shop: pointer('Shop'), order: pointer('Order'),
+  },
+  AmazonFinancialEvent: {
+    recordKey: StringField, region: StringField, eventType: StringField,
+    amazonOrderId: StringField, sellerOrderId: StringField, marketplaceName: StringField,
+    postedDate: DateField, capturedAt: DateField, rawData: ObjectField,
+    shop: pointer('Shop'),
+  },
+  AmazonCustomerFeedback: {
+    recordKey: StringField, marketplaceId: StringField, feedbackType: StringField,
+    asin: StringField, browseNodeId: StringField, capturedAt: DateField,
+    rawData: ObjectField, shop: pointer('Shop'),
+  },
+  AmazonInboundShipment: {
+    recordKey: StringField, region: StringField, marketplaceId: StringField, shipmentId: StringField,
+    shipmentName: StringField, shipmentStatus: StringField, destinationFulfillmentCenterId: StringField,
+    labelPrepType: StringField, capturedAt: DateField, rawData: ObjectField,
+    shop: pointer('Shop'),
+  },
+  AmazonReportRow: {
+    recordKey: StringField, rowHash: StringField, reportType: StringField,
+    reportId: StringField, region: StringField, marketplaceIds: ArrayField,
+    capturedAt: DateField, rowData: ObjectField, rawData: ObjectField,
+    shop: pointer('Shop'),
+  },
+  SpApiSyncAudit: {
+    runId: StringField, dataType: StringField, endpoint: StringField,
+    status: StringField, region: StringField, marketplaceId: StringField,
+    records: NumberField, pages: NumberField, error: StringField,
+    startedAt: DateField, finishedAt: DateField, details: ObjectField,
+    shop: pointer('Shop'),
+  },
+};
+
+const classNames = Object.keys(schemaDefinitions);
+
+const current = await request('/schemas');
+const currentByName = new Map((current.results || []).map(item => [item.className, item]));
+const existing = new Set(currentByName.keys());
+const adminOnly = { 'role:admin': true };
+let created = 0;
+
+for (const className of classNames) {
+  if (!existing.has(className)) {
+    await request('/schemas', 'POST', { className, fields: {} });
+    created += 1;
+  }
+  const knownFields = currentByName.get(className)?.fields || {};
+  const fields = Object.fromEntries(
+    Object.entries(schemaDefinitions[className] || {}).filter(([name]) => !knownFields[name])
+  );
+  await request(`/schemas/${encodeURIComponent(className)}`, 'PUT', {
+    ...(Object.keys(fields).length ? { fields } : {}),
+    classLevelPermissions: {
+      find: adminOnly,
+      count: adminOnly,
+      get: adminOnly,
+      create: adminOnly,
+      update: adminOnly,
+      delete: adminOnly,
+      addField: {},
+    },
+  });
+}
+
+console.log(JSON.stringify({ created, secured: classNames.length }));

+ 138 - 0
deploy/bootstrap-shops.mjs

@@ -0,0 +1,138 @@
+import fs from 'node:fs';
+
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+const sellerId = process.env.AMAZON_SELLER_ID;
+const credentialFile = process.env.SP_API_CREDENTIAL_FILE;
+
+if (!appId || !masterKey || !sellerId || !credentialFile) {
+  throw new Error('Missing bootstrap environment variables');
+}
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function request(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const result = await response.json();
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error || response.status}`);
+  }
+  return result;
+}
+
+const text = fs.readFileSync(credentialFile, 'utf8');
+const clientId = text.match(/客户端编码:\s*(\S+)/)?.[1];
+const clientSecret = text.match(/客户端秘钥:\s*(\S+)/)?.[1];
+const refreshTokens = [...text.matchAll(/Atzr\|\S+/g)].map(match => match[0]);
+
+if (!clientId || !clientSecret || refreshTokens.length !== 6) {
+  throw new Error('SP-API credential file is incomplete');
+}
+
+const marketplaces = [
+  [
+    ['美国', 'ATVPDKIKX0DER', '1', 'NA', 'us-east-1'],
+    ['加拿大', 'A2EUQ1WTGCTBG2', '6', 'NA', 'us-east-1'],
+    ['墨西哥', 'A1AM78C64UM0Y8', '10', 'NA', 'us-east-1'],
+    ['巴西', 'A2Q3Y263D00KWC', '13', 'NA', 'us-east-1'],
+  ],
+  [['新加坡', 'A19VAU5U5O7RUS', '', 'FE', 'us-west-2']],
+  [
+    ['意大利', 'APJ6JRA9NG5V4', '9', 'EU', 'eu-west-1'],
+    ['法国', 'A13V1IB3VIYZZH', '4', 'EU', 'eu-west-1'],
+    ['爱尔兰', 'A28R8C7NBKEWEA', '', 'EU', 'eu-west-1'],
+    ['西班牙', 'A1RKKUPIHCS9HS', '8', 'EU', 'eu-west-1'],
+    ['波兰', 'A1C3SOZRARQ6R3', '', 'EU', 'eu-west-1'],
+    ['荷兰', 'A1805IZSGTT6HS', '', 'EU', 'eu-west-1'],
+    ['比利时', 'AMEN7PMS3EDWL', '', 'EU', 'eu-west-1'],
+    ['瑞典', 'A2NODRKZP88ZB9', '', 'EU', 'eu-west-1'],
+    ['德国', 'A1PA6795UKMFR9', '3', 'EU', 'eu-west-1'],
+    ['英国', 'A1F83G8C2ARO7P', '2', 'EU', 'eu-west-1'],
+  ],
+  [['阿联酋', 'A2VIGQ35RCS4UG', '11', 'EU', 'eu-west-1']],
+  [['沙特阿拉伯', 'A17E79C6D8DWNP', '14', 'EU', 'eu-west-1']],
+  [['澳大利亚', 'A39IBJ37TRP1C6', '12', 'FE', 'us-west-2']],
+];
+
+let created = 0;
+let updated = 0;
+for (let groupIndex = 0; groupIndex < marketplaces.length; groupIndex += 1) {
+  for (const [country, marketplaceId, domain, region, spRegion] of marketplaces[groupIndex]) {
+    const where = encodeURIComponent(JSON.stringify({ marketplaceId }));
+    const existing = await request(`/classes/Shop?where=${where}&limit=1`);
+    const payload = {
+      name: `${country} - zzhhxcl`,
+      platform: 'amazon',
+      region,
+      marketplaceId,
+      domain,
+      nodeIds: [],
+      config: {
+        SpApiConfig: {
+          clientId,
+          sellerID: sellerId,
+          clientSecret,
+          refreshToken: refreshTokens[groupIndex],
+          region: spRegion,
+          sandbox: false,
+        },
+      },
+      sync_status: 'idle',
+      status: 'active',
+    };
+
+    if (existing.results?.[0]?.objectId) {
+      await request(`/classes/Shop/${existing.results[0].objectId}`, 'PUT', payload);
+      updated += 1;
+    } else {
+      await request('/classes/Shop', 'POST', payload);
+      created += 1;
+    }
+    console.log(`Shop ready: ${country} (${marketplaceId})`);
+  }
+}
+
+const adminUsers = await request(`/users?where=${encodeURIComponent(JSON.stringify({ username: 'admin' }))}&limit=1`);
+const adminUser = adminUsers.results?.[0];
+if (!adminUser) throw new Error('Initial admin user was not found');
+
+const roles = await request(`/roles?where=${encodeURIComponent(JSON.stringify({ name: 'admin' }))}&limit=1`);
+let roleId = roles.results?.[0]?.objectId;
+if (!roleId) {
+  const role = await request('/roles', 'POST', {
+    name: 'admin',
+    ACL: { '*': { read: false, write: false } },
+  });
+  roleId = role.objectId;
+}
+
+await request(`/roles/${roleId}`, 'PUT', {
+  users: {
+    __op: 'AddRelation',
+    objects: [{ __type: 'Pointer', className: '_User', objectId: adminUser.objectId }],
+  },
+});
+
+const adminOnly = { 'role:admin': true };
+await request('/schemas/Shop', 'PUT', {
+  classLevelPermissions: {
+    find: adminOnly,
+    count: adminOnly,
+    get: adminOnly,
+    create: adminOnly,
+    update: adminOnly,
+    delete: adminOnly,
+    addField: {},
+  },
+});
+
+console.log(JSON.stringify({ created, updated, total: created + updated, adminRole: roleId }));

+ 289 - 0
deploy/collect-sorftime-reviews-year.mjs

@@ -0,0 +1,289 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+
+const configPath = process.env.VOC_CONFIG || '/etc/jianhen-voc/config.json';
+const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const relayUrl = process.env.SORFTIME_URL || 'http://127.0.0.1:3000/api/sorftime/forward';
+const appId = config.parse.appId;
+const masterKey = config.parse.masterKey;
+const maxRowsPerResponse = 10;
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const shopPointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId });
+const isoDate = date => date.toISOString().slice(0, 10);
+
+function log(event, data = {}) {
+  console.log(JSON.stringify({ at: new Date().toISOString(), event, ...data }));
+}
+
+function hash(value) {
+  return crypto.createHash('sha256').update(value).digest('hex');
+}
+
+async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 400) || response.status}`);
+  }
+  return result;
+}
+
+async function allRows(className, keys = '') {
+  const rows = [];
+  let skip = 0;
+  while (true) {
+    const page = await parseRequest(`/classes/${className}?limit=1000&skip=${skip}${keys ? `&keys=${keys}` : ''}`);
+    rows.push(...(page.results || []));
+    const length = page.results?.length || 0;
+    if (length < 1000) return rows;
+    skip += length;
+  }
+}
+
+function unwrap(result) {
+  let data = result?.data ?? result?.Data ?? result;
+  while (data && typeof data === 'object' && !Array.isArray(data)
+    && (data.data !== undefined || data.Data !== undefined)) {
+    data = data.data ?? data.Data;
+  }
+  return data;
+}
+
+async function queryReviews({ asin, domain, start, end, star = '1,2,3,4,5' }) {
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const response = await fetch(relayUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          path: '/api/ProductReviewsQuery',
+          method: 'POST',
+          query: { domain },
+          body: {
+            ASIN: asin,
+            PageIndex: 1,
+            PageSize: 100,
+            OnlyPurchase: 0,
+            Star: star,
+            QueryStartDt: start,
+            QueryEndDt: end,
+          },
+        }),
+        signal: AbortSignal.timeout(90000),
+      });
+      const result = await response.json();
+      if (!response.ok || result?.success === false) {
+        throw new Error(result?.message || result?.Message || `Relay HTTP ${response.status}`);
+      }
+      const data = unwrap(result);
+      return data?.Reviews || data?.Items || data?.List || (Array.isArray(data) ? data : []);
+    } catch (error) {
+      if (attempt === 4) throw error;
+      await sleep(attempt * 3000);
+    }
+  }
+}
+
+function reviewIdOf(review, target) {
+  const id = String(review.ReviewId || review.Id || review.ID || review.id || '').trim();
+  if (id) return id;
+  const identity = [
+    target.domain,
+    target.asin,
+    review.ReviewsDate || review.ReviewDate || review.Date || '',
+    review.ProfileName || review.Author || '',
+    review.Content || review.ReviewContent || review.Body || '',
+  ].join('|');
+  return `derived-${hash(identity)}`;
+}
+
+function reviewDateOf(review) {
+  return String(review.ReviewsDate || review.ReviewDate || review.Date || '').trim();
+}
+
+function parseReviewDate(value) {
+  if (!value) return undefined;
+  const normalized = /^\d{8}$/.test(value)
+    ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
+    : value;
+  const date = new Date(normalized);
+  return Number.isNaN(date.getTime()) ? undefined : { __type: 'Date', iso: date.toISOString() };
+}
+
+async function collectStarBuckets(target, start, end, resultMap, stats) {
+  for (const star of ['1', '2', '3', '4', '5']) {
+    const starReviews = await queryReviews({ asin: target.asin, domain: target.domain, start, end, star });
+    stats.requests++;
+    for (const review of starReviews) resultMap.set(reviewIdOf(review, target), review);
+    log('review_star_bucket', { asin: target.asin, domain: target.domain, start, end, star, rows: starReviews.length });
+    if (starReviews.length >= maxRowsPerResponse) {
+      stats.unresolvedSaturatedBuckets.push({ asin: target.asin, domain: target.domain, start, end, star });
+    }
+  }
+}
+
+async function saveBatch(requests) {
+  for (let start = 0; start < requests.length; start += 25) {
+    const result = await parseRequest('/batch', 'POST', { requests: requests.slice(start, start + 25) });
+    const failure = result.find?.(item => item.error)?.error;
+    if (failure) throw new Error(failure.error || failure.code || 'Parse batch failed');
+  }
+}
+
+const [products, listings, shops, existingReviews] = await Promise.all([
+  allRows('Product'),
+  allRows('Listing'),
+  allRows('Shop'),
+  allRows('SorftimeReviews', 'objectId,recordKey,reviewId,asin,domain,shop'),
+]);
+const shopById = new Map(shops.map(shop => [shop.objectId, shop]));
+const existingByReviewId = new Map();
+for (const review of existingReviews) {
+  if (review.reviewId && !existingByReviewId.has(review.reviewId)) {
+    existingByReviewId.set(review.reviewId, review);
+  }
+}
+
+const targets = new Map();
+for (const row of [...products, ...listings]) {
+  const asin = String(row.asin || row.Asin || '').trim().toUpperCase();
+  const shop = shopById.get(row.shop?.objectId);
+  if (!asin || !shop) continue;
+  const domain = Number(row.domain || shop.domain || 1);
+  const marketplaceId = String(row.marketplaceId || shop.marketplaceId || '');
+  const key = `${domain}:${asin}`;
+  if (!targets.has(key)) targets.set(key, { asin, domain, marketplaceId, shopId: shop.objectId, shopName: shop.name || '' });
+}
+
+const endDate = isoDate(new Date());
+const start = new Date();
+start.setUTCFullYear(start.getUTCFullYear() - 1);
+const startDate = isoDate(start);
+const runStats = {
+  startedAt: new Date().toISOString(), startDate, endDate, targets: targets.size,
+  requests: 0, fetchedUnique: 0, associations: 0, withContent: 0, inserted: 0, updated: 0,
+  failures: [], unresolvedSaturatedBuckets: [], byTarget: [],
+};
+const globalReviews = new Map();
+const associations = new Map();
+
+log('review_collection_started', { targets: targets.size, startDate, endDate });
+
+for (const target of targets.values()) {
+  const collected = new Map();
+  const targetStats = { requests: 0, unresolvedSaturatedBuckets: [] };
+  try {
+    await collectStarBuckets(target, startDate, endDate, collected, targetStats);
+    let withContent = 0;
+    for (const [reviewId, review] of collected) {
+      const content = String(review.Content || review.ReviewContent || review.Body || '');
+      if (content.trim()) withContent++;
+      if (!globalReviews.has(reviewId)) {
+        globalReviews.set(reviewId, {
+          review,
+          asins: new Set(),
+          marketplaceIds: new Set(),
+          domains: new Set(),
+          shopIds: new Set(),
+          targets: [],
+        });
+      }
+      const global = globalReviews.get(reviewId);
+      global.asins.add(target.asin);
+      if (target.marketplaceId) global.marketplaceIds.add(target.marketplaceId);
+      global.domains.add(target.domain);
+      global.shopIds.add(target.shopId);
+      global.targets.push(target);
+      const associationKey = `${target.domain}:${target.asin}:${reviewId}`;
+      associations.set(associationKey, {
+        recordKey: associationKey,
+        reviewId,
+        asin: target.asin,
+        marketplaceId: target.marketplaceId,
+        domain: target.domain,
+        source: 'sorftime-product-reviews-year',
+        capturedAt: { __type: 'Date', iso: new Date().toISOString() },
+        shop: shopPointer(target.shopId),
+      });
+    }
+    runStats.requests += targetStats.requests;
+    runStats.unresolvedSaturatedBuckets.push(...targetStats.unresolvedSaturatedBuckets);
+    runStats.byTarget.push({ ...target, reviews: collected.size, withContent, requests: targetStats.requests });
+    log('review_target_completed', { ...target, reviews: collected.size, withContent, requests: targetStats.requests });
+  } catch (error) {
+    runStats.failures.push({ ...target, error: error.message });
+    log('review_target_failed', { ...target, error: error.message });
+  }
+  await sleep(500);
+}
+
+const capturedAt = { __type: 'Date', iso: new Date().toISOString() };
+const reviewRequests = [];
+for (const [reviewId, global] of globalReviews) {
+  const existing = existingByReviewId.get(reviewId);
+  const targetsForReview = global.targets.slice().sort((a, b) => `${a.domain}:${a.asin}`.localeCompare(`${b.domain}:${b.asin}`));
+  const primary = targetsForReview.find(target => target.asin === existing?.asin) || targetsForReview[0];
+  const review = global.review;
+  const content = String(review.Content || review.ReviewContent || review.Body || '');
+  reviewRequests.push({
+    method: existing ? 'PUT' : 'POST',
+    path: existing ? `/parse/classes/SorftimeReviews/${existing.objectId}` : '/parse/classes/SorftimeReviews',
+    body: {
+      recordKey: reviewId,
+      reviewId,
+      asin: primary.asin,
+      asins: [...global.asins].sort(),
+      marketplaceId: primary.marketplaceId,
+      marketplaceIds: [...global.marketplaceIds].sort(),
+      domain: primary.domain,
+      domains: [...global.domains].sort((a, b) => a - b),
+      shopIds: [...global.shopIds].sort(),
+      source: 'sorftime-product-reviews-year',
+      star: Number(review.Star || review.Rating || 0),
+      rating: Number(review.Star || review.Rating || 0),
+      title: review.Title || review.ReviewTitle || '',
+      content,
+      reviewDate: reviewDateOf(review),
+      reviewPublishedAt: parseReviewDate(reviewDateOf(review)),
+      author: review.ProfileName || review.Author || '',
+      verified: Boolean(review.OnlyPurchase || review.VerifiedPurchase),
+      capturedAt,
+      shop: shopPointer(primary.shopId),
+      rawData: review,
+    },
+  });
+  if (existing) runStats.updated++; else runStats.inserted++;
+  if (content.trim()) runStats.withContent++;
+}
+await saveBatch(reviewRequests);
+
+const existingAssociations = await allRows('SorftimeReviewProduct', 'objectId,recordKey');
+const associationByKey = new Map(existingAssociations.filter(row => row.recordKey).map(row => [row.recordKey, row.objectId]));
+const associationRequests = [...associations.values()].map(body => {
+  const objectId = associationByKey.get(body.recordKey);
+  return {
+    method: objectId ? 'PUT' : 'POST',
+    path: objectId ? `/parse/classes/SorftimeReviewProduct/${objectId}` : '/parse/classes/SorftimeReviewProduct',
+    body,
+  };
+});
+await saveBatch(associationRequests);
+
+runStats.fetchedUnique = globalReviews.size;
+runStats.associations = associations.size;
+
+runStats.finishedAt = new Date().toISOString();
+console.log(JSON.stringify({ event: 'review_collection_finished', ...runStats }));

+ 304 - 0
deploy/collect-sp-api-core.mjs

@@ -0,0 +1,304 @@
+import {
+  activeAmazonShops, allRows, cleanObject, dateValue, forward, hash, log,
+  orderPointer, regionByMarketplace, runId, shopPointer, sleep, updateObject,
+  upsertMany, writeAudit,
+} from './sp-api-collector-lib.mjs';
+
+const now = new Date();
+const capturedAt = dateValue(now);
+const shops = await activeAmazonShops();
+const orders = await allRows('Order', 'objectId,platformOrderId,orderId,orderDate,itemsSyncedAt,shop');
+const listings = await allRows('Listing', 'objectId,sku,asin,marketplaceId,shop');
+const products = await allRows('Product', 'objectId,asin,marketplaceId,shop');
+
+const shopsByRegion = new Map();
+for (const shop of shops) {
+  const region = regionByMarketplace[shop.marketplaceId] || shop.config?.SpApiConfig?.region || 'UNKNOWN';
+  if (!shopsByRegion.has(region)) shopsByRegion.set(region, shop);
+}
+
+async function runTask({ dataType, endpoint, shop, execute }) {
+  const startedAt = new Date();
+  const region = regionByMarketplace[shop.marketplaceId] || 'UNKNOWN';
+  try {
+    const result = await execute(region);
+    await writeAudit({
+      dataType, endpoint, status: 'completed', shop, region,
+      marketplaceId: shop.marketplaceId, records: result.records || 0,
+      pages: result.pages || 1, startedAt, details: result,
+    });
+    log('task_completed', { dataType, endpoint, shop: shop.name, region, ...result });
+    return result;
+  } catch (error) {
+    await writeAudit({
+      dataType, endpoint, status: 'failed', shop, region,
+      marketplaceId: shop.marketplaceId, error: error.message, startedAt,
+    });
+    log('task_failed', { dataType, endpoint, shop: shop.name, region, error: error.message });
+    return { records: 0, pages: 0, error: error.message };
+  }
+}
+
+for (const shop of shops) {
+  const region = regionByMarketplace[shop.marketplaceId] || 'UNKNOWN';
+  const accountResult = await runTask({
+    dataType: 'seller_account', endpoint: '/sellers/v1/account', shop,
+    execute: async () => {
+      const result = await forward(shop.objectId, '/sellers/v1/account');
+      const payload = result.payload || result;
+      if (payload.errors?.length) throw new Error(payload.errors[0].message || payload.errors[0].code || 'Seller account query failed');
+      const sellerId = payload.sellerId || shop.config?.SpApiConfig?.sellerID || '';
+      await upsertMany('SpApiSellerAccount', [{
+        recordKey: `${sellerId || shop.objectId}:${region}`,
+        sellerId,
+        marketplaceId: shop.marketplaceId,
+        businessType: payload.businessType || '',
+        region,
+        capturedAt,
+        rawData: payload,
+        shop: shopPointer(shop.objectId),
+      }]);
+      return { records: 1, pages: 1, sellerId };
+    },
+  });
+
+  await runTask({
+    dataType: 'marketplace_participations', endpoint: '/sellers/v1/marketplaceParticipations', shop,
+    execute: async () => {
+      const result = await forward(shop.objectId, '/sellers/v1/marketplaceParticipations');
+      const payload = result.payload || result;
+      const participations = Array.isArray(payload) ? payload : (payload.marketplaceParticipations || []);
+      const sellerId = accountResult.sellerId || shop.config?.SpApiConfig?.sellerID || `${region}:${shop.objectId}`;
+      const rows = participations.map(item => ({
+        recordKey: `${sellerId}:${item.marketplace?.id || ''}`,
+        marketplaceId: item.marketplace?.id || '',
+        name: item.marketplace?.name || '',
+        countryCode: item.marketplace?.countryCode || '',
+        region,
+        participation: item.participation || {},
+        capturedAt,
+        rawData: item,
+        shop: shopPointer(shop.objectId),
+      }));
+      await upsertMany('SpApiMarketplaceParticipation', rows);
+      return { records: rows.length, pages: 1 };
+    },
+  });
+}
+
+for (const shop of shops) {
+  await runTask({
+    dataType: 'fba_inventory', endpoint: '/fba/inventory/v1/summaries', shop,
+    execute: async () => {
+      let token = '';
+      let pages = 0;
+      let records = 0;
+      const seen = new Set();
+      do {
+        pages++;
+        const path = token
+          ? `/fba/inventory/v1/summaries?nextToken=${encodeURIComponent(token)}`
+          : `/fba/inventory/v1/summaries?granularityType=Marketplace&granularityId=${encodeURIComponent(shop.marketplaceId)}&marketplaceIds=${encodeURIComponent(shop.marketplaceId)}&details=true`;
+        const result = await forward(shop.objectId, path, 'GET', undefined, 2);
+        const payload = result.payload || result;
+        const rows = (payload.inventorySummaries || []).map(item => ({
+          recordKey: `${shop.objectId}:${item.sellerSku || item.fnSku || item.asin || hash(item)}`,
+          marketplaceId: shop.marketplaceId,
+          asin: item.asin || '',
+          sellerSku: item.sellerSku || '',
+          fnSku: item.fnSku || '',
+          condition: item.condition || '',
+          productName: item.productName || '',
+          totalQuantity: Number(item.totalQuantity || 0),
+          inventoryDetails: item.inventoryDetails || {},
+          lastUpdatedTime: dateValue(item.lastUpdatedTime),
+          capturedAt,
+          rawData: item,
+          shop: shopPointer(shop.objectId),
+        }));
+        await upsertMany('AmazonInventorySummary', rows);
+        records += rows.length;
+        token = String(result.pagination?.nextToken || payload.nextToken || '').trim();
+        if (token) {
+          if (seen.has(token)) throw new Error('Repeated inventory nextToken');
+          seen.add(token);
+          await sleep(1200);
+        }
+      } while (token);
+      return { records, pages };
+    },
+  });
+
+  await runTask({
+    dataType: 'sales_metrics', endpoint: '/sales/v1/orderMetrics', shop,
+    execute: async () => {
+      const start = new Date(now.getTime() - 729 * 24 * 60 * 60 * 1000).toISOString();
+      const path = `/sales/v1/orderMetrics?marketplaceIds=${encodeURIComponent(shop.marketplaceId)}`
+        + `&interval=${encodeURIComponent(`${start}--${now.toISOString()}`)}&granularity=Day`;
+      const result = await forward(shop.objectId, path, 'GET', undefined, 2);
+      const metrics = result.payload || result;
+      const rows = (Array.isArray(metrics) ? metrics : []).map(metric => ({
+        recordKey: `${shop.objectId}:${metric.interval || hash(metric)}`,
+        marketplaceId: shop.marketplaceId,
+        interval: metric.interval || '',
+        granularity: 'Day',
+        unitCount: Number(metric.unitCount || 0),
+        orderItemCount: Number(metric.orderItemCount || 0),
+        orderCount: Number(metric.orderCount || 0),
+        averageUnitPrice: metric.averageUnitPrice || {},
+        totalSales: metric.totalSales || {},
+        capturedAt,
+        rawData: metric,
+        shop: shopPointer(shop.objectId),
+      }));
+      await upsertMany('AmazonSalesMetric', rows);
+      return { records: rows.length, pages: 1 };
+    },
+  });
+}
+
+const catalogTargets = new Map();
+for (const row of [...listings, ...products]) {
+  const asin = String(row.asin || '').trim().toUpperCase();
+  const shop = shops.find(item => item.objectId === row.shop?.objectId);
+  if (!asin || !shop) continue;
+  catalogTargets.set(`${shop.objectId}:${asin}`, { asin, shop });
+}
+
+for (const { asin, shop } of catalogTargets.values()) {
+  await runTask({
+    dataType: 'catalog_item', endpoint: '/catalog/2022-04-01/items/{asin}', shop,
+    execute: async () => {
+      const included = 'attributes,dimensions,identifiers,images,productTypes,relationships,salesRanks,summaries';
+      const result = await forward(shop.objectId,
+        `/catalog/2022-04-01/items/${encodeURIComponent(asin)}?marketplaceIds=${encodeURIComponent(shop.marketplaceId)}&includedData=${included}`,
+        'GET', undefined, 2);
+      const summary = result.summaries?.[0] || {};
+      const row = {
+        recordKey: `${shop.objectId}:${asin}`,
+        marketplaceId: shop.marketplaceId,
+        asin,
+        title: summary.itemName || '',
+        brand: summary.brand || '',
+        productType: result.productTypes?.[0]?.productType || '',
+        attributes: result.attributes || {},
+        dimensions: result.dimensions || [],
+        identifiers: result.identifiers || [],
+        images: result.images || [],
+        relationships: result.relationships || [],
+        salesRanks: result.salesRanks || [],
+        summaries: result.summaries || [],
+        capturedAt,
+        rawData: result,
+        shop: shopPointer(shop.objectId),
+      };
+      await upsertMany('AmazonCatalogItem', [row]);
+      return { records: 1, pages: 1 };
+    },
+  });
+  await sleep(500);
+}
+
+for (const shop of shops) {
+  const asins = [...new Set(listings
+    .filter(item => item.shop?.objectId === shop.objectId && item.asin)
+    .map(item => String(item.asin).trim().toUpperCase()))];
+  for (let start = 0; start < asins.length; start += 20) {
+    const batch = asins.slice(start, start + 20);
+    await runTask({
+      dataType: 'product_pricing', endpoint: '/products/pricing/v0/price', shop,
+      execute: async () => {
+        const result = await forward(shop.objectId,
+          `/products/pricing/v0/price?MarketplaceId=${encodeURIComponent(shop.marketplaceId)}&ItemType=Asin&Asins=${batch.join(',')}`,
+          'GET', undefined, 2);
+        const payload = result.payload || result;
+        const rows = (Array.isArray(payload) ? payload : []).map(item => ({
+          recordKey: `${shop.objectId}:${item.ASIN || item.Sku || hash(item)}`,
+          marketplaceId: shop.marketplaceId,
+          asin: item.ASIN || '',
+          sku: item.Sku || '',
+          status: item.status || '',
+          product: item.Product || {},
+          capturedAt,
+          rawData: item,
+          shop: shopPointer(shop.objectId),
+        }));
+        await upsertMany('AmazonProductPricing', rows);
+        return { records: rows.length, pages: 1, batch: start / 20 + 1 };
+      },
+    });
+    await sleep(1200);
+  }
+}
+
+const ordersByShop = new Map();
+for (const order of orders) {
+  if (!order.shop?.objectId || !order.platformOrderId) continue;
+  if (!ordersByShop.has(order.shop.objectId)) ordersByShop.set(order.shop.objectId, []);
+  ordersByShop.get(order.shop.objectId).push(order);
+}
+
+for (const shop of shops) {
+  const shopOrders = (ordersByShop.get(shop.objectId) || []).filter(order => !order.itemsSyncedAt);
+  let processed = 0;
+  let failures = 0;
+  const startedAt = new Date();
+  for (const order of shopOrders) {
+    try {
+      let token = '';
+      const allItems = [];
+      do {
+        const path = `/orders/v0/orders/${encodeURIComponent(order.platformOrderId)}/orderItems`
+          + (token ? `?NextToken=${encodeURIComponent(token)}` : '');
+        const result = await forward(shop.objectId, path, 'GET', undefined, 3);
+        const payload = result.payload || result;
+        allItems.push(...(payload.OrderItems || []));
+        token = String(payload.NextToken || '').trim();
+        if (token) await sleep(500);
+      } while (token);
+      const rows = allItems.map(item => ({
+        recordKey: `${shop.objectId}:${item.OrderItemId}`,
+        marketplaceId: shop.marketplaceId,
+        amazonOrderId: order.platformOrderId,
+        orderItemId: item.OrderItemId || '',
+        asin: item.ASIN || '',
+        sellerSku: item.SellerSKU || '',
+        title: item.Title || '',
+        quantityOrdered: Number(item.QuantityOrdered || 0),
+        quantityShipped: Number(item.QuantityShipped || 0),
+        itemPrice: item.ItemPrice || {},
+        itemTax: item.ItemTax || {},
+        promotionDiscount: item.PromotionDiscount || {},
+        promotionIds: item.PromotionIds || [],
+        capturedAt,
+        rawData: item,
+        shop: shopPointer(shop.objectId),
+        order: orderPointer(order.objectId),
+      }));
+      await upsertMany('AmazonOrderItem', rows);
+      await updateObject('Order', order.objectId, { itemCount: rows.length, itemsSyncedAt: capturedAt });
+      processed += rows.length;
+    } catch (error) {
+      failures++;
+      log('order_items_failed', { shop: shop.name, orderId: order.platformOrderId, error: error.message });
+    }
+    if ((processed + failures) % 50 === 0) log('order_items_progress', { shop: shop.name, processed, failures });
+    await sleep(650);
+  }
+  await writeAudit({
+    dataType: 'order_items', endpoint: '/orders/v0/orders/{orderId}/orderItems',
+    status: failures ? 'partial' : 'completed', shop,
+    region: regionByMarketplace[shop.marketplaceId] || 'UNKNOWN', marketplaceId: shop.marketplaceId,
+    records: processed, pages: shopOrders.length, error: failures ? `${failures} orders failed` : '',
+    startedAt, details: { orders: shopOrders.length, failures },
+  });
+  log('order_items_completed', { shop: shop.name, orders: shopOrders.length, records: processed, failures });
+}
+
+log('core_collection_finished', {
+  runId,
+  shops: shops.length,
+  regions: [...shopsByRegion.keys()],
+  catalogTargets: catalogTargets.size,
+  orders: orders.length,
+});

+ 118 - 0
deploy/collect-sp-api-feedback-inbound.mjs

@@ -0,0 +1,118 @@
+import {
+  activeAmazonShops, allRows, dateValue, forward, hash, log, regionByMarketplace,
+  shopPointer, sleep, upsertMany, writeAudit,
+} from './sp-api-collector-lib.mjs';
+
+const shops = await activeAmazonShops();
+const listings = await allRows('Listing', 'asin,shop');
+const capturedAt = dateValue(new Date());
+
+for (const shop of shops) {
+  const asins = [...new Set(listings
+    .filter(item => item.shop?.objectId === shop.objectId && item.asin)
+    .map(item => String(item.asin).trim().toUpperCase()))];
+  let records = 0;
+  let emptyResponses = 0;
+  let failures = 0;
+  const startedAt = new Date();
+
+  for (const asin of asins) {
+    const endpoints = [
+      ['item_browse_node', `/customerFeedback/2024-06-01/items/${asin}/browseNode?marketplaceId=${shop.marketplaceId}`],
+      ['item_review_topics', `/customerFeedback/2024-06-01/items/${asin}/reviews/topics?marketplaceId=${shop.marketplaceId}&sortBy=MENTIONS&sortOrder=DESC`],
+      ['item_review_trends', `/customerFeedback/2024-06-01/items/${asin}/reviews/trends?marketplaceId=${shop.marketplaceId}`],
+    ];
+    for (const [feedbackType, endpoint] of endpoints) {
+      try {
+        const result = await forward(shop.objectId, endpoint, 'GET', undefined, 2);
+        if (!result || Object.keys(result).length === 0) {
+          emptyResponses++;
+          continue;
+        }
+        const browseNodeId = String(result.browseNodeId || result.browseNode?.id || '');
+        await upsertMany('AmazonCustomerFeedback', [{
+          recordKey: `${shop.objectId}:${asin}:${feedbackType}`,
+          marketplaceId: shop.marketplaceId,
+          feedbackType,
+          asin,
+          browseNodeId,
+          capturedAt,
+          rawData: result,
+          shop: shopPointer(shop.objectId),
+        }]);
+        records++;
+      } catch (error) {
+        failures++;
+        log('feedback_failed', { shop: shop.name, asin, feedbackType, error: error.message });
+      }
+      await sleep(500);
+    }
+  }
+
+  await writeAudit({
+    dataType: 'customer_feedback', endpoint: '/customerFeedback/2024-06-01',
+    status: failures ? 'partial' : 'completed', shop,
+    region: regionByMarketplace[shop.marketplaceId] || 'UNKNOWN', marketplaceId: shop.marketplaceId,
+    records, pages: asins.length * 3, error: failures ? `${failures} calls failed` : '',
+    startedAt, details: { asins: asins.length, emptyResponses, failures },
+  });
+  log('feedback_completed', { shop: shop.name, asins: asins.length, records, emptyResponses, failures });
+}
+
+for (const shop of shops) {
+  const startedAt = new Date();
+  const statuses = ['WORKING', 'SHIPPED', 'RECEIVING', 'CLOSED', 'CANCELLED', 'DELETED', 'ERROR', 'IN_TRANSIT', 'DELIVERED', 'CHECKED_IN'];
+  const after = new Date(Date.now() - 729 * 24 * 60 * 60 * 1000).toISOString();
+  const before = new Date().toISOString();
+  let token = '';
+  let pages = 0;
+  let records = 0;
+  const seenTokens = new Set();
+  try {
+    do {
+      pages++;
+      const path = token
+        ? `/fba/inbound/v0/shipments?QueryType=NEXT_TOKEN&MarketplaceId=${shop.marketplaceId}&NextToken=${encodeURIComponent(token)}`
+        : `/fba/inbound/v0/shipments?QueryType=DATE_RANGE&MarketplaceId=${shop.marketplaceId}`
+          + `&ShipmentStatusList=${statuses.join(',')}`
+          + `&LastUpdatedAfter=${encodeURIComponent(after)}&LastUpdatedBefore=${encodeURIComponent(before)}`;
+      const result = await forward(shop.objectId, path, 'GET', undefined, 2);
+      const payload = result.payload || result;
+      const shipments = payload.ShipmentData || payload.shipments || [];
+      const rows = shipments.map(item => ({
+        recordKey: `${regionByMarketplace[shop.marketplaceId] || 'UNKNOWN'}:${item.ShipmentId || hash(item)}`,
+        region: regionByMarketplace[shop.marketplaceId] || 'UNKNOWN',
+        marketplaceId: shop.marketplaceId,
+        shipmentId: item.ShipmentId || '',
+        shipmentName: item.ShipmentName || '',
+        shipmentStatus: item.ShipmentStatus || '',
+        destinationFulfillmentCenterId: item.DestinationFulfillmentCenterId || '',
+        labelPrepType: item.LabelPrepType || '',
+        capturedAt,
+        rawData: item,
+        shop: shopPointer(shop.objectId),
+      }));
+      await upsertMany('AmazonInboundShipment', rows);
+      records += rows.length;
+      token = String(payload.NextToken || '').trim();
+      if (token) {
+        if (seenTokens.has(token)) throw new Error('Repeated inbound NextToken');
+        seenTokens.add(token);
+        await sleep(1200);
+      }
+    } while (token);
+    await writeAudit({
+      dataType: 'inbound_shipments', endpoint: '/fba/inbound/v0/shipments',
+      status: 'completed', shop, region: regionByMarketplace[shop.marketplaceId] || 'UNKNOWN',
+      marketplaceId: shop.marketplaceId, records, pages, startedAt, details: { after, before },
+    });
+    log('inbound_completed', { shop: shop.name, records, pages });
+  } catch (error) {
+    await writeAudit({
+      dataType: 'inbound_shipments', endpoint: '/fba/inbound/v0/shipments',
+      status: records ? 'partial' : 'failed', shop, region: regionByMarketplace[shop.marketplaceId] || 'UNKNOWN',
+      marketplaceId: shop.marketplaceId, records, pages, error: error.message, startedAt,
+    });
+    log('inbound_failed', { shop: shop.name, records, pages, error: error.message });
+  }
+}

+ 79 - 0
deploy/collect-sp-api-finances.mjs

@@ -0,0 +1,79 @@
+import {
+  activeAmazonShops, dateValue, forward, hash, log, regionByMarketplace,
+  shopPointer, sleep, upsertMany, writeAudit,
+} from './sp-api-collector-lib.mjs';
+
+const shops = await activeAmazonShops();
+const regionShops = new Map();
+for (const shop of shops) {
+  const region = regionByMarketplace[shop.marketplaceId] || 'UNKNOWN';
+  if (!regionShops.has(region)) regionShops.set(region, shop);
+}
+
+const postedAfter = new Date(Date.now() - 729 * 24 * 60 * 60 * 1000).toISOString();
+const capturedAt = dateValue(new Date());
+
+for (const [region, shop] of regionShops) {
+  const startedAt = new Date();
+  let token = '';
+  let pages = 0;
+  let records = 0;
+  const seenTokens = new Set();
+  const typeCounts = {};
+  try {
+    do {
+      pages++;
+      if (pages > 1000) throw new Error('Finances pagination exceeded 1000 pages');
+      const path = token
+        ? `/finances/v0/financialEvents?NextToken=${encodeURIComponent(token)}`
+        : `/finances/v0/financialEvents?PostedAfter=${encodeURIComponent(postedAfter)}&MaxResultsPerPage=100`;
+      const result = await forward(shop.objectId, path);
+      const payload = result.payload || result;
+      const financialEvents = payload.FinancialEvents || {};
+      const rows = [];
+      for (const [eventListName, events] of Object.entries(financialEvents)) {
+        if (!Array.isArray(events)) continue;
+        const eventType = eventListName.replace(/List$/, '');
+        typeCounts[eventType] = (typeCounts[eventType] || 0) + events.length;
+        for (const event of events) {
+          const eventHash = hash(event);
+          rows.push({
+            recordKey: `${region}:${eventType}:${eventHash}`,
+            region,
+            eventType,
+            amazonOrderId: event.AmazonOrderId || event.amazonOrderId || event.OrderId || '',
+            sellerOrderId: event.SellerOrderId || event.MerchantOrderId || '',
+            marketplaceName: event.MarketplaceName || '',
+            postedDate: dateValue(event.PostedDate || event.postedDate),
+            capturedAt,
+            rawData: event,
+            shop: shopPointer(shop.objectId),
+          });
+        }
+      }
+      await upsertMany('AmazonFinancialEvent', rows);
+      records += rows.length;
+      log('finances_page', { region, page: pages, records: rows.length, total: records });
+      token = String(payload.NextToken || result.NextToken || '').trim();
+      if (token) {
+        if (seenTokens.has(token)) throw new Error('Repeated finances NextToken');
+        seenTokens.add(token);
+        await sleep(1800);
+      }
+    } while (token);
+
+    await writeAudit({
+      dataType: 'financial_events', endpoint: '/finances/v0/financialEvents',
+      status: 'completed', shop, region, marketplaceId: shop.marketplaceId,
+      records, pages, startedAt, details: { postedAfter, typeCounts },
+    });
+    log('finances_completed', { region, records, pages, typeCounts });
+  } catch (error) {
+    await writeAudit({
+      dataType: 'financial_events', endpoint: '/finances/v0/financialEvents',
+      status: records ? 'partial' : 'failed', shop, region, marketplaceId: shop.marketplaceId,
+      records, pages, error: error.message, startedAt, details: { postedAfter, typeCounts },
+    });
+    log('finances_failed', { region, records, pages, typeCounts, error: error.message });
+  }
+}

+ 310 - 0
deploy/collect-sp-api-reports-all.mjs

@@ -0,0 +1,310 @@
+import zlib from 'node:zlib';
+import {
+  activeAmazonShops, allRows, dateValue, forward, hash, log, regionByMarketplace,
+  shopPointer, sleep, updateObject, upsertMany, writeAudit,
+} from './sp-api-collector-lib.mjs';
+
+const allReportDefinitions = [
+  { type: 'GET_MERCHANT_LISTINGS_ALL_DATA' },
+  { type: 'GET_FBA_MYI_UNSUPPRESSED_INVENTORY_DATA' },
+  { type: 'GET_FBA_MYI_ALL_INVENTORY_DATA' },
+  { type: 'GET_FBA_INVENTORY_AGED_DATA' },
+  { type: 'GET_FBA_INVENTORY_PLANNING_DATA' },
+  { type: 'GET_RESERVED_INVENTORY_DATA' },
+  { type: 'GET_FBA_ESTIMATED_FBA_FEES_TXT_DATA' },
+  { type: 'GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA', dateRange: true },
+  { type: 'GET_FBA_REIMBURSEMENTS_DATA', dateRange: true },
+  { type: 'GET_AMAZON_FULFILLED_SHIPMENTS_DATA_GENERAL', dateRange: true },
+  { type: 'GET_FBA_FULFILLMENT_INVENTORY_RECEIPTS_DATA', dateRange: true },
+  { type: 'GET_FBA_FULFILLMENT_INVENTORY_ADJUSTMENTS_DATA', dateRange: true },
+  { type: 'GET_FBA_FULFILLMENT_REMOVAL_ORDER_DETAIL_DATA', dateRange: true },
+  { type: 'GET_FBA_FULFILLMENT_REMOVAL_SHIPMENT_DETAIL_DATA', dateRange: true },
+  {
+    type: 'GET_SALES_AND_TRAFFIC_REPORT',
+    dateRange: true,
+    singleMarketplace: true,
+    reportOptions: { dateGranularity: 'DAY', asinGranularity: 'SKU' },
+  },
+];
+const requestedReportTypes = new Set(
+  String(process.env.SP_API_REPORT_TYPES || '')
+    .split(',')
+    .map(value => value.trim())
+    .filter(Boolean),
+);
+const reportDefinitions = requestedReportTypes.size
+  ? allReportDefinitions.filter(definition => requestedReportTypes.has(definition.type))
+  : allReportDefinitions;
+
+const shops = await activeAmazonShops();
+const regionShops = new Map();
+for (const shop of shops) {
+  const region = regionByMarketplace[shop.marketplaceId] || 'UNKNOWN';
+  if (!regionShops.has(region)) regionShops.set(region, { shop, marketplaceIds: [] });
+  regionShops.get(region).marketplaceIds.push(shop.marketplaceId);
+}
+
+const dateKey = new Date().toISOString().slice(0, 10);
+const dataEndTime = new Date().toISOString();
+const dataStartTime = new Date(Date.now() - 89 * 24 * 60 * 60 * 1000).toISOString();
+const existingReports = await allRows('Reports');
+const existingByKey = new Map(existingReports.filter(report => report.recordKey).map(report => [report.recordKey, report]));
+const created = [];
+
+for (const [region, { shop, marketplaceIds }] of regionShops) {
+  for (const definition of reportDefinitions) {
+    // Merchant listings do not identify the originating marketplace when a
+    // regional request contains multiple marketplace IDs. Collect them per
+    // marketplace below so products can be linked to the correct Shop.
+    if (region === 'FE' || definition.singleMarketplace || definition.type === 'GET_MERCHANT_LISTINGS_ALL_DATA') continue;
+    const recordKey = `${region}:${definition.type}:${dateKey}`;
+    const previous = existingByKey.get(recordKey);
+    if (previous?.reportId) {
+      created.push(previous);
+      log('report_reused', { region, reportType: definition.type, reportId: previous.reportId });
+      continue;
+    }
+
+    const startedAt = new Date();
+    try {
+      const body = {
+        reportType: definition.type,
+        marketplaceIds: [...new Set(marketplaceIds)],
+        ...(definition.dateRange ? { dataStartTime, dataEndTime } : {}),
+        ...(definition.reportOptions ? { reportOptions: definition.reportOptions } : {}),
+      };
+      const result = await forward(shop.objectId, '/reports/2021-06-30/reports', 'POST', body, 5);
+      if (!result.reportId) throw new Error('Create report returned no reportId');
+      const row = {
+        recordKey,
+        region,
+        reportId: result.reportId,
+        reportType: definition.type,
+        marketplaceIds: body.marketplaceIds,
+        dataStartTime: definition.dateRange ? dateValue(dataStartTime) : undefined,
+        dataEndTime: definition.dateRange ? dateValue(dataEndTime) : undefined,
+        processingStatus: 'IN_QUEUE',
+        isParsed: false,
+        rawData: result,
+        shop: shopPointer(shop.objectId),
+      };
+      await upsertMany('Reports', [row]);
+      created.push(row);
+      await writeAudit({
+        dataType: 'report_create', endpoint: '/reports/2021-06-30/reports',
+        status: 'completed', shop, region, marketplaceId: shop.marketplaceId,
+        records: 1, pages: 1, startedAt, details: { reportType: definition.type, reportId: result.reportId },
+      });
+      log('report_created', { region, reportType: definition.type, reportId: result.reportId });
+    } catch (error) {
+      await writeAudit({
+        dataType: 'report_create', endpoint: '/reports/2021-06-30/reports',
+        status: 'failed', shop, region, marketplaceId: shop.marketplaceId,
+        error: error.message, startedAt, details: { reportType: definition.type },
+      });
+      log('report_create_failed', { region, reportType: definition.type, error: error.message });
+    }
+    await sleep(2500);
+  }
+}
+
+for (const shop of shops) {
+  const region = regionByMarketplace[shop.marketplaceId] || 'UNKNOWN';
+  const fallbackDefinitions = reportDefinitions.filter(definition =>
+    definition.singleMarketplace
+    || region === 'FE'
+    || definition.type === 'GET_MERCHANT_LISTINGS_ALL_DATA'
+    || definition.type === 'GET_FBA_FULFILLMENT_INVENTORY_RECEIPTS_DATA'
+    || definition.type === 'GET_FBA_FULFILLMENT_INVENTORY_ADJUSTMENTS_DATA'
+    || (region === 'NA' && definition.type === 'GET_FBA_MYI_ALL_INVENTORY_DATA')
+  );
+  for (const definition of fallbackDefinitions) {
+    const recordKey = `MP:${shop.marketplaceId}:${definition.type}:${dateKey}`;
+    const previous = existingByKey.get(recordKey);
+    if (previous?.reportId) {
+      created.push(previous);
+      log('marketplace_report_reused', { marketplaceId: shop.marketplaceId, reportType: definition.type, reportId: previous.reportId });
+      continue;
+    }
+    const startedAt = new Date();
+    try {
+      const body = {
+        reportType: definition.type,
+        marketplaceIds: [shop.marketplaceId],
+        ...(definition.dateRange ? { dataStartTime, dataEndTime } : {}),
+        ...(definition.reportOptions ? { reportOptions: definition.reportOptions } : {}),
+      };
+      const result = await forward(shop.objectId, '/reports/2021-06-30/reports', 'POST', body, 5);
+      if (!result.reportId) throw new Error('Create report returned no reportId');
+      const row = {
+        recordKey,
+        region,
+        reportId: result.reportId,
+        reportType: definition.type,
+        marketplaceIds: [shop.marketplaceId],
+        dataStartTime: definition.dateRange ? dateValue(dataStartTime) : undefined,
+        dataEndTime: definition.dateRange ? dateValue(dataEndTime) : undefined,
+        processingStatus: 'IN_QUEUE',
+        isParsed: false,
+        rawData: result,
+        shop: shopPointer(shop.objectId),
+      };
+      await upsertMany('Reports', [row]);
+      created.push(row);
+      await writeAudit({
+        dataType: 'report_create_marketplace', endpoint: '/reports/2021-06-30/reports',
+        status: 'completed', shop, region, marketplaceId: shop.marketplaceId,
+        records: 1, pages: 1, startedAt, details: { reportType: definition.type, reportId: result.reportId },
+      });
+      log('marketplace_report_created', { marketplaceId: shop.marketplaceId, reportType: definition.type, reportId: result.reportId });
+    } catch (error) {
+      await writeAudit({
+        dataType: 'report_create_marketplace', endpoint: '/reports/2021-06-30/reports',
+        status: 'failed', shop, region, marketplaceId: shop.marketplaceId,
+        error: error.message, startedAt, details: { reportType: definition.type },
+      });
+      log('marketplace_report_create_failed', { marketplaceId: shop.marketplaceId, reportType: definition.type, error: error.message });
+    }
+    await sleep(2500);
+  }
+}
+
+function parseCsvLine(line, delimiter) {
+  const values = [];
+  let value = '';
+  let quoted = false;
+  for (let index = 0; index < line.length; index++) {
+    const char = line[index];
+    if (char === '"') {
+      if (quoted && line[index + 1] === '"') {
+        value += '"';
+        index++;
+      } else {
+        quoted = !quoted;
+      }
+    } else if (char === delimiter && !quoted) {
+      values.push(value);
+      value = '';
+    } else {
+      value += char;
+    }
+  }
+  values.push(value);
+  return values;
+}
+
+function safeKey(value, index) {
+  const key = String(value || `column_${index + 1}`).trim().replace(/[.$]/g, '_');
+  return key || `column_${index + 1}`;
+}
+
+function parseDocument(content, reportType) {
+  const trimmed = content.replace(/^\uFEFF/, '').trim();
+  if (!trimmed) return [];
+  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+    try {
+      const parsed = JSON.parse(trimmed);
+      if (Array.isArray(parsed)) return parsed;
+      const rows = [];
+      for (const [section, value] of Object.entries(parsed)) {
+        if (Array.isArray(value)) rows.push(...value.map(item => ({ _section: section, ...(item && typeof item === 'object' ? item : { value: item }) })));
+      }
+      return rows.length ? rows : [parsed];
+    } catch {}
+  }
+  const lines = trimmed.split(/\r?\n/).filter(Boolean);
+  if (lines.length === 1) return [{ content: lines[0] }];
+  const delimiter = lines[0].includes('\t') ? '\t' : ',';
+  const headers = parseCsvLine(lines[0], delimiter).map(safeKey);
+  return lines.slice(1).map(line => {
+    const values = parseCsvLine(line, delimiter);
+    return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
+  }).filter(row => Object.values(row).some(value => value !== ''));
+}
+
+async function processReport(report, shop, region) {
+  const document = await forward(shop.objectId,
+    `/reports/2021-06-30/documents/${encodeURIComponent(report.reportDocumentId)}`,
+    'GET', undefined, 5);
+  if (!document.url) throw new Error('Report document returned no URL');
+  const response = await fetch(document.url, { signal: AbortSignal.timeout(300000) });
+  if (!response.ok) throw new Error(`Report download failed: ${response.status}`);
+  let bytes = Buffer.from(await response.arrayBuffer());
+  if (document.compressionAlgorithm === 'GZIP') bytes = zlib.gunzipSync(bytes);
+  const content = new TextDecoder('utf-8').decode(bytes);
+  const parsedRows = parseDocument(content, report.reportType);
+  const rows = parsedRows.map(row => {
+    const rowHash = hash(row);
+    const marketplaceKey = [...(report.marketplaceIds || [])].sort().join(',') || report.shop?.objectId || region;
+    return {
+      recordKey: `${region}:${marketplaceKey}:${report.reportType}:${rowHash}`,
+      rowHash,
+      reportType: report.reportType,
+      reportId: report.reportId,
+      region,
+      marketplaceIds: report.marketplaceIds || [],
+      capturedAt: dateValue(new Date()),
+      rowData: row,
+      rawData: row,
+      shop: shopPointer(shop.objectId),
+    };
+  });
+  const saved = await upsertMany('AmazonReportRow', rows);
+  await updateObject('Reports', report.objectId, {
+    isParsed: true,
+    parsedAt: dateValue(new Date()),
+    parsedRecords: rows.length,
+  });
+  return { rows: rows.length, ...saved, bytes: bytes.length };
+}
+
+for (let round = 1; round <= 20; round++) {
+  const reports = await allRows('Reports');
+  const targetKeys = new Set(created.map(report => report.recordKey).filter(Boolean));
+  const pending = reports.filter(report => targetKeys.has(report.recordKey) && report.isParsed !== true);
+  let waiting = 0;
+  log('report_poll_round', { round, pending: pending.length });
+
+  for (const report of pending) {
+    const shop = shops.find(item => item.objectId === report.shop?.objectId) || regionShops.get(report.region)?.shop;
+    if (!shop) continue;
+    try {
+      let status = report.processingStatus || 'IN_QUEUE';
+      let documentId = report.reportDocumentId || '';
+      if (!['DONE', 'CANCELLED', 'FATAL'].includes(status)) {
+        const info = await forward(shop.objectId, `/reports/2021-06-30/reports/${report.reportId}`, 'GET', undefined, 5);
+        status = info.processingStatus || status;
+        documentId = info.reportDocumentId || documentId;
+        await updateObject('Reports', report.objectId, {
+          processingStatus: status,
+          ...(documentId ? { reportDocumentId: documentId } : {}),
+          rawData: info,
+        });
+      }
+      if (status === 'DONE' && documentId) {
+        report.reportDocumentId = documentId;
+        const result = await processReport(report, shop, report.region);
+        log('report_processed', { region: report.region, reportType: report.reportType, reportId: report.reportId, ...result });
+      } else if (!['CANCELLED', 'FATAL'].includes(status)) {
+        waiting++;
+      } else {
+        log('report_terminal_without_data', { region: report.region, reportType: report.reportType, reportId: report.reportId, status });
+      }
+    } catch (error) {
+      waiting++;
+      log('report_process_failed', { region: report.region, reportType: report.reportType, reportId: report.reportId, error: error.message });
+    }
+    await sleep(1000);
+  }
+  if (waiting === 0) break;
+  if (round < 20) await sleep(30000);
+}
+
+const finalReports = await allRows('Reports');
+const targetKeys = new Set(created.map(report => report.recordKey).filter(Boolean));
+const summary = finalReports.filter(report => targetKeys.has(report.recordKey)).reduce((result, report) => {
+  const key = `${report.processingStatus || 'UNKNOWN'}|parsed=${Boolean(report.isParsed)}`;
+  result[key] = (result[key] || 0) + 1;
+  return result;
+}, {});
+log('report_collection_finished', { created: created.length, summary });

+ 124 - 0
deploy/dedupe-return-records.mjs

@@ -0,0 +1,124 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+const fallbackShopId = process.env.FALLBACK_SHOP_ID;
+
+if (!appId || !masterKey || !fallbackShopId) {
+  throw new Error('Missing PARSE_APP_ID, PARSE_MASTER_KEY, or FALLBACK_SHOP_ID');
+}
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+const shopPointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId });
+
+async function request(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 300) || response.status}`);
+  }
+  return result;
+}
+
+async function allRows(className, keys = '') {
+  const rows = [];
+  let skip = 0;
+  while (true) {
+    const page = await request(`/classes/${className}?limit=1000&skip=${skip}${keys ? `&keys=${keys}` : ''}`);
+    rows.push(...(page.results || []));
+    const length = page.results?.length || 0;
+    if (length < 1000) return rows;
+    skip += length;
+  }
+}
+
+async function batch(requests) {
+  for (let start = 0; start < requests.length; start += 25) {
+    const result = await request('/batch', 'POST', { requests: requests.slice(start, start + 25) });
+    const error = result.find?.(item => item.error)?.error;
+    if (error) throw new Error(error.error || error.code || 'Parse batch failed');
+  }
+}
+
+function returnKey(row) {
+  const date = row.return_date?.iso || row.returnDate?.iso || row.return_date || row.returnDate || '';
+  const orderId = row.order_id || row.orderId || row.amazon_order_id || '';
+  const sku = row.sku || row.seller_sku || '';
+  return [date, orderId, sku, row.asin || '', row.license_plate_number || ''].join('|');
+}
+
+const [returns, orders] = await Promise.all([
+  allRows('ReturnRecord'),
+  allRows('Order', 'platformOrderId,orderId,shop'),
+]);
+
+const orderShop = new Map();
+for (const order of orders) {
+  for (const id of [order.platformOrderId, order.orderId]) {
+    if (id && order.shop?.objectId) orderShop.set(id, order.shop.objectId);
+  }
+}
+
+const groups = new Map();
+for (const row of returns) {
+  const key = returnKey(row);
+  if (!groups.has(key)) groups.set(key, []);
+  groups.get(key).push(row);
+}
+
+const updates = [];
+const deletes = [];
+let matchedOrders = 0;
+
+for (const rows of groups.values()) {
+  const orderId = rows[0].order_id || rows[0].orderId || rows[0].amazon_order_id || '';
+  const targetShopId = orderShop.get(orderId) || fallbackShopId;
+  if (orderShop.has(orderId)) matchedOrders++;
+  const keep = rows.find(row => row.shop?.objectId === targetShopId) || rows[0];
+
+  updates.push({
+    method: 'PUT',
+    path: `/parse/classes/ReturnRecord/${keep.objectId}`,
+    body: {
+      shop: shopPointer(targetShopId),
+      orderId,
+      returnDate: keep.returnDate || keep.return_date,
+      asin: keep.asin || '',
+      sku: keep.sku || keep.seller_sku || '',
+      reason: keep.reason || '',
+      quantity: Number(keep.quantity || 0),
+      comment: keep.customer_comments || keep.comment || '',
+      disposition: keep.detailed_disposition || keep.disposition || '',
+      fulfillmentCenterId: keep.fulfillment_center_id || keep.fulfillmentCenterId || '',
+      licensePlateNumber: keep.license_plate_number || keep.licensePlateNumber || '',
+    },
+  });
+
+  for (const row of rows) {
+    if (row.objectId === keep.objectId) continue;
+    deletes.push({ method: 'DELETE', path: `/parse/classes/ReturnRecord/${row.objectId}` });
+  }
+}
+
+await batch(updates);
+await batch(deletes);
+
+const after = await allRows('ReturnRecord', 'objectId,shop,orderId');
+console.log(JSON.stringify({
+  before: returns.length,
+  uniqueBusinessKeys: groups.size,
+  matchedOrders,
+  fallbackAssignments: groups.size - matchedOrders,
+  updated: updates.length,
+  deleted: deletes.length,
+  after: after.length,
+}, null, 2));

+ 73 - 0
deploy/dedupe-sp-api-classes.mjs

@@ -0,0 +1,73 @@
+import {
+  allRows, parseRequest, regionByMarketplace, shopPointer,
+} from './sp-api-collector-lib.mjs';
+
+async function batch(requests) {
+  for (let start = 0; start < requests.length; start += 25) {
+    const result = await parseRequest('/batch', 'POST', { requests: requests.slice(start, start + 25) });
+    const error = result.find?.(item => item.error)?.error;
+    if (error) throw new Error(error.error || error.code || 'Parse batch failed');
+  }
+}
+
+async function dedupeRecordKeys(className) {
+  const rows = await allRows(className, 'objectId,recordKey');
+  const seen = new Set();
+  const deletes = [];
+  for (const row of rows) {
+    if (!row.recordKey || seen.has(row.recordKey)) {
+      deletes.push({ method: 'DELETE', path: `/parse/classes/${className}/${row.objectId}` });
+    } else {
+      seen.add(row.recordKey);
+    }
+  }
+  await batch(deletes);
+  return { before: rows.length, deleted: deletes.length, after: rows.length - deletes.length };
+}
+
+const shops = await allRows('Shop', 'objectId,marketplaceId');
+const shopById = new Map(shops.map(shop => [shop.objectId, shop]));
+const shipments = await allRows('AmazonInboundShipment');
+const groups = new Map();
+for (const row of shipments) {
+  const shop = shopById.get(row.shop?.objectId);
+  const region = row.region || regionByMarketplace[shop?.marketplaceId] || 'UNKNOWN';
+  const businessKey = `${region}:${row.shipmentId || row.recordKey || row.objectId}`;
+  if (!groups.has(businessKey)) groups.set(businessKey, []);
+  groups.get(businessKey).push({ ...row, region });
+}
+
+const shipmentUpdates = [];
+const shipmentDeletes = [];
+for (const [recordKey, rows] of groups) {
+  const keep = rows[0];
+  shipmentUpdates.push({
+    method: 'PUT',
+    path: `/parse/classes/AmazonInboundShipment/${keep.objectId}`,
+    body: { recordKey, region: keep.region },
+  });
+  for (const row of rows.slice(1)) {
+    shipmentDeletes.push({ method: 'DELETE', path: `/parse/classes/AmazonInboundShipment/${row.objectId}` });
+  }
+}
+await batch(shipmentUpdates);
+await batch(shipmentDeletes);
+
+const results = {
+  AmazonInboundShipment: {
+    before: shipments.length,
+    uniqueShipments: groups.size,
+    deleted: shipmentDeletes.length,
+    after: groups.size,
+  },
+};
+
+for (const className of [
+  'SpApiSellerAccount', 'SpApiMarketplaceParticipation', 'AmazonInventorySummary',
+  'AmazonSalesMetric', 'AmazonCatalogItem', 'AmazonProductPricing', 'AmazonOrderItem',
+  'AmazonFinancialEvent', 'AmazonCustomerFeedback', 'AmazonReportRow',
+]) {
+  results[className] = await dedupeRecordKeys(className);
+}
+
+console.log(JSON.stringify(results, null, 2));

+ 23 - 0
deploy/ecosystem.config.cjs

@@ -0,0 +1,23 @@
+module.exports = {
+  apps: [
+    {
+      name: 'jianhen-voc-backend',
+      script: '/opt/jianhen-voc/bin/start-backend.sh',
+      cwd: '/opt/jianhen-voc/backend',
+      interpreter: 'none',
+      exec_mode: 'fork',
+      instances: 1,
+      autorestart: true,
+      restart_delay: 5000,
+      max_restarts: 10,
+      kill_timeout: 10000,
+      time: true,
+      merge_logs: true,
+      out_file: '/var/log/jianhen-voc/pm2-out.log',
+      error_file: '/var/log/jianhen-voc/pm2-error.log',
+      env: {
+        NODE_ENV: 'production'
+      }
+    }
+  ]
+};

+ 167 - 0
deploy/enrich-products.mjs

@@ -0,0 +1,167 @@
+import fs from 'node:fs';
+
+const configPath = process.env.VOC_CONFIG || '/etc/jianhen-voc/config.json';
+const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const appId = config.parse.appId;
+const masterKey = config.parse.masterKey;
+const relayBase = String(config.relay.baseUrl || '').replace(/\/$/, '');
+const relayAuth = config.relay.auth;
+
+const parseHeaders = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers: parseHeaders,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const result = await response.json();
+  if (!response.ok || result.error) throw new Error(`${method} ${path}: ${JSON.stringify(result.error || response.status)}`);
+  return result;
+}
+
+async function relay(path, domain, body) {
+  const response = await fetch(`${relayBase}${config.relay.sorftimePath}`, {
+    method: 'POST',
+    headers: {
+      Authorization: relayAuth,
+      'Content-Type': 'application/json',
+      'X-Request-Id': `enrich_${Date.now()}_${Math.random().toString(16).slice(2)}`,
+    },
+    body: JSON.stringify({ path, method: 'POST', query: { domain }, body }),
+  });
+  const result = await response.json();
+  if (!response.ok || (result.code !== undefined && ![0, 200].includes(Number(result.code)))) {
+    throw new Error(result.message || `Relay HTTP ${response.status}`);
+  }
+  let data = result.data ?? result.Data ?? result;
+  if (data && typeof data === 'object' && !Array.isArray(data)) data = data.data ?? data.Data ?? data;
+  return data;
+}
+
+async function upsert(className, where, payload) {
+  const encoded = encodeURIComponent(JSON.stringify(where));
+  const existing = await parseRequest(`/classes/${className}?where=${encoded}&limit=1`);
+  const objectId = existing.results?.[0]?.objectId;
+  return objectId
+    ? parseRequest(`/classes/${className}/${objectId}`, 'PUT', payload)
+    : parseRequest(`/classes/${className}`, 'POST', payload);
+}
+
+const siteByMarketplace = {
+  ATVPDKIKX0DER: 'us', A2EUQ1WTGCTBG2: 'ca', A1AM78C64UM0Y8: 'mx', A2Q3Y263D00KWC: 'br',
+  A1F83G8C2ARO7P: 'uk', A1PA6795UKMFR9: 'de', A13V1IB3VIYZZH: 'fr', A1RKKUPIHCS9HS: 'es',
+  APJ6JRA9NG5V4: 'it', A1VC38T7YXB528: 'jp', A39IBJ37TRP1C6: 'au', A2VIGQ35RCS4UG: 'ae',
+  A17E79C6D8DWNP: 'sa', A21TJRUUN4KGV: 'in',
+};
+
+const imageOf = value => Array.isArray(value) ? String(value[0] || '') : String(value || '');
+const categoryOf = value => {
+  if (typeof value === 'string') return value;
+  if (Array.isArray(value)) return value.map(item => categoryOf(item)).filter(Boolean).join(' > ');
+  if (value && typeof value === 'object') return String(value.Name || value.name || value.CategoryName || '');
+  return '';
+};
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+const shopsResult = await parseRequest('/classes/Shop?limit=1000');
+const shops = new Map(shopsResult.results.map(shop => [shop.objectId, shop]));
+const listingsResult = await parseRequest('/classes/Listing?limit=1000');
+let enriched = 0;
+let reviewsSaved = 0;
+let failed = 0;
+
+for (const listing of listingsResult.results) {
+  const asin = String(listing.asin || '').trim().toUpperCase();
+  const shopId = listing.shop?.objectId || '';
+  if (!asin || !shopId) continue;
+  const shop = shops.get(shopId) || {};
+  const domain = Number(shop.domain || 1);
+  const marketplaceId = listing.marketplaceId || shop.marketplaceId || '';
+  const pointer = { __type: 'Pointer', className: 'Shop', objectId: shopId };
+
+  try {
+    const raw = await relay('/api/ProductRequest', domain, {
+      ASIN: asin,
+      Trend: 1,
+      QueryTrendStartDt: '',
+      QueryTrendEndDt: '',
+    });
+    if (raw && (raw.Asin || raw.ASIN || raw.Title)) {
+      const payload = {
+        asin,
+        parentAsin: raw.ParentAsin || listing.parentAsin || '',
+        sku: listing.sku || '',
+        sellerSku: listing.sku || '',
+        title: raw.Title || listing.title || '',
+        itemName: raw.Title || listing.title || '',
+        imageUrl: imageOf(raw.Photo) || listing.mainImage || '',
+        photo: imageOf(raw.Photo) || listing.mainImage || '',
+        category: [categoryOf(raw.Category) || listing.productType || ''].filter(Boolean),
+        productType: listing.productType || '',
+        brand: raw.Brand || '',
+        sellerId: raw.BuyboxSellerId || '',
+        ratings: Number(raw.Ratings || 0),
+        rating: Number(raw.Ratings || 0),
+        ratingsCount: Number(raw.RatingsCount || 0),
+        price: Number(raw.Price || listing.price || 0),
+        salesPrice: Number(raw.SalesPrice || raw.Price || listing.price || 0),
+        rank: Number(raw.Rank || 0),
+        bsrCategory: Array.isArray(raw.BsrCategory) ? raw.BsrCategory : [],
+        listingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+        ListingSalesVolumeOfMonth: Number(raw.ListingSalesVolumeOfMonth || 0),
+        listingSalesOfMonth: Number(raw.ListingSalesOfMonth || 0),
+        marketplaceId,
+        domain: String(domain),
+        site: siteByMarketplace[marketplaceId] || String(domain),
+        storeName: shop.name || '',
+        shopName: shop.name || '',
+        shopId,
+        source: 'sorftime-product-request',
+        onlineDate: String(raw.OnlineDate || listing.createdDate?.iso || listing.createdDate || '').slice(0, 10),
+        shop: pointer,
+        rawData: raw,
+      };
+      const where = { asin, shop: pointer };
+      await upsert('Product', where, payload);
+      await upsert('ProductDetail', where, payload);
+      enriched += 1;
+    }
+
+    const reviewData = await relay('/api/ProductReviewsQuery', domain, {
+      ASIN: asin,
+      PageIndex: 1,
+      OnlyPurchase: 0,
+      Star: '1,2,3,4,5',
+    });
+    const reviews = reviewData?.Reviews || reviewData?.Items || reviewData?.List || (Array.isArray(reviewData) ? reviewData : []);
+    for (const review of reviews) {
+      const reviewId = String(review.ReviewId || review.Id || review.ID || review.id || `${asin}:${review.ReviewsDate || review.ReviewDate || ''}:${review.ProfileName || review.Author || ''}`);
+      await upsert('SorftimeReviews', { reviewId }, {
+        reviewId,
+        asin,
+        star: Number(review.Star || review.Rating || 0),
+        rating: Number(review.Star || review.Rating || 0),
+        title: review.Title || review.ReviewTitle || '',
+        content: review.Content || review.ReviewContent || review.Body || '',
+        reviewDate: review.ReviewsDate || review.ReviewDate || review.Date || '',
+        author: review.ProfileName || review.Author || '',
+        verified: Boolean(review.OnlyPurchase || review.VerifiedPurchase),
+        shop: pointer,
+        rawData: review,
+      });
+      reviewsSaved += 1;
+    }
+  } catch (error) {
+    failed += 1;
+    console.warn(`Failed ${asin}: ${error.message}`);
+  }
+  await delay(150);
+}
+
+console.log(JSON.stringify({ listings: listingsResult.results.length, enriched, reviewsSaved, failed }));

+ 21 - 0
deploy/full-sp-api-refresh.sh

@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+exec 9>/run/lock/jianhen-voc-sp-api-refresh.lock
+flock -n 9 || exit 0
+
+CONFIG=/etc/jianhen-voc/config.json
+export PARSE_APP_ID="$(node -p "require('$CONFIG').parse.appId")"
+export PARSE_MASTER_KEY="$(node -p "require('$CONFIG').parse.masterKey")"
+export SELLER_ID="${SELLER_ID:-AJGFU9OQTH5XU}"
+export SP_API_RUN_ID="spapi-$(date -u +%Y%m%dT%H%M%SZ)"
+
+cd /opt/jianhen-voc/deploy
+
+node bootstrap-schemas.mjs
+node collect-sp-api-core.mjs
+node collect-sp-api-finances.mjs
+node collect-sp-api-feedback-inbound.mjs
+node collect-sp-api-reports-all.mjs
+node dedupe-sp-api-classes.mjs
+node backfill-listings.mjs

+ 166 - 0
deploy/full-sp-api-sync.mjs

@@ -0,0 +1,166 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+const targetSellerId = process.env.SELLER_ID;
+
+if (!appId || !masterKey || !targetSellerId) {
+  throw new Error('Missing PARSE_APP_ID, PARSE_MASTER_KEY, or SELLER_ID');
+}
+
+const parseHeaders = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers: parseHeaders,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 300) || response.status}`);
+  }
+  return result;
+}
+
+async function countRows(className, shopId) {
+  const where = encodeURIComponent(JSON.stringify({
+    shop: { __type: 'Pointer', className: 'Shop', objectId: shopId },
+  }));
+  let total = 0;
+  let skip = 0;
+  while (true) {
+    const page = await parseRequest(`/classes/${className}?where=${where}&keys=objectId&limit=1000&skip=${skip}`);
+    const length = page.results?.length || 0;
+    total += length;
+    if (length < 1000) return total;
+    skip += length;
+  }
+}
+
+async function forward(shopId, payload, timeoutMs = 45 * 60 * 1000) {
+  const response = await fetch(`${apiUrl}/forward`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
+    body: JSON.stringify(payload),
+    signal: AbortSignal.timeout(timeoutMs),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.success === false) {
+    throw new Error(result.message || result.errors?.[0]?.message || text.slice(0, 300) || `HTTP ${response.status}`);
+  }
+  return result;
+}
+
+function log(event, data = {}) {
+  console.log(JSON.stringify({ at: new Date().toISOString(), event, ...data }));
+}
+
+const shopResult = await parseRequest('/classes/Shop?limit=1000');
+const shops = (shopResult.results || []).filter(shop => {
+  const config = shop.config?.SpApiConfig;
+  return shop.platform === 'amazon'
+    && shop.status === 'active'
+    && shop.disable !== true
+    && shop.deleted !== true
+    && config?.sellerID === targetSellerId;
+});
+
+const startedAt = new Date();
+const ordersStart = new Date(startedAt.getTime() - 729 * 24 * 60 * 60 * 1000).toISOString();
+const listingsStart = new Date(startedAt.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString();
+const summary = [];
+
+log('sync_started', {
+  sellerId: targetSellerId,
+  shops: shops.length,
+  ordersStart,
+  listingsStart,
+});
+
+for (const shop of shops) {
+  const config = shop.config.SpApiConfig;
+  const item = {
+    shopId: shop.objectId,
+    shopName: shop.name,
+    marketplaceId: shop.marketplaceId,
+    listingEnabled: config.listingEnabled !== false,
+    orders: {},
+    listings: {},
+    errors: [],
+  };
+  summary.push(item);
+
+  log('shop_started', item);
+
+  const orderBefore = await countRows('Order', shop.objectId);
+  try {
+    await forward(shop.objectId, {
+      path: `/orders/v0/orders?MarketplaceIds=${encodeURIComponent(shop.marketplaceId)}&CreatedAfter=${encodeURIComponent(ordersStart)}`,
+      method: 'GET',
+      functionName: 'cleanOrders',
+    });
+    const orderAfter = await countRows('Order', shop.objectId);
+    item.orders = { success: true, before: orderBefore, after: orderAfter, added: orderAfter - orderBefore };
+    log('orders_completed', { shopId: shop.objectId, shopName: shop.name, ...item.orders });
+  } catch (error) {
+    item.orders = { success: false, before: orderBefore, after: await countRows('Order', shop.objectId), error: error.message };
+    item.errors.push(`orders: ${error.message}`);
+    log('orders_failed', { shopId: shop.objectId, shopName: shop.name, ...item.orders });
+  }
+
+  const listingBefore = await countRows('Listing', shop.objectId);
+  if (config.listingEnabled === false) {
+    item.listings = { success: false, skipped: true, before: listingBefore, after: listingBefore, reason: 'regional seller ID not authorized' };
+    log('listings_skipped', { shopId: shop.objectId, shopName: shop.name, ...item.listings });
+  } else {
+    try {
+      const path = `/listings/2021-08-01/items/${encodeURIComponent(targetSellerId)}`
+        + `?marketplaceIds=${encodeURIComponent(shop.marketplaceId)}`
+        + '&includedData=summaries,attributes,issues,fulfillmentAvailability'
+        + '&withStatus=BUYABLE,DISCOVERABLE'
+        + '&sortBy=lastUpdatedDate&sortOrder=ASC&pageSize=10'
+        + `&lastUpdatedAfter=${encodeURIComponent(listingsStart)}`;
+      await forward(shop.objectId, { path, method: 'GET', functionName: 'cleanListings' });
+      const listingAfter = await countRows('Listing', shop.objectId);
+      item.listings = { success: true, before: listingBefore, after: listingAfter, added: listingAfter - listingBefore };
+      log('listings_completed', { shopId: shop.objectId, shopName: shop.name, ...item.listings });
+    } catch (error) {
+      item.listings = { success: false, before: listingBefore, after: await countRows('Listing', shop.objectId), error: error.message };
+      item.errors.push(`listings: ${error.message}`);
+      log('listings_failed', { shopId: shop.objectId, shopName: shop.name, ...item.listings });
+    }
+  }
+
+  const completed = item.errors.length === 0;
+  await parseRequest(`/classes/Shop/${shop.objectId}`, 'PUT', {
+    lastSyncTime: { __type: 'Date', iso: new Date().toISOString() },
+    last_sync_at: { __type: 'Date', iso: new Date().toISOString() },
+    syncStatus: completed ? 'completed' : 'partial',
+    sync_status: completed ? 'completed' : 'partial',
+    sync_error: item.errors.join('; '),
+  });
+  log('shop_completed', { shopId: shop.objectId, shopName: shop.name, success: completed });
+}
+
+const result = {
+  sellerId: targetSellerId,
+  startedAt: startedAt.toISOString(),
+  finishedAt: new Date().toISOString(),
+  shops: summary.length,
+  orderSuccesses: summary.filter(item => item.orders.success).length,
+  listingSuccesses: summary.filter(item => item.listings.success).length,
+  listingSkips: summary.filter(item => item.listings.skipped).length,
+  errors: summary.flatMap(item => item.errors.map(error => `${item.shopName}: ${error}`)),
+  details: summary,
+};
+
+log('sync_finished', result);

+ 14 - 0
deploy/jianhen-voc-backup.service

@@ -0,0 +1,14 @@
+[Unit]
+Description=Backup Jianhen VOC PostgreSQL database
+After=postgresql.service
+
+[Service]
+Type=oneshot
+User=voc
+Group=voc
+ExecStart=/opt/jianhen-voc/deploy/backup.sh
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/backups/jianhen-voc

+ 10 - 0
deploy/jianhen-voc-backup.timer

@@ -0,0 +1,10 @@
+[Unit]
+Description=Daily Jianhen VOC database backup
+
+[Timer]
+OnCalendar=*-*-* 03:30:00 Asia/Shanghai
+Persistent=true
+RandomizedDelaySec=10m
+
+[Install]
+WantedBy=timers.target

+ 10 - 0
deploy/jianhen-voc-pm2.logrotate

@@ -0,0 +1,10 @@
+/var/log/jianhen-voc/pm2-*.log /opt/jianhen-voc/.pm2/pm2.log {
+    daily
+    rotate 14
+    compress
+    delaycompress
+    missingok
+    notifempty
+    copytruncate
+    su voc voc
+}

+ 19 - 0
deploy/jianhen-voc-reviews.service

@@ -0,0 +1,19 @@
+[Unit]
+Description=Jianhen VOC full one-year review collection
+After=network-online.target jianhen-voc.service
+Wants=network-online.target
+Requires=jianhen-voc.service
+
+[Service]
+Type=oneshot
+User=voc
+Group=voc
+WorkingDirectory=/opt/jianhen-voc/deploy
+ExecStart=/usr/bin/node /opt/jianhen-voc/deploy/collect-sorftime-reviews-year.mjs
+TimeoutStartSec=3h
+Nice=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+

+ 20 - 0
deploy/jianhen-voc-sp-api-refresh.service

@@ -0,0 +1,20 @@
+[Unit]
+Description=Jianhen VOC comprehensive SP-API refresh
+After=network-online.target jianhen-voc.service postgresql.service
+Wants=network-online.target
+Requires=jianhen-voc.service
+
+[Service]
+Type=oneshot
+User=voc
+Group=voc
+WorkingDirectory=/opt/jianhen-voc/deploy
+ExecStart=/opt/jianhen-voc/deploy/full-sp-api-refresh.sh
+TimeoutStartSec=6h
+Nice=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/run/lock /opt/jianhen-voc/.cache
+

+ 11 - 0
deploy/jianhen-voc-sp-api-refresh.timer

@@ -0,0 +1,11 @@
+[Unit]
+Description=Run Jianhen VOC comprehensive SP-API refresh daily
+
+[Timer]
+OnCalendar=*-*-* 03:15:00 Asia/Shanghai
+Persistent=true
+RandomizedDelaySec=10m
+Unit=jianhen-voc-sp-api-refresh.service
+
+[Install]
+WantedBy=timers.target

+ 24 - 0
deploy/jianhen-voc.service

@@ -0,0 +1,24 @@
+[Unit]
+Description=Jianhen VOC backend
+After=network-online.target postgresql.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=voc
+Group=voc
+WorkingDirectory=/opt/jianhen-voc/backend
+EnvironmentFile=/etc/jianhen-voc/backend.env
+ExecStart=/opt/jianhen-voc/bin/fmode-server --config /etc/jianhen-voc/config.json
+Restart=on-failure
+RestartSec=5
+StandardOutput=null
+StandardError=journal
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/log/jianhen-voc /opt/jianhen-voc/.cache
+
+[Install]
+WantedBy=multi-user.target

+ 48 - 0
deploy/nginx.http.conf

@@ -0,0 +1,48 @@
+server {
+    listen 80;
+    listen [::]:80;
+    server_name dskb.jianhengyiliao.com;
+
+    root /opt/jianhen-voc/frontend/dist;
+    index index.html;
+
+    location = /index.html {
+        add_header Cache-Control "no-store";
+        try_files $uri =404;
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    location /api/ {
+        proxy_pass http://127.0.0.1:3000/api/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Request-Id $request_id;
+        proxy_set_header X-External-Request 1;
+        proxy_buffering off;
+        proxy_read_timeout 600s;
+    }
+
+    location /parse/ {
+        proxy_pass http://127.0.0.1:3000/parse/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    location = /health {
+        proxy_pass http://127.0.0.1:3000/health;
+    }
+
+    location = /assets/runtime-config.json {
+        add_header Cache-Control "no-store";
+        try_files $uri =404;
+    }
+}

+ 86 - 0
deploy/nginx.https-dashboard.conf

@@ -0,0 +1,86 @@
+server {
+    listen 80;
+    listen [::]:80;
+    server_name dskb.jianhengyiliao.com;
+
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name dskb.jianhengyiliao.com;
+
+    ssl_certificate /etc/letsencrypt/live/dskb.jianhengyiliao.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/dskb.jianhengyiliao.com/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+    root /opt/jianhen-voc/frontend/dist;
+    index index.html;
+
+    location = /dbadmin {
+        return 301 /dbadmin/;
+    }
+
+    location /dbadmin/ {
+        proxy_pass http://127.0.0.1:4040/dbadmin/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Host $host;
+        proxy_set_header X-Forwarded-Proto https;
+        proxy_buffering off;
+    }
+
+    location = /index.html {
+        add_header Cache-Control "no-store";
+        try_files $uri =404;
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    location /api/ {
+        proxy_pass http://127.0.0.1:3000/api/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Request-Id $request_id;
+        proxy_set_header X-External-Request 1;
+        proxy_set_header If-None-Match "";
+        proxy_set_header If-Modified-Since "";
+        proxy_hide_header ETag;
+        proxy_hide_header Last-Modified;
+        add_header Cache-Control "no-store" always;
+        proxy_buffering off;
+        proxy_read_timeout 600s;
+    }
+
+    location /parse/ {
+        proxy_pass http://127.0.0.1:3000/parse/;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header If-None-Match "";
+        proxy_set_header If-Modified-Since "";
+        proxy_hide_header ETag;
+        proxy_hide_header Last-Modified;
+        add_header Cache-Control "no-store" always;
+    }
+
+    location = /health {
+        proxy_pass http://127.0.0.1:3000/health;
+    }
+
+    location = /assets/runtime-config.json {
+        add_header Cache-Control "no-store";
+        try_files $uri =404;
+    }
+}

+ 26 - 0
deploy/parse-dashboard.service

@@ -0,0 +1,26 @@
+[Unit]
+Description=Jianhen VOC Parse Dashboard
+After=network-online.target pm2-voc.service
+Wants=network-online.target
+Requires=pm2-voc.service
+
+[Service]
+Type=simple
+User=parse-dashboard
+Group=parse-dashboard
+WorkingDirectory=/opt/parse-dashboard
+EnvironmentFile=/etc/parse-dashboard/dashboard.env
+ExecStart=/opt/node22/bin/node /opt/parse-dashboard/node_modules/parse-dashboard/Parse-Dashboard/index.js --config /etc/parse-dashboard/config.json --host 127.0.0.1 --port 4040 --mountPath /dbadmin --trustProxy 1
+Restart=on-failure
+RestartSec=5
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+RestrictSUIDSGID=true
+
+[Install]
+WantedBy=multi-user.target

+ 18 - 0
deploy/pm2-voc

@@ -0,0 +1,18 @@
+#!/bin/sh
+set -eu
+
+cd /opt/jianhen-voc
+
+if [ "$(id -un)" = "voc" ]; then
+  exec env \
+    HOME=/opt/jianhen-voc \
+    PM2_HOME=/opt/jianhen-voc/.pm2 \
+    PATH=/opt/node22/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
+    /opt/node22/bin/pm2 "$@"
+fi
+
+exec /usr/sbin/runuser -u voc -- env \
+  HOME=/opt/jianhen-voc \
+  PM2_HOME=/opt/jianhen-voc/.pm2 \
+  PATH=/opt/node22/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
+  /opt/node22/bin/pm2 "$@"

+ 30 - 0
deploy/pm2-voc.service

@@ -0,0 +1,30 @@
+[Unit]
+Description=PM2 process manager for Jianhen VOC
+Documentation=https://pm2.keymetrics.io/
+After=network-online.target postgresql.service
+Wants=network-online.target
+Requires=postgresql.service
+
+[Service]
+Type=forking
+User=voc
+WorkingDirectory=/opt/jianhen-voc
+Environment=HOME=/opt/jianhen-voc
+Environment=PATH=/opt/node22/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Environment=PM2_HOME=/opt/jianhen-voc/.pm2
+PIDFile=/opt/jianhen-voc/.pm2/pm2.pid
+ExecStart=/opt/node22/lib/node_modules/pm2/bin/pm2 resurrect
+ExecReload=/opt/node22/lib/node_modules/pm2/bin/pm2 reload all
+ExecStop=/opt/node22/lib/node_modules/pm2/bin/pm2 kill
+Restart=on-failure
+LimitNOFILE=infinity
+LimitNPROC=infinity
+LimitCORE=infinity
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/opt/jianhen-voc/.pm2 /opt/jianhen-voc/.cache /var/log/jianhen-voc
+
+[Install]
+WantedBy=multi-user.target

+ 58 - 0
deploy/probe-review-pagination.mjs

@@ -0,0 +1,58 @@
+const asin = process.env.ASIN || 'B0G8D23R1Q';
+const baseUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/sorftime/forward';
+
+function unwrap(result) {
+  let data = result?.data ?? result?.Data ?? result;
+  while (data && typeof data === 'object' && !Array.isArray(data)
+    && (data.data !== undefined || data.Data !== undefined)) {
+    data = data.data ?? data.Data;
+  }
+  return data;
+}
+
+async function query(body) {
+  const response = await fetch(baseUrl, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      path: '/api/ProductReviewsQuery',
+      method: 'POST',
+      query: { domain: 1 },
+      body,
+    }),
+    signal: AbortSignal.timeout(60000),
+  });
+  const result = await response.json();
+  const data = unwrap(result);
+  const reviews = data?.Reviews || data?.Items || data?.List || (Array.isArray(data) ? data : []);
+  return {
+    status: response.status,
+    rows: reviews.map(review => ({
+      id: review.ReviewId || review.Id || review.ID || review.id,
+      date: review.ReviewsDate || review.ReviewDate || review.Date,
+      star: review.Star || review.Rating,
+      verified: review.OnlyPurchase || review.VerifiedPurchase,
+      contentLength: String(review.Content || review.ReviewContent || review.Body || '').length,
+    })),
+    dataKeys: data && typeof data === 'object' ? Object.keys(data) : [],
+    message: result?.message || result?.Message || '',
+  };
+}
+
+const tests = [
+  { label: 'year-page-1', body: { ASIN: asin, PageIndex: 1, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2025-09-18', QueryEndDt: '2026-09-18' } },
+  { label: 'year-page-size-100', body: { ASIN: asin, PageIndex: 1, PageSize: 100, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2025-09-18', QueryEndDt: '2026-09-18' } },
+  { label: 'year-page-2-alt', body: { ASIN: asin, Page: 2, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2025-09-18', QueryEndDt: '2026-09-18' } },
+  { label: 'year-page-0', body: { ASIN: asin, PageIndex: 0, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2025-09-18', QueryEndDt: '2026-09-18' } },
+  { label: 'recent-window', body: { ASIN: asin, PageIndex: 1, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2026-06-01', QueryEndDt: '2026-09-18' } },
+  { label: 'older-window', body: { ASIN: asin, PageIndex: 1, OnlyPurchase: 0, Star: '1,2,3,4,5', QueryStartDt: '2025-09-18', QueryEndDt: '2026-05-31' } },
+];
+
+for (const test of tests) {
+  try {
+    console.log(JSON.stringify({ label: test.label, body: test.body, result: await query(test.body) }));
+  } catch (error) {
+    console.log(JSON.stringify({ label: test.label, error: error.message }));
+  }
+  await new Promise(resolve => setTimeout(resolve, 500));
+}

+ 175 - 0
deploy/process-sp-api-reports.mjs

@@ -0,0 +1,175 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+const rounds = Number(process.env.REPORT_POLL_ROUNDS || 12);
+const roundDelayMs = Number(process.env.REPORT_POLL_DELAY_MS || 30000);
+
+if (!appId || !masterKey) throw new Error('Missing PARSE_APP_ID or PARSE_MASTER_KEY');
+
+const headers = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+function log(event, data = {}) {
+  console.log(JSON.stringify({ at: new Date().toISOString(), event, ...data }));
+}
+
+async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 300) || response.status}`);
+  }
+  return result;
+}
+
+async function amazonRequest(shopId, path, body) {
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    try {
+      const response = await fetch(`${apiUrl}${path}`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
+        body: JSON.stringify(body),
+        signal: AbortSignal.timeout(180000),
+      });
+      const text = await response.text();
+      let result = {};
+      try { result = JSON.parse(text); } catch {}
+      if (!response.ok || result.success === false) {
+        throw new Error(result.message || result.errors?.[0]?.message || text.slice(0, 300) || `HTTP ${response.status}`);
+      }
+      return result.data || result;
+    } catch (error) {
+      if (attempt === 5) throw error;
+      const delay = attempt * 15000;
+      log('amazon_retry', { shopId, path, attempt, delay, error: error.message });
+      await sleep(delay);
+    }
+  }
+}
+
+async function getReports() {
+  const reports = [];
+  let skip = 0;
+  while (true) {
+    const page = await parseRequest(`/classes/Reports?limit=1000&skip=${skip}&order=createdAt`);
+    reports.push(...(page.results || []));
+    const length = page.results?.length || 0;
+    if (length < 1000) return reports;
+    skip += length;
+  }
+}
+
+async function updateReport(reportId, body) {
+  return parseRequest(`/classes/Reports/${reportId}`, 'PUT', body);
+}
+
+const finalStates = new Set(['DONE', 'CANCELLED', 'FATAL']);
+const processedReports = new Set();
+let totalParsedRecords = 0;
+
+for (let round = 1; round <= rounds; round++) {
+  const reports = (await getReports()).filter(report => report.isParsed !== true);
+  let pending = 0;
+  let processedThisRound = 0;
+  const statusCounts = {};
+
+  log('report_round_started', { round, reports: reports.length });
+
+  for (const report of reports) {
+    const shopId = report.shop?.objectId;
+    const amazonReportId = report.reportId;
+    if (!shopId || !amazonReportId) {
+      log('report_skipped', { objectId: report.objectId, reason: 'missing shop pointer or reportId' });
+      continue;
+    }
+
+    let status = report.processingStatus || 'IN_QUEUE';
+    let documentId = report.reportDocumentId || '';
+
+    if (!finalStates.has(status)) {
+      try {
+        const result = await amazonRequest(shopId, '/forward', {
+          path: `/reports/2021-06-30/reports/${amazonReportId}`,
+          method: 'GET',
+        });
+        status = result.processingStatus || status;
+        documentId = result.reportDocumentId || documentId;
+        await updateReport(report.objectId, {
+          processingStatus: status,
+          ...(documentId ? { reportDocumentId: documentId } : {}),
+          ...(result.processingStartTime ? { processingStartTime: { __type: 'Date', iso: result.processingStartTime } } : {}),
+          ...(result.processingEndTime ? { processingEndTime: { __type: 'Date', iso: result.processingEndTime } } : {}),
+        });
+        log('report_status', { objectId: report.objectId, shopId, reportId: amazonReportId, status, hasDocument: Boolean(documentId) });
+      } catch (error) {
+        pending++;
+        log('report_status_failed', { objectId: report.objectId, shopId, reportId: amazonReportId, error: error.message });
+        continue;
+      }
+    }
+
+    statusCounts[status] = (statusCounts[status] || 0) + 1;
+
+    if (status !== 'DONE') {
+      if (!finalStates.has(status)) pending++;
+      continue;
+    }
+    if (!documentId) {
+      pending++;
+      log('report_waiting_document', { objectId: report.objectId, shopId, reportId: amazonReportId });
+      continue;
+    }
+    if (processedReports.has(report.objectId)) continue;
+
+    try {
+      const result = await amazonRequest(shopId, '/processReport', {
+        reportDocumentId: documentId,
+        reportType: report.reportType,
+      });
+      const processResult = result.processResult || result.data?.processResult || {};
+      if (processResult.success === false) throw new Error(processResult.message || 'report parser returned failure');
+      const parsed = Number(processResult.processed || 0);
+      await updateReport(report.objectId, {
+        isParsed: true,
+        parsedAt: { __type: 'Date', iso: new Date().toISOString() },
+        parsedRecords: parsed,
+      });
+      processedReports.add(report.objectId);
+      totalParsedRecords += parsed;
+      processedThisRound++;
+      log('report_parsed', { objectId: report.objectId, shopId, reportId: amazonReportId, reportType: report.reportType, parsed });
+    } catch (error) {
+      pending++;
+      log('report_parse_failed', { objectId: report.objectId, shopId, reportId: amazonReportId, error: error.message });
+    }
+
+    await sleep(1000);
+  }
+
+  log('report_round_finished', { round, pending, processedThisRound, statusCounts, totalParsedRecords });
+  if (pending === 0) break;
+  if (round < rounds) await sleep(roundDelayMs);
+}
+
+const remaining = (await getReports()).filter(report => report.isParsed !== true && !['CANCELLED', 'FATAL'].includes(report.processingStatus));
+log('report_processing_finished', {
+  processedReports: processedReports.size,
+  totalParsedRecords,
+  remaining: remaining.length,
+  remainingStatuses: remaining.reduce((counts, report) => {
+    const status = report.processingStatus || 'UNKNOWN';
+    counts[status] = (counts[status] || 0) + 1;
+    return counts;
+  }, {}),
+});

+ 80 - 0
deploy/reparse-sales-traffic-reports.mjs

@@ -0,0 +1,80 @@
+import zlib from 'node:zlib';
+import {
+  activeAmazonShops, allRows, dateValue, forward, hash, log,
+  shopPointer, sleep, updateObject, upsertMany,
+} from './sp-api-collector-lib.mjs';
+
+function parseDocument(content) {
+  const trimmed = content.replace(/^\uFEFF/, '').trim();
+  if (!trimmed) return [];
+  const parsed = JSON.parse(trimmed);
+  const rows = [];
+  for (const [section, value] of Object.entries(parsed)) {
+    if (!Array.isArray(value)) continue;
+    for (const item of value) {
+      rows.push({ _section: section, ...(item && typeof item === 'object' ? item : { value: item }) });
+    }
+  }
+  return rows.length ? rows : [parsed];
+}
+
+const shops = await activeAmazonShops();
+const reports = (await allRows('Reports')).filter(report =>
+  report.recordKey?.startsWith('MP:')
+  && report.reportType === 'GET_SALES_AND_TRAFFIC_REPORT'
+  && report.isParsed !== true
+  && report.processingStatus === 'DONE'
+  && report.reportDocumentId
+);
+
+for (const report of reports) {
+  const shop = shops.find(item => item.objectId === report.shop?.objectId);
+  if (!shop) {
+    log('sales_report_skipped', { reportId: report.reportId, reason: 'shop not found' });
+    continue;
+  }
+  try {
+    const document = await forward(shop.objectId,
+      `/reports/2021-06-30/documents/${encodeURIComponent(report.reportDocumentId)}`,
+      'GET', undefined, 5);
+    if (!document.url) throw new Error('Report document returned no URL');
+    const response = await fetch(document.url, { signal: AbortSignal.timeout(300000) });
+    if (!response.ok) throw new Error(`Report download failed: ${response.status}`);
+    let bytes = Buffer.from(await response.arrayBuffer());
+    if (document.compressionAlgorithm === 'GZIP') bytes = zlib.gunzipSync(bytes);
+    const rows = parseDocument(new TextDecoder('utf-8').decode(bytes));
+    const marketplaceKey = [...(report.marketplaceIds || [])].sort().join(',') || shop.marketplaceId;
+    const objects = rows.map(row => {
+      const rowHash = hash(row);
+      return {
+        recordKey: `${report.region || 'UNKNOWN'}:${marketplaceKey}:${report.reportType}:${rowHash}`,
+        rowHash,
+        reportType: report.reportType,
+        reportId: report.reportId,
+        region: report.region || '',
+        marketplaceIds: report.marketplaceIds || [],
+        capturedAt: dateValue(new Date()),
+        rowData: row,
+        rawData: row,
+        shop: shopPointer(shop.objectId),
+      };
+    });
+    const saved = await upsertMany('AmazonReportRow', objects);
+    await updateObject('Reports', report.objectId, {
+      isParsed: true,
+      parsedAt: dateValue(new Date()),
+      parsedRecords: objects.length,
+    });
+    log('sales_report_reparsed', {
+      marketplaceIds: report.marketplaceIds,
+      reportId: report.reportId,
+      rows: objects.length,
+      ...saved,
+    });
+  } catch (error) {
+    log('sales_report_reparse_failed', { reportId: report.reportId, error: error.message });
+  }
+  await sleep(1000);
+}
+
+log('sales_report_reparse_finished', { reports: reports.length });

+ 181 - 0
deploy/sp-api-collector-lib.mjs

@@ -0,0 +1,181 @@
+import crypto from 'node:crypto';
+
+export const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+export const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon';
+export const appId = process.env.PARSE_APP_ID;
+export const masterKey = process.env.PARSE_MASTER_KEY;
+export const runId = process.env.SP_API_RUN_ID || `spapi-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`;
+
+if (!appId || !masterKey) throw new Error('Missing PARSE_APP_ID or PARSE_MASTER_KEY');
+
+export const parseHeaders = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+export const shopPointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId });
+export const orderPointer = objectId => ({ __type: 'Pointer', className: 'Order', objectId });
+export const dateValue = value => {
+  if (!value) return undefined;
+  const date = new Date(value);
+  return Number.isNaN(date.getTime()) ? undefined : { __type: 'Date', iso: date.toISOString() };
+};
+
+export function cleanObject(value) {
+  return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
+}
+
+export function stableStringify(value) {
+  if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
+  }
+  return JSON.stringify(value);
+}
+
+export function hash(value) {
+  return crypto.createHash('sha256').update(typeof value === 'string' ? value : stableStringify(value)).digest('hex');
+}
+
+export function log(event, data = {}) {
+  console.log(JSON.stringify({ at: new Date().toISOString(), runId, event, ...data }));
+}
+
+export async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers: parseHeaders,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 400) || response.status}`);
+  }
+  return result;
+}
+
+export async function allRows(className, keys = '', where = undefined) {
+  const rows = [];
+  let skip = 0;
+  const whereParam = where === undefined ? '' : `&where=${encodeURIComponent(JSON.stringify(where))}`;
+  while (true) {
+    const page = await parseRequest(`/classes/${className}?limit=1000&skip=${skip}${keys ? `&keys=${keys}` : ''}${whereParam}`);
+    rows.push(...(page.results || []));
+    const length = page.results?.length || 0;
+    if (length < 1000) return rows;
+    skip += length;
+  }
+}
+
+export async function forward(shopId, path, method = 'GET', body = undefined, maxAttempts = 5) {
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    try {
+      const response = await fetch(`${apiUrl}/forward`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
+        body: JSON.stringify({ path, method, ...(body === undefined ? {} : { body }) }),
+        signal: AbortSignal.timeout(180000),
+      });
+      const text = await response.text();
+      let result = {};
+      try { result = JSON.parse(text); } catch {}
+      if (!response.ok || result.success === false) {
+        const error = new Error(result.message || result.errors?.[0]?.message || text.slice(0, 400) || `HTTP ${response.status}`);
+        error.status = Number(result.status || response.status);
+        throw error;
+      }
+      return result.data || result;
+    } catch (error) {
+      if (attempt === maxAttempts || [400, 401, 403, 404, 409, 422].includes(error.status)) throw error;
+      const delay = Math.min(attempt * 15000, 60000);
+      log('forward_retry', { shopId, path: path.split('?')[0], attempt, delay, error: error.message });
+      await sleep(delay);
+    }
+  }
+}
+
+const classCaches = new Map();
+
+async function classCache(className) {
+  if (!classCaches.has(className)) {
+    const rows = await allRows(className, 'objectId,recordKey');
+    classCaches.set(className, new Map(rows.filter(row => row.recordKey).map(row => [row.recordKey, row.objectId])));
+  }
+  return classCaches.get(className);
+}
+
+export async function upsertMany(className, rows) {
+  if (!rows.length) return { inserted: 0, updated: 0 };
+  rows = [...new Map(rows.map(row => [row.recordKey, row])).values()];
+  const cache = await classCache(className);
+  let inserted = 0;
+  let updated = 0;
+  for (let start = 0; start < rows.length; start += 25) {
+    const page = rows.slice(start, start + 25);
+    const requests = page.map(row => {
+      if (!row.recordKey) throw new Error(`${className} row is missing recordKey`);
+      const objectId = cache.get(row.recordKey);
+      return {
+        method: objectId ? 'PUT' : 'POST',
+        path: objectId ? `/parse/classes/${className}/${objectId}` : `/parse/classes/${className}`,
+        body: row,
+      };
+    });
+    const result = await parseRequest('/batch', 'POST', { requests });
+    for (let index = 0; index < page.length; index++) {
+      const response = result[index];
+      if (response?.error) throw new Error(`${className}: ${response.error.error || response.error.code}`);
+      if (cache.has(page[index].recordKey)) {
+        updated++;
+      } else {
+        cache.set(page[index].recordKey, response?.success?.objectId);
+        inserted++;
+      }
+    }
+  }
+  return { inserted, updated };
+}
+
+export async function updateObject(className, objectId, body) {
+  return parseRequest(`/classes/${className}/${objectId}`, 'PUT', body);
+}
+
+export async function writeAudit({ dataType, endpoint, status, shop, region = '', marketplaceId = '', records = 0, pages = 0, error = '', startedAt, details = {} }) {
+  const finishedAt = new Date();
+  return parseRequest('/classes/SpApiSyncAudit', 'POST', {
+    runId,
+    dataType,
+    endpoint,
+    status,
+    region,
+    marketplaceId,
+    records,
+    pages,
+    error: String(error || '').slice(0, 2000),
+    startedAt: dateValue(startedAt || finishedAt),
+    finishedAt: dateValue(finishedAt),
+    details,
+    ...(shop?.objectId ? { shop: shopPointer(shop.objectId) } : {}),
+  });
+}
+
+export const regionByMarketplace = {
+  ATVPDKIKX0DER: 'NA', A2EUQ1WTGCTBG2: 'NA', A1AM78C64UM0Y8: 'NA', A2Q3Y263D00KWC: 'NA',
+  A1F83G8C2ARO7P: 'EU', A1PA6795UKMFR9: 'EU', A13V1IB3VIYZZH: 'EU', A1RKKUPIHCS9HS: 'EU',
+  APJ6JRA9NG5V4: 'EU', A1805IZSGTT6HS: 'EU', A2NODRKZP88ZB9: 'EU', A1C3SOZRARQ6R3: 'EU',
+  AMEN7PMS3EDWL: 'EU', A28R8C7NBKEWEA: 'EU', A2VIGQ35RCS4UG: 'EU', A17E79C6D8DWNP: 'EU',
+  A39IBJ37TRP1C6: 'FE', A19VAU5U5O7RUS: 'FE',
+};
+
+export async function activeAmazonShops() {
+  const shops = await allRows('Shop');
+  return shops.filter(shop => shop.platform === 'amazon'
+    && shop.status === 'active'
+    && shop.disable !== true
+    && shop.deleted !== true
+    && shop.config?.SpApiConfig);
+}

+ 8 - 0
deploy/start-backend.sh

@@ -0,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+set -a
+. /etc/jianhen-voc/backend.env
+set +a
+
+exec /opt/jianhen-voc/bin/fmode-server --config /etc/jianhen-voc/config.json

+ 281 - 0
deploy/sync-sp-api-pages.mjs

@@ -0,0 +1,281 @@
+const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse';
+const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon';
+const appId = process.env.PARSE_APP_ID;
+const masterKey = process.env.PARSE_MASTER_KEY;
+const targetSellerId = process.env.SELLER_ID;
+
+if (!appId || !masterKey || !targetSellerId) {
+  throw new Error('Missing PARSE_APP_ID, PARSE_MASTER_KEY, or SELLER_ID');
+}
+
+const parseHeaders = {
+  'Content-Type': 'application/json',
+  'X-Parse-Application-Id': appId,
+  'X-Parse-Master-Key': masterKey,
+};
+
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const pointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId });
+const parseDate = value => value ? { __type: 'Date', iso: new Date(value).toISOString() } : undefined;
+
+function cleanObject(value) {
+  return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
+}
+
+function log(event, data = {}) {
+  console.log(JSON.stringify({ at: new Date().toISOString(), event, ...data }));
+}
+
+async function parseRequest(path, method = 'GET', body) {
+  const response = await fetch(`${parseUrl}${path}`, {
+    method,
+    headers: parseHeaders,
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.error) {
+    throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 300) || response.status}`);
+  }
+  return result;
+}
+
+async function forward(shopId, path) {
+  const response = await fetch(`${apiUrl}/forward`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId },
+    body: JSON.stringify({ path, method: 'GET' }),
+    signal: AbortSignal.timeout(180000),
+  });
+  const text = await response.text();
+  let result = {};
+  try { result = JSON.parse(text); } catch {}
+  if (!response.ok || result.success === false) {
+    throw new Error(result.message || result.errors?.[0]?.message || text.slice(0, 300) || `HTTP ${response.status}`);
+  }
+  return result.data || result;
+}
+
+async function loadExisting(className, shopId, identityKey) {
+  const where = encodeURIComponent(JSON.stringify({ shop: pointer(shopId) }));
+  const rows = new Map();
+  let skip = 0;
+  while (true) {
+    const page = await parseRequest(`/classes/${className}?where=${where}&keys=objectId,${identityKey}&limit=1000&skip=${skip}`);
+    for (const row of page.results || []) {
+      const identity = String(row[identityKey] || '').trim();
+      if (identity && !rows.has(identity)) rows.set(identity, row.objectId);
+    }
+    const length = page.results?.length || 0;
+    if (length < 1000) return rows;
+    skip += length;
+  }
+}
+
+async function saveRows(className, identityKey, rows, existing) {
+  let inserted = 0;
+  let updated = 0;
+  for (let start = 0; start < rows.length; start += 25) {
+    const batch = rows.slice(start, start + 25);
+    const requests = batch.map(row => {
+      const identity = String(row[identityKey] || '').trim();
+      const objectId = existing.get(identity);
+      return {
+        method: objectId ? 'PUT' : 'POST',
+        path: objectId ? `/parse/classes/${className}/${objectId}` : `/parse/classes/${className}`,
+        body: row,
+      };
+    });
+    const result = await parseRequest('/batch', 'POST', { requests });
+    for (let index = 0; index < batch.length; index++) {
+      const response = result[index];
+      if (response?.error) throw new Error(`${className} batch save: ${response.error.error || response.error.code}`);
+      const identity = String(batch[index][identityKey] || '').trim();
+      if (existing.has(identity)) {
+        updated++;
+      } else {
+        existing.set(identity, response?.success?.objectId);
+        inserted++;
+      }
+    }
+  }
+  return { inserted, updated };
+}
+
+function mapOrder(raw, shopId, isNew) {
+  const shippingAddress = raw.ShippingAddress || undefined;
+  const body = cleanObject({
+    platformOrderId: raw.AmazonOrderId,
+    orderId: raw.AmazonOrderId,
+    orderDate: parseDate(raw.PurchaseDate),
+    lastUpdateDate: parseDate(raw.LastUpdateDate),
+    status: raw.OrderStatus,
+    marketplaceId: raw.MarketplaceId,
+    totalAmount: raw.OrderTotal?.Amount === undefined ? undefined : Number(raw.OrderTotal.Amount),
+    currency: raw.OrderTotal?.CurrencyCode,
+    buyerInfo: raw.BuyerInfo,
+    shippingAddress,
+    customerRegion: shippingAddress?.CountryCode,
+    shippingCountry: shippingAddress?.CountryCode,
+    shippingState: shippingAddress?.StateOrRegion,
+    shippingCity: shippingAddress?.City,
+    fulfillmentChannel: raw.FulfillmentChannel,
+    orderType: raw.OrderType,
+    isPrime: Boolean(raw.IsPrime),
+    isBusinessOrder: Boolean(raw.IsBusinessOrder),
+    earliestShipDate: parseDate(raw.EarliestShipDate),
+    latestShipDate: parseDate(raw.LatestShipDate),
+    shop: pointer(shopId),
+  });
+  if (isNew) {
+    if (body.totalAmount === undefined) body.totalAmount = 0;
+    body.items = [];
+  }
+  return body;
+}
+
+function mapListing(raw, shopId, marketplaceId) {
+  const summary = (raw.summaries || []).find(item => item.marketplaceId === marketplaceId) || raw.summaries?.[0] || {};
+  const price = raw.attributes?.list_price?.[0];
+  const availability = raw.fulfillmentAvailability?.[0];
+  const parentRelation = raw.attributes?.child_parent_sku_relationship?.[0];
+  return cleanObject({
+    sku: raw.sku,
+    sellerSku: raw.sku,
+    asin: summary.asin,
+    parentSku: parentRelation?.parent_sku,
+    title: summary.itemName,
+    mainImage: summary.mainImage?.link,
+    productType: summary.productType,
+    status: summary.status?.[0],
+    createdDate: parseDate(summary.createdDate),
+    lastUpdatedDate: parseDate(summary.lastUpdatedDate),
+    marketplaceId: summary.marketplaceId || marketplaceId,
+    attributes: raw.attributes,
+    issues: raw.issues,
+    fulfillmentAvailability: raw.fulfillmentAvailability,
+    quantity: availability?.quantity,
+    price: price?.value === undefined ? undefined : Number(price.value),
+    currency: price?.currency,
+    shop: pointer(shopId),
+  });
+}
+
+async function syncOrders(shop, ordersStart) {
+  const existing = await loadExisting('Order', shop.objectId, 'platformOrderId');
+  const seenTokens = new Set();
+  let token = '';
+  let page = 0;
+  let fetched = 0;
+  let inserted = 0;
+  let updated = 0;
+  do {
+    page++;
+    if (page > 500) throw new Error('Orders pagination exceeded 500 pages');
+    const path = token
+      ? `/orders/v0/orders?NextToken=${encodeURIComponent(token)}`
+      : `/orders/v0/orders?MarketplaceIds=${encodeURIComponent(shop.marketplaceId)}&CreatedAfter=${encodeURIComponent(ordersStart)}`;
+    const result = await forward(shop.objectId, path);
+    const rows = result.payload?.Orders || [];
+    const bodies = rows
+      .filter(row => row.AmazonOrderId)
+      .map(row => mapOrder(row, shop.objectId, !existing.has(row.AmazonOrderId)));
+    const saved = await saveRows('Order', 'platformOrderId', bodies, existing);
+    fetched += rows.length;
+    inserted += saved.inserted;
+    updated += saved.updated;
+    log('orders_page', { shopId: shop.objectId, shopName: shop.name, page, fetched: rows.length, inserted: saved.inserted, updated: saved.updated });
+    token = String(result.payload?.NextToken || result.NextToken || '').trim();
+    if (token) {
+      if (seenTokens.has(token)) throw new Error('Orders API returned a repeated NextToken');
+      seenTokens.add(token);
+      await sleep(1200);
+    }
+  } while (token);
+  return { pages: page, fetched, inserted, updated, total: existing.size };
+}
+
+async function syncListings(shop, listingsStart) {
+  const existing = await loadExisting('Listing', shop.objectId, 'sku');
+  const basePath = `/listings/2021-08-01/items/${encodeURIComponent(targetSellerId)}`;
+  const baseQuery = `marketplaceIds=${encodeURIComponent(shop.marketplaceId)}`
+    + '&includedData=summaries,attributes,issues,fulfillmentAvailability'
+    + '&withStatus=BUYABLE,DISCOVERABLE'
+    + '&sortBy=lastUpdatedDate&sortOrder=ASC&pageSize=10'
+    + `&lastUpdatedAfter=${encodeURIComponent(listingsStart)}`;
+  const seenTokens = new Set();
+  let token = '';
+  let page = 0;
+  let fetched = 0;
+  let inserted = 0;
+  let updated = 0;
+  do {
+    page++;
+    if (page > 500) throw new Error('Listings pagination exceeded 500 pages');
+    const path = `${basePath}?${baseQuery}${token ? `&pageToken=${encodeURIComponent(token)}` : ''}`;
+    const result = await forward(shop.objectId, path);
+    const rows = result.items || [];
+    const bodies = rows.filter(row => row.sku).map(row => mapListing(row, shop.objectId, shop.marketplaceId));
+    const saved = await saveRows('Listing', 'sku', bodies, existing);
+    fetched += rows.length;
+    inserted += saved.inserted;
+    updated += saved.updated;
+    log('listings_page', { shopId: shop.objectId, shopName: shop.name, page, fetched: rows.length, inserted: saved.inserted, updated: saved.updated });
+    token = String(result.pagination?.nextToken || '').trim();
+    if (token) {
+      if (seenTokens.has(token)) throw new Error('Listings API returned a repeated pageToken');
+      seenTokens.add(token);
+      await sleep(1200);
+    }
+  } while (token);
+  return { pages: page, fetched, inserted, updated, total: existing.size };
+}
+
+const startedAt = new Date();
+const ordersStart = new Date(startedAt.getTime() - 729 * 24 * 60 * 60 * 1000).toISOString();
+const listingsStart = new Date(startedAt.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString();
+const shopsResult = await parseRequest('/classes/Shop?limit=1000');
+const shops = (shopsResult.results || []).filter(shop => {
+  const sp = shop.config?.SpApiConfig;
+  return shop.platform === 'amazon'
+    && shop.status === 'active'
+    && shop.disable !== true
+    && shop.deleted !== true
+    && sp?.sellerID === targetSellerId;
+});
+const details = [];
+
+log('paged_sync_started', { sellerId: targetSellerId, shops: shops.length, ordersStart, listingsStart });
+
+for (const shop of shops) {
+  const detail = { shopId: shop.objectId, shopName: shop.name, marketplaceId: shop.marketplaceId, orders: null, listings: null, errors: [] };
+  details.push(detail);
+  try {
+    detail.orders = await syncOrders(shop, ordersStart);
+    log('orders_completed', { shopId: shop.objectId, shopName: shop.name, ...detail.orders });
+  } catch (error) {
+    detail.errors.push(`orders: ${error.message}`);
+    log('orders_failed', { shopId: shop.objectId, shopName: shop.name, error: error.message });
+  }
+  if (shop.config.SpApiConfig.listingEnabled === false) {
+    detail.listings = { skipped: true, reason: 'regional seller ID not authorized' };
+  } else {
+    try {
+      detail.listings = await syncListings(shop, listingsStart);
+      log('listings_completed', { shopId: shop.objectId, shopName: shop.name, ...detail.listings });
+    } catch (error) {
+      detail.errors.push(`listings: ${error.message}`);
+      log('listings_failed', { shopId: shop.objectId, shopName: shop.name, error: error.message });
+    }
+  }
+}
+
+log('paged_sync_finished', {
+  sellerId: targetSellerId,
+  startedAt: startedAt.toISOString(),
+  finishedAt: new Date().toISOString(),
+  shops: shops.length,
+  errors: details.flatMap(item => item.errors.map(error => `${item.shopName}: ${error}`)),
+  details,
+});

+ 110 - 31
docs/验收清单.md

@@ -1,42 +1,121 @@
-# TARGET-voc-instance 验收清单
+# 建衡 VOC 实例验收清单
 
-## A. 目录与脱敏
+核对日期:2026-09-23。对照对象是线上 `dskb.jianhengyiliao.com`(`39.101.65.96`)和本地 `jianhen-voc` 工作区,不再使用 `TARGET` 占位模板。
 
-- [ ] `E:\workspace\TARGET-voc-instance` 存在完整子目录
-- [ ] 源仓 `msq-voc-web` / `moshengqi-server` / `future-server` 无本任务业务改动
-- [ ] `backend/config.json` 无真实 DB 密码 / 上游 Token
-- [ ] 前端无 `TIKHUB_TOKEN` / `TIKHUB_PROXY_URL`
-- [ ] 密钥扫描脚本无阻断项(或已登记已知占位符)
+状态:`[x]` 已在线上核实,`[~]` 只核实到部分,`[ ]` 未核或未通过。
 
-## B. 客户实例基础
+## 结论
 
-- [ ] 前端首页可访问
-- [ ] `/health` 正常
-- [ ] `/parse/health` 正常(配置真实 DB 后)
-- [ ] 用户登录成功
-- [ ] 退出后无法访问受保护页
+实例已部署并在提供服务:Nginx、fmode-server、PostgreSQL 16、Parse Dashboard 均在运行。Amazon 订单、Listing、商品和每日 SP-API 同步已有真实数据。
+
+未通过项:
+
+- 本地仓库仍是占位配置,且相对 `a92599a` 有约 72 个文件未提交;服务器 `/opt/jianhen-voc` 不是 git 仓库,两端没有可核对的同一版本。
+- 公司中转密钥已写入 `RelayCredential`,但配置文件中的 `relay.auth` 为空;浏览器登录后会向 `/api/relay/token` 取完整 `Bearer sk-`,再直连 `server.fmode.cn`。
+- 每日备份文件已生成,但 `jianhen-voc-backup.service` 自 2026-09-19 起因清理目录权限失败,systemd 结果为失败。
+- 生产配置、数据库备份和源码副本的文件权限过宽。
+- 本地工作区外有明文 SP-API、AWS 凭证和证书私钥,不在 git 忽略范围内。
+
+## A. 代码与部署一致性
+
+- [x] 线上目录 `/opt/jianhen-voc` 含 `frontend`、`backend`、`deploy`、`schema`、`scripts`、`docs`、`bin`
+- [x] 后端由 PM2 以 `voc` 用户运行:`/opt/jianhen-voc/bin/fmode-server --config /etc/jianhen-voc/config.json`
+- [x] 域名 `dskb.jianhengyiliao.com`,Nginx 已启用 80 跳 443,证书在 `/etc/letsencrypt/live/dskb.jianhengyiliao.com/`
+- [ ] 本地 `backend/config.json`、`frontend/src/assets/runtime-config.json` 仍是 `TARGET_*` 占位,与线上不一致
+- [ ] 线上代码没有 git 版本,无法证明与本地未提交改动一致
+- [x] 源仓 `msq-voc-web` / `moshengqi-server` / `future-server` 不在本机,本次未改
+
+## B. 运行状态
+
+- [x] `GET /` 返回前端 `index.html`
+- [x] `GET /health` 返回 200,服务名为 Fmode Server
+- [x] `GET /parse/health` 返回 `{"status":"ok"}`
+- [x] `GET /api/ping` 返回 `jianhen-voc`
+- [x] `GET /api/relay/health`、`GET /api/amazon/health` 未登录返回 401
+- [x] 前端路由有登录页,业务路由带 `AuthGuard`;登录云函数为 `user_login`,密码经 RSA 加密后由服务端解密
+- [ ] 未使用客户账号完成登录、退出和越权访问复测
+- [~] 仅存在 `admin`(`isAdmin=true`、`status=active`),没有普通用户可验证权限隔离
+
+线上运行时配置:
+
+| 项 | 实际值 |
+| --- | --- |
+| Parse AppId | `jianhen-voc` |
+| Parse Server | `https://dskb.jianhengyiliao.com/parse` |
+| API | `https://dskb.jianhengyiliao.com` |
+| 数据库 | PostgreSQL 16,仅监听 `127.0.0.1:5432`,库 `jianhen_voc` |
+| Master Key 来源限制 | `127.0.0.1/32`、`::1/128` |
+| 客户端建类 | 已关闭 |
+| Dashboard | `127.0.0.1:4040`,由 `/dbadmin/` 反代,账号 `vocadmin` |
 
 ## C. 公司中转
 
-- [ ] 替换 `sk-TARGET_RELAY_SK` 为公司发放凭证
-- [ ] `POST /api/sorftime/forward` 成功
-- [ ] `POST /api/tikhub/forward` 成功
-- [ ] 浏览器 Network 无上游 Token、无 `server.fmode.cn/thapi` 直连
-- [ ] 后端日志无完整 sk-/Token
-- [ ] 响应含 `X-Request-Id`
-- [ ] 错误 sk 返回可诊断 401/503,不回退直连供应商
+- [x] `RelayCredential` 有 1 条记录,掩码 `sk-****62c6`,2026-09-22 更新
+- [x] `/etc/jianhen-voc/config.json` 的 `relay.auth` 为空,占位 sk 未留在生产配置
+- [ ] 未复测 Sorftime / TikHub 的实际上游调用结果
+- [ ] 浏览器仍会获取完整 sk,并按 `runtime-config.json` 直连 `https://server.fmode.cn`
+- [ ] 因此不能满足“浏览器 Network 不出现上游 Token、不直连公司中转”的原验收要求
+- [~] Nginx 给 `/api/` 注入 `X-Request-Id`;后端业务响应本身未逐项复测
+- [ ] 错误 sk 的 401/503 行为本次未复测
+
+## D. 业务数据
+
+统计时点约为 2026-09-23 11:15 CST。
+
+- [x] Amazon 店铺:`Shop` 18 条,16 条 `active`,2 条 `inactive`
+- [x] 商品与 Listing:`Product` 709、`ProductDetail` 709、`Listing` 757、`AsinSkuMapping` 757
+- [x] 订单:`Order` 3227、`AmazonOrderItem` 3058;另有销售、库存、财务、报表等 SP-API 表
+- [~] VOC 评论:`SorftimeReviews` 31 条,最近更新 2026-09-18,覆盖明显不足
+- [ ] TikHub 社媒查询未复测,相关缓存表尚无有效行估计
+- [ ] Sorftime 类目、商品、关键词页面未复测
+- [x] `TaskExecutionLog` 有 `sp-api-daily-sync=success`,最近一次 2026-09-23 01:52;`initial-listing-sync` 为 `partial`
+- [x] `jianhen-voc-sp-api-refresh.timer` 每天 03:15(上海时间,10 分钟随机延迟)执行,2026-09-23 03:20 启动并于 04:29 成功退出
+- [ ] 退货表 `ReturnRecord` 有 96 行,但退货分析页面未复测
+
+## E. 隔离与暴露面
+
+- [x] 使用独立 AppId `jianhen-voc`、独立 Master Key 和独立数据库
+- [x] 使用独立域名;PostgreSQL 与 Parse Dashboard 只绑定本机
+- [x] 公网直接访问 `:3000`、`:4040` 超时,当前只探到 22/80/443
+- [ ] 主机防火墙未启用,INPUT 策略为 ACCEPT;3000 端口监听 `0.0.0.0`,目前只是被外层网络挡住
+- [~] `allowOrigin` 仍是 `http://dskb.jianhengyiliao.com`,与实际 HTTPS 入口不一致
+- [x] 公开注册云函数要求 master;匿名用户已关闭
+- [ ] path 白名单是否能阻止任意 URL 代理,本次未做请求级测试
+
+## F. 密钥扫描(2026-09-23)
+
+扫描范围:本地 `D:\Angular\jianhen`,服务器 `/opt/jianhen-voc`、`/etc/jianhen-voc`、`/etc/parse-dashboard`、`/etc/nginx`、`/root`。只记录类型和位置,不记录密钥内容。
+
+### 通过
+
+- [x] 本地及服务器运行代码未发现真实 `sk-`、TikHub Token、`api.tikhub.io`、`standardapi.sorftime.com`、旧 BasicAuth 或旧数据库密码
+- [x] 仓库内 `config.json`、`.env.example` 只有 `TARGET_*` 占位
+- [x] 前端构建产物中的 `BEGIN RSA PRIVATE KEY` 是加密库模板,不是部署私钥
+- [x] 生产 Master Key、数据库 URI、登录私钥、Dashboard 口令都在配置或数据库中,不在仓库业务源码中
+
+### 不通过
+
+- [ ] 本地明文文件未纳入版本忽略且位于仓库外:
+  - `D:\Angular\jianhen\SP-API秘钥信息.txt`:6 个 LWA refresh token、1 个 LWA client secret
+  - `D:\Angular\jianhen\yongge-IAM_accessKeys.csv`:1 对 AWS Access Key
+  - `D:\Angular\jianhen\dskb_cert\dskb.jianhengyiliao.com.key`:证书私钥
+  - `D:\Angular\jianhen\root.txt`:服务器 root 口令
+- [ ] `/etc/jianhen-voc/config.json` 含真实数据库 URI 和 Master Key,权限为 `644`
+- [ ] `/var/backups/jianhen-voc/*.dump` 权限为 `644`,任何本机用户可读整库备份
+- [ ] `/opt/jianhen-voc/source` 和多份 `frontend/dist*` 备份权限为 `666`
+- [ ] 登录私钥文件权限正确(`640 root:voc`),但证书 live 目录为 `755`,目录内私钥符号链接显示为 `777`;真实归档私钥仍是 `600`
 
-## D. 业务(配置真实数据后)
+## G. 备份
 
-- [ ] Amazon 店铺连通
-- [ ] Listing / Order 同步
-- [ ] VOC 看板读客户库
-- [ ] TikHub 社媒查询
-- [ ] Sorftime 类目/商品/关键词/评论
-- [ ] 定时任务写 TaskExecutionLog
+- [x] 每天 03:30(上海时间,10 分钟随机延迟)触发备份
+- [x] 2026-09-23 03:36 已写出 6.8 MB 备份
+- [ ] `backup.sh` 的过期清理遇到 root 所有的 `pre-fix-20260919-095758` 返回权限错误,服务被 systemd 记为失败
+- [ ] 备份保留策略因此没有正常执行
 
-## E. 隔离
+## 后续顺序
 
-- [ ] 独立 databaseURI / Parse AppId / Master Key
-- [ ] 独立域名与日志目录
-- [ ] 修改 path 不能变成任意 URL 代理
+1. 把本地明文凭证移出工作区或加密保管,并轮换已经出现在明文文件中的 SP-API、AWS 和 root 口令。
+2. 收紧 `config.json`、数据库备份、`source` 与前端备份目录权限。
+3. 修正备份清理的目录所有权,确认次日 timer 结果为 `success`。
+4. 确定浏览器直连公司中转是否为最终架构;若不是,改回服务端转发并停止下发完整 sk。
+5. 给线上部署建立可追溯版本后,再提交或同步本地 72 个文件的改动。

+ 3 - 2
frontend/src/app/app.config.ts

@@ -1,15 +1,16 @@
 import { ApplicationConfig } from '@angular/core';
 import { provideRouter } from '@angular/router';
-import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
 import { provideEchartsCore } from 'ngx-echarts';
 
 import { routes } from './app.routes';
+import { apiSessionInterceptor } from './core/services/api-session.interceptor';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideRouter(routes),
-    provideHttpClient(withInterceptorsFromDi()),
+    provideHttpClient(withInterceptors([apiSessionInterceptor])),
     provideAnimationsAsync(),
     provideEchartsCore({ echarts: () => import('./echarts').then((m) => m.default) }),
   ]

+ 9 - 0
frontend/src/app/core/services/api-auth.ts

@@ -0,0 +1,9 @@
+import { MSQParse } from '../../parse';
+
+export async function getApiAuthHeaders(): Promise<Record<string, string>> {
+  const user = await MSQParse.User.current();
+  const token = user && typeof user.getSessionToken === 'function'
+    ? user.getSessionToken()
+    : '';
+  return token ? { 'X-Parse-Session-Token': token } : {};
+}

+ 11 - 25
frontend/src/app/core/services/api-connectivity.service.ts

@@ -40,8 +40,7 @@ export class ApiConnectivityService {
             this.testSorftime(),
             this.testSorftimeCategoryTree(),
             this.testSorftimeProductQuery(),
-            this.testSorftimeKeywordQuery(),
-            this.testSorftimeProductReviews()
+            this.testSorftimeKeywordQuery()
         ]);
     }
 
@@ -105,30 +104,10 @@ export class ApiConnectivityService {
         );
     }
 
-    /** Sorftime — ProductReviewsQuery 端点 */
-    private testSorftimeProductReviews(): Observable<ConnectivityResult> {
-        const start = Date.now();
-        return this.sorftimeApi.getProductReviews('B08N5WRWNW').pipe(
-            timeout(API_CONFIG.TIMEOUT),
-            map((resp: any) => ({
-                service: 'Sorftime ProductReviews',
-                status: 'ok' as const,
-                latency: Date.now() - start,
-                message: `评论查询正常`
-            })),
-            catchError(err => of({
-                service: 'Sorftime ProductReviews',
-                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
-                latency: Date.now() - start,
-                message: err?.message || '连接失败'
-            }))
-        );
-    }
-
     /** 测试 Amazon SP-API */
     testAmazon(): Observable<ConnectivityResult> {
         const start = Date.now();
-        return this.amazonRaw.test().pipe(
+        return this.amazonRaw.getMarketplaceParticipations().pipe(
             timeout(API_CONFIG.TIMEOUT),
             map(() => ({
                 service: 'Amazon SP-API',
@@ -148,7 +127,10 @@ export class ApiConnectivityService {
     /** 测试 Sorftime API */
     testSorftime(): Observable<ConnectivityResult> {
         const start = Date.now();
-        return this.api.rawGet<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/test`).pipe(
+        return this.api.sorftimeForward<any>('/api/ProductQuery', {
+            query: { domain: 1 },
+            body: { Query: 1, QueryType: 7, Pattern: 'dress', Page: 1 }
+        }).pipe(
             timeout(API_CONFIG.TIMEOUT),
             map(() => ({
                 service: 'Sorftime API',
@@ -168,7 +150,11 @@ export class ApiConnectivityService {
     /** 测试 TikHub API */
     testTikHub(): Observable<ConnectivityResult> {
         const start = Date.now();
-        return this.api.rawPost<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`, { path: '/api/v1/health/check', method: 'GET' }).pipe(
+        return this.api.tikhubGet<any>('/api/v1/tiktok/app/v3/fetch_video_search_result', {
+            keyword: 'dress',
+            sort_type: 0,
+            publish_time: 0
+        }).pipe(
             timeout(API_CONFIG.TIMEOUT),
             map(() => ({
                 service: 'TikHub API',

+ 24 - 0
frontend/src/app/core/services/api-session.interceptor.ts

@@ -0,0 +1,24 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+import { from, switchMap } from 'rxjs';
+import { getApiAuthHeaders } from './api-auth';
+
+function isSameOriginApi(url: string): boolean {
+  if (!url.includes('/api/')) return false;
+  if (typeof window === 'undefined' || !window.location?.origin) {
+    return url.startsWith('/api/');
+  }
+  try {
+    const resolved = new URL(url, window.location.origin);
+    return resolved.origin === window.location.origin && resolved.pathname.startsWith('/api/');
+  } catch {
+    return url.startsWith('/api/');
+  }
+}
+
+export const apiSessionInterceptor: HttpInterceptorFn = (request, next) => {
+  if (!isSameOriginApi(request.url)) return next(request);
+
+  return from(getApiAuthHeaders()).pipe(
+    switchMap(headers => next(request.clone({ setHeaders: headers })))
+  );
+};

+ 4 - 4
frontend/src/app/core/services/api.config.ts

@@ -18,11 +18,11 @@ export const API_CONFIG = {
   /** Amazon SP-API 前缀 */
   AMAZON_PREFIX: '/api/amazon',
 
-  /** Sorftime API 前缀 */
-  SORFTIME_PREFIX: '/api/sorftime',
+  /** Sorftime 公司中转前缀(仅作路径说明,实际请求走 runtime.relay) */
+  SORFTIME_PREFIX: '/api/voc-ecom',
 
-  /** TikHub API 前缀(客户后端转发) */
-  TIKHUB_PREFIX: '/api/tikhub',
+  /** TikHub 公司中转前缀(仅作路径说明,实际请求走 runtime.relay) */
+  TIKHUB_PREFIX: '/api/voc-social',
 
   /** 数据清洗统一接口前缀 (v2) */
   CLEAN_PREFIX: '/api/v2',

+ 11 - 4
frontend/src/app/core/services/category-api.service.ts

@@ -19,8 +19,9 @@ export class CategoryApiService {
         platform?: 'amazon' | 'sorftime';
         domain?: string;
     }): Observable<any> {
-        return this.api.sorftimeForward('/categories/tree', {
-            domain: params?.domain
+        return this.api.sorftimeForward('/api/CategoryTree', {
+            query: { domain: params?.domain || 1 },
+            body: {}
         });
     }
 
@@ -29,7 +30,10 @@ export class CategoryApiService {
         page?: number;
         pageSize?: number;
     }): Observable<any> {
-        return this.api.sorftimeForward(`/categories/${categoryId}/top-products`, params);
+        return this.api.sorftimeForward('/api/CategoryProducts', {
+            query: { domain: 1 },
+            body: { NodeId: categoryId, Page: params?.page || 1, Range: params?.pageSize || 100 }
+        });
     }
 
     /** 类目关键词 — 通过 Sorftime 获取 */
@@ -37,6 +41,9 @@ export class CategoryApiService {
         page?: number;
         pageSize?: number;
     }): Observable<any> {
-        return this.api.sorftimeForward(`/categories/${categoryId}/keywords`, params);
+        return this.api.sorftimeForward('/api/CategoryRequestKeyword', {
+            query: { domain: 1 },
+            body: { Nodeid: categoryId, PageIndex: params?.page || 1, PageSize: params?.pageSize || 100 }
+        });
     }
 }

+ 65 - 0
frontend/src/app/core/services/company-relay-auth.service.ts

@@ -0,0 +1,65 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { API_CONFIG } from './api.config';
+
+const CACHE_TTL_MS = 60 * 1000;
+
+@Injectable({ providedIn: 'root' })
+export class CompanyRelayAuthService {
+    private memoryAuth = '';
+    private memoryExpiresAt = 0;
+    private inflight: Promise<string> | null = null;
+
+    constructor(private http: HttpClient) {}
+
+    peekCachedAuth(): string {
+        if (this.memoryAuth && this.memoryExpiresAt > Date.now()) {
+            return this.memoryAuth;
+        }
+        this.memoryAuth = '';
+        this.memoryExpiresAt = 0;
+        return '';
+    }
+
+    async getAuthorization(force = false): Promise<string> {
+        if (!force) {
+            const cached = this.peekCachedAuth();
+            if (cached) return cached;
+        }
+        if (this.inflight) return this.inflight;
+        this.inflight = this.fetchAuthorization()
+            .catch(err => {
+                this.clear();
+                throw err;
+            })
+            .finally(() => {
+                this.inflight = null;
+            });
+        return this.inflight;
+    }
+
+    clear(): void {
+        this.memoryAuth = '';
+        this.memoryExpiresAt = 0;
+        this.inflight = null;
+    }
+
+    private async fetchAuthorization(): Promise<string> {
+        const res = await firstValueFrom(
+            this.http.get<any>(`${API_CONFIG.BASE_URL}/api/relay/token`)
+        );
+        const auth = String(res?.data?.authorization || res?.authorization || '').trim();
+        if (!this.isUsable(auth)) {
+            throw new Error(res?.message || '尚未配置 API 密钥');
+        }
+        this.memoryAuth = auth.startsWith('Bearer ') ? auth : `Bearer ${auth}`;
+        this.memoryExpiresAt = Date.now() + CACHE_TTL_MS;
+        return this.memoryAuth;
+    }
+
+    private isUsable(auth: string): boolean {
+        const token = String(auth || '').replace(/^Bearer\s+/i, '').trim();
+        return token.startsWith('sk-') && token.length >= 20;
+    }
+}

+ 30 - 3
frontend/src/app/core/services/data-store.service.ts

@@ -153,6 +153,33 @@ export class DataStoreService {
      * 调用 Parse Cloud Function
      */
     cloudFunction(params: { id: string;[key: string]: any }): Observable<any> {
+        if (params.id === '1tZqfcAys1' || params.id === 'RFDDkJ2usG') {
+            const { id: _id, ...payload } = params;
+            return from(MSQParse.Cloud.run('dashboard_metrics', payload)).pipe(
+                catchError(err => {
+                    console.warn('[DataStore] dashboard_metrics 调用失败(已回退):', err?.message || err);
+                    return of(null);
+                })
+            );
+        }
+        if (params.id === 'BdZWIv7cqu') {
+            const { id: _id, ...payload } = params;
+            return from(MSQParse.Cloud.run('upsert_amazon_product', payload)).pipe(
+                catchError(err => {
+                    console.warn('[DataStore] upsert_amazon_product 调用失败:', err?.message || err);
+                    return of(null);
+                })
+            );
+        }
+        if (params.id === 'TUQezljBv9') {
+            const { id: _id, ...payload } = params;
+            return from(MSQParse.Cloud.run('return_records', payload)).pipe(
+                catchError(err => {
+                    console.warn('[DataStore] return_records 调用失败:', err?.message || err);
+                    return of({ success: false, data: [], message: err?.message || '退货数据加载失败' });
+                })
+            );
+        }
         return from(MSQParse.Cloud.function(params)).pipe(
             catchError(err => {
                 console.warn(`[DataStore] Cloud Function 调用失败(已回退):`, err?.message || err);
@@ -227,7 +254,6 @@ export class DataStoreService {
                 Object.entries(data).forEach(([key, value]) => {
                     existing.set(key, value);
                 });
-                existing.set('updatedAt_custom', new Date().toISOString());
                 return existing.save();
             }
         }
@@ -237,7 +263,6 @@ export class DataStoreService {
         Object.entries(data).forEach(([key, value]) => {
             obj.set(key, value);
         });
-        obj.set('createdAt_custom', new Date().toISOString());
         return obj.save();
     }
 
@@ -410,7 +435,9 @@ export class DataStoreService {
                 typeof value.toPointer === 'function'
                 || value.__type === 'Pointer'
                 || value.__type === 'Object'
-                || (typeof value.className === 'string' && (value.attributes || value.id))
+                || (typeof value.className === 'string'
+                    && typeof value.get === 'function'
+                    && (value.id || value.objectId))
             );
     }
 

+ 16 - 16
frontend/src/app/core/services/data-sync.service.ts

@@ -38,21 +38,21 @@ export class DataSyncService {
     syncOrders(startDate: string, endDate: string): Observable<SyncResult> {
         return this.orderApi.getOrders({ startDate, endDate, pageSize: 100 }).pipe(
             switchMap(data => {
-                if (data?.items?.length) {
-                    return this.dataStore.saveBatch('Order', data.items.map((o: any) => ({
-                        orderId: o.orderId,
-                        orderDate: o.orderDate,
-                        status: o.status,
-                        totalAmount: o.total?.amount || 0,
-                        currency: o.total?.currency || 'USD',
-                        itemCount: o.itemCount,
-                        buyerName: o.buyerName,
-                        channel: o.channel
-                    })), 'orderId').pipe(
+                const orders = data?.payload?.Orders || data?.Orders || data?.items || [];
+                if (orders.length) {
+                    return this.dataStore.saveBatch('Order', orders.map((o: any) => ({
+                        platformOrderId: o.AmazonOrderId || o.orderId,
+                        orderDate: o.PurchaseDate || o.orderDate,
+                        status: o.OrderStatus || o.status,
+                        totalAmount: Number(o.OrderTotal?.Amount || o.total?.amount || 0),
+                        currency: o.OrderTotal?.CurrencyCode || o.total?.currency || 'USD',
+                        marketplaceId: o.MarketplaceId,
+                        fulfillmentChannel: o.FulfillmentChannel
+                    })), 'platformOrderId').pipe(
                         map(() => ({
                             module: '订单', success: true,
-                            count: data.items.length,
-                            message: `成功同步 ${data.items.length} 条订单`
+                            count: orders.length,
+                            message: `成功同步 ${orders.length} 条订单`
                         }))
                     );
                 }
@@ -102,7 +102,7 @@ export class DataSyncService {
                         asin: p.asin,
                         title: p.title,
                         imageUrl: p.imageUrl,
-                        priceAmount: p.price?.amount || 0,
+                        price: p.price?.amount || 0,
                         currency: p.price?.currency || 'USD',
                         rating: p.rating,
                         reviewCount: p.reviewCount,
@@ -211,7 +211,7 @@ export class DataSyncService {
                 if (data?.items?.length) {
                     return this.dataStore.saveBatch('Listing', data.items.map((l: any) => ({
                         sku: l.sku, asin: l.asin, title: l.title,
-                        status: l.status, priceAmount: l.price?.amount || 0,
+                        status: l.status, price: l.price?.amount || 0,
                         currency: l.price?.currency || 'USD',
                         imageUrl: l.imageUrl, condition: l.condition,
                         createdAt: l.createdAt
@@ -241,7 +241,7 @@ export class DataSyncService {
                         reviewId: r.id, asin, rating: r.rating,
                         title: r.title, content: r.content,
                         author: r.author, verified: r.verified,
-                        createTime: r.createTime
+                        reviewDate: r.createTime
                     })), 'reviewId').pipe(
                         map(() => ({
                             module: '评论', success: true,

+ 129 - 31
frontend/src/app/core/services/http-api.service.ts

@@ -1,12 +1,86 @@
 import { Injectable } from '@angular/core';
 import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
-import { Observable, throwError } from 'rxjs';
-import { map, catchError, retry } from 'rxjs/operators';
+import { Observable, throwError, from, of } from 'rxjs';
+import { map, catchError, retry, switchMap } from 'rxjs/operators';
 import { API_CONFIG } from './api.config';
+import { getRuntimeConfigSync } from './runtime-config.service';
 import { ApiResponse, PaginatedResponse } from '../models/api.models';
+import { CompanyRelayAuthService } from './company-relay-auth.service';
 
 const SHOP_OBJECT_ID_KEY = 'msq_shop_objectId';
 
+const TIKHUB_MEDIA_PREFIXES = [
+    'tiktok/',
+    'instagram/',
+    'youtube/',
+    'twitter/',
+    'x/',
+    'facebook/',
+    'linkedin/',
+    'reddit/',
+    'pinterest/',
+    'threads/',
+];
+
+function companyRelayBase(): string {
+    const cfg = getRuntimeConfigSync();
+    return String(cfg.relay?.baseUrl || 'https://server.fmode.cn').replace(/\/+$/, '');
+}
+
+function companySorftimePath(): string {
+    const cfg = getRuntimeConfigSync();
+    const path = String(cfg.relay?.sorftimePath || '/api/voc-ecom/forward');
+    return path.startsWith('/') ? path : `/${path}`;
+}
+
+function companyTikhubPath(): string {
+    const cfg = getRuntimeConfigSync();
+    const path = String(cfg.relay?.tikhubPath || '/api/voc-social');
+    return path.startsWith('/') ? path : `/${path}`;
+}
+
+function companyAuthHeaders(authorization?: string): HttpHeaders {
+    let headers = new HttpHeaders({
+        Accept: 'application/json',
+        'Content-Type': 'application/json',
+    });
+    if (authorization) {
+        const token = authorization.startsWith('Bearer ') ? authorization : `Bearer ${authorization}`;
+        headers = headers.set('Authorization', token);
+    }
+    return headers;
+}
+
+function normalizeTikhubSuffix(rawPath: string): string {
+    if (!rawPath || typeof rawPath !== 'string') {
+        throw new Error('Missing required parameter: path');
+    }
+    if (rawPath.includes('://') || rawPath.includes('..') || rawPath.includes('\\')) {
+        throw new Error('Path must be a relative API path');
+    }
+    let path = rawPath.trim().replace(/^\/+/, '');
+    if (path.startsWith('api/v1/')) path = path.slice('api/v1/'.length);
+    else if (path.startsWith('v1/')) path = path.slice('v1/'.length);
+    path = path.replace(/^\/+/, '');
+    const allowed = TIKHUB_MEDIA_PREFIXES.some(prefix => path.startsWith(prefix));
+    if (!allowed) {
+        throw new Error(`TikHub path not allowed: ${path}`);
+    }
+    return path;
+}
+
+function unwrapCompanyData(res: any): any {
+    if (res?.success === false) {
+        throw new Error(res?.message || '上游接口返回失败');
+    }
+    const code = res?.Code ?? res?.code;
+    if (typeof code === 'number' && code !== 200 && code !== 0) {
+        throw new Error(res?.Message || res?.message || `API Error ${code}`);
+    }
+    return res?.Data ?? res?.data ?? res;
+}
+
+
 /**
  * 核心 HTTP 请求服务
  * 封装统一的请求/响应处理、错误处理、重试逻辑
@@ -17,7 +91,10 @@ const SHOP_OBJECT_ID_KEY = 'msq_shop_objectId';
 export class HttpApiService {
     private shopObjectId: string | null = null;
 
-    constructor(private http: HttpClient) {
+    constructor(
+        private http: HttpClient,
+        private companyRelayAuth: CompanyRelayAuthService
+    ) {
         // 从 localStorage 恢复 shopObjectId
         try {
             this.shopObjectId = localStorage.getItem(SHOP_OBJECT_ID_KEY);
@@ -90,50 +167,53 @@ export class HttpApiService {
 
     // ==================== Sorftime API ====================
 
-    /** POST 转发请求 - Sorftime 接口 */
+    /** POST 公司中转 - Sorftime 接口 */
     sorftimeForward<T>(apiPath: string, body?: any): Observable<T> {
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/forward`;
+        const url = `${companyRelayBase()}${companySorftimePath()}`;
         const payload = {
             path: apiPath,
-            method: 'POST',
-            body: body?.body ?? {},
-            query: body?.query ?? {}
+            method: (body?.method || 'POST').toUpperCase(),
+            query: body?.query ?? {},
+            body: body?.body ?? {}
         };
-        return this.http.post<any>(url, payload).pipe(
-            map(res => {
-                // 兼容两类错误结构:
-                // 1) { success:false, message:"..." }
-                // 2) { Code:10, Message:"请求参数错误", Data: ... }
-                if (res?.success === false) {
-                    throw new Error(res?.message || 'Sorftime 上游接口返回失败');
-                }
-                if (typeof res?.Code === 'number' && res.Code !== 200 && res.Code !== 0) {
-                    throw new Error(res?.Message || `Sorftime API Error ${res.Code}`);
-                }
-                return res?.Data ?? res?.data ?? res;
-            }),
+        return this.withCompanyAuth(headers => this.http.post<any>(url, payload, { headers })).pipe(
+            map(res => unwrapCompanyData(res)),
             catchError(err => {
-                console.warn(`[Sorftime] ${apiPath} 调用失败 (上游API可能未就绪):`, err?.status || '', err?.error?.message || err?.message || '');
-                return throwError(() => err);
+                console.warn(`[Sorftime] ${apiPath} 调用失败 (上游API可能未就绪):`, err?.status || '', err?.error?.mess || err?.error?.message || err?.message || '');
+                return this.handleError(err);
             })
         );
     }
 
     // ==================== TikHub API ====================
 
-    /** POST 转发请求 - TikHub 接口(客户后端 → 公司中转) */
+    /** 公司中转 - TikHub 接口(直打 /api/voc-social/{suffix},无 forward 包装) */
     tikhubForward<T>(apiPath: string, body?: any): Observable<T> {
-        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`;
-        return this.http.post<any>(url, { path: apiPath, ...body }).pipe(
+        const method = String(body?.method || 'GET').toUpperCase();
+        const query = (body && typeof body === 'object' && !Array.isArray(body) && ('query' in body || 'method' in body || 'body' in body))
+            ? (body.query || {})
+            : (body || {});
+        const payloadBody = body?.body ?? {};
+        const suffix = normalizeTikhubSuffix(apiPath);
+        const url = `${companyRelayBase()}${companyTikhubPath()}/${suffix}`;
+        return this.withCompanyAuth(headers => {
+            const options = {
+                params: this.buildParams(query),
+                headers
+            };
+            return ['POST', 'PUT', 'PATCH'].includes(method)
+                ? this.http.request<any>(method, url, { ...options, body: payloadBody })
+                : this.http.request<any>(method, url, options);
+        }).pipe(
             retry(1),
-            map(res => res?.data ?? res),
+            map(res => unwrapCompanyData(res)),
             catchError(err => this.handleError(err))
         );
     }
 
     /**
      * GET 语义的 TikHub 请求。
-     * 独立实例禁止浏览器直连 thapi / 携带上游 Token,统一走后端 forward。
+     * 浏览器直连公司 /api/voc-social/{suffix},不经客户 /forward。
      */
     tikhubGet<T>(apiPath: string, params?: Record<string, any>): Observable<T> {
         return this.tikhubForward<T>(apiPath, {
@@ -157,6 +237,24 @@ export class HttpApiService {
         );
     }
 
+
+    private withCompanyAuth<T>(run: (headers: HttpHeaders) => Observable<T>): Observable<T> {
+        const invoke = (force: boolean) => {
+            const cached = force ? '' : this.companyRelayAuth.peekCachedAuth();
+            const auth$ = cached ? of(cached) : from(this.companyRelayAuth.getAuthorization(force));
+            return auth$.pipe(switchMap(auth => run(companyAuthHeaders(auth))));
+        };
+        return invoke(false).pipe(
+            catchError(err => {
+                if (err?.status === 401) {
+                    this.companyRelayAuth.clear();
+                    return invoke(true);
+                }
+                return throwError(() => err);
+            })
+        );
+    }
+
     // ==================== 内部方法 ====================
 
     private buildParams(params?: Record<string, any>): HttpParams {
@@ -187,11 +285,11 @@ export class HttpApiService {
     }
 
     private handleError(error: any): Observable<never> {
-        let message = '请求失败';
+        let message = error?.error?.mess || error?.error?.message || error?.error?.error || error?.message || '请求失败';
         if (error?.status) {
             switch (error.status) {
-                case 400: message = '请求参数错误'; break;
-                case 401: message = '认证失败,请检查店铺配置'; break;
+                case 400: message = error?.error?.mess || error?.error?.message || '请求参数错误'; break;
+                case 401: message = error?.error?.mess || error?.error?.message || '认证失败,请先在 API 充值中保存密钥'; break;
                 case 403: message = '权限不足'; break;
                 case 429: message = '请求过于频繁,请稍后重试'; break;
                 case 500: message = '服务器内部错误'; break;

+ 1 - 0
frontend/src/app/core/services/index.ts

@@ -1,5 +1,6 @@
 // Core API Services
 export { HttpApiService } from './http-api.service';
+export { CompanyRelayAuthService } from './company-relay-auth.service';
 export { API_CONFIG } from './api.config';
 
 // Domain API Services

+ 4 - 1
frontend/src/app/core/services/keyword-api.service.ts

@@ -17,7 +17,10 @@ export class KeywordApiService {
         page?: number;
         pageSize?: number;
     }): Observable<any> {
-        return this.api.sorftimeForward('/keywords/search', params);
+        return this.api.sorftimeForward('/api/KeywordQuery', {
+            query: { domain: 1 },
+            body: { keyword: params.keyword, PageIndex: params.page || 1, PageSize: params.pageSize || 50 }
+        });
     }
 
     /** 关键词下的产品排名 */

+ 20 - 4
frontend/src/app/core/services/runtime-config.service.ts

@@ -8,14 +8,19 @@ export interface RuntimeConfig {
   };
   apiBaseUrl: string;
   relay: {
+    baseUrl?: string;
     sorftimePath: string;
     tikhubPath: string;
+    auth?: string;
   };
   ai?: {
     apiUrl?: string;
     novaParseUrl?: string;
     novaAppId?: string;
   };
+  auth?: {
+    loginPublicKey?: string;
+  };
   features?: {
     socialMedia?: boolean;
     marketInsight?: boolean;
@@ -31,8 +36,9 @@ const DEFAULT_CONFIG: RuntimeConfig = {
   },
   apiBaseUrl: '',
   relay: {
-    sorftimePath: '/api/sorftime/forward',
-    tikhubPath: '/api/tikhub/forward',
+    baseUrl: 'https://server.fmode.cn',
+    sorftimePath: '/api/voc-ecom/forward',
+    tikhubPath: '/api/voc-social',
   },
   ai: {
     apiUrl: 'https://server.fmode.cn/api/apig/aigc/gpt/v1/chat/completions',
@@ -49,6 +55,12 @@ const DEFAULT_CONFIG: RuntimeConfig = {
 let cached: RuntimeConfig | null = null;
 let loadPromise: Promise<RuntimeConfig> | null = null;
 
+function absoluteRuntimeUrl(value: string): string {
+  const raw = String(value || '').trim();
+  if (!raw || typeof window === 'undefined') return raw;
+  return new URL(raw, window.location.origin).toString().replace(/\/$/, '');
+}
+
 /**
  * 启动前加载公开运行时配置。
  * 注意:不得包含 sk-、Master Key、DB 密码、上游 Token。
@@ -66,15 +78,19 @@ export async function loadRuntimeConfig(): Promise<RuntimeConfig> {
         return cached;
       }
       const json = await res.json();
-      cached = {
+      const merged: RuntimeConfig = {
         ...DEFAULT_CONFIG,
         ...json,
         parse: { ...DEFAULT_CONFIG.parse, ...(json.parse || {}) },
         relay: { ...DEFAULT_CONFIG.relay, ...(json.relay || {}) },
         ai: { ...DEFAULT_CONFIG.ai, ...(json.ai || {}) },
+        auth: { ...(json.auth || {}) },
         features: { ...DEFAULT_CONFIG.features, ...(json.features || {}) },
       };
-      return cached!;
+      merged.parse.serverUrl = absoluteRuntimeUrl(merged.parse.serverUrl || '/parse');
+      if (merged.apiBaseUrl) merged.apiBaseUrl = absoluteRuntimeUrl(merged.apiBaseUrl);
+      cached = merged;
+      return merged;
     } catch (error) {
       console.warn('[RuntimeConfig] load failed, using defaults', error);
       cached = DEFAULT_CONFIG;

+ 6 - 12
frontend/src/app/core/services/sorftime-api.service.ts

@@ -5,7 +5,7 @@ import { HttpApiService } from './http-api.service';
 
 /**
  * Sorftime 平台 API 服务
- * 封装所有 Sorftime 数据查询接口,通过 /api/sorftime/forward 转发
+ * 封装所有 Sorftime 数据查询接口,通过公司 https://server.fmode.cn/api/voc-ecom/forward 中转
  * 认证已在后端内置,前端无需传入 Token
  */
 @Injectable({ providedIn: 'root' })
@@ -99,7 +99,8 @@ export class SorftimeApiService {
     searchProducts(keyword: string, params?: { page?: number; pageSize?: number; domain?: string }): Observable<any[]> {
         const domain = params?.domain;
         const page = params?.page || 1;
-        const kw = (keyword || '').trim() || 'swimsuit';
+        const kw = (keyword || '').trim();
+        if (!kw) return of([]);
 
         console.log(`[Sorftime] searchProducts via ProductQuery: keyword="${kw}", page=${page}`);
 
@@ -109,13 +110,11 @@ export class SorftimeApiService {
                     console.log(`[Sorftime] ProductQuery returned ${products.length} valid products`);
                     return of(products);
                 }
-                // ProductQuery 无数据,回退到批量 ProductRequest
-                console.warn('[Sorftime] ProductQuery returned 0 valid, falling back to batch ProductRequest');
-                return this.getProductsByAsins(this.FALLBACK_ASINS, domain);
+                return of([]);
             }),
             catchError(err => {
-                console.warn('[Sorftime] ProductQuery failed:', err?.message, ', falling back to batch ProductRequest');
-                return this.getProductsByAsins(this.FALLBACK_ASINS, domain);
+                console.warn('[Sorftime] ProductQuery failed:', err?.message);
+                return of([]);
             })
         );
     }
@@ -203,11 +202,6 @@ export class SorftimeApiService {
         return hasTitle || hasPrice || hasRatings;
     }
 
-    /** 回退 ASIN(当 ProductQuery 也被封时使用) */
-    private readonly FALLBACK_ASINS: string[] = [
-        'B0DJHP1FCS', 'B0CSVRWJ1H'
-    ];
-
     /** 产品详情 */
     getProductDetail(asin: string, domain?: string): Observable<any> {
         return this.api.sorftimeForward('/api/ProductRequest', {

+ 3 - 2
frontend/src/assets/runtime-config.json

@@ -6,8 +6,9 @@
   },
   "apiBaseUrl": "https://TARGET_HOST",
   "relay": {
-    "sorftimePath": "/api/sorftime/forward",
-    "tikhubPath": "/api/tikhub/forward"
+    "baseUrl": "https://server.fmode.cn",
+    "sorftimePath": "/api/voc-ecom/forward",
+    "tikhubPath": "/api/voc-social"
   },
   "ai": {
     "apiUrl": "https://server.fmode.cn/api/apig/aigc/gpt/v1/chat/completions",

+ 44 - 0
frontend/src/modules/dashboard/dashboard.component.html

@@ -148,6 +148,50 @@
     }
   </section>
 
+  <section class="overview-metrics business-overview" aria-label="经营概览">
+    <div class="dashboard-metric-cards">
+      <app-summary-metric-card
+        label="销售额"
+        [value]="businessSalesValue"
+        [unit]="businessSalesUnit"
+        [description]="businessSalesDescription"
+        tone="blue"
+        size="lg"
+        [loading]="loading">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="订单量"
+        [value]="(businessSummary?.totalOrders ?? 0) | number:'1.0-0'"
+        unit="单"
+        description="所选时间与店铺范围"
+        tone="green"
+        size="lg"
+        [loading]="loading">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="平均客单价"
+        [value]="businessAverageOrderValue"
+        [unit]="businessSalesUnit"
+        description="人民币销售额 / 订单量"
+        tone="amber"
+        size="lg"
+        [loading]="loading">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="退货率"
+        [value]="(businessSummary?.returnRate ?? 0) | number:'1.0-1'"
+        unit="%"
+        description="退货记录 / 订单量"
+        tone="red"
+        size="lg"
+        [loading]="loading">
+      </app-summary-metric-card>
+    </div>
+  </section>
+
   <!-- 页内 Tab 切换:问题概览 / 趋势 / 预警 -->
   <app-board-tabs
     class="dashboard-board-tabs"

+ 71 - 0
frontend/src/modules/dashboard/dashboard.component.ts

@@ -86,6 +86,18 @@ interface ScopedVocMetrics {
   vocVolume: number;
 }
 
+interface DashboardBusinessSummary {
+  totalOrders: number;
+  totalSales: number;
+  averageOrderPrice: number;
+  returnRate: number;
+  currency: string;
+  mixedCurrency: boolean;
+  salesByCurrency: Record<string, number>;
+  salesCnyByCurrency: Record<string, number>;
+  fxMissingCurrencies: string[];
+}
+
 @Component({
   selector: 'app-dashboard',
   standalone: true,
@@ -120,6 +132,7 @@ export class DashboardComponent implements OnInit, OnDestroy {
   readonly overviewTimeRangeOptions: TimeRangeOption[] = OVERVIEW_TIME_RANGE_OPTIONS;
   
   vocMetrics?: VocMetrics;
+  businessSummary?: DashboardBusinessSummary;
   todoItems: TodoItem[] = [];
   todoExpanded = false;
   readonly todoCollapsedCount = 4;
@@ -242,6 +255,7 @@ export class DashboardComponent implements OnInit, OnDestroy {
     this.loadCancel$.next();
     this.loading = true;
     this.vocMetrics = undefined;
+    this.businessSummary = undefined;
     this.todayVocCount = undefined;
     this.scopedPositiveEmotionRate = undefined;
     this.coreIssueCount = undefined;
@@ -267,6 +281,7 @@ export class DashboardComponent implements OnInit, OnDestroy {
       takeUntil(stop$)
     ).subscribe((asinScope: Set<string> | null) => {
       const dimensionFilters = this.getMonitoringDimensionFilters();
+      const { start, end } = this.getCurrentDateRange();
       const matchReview = (r: any): boolean =>
         !asinScope || asinScope.has((r.asin || r.Asin || r.rawData?.Asin || '').trim().toUpperCase());
 
@@ -309,6 +324,37 @@ export class DashboardComponent implements OnInit, OnDestroy {
         this.cdr.detectChanges();
       });
 
+      this.dataStore.cloudFunction({
+        id: '1tZqfcAys1',
+        shop: this.currentFilters.store?.[0] || '',
+        startDate: start.toISOString(),
+        endDate: end.toISOString()
+      }).pipe(
+        takeUntil(stop$),
+        catchError(() => of(null))
+      ).subscribe((response: any) => {
+        const data = response?.data || response;
+        if (!data) return;
+        this.businessSummary = {
+          totalOrders: Number(data.totalOrders || 0),
+          totalSales: Number(data.totalSales || 0),
+          averageOrderPrice: Number(data.averageOrderPrice || 0),
+          returnRate: Number(data.returnRate || 0),
+          currency: String(data.currency || 'CNY'),
+          mixedCurrency: data.mixedCurrency === true,
+          salesByCurrency: data.salesByCurrency && typeof data.salesByCurrency === 'object'
+            ? data.salesByCurrency
+            : {},
+          salesCnyByCurrency: data.salesCnyByCurrency && typeof data.salesCnyByCurrency === 'object'
+            ? data.salesCnyByCurrency
+            : {},
+          fxMissingCurrencies: Array.isArray(data.fxMissingCurrencies)
+            ? data.fxMissingCurrencies.map((item: unknown) => String(item || '').trim()).filter(Boolean)
+            : []
+        };
+        this.cdr.detectChanges();
+      });
+
       // 1) VocMetrics — 云函数快速返回,秒级渲染
       // 2) 趋势图表 — 拉 1 年日级数据(传入 asinScope 以按产品维度筛选评论)
       combineLatest([
@@ -862,6 +908,31 @@ export class DashboardComponent implements OnInit, OnDestroy {
     return (this.alertCount ?? 0) > 0;
   }
 
+  get businessSalesValue(): string | number {
+    return this.businessSummary?.totalSales || 0;
+  }
+
+  get businessSalesUnit(): string {
+    return 'CNY';
+  }
+
+  get businessSalesDescription(): string {
+    const summary = this.businessSummary;
+    if (!summary) return '已统一换算为人民币';
+    const parts = Object.entries(summary.salesByCurrency)
+      .sort(([left], [right]) => left.localeCompare(right))
+      .map(([currency, amount]) => `${currency} ${Number(amount).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`);
+    const missing = summary.fxMissingCurrencies?.length
+      ? `未换算 ${summary.fxMissingCurrencies.join('/')}`
+      : '';
+    const detail = [...parts, missing].filter(Boolean).join(' · ');
+    return detail ? `已统一换算为人民币 · ${detail}` : '已统一换算为人民币';
+  }
+
+  get businessAverageOrderValue(): string | number {
+    return this.businessSummary?.averageOrderPrice || 0;
+  }
+
   get vocMetricLabel(): string {
     if (this.vocMetricUsesListingVolume) return '评论声量';
     return this.vocMetricUsesProductRatingFallback ? 'VOC样本' : '周期VOC';

+ 35 - 1
frontend/src/modules/dashboard/pages/order-list/order-list.component.ts

@@ -3,7 +3,9 @@ import { CommonModule } from '@angular/common';
 import { RouterModule, ActivatedRoute } from '@angular/router';
 import { FormsModule } from '@angular/forms';
 import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
 import { MSQParse } from '../../../../app/parse';
+import { DataStoreService } from '../../../../app/core/services/data-store.service';
 import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
@@ -38,7 +40,11 @@ export class OrderListComponent implements OnInit {
   detailData: any = null;
   orderItems: any[] = [];
 
-  constructor(private route: ActivatedRoute, private http: HttpClient) {}
+  constructor(
+    private route: ActivatedRoute,
+    private http: HttpClient,
+    private dataStore: DataStoreService
+  ) {}
 
   ngOnInit() {
     this.shopId = this.route.snapshot.queryParamMap.get('shopId') || '';
@@ -96,6 +102,17 @@ export class OrderListComponent implements OnInit {
     this.detailData = null;
     this.orderItems = [];
     try {
+      const persisted = await firstValueFrom(this.dataStore.query(
+        'AmazonOrderItem',
+        { amazonOrderId: orderId },
+        { limit: 500, orderBy: 'orderItemId' }
+      ));
+      if (persisted.length) {
+        this.orderItems = this.normalizePersistedItems(persisted);
+        this.detailData = { items: this.orderItems, source: 'database' };
+        return;
+      }
+
       const { API_CONFIG } = await import('../../../../app/core/services/api.config');
       const itemsResp = await this.http.post(`${API_CONFIG.BASE_URL}/api/amazon/forward`, { path: `/orders/v0/orders/${orderId}/orderItems` }, {
         headers: { 'shop-objectId': this.shopId }
@@ -184,4 +201,21 @@ export class OrderListComponent implements OnInit {
       shippingCurrency: it?.ShippingPrice?.CurrencyCode || ''
     }));
   }
+
+  private normalizePersistedItems(items: any[]) {
+    return items.map(it => ({
+      asin: it?.asin || '',
+      orderItemId: it?.orderItemId || '',
+      sku: it?.sellerSku || '',
+      title: it?.title || '',
+      qtyOrdered: Number(it?.quantityOrdered ?? 0),
+      qtyShipped: Number(it?.quantityShipped ?? 0),
+      itemPrice: it?.itemPrice?.amount != null ? Number(it.itemPrice.amount) : null,
+      itemCurrency: it?.itemPrice?.currencyCode || '',
+      shippingPrice: it?.rawData?.ShippingPrice?.Amount != null
+        ? Number(it.rawData.ShippingPrice.Amount)
+        : null,
+      shippingCurrency: it?.rawData?.ShippingPrice?.CurrencyCode || ''
+    }));
+  }
 }

+ 1 - 2
frontend/src/modules/knowledge-center/competitor-detail/product-variations/product-variations.component.ts

@@ -365,8 +365,7 @@ export class ProductVariationsComponent implements OnChanges {
   }
 
   /**
-   * 解析形如 "B07KMTRT62 / Size / 32 Long / Color / Soft Black" 的字符串
-   * 或嵌套数组 [["ASIN", "B07KMTRT62"], ["Size", "32 Long"], ...]
+   * 解析斜杠分隔的变体属性字符串或嵌套键值数组。
    */
   private parseAttrString(raw: any): VariationEntry | null {
     if (Array.isArray(raw)) {

+ 1 - 1
frontend/src/modules/knowledge-center/export-reviews-dialog/export-reviews-dialog.component.ts

@@ -454,7 +454,7 @@ export class ExportReviewsDialogComponent implements OnInit {
 
     // 如果没有传入竞品列表,从 DB 加载
     this.isLoadingList = true;
-    this.dataStore.query('SorftimeProducts', undefined, { limit: 1000 }).subscribe({
+    this.dataStore.query('SorftimeProduct', undefined, { limit: 1000 }).subscribe({
       next: (rows) => {
         this.items = (rows || [])
           .filter((r: any) => r.asin)

+ 1 - 1
frontend/src/modules/knowledge-center/fetch-count-dialog/fetch-count-dialog.component.ts

@@ -44,7 +44,7 @@ export interface FetchDialogResult {
               <label>输入 ASIN 或 Amazon 商品链接(每行一个或用逗号分隔)</label>
               <textarea
                 [(ngModel)]="asinInput"
-                placeholder="例如:&#10;B0CSVRWJ1H&#10;https://www.amazon.com/dp/B0DGY7DNTL&#10;https://www.amazon.com/gp/aw/d/B0DJLKFKML/?..."
+                placeholder="每行输入一个 ASIN 或 Amazon 商品链接"
                 class="asin-textarea"
                 rows="5"></textarea>
               <div class="input-stats">

+ 1 - 3
frontend/src/modules/knowledge-center/influencer-detail/influencer-detail.component.ts

@@ -398,9 +398,7 @@ export class InfluencerDetailComponent implements OnInit {
     this.profile.videoCount = Number(u?.media_count || u?.edge_owner_to_timeline_media?.count || 0);
     this.profile.followingCount = Number(u?.following_count || u?.edge_follow?.count || 0);
 
-    if (this.profile.followerCount > 0) {
-      this.profile.engagementRate = Math.round((Math.random() * 4 + 2) * 100) / 100;
-    }
+    this.profile.engagementRate = Number(u?.engagement_rate || u?.engagementRate || 0);
 
     const followerScore = Math.min(this.profile.followerCount / 100000, 50);
     const engScore = Math.min(this.profile.engagementRate * 5, 50);

+ 8 - 19
frontend/src/modules/knowledge-center/knowledge-center.component.ts

@@ -621,9 +621,9 @@ export class KnowledgeCenterComponent implements OnInit {
   hotProductData: HotProductKnowledge[] = [];
   dataSources: DataSourceItem[] = [
     { id: 'ds-parse', name: 'Parse 主库', status: 'warning', updateFrequency: '实时', lastSync: '-', owner: '数据工程师', message: '等待检测' },
-    { id: 'ds-sorftime', name: 'Sorftime API', status: 'warning', updateFrequency: '每 2 小时', lastSync: '-', owner: '分析师', message: '等待检测' },
+    { id: 'ds-sorftime', name: '国外商品 API', status: 'warning', updateFrequency: '每 2 小时', lastSync: '-', owner: '分析师', message: '等待检测' },
     { id: 'ds-amazon', name: 'Amazon 数据源', status: 'warning', updateFrequency: '每日', lastSync: '-', owner: '运营经理', message: '等待检测' },
-    { id: 'ds-tikhub', name: 'TikHub 社媒源', status: 'warning', updateFrequency: '每 1 小时', lastSync: '-', owner: '内容运营', message: '等待检测' }
+    { id: 'ds-tikhub', name: '社媒API', status: 'warning', updateFrequency: '每 1 小时', lastSync: '-', owner: '内容运营', message: '等待检测' }
   ];
   dictionaries: DictionaryItem[] = [
     { id: 'dic-industry', name: '行业词典', termCount: 1320, lastUpdated: '-', owner: '数据分析师' },
@@ -754,10 +754,7 @@ export class KnowledgeCenterComponent implements OnInit {
         this.knowledgeCenterService.loadCompetitorFromDB().subscribe(dbData => {
           if (dbData?.length) {
             this.setCompetitorData(dbData);
-            return;
           }
-          // 数据库为空时回退 mock(不触发接口)
-          this.setCompetitorData(this.knowledgeCenterService.getCompetitorKnowledge(this.filters));
         });
         break;
       case 'influencer':
@@ -769,16 +766,12 @@ export class KnowledgeCenterComponent implements OnInit {
         this.knowledgeCenterService.loadInfluencerFromDB().subscribe(dbData => {
           if (dbData?.length) {
             this.setInfluencerData(dbData);
-            return;
           }
-          // 数据库为空时回退 mock(不触发接口)
-          this.setInfluencerData(this.knowledgeCenterService.getInfluencerKnowledge(this.filters));
         });
         break;
       case 'keyword':
-        // 先显示 mock 作为占位
-        this.keywordData = this.knowledgeCenterService.getKeywordKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.keywordData.length / this.pageSize);
+        this.keywordData = [];
+        this.totalPages = 1;
         // 优先从 KeywordSummary 表加载真实数据(关键词调研模块维护的主数据源)
         this.knowledgeCenterService.loadKeywordFromKeywordSummary().subscribe(realData => {
           if (realData?.length) {
@@ -804,11 +797,8 @@ export class KnowledgeCenterComponent implements OnInit {
         });
         break;
       case 'pattern':
-        this.patternData = this.knowledgeCenterService.getPatternKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.patternData.length / this.pageSize);
-        // 保存 mock 数据到数据库
-        this.knowledgeCenterService.savePatternData(this.patternData);
-        // 从数据库加载
+        this.patternData = [];
+        this.totalPages = 1;
         this.knowledgeCenterService.loadPatternFromDB().subscribe(dbData => {
           if (dbData?.length) {
             this.patternData = dbData;
@@ -817,9 +807,8 @@ export class KnowledgeCenterComponent implements OnInit {
         });
         break;
       case 'hotproduct':
-        // 先显示 mock 占位
-        this.hotProductData = this.knowledgeCenterService.getHotProductKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.hotProductData.length / this.pageSize);
+        this.hotProductData = [];
+        this.totalPages = 1;
         // 优先从 AmazonProduct 表加载真实数据(竞品监控模块维护的主数据源)
         this.knowledgeCenterService.loadHotProductFromAmazonProduct().subscribe(realData => {
           if (realData?.length) {

+ 4 - 38
frontend/src/modules/login/login.component.ts

@@ -8,8 +8,9 @@ import { PermissionService } from '../shared/services/permission.service';
 import { ProductDimensionService } from '../shared/services/product-dimension.service';
 import { AsinSkuMappingService } from '../shared/services/asin-sku-mapping.service';
 import { SharedDataCacheService } from '../shared/services/shared-data-cache.service';
+import { getRuntimeConfigSync } from '../../app/core/services/runtime-config.service';
 
-const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
+const FALLBACK_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6NxiJAxajI0ZsQ7dC5zP
 x4UiCjTHQVjl1AagC2PNdatEQ1gKIgF/+C+QSp7SbpqKTISACWIyLGUxc4fH9/2n
 63o3lIlew8l2A+WSkw/2cissXKxYBHSzqsd86L8blhMZ0F6wZ51Da7jzAXkL0IC9
@@ -74,7 +75,6 @@ export class LoginComponent {
         this.clearPermissionCaches();
         await MSQParse.User.become(response.sessionToken);
         this.clearPermissionCaches();
-        this.ensureNovaAuth(username, password);
         this.router.navigateByUrl(this.returnUrl);
       } else {
         this.errorMessage = response?.message || '登录失败,请检查账号密码';
@@ -87,41 +87,6 @@ export class LoginComponent {
     }
   }
 
-  private async ensureNovaAuth(username: string, password: string): Promise<void> {
-    // nova 仅用于 AI 网关鉴权;地址来自 runtime-config,禁止写死客户无关密钥
-    const { getRuntimeConfigSync } = await import('../../app/core/services/runtime-config.service');
-    const runtime = getRuntimeConfigSync();
-    const NOVA_URL = runtime.ai?.novaParseUrl || 'https://server.fmode.cn/parse';
-    const NOVA_APP_ID = runtime.ai?.novaAppId || 'ncloudmaster';
-    const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': NOVA_APP_ID };
-
-    try {
-      const loginResp = await fetch(`${NOVA_URL}/login`, {
-        method: 'POST', headers,
-        body: JSON.stringify({ username, password })
-      });
-      const loginResult = await loginResp.json();
-
-      if (loginResult.sessionToken) {
-        localStorage.setItem('FMODE_AI_TOKEN', loginResult.sessionToken);
-        return;
-      }
-
-      if (loginResult.code === 101) {
-        const signupResp = await fetch(`${NOVA_URL}/users`, {
-          method: 'POST', headers,
-          body: JSON.stringify({ username, password })
-        });
-        const signupResult = await signupResp.json();
-        if (signupResult.sessionToken) {
-          localStorage.setItem('FMODE_AI_TOKEN', signupResult.sessionToken);
-        }
-      }
-    } catch (err) {
-      console.warn('[AI Auth] nova 鉴权异常:', err);
-    }
-  }
-
   private clearPermissionCaches(): void {
     this.permissionService.clearCache();
     this.productDimensionService.clearCache();
@@ -130,8 +95,9 @@ export class LoginComponent {
   }
 
   encryptRSA(text: string): string {
+    const publicKey = getRuntimeConfigSync().auth?.loginPublicKey || FALLBACK_RSA_PUBLIC_KEY;
     const encryptor = new JSEncrypt();
-    encryptor.setPublicKey(RSA_PUBLIC_KEY);
+    encryptor.setPublicKey(publicKey);
     const encrypted = encryptor.encrypt(text);
     if (!encrypted) {
       throw new Error('RSA 加密失败,请检查公钥格式');

+ 1 - 1
frontend/src/modules/market-insight/components/emerging-market-insights/emerging-market-insights.component.ts

@@ -145,7 +145,7 @@ export class EmergingMarketInsightsComponent implements OnInit, OnDestroy, OnCha
   selectedTrendType: string = 'All';
   selectedSource: string = 'all';
 
-  // Mock Data
+  // Insight data loaded from the service.
   trendSignals: TrendSignal[] = [];
   allKeywords: TrendKeyword[] = [];
   displayKeywords: TrendKeyword[] = [];

+ 36 - 32
frontend/src/modules/market-insight/pages/keyword-research/keyword-research.component.ts

@@ -1281,36 +1281,39 @@ export class KeywordResearchComponent implements OnInit, OnDestroy {
     return `平均 CPC $${avgCpc.toFixed(2)},平均商品数 ${this.formatNum(avgProducts)}。`;
   }
 
-  /** 取行业归一化类目:优先根据关键词语义归类,避免 Amazon 非服饰大类污染筛选项 */
+  /** 按当前店铺真实商品归类,不再保留旧服装类目。 */
   getSubcategory(kw: KeywordItem): string | null {
     const text = `${kw.keyword || ''} ${kw.keywordCNName || ''}`.toLowerCase();
     if (this.NON_INDUSTRY_KEYWORD_PATTERN.test(text)) {
       return null;
     }
 
-    if (/(swimsuit|swimwear|bathing\s+suits?|bikini|tankini|monokini|rash\s+guard|board\s+shorts?|swim\s+dress|泳衣|泳装|比基尼)/i.test(text)) {
-      return 'Swimwear';
+    if (/(neck massager|cervical|neck stretcher|neck pillow|颈部|颈椎)/i.test(text)) {
+      return 'Neck & Cervical';
     }
-    if (/(bodysuits?|body\s+suits?|连体衣|连体衣裤)/i.test(text)) {
-      return 'Bodysuits';
+    if (/(lumbar|lower back|back massager|back brace|posture corrector|腰部|背部|矫形)/i.test(text)) {
+      return 'Back & Posture';
     }
-    if (/(shapewear|corsets?|waist\s+trainer|塑身|束身|紧身胸衣)/i.test(text)) {
-      return 'Shapewear & Corsets';
+    if (/(massage gun|percussion|deep tissue|muscle scraper|fascia|gua sha|筋膜|按摩枪)/i.test(text)) {
+      return 'Massage & Recovery';
     }
-    if (/(leggings?|yoga|biker\s+shorts?|workout|gym|athletic|sports?\s+bra|activewear|瑜伽|运动|健身)/i.test(text)) {
-      return 'Activewear';
+    if (/(foot|ankle|plantar|knee massager|足部|足底|膝盖)/i.test(text)) {
+      return 'Foot & Knee';
     }
-    if (/(cover\s*ups?|sarong|kimono|dresses?|skirts?|连衣裙|半身裙|罩衫)/i.test(text)) {
-      return 'Dresses & Cover-Ups';
+    if (/(blood flow restriction|\bbfr\b|occlusion training|resistance bands?|home gym|ab roller|abdominal|forearm trainer|arm exerciser|hand grip|hand therapy|mobilization belt|balance board|yoga block|力量训练|阻力带)/i.test(text)) {
+      return 'Training & Rehab';
     }
-    if (/(tops?|shirts?|t-?shirts?|tees?|blouses?|tanks?|camisoles?|crop\s+top|halter|tunics?|上衣|衬衫|背心)/i.test(text)) {
-      return 'Tops';
+    if (/(massage roller|smooth massage balls?|foam roller|meditation cushion|wedge pillow|memory foam pillow|枕头|按摩球)/i.test(text)) {
+      return 'Support & Comfort';
     }
-    if (/(shorts?|pants?|jeans?|jumpsuits?|rompers?|trousers?|裤|短裤|牛仔裤)/i.test(text)) {
-      return 'Bottoms & Jumpsuits';
+    if (/(shapewear|tummy control|塑身)/i.test(text)) {
+      return 'Shapewear';
     }
-    if (/(bras?|lingerie|underwear|panties|thongs?|内衣|胸罩)/i.test(text)) {
-      return 'Lingerie';
+    if (/(smart ring|sleep tracker|智能戒指)/i.test(text)) {
+      return 'Wearables';
+    }
+    if (/(soil moisture|soil ph|air quality|\bco2\b|土壤|空气质量)/i.test(text)) {
+      return 'Home Monitoring';
     }
 
     const cats = kw.top3Category || [];
@@ -2111,20 +2114,15 @@ export class KeywordResearchComponent implements OnInit, OnDestroy {
 
   getCategoryIcon(cat: string): string {
     const lower = cat.toLowerCase();
-    if (lower.includes('swim') || lower.includes('bathing')) return '🩱';
-    if (lower.includes('yoga') || lower.includes('legging') || lower.includes('sport') || lower.includes('athletic')) return '🏃';
-    if (lower.includes('hoodie') || lower.includes('sweatshirt') || lower.includes('pullover')) return '👕';
-    if (lower.includes('dress')) return '👗';
-    if (lower.includes('top') || lower.includes('shirt') || lower.includes('tee') || lower.includes('blouse')) return '👚';
-    if (lower.includes('pant') || lower.includes('jean') || lower.includes('short')) return '👖';
-    if (lower.includes('jacket') || lower.includes('coat') || lower.includes('outerwear')) return '🧥';
-    if (lower.includes('shoe') || lower.includes('sneaker') || lower.includes('boot')) return '👟';
-    if (lower.includes('bag') || lower.includes('purse') || lower.includes('tote')) return '👜';
-    if (lower.includes('jewelry') || lower.includes('necklace') || lower.includes('ring')) return '💍';
-    if (lower.includes('underwear') || lower.includes('bra') || lower.includes('lingerie')) return '🩲';
-    if (lower.includes('sock') || lower.includes('stocking')) return '🧦';
-    if (lower.includes('hat') || lower.includes('cap') || lower.includes('beanie')) return '🧢';
-    if (lower.includes('scarf') || lower.includes('glove') || lower.includes('mitten')) return '🧣';
+    if (lower.includes('neck') || lower.includes('cervical')) return '💆';
+    if (lower.includes('back') || lower.includes('posture')) return '🦴';
+    if (lower.includes('massage') || lower.includes('recovery')) return '💆';
+    if (lower.includes('foot') || lower.includes('knee')) return '🦶';
+    if (lower.includes('training') || lower.includes('rehab')) return '🏋️';
+    if (lower.includes('support') || lower.includes('comfort')) return '🛏️';
+    if (lower.includes('shapewear')) return '🩳';
+    if (lower.includes('wearable')) return '⌚';
+    if (lower.includes('monitoring')) return '🌡️';
     return '🏷️';
   }
 
@@ -2316,11 +2314,17 @@ export class KeywordResearchComponent implements OnInit, OnDestroy {
     const previousPrompt = this.aiPrompt();
     this.buildAiPrompt(this.selectedCategory());
     const nextPrompt = this.aiPrompt();
-    if (!nextPrompt) return;
+    if (!nextPrompt) {
+      window.alert('当前筛选没有可分析的关键词');
+      return;
+    }
     if (previousPrompt === nextPrompt) {
       this.aiPrompt.set('');
       setTimeout(() => this.aiPrompt.set(nextPrompt), 0);
     }
+    setTimeout(() => {
+      document.getElementById('sec-ai')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }, 0);
   }
 
   onOpenAiChat(event: { prompt: string; report: string }): void {

+ 1 - 1
frontend/src/modules/market-insight/pages/new-product-trend/new-product-trend.component.scss

@@ -315,7 +315,7 @@
   max-width: 100%;
 }
 
-// ---- 示例数据横幅 ----
+// ---- 空数据横幅 ----
 .npt-demo-banner {
   display: flex;
   align-items: center;

+ 13 - 94
frontend/src/modules/market-insight/pages/new-product-trend/new-product-trend.component.ts

@@ -145,14 +145,13 @@ type NewProductTabKey = 'chart' | 'health' | 'competition' | 'price-style' | 'da
             <app-summary-metric-card label="成功率" [value]="summaryStats.successRate + '%'" tone="green"></app-summary-metric-card>
           </div>
 
-          @if (isDemo) {
+          @if (isEmpty) {
             <div class="npt-demo-banner">
               <span class="npt-demo-icon">📦</span>
               <div class="npt-demo-info">
-                <strong>示例数据展示</strong>
-                <span>当前暂无真实新品数据,以下为示例产品,仅供功能预览。请在「系统管理」中配置店铺并确保 Product 表有近六个月上架的产品。</span>
+                <strong>暂无新品数据</strong>
+                <span>请在「系统管理」中配置店铺并确保 Product 表有近六个月上架的产品。</span>
               </div>
-              <span class="npt-demo-badge">● 示例数据</span>
             </div>
           }
 
@@ -1333,7 +1332,7 @@ export class NewProductTrendComponent implements OnInit, OnDestroy {
   stats: NewProductStats | null = null;
   products: ProductInfo[] = [];
   shopId: string | null = null;
-  isDemo = false;
+  isEmpty = false;
   private productsRevision = 0;
   private productHealthRevision = 0;
   private filteredProductsCacheKey = '';
@@ -2288,12 +2287,12 @@ export class NewProductTrendComponent implements OnInit, OnDestroy {
       if (res.products && res.products.length > 0) {
         this.stats = res.stats;
         this.products = res.products;
-        this.isDemo = false;
+        this.isEmpty = false;
       } else {
-        const demo = this.generateDemoProducts();
-        this.products = demo.products;
-        this.stats = demo.stats;
-        this.isDemo = true;
+        const empty = this.createEmptyProductResult();
+        this.products = empty.products;
+        this.stats = empty.stats;
+        this.isEmpty = true;
       }
       this.markProductsChanged();
       this.loading = false;
@@ -3491,38 +3490,6 @@ ${sampleReviews.join('\n')}
       }))
       .sort((a, b) => b.count - a.count);
 
-    // ---- 若无真实失败产品,注入 mock 数据供预览 ----
-    if (failedProducts.length === 0) {
-      const mockReasonCounts: Record<string, number> = {
-        zero_sales: 3, low_rating: 2, poor_rank: 4, no_reviews: 2, price_high: 1, stagnant: 2
-      };
-      const mockTotal = 8;
-      const mockReasons = Object.entries(mockReasonCounts).map(([key, count]) => ({
-        key,
-        label: REASON_META[key].label,
-        count,
-        pct: Math.round((count / mockTotal) * 100),
-        color: REASON_META[key].color,
-        icon: REASON_META[key].icon,
-        desc: REASON_META[key].desc,
-      })).sort((a, b) => b.count - a.count);
-
-      const mockProducts = [
-        { asin: 'B0MOCK0001', title: 'Women Oversized Hoodie Fleece Pullover Sweatshirt', photo: '', daysSinceLaunch: 22, primaryReason: 'zero_sales', reasonLabel: '零销量', reasonColor: '#ef4444', sales: 0, rating: 0, rank: 0 },
-        { asin: 'B0MOCK0002', title: 'Slim Fit Quarter Zip Athletic Pullover Long Sleeve', photo: '', daysSinceLaunch: 35, primaryReason: 'poor_rank', reasonLabel: '排名过差', reasonColor: '#eab308', sales: 2, rating: 4.1, rank: 680000 },
-        { asin: 'B0MOCK0003', title: 'Y2K Crop Top Graphic Print Tee Short Sleeve', photo: '', daysSinceLaunch: 18, primaryReason: 'low_rating', reasonLabel: '评分过低', reasonColor: '#f97316', sales: 8, rating: 2.9, rank: 120000 },
-        { asin: 'B0MOCK0004', title: 'High Waist Yoga Leggings Squat Proof Workout Pants', photo: '', daysSinceLaunch: 28, primaryReason: 'no_reviews', reasonLabel: '评论不足', reasonColor: '#8b5cf6', sales: 5, rating: 0, rank: 95000 },
-        { asin: 'B0MOCK0005', title: 'Lace Mesh Patchwork Bodycon Mini Dress', photo: '', daysSinceLaunch: 45, primaryReason: 'poor_rank', reasonLabel: '排名过差', reasonColor: '#eab308', sales: 1, rating: 3.8, rank: 720000 },
-        { asin: 'B0MOCK0006', title: 'Thumbhole Long Sleeve Running Athletic Top', photo: '', daysSinceLaunch: 16, primaryReason: 'zero_sales', reasonLabel: '零销量', reasonColor: '#ef4444', sales: 0, rating: 0, rank: 0 },
-        { asin: 'B0MOCK0007', title: 'Oversized Graphic Sweatshirt Vintage Print Crewneck', photo: '', daysSinceLaunch: 38, primaryReason: 'stagnant', reasonLabel: '增长停滞', reasonColor: '#6b7280', sales: 2, rating: 4.3, rank: 210000 },
-        { asin: 'B0MOCK0008', title: 'Premium Cashmere Blend Turtleneck Sweater', photo: '', daysSinceLaunch: 20, primaryReason: 'price_high', reasonLabel: '定价偏高', reasonColor: '#06b6d4', sales: 3, rating: 4.5, rank: 88000 },
-      ];
-
-      this.failureAttribution = { reasons: mockReasons, failedProducts: mockProducts, total: mockTotal };
-      this.buildFailureAttributionCharts(mockReasons);
-      return;
-    }
-
     this.failureAttribution = { reasons, failedProducts, total };
     this.buildFailureAttributionCharts(reasons);
   }
@@ -4135,60 +4102,12 @@ ${sampleReviews.join('\n')}
     }
   }
 
-  // ==================== 示例数据 ====================
-
-  private generateDemoProducts(): { products: ProductInfo[]; stats: NewProductStats } {
-    const now = new Date();
-    const demoItems: Array<{
-      asin: string; title: string; category: string; brand: string;
-      price: number; sales: number; rating: number; reviews: number;
-      daysAgo: number; rank: number;
-    }> = [
-      { asin: 'B0DEMO0001', title: 'Women\'s Slim Fit Quarter Zip Pullover Sweatshirt Mock Neck Long Sleeve', category: 'Clothing > Women > Tops', brand: 'DemoBrand', price: 2990, sales: 320, rating: 4.5, reviews: 48, daysAgo: 12, rank: 18500 },
-      { asin: 'B0DEMO0002', title: 'Oversized Hoodie for Women Fleece Lined Drop Shoulder Pullover', category: 'Clothing > Women > Hoodies', brand: 'DemoBrand', price: 3490, sales: 580, rating: 4.3, reviews: 85, daysAgo: 25, rank: 9200 },
-      { asin: 'B0DEMO0003', title: 'High Waist Yoga Leggings with Pockets Squat Proof Workout Pants', category: 'Clothing > Women > Leggings', brand: 'FitStyle', price: 2490, sales: 920, rating: 4.6, reviews: 156, daysAgo: 45, rank: 5800 },
-      { asin: 'B0DEMO0004', title: 'Y2K Crop Top Ribbed Knit Short Sleeve Baby Tee for Women', category: 'Clothing > Women > Tops', brand: 'TrendyWear', price: 1590, sales: 210, rating: 4.1, reviews: 32, daysAgo: 8, rank: 34000 },
-      { asin: 'B0DEMO0005', title: 'Men\'s Athletic Running Shorts Quick Dry with Zipper Pockets', category: 'Clothing > Men > Shorts', brand: 'FitStyle', price: 1990, sales: 450, rating: 4.4, reviews: 67, daysAgo: 30, rank: 12000 },
-      { asin: 'B0DEMO0006', title: 'Women\'s Summer Flowy Midi Dress Casual V Neck Sundress', category: 'Clothing > Women > Dresses', brand: 'ElegantLine', price: 2790, sales: 180, rating: 4.2, reviews: 29, daysAgo: 18, rank: 28000 },
-      { asin: 'B0DEMO0007', title: 'Mesh Panel Sports Bra High Impact Workout Racerback', category: 'Clothing > Women > Activewear', brand: 'FitStyle', price: 1890, sales: 340, rating: 4.0, reviews: 41, daysAgo: 55, rank: 22000 },
-      { asin: 'B0DEMO0008', title: 'Men\'s Heavyweight Graphic Hoodie Oversized Streetwear Pullover', category: 'Clothing > Men > Hoodies', brand: 'UrbanEdge', price: 3990, sales: 95, rating: 3.8, reviews: 12, daysAgo: 60, rank: 65000 },
-      { asin: 'B0DEMO0009', title: 'Women\'s Thumbhole Athletic Long Sleeve Running Top', category: 'Clothing > Women > Activewear', brand: 'DemoBrand', price: 2290, sales: 260, rating: 4.7, reviews: 53, daysAgo: 35, rank: 15000 },
-      { asin: 'B0DEMO0010', title: 'Lace Detail Blouse Women\'s Elegant Sheer Sleeve Office Top', category: 'Clothing > Women > Tops', brand: 'ElegantLine', price: 2190, sales: 130, rating: 4.3, reviews: 18, daysAgo: 20, rank: 42000 }
-    ];
-
-    const products: ProductInfo[] = demoItems.map(d => {
-      const launchDate = new Date(now);
-      launchDate.setDate(launchDate.getDate() - d.daysAgo);
-      const estimated = { positiveRate: d.rating >= 4 ? 75 : 55, negativeRate: d.rating >= 4 ? 8 : 20, neutralRate: d.rating >= 4 ? 17 : 25 };
-      return {
-        id: d.asin, asin: d.asin, title: d.title,
-        photo: '/assets/images/placeholder.svg',
-        price: d.price, sales: d.sales, launchDate,
-        category: d.category, store: d.brand,
-        rank: d.rank,
-        vocSummary: {
-          totalReviews: d.reviews,
-          averageRating: d.rating,
-          positiveRate: estimated.positiveRate,
-          negativeRate: estimated.negativeRate,
-          neutralRate: estimated.neutralRate
-        },
-        competitorComparison: { rating: d.rating * 0.9, price: d.price * 1.1, sales: d.sales * 0.8 },
-        analysisStatus: { dataCompleteness: 80, aiProgress: 70, lastUpdate: new Date() }
-      };
-    });
-
-    const totalCount = products.length;
-    const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
-    const thisMonthCount = products.filter(p => p.launchDate >= thisMonthStart).length;
-    const avgSales = Math.round(products.reduce((s, p) => s + p.sales, 0) / totalCount);
-    const avgRating = Number((products.reduce((s, p) => s + p.vocSummary.averageRating, 0) / totalCount).toFixed(1));
-    const successCount = products.filter(p => p.vocSummary.averageRating >= 4 && p.sales > 0).length;
-    const successRate = Math.round((successCount / totalCount) * 100);
+  // ==================== 空数据 ====================
 
+  private createEmptyProductResult(): { products: ProductInfo[]; stats: NewProductStats } {
     return {
-      products,
-      stats: { totalCount, thisMonthCount, avgSales, avgRating, successRate }
+      products: [],
+      stats: { totalCount: 0, thisMonthCount: 0, avgSales: 0, avgRating: 0, successRate: 0 }
     };
   }
 }

+ 6 - 33
frontend/src/modules/market-insight/pages/website-research/website-research.component.ts

@@ -46,7 +46,7 @@ interface SiteData {
 
       <!-- 添加站点 -->
       <div class="add-site-bar">
-        <input class="site-input" type="text" placeholder="输入独立站域名,如 anker.com"
+        <input class="site-input" type="text" placeholder="输入独立站域名"
                [(ngModel)]="newSiteUrl" (keyup.enter)="addSite()">
         <app-neu-button size="sm" color="primary" (click)="addSite()" [disabled]="!newSiteUrl.trim()">+ 添加站点</app-neu-button>
       </div>
@@ -301,38 +301,12 @@ export class WebsiteResearchComponent implements OnInit {
   }
 
   private generateSiteData(url: string): SiteData {
-    const hash = this.hashStr(url);
     const name = url.split('.')[0] || url;
-    const trafficSources = [
-      { source: '直接访问', share: 20 + (hash % 25) },
-      { source: '搜索引擎', share: 15 + ((hash * 3) % 30) },
-      { source: '社交媒体', share: 5 + ((hash * 7) % 20) },
-      { source: '引荐', share: 5 + ((hash * 11) % 15) },
-      { source: '邮件', share: 2 + ((hash * 13) % 8) },
-    ];
-    const totalShare = trafficSources.reduce((s, t) => s + t.share, 0);
-    trafficSources.forEach(t => t.share = Math.round(t.share / totalShare * 100));
-
     return {
-      id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
-      url,
-      name: name.charAt(0).toUpperCase() + name.slice(1),
-      monthlyVisits: 50000 + (hash % 950000),
-      bounceRate: 25 + (hash % 45),
-      avgVisitDuration: 60 + (hash % 300),
-      pagesPerVisit: +(2 + (hash % 8) + Math.random()).toFixed(1),
-      globalRank: 1000 + (hash % 500000),
-      countryRank: 100 + (hash % 50000),
-      topCountries: [
-        { country: '美国', share: 20 + (hash % 30) },
-        { country: '英国', share: 5 + ((hash * 3) % 15) },
-        { country: '德国', share: 3 + ((hash * 5) % 12) },
-        { country: '日本', share: 2 + ((hash * 7) % 10) },
-      ],
-      trafficSources,
-      techStack: this.pickTech(hash),
-      similarSites: this.pickSimilar(hash, url),
-      selected: false,
+      id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), url,
+      name: name.charAt(0).toUpperCase() + name.slice(1), monthlyVisits: 0, bounceRate: 0,
+      avgVisitDuration: 0, pagesPerVisit: 0, globalRank: 0, countryRank: 0, topCountries: [],
+      trafficSources: [], techStack: [], similarSites: [], selected: false
     };
   }
 
@@ -347,8 +321,7 @@ export class WebsiteResearchComponent implements OnInit {
   }
 
   private pickSimilar(hash: number, url: string): string[] {
-    const all = ['anker.com', 'shein.com', 'zaful.com', 'banggood.com', 'gearbest.com', 'aliexpress.com', 'temu.com', 'wish.com', 'patpat.com', 'cupshe.com'];
-    return all.filter(s => s !== url).slice((hash % 3), (hash % 3) + 3);
+    return [];
   }
 
   private hashStr(s: string): number {

+ 4 - 64
frontend/src/modules/monitoring/components/traffic-source-monitoring/traffic-source-monitoring.component.ts

@@ -125,11 +125,7 @@ export class TrafficSourceMonitoringComponent implements OnInit, OnDestroy {
   
   currentLevel: DrillDownLevel = 'ASIN';
   
-  currentLevelItems: {id: string, name: string}[] = [
-    { id: 'asin1', name: 'B08XXXXX01 (主力款)' },
-    { id: 'asin2', name: 'B08XXXXX02 (新品)' },
-    { id: 'asin3', name: 'B08XXXXX03 (促销款)' }
-  ];
+  currentLevelItems: {id: string, name: string}[] = [];
 
   trendTimeRange: '24h' | '7d' = '24h';
 
@@ -190,39 +186,7 @@ export class TrafficSourceMonitoringComponent implements OnInit, OnDestroy {
   }
 
   updateLevelItems(level: DrillDownLevel) {
-    // Mock data for dropdown
-    switch(level) {
-      case 'ASIN':
-        this.currentLevelItems = [
-          { id: 'asin1', name: 'B08XXXXX01 (主力款)' },
-          { id: 'asin2', name: 'B08XXXXX02 (新品)' }
-        ];
-        break;
-      case 'Group':
-        this.currentLevelItems = [
-          { id: 'g1', name: '休闲女装组' },
-          { id: 'g2', name: '运动系列组' }
-        ];
-        break;
-      case 'Brand':
-        this.currentLevelItems = [
-          { id: 'b1', name: 'Anker' },
-          { id: 'b2', name: 'Eufy' }
-        ];
-        break;
-      case 'Category':
-        this.currentLevelItems = [
-          { id: 'c1', name: 'Consumer Electronics' },
-          { id: 'c2', name: 'Smart Home' }
-        ];
-        break;
-      case 'Market':
-        this.currentLevelItems = [
-          { id: 'm1', name: 'US Market' },
-          { id: 'm2', name: 'EU Market' }
-        ];
-        break;
-    }
+    this.currentLevelItems = [];
   }
 
   refreshData() {
@@ -232,32 +196,8 @@ export class TrafficSourceMonitoringComponent implements OnInit, OnDestroy {
   }
 
   generateMockData() {
-    // Mock Traffic Source Data
-    this.currentSourceData = [
-      { channel: 'Amazon搜索', value: 45, trend: 12.5, weeklyTrend: 10, status: 'normal' },
-      { channel: '站内广告', value: 25, trend: -5.2, weeklyTrend: -2, status: 'warning' },
-      { channel: 'TikTok', value: 15, trend: 28.4, weeklyTrend: 45, status: 'critical' },
-      { channel: '独立站', value: 10, trend: 2.1, weeklyTrend: 5, status: 'normal' },
-      { channel: '其他', value: 5, trend: 0.5, weeklyTrend: 1, status: 'normal' }
-    ];
-
-    // Mock Alerts
+    this.currentSourceData = [];
     this.activeAlerts = [];
-    // Randomly generate alerts based on mock data
-    this.currentSourceData.forEach(item => {
-      if (Math.abs(item.trend) >= 15 || Math.abs(item.weeklyTrend) >= 30) {
-        this.activeAlerts.push({
-          id: Math.random().toString(36).substr(2, 9),
-          channel: item.channel,
-          level: this.currentLevel,
-          targetName: this.currentLevelItems[0].name,
-          message: `${item.channel}流量${item.trend > 0 ? '激增' : '骤降'}`,
-          severity: Math.abs(item.trend) >= 25 ? 'critical' : 'warning',
-          timestamp: new Date(),
-          changeRate: item.trend
-        });
-      }
-    });
   }
 
   updateRingChart() {
@@ -314,7 +254,7 @@ export class TrafficSourceMonitoringComponent implements OnInit, OnDestroy {
       type: 'line',
       smooth: true,
       symbol: 'none',
-      data: xAxisData.map(() => Math.floor(Math.random() * 100)),
+      data: [],
       itemStyle: { color: this.getChannelColor(item.channel) }
     }));
 

+ 1 - 1
frontend/src/modules/monitoring/pages/competitor-update/campaign-create-panel/campaign-create-panel.component.html

@@ -24,7 +24,7 @@
           class="form-textarea"
           [(ngModel)]="asinsInput"
           (ngModelChange)="parseAsinsFromInput()"
-          placeholder="B08N5WRWNW&#10;https://amazon.com/dp/B0CSVRWJ1H&#10;B09..."
+          placeholder="每行输入一个 ASIN 或 Amazon 商品链接"
           rows="6">
         </textarea>
         <div class="parsed-info">

+ 1 - 1
frontend/src/modules/monitoring/pages/competitor-update/competitor-update.component.ts

@@ -333,7 +333,7 @@ interface CompetitorSubpageMeta {
             <input
               class="pulse-add-input"
               type="text"
-              placeholder="输入品牌名添加监控,如 Anker、CUPSHE…"
+              placeholder="输入品牌名添加监控"
               [(ngModel)]="customBrandInput"
               (focus)="customBrandFocused = true"
               (blur)="customBrandFocused = false"

+ 8 - 24
frontend/src/modules/monitoring/pages/store-detail/store-detail.component.ts

@@ -94,12 +94,12 @@ export class StoreDetailComponent implements OnInit {
 
   loadStoreData() {
     // 1) 先从数据库查询已保存的店铺数据
-    this.dataStore.query('MonitoringStoreDetail', { storeId: this.storeId }, { limit: 1 }).subscribe({
+    this.dataStore.query('MonitoringStoreDetail', { shopId: this.storeId }, { limit: 1 }).subscribe({
       next: (rows: any[]) => {
         if (rows?.length) {
           const r = rows[0];
           this.storeInfo = {
-            id: r.storeId || this.storeId,
+            id: r.shopId || this.storeId,
             name: r.name || this.getStoreName(this.storeId),
             totalSales: r.totalSales ?? 0,
             totalAmount: r.totalAmount ?? 0,
@@ -113,9 +113,8 @@ export class StoreDetailComponent implements OnInit {
           };
           console.log('[StoreDetail] Loaded store info from DB:', this.storeInfo.name);
         } else {
-          // 2) 数据库无数据,使用 mock 并保存
+          // 数据库无数据时保留空状态,不生成或保存占位记录。
           this.loadMockStoreData();
-          this.saveStoreInfoToDB();
         }
       },
       error: () => {
@@ -126,24 +125,15 @@ export class StoreDetailComponent implements OnInit {
 
   private loadMockStoreData() {
     this.storeInfo = {
-      id: this.storeId,
-      name: this.getStoreName(this.storeId),
-      totalSales: 12580,
-      totalAmount: 3580000,
-      avgPrice: 285,
-      healthScore: 92,
-      rating: 4.8,
-      returnRate: 3.2,
-      positiveRate: 94.5,
-      vocVolume: 456,
-      newArrivalCount: 12
+      id: this.storeId, name: this.getStoreName(this.storeId), totalSales: 0, totalAmount: 0, avgPrice: 0,
+      healthScore: 0, rating: 0, returnRate: 0, positiveRate: 0, vocVolume: 0, newArrivalCount: 0
     };
   }
 
   private saveStoreInfoToDB() {
     if (!this.storeId) return;
     this.dataStore.save('MonitoringStoreDetail', {
-      storeId: this.storeId,
+      shopId: this.storeId,
       name: this.storeInfo.name,
       totalSales: this.storeInfo.totalSales,
       totalAmount: this.storeInfo.totalAmount,
@@ -154,7 +144,7 @@ export class StoreDetailComponent implements OnInit {
       positiveRate: this.storeInfo.positiveRate,
       vocVolume: this.storeInfo.vocVolume,
       newArrivalCount: this.storeInfo.newArrivalCount
-    }, 'storeId').subscribe({
+    }, 'shopId').subscribe({
       next: () => console.log('[StoreDetail] Store info saved to DB:', this.storeId),
       error: (err: any) => console.warn('[StoreDetail] Store info save failed:', err?.message)
     });
@@ -224,13 +214,7 @@ export class StoreDetailComponent implements OnInit {
   }
 
   getStoreName(id: string): string {
-    const map: any = {
-      'store1': '主力店铺A',
-      'store2': '主力店铺B',
-      'store3': '新开店铺C',
-      'store4': '测试店铺D'
-    };
-    return map[id] || '未知店铺';
+    return id || '未知店铺';
   }
 
   applyFilter() {

+ 1 - 1
frontend/src/modules/product-lifecycle-review/product-lifecycle-review.component.html

@@ -8,7 +8,7 @@
     <div pageHeaderActions class="header-actions">
       <div class="product-selector">
         <label>当前产品:</label>
-        <msq-dropdown [options]="plrProductOpts" placeholder="夏季轻薄防晒衣 V2.0"></msq-dropdown>
+        <msq-dropdown [options]="plrProductOpts" placeholder="请选择产品"></msq-dropdown>
       </div>
       <button class="btn btn-primary" (click)="generateReport()">
         <span class="icon">📊</span> 生成复盘报告

+ 1 - 4
frontend/src/modules/product-lifecycle-review/product-lifecycle-review.component.ts

@@ -27,10 +27,7 @@ export class ProductLifecycleReviewComponent implements OnInit {
   loading = false;
   report: ReviewReport | null = null;
 
-  readonly plrProductOpts: MsqDropdownOption[] = [
-    { value: 'prod1', label: '夏季轻薄防晒衣 V2.0' },
-    { value: 'prod2', label: '瑜伽裤经典款' }
-  ];
+  readonly plrProductOpts: MsqDropdownOption[] = [];
   teamFeedback: TeamFeedback[] = [];
   currentStage: LifecycleStage = 'growth';
 

+ 5 - 8
frontend/src/modules/return-depth-analysis/return-depth-analysis.component.ts

@@ -437,13 +437,10 @@ export class ReturnDepthAnalysisComponent implements OnInit, OnDestroy {
       listingSize: filters.listingSize
     };
     
-    // 只有在有店铺ID的情况下才加载数据
-    if (this.currentFilters.store && this.currentFilters.store.length > 0) {
-      this.clearAttributionProductSelection(false);
-      this.clearAttributionExportSelection();
-      this.loadData();
-      this.isInitialLoad = false;
-    }
+    this.clearAttributionProductSelection(false);
+    this.clearAttributionExportSelection();
+    this.loadData();
+    this.isInitialLoad = false;
   }
 
   loadData(): void {
@@ -461,7 +458,7 @@ export class ReturnDepthAnalysisComponent implements OnInit, OnDestroy {
   }
 
   private hasActiveStoreFilter(): boolean {
-    return !!(this.currentFilters.store && this.currentFilters.store.length > 0);
+    return true;
   }
 
   private getCurrentFilterSignature(): string {

+ 5 - 24
frontend/src/modules/shared/components/filter-bar/filter-bar.component.ts

@@ -171,7 +171,7 @@ export class FilterBarComponent implements OnInit, OnChanges {
   categoryL4Options: MsqDropdownOption[] = [];
   private categoryTree: { categoryId: string; nodeId: string; name: string; parentId: string; level: number; isLeaf: boolean }[] = [];
   selectedStore: string = '';
-  selectedSite: string = 'us';
+  selectedSite: string = '';
   selectedDesigner = '';
   selectedOperator = '';
   selectedDeveloper = '';
@@ -297,14 +297,7 @@ export class FilterBarComponent implements OnInit, OnChanges {
     return this.customInitialRangeCache;
   }
 
-  presets = [
-    { name: '本月泳装类目', filters: { timeRange: '30days' as const, category: ['clothing'] } },
-    {
-      name: '美国站点上衣',
-      filters: { timeRange: '7days' as const, site: ['us'], category: ['clothing'] }
-    },
-    { name: '主力店铺', filters: { timeRange: '7days' as const, store: ['store1', 'store2'] } }
-  ];
+  presets: Array<{ name: string; filters: Partial<FilterOptions> }> = [];
 
   constructor(private productDimensionService: ProductDimensionService) {}
 
@@ -368,11 +361,6 @@ export class FilterBarComponent implements OnInit, OnChanges {
         id: u.id,
         name: u.get('name') || u.id
       }));
-      if (!this.selectedStore && this.shops.length > 0) {
-        this.selectedStore = this.pickDefaultShopId();
-        this.filters.store = [this.selectedStore];
-        this.syncStoreNameFilter();
-      }
       if (this.selectedStore && !this.filters.store?.length) {
         this.filters.store = [this.selectedStore];
         this.syncStoreNameFilter();
@@ -591,16 +579,9 @@ export class FilterBarComponent implements OnInit, OnChanges {
     this.selectedDeveloper = '';
     this.selectedItemStatus = '';
     this.selectedFulfillmentType = '';
-    // 默认优先选中 rxrxcoco,否则回落到第一个店铺
-    if (this.shops.length > 0) {
-      this.selectedStore = this.pickDefaultShopId();
-      this.filters.store = [this.selectedStore];
-      this.syncStoreNameFilter();
-    } else {
-      this.selectedStore = '';
-      this.filters.store = undefined;
-      this.filters.storeNames = undefined;
-    }
+    this.selectedStore = '';
+    this.filters.store = undefined;
+    this.filters.storeNames = undefined;
     this.emitFilters();
   }
 

+ 2 - 0
frontend/src/modules/shared/components/header/header.component.ts

@@ -5,6 +5,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
 import { from, Observable, of, Subject } from 'rxjs';
 import { catchError, debounceTime, switchMap, takeUntil } from 'rxjs/operators';
 import { LayoutService } from '../../../../app/core/services/layout.service';
+import { CompanyRelayAuthService } from '../../../../app/core/services/company-relay-auth.service';
 import { MSQParse } from '../../../../app/parse';
 import type { GlobalSearchResult, SearchSeverity } from '../../services/global-search.service';
 import { NAV_ICONS } from '../navigation/nav-icons';
@@ -240,6 +241,7 @@ export class HeaderComponent implements OnDestroy {
     try {
       await this.clearPermissionCaches();
       localStorage.removeItem('FMODE_AI_TOKEN');
+      this.injector.get(CompanyRelayAuthService).clear();
       await MSQParse.User.logOut();
       await this.clearPermissionCaches();
       this.router.navigate(['/login']);

+ 32 - 24
frontend/src/modules/shared/services/ai-analysis.service.ts

@@ -1,13 +1,13 @@
 import { Injectable } from '@angular/core';
-import { Observable, of, Subscription, TimeoutError } from 'rxjs';
+import { Observable, of, Subscription, TimeoutError, from } from 'rxjs';
 import { switchMap, timeout, catchError } from 'rxjs/operators';
 import { FmodeChatMessage } from 'fmode-ng/core';
-import { MSQParse } from '../../../app/parse';
+import { CompanyRelayAuthService } from '../../../app/core/services/company-relay-auth.service';
 import { AiOutputStyleTargetKey, AiPromptConfigService } from './ai-prompt-config.service';
 
 import { getRuntimeConfigSync } from '../../../app/core/services/runtime-config.service';
 
-/** AI API 端点(来自 runtime-config;需登录态 token,禁止写死 sk-/固定 r:) */
+/** AI API 端点(来自 runtime-config;凭证使用 API 管理中保存的密钥) */
 function getAiApiUrl(): string {
   return getRuntimeConfigSync().ai?.apiUrl
     || 'https://server.fmode.cn/api/apig/aigc/gpt/v1/chat/completions';
@@ -53,7 +53,10 @@ export class AiAnalysisService {
     '可以给出经验性建议,但必须与数据结论区分为“待验证假设”或“建议补充的数据字段”。'
   ].join('\n');
 
-  constructor(private promptConfig: AiPromptConfigService) {}
+  constructor(
+    private promptConfig: AiPromptConfigService,
+    private companyRelayAuth: CompanyRelayAuthService
+  ) {}
 
   /**
    * Stream an AI analysis given a user prompt and optional system prompt.
@@ -95,19 +98,30 @@ export class AiAnalysisService {
   }
 
   /**
-   * 获取 token 并发起流式请求
+   * 获取 API 管理中保存的密钥,再发起流式请求。
    */
   private streamWithFallback(
     messages: FmodeChatMessage[],
     options: AiRequestOptions
   ): Observable<AiStreamState> {
-    const token = this.getAuthToken();
-    if (!token) {
-      return new Observable<AiStreamState>(sub => {
-        sub.next({ text: '', delta: '', loading: false, complete: true, error: '未登录,请先登录后再使用 AI 分析' });
-        sub.complete();
-      });
-    }
+    return from(this.getApiCredential()).pipe(
+      switchMap(token => {
+        if (!token) {
+          return new Observable<AiStreamState>(sub => {
+            sub.next({ text: '', delta: '', loading: false, complete: true, error: '尚未配置 API 密钥,请先在 API 管理中保存' });
+            sub.complete();
+          });
+        }
+        return this.streamPrepared(messages, options, token);
+      })
+    );
+  }
+
+  private streamPrepared(
+    messages: FmodeChatMessage[],
+    options: AiRequestOptions,
+    token: string
+  ): Observable<AiStreamState> {
     const finalMessages = this.withGlobalInstructions(messages, options);
     // #16:联网搜索结果有时效性,不缓存;其余确定性请求命中缓存则秒回
     const cacheKey = options.websearch ? '' : this.buildCacheKey(finalMessages, options);
@@ -770,25 +784,19 @@ export class AiAnalysisService {
   }
 
   /**
-   * 获取 AI 认证 token:
-   * 1) localStorage FMODE_AI_TOKEN(nova 登录写入)
-   * 2) 当前客户 Parse 用户 sessionToken
-   * 禁止写死固定 r: / sk-
+   * 使用 API 管理保存的公司中转密钥。
+   * 不使用客户 Parse session,也不使用浏览器里残留的 Nova 会话。
    */
-  private getAuthToken(): string | null {
+  private async getApiCredential(): Promise<string | null> {
     try {
-      const stored = localStorage.getItem('FMODE_AI_TOKEN');
-      if (stored) return stored.startsWith('r:') || stored.startsWith('Bearer ') ? stored.replace(/^Bearer\s+/i, '') : stored;
+      localStorage.removeItem('FMODE_AI_TOKEN');
     } catch {
       // ignore
     }
     try {
-      const user = MSQParse?.User?.current?.();
-      const token = user?.getSessionToken?.() || user?.get?.('sessionToken');
-      if (token) return String(token);
+      return await this.companyRelayAuth.getAuthorization();
     } catch {
-      // ignore
+      return null;
     }
-    return null;
   }
 }

+ 14 - 30
frontend/src/modules/shared/services/competitor-monitor-prefs.service.ts

@@ -53,29 +53,13 @@ export class CompetitorMonitorPrefsService {
           map((rows: any[]) => {
             if (rows?.length) {
               const r = rows[0];
-              let selected: string[] = [];
-              let groupMap: Record<string, CompetitorGroup> = {};
-              try {
-                selected = JSON.parse(r.selectedAsinsJson || '[]') || [];
-              } catch {
-                selected = [];
-              }
-              try {
-                const g = JSON.parse(r.groupMapJson || '{}') || {};
-                groupMap = typeof g === 'object' && g ? g : {};
-              } catch {
-                groupMap = {};
-              }
-              let customGroups: CustomGroupDef[] = DEFAULT_GROUPS;
-              let tagMap: Record<string, string[]> = {};
-              let allTags: string[] = [];
-              try { customGroups = JSON.parse(r.customGroupsJson || 'null') || DEFAULT_GROUPS; } catch { customGroups = DEFAULT_GROUPS; }
-              try { tagMap = JSON.parse(r.tagMapJson || '{}') || {}; } catch { tagMap = {}; }
-              try { allTags = JSON.parse(r.allTagsJson || '[]') || []; } catch { allTags = []; }
-              let hiddenAsins: string[] = [];
-              try { hiddenAsins = JSON.parse(r.hiddenAsinsJson || '[]') || []; } catch { hiddenAsins = []; }
-              let customSocialBrands: string[] = [];
-              try { customSocialBrands = JSON.parse(r.customSocialBrandsJson || '[]') || []; } catch { customSocialBrands = []; }
+              const selected = Array.isArray(r.selectedAsins) ? r.selectedAsins : [];
+              const groupMap = r.groupMap && typeof r.groupMap === 'object' ? r.groupMap : {};
+              const customGroups = Array.isArray(r.customGroups) && r.customGroups.length ? r.customGroups : DEFAULT_GROUPS;
+              const tagMap = r.tagMap && typeof r.tagMap === 'object' ? r.tagMap : {};
+              const allTags = Array.isArray(r.allTags) ? r.allTags : [];
+              const hiddenAsins = Array.isArray(r.hiddenAsins) ? r.hiddenAsins : [];
+              const customSocialBrands = Array.isArray(r.socialBrands) ? r.socialBrands : [];
               if (selected.length > 0 || Object.keys(groupMap).length > 0) {
                 console.log(`[CompetitorPrefs] 从云端加载成功: ${selected.length}个ASIN, ${(customGroups||[]).length}组, ${(allTags||[]).length}标签, ${hiddenAsins.length}个隐藏, ${customSocialBrands.length}个自定义品牌`);
                 this.saveToLocal(selected, groupMap, customGroups, tagMap, allTags, hiddenAsins, customSocialBrands);
@@ -101,13 +85,13 @@ export class CompetitorMonitorPrefsService {
           PARSE_CLASS,
           {
             ownerId,
-            selectedAsinsJson: JSON.stringify(payload.selectedAsins),
-            groupMapJson: JSON.stringify(payload.groupMap),
-            customGroupsJson: JSON.stringify(payload.customGroups || DEFAULT_GROUPS),
-            tagMapJson: JSON.stringify(payload.tagMap || {}),
-            allTagsJson: JSON.stringify(payload.allTags || []),
-            hiddenAsinsJson: JSON.stringify(payload.hiddenAsins || []),
-            customSocialBrandsJson: JSON.stringify(payload.customSocialBrands || [])
+            selectedAsins: payload.selectedAsins,
+            groupMap: payload.groupMap,
+            customGroups: payload.customGroups || DEFAULT_GROUPS,
+            tagMap: payload.tagMap || {},
+            allTags: payload.allTags || [],
+            hiddenAsins: payload.hiddenAsins || [],
+            socialBrands: payload.customSocialBrands || []
           },
           'ownerId'
         )

+ 3 - 29
frontend/src/modules/shared/services/emerging-insight-data.service.ts

@@ -462,7 +462,7 @@ export class EmergingInsightDataService {
           description: views > 0
             ? `TikTok 互动率 ${engagement}%, 总播放量 ${this.formatNumber(views)}`
             : `时尚趋势关键词,待观测社媒热度`,
-          growth: views > 0 ? Math.round(engagement * 10) : Math.round(Math.random() * 30 + 10),
+          growth: views > 0 ? Math.round(engagement * 10) : 0,
           examples: examples.length > 0 ? examples : [t.keyword]
         });
       });
@@ -505,7 +505,7 @@ export class EmergingInsightDataService {
         const views = t.totalViews || 0;
         const demandIndex = views > 0
           ? Math.min(100, Math.round(engagement * 5 + Math.log10(views + 1) * 5))
-          : Math.round(30 + Math.random() * 20);
+          : 0;
         // 供给指数 = 基于卖家数和品牌集中度
         const supplyIndex = baseSupplyIndex;
         const gapIndex = demandIndex - supplyIndex;
@@ -871,32 +871,6 @@ export class EmergingInsightDataService {
   // ==================== Mock 回退 ====================
 
   private getMockData(): EmergingInsightData {
-    return {
-      trendSignals: [
-        { category: 'Social', label: '社媒互动率', score: 85, trend: 12.5 },
-        { category: 'Search', label: '搜索增长率', score: 72, trend: 8.4 },
-        { category: 'Supply', label: '新品渗透率', score: 45, trend: 5.1 }
-      ],
-      keywords: [
-        { text: 'Y2K Style', weight: 88, platform: 'TikTok', growth: 120 },
-        { text: 'Eco-friendly', weight: 75, platform: 'Google', growth: 45 },
-        { text: 'Oversized', weight: 65, platform: 'Amazon', growth: 30 },
-        { text: 'Cargo Pants', weight: 82, platform: 'TikTok', growth: 95 },
-        { text: 'Mesh Fabric', weight: 60, platform: 'Amazon', growth: 25 },
-        { text: 'Retro', weight: 70, platform: 'TikTok', growth: 50 },
-        { text: 'Seamless', weight: 68, platform: 'Amazon', growth: 35 }
-      ],
-      microTrends: [
-        { id: 'mt1', type: 'Design', name: '不规则剪裁 (Asymmetric)', description: '下摆或领口的不规则设计在TikTok上互动率飙升', growth: 158, examples: ['斜肩T恤', '不规则半裙'] },
-        { id: 'mt2', type: 'Fabric', name: '罗纹针织 (Ribbed Knit)', description: '高弹舒适的罗纹面料搜索量持续增长', growth: 85, examples: ['罗纹背心', '紧身裙'] },
-        { id: 'mt3', type: 'Function', name: '吸湿排汗 (Moisture Wicking)', description: '日常通勤装对功能性面料的需求增加', growth: 62, examples: ['通勤Polo', '瑜伽裤'] },
-        { id: 'mt4', type: 'Design', name: '工装口袋 (Utility Pockets)', description: '实用主义回潮,多口袋设计渗透率提升', growth: 45, examples: ['工装裤', '机能马甲'] }
-      ],
-      opportunities: [
-        { id: 'opp1', title: '大码工装裤 (Plus Size Cargo)', gapIndex: 28, description: '搜索量激增但市场供给不足', tags: ['高需求', '低供给', '蓝海'], status: 'opportunity' },
-        { id: 'opp2', title: '基础款瑜伽裤 (Basic Yoga Pants)', gapIndex: -35, description: '市场供给严重过剩', tags: ['高供给', '红海', '价格战'], status: 'competitive' },
-        { id: 'opp3', title: '环保材质泳衣 (Recycled Swimwear)', gapIndex: 5, description: '供需基本平衡', tags: ['趋势稳定', '品牌溢价'], status: 'balanced' }
-      ]
-    };
+    return { trendSignals: [], keywords: [], microTrends: [], opportunities: [] };
   }
 }

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно