فهرست منبع

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 3 ساعت پیش
والد
کامیت
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: [] };
   }
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است