|
|
@@ -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
|
|
|
+};
|